Spaces:
Running
Running
File size: 17,404 Bytes
399b80c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 | """
ClawWork-compatible productivity tools for fair benchmark comparison.
When use_clawwork_productivity is enabled and the livebench package is installed,
these tools are added to the Shell backend so OpenSpace agents have the same
capabilities as ClawWork: search_web, read_webpage, create_file, read_file,
execute_code_sandbox, create_video.
"""
import asyncio
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from openspace.grounding.core.types import BackendType, ToolResult, ToolStatus
from openspace.grounding.core.tool import BaseTool
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
# Lazy import to avoid hard dependency on ClawWork
_LIVEBENCH_AVAILABLE = False
_direct_tools = None
_productivity = None
def _ensure_livebench():
"""Import livebench modules if available."""
global _LIVEBENCH_AVAILABLE, _direct_tools, _productivity
if _direct_tools is not None:
return _LIVEBENCH_AVAILABLE
try:
import livebench.tools.direct_tools as dt
import livebench.tools.productivity as prod
_direct_tools = dt
_productivity = prod
_LIVEBENCH_AVAILABLE = True
except ImportError as e:
logger.debug("ClawWork productivity tools not available: %s", e)
_LIVEBENCH_AVAILABLE = False
return _LIVEBENCH_AVAILABLE
def _set_global_state_for_productivity(data_path: str, current_date: str) -> None:
"""Set ClawWork global state so productivity tools have data_path/date."""
if not _direct_tools:
return
_direct_tools.set_global_state(
signature="openspace",
economic_tracker=None,
task_manager=None,
evaluator=None,
current_date=current_date,
current_task=None,
data_path=data_path,
supports_multimodal=True,
)
def _dict_to_tool_result(out: Dict[str, Any]) -> ToolResult:
"""Convert ClawWork tool dict to OpenSpace ToolResult."""
if not isinstance(out, dict):
return ToolResult(
status=ToolStatus.ERROR,
content=str(out),
)
err = out.get("error")
if err:
return ToolResult(
status=ToolStatus.ERROR,
content=err if isinstance(err, str) else json.dumps(err, ensure_ascii=False),
)
return ToolResult(
status=ToolStatus.SUCCESS,
content=json.dumps(out, ensure_ascii=False, default=str),
)
def _sync_invoke(tool_any: Any, args: Dict[str, Any]) -> Dict[str, Any]:
"""Invoke a LangChain-style tool (sync) from async context."""
if hasattr(tool_any, "invoke"):
return tool_any.invoke(args)
return tool_any(**args)
class _ProductivityToolBase(BaseTool):
"""Base for productivity tools that delegate to ClawWork."""
backend_type = BackendType.SHELL
def __init__(self, session: Any, data_path: str, current_date: str):
self._session = session
self._data_path = data_path or "."
self._current_date = current_date or "default"
super().__init__()
async def _arun(self, **kwargs) -> ToolResult:
raise NotImplementedError("Subclasses must override _arun")
async def _run_sync_tool(self, tool_obj: Any, args: Dict[str, Any]) -> ToolResult:
data_path = getattr(self._session, "default_working_dir", None) or self._data_path
_set_global_state_for_productivity(data_path, self._current_date)
try:
result = await asyncio.to_thread(_sync_invoke, tool_obj, args)
return _dict_to_tool_result(result)
except Exception as e:
logger.exception("Productivity tool %s failed", self.name)
return ToolResult(status=ToolStatus.ERROR, content=str(e))
class SearchWebTool(_ProductivityToolBase):
_name = "search_web"
_description = (
"Search the internet using Tavily or Jina. Returns structured results with "
"AI-generated answers. Use for up-to-date information."
)
async def _arun(self, query: str, max_results: int = 5) -> ToolResult:
return await self._run_sync_tool(
_productivity.search_web,
{"query": query, "max_results": max_results},
)
class ReadWebpageTool(_ProductivityToolBase):
_name = "read_webpage"
_description = (
"Extract and read web page content from URLs using Tavily Extract. "
"Returns cleaned text in markdown format."
)
async def _arun(self, urls: str, query: Optional[str] = None) -> ToolResult:
return await self._run_sync_tool(
_productivity.read_webpage,
{"urls": urls, "query": query},
)
class CreateFileProductivityTool(_ProductivityToolBase):
_name = "create_file"
_description = (
"Create a file in the current working directory. "
"Supports: txt, md, csv, json, xlsx, docx, pdf. "
"The file is created directly in your workspace."
)
async def _arun(
self,
filename: str,
content: str,
file_type: str = "txt",
) -> ToolResult:
"""Create a file via Shell connector so it lands in the task workspace."""
file_type = file_type.lower().strip()
valid_types = ["txt", "md", "csv", "json", "xlsx", "docx", "pdf"]
if file_type not in valid_types:
return ToolResult(
status=ToolStatus.ERROR,
content=f"Invalid file type: {file_type}. Valid: {valid_types}",
)
if not filename or not content:
return ToolResult(status=ToolStatus.ERROR, content="filename and content are required")
import os
safe_name = os.path.basename(filename).replace("/", "_").replace("\\", "_")
# Strip extension from filename if it matches file_type to avoid .docx.docx
name_root, name_ext = os.path.splitext(safe_name)
if name_ext.lstrip(".").lower() == file_type:
safe_name = name_root
final_name = f"{safe_name}.{file_type}"
escaped_content = json.dumps(content)
escaped_name = json.dumps(final_name)
if file_type in ("txt", "md", "csv"):
code = (
"import os\n"
f"name = {escaped_name}\n"
f"content = {escaped_content}\n"
"with open(name, 'w', encoding='utf-8') as f:\n"
" f.write(content)\n"
"sz = os.path.getsize(name)\n"
"print(f'Created {name} ({sz} bytes)')\n"
)
elif file_type == "json":
code = (
"import os, json\n"
f"name = {escaped_name}\n"
f"content = {escaped_content}\n"
"data = json.loads(content)\n"
"with open(name, 'w', encoding='utf-8') as f:\n"
" json.dump(data, f, indent=2, ensure_ascii=False)\n"
"sz = os.path.getsize(name)\n"
"print(f'Created {name} ({sz} bytes)')\n"
)
elif file_type == "xlsx":
code = (
"import os, json, io\n"
"import pandas as pd\n"
f"name = {escaped_name}\n"
f"content = {escaped_content}\n"
"try:\n"
" data = json.loads(content)\n"
" df = pd.DataFrame(data)\n"
"except:\n"
" df = pd.read_csv(io.StringIO(content))\n"
"df.to_excel(name, index=False, engine='openpyxl')\n"
"sz = os.path.getsize(name)\n"
"print(f'Created {name} ({sz} bytes)')\n"
)
elif file_type == "docx":
code = (
"import os\n"
"from docx import Document\n"
f"name = {escaped_name}\n"
f"content = {escaped_content}\n"
"doc = Document()\n"
"for para in content.split('\\n\\n'):\n"
" if para.strip():\n"
" doc.add_paragraph(para.strip())\n"
"doc.save(name)\n"
"sz = os.path.getsize(name)\n"
"print(f'Created {name} ({sz} bytes)')\n"
)
elif file_type == "pdf":
code = (
"import os\n"
"from reportlab.lib.pagesizes import letter\n"
"from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer\n"
"from reportlab.lib.styles import getSampleStyleSheet\n"
f"name = {escaped_name}\n"
f"content = {escaped_content}\n"
"doc = SimpleDocTemplate(name, pagesize=letter)\n"
"styles = getSampleStyleSheet()\n"
"story = []\n"
"for para in content.split('\\n\\n'):\n"
" if para.strip():\n"
" story.append(Paragraph(para.strip(), styles['Normal']))\n"
" story.append(Spacer(1, 12))\n"
"doc.build(story)\n"
"sz = os.path.getsize(name)\n"
"print(f'Created {name} ({sz} bytes)')\n"
)
else:
return ToolResult(status=ToolStatus.ERROR, content=f"Unsupported: {file_type}")
try:
from openspace.grounding.backends.shell.session import _parse_shell_result
working_dir = getattr(self._session, "default_working_dir", None)
result = await self._session.connector.run_python_script(
code, timeout=30, working_dir=working_dir,
)
stdout, stderr, rc = _parse_shell_result(result)
if rc != 0:
return ToolResult(status=ToolStatus.ERROR, content=stderr or f"Failed to create {final_name}")
return ToolResult(
status=ToolStatus.SUCCESS,
content=f"Created {final_name} in workspace. {stdout.strip()}",
)
except Exception as e:
return ToolResult(status=ToolStatus.ERROR, content=f"create_file failed: {e}")
class ReadFileProductivityTool(_ProductivityToolBase):
_name = "read_file"
_description = (
"Read a file in various formats: pdf, docx, xlsx, pptx, png, jpg, jpeg, txt, json, md, csv, html, xml, yaml. "
"Returns content suitable for LLM consumption (text or images). "
"Relative paths are resolved against the task workspace directory."
)
def _resolve_path(self, file_path: str) -> Path:
"""Resolve relative paths against the task workspace (data_path)."""
data_path = getattr(self._session, "default_working_dir", None) or self._data_path
p = Path(file_path)
if not p.is_absolute():
resolved = Path(data_path) / p
if resolved.exists():
return resolved
workspace = Path(data_path)
if workspace.is_dir():
name = p.name
for candidate in workspace.rglob(name):
return candidate
return p
async def _arun(self, filetype: str, file_path: str) -> ToolResult:
resolved = self._resolve_path(file_path)
return await self._run_sync_tool(
_productivity.read_file,
{"filetype": filetype, "file_path": resolved},
)
class ExecuteCodeSandboxTool(_ProductivityToolBase):
_name = "execute_code_sandbox"
_description = (
"Execute Python code in a persistent sandbox. Supports artifact download via ARTIFACT_PATH:/path/to/file in output."
)
async def _arun(self, code: str, language: str = "python") -> ToolResult:
return await self._run_sync_tool(
_productivity.execute_code_sandbox,
{"code": code, "language": language},
)
class CreateVideoTool(_ProductivityToolBase):
_name = "create_video"
_description = (
"Create a video from text slides and/or images. Input is a JSON string describing slides; output is MP4. "
"The video is created in the current working directory."
)
async def _arun(
self,
slides_json: str,
output_filename: str,
width: int = 1280,
height: int = 720,
fps: int = 24,
) -> ToolResult:
"""Create video via Shell connector so it lands in the task workspace."""
import os
safe_name = os.path.basename(output_filename).replace("/", "_").replace("\\", "_")
if not safe_name.endswith(".mp4"):
safe_name = safe_name.rsplit(".", 1)[0] if "." in safe_name else safe_name
safe_name += ".mp4"
escaped_slides = json.dumps(slides_json)
escaped_name = json.dumps(safe_name)
code = (
"import json, os\n"
f"slides_json = {escaped_slides}\n"
f"output_name = {escaped_name}\n"
f"width, height, fps = {width}, {height}, {fps}\n"
"slides = json.loads(slides_json)\n"
"try:\n"
" from PIL import Image, ImageDraw, ImageFont\n"
" import subprocess, tempfile, shutil\n"
" tmpdir = tempfile.mkdtemp()\n"
" frame_paths = []\n"
" for i, slide in enumerate(slides):\n"
" dur = slide.get('duration', 3.0)\n"
" n_frames = int(dur * fps)\n"
" if slide.get('type') == 'image' and slide.get('path'):\n"
" img = Image.open(slide['path']).resize((width, height))\n"
" else:\n"
" bg = slide.get('bg_color', '#000000')\n"
" tc = slide.get('text_color', '#FFFFFF')\n"
" img = Image.new('RGB', (width, height), bg)\n"
" draw = ImageDraw.Draw(img)\n"
" text = slide.get('content', '')\n"
" try:\n"
" font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 36)\n"
" except:\n"
" font = ImageFont.load_default()\n"
" bbox = draw.textbbox((0, 0), text, font=font)\n"
" tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]\n"
" draw.text(((width - tw) / 2, (height - th) / 2), text, fill=tc, font=font)\n"
" for j in range(n_frames):\n"
" fp = os.path.join(tmpdir, f'frame_{len(frame_paths):06d}.png')\n"
" img.save(fp)\n"
" frame_paths.append(fp)\n"
" cmd = ['ffmpeg', '-y', '-framerate', str(fps), '-i', os.path.join(tmpdir, 'frame_%06d.png'),\n"
" '-c:v', 'libx264', '-pix_fmt', 'yuv420p', output_name]\n"
" subprocess.run(cmd, capture_output=True, check=True)\n"
" shutil.rmtree(tmpdir)\n"
" sz = os.path.getsize(output_name)\n"
" print(f'Created {output_name} ({sz} bytes, {len(frame_paths)} frames)')\n"
"except Exception as e:\n"
" print(f'ERROR: {e}')\n"
" raise\n"
)
try:
from openspace.grounding.backends.shell.session import _parse_shell_result
working_dir = getattr(self._session, "default_working_dir", None)
result = await self._session.connector.run_python_script(
code, timeout=120, working_dir=working_dir,
)
stdout, stderr, rc = _parse_shell_result(result)
if rc != 0:
return ToolResult(status=ToolStatus.ERROR, content=stderr or f"Failed to create video {safe_name}")
return ToolResult(
status=ToolStatus.SUCCESS,
content=f"Created video {safe_name} in workspace. {stdout.strip()}",
)
except Exception as e:
return ToolResult(status=ToolStatus.ERROR, content=f"create_video failed: {e}")
def get_productivity_tools(
session: Any,
data_path: Optional[str] = None,
current_date: Optional[str] = None,
) -> List[BaseTool]:
"""
Return ClawWork-compatible productivity tools if livebench is installed.
Args:
session: ShellSession (for compatibility; not used beyond data_path/date).
data_path: Sandbox root (default: session.default_working_dir or ".").
current_date: Date segment for sandbox paths (default: "default").
Returns:
List of tools to add to the session, or empty list if livebench unavailable.
"""
if not _ensure_livebench():
return []
path = data_path if data_path is not None else getattr(session, "default_working_dir", None) or "."
date = current_date if current_date is not None else "default"
return [
SearchWebTool(session, data_path=path, current_date=date),
ReadWebpageTool(session, data_path=path, current_date=date),
CreateFileProductivityTool(session, data_path=path, current_date=date),
ReadFileProductivityTool(session, data_path=path, current_date=date),
ExecuteCodeSandboxTool(session, data_path=path, current_date=date),
CreateVideoTool(session, data_path=path, current_date=date),
]
def is_productivity_available() -> bool:
"""Return True if ClawWork productivity tools can be loaded."""
return _ensure_livebench()
|