From bc36ecf1c3eb45594d9513b6201124c9c3c5a705 Mon Sep 17 00:00:00 2001 From: hzzz2020 Date: Mon, 25 May 2026 15:18:02 +0800 Subject: [PATCH] feat: add all-in-one sandbox toolset --- agentrun/integration/agentscope/builtin.py | 2 + agentrun/integration/builtin/sandbox.py | 420 ++++++++++++++++-- agentrun/integration/crewai/builtin.py | 2 + agentrun/integration/google_adk/builtin.py | 2 + agentrun/integration/langchain/builtin.py | 2 + agentrun/integration/langgraph/builtin.py | 2 + agentrun/integration/pydantic_ai/builtin.py | 2 + .../unittests/integration/test_aio_toolset.py | 278 ++++++++++++ 8 files changed, 664 insertions(+), 46 deletions(-) create mode 100644 tests/unittests/integration/test_aio_toolset.py diff --git a/agentrun/integration/agentscope/builtin.py b/agentrun/integration/agentscope/builtin.py index bf978c9..944f3b9 100644 --- a/agentrun/integration/agentscope/builtin.py +++ b/agentrun/integration/agentscope/builtin.py @@ -77,6 +77,7 @@ def sandbox_toolset( template_type: TemplateType = TemplateType.CODE_INTERPRETER, config: Optional[Config] = None, sandbox_idle_timeout_seconds: int = 600, + local_artifact_dir: Optional[str] = None, prefix: Optional[str] = None, ) -> List[Any]: """将沙箱模板封装为 LangChain ``StructuredTool`` 列表。 / AgentScope Built-in Integration Functions""" @@ -86,6 +87,7 @@ def sandbox_toolset( template_type=template_type, config=config, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + local_artifact_dir=local_artifact_dir, ).to_agentscope(prefix=prefix) diff --git a/agentrun/integration/builtin/sandbox.py b/agentrun/integration/builtin/sandbox.py index 24bce64..4ac73f2 100644 --- a/agentrun/integration/builtin/sandbox.py +++ b/agentrun/integration/builtin/sandbox.py @@ -1,14 +1,16 @@ from __future__ import annotations import base64 +from pathlib import Path +import shutil +import tempfile import threading from typing import Any, Callable, Dict, Optional, TYPE_CHECKING -from agentrun.integration.utils.tool import CommonToolSet, tool +from agentrun.integration.utils.tool import CommonToolSet, Tool, tool from agentrun.sandbox import Sandbox, TemplateType from agentrun.sandbox.browser_sandbox import BrowserSandbox from agentrun.sandbox.client import SandboxClient -from agentrun.sandbox.code_interpreter_sandbox import CodeInterpreterSandbox from agentrun.utils.config import Config from agentrun.utils.log import logger @@ -36,6 +38,68 @@ class GreenletError(Exception): # type: ignore[no-redef] pass +def _require_attrs(sb: Sandbox, attrs: tuple[str, ...], capability: str) -> Any: + missing = [attr for attr in attrs if not hasattr(sb, attr)] + if missing: + raise TypeError( + f"Sandbox {getattr(sb, 'sandbox_id', '')} does not " + f"provide {capability} capability: missing {', '.join(missing)}" + ) + return sb + + +def _require_code_capable(sb: Sandbox) -> Any: + return _require_attrs( + sb, + ("check_health", "context", "file", "file_system", "process"), + "code interpreter", + ) + + +def _require_browser_capable(sb: Sandbox) -> Any: + return _require_attrs( + sb, + ("check_health", "sync_playwright"), + "browser", + ) + + +def _require_aio_capable(sb: Sandbox) -> Any: + return _require_attrs( + sb, + ( + "check_health", + "context", + "file", + "file_system", + "process", + "sync_playwright", + "get_cdp_url", + "get_vnc_url", + "list_recordings", + "download_recording", + "delete_recording", + ), + "all-in-one", + ) + + +def _bind_declared_tools( + owner: Any, + cls: type, + *, + skip: Optional[set[str]] = None, +) -> list[Tool]: + skip = skip or set() + tools: list[Tool] = [] + for attr_name, attr_value in cls.__dict__.items(): + if attr_name.startswith("_") or attr_name in skip: + continue + if isinstance(attr_value, Tool): + tools.append(attr_value.bind(owner)) + return tools + + class SandboxToolSet(CommonToolSet): """沙箱工具集基类 @@ -231,7 +295,7 @@ def check_health(self) -> Dict[str, Any]: """检查沙箱健康状态 / Check sandbox health status""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) return sb.check_health() return self._run_in_sandbox(inner) @@ -257,7 +321,7 @@ def run_code( """执行代码 / Execute code""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) if context_id: result = sb.context.execute( code=code, context_id=context_id, timeout=timeout @@ -271,12 +335,12 @@ def inner(sb: Sandbox): ctx.delete() except Exception: pass - return { - "stdout": result.get("stdout", ""), - "stderr": result.get("stderr", ""), - "exit_code": result.get("exitCode", 0), - "result": result, - } + return { + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", ""), + "exit_code": result.get("exitCode", 0), + "result": result, + } return self._run_in_sandbox(inner) @@ -310,7 +374,7 @@ def list_contexts(self) -> Dict[str, Any]: """列出所有执行上下文 / List all execution contexts""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) contexts = sb.context.list() return {"contexts": contexts} @@ -333,7 +397,7 @@ def create_context( """创建新的执行上下文 / Create a new execution context""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) ctx = sb.context.create(language=language, cwd=cwd) return { "context_id": ctx.context_id, @@ -354,7 +418,7 @@ def get_context(self, context_id: str) -> Dict[str, Any]: """获取上下文详情 / Get context details""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) ctx = sb.context.get(context_id=context_id) return { "context_id": ctx.context_id, @@ -376,7 +440,7 @@ def delete_context(self, context_id: str) -> Dict[str, Any]: """删除执行上下文 / Delete execution context""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) result = sb.context.delete(context_id=context_id) return {"success": True, "result": result} @@ -396,7 +460,7 @@ def read_file(self, path: str) -> Dict[str, Any]: """读取文件内容 / Read file content""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) content = sb.file.read(path=path) return {"path": path, "content": content} @@ -421,7 +485,7 @@ def write_file( """写入文件内容 / Write file content""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) result = sb.file.write( path=path, content=content, mode=mode, encoding=encoding ) @@ -446,7 +510,7 @@ def file_system_list( """列出目录内容 / List directory contents""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) entries = sb.file_system.list(path=path, depth=depth) return {"path": path, "entries": entries} @@ -474,7 +538,7 @@ def file_system_stat(self, path: str) -> Dict[str, Any]: """获取文件/目录状态 / Get file/directory status""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) stat_info = sb.file_system.stat(path=path) return {"path": path, "stat": stat_info} @@ -497,7 +561,7 @@ def file_system_mkdir( """创建目录 / Create directory""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) result = sb.file_system.mkdir(path=path, parents=parents, mode=mode) return {"path": path, "success": True, "result": result} @@ -514,7 +578,7 @@ def file_system_move(self, source: str, destination: str) -> Dict[str, Any]: """移动/重命名文件或目录 / Move/rename file or directory""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) result = sb.file_system.move(source=source, destination=destination) return { "source": source, @@ -536,7 +600,7 @@ def file_system_remove(self, path: str) -> Dict[str, Any]: """删除文件或目录 / Delete file or directory""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) result = sb.file_system.remove(path=path) return {"path": path, "success": True, "result": result} @@ -562,7 +626,7 @@ def process_exec_cmd( """执行命令 / Execute command""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) result = sb.process.cmd(command=command, cwd=cwd, timeout=timeout) return { "command": command, @@ -586,7 +650,7 @@ def process_list(self) -> Dict[str, Any]: """列出所有进程 / List all processes""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) processes = sb.process.list() return {"processes": processes} @@ -603,7 +667,7 @@ def process_stat(self, pid: str) -> Dict[str, Any]: """获取进程状态 / Get process status""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) process_info = sb.process.get(pid=pid) return {"pid": pid, "process": process_info} @@ -621,7 +685,7 @@ def process_kill(self, pid: str) -> Dict[str, Any]: """终止进程 / Terminate process""" def inner(sb: Sandbox): - assert isinstance(sb, CodeInterpreterSandbox) + sb = _require_code_capable(sb) result = sb.process.kill(pid=pid) return {"pid": pid, "success": True, "result": result} @@ -917,7 +981,7 @@ def check_health(self) -> Dict[str, Any]: """检查浏览器沙箱健康状态 / Check browser sandbox health status""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) return sb.check_health() return self._run_in_sandbox(inner) @@ -943,7 +1007,7 @@ def browser_navigate( """导航到 URL / Navigate to URL""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) response = p.goto(url, wait_until=wait_until, timeout=timeout) return { @@ -982,7 +1046,7 @@ def browser_navigate_back( """返回上一页 / Go back to previous page""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) response = p.go_back(wait_until=wait_until, timeout=timeout) return { @@ -1008,7 +1072,7 @@ def browser_go_forward( """前进到下一页 / Go forward to next page""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) response = p.go_forward(wait_until=wait_until, timeout=timeout) return { @@ -1039,7 +1103,7 @@ def browser_click( """点击元素 / Click element""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) p.click( selector, @@ -1079,7 +1143,7 @@ def browser_dblclick( """双击元素 / Double-click element""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) p.dblclick(selector, timeout=timeout) return {"selector": selector, "success": True} @@ -1102,7 +1166,7 @@ def browser_drag( """拖拽元素 / Drag element""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) p.drag_and_drop(source_selector, target_selector, timeout=timeout) return { @@ -1128,7 +1192,7 @@ def browser_hover( """鼠标悬停 / Mouse hover""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) p.hover(selector, timeout=timeout) return {"selector": selector, "success": True} @@ -1157,7 +1221,7 @@ def browser_type( """输入文本 / Type text""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) p.type(selector, text, delay=delay, timeout=timeout) return {"selector": selector, "text": text, "success": True} @@ -1182,7 +1246,7 @@ def browser_fill( """填充输入框 / Fill input""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) p.fill(selector, value, timeout=timeout) return {"selector": selector, "value": value, "success": True} @@ -1215,7 +1279,7 @@ def browser_snapshot(self) -> Dict[str, Any]: """获取页面 HTML 快照 / Get page HTML snapshot""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) html = p.html_content() title = p.title() @@ -1252,7 +1316,7 @@ def browser_take_screenshot( """截取页面截图 / Take page screenshot""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) screenshot_bytes = p.screenshot(full_page=full_page, type=type) screenshot_base64 = base64.b64encode(screenshot_bytes).decode( @@ -1277,7 +1341,7 @@ def browser_get_title(self) -> Dict[str, Any]: """获取页面标题 / Get page title""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) title = p.title() return {"title": title} @@ -1298,7 +1362,7 @@ def browser_tabs_list(self) -> Dict[str, Any]: """列出所有标签页 / List all tabs""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) pages = p.list_pages() tabs = [] @@ -1324,7 +1388,7 @@ def browser_tabs_new(self, url: Optional[str] = None) -> Dict[str, Any]: """创建新标签页 / Create new tab""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) page = p.new_page() if url: @@ -1349,7 +1413,7 @@ def browser_tabs_select(self, index: int) -> Dict[str, Any]: """切换标签页 / Switch tab""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) page = p.select_tab(index) return { @@ -1381,7 +1445,7 @@ def browser_evaluate( """执行 JavaScript / Execute JavaScript""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) result = p.evaluate(expression, arg=arg) return {"result": result} @@ -1414,7 +1478,7 @@ def browser_wait_for(self, timeout: float) -> Dict[str, Any]: """等待指定时间 / Wait for specified time""" def inner(sb: Sandbox): - assert isinstance(sb, BrowserSandbox) + sb = _require_browser_capable(sb) p = self._get_playwright(sb) p.wait(timeout) return {"success": True, "waited_ms": timeout} @@ -1422,6 +1486,255 @@ def inner(sb: Sandbox): return self._run_in_sandbox(inner) +class AioToolSet(BrowserToolSet): + """All-in-One sandbox toolset. + + Exposes browser, code interpreter, filesystem, process, file transfer, + recording, and remote-access URL tools against one shared AIO sandbox. + """ + + def __init__( + self, + template_name: str, + config: Optional[Config], + sandbox_idle_timeout_seconds: int, + oss_mount_config: Optional["OSSMountConfig"] = None, + nas_config: Optional["NASConfig"] = None, + polar_fs_config: Optional["PolarFsConfig"] = None, + local_artifact_dir: Optional[str] = None, + ) -> None: + SandboxToolSet.__init__( + self, + template_name=template_name, + template_type=TemplateType.AIO, + sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + config=config, + oss_mount_config=oss_mount_config, + nas_config=nas_config, + polar_fs_config=polar_fs_config, + ) + self._playwright_sync: Optional["BrowserPlaywrightSync"] = None + self._playwright_thread: Optional[threading.Thread] = None + + if local_artifact_dir is None: + self._local_artifact_dir_owned = True + artifact_dir = Path( + tempfile.mkdtemp(prefix="agentrun-aio-toolset-") + ) + else: + self._local_artifact_dir_owned = False + artifact_dir = Path(local_artifact_dir).expanduser() + artifact_dir.mkdir(parents=True, exist_ok=True) + + self._local_artifact_dir = artifact_dir.resolve() + self.local_artifact_dir = str(self._local_artifact_dir) + + for code_tool in _bind_declared_tools( + self, CodeInterpreterToolSet, skip={"check_health"} + ): + if code_tool.name == "health": + continue + self._tools.append(code_tool) + self.__dict__[code_tool.name] = code_tool + + def _resolve_artifact_path(self, local_artifact_path: str) -> Path: + path = Path(local_artifact_path) + if path.is_absolute(): + raise ValueError("local_artifact_path must be relative") + + candidate = (self._local_artifact_dir / path).resolve(strict=False) + try: + candidate.relative_to(self._local_artifact_dir) + except ValueError as exc: + raise ValueError("local_artifact_path escapes artifact dir") from exc + return candidate + + def close(self) -> None: + try: + super().close() + finally: + if self._local_artifact_dir_owned and self._local_artifact_dir.exists(): + shutil.rmtree(self._local_artifact_dir, ignore_errors=True) + + @tool( + name="health", + description=( + "Check the health status of the All-in-One sandbox. Returns" + " status='ok' if the shared AIO sandbox is running normally." + ), + ) + def check_health(self) -> Dict[str, Any]: + """检查 AIO 沙箱健康状态 / Check AIO sandbox health status""" + + def inner(sb: Sandbox): + sb = _require_aio_capable(sb) + return sb.check_health() + + return self._run_in_sandbox(inner) + + @tool( + name="upload_file", + description=( + "Upload a file from the SDK-side artifact directory to the" + " sandbox. local_artifact_path must be a relative path under" + " local_artifact_dir; sandbox_path is the target path inside the" + " sandbox filesystem." + ), + ) + def upload_file( + self, + local_artifact_path: str, + sandbox_path: str, + ) -> Dict[str, Any]: + """上传本地 artifact 文件到沙箱 / Upload local artifact to sandbox""" + local_path = self._resolve_artifact_path(local_artifact_path) + + def inner(sb: Sandbox): + sb = _require_aio_capable(sb) + result = sb.file_system.upload( + local_file_path=str(local_path), + target_file_path=sandbox_path, + ) + return { + "local_artifact_path": local_artifact_path, + "sandbox_path": sandbox_path, + "success": True, + "result": result, + } + + return self._run_in_sandbox(inner) + + @tool( + name="download_file", + description=( + "Download a file from the sandbox into the SDK-side artifact" + " directory. sandbox_path is the source path inside the sandbox;" + " local_artifact_path must be a relative destination path under" + " local_artifact_dir." + ), + ) + def download_file( + self, + sandbox_path: str, + local_artifact_path: str, + ) -> Dict[str, Any]: + """下载沙箱文件到本地 artifact 目录 / Download sandbox file to artifact dir""" + local_path = self._resolve_artifact_path(local_artifact_path) + local_path.parent.mkdir(parents=True, exist_ok=True) + + def inner(sb: Sandbox): + sb = _require_aio_capable(sb) + result = sb.file_system.download( + path=sandbox_path, + save_path=str(local_path), + ) + return { + "sandbox_path": sandbox_path, + "local_artifact_path": local_artifact_path, + "success": True, + "result": result, + } + + return self._run_in_sandbox(inner) + + @tool( + name="browser_recordings_list", + description="List browser recording files available in the AIO sandbox.", + ) + def browser_recordings_list(self) -> Dict[str, Any]: + """列出浏览器录制文件 / List browser recordings""" + + def inner(sb: Sandbox): + sb = _require_aio_capable(sb) + return {"recordings": sb.list_recordings()} + + return self._run_in_sandbox(inner) + + @tool( + name="browser_recording_download", + description=( + "Download a browser recording into local_artifact_dir. filename is" + " the recording filename in the sandbox; local_artifact_path must" + " be a relative destination path under local_artifact_dir." + ), + ) + def browser_recording_download( + self, + filename: str, + local_artifact_path: str, + ) -> Dict[str, Any]: + """下载浏览器录制文件 / Download browser recording""" + local_path = self._resolve_artifact_path(local_artifact_path) + local_path.parent.mkdir(parents=True, exist_ok=True) + + def inner(sb: Sandbox): + sb = _require_aio_capable(sb) + result = sb.download_recording( + filename=filename, + save_path=str(local_path), + ) + return { + "filename": filename, + "local_artifact_path": local_artifact_path, + "success": True, + "result": result, + } + + return self._run_in_sandbox(inner) + + @tool( + name="browser_recording_delete", + description="Delete a browser recording file from the AIO sandbox.", + ) + def browser_recording_delete(self, filename: str) -> Dict[str, Any]: + """删除浏览器录制文件 / Delete browser recording""" + + def inner(sb: Sandbox): + sb = _require_aio_capable(sb) + result = sb.delete_recording(filename=filename) + return {"filename": filename, "success": True, "result": result} + + return self._run_in_sandbox(inner) + + @tool( + name="browser_get_cdp_url", + description=( + "Get the remote CDP WebSocket URL for the AIO browser. If" + " record=True, browser recording may be enabled. Authentication" + " headers are intentionally not exposed through this Agent tool." + ), + ) + def browser_get_cdp_url(self, record: bool = False) -> Dict[str, Any]: + """获取远程 CDP URL / Get remote CDP URL""" + + def inner(sb: Sandbox): + sb = _require_aio_capable(sb) + result = sb.get_cdp_url(record=record, with_headers=False) + url = result[0] if isinstance(result, tuple) else result + return {"url": url, "record": record} + + return self._run_in_sandbox(inner) + + @tool( + name="browser_get_vnc_url", + description=( + "Get the remote VNC WebSocket URL for live AIO browser access. If" + " record=True, browser recording may be enabled. Authentication" + " headers are intentionally not exposed through this Agent tool." + ), + ) + def browser_get_vnc_url(self, record: bool = False) -> Dict[str, Any]: + """获取远程 VNC URL / Get remote VNC URL""" + + def inner(sb: Sandbox): + sb = _require_aio_capable(sb) + result = sb.get_vnc_url(record=record, with_headers=False) + url = result[0] if isinstance(result, tuple) else result + return {"url": url, "record": record} + + return self._run_in_sandbox(inner) + + def sandbox_toolset( template_name: str, *, @@ -1431,10 +1744,20 @@ def sandbox_toolset( oss_mount_config: Optional["OSSMountConfig"] = None, nas_config: Optional["NASConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, + local_artifact_dir: Optional[str] = None, ) -> CommonToolSet: """将沙箱模板封装为 LangChain ``StructuredTool`` 列表。""" - if template_type != TemplateType.CODE_INTERPRETER: + if template_type == TemplateType.CODE_INTERPRETER: + return CodeInterpreterToolSet( + template_name=template_name, + config=config, + sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + oss_mount_config=oss_mount_config, + nas_config=nas_config, + polar_fs_config=polar_fs_config, + ) + if template_type == TemplateType.BROWSER: return BrowserToolSet( template_name=template_name, config=config, @@ -1443,12 +1766,17 @@ def sandbox_toolset( nas_config=nas_config, polar_fs_config=polar_fs_config, ) - else: - return CodeInterpreterToolSet( + if template_type == TemplateType.AIO: + return AioToolSet( template_name=template_name, config=config, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, oss_mount_config=oss_mount_config, nas_config=nas_config, polar_fs_config=polar_fs_config, + local_artifact_dir=local_artifact_dir, ) + if template_type == TemplateType.CUSTOM: + raise ValueError("TemplateType.CUSTOM is not supported by sandbox_toolset") + + raise ValueError(f"Unsupported sandbox template_type: {template_type!r}") diff --git a/agentrun/integration/crewai/builtin.py b/agentrun/integration/crewai/builtin.py index 1c8aadb..9f96b57 100644 --- a/agentrun/integration/crewai/builtin.py +++ b/agentrun/integration/crewai/builtin.py @@ -77,6 +77,7 @@ def sandbox_toolset( template_type: TemplateType = TemplateType.CODE_INTERPRETER, config: Optional[Config] = None, sandbox_idle_timeout_seconds: int = 600, + local_artifact_dir: Optional[str] = None, prefix: Optional[str] = None, ) -> List[Any]: """将沙箱模板封装为 LangChain ``StructuredTool`` 列表。 / CrewAI Built-in Integration Functions""" @@ -86,6 +87,7 @@ def sandbox_toolset( template_type=template_type, config=config, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + local_artifact_dir=local_artifact_dir, ).to_crewai(prefix=prefix) diff --git a/agentrun/integration/google_adk/builtin.py b/agentrun/integration/google_adk/builtin.py index 9622565..76d40f9 100644 --- a/agentrun/integration/google_adk/builtin.py +++ b/agentrun/integration/google_adk/builtin.py @@ -77,6 +77,7 @@ def sandbox_toolset( template_type: TemplateType = TemplateType.CODE_INTERPRETER, config: Optional[Config] = None, sandbox_idle_timeout_seconds: int = 600, + local_artifact_dir: Optional[str] = None, prefix: Optional[str] = None, ) -> List[Any]: """将沙箱模板封装为 LangChain ``StructuredTool`` 列表。 / Google ADK Built-in Integration Functions""" @@ -86,6 +87,7 @@ def sandbox_toolset( template_type=template_type, config=config, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + local_artifact_dir=local_artifact_dir, ).to_google_adk(prefix=prefix) diff --git a/agentrun/integration/langchain/builtin.py b/agentrun/integration/langchain/builtin.py index 9c6b9ab..fed0573 100644 --- a/agentrun/integration/langchain/builtin.py +++ b/agentrun/integration/langchain/builtin.py @@ -76,6 +76,7 @@ def sandbox_toolset( *, template_type: TemplateType = TemplateType.CODE_INTERPRETER, sandbox_idle_timeout_seconds: int = 600, + local_artifact_dir: Optional[str] = None, prefix: Optional[str] = None, modify_tool_name: Optional[Callable[[Tool], Tool]] = None, filter_tools_by_name: Optional[Callable[[str], bool]] = None, @@ -88,6 +89,7 @@ def sandbox_toolset( template_type=template_type, config=config, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + local_artifact_dir=local_artifact_dir, ).to_langchain( prefix=prefix, modify_tool_name=modify_tool_name, diff --git a/agentrun/integration/langgraph/builtin.py b/agentrun/integration/langgraph/builtin.py index 5b06979..3aaab14 100644 --- a/agentrun/integration/langgraph/builtin.py +++ b/agentrun/integration/langgraph/builtin.py @@ -77,6 +77,7 @@ def sandbox_toolset( template_type: TemplateType = TemplateType.CODE_INTERPRETER, config: Optional[Config] = None, sandbox_idle_timeout_seconds: int = 600, + local_artifact_dir: Optional[str] = None, prefix: Optional[str] = None, ) -> List[Any]: """将沙箱模板封装为 LangChain ``StructuredTool`` 列表。 / LangGraph Built-in Integration Functions""" @@ -86,6 +87,7 @@ def sandbox_toolset( template_type=template_type, config=config, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + local_artifact_dir=local_artifact_dir, ).to_langgraph(prefix=prefix) diff --git a/agentrun/integration/pydantic_ai/builtin.py b/agentrun/integration/pydantic_ai/builtin.py index eb235f9..75a428e 100644 --- a/agentrun/integration/pydantic_ai/builtin.py +++ b/agentrun/integration/pydantic_ai/builtin.py @@ -77,6 +77,7 @@ def sandbox_toolset( template_type: TemplateType = TemplateType.CODE_INTERPRETER, config: Optional[Config] = None, sandbox_idle_timeout_seconds: int = 600, + local_artifact_dir: Optional[str] = None, prefix: Optional[str] = None, ) -> List[Any]: """将沙箱模板封装为 LangChain ``StructuredTool`` 列表。 / PydanticAI Built-in Integration Functions""" @@ -86,6 +87,7 @@ def sandbox_toolset( template_type=template_type, config=config, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + local_artifact_dir=local_artifact_dir, ).to_pydantic_ai(prefix=prefix) diff --git a/tests/unittests/integration/test_aio_toolset.py b/tests/unittests/integration/test_aio_toolset.py new file mode 100644 index 0000000..5fa8cb2 --- /dev/null +++ b/tests/unittests/integration/test_aio_toolset.py @@ -0,0 +1,278 @@ +import importlib +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from agentrun.integration.builtin import sandbox as sandbox_builtin +from agentrun.integration.builtin.sandbox import ( + BrowserToolSet, + CodeInterpreterToolSet, + sandbox_toolset, +) +from agentrun.sandbox.model import TemplateType + + +def _tool_names(toolset): + return [tool.name for tool in toolset.tools()] + + +def test_aio_factory_returns_aio_toolset(): + ts = sandbox_toolset("aio-template", template_type=TemplateType.AIO) + + assert isinstance(ts, sandbox_builtin.AioToolSet) + assert ts.template_type == TemplateType.AIO + + +def test_aio_factory_uses_aio_template_type_when_creating_sandbox(): + mock_sandbox = MagicMock() + mock_sandbox.sandbox_id = "sandbox-aio-123" + mock_sandbox.check_health.return_value = {"status": "ok"} + + with patch("agentrun.integration.builtin.sandbox.Sandbox") as sandbox_cls: + sandbox_cls.create.return_value = mock_sandbox + ts = sandbox_toolset("aio-template", template_type=TemplateType.AIO) + + assert ts.check_health() == {"status": "ok"} + + sandbox_cls.create.assert_called_once() + assert sandbox_cls.create.call_args.kwargs["template_type"] == TemplateType.AIO + + +def test_aio_tool_names_include_browser_code_and_extension_tools_once(): + ts = sandbox_toolset("aio-template", template_type=TemplateType.AIO) + + names = _tool_names(ts) + + assert len(names) == len(set(names)) + assert names.count("health") == 1 + for name in [ + "browser_navigate", + "run_code", + "read_file", + "file_system_list", + "process_exec_cmd", + "browser_get_cdp_url", + "browser_get_vnc_url", + "upload_file", + "download_file", + "browser_recordings_list", + "browser_recording_download", + "browser_recording_delete", + ]: + assert name in names + + +def test_aio_code_tools_accept_capability_object_not_concrete_code_sandbox(): + ts = sandbox_toolset("aio-template", template_type=TemplateType.AIO) + mock_sandbox = MagicMock() + mock_sandbox.file.read.return_value = "hello from aio" + ts.sandbox = mock_sandbox + ts.sandbox_id = "sandbox-aio-123" + + assert ts.read_file("/tmp/hello.txt") == { + "path": "/tmp/hello.txt", + "content": "hello from aio", + } + + +def test_aio_browser_tools_accept_capability_object_not_concrete_browser_sandbox(): + ts = sandbox_toolset("aio-template", template_type=TemplateType.AIO) + mock_sandbox = MagicMock() + mock_playwright = MagicMock() + mock_playwright.title.return_value = "AIO page" + mock_sandbox.sync_playwright.return_value = mock_playwright + ts.sandbox = mock_sandbox + ts.sandbox_id = "sandbox-aio-123" + + assert ts.browser_get_title() == {"title": "AIO page"} + mock_sandbox.sync_playwright.assert_called_once() + + +def test_aio_run_code_with_existing_context_returns_standard_result(): + ts = sandbox_toolset("aio-template", template_type=TemplateType.AIO) + mock_sandbox = MagicMock() + mock_sandbox.context.execute.return_value = { + "stdout": "42\n", + "stderr": "", + "exitCode": 0, + } + ts.sandbox = mock_sandbox + ts.sandbox_id = "sandbox-aio-123" + + result = ts.run_code("print(42)", context_id="ctx-1") + + assert result == { + "stdout": "42\n", + "stderr": "", + "exit_code": 0, + "result": {"stdout": "42\n", "stderr": "", "exitCode": 0}, + } + + +def test_custom_template_type_is_explicitly_unsupported(): + with pytest.raises(ValueError, match="TemplateType.CUSTOM"): + sandbox_toolset("custom-template", template_type=TemplateType.CUSTOM) + + +def test_aio_local_artifact_dir_rejects_escape_paths(tmp_path): + ts = sandbox_toolset( + "aio-template", + template_type=TemplateType.AIO, + local_artifact_dir=str(tmp_path), + ) + + with pytest.raises(ValueError, match="local_artifact_path must be relative"): + ts.download_file("/tmp/a.txt", "/tmp/out.txt") + + with pytest.raises(ValueError, match="local_artifact_path escapes"): + ts.download_file("/tmp/a.txt", "../out.txt") + + +def test_aio_local_artifact_dir_rejects_symlink_escape(tmp_path): + outside = tmp_path.parent / "outside" + outside.mkdir(exist_ok=True) + link = tmp_path / "link" + link.symlink_to(outside, target_is_directory=True) + ts = sandbox_toolset( + "aio-template", + template_type=TemplateType.AIO, + local_artifact_dir=str(tmp_path), + ) + + with pytest.raises(ValueError, match="local_artifact_path escapes"): + ts.download_file("/tmp/a.txt", "link/out.txt") + + +def test_aio_upload_download_use_file_system_operations(tmp_path, monkeypatch): + local_source = tmp_path / "source.txt" + local_source.write_text("upload", encoding="utf-8") + + upload = MagicMock(return_value={"uploaded": True}) + download = MagicMock(return_value={"downloaded": True}) + sb = SimpleNamespace( + sandbox_id="sandbox-aio-123", + check_health=lambda: {"status": "ok"}, + context=object(), + file=SimpleNamespace(read=lambda path: "", write=lambda **kwargs: {}), + file_system=SimpleNamespace(upload=upload, download=download), + process=object(), + sync_playwright=lambda: object(), + get_cdp_url=lambda **kwargs: "", + get_vnc_url=lambda **kwargs: "", + list_recordings=lambda: [], + download_recording=lambda **kwargs: {}, + delete_recording=lambda filename: {}, + ) + ts = sandbox_toolset( + "aio-template", + template_type=TemplateType.AIO, + local_artifact_dir=str(tmp_path), + ) + monkeypatch.setattr(ts, "_ensure_sandbox", lambda: sb) + + assert ts.upload_file("source.txt", "/tmp/source.txt")["success"] is True + assert ( + ts.download_file("/tmp/source.txt", "downloaded.txt")["success"] + is True + ) + upload.assert_called_once_with( + local_file_path=str(local_source.resolve()), + target_file_path="/tmp/source.txt", + ) + download.assert_called_once_with( + path="/tmp/source.txt", + save_path=str((tmp_path / "downloaded.txt").resolve()), + ) + + +def test_aio_auto_artifact_dir_is_cleaned_on_close(): + ts = sandbox_toolset("aio-template", template_type=TemplateType.AIO) + artifact_dir = Path(ts.local_artifact_dir) + + assert artifact_dir.exists() + + ts.close() + + assert not artifact_dir.exists() + + +def test_aio_explicit_artifact_dir_is_not_cleaned_on_close(tmp_path): + ts = sandbox_toolset( + "aio-template", + template_type=TemplateType.AIO, + local_artifact_dir=str(tmp_path), + ) + + ts.close() + + assert tmp_path.exists() + + +def test_aio_remote_url_tools_do_not_expose_headers(): + fields = sandbox_builtin.AioToolSet.browser_get_cdp_url.args_schema.model_fields + assert "with_headers" not in fields + + ts = sandbox_toolset("aio-template", template_type=TemplateType.AIO) + mock_sandbox = MagicMock() + mock_sandbox.get_cdp_url.return_value = "wss://example.invalid/cdp" + mock_sandbox.get_vnc_url.return_value = "wss://example.invalid/vnc" + ts.sandbox = mock_sandbox + ts.sandbox_id = "sandbox-aio-123" + + assert ts.browser_get_cdp_url(record=True) == { + "url": "wss://example.invalid/cdp", + "record": True, + } + assert ts.browser_get_vnc_url(record=False) == { + "url": "wss://example.invalid/vnc", + "record": False, + } + mock_sandbox.get_cdp_url.assert_called_once_with( + record=True, with_headers=False + ) + mock_sandbox.get_vnc_url.assert_called_once_with( + record=False, with_headers=False + ) + + +@pytest.mark.parametrize( + ("module_name", "convert_method"), + [ + ("agentrun.integration.langchain.builtin", "to_langchain"), + ("agentrun.integration.langgraph.builtin", "to_langgraph"), + ("agentrun.integration.google_adk.builtin", "to_google_adk"), + ("agentrun.integration.pydantic_ai.builtin", "to_pydantic_ai"), + ("agentrun.integration.crewai.builtin", "to_crewai"), + ("agentrun.integration.agentscope.builtin", "to_agentscope"), + ], +) +def test_public_sandbox_toolset_wrappers_pass_local_artifact_dir( + module_name, convert_method, tmp_path +): + module = importlib.import_module(module_name) + mock_toolset = MagicMock() + getattr(mock_toolset, convert_method).return_value = [] + + with patch.object(module, "_sandbox_toolset", return_value=mock_toolset) as fn: + module.sandbox_toolset( + "aio-template", + template_type=TemplateType.AIO, + local_artifact_dir=str(tmp_path), + ) + + assert fn.call_args.kwargs["local_artifact_dir"] == str(tmp_path) + + +def test_existing_browser_and_code_interpreter_factory_behavior_stays_same(): + assert isinstance( + sandbox_toolset("browser-template", template_type=TemplateType.BROWSER), + BrowserToolSet, + ) + assert isinstance( + sandbox_toolset( + "code-template", template_type=TemplateType.CODE_INTERPRETER + ), + CodeInterpreterToolSet, + )