From ab88bea71af0a6d117d6045a90d31cee067fa639 Mon Sep 17 00:00:00 2001 From: Yunnglin Date: Mon, 11 May 2026 18:53:58 +0800 Subject: [PATCH 1/3] update volcengine --- .vscode/settings.json | 3 +- README.md | 2 +- README_zh.md | 2 +- docs/en/docs/advanced/customization.md | 16 +- docs/zh/docs/advanced/customization.md | 18 +- examples/volcengine_usage.py | 79 +++++ ms_enclave/sandbox/boxes/__init__.py | 4 + ms_enclave/sandbox/boxes/base.py | 7 +- ms_enclave/sandbox/boxes/stateless_sandbox.py | 213 ++++++++++++++ ms_enclave/sandbox/boxes/volcengine.py | 131 +++++++++ ms_enclave/sandbox/manager/__init__.py | 2 + ms_enclave/sandbox/manager/volcengine.py | 273 ++++++++++++++++++ ms_enclave/sandbox/model/__init__.py | 2 + ms_enclave/sandbox/model/base.py | 4 + ms_enclave/sandbox/model/config.py | 52 ++++ ms_enclave/sandbox/tools/base.py | 21 +- ms_enclave/sandbox/tools/sandbox_tool.py | 14 +- .../tools/sandbox_tools/file_operation.py | 2 +- .../sandbox_tools/multi_code_executor.py | 24 +- .../tools/sandbox_tools/notebook_executor.py | 2 +- .../tools/sandbox_tools/python_executor.py | 22 +- .../tools/sandbox_tools/shell_executor.py | 18 +- tests/test_tool.py | 2 +- 23 files changed, 864 insertions(+), 49 deletions(-) create mode 100644 examples/volcengine_usage.py create mode 100644 ms_enclave/sandbox/boxes/stateless_sandbox.py create mode 100644 ms_enclave/sandbox/boxes/volcengine.py create mode 100644 ms_enclave/sandbox/manager/volcengine.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 45e0ffb..2ae1713 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,6 @@ "*test*.py" ], "python.testing.pytestEnabled": false, - "python.testing.unittestEnabled": true + "python.testing.unittestEnabled": true, + "python.languageServer": "None" } diff --git a/README.md b/README.md index 4654835..c078a30 100644 --- a/README.md +++ b/README.md @@ -374,7 +374,7 @@ Current built-in sandbox types: Tool loading rules: - Tools are only initialized and available when explicitly declared in `tools_config` -- Tools validate `required_sandbox_type`, automatically ignored if mismatched +- Tools validate `required_sandbox_types`, automatically ignored if mismatched Example: diff --git a/README_zh.md b/README_zh.md index 5eb426c..84acaa4 100644 --- a/README_zh.md +++ b/README_zh.md @@ -379,7 +379,7 @@ asyncio.run(main()) 工具加载规则: - 仅当在 `tools_config` 中显式声明时,工具才会初始化并可用 -- 工具会校验 `required_sandbox_type`,不匹配则自动忽略 +- 工具会校验 `required_sandbox_types`,不匹配则自动忽略 示例: diff --git a/docs/en/docs/advanced/customization.md b/docs/en/docs/advanced/customization.md index 3dca8cf..2c53a9a 100644 --- a/docs/en/docs/advanced/customization.md +++ b/docs/en/docs/advanced/customization.md @@ -29,18 +29,18 @@ Tips: ## Custom Tool Implement/override: -- `required_sandbox_type`: declare compatible sandbox type (return `None` for any). +- `required_sandbox_types`: declare compatible sandbox types (return `None` for any). - `async def execute(self, sandbox_context, **kwargs)`: implement tool logic and return `ToolResult`. - Optional constructor args: `name/description/parameters/enabled/timeout`. If no params are needed, you may omit `parameters`. Notes: - The framework exposes an OpenAI-style function schema via `Tool.schema`. For strict validation, pass a Pydantic model via `parameters` (see `tools/tool_info.py`). -- Compatibility is checked by `Tool.is_compatible_with_sandbox` using `required_sandbox_type` plus `SandboxType.is_compatible`. +- Compatibility is checked by `Tool.is_compatible_with_sandbox` using `required_sandbox_types` plus `SandboxType.is_compatible`. ### Example A: Minimal tool (no sandbox command) ```python -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from ms_enclave.sandbox.tools.base import Tool, register_tool from ms_enclave.sandbox.model import SandboxType @@ -50,7 +50,7 @@ class HelloTool(Tool): super().__init__(name=name, description=description, enabled=enabled) @property - def required_sandbox_type(self) -> Optional[SandboxType]: + def required_sandbox_types(self) -> Optional[List[SandboxType]]: return None async def execute(self, sandbox_context: Any, name: str = 'world', **kwargs) -> Dict[str, Any]: @@ -76,7 +76,7 @@ asyncio.run(main()) ### Example B: Prefer in-sandbox command with local fallback ```python -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from datetime import datetime, timezone from ms_enclave.sandbox.tools.base import Tool, register_tool from ms_enclave.sandbox.model import SandboxType @@ -87,8 +87,8 @@ class TimeTellerTool(Tool): super().__init__(name=name, description=description, enabled=enabled) @property - def required_sandbox_type(self) -> Optional[SandboxType]: - return SandboxType.DOCKER + def required_sandbox_types(self) -> Optional[List[SandboxType]]: + return [SandboxType.DOCKER] async def execute(self, sandbox_context: Any, timezone_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: cmd = 'date' @@ -383,7 +383,7 @@ asyncio.run(main()) - Only execute tools when status is `SandboxStatus.RUNNING`. - Call `await self.initialize_tools()` in `start()`. - Compatibility: - - Tools should declare `required_sandbox_type`; return `None` if no restriction. + - Tools should declare `required_sandbox_types`; return `None` if no restriction. - `SandboxType.is_compatible` enables subtypes to reuse parent tools (e.g., `DOCKER_NOTEBOOK` with `DOCKER`). - Parameter schema: - Pass a Pydantic model via `parameters` for validation and documentation. Otherwise, `parameters` defaults to `{}`. diff --git a/docs/zh/docs/advanced/customization.md b/docs/zh/docs/advanced/customization.md index 6a7f288..3fb79c4 100644 --- a/docs/zh/docs/advanced/customization.md +++ b/docs/zh/docs/advanced/customization.md @@ -32,19 +32,19 @@ ms-enclave 采用高度模块化的设计,支持按需扩展 Tool、Sandbox ## 自定义 Tool 必须实现/覆写: -- `required_sandbox_type`:声明该工具可运行的沙箱类型(返回 `None` 表示所有类型均可)。 +- `required_sandbox_types`:声明该工具可运行的沙箱类型列表(返回 `None` 表示所有类型均可)。 - `async def execute(self, sandbox_context, **kwargs)`:执行工具逻辑,返回 `ToolResult`(字典即可)。 - 可选:构造函数参数 `name/description/parameters/enabled/timeout`。若不需要参数,可以不设置 `parameters`。 注意: - 框架会通过 `Tool.schema` 暴露 OpenAI 风格的 function schema。若需要严格的参数校验,可传入 `parameters`(Pydantic 模型,参见 `tools/tool_info.py`)。 -- 工具与沙箱兼容性:`Tool.is_compatible_with_sandbox` 会根据 `required_sandbox_type` 与 `SandboxType.is_compatible` 判定。 +- 工具与沙箱兼容性:`Tool.is_compatible_with_sandbox` 会根据 `required_sandbox_types` 与 `SandboxType.is_compatible` 判定。 ### 示例 A:最小可用工具(不依赖沙箱命令) ```python # 最少依赖:返回问候语,任何沙箱可用 -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from ms_enclave.sandbox.tools.base import Tool, register_tool from ms_enclave.sandbox.model import SandboxType # 仅用于类型声明 @@ -54,7 +54,7 @@ class HelloTool(Tool): super().__init__(name=name, description=description, enabled=enabled) @property - def required_sandbox_type(self) -> Optional[SandboxType]: + def required_sandbox_types(self) -> Optional[List[SandboxType]]: # None => 任何沙箱可用 return None @@ -82,7 +82,7 @@ asyncio.run(main()) ```python # 在沙箱内执行 `date` 命令;若不可用则回退到 Python 计算当前时间 -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from datetime import datetime, timezone from ms_enclave.sandbox.tools.base import Tool, register_tool from ms_enclave.sandbox.model import SandboxType @@ -93,9 +93,9 @@ class TimeTellerTool(Tool): super().__init__(name=name, description=description, enabled=enabled) @property - def required_sandbox_type(self) -> Optional[SandboxType]: - # DOCKER 工具在 DOCKER、DOCKER_NOTEBOOK 等兼容类型中可用 - return SandboxType.DOCKER + def required_sandbox_types(self) -> Optional[List[SandboxType]]: + # 声明为 DOCKER => 在 DOCKER、DOCKER_NOTEBOOK 等兼容类型中可用 + return [SandboxType.DOCKER] async def execute(self, sandbox_context: Any, timezone_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: cmd = 'date' @@ -405,7 +405,7 @@ asyncio.run(main()) - 只有在 `SandboxStatus.RUNNING` 时才应执行工具。 - 在 `start()` 中调用 `await self.initialize_tools()`,确保工具就绪。 - 兼容性: - - 工具应通过 `required_sandbox_type` 明确要求;若无要求,返回 `None`。 + - 工具应通过 `required_sandbox_types` 明确要求;若无要求,返回 `None`。 - `SandboxType.is_compatible` 用于允许子类型复用父类型工具(例如:`DOCKER_NOTEBOOK` 兼容 `DOCKER` 工具)。 - 参数 Schema: - 若需要严格参数校验/文档化,在构造 Tool 时传入 `parameters`(Pydantic 模型)。未指定时,schema 的 `parameters` 为 `{}`。 diff --git a/examples/volcengine_usage.py b/examples/volcengine_usage.py new file mode 100644 index 0000000..7b58bdd --- /dev/null +++ b/examples/volcengine_usage.py @@ -0,0 +1,79 @@ +"""Example: use the VolcEngine / SandboxFusion stateless sandbox. + +Prerequisite — start the SandboxFusion HTTP service manually:: + + docker run -it -p 8080:8080 \ + vemlp-cn-beijing.cr.volces.com/preset-images/code-sandbox:server-20250609 + +Then run:: + + python examples/volcengine_usage.py +""" + +from __future__ import annotations + +import asyncio + +from ms_enclave.sandbox.manager import VolcengineSandboxManager +from ms_enclave.sandbox.model import SandboxType, VolcengineSandboxConfig, VolcengineSandboxManagerConfig + + +async def main() -> None: + manager_config = VolcengineSandboxManagerConfig( + base_url='http://localhost:8080', + max_concurrency=4, + request_timeout=30.0, + ) + + sandbox_config = VolcengineSandboxConfig( + tools_config=['python_executor', 'shell_executor', 'multi_code_executor'], + ) + + async with VolcengineSandboxManager(config=manager_config) as manager: + sandbox_id = await manager.create_sandbox(SandboxType.VOLCENGINE, sandbox_config) + print(f'Created sandbox: {sandbox_id}') + print('Available tools:', list((await manager.get_sandbox_tools(sandbox_id)).keys())) + + # 1) Python + r = await manager.execute_tool( + sandbox_id, + 'python_executor', + {'code': 'print("hello from python:", 1 + 2)'}, + ) + print('\n[python_executor]', r.status.value) + print('stdout:', r.output) + if r.error: + print('stderr:', r.error) + + # 2) Shell + r = await manager.execute_tool( + sandbox_id, + 'shell_executor', + {'command': 'echo hello from bash && uname -a'}, + ) + print('\n[shell_executor]', r.status.value) + print('stdout:', r.output) + if r.error: + print('stderr:', r.error) + + # 3) Multi-language: C++ + cpp_code = ( + '#include \n' + 'int main() {\n' + ' std::cout << "hello from c++" << std::endl;\n' + ' return 0;\n' + '}\n' + ) + r = await manager.execute_tool( + sandbox_id, + 'multi_code_executor', + {'language': 'cpp', 'code': cpp_code}, + ) + print('\n[multi_code_executor / cpp]', r.status.value) + print('stdout:', r.output) + if r.error: + print('stderr:', r.error) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/ms_enclave/sandbox/boxes/__init__.py b/ms_enclave/sandbox/boxes/__init__.py index d923ad5..988f88b 100644 --- a/ms_enclave/sandbox/boxes/__init__.py +++ b/ms_enclave/sandbox/boxes/__init__.py @@ -3,14 +3,18 @@ from .base import Sandbox, SandboxFactory, register_sandbox from .docker_notebook import DockerNotebookSandbox from .docker_sandbox import DockerSandbox +from .stateless_sandbox import StatelessSandbox +from .volcengine import VolcengineSandbox __all__ = [ # Base interfaces 'Sandbox', 'SandboxFactory', 'register_sandbox', + 'StatelessSandbox', # Implementations 'DockerSandbox', 'DockerNotebookSandbox', + 'VolcengineSandbox', ] diff --git a/ms_enclave/sandbox/boxes/base.py b/ms_enclave/sandbox/boxes/base.py index f1d3753..de6db2c 100644 --- a/ms_enclave/sandbox/boxes/base.py +++ b/ms_enclave/sandbox/boxes/base.py @@ -17,6 +17,7 @@ SandboxStatus, SandboxType, ToolResult, + VolcengineSandboxConfig, ) from ..tools import Tool, ToolFactory @@ -100,7 +101,7 @@ def add_tool(self, tool: Tool) -> None: self._tools[tool.name] = tool else: logger.warning( - f"Tool '{tool.name}' requires sandbox type '{tool.required_sandbox_type}' " + f"Tool '{tool.name}' requires sandbox types '{tool.required_sandbox_types}' " f"but this is a '{self.sandbox_type}' sandbox. " f'Compatible types: {SandboxType.get_compatible_types(self.sandbox_type)}' ) @@ -237,6 +238,8 @@ def create_sandbox( config = DockerSandboxConfig() elif sandbox_type == SandboxType.DOCKER_NOTEBOOK: config = DockerNotebookConfig() + elif sandbox_type == SandboxType.VOLCENGINE: + config = VolcengineSandboxConfig() else: config = SandboxConfig() elif isinstance(config, dict): @@ -244,6 +247,8 @@ def create_sandbox( config = DockerSandboxConfig(**config) elif sandbox_type == SandboxType.DOCKER_NOTEBOOK: config = DockerNotebookConfig(**config) + elif sandbox_type == SandboxType.VOLCENGINE: + config = VolcengineSandboxConfig(**config) else: config = SandboxConfig(**config) diff --git a/ms_enclave/sandbox/boxes/stateless_sandbox.py b/ms_enclave/sandbox/boxes/stateless_sandbox.py new file mode 100644 index 0000000..834a123 --- /dev/null +++ b/ms_enclave/sandbox/boxes/stateless_sandbox.py @@ -0,0 +1,213 @@ +"""Reusable base class for stateless remote sandboxes (no long-lived container). + +Stateless remote sandboxes (e.g. VolcEngine/SandboxFusion, E2B, Daytona, ...) +expose execution through an HTTP service managed externally. A "sandbox" here +is just a logical handle into that remote service, so the base class only owns +the pieces that are truly common to any such backend: + +- Lifecycle that is effectively no-op (``start`` / ``stop`` / ``cleanup``): + no container to spawn; the only resource is a shared ``aiohttp.ClientSession``. +- Session management: the manager typically injects a shared session; when none + is provided the sandbox creates (and owns) a private one. +- Generic HTTP helpers (``_post_json`` / ``_request_json``) that subclasses can + use to talk to whatever endpoints their vendor exposes. +- A default ``_health_check`` hook that subclasses may override for a startup + probe. +- ``execute_command`` raises :class:`NotImplementedError`, since stateless + remotes typically don't expose free-form shell access. + +Vendor/protocol-specific concerns (e.g. SandboxFusion's ``/run_code`` API, +language identifier rewriting, response → :class:`ToolResult` mapping) are +deliberately **NOT** implemented here; they belong to concrete subclasses such +as :class:`~ms_enclave.sandbox.boxes.volcengine.VolcengineSandbox`. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +import aiohttp + +from ms_enclave.utils import get_logger + +from ..model import SandboxConfig, SandboxStatus +from .base import Sandbox + +logger = get_logger() + + +class StatelessSandbox(Sandbox): + """Reusable base class for stateless HTTP-backed sandboxes. + + Subclasses are responsible for implementing any vendor-specific execution + protocol on top of the HTTP helpers provided here, and for advertising + their concrete :class:`SandboxType` via the ``sandbox_type`` property. + """ + + def __init__( + self, + config: SandboxConfig, + *, + base_url: str, + request_timeout: float = 30.0, + verify_ssl: bool = True, + extra_headers: Optional[Dict[str, str]] = None, + api_key: Optional[str] = None, + session: Optional[aiohttp.ClientSession] = None, + sandbox_id: Optional[str] = None, + ) -> None: + super().__init__(config, sandbox_id) + if not base_url: + raise ValueError('StatelessSandbox requires a non-empty base_url') + self._base_url: str = base_url.rstrip('/') + self._request_timeout: float = float(request_timeout) + self._verify_ssl: bool = bool(verify_ssl) + self._api_key: Optional[str] = api_key + self._extra_headers: Dict[str, str] = dict(extra_headers) if extra_headers else {} + + # If a session is injected by the manager, we don't own it and must not close it. + self._session: Optional[aiohttp.ClientSession] = session + self._owns_session: bool = session is None + + # --------------------------------------------------------------------- # + # Accessors # + # --------------------------------------------------------------------- # + @property + def base_url(self) -> str: + return self._base_url + + @property + def session(self) -> Optional[aiohttp.ClientSession]: + return self._session + + # --------------------------------------------------------------------- # + # Lifecycle # + # --------------------------------------------------------------------- # + async def start(self) -> None: + """Start the stateless sandbox (no container to spawn).""" + try: + self.update_status(SandboxStatus.INITIALIZING) + + # Create a private session if none was injected. + if self._session is None: + timeout = aiohttp.ClientTimeout(total=self._request_timeout) + headers = self._build_default_headers() + self._session = aiohttp.ClientSession(timeout=timeout, headers=headers) + self._owns_session = True + + # Optional subclass-provided health check; failures should not block startup. + try: + await self._health_check() + except Exception as e: + logger.warning(f'Stateless sandbox health check failed (non-fatal): {e}') + + await self.initialize_tools() + self.update_status(SandboxStatus.RUNNING) + except Exception as e: + self.update_status(SandboxStatus.ERROR) + self.metadata['error'] = str(e) + logger.error(f'Failed to start stateless sandbox: {e}') + raise + + async def stop(self) -> None: + """Stop the stateless sandbox; close private session if owned.""" + try: + self.update_status(SandboxStatus.STOPPING) + await self._close_private_session() + self.update_status(SandboxStatus.STOPPED) + except Exception as e: + logger.error(f'Error stopping stateless sandbox: {e}') + self.update_status(SandboxStatus.ERROR) + raise + + async def cleanup(self) -> None: + """Clean up resources; identical to stop for stateless sandboxes.""" + await self._close_private_session() + + async def get_execution_context(self) -> Any: + """Tools receive the sandbox itself as the execution context.""" + return self + + async def execute_command(self, command, timeout=None, stream=True): # type: ignore[override] + raise NotImplementedError('stateless sandbox does not support execute_command') + + # --------------------------------------------------------------------- # + # Subclass hooks # + # --------------------------------------------------------------------- # + async def _health_check(self) -> None: + """Optional startup probe. Default: no-op. Subclasses may override.""" + return None + + # --------------------------------------------------------------------- # + # HTTP helpers (generic, protocol-agnostic) # + # --------------------------------------------------------------------- # + def _build_default_headers(self) -> Dict[str, str]: + headers: Dict[str, str] = {'Content-Type': 'application/json'} + if self._api_key: + headers['Authorization'] = self._api_key + if self._extra_headers: + headers.update(self._extra_headers) + return headers + + def _build_url(self, path: str) -> str: + """Join ``base_url`` and ``path`` (absolute URL in ``path`` is honored).""" + if path.startswith('http://') or path.startswith('https://'): + return path + suffix = path if path.startswith('/') else f'/{path}' + return f'{self._base_url}{suffix}' + + async def _close_private_session(self) -> None: + if self._session is not None and self._owns_session: + try: + await self._session.close() + except Exception as e: # noqa: BLE001 + logger.warning(f'Error closing stateless sandbox session: {e}') + finally: + self._session = None + self._owns_session = False + + async def _request_json( + self, + method: str, + path: str, + *, + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + """Issue an HTTP request and return the parsed JSON body. + + This is intentionally low-level: subclasses use it to implement their + own vendor-specific APIs without reimplementing session / timeout / SSL + plumbing. + """ + if self._session is None: + raise RuntimeError('StatelessSandbox session is not initialized; did you call start()?') + + url = self._build_url(path) + request_kwargs: Dict[str, Any] = {} + if json is not None: + request_kwargs['json'] = json + if params is not None: + request_kwargs['params'] = params + if timeout is not None: + request_kwargs['timeout'] = aiohttp.ClientTimeout(total=float(timeout)) + if not self._verify_ssl: + request_kwargs['ssl'] = False + + async with self._session.request(method, url, **request_kwargs) as response: + response.raise_for_status() + data = await response.json() + if not isinstance(data, dict): + raise ValueError(f'Expected JSON object from {method} {url}, got {type(data).__name__}') + return data + + async def _post_json( + self, + path: str, + payload: Dict[str, Any], + *, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + """Convenience wrapper around ``_request_json('POST', ...)``.""" + return await self._request_json('POST', path, json=payload, timeout=timeout) diff --git a/ms_enclave/sandbox/boxes/volcengine.py b/ms_enclave/sandbox/boxes/volcengine.py new file mode 100644 index 0000000..f9a7ed6 --- /dev/null +++ b/ms_enclave/sandbox/boxes/volcengine.py @@ -0,0 +1,131 @@ +"""VolcEngine / SandboxFusion stateless sandbox. + +Concrete :class:`StatelessSandbox` subclass that speaks the SandboxFusion HTTP +protocol (``POST {base_url}{run_code_path}`` with a JSON ``{code, language}`` +body). All vendor/protocol specifics live here — the generic plumbing (shared +``aiohttp.ClientSession``, lifecycle, HTTP helpers) is inherited from +:class:`StatelessSandbox`. + +A :class:`VolcengineSandbox` is normally created by +:class:`~ms_enclave.sandbox.manager.VolcengineSandboxManager`, which injects +the shared session and manager-level HTTP settings. It is also registered with +:class:`SandboxFactory` for introspection, but direct factory creation without +a ``base_url`` will fail fast in ``StatelessSandbox.__init__``. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + +import aiohttp + +from ..model import ExecutionStatus, SandboxType, ToolResult, VolcengineSandboxConfig +from .base import register_sandbox +from .stateless_sandbox import StatelessSandbox + + +@register_sandbox(SandboxType.VOLCENGINE) +class VolcengineSandbox(StatelessSandbox): + """Stateless sandbox backed by a SandboxFusion HTTP service.""" + + def __init__( + self, + config: VolcengineSandboxConfig, + sandbox_id: Optional[str] = None, + *, + base_url: Optional[str] = None, + run_code_path: str = '/run_code', + request_timeout: float = 30.0, + verify_ssl: bool = True, + extra_headers: Optional[Dict[str, str]] = None, + api_key: Optional[str] = None, + dataset_language_map: Optional[Dict[str, str]] = None, + session: Optional[aiohttp.ClientSession] = None, + ) -> None: + super().__init__( + config, + base_url=base_url or '', + request_timeout=request_timeout, + verify_ssl=verify_ssl, + extra_headers=extra_headers, + api_key=api_key, + session=session, + sandbox_id=sandbox_id, + ) + # Convenient reference to the typed sandbox config for tool dispatch. + self.config: VolcengineSandboxConfig = config + self._run_code_path: str = run_code_path if run_code_path.startswith('/') else f'/{run_code_path}' + self._dataset_language_map: Dict[str, str] = (dict(dataset_language_map) if dataset_language_map else {}) + + @property + def sandbox_type(self) -> SandboxType: + return SandboxType.VOLCENGINE + + @property + def run_code_path(self) -> str: + return self._run_code_path + + # --------------------------------------------------------------------- # + # SandboxFusion protocol # + # --------------------------------------------------------------------- # + async def run_code( + self, + code: str, + language: str, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + """Issue ``POST {base_url}{run_code_path}`` and return the parsed JSON body. + + Applies ``dataset_language_map`` to translate language identifiers + before sending, so e.g. ``r`` can be sent upstream as ``R``. + """ + mapped_language = ( + self._dataset_language_map.get(language, language) if self._dataset_language_map else language + ) + payload = {'code': code, 'language': mapped_language} + return await self._post_json(self._run_code_path, payload, timeout=timeout) + + # --------------------------------------------------------------------- # + # Response -> ToolResult helper # + # --------------------------------------------------------------------- # + @staticmethod + def parse_run_code_response(resp: Dict[str, Any]) -> Tuple[ExecutionStatus, str, Optional[str]]: + """Parse a SandboxFusion-style ``/run_code`` response into ``(status, output, error)``. + + Merges stdout / stderr / ``compile_result.stderr`` / top-level message + into a human-readable output; maps the overall status + non-zero + ``return_code`` to :attr:`ExecutionStatus.ERROR`. + """ + status_field = resp.get('status') + message = resp.get('message') or '' + + compile_result = resp.get('compile_result') or {} + run_result = resp.get('run_result') or {} + + stdout = run_result.get('stdout') or '' if isinstance(run_result, dict) else '' + stderr = run_result.get('stderr') or '' if isinstance(run_result, dict) else '' + return_code = run_result.get('return_code') if isinstance(run_result, dict) else None + compile_stderr = compile_result.get('stderr') or '' if isinstance(compile_result, dict) else '' + + def _append(base: str, extra: str) -> str: + if not extra: + return base + if not base: + return extra + return base + ('' if base.endswith('\n') else '\n') + extra + + output = stdout + error_parts = '' + error_parts = _append(error_parts, stderr) + error_parts = _append(error_parts, compile_stderr) + if message and message not in output and message not in error_parts: + error_parts = _append(error_parts, message) + + ok = (status_field == 'Success') and (return_code in (0, '0', None)) + status = ExecutionStatus.SUCCESS if ok else ExecutionStatus.ERROR + return status, output, (error_parts or None) + + def build_tool_result(self, tool_name: str, resp: Dict[str, Any]) -> ToolResult: + """Convenience wrapper: turn a ``/run_code`` response into a :class:`ToolResult`.""" + status, output, error = self.parse_run_code_response(resp) + return ToolResult(tool_name=tool_name, status=status, output=output, error=error) diff --git a/ms_enclave/sandbox/manager/__init__.py b/ms_enclave/sandbox/manager/__init__.py index 0cbd8ef..4fd67f7 100644 --- a/ms_enclave/sandbox/manager/__init__.py +++ b/ms_enclave/sandbox/manager/__init__.py @@ -3,10 +3,12 @@ from .base import SandboxManager, SandboxManagerFactory from .http_manager import HttpSandboxManager from .local_manager import LocalSandboxManager +from .volcengine import VolcengineSandboxManager __all__ = [ 'SandboxManager', 'SandboxManagerFactory', 'LocalSandboxManager', 'HttpSandboxManager', + 'VolcengineSandboxManager', ] diff --git a/ms_enclave/sandbox/manager/volcengine.py b/ms_enclave/sandbox/manager/volcengine.py new file mode 100644 index 0000000..1c3121a --- /dev/null +++ b/ms_enclave/sandbox/manager/volcengine.py @@ -0,0 +1,273 @@ +"""VolcEngine / SandboxFusion sandbox manager. + +Wraps a SandboxFusion HTTP service (started manually by the user, e.g. +``docker run -it -p 8080:8080 vemlp-cn-beijing.cr.volces.com/preset-images/code-sandbox:server-20250609``) +and exposes it through the standard :class:`SandboxManager` interface. + +Key design: + +- **Shared HTTP session**: manager owns a single ``aiohttp.ClientSession`` and + hands it to every :class:`VolcengineSandbox` it creates. The session is + closed only when the manager stops. +- **Concurrency gate**: a global ``asyncio.Semaphore(max_concurrency)`` throttles + concurrent ``/run_code`` requests across all sandboxes. +- **No real pool**: because the backend is stateless, a "pool" is logically a + no-op; :meth:`initialize_pool` just ensures a default sandbox exists so that + :meth:`execute_tool_in_pool` has somewhere to dispatch. +""" + +from __future__ import annotations + +import asyncio +from collections import Counter +from typing import Any, Dict, List, Optional, Union + +import aiohttp + +from ms_enclave.utils import get_logger + +from ..boxes.volcengine import VolcengineSandbox +from ..model import ( + SandboxConfig, + SandboxInfo, + SandboxManagerConfig, + SandboxManagerType, + SandboxStatus, + SandboxType, + ToolResult, + VolcengineSandboxConfig, + VolcengineSandboxManagerConfig, +) +from .base import SandboxManager, register_manager + +logger = get_logger() + + +@register_manager(SandboxManagerType.VOLCENGINE) +class VolcengineSandboxManager(SandboxManager): + """Manager for VolcEngine/SandboxFusion stateless sandboxes.""" + + def __init__(self, config: Optional[SandboxManagerConfig] = None, **kwargs): + if config is None: + raise ValueError( + 'VolcengineSandboxManager requires a VolcengineSandboxManagerConfig ' + '(with at least `base_url`).' + ) + if not isinstance(config, VolcengineSandboxManagerConfig): + # Best-effort upcast: build a VolcengineSandboxManagerConfig from the + # fields of the provided config, so users that pass a plain dict or + # base SandboxManagerConfig still work when a `base_url` is present. + data = config.model_dump(exclude_none=True) if hasattr(config, 'model_dump') else dict(config) + config = VolcengineSandboxManagerConfig(**data) + + super().__init__(config, **kwargs) + self.config: VolcengineSandboxManagerConfig = config + self._session: Optional[aiohttp.ClientSession] = None + self._semaphore: Optional[asyncio.Semaphore] = None + + # --------------------------------------------------------------------- # + # Lifecycle # + # --------------------------------------------------------------------- # + async def start(self) -> None: + if self._running: + return + + timeout = aiohttp.ClientTimeout(total=self.config.request_timeout) + headers: Dict[str, str] = {'Content-Type': 'application/json'} + if self.config.api_key: + headers['Authorization'] = self.config.api_key + if self.config.extra_headers: + headers.update(self.config.extra_headers) + + self._session = aiohttp.ClientSession(timeout=timeout, headers=headers) + self._semaphore = asyncio.Semaphore(self.config.max_concurrency) + self._running = True + logger.info( + f'VolcengineSandboxManager started (base_url={self.config.base_url}, ' + f'max_concurrency={self.config.max_concurrency})' + ) + + async def stop(self) -> None: + if not self._running: + return + self._running = False + + await self.cleanup_all_sandboxes() + + if self._session is not None: + try: + await self._session.close() + except Exception as e: # noqa: BLE001 + logger.warning(f'Error closing manager HTTP session: {e}') + finally: + self._session = None + logger.info('VolcengineSandboxManager stopped') + + # --------------------------------------------------------------------- # + # Sandbox CRUD # + # --------------------------------------------------------------------- # + async def create_sandbox( + self, + sandbox_type: SandboxType, + config: Optional[Union[SandboxConfig, Dict]] = None, + sandbox_id: Optional[str] = None, + ) -> str: + if sandbox_type != SandboxType.VOLCENGINE: + raise ValueError(f'VolcengineSandboxManager only supports SandboxType.VOLCENGINE, got {sandbox_type}') + + # Normalize the sandbox-level config. + if config is None: + cfg = VolcengineSandboxConfig() + elif isinstance(config, dict): + cfg = VolcengineSandboxConfig(**config) + elif isinstance(config, VolcengineSandboxConfig): + cfg = config + else: + # Allow plain SandboxConfig by forwarding its fields. + data = config.model_dump(exclude_none=True) + cfg = VolcengineSandboxConfig(**data) + + try: + sandbox = VolcengineSandbox( + cfg, + sandbox_id=sandbox_id, + base_url=self.config.base_url, + run_code_path=self.config.run_code_path, + request_timeout=self.config.request_timeout, + verify_ssl=self.config.verify_ssl, + extra_headers=self.config.extra_headers, + api_key=self.config.api_key, + dataset_language_map=self.config.dataset_language_map, + session=self._session, + ) + await sandbox.start() + self._sandboxes[sandbox.id] = sandbox + logger.info(f'Created VolcEngine sandbox {sandbox.id}') + return sandbox.id + except Exception as e: + logger.error(f'Failed to create VolcEngine sandbox: {e}') + raise RuntimeError(f'Failed to create sandbox: {e}') from e + + async def get_sandbox_info(self, sandbox_id: str) -> Optional[SandboxInfo]: + sandbox = self._sandboxes.get(sandbox_id) + return sandbox.get_info() if sandbox else None + + async def list_sandboxes(self, status_filter: Optional[SandboxStatus] = None) -> List[SandboxInfo]: + result: List[SandboxInfo] = [] + for sandbox in self._sandboxes.values(): + info = sandbox.get_info() + if status_filter is None or info.status == status_filter: + result.append(info) + return result + + async def stop_sandbox(self, sandbox_id: str) -> bool: + sandbox = self._sandboxes.get(sandbox_id) + if not sandbox: + return False + try: + await sandbox.stop() + return True + except Exception as e: + logger.error(f'Error stopping sandbox {sandbox_id}: {e}') + return False + + async def delete_sandbox(self, sandbox_id: str) -> bool: + sandbox = self._sandboxes.get(sandbox_id) + if not sandbox: + return False + try: + await sandbox.stop() + except Exception as e: + logger.warning(f'Error stopping sandbox {sandbox_id} during delete: {e}') + self._sandboxes.pop(sandbox_id, None) + # Remove from pool deque if present (rare path) + try: + self._sandbox_pool.remove(sandbox_id) # type: ignore[arg-type] + except ValueError: + pass + return True + + async def cleanup_all_sandboxes(self) -> None: + ids = list(self._sandboxes.keys()) + for sid in ids: + try: + await self.delete_sandbox(sid) + except Exception as e: # noqa: BLE001 + logger.error(f'Error cleaning up sandbox {sid}: {e}') + + # --------------------------------------------------------------------- # + # Tool execution # + # --------------------------------------------------------------------- # + async def execute_tool(self, sandbox_id: str, tool_name: str, parameters: Dict[str, Any]) -> ToolResult: + sandbox = self._sandboxes.get(sandbox_id) + if not sandbox: + raise ValueError(f'Sandbox {sandbox_id} not found') + if self._semaphore is None: + raise RuntimeError('VolcengineSandboxManager is not started') + + async with self._semaphore: + return await sandbox.execute_tool(tool_name, parameters) + + async def get_sandbox_tools(self, sandbox_id: str) -> Dict[str, Any]: + sandbox = self._sandboxes.get(sandbox_id) + if not sandbox: + raise ValueError(f'Sandbox {sandbox_id} not found') + return sandbox.get_available_tools() + + # --------------------------------------------------------------------- # + # Stats # + # --------------------------------------------------------------------- # + async def get_stats(self) -> Dict[str, Any]: + status_counter: Counter = Counter() + type_counter: Counter = Counter() + for sandbox in self._sandboxes.values(): + status_counter[sandbox.status.value] += 1 + type_counter[sandbox.sandbox_type.value] += 1 + return { + 'manager_type': SandboxManagerType.VOLCENGINE.value, + 'base_url': self.config.base_url, + 'total_sandboxes': len(self._sandboxes), + 'status_counts': dict(status_counter), + 'sandbox_types': dict(type_counter), + 'running': self._running, + 'max_concurrency': self.config.max_concurrency, + 'pool_size': len(self._sandbox_pool), + 'pool_initialized': self._pool_initialized, + } + + # --------------------------------------------------------------------- # + # Pool (no-op, since the backend is stateless) # + # --------------------------------------------------------------------- # + async def initialize_pool( + self, + pool_size: Optional[int] = None, + sandbox_type: Optional[SandboxType] = None, + config: Optional[Union[SandboxConfig, Dict]] = None, + ) -> List[str]: + """Mark the pool as initialized without spawning any extra sandboxes. + + SandboxFusion is stateless, so a real pool buys nothing. We simply + ensure a default sandbox exists, register it in ``_sandbox_pool`` so + it can be used by :meth:`execute_tool_in_pool`, and return its id. + """ + _ = pool_size # intentionally unused — kept for interface parity + async with self._pool_lock: + if not self._sandboxes: + default_id = await self.create_sandbox( + sandbox_type or SandboxType.VOLCENGINE, + config or self.config.sandbox_config, + ) + self._sandbox_pool.append(default_id) + self._pool_initialized = True + return list(self._sandboxes.keys()) + + async def execute_tool_in_pool( + self, tool_name: str, parameters: Dict[str, Any], timeout: Optional[float] = None + ) -> ToolResult: + """Dispatch the tool on any existing sandbox (creating one if missing).""" + _ = timeout # the semaphore + HTTP timeout already bound wait time + if not self._sandboxes: + await self.initialize_pool() + # Any sandbox works — pick the first one. + sandbox_id = next(iter(self._sandboxes)) + return await self.execute_tool(sandbox_id, tool_name, parameters) diff --git a/ms_enclave/sandbox/model/__init__.py b/ms_enclave/sandbox/model/__init__.py index 9bb8e20..b9c2d44 100644 --- a/ms_enclave/sandbox/model/__init__.py +++ b/ms_enclave/sandbox/model/__init__.py @@ -10,6 +10,8 @@ SandboxManagerConfig, ShellExecutorConfig, ToolConfig, + VolcengineSandboxConfig, + VolcengineSandboxManagerConfig, ) from .requests import ( ExecuteCodeRequest, diff --git a/ms_enclave/sandbox/model/base.py b/ms_enclave/sandbox/model/base.py index a513dd7..e128093 100644 --- a/ms_enclave/sandbox/model/base.py +++ b/ms_enclave/sandbox/model/base.py @@ -20,6 +20,7 @@ class SandboxType(str, Enum): """Sandbox type enumeration.""" DOCKER = 'docker' DOCKER_NOTEBOOK = 'docker_notebook' + VOLCENGINE = 'volcengine' DUMMY = 'dummy' @classmethod @@ -38,9 +39,11 @@ def get_compatible_types(cls, sandbox_type: 'SandboxType') -> Set['SandboxType'] """ # Define inheritance hierarchy: child -> parents # DOCKER_NOTEBOOK inherits from DOCKER (can use DOCKER tools) + # VOLCENGINE is a standalone stateless remote sandbox (no inheritance) inheritance_map = { cls.DOCKER: {cls.DOCKER}, cls.DOCKER_NOTEBOOK: {cls.DOCKER_NOTEBOOK, cls.DOCKER}, + cls.VOLCENGINE: {cls.VOLCENGINE}, } return inheritance_map.get(sandbox_type, {sandbox_type}) @@ -65,6 +68,7 @@ class SandboxManagerType(str, Enum): """Sandbox manager type enumeration.""" LOCAL = 'local' HTTP = 'http' + VOLCENGINE = 'volcengine' class ToolType(str, Enum): diff --git a/ms_enclave/sandbox/model/config.py b/ms_enclave/sandbox/model/config.py index 5aed8b8..9ba2f94 100644 --- a/ms_enclave/sandbox/model/config.py +++ b/ms_enclave/sandbox/model/config.py @@ -99,6 +99,58 @@ class DockerNotebookConfig(DockerSandboxConfig): token: Optional[str] = Field(None, description='Token for Jupyter Notebook access') +class VolcengineSandboxConfig(SandboxConfig): + """Volcengine/SandboxFusion stateless sandbox configuration. + + Note: The VolcEngine sandbox is stateless and exposed via an HTTP service + started manually by the user (e.g. via + ``docker run -it -p 8080:8080 vemlp-cn-beijing.cr.volces.com/preset-images/code-sandbox:server-20250609``). + HTTP-level settings (base_url, api_key, etc.) live on the manager config; + this class only carries sandbox-local options. + """ + + tool_language_map: Dict[ + str, + str] = Field(default_factory=dict, description='Per-tool language override, e.g. {"shell_executor": "bash"}.') + + +class VolcengineSandboxManagerConfig(SandboxManagerConfig): + """Manager-level configuration for the Volcengine/SandboxFusion backend.""" + + base_url: str = Field(..., description='SandboxFusion HTTP service base URL, e.g. http://localhost:8080') + api_key: Optional[str] = Field(None, description='Optional API key; sent as Authorization header') + request_timeout: float = Field(default=30.0, description='Per-request HTTP timeout in seconds') + verify_ssl: bool = Field(default=True, description='Whether to verify SSL certificates') + run_code_path: str = Field(default='/run_code', description='Path of the run_code endpoint') + extra_headers: Optional[Dict[str, str]] = Field(None, description='Extra HTTP headers to attach to every request') + max_concurrency: int = Field(default=16, description='Maximum concurrent requests shared by this manager') + dataset_language_map: Optional[ + Dict[str, str] + ] = Field(None, description='Optional language rename map applied before calling /run_code (e.g. {"r": "R"}).') + + @field_validator('base_url') + def validate_base_url(cls, v): + if not v: + raise ValueError('base_url is required for VolcengineSandboxManagerConfig') + return v.rstrip('/') + + @field_validator('max_concurrency') + def validate_max_concurrency(cls, v): + if v <= 0: + raise ValueError('max_concurrency must be positive') + return v + + @field_validator('request_timeout') + def validate_request_timeout(cls, v): + if v <= 0: + raise ValueError('request_timeout must be positive') + return v + + @field_validator('run_code_path') + def validate_run_code_path(cls, v): + return v if v.startswith('/') else f'/{v}' + + class ToolConfig(BaseModel): """Tool configuration.""" diff --git a/ms_enclave/sandbox/tools/base.py b/ms_enclave/sandbox/tools/base.py index 60ee955..8e6aa69 100644 --- a/ms_enclave/sandbox/tools/base.py +++ b/ms_enclave/sandbox/tools/base.py @@ -1,7 +1,7 @@ """Base tool interface and factory.""" from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Dict, Optional, Type +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type from ..model import SandboxType, ToolResult from .tool_info import ToolParams @@ -54,16 +54,18 @@ def schema(self) -> Dict: @property @abstractmethod - def required_sandbox_type(self) -> Optional[SandboxType]: + def required_sandbox_types(self) -> Optional[List[SandboxType]]: """ - Return the required sandbox type for this tool. + Return the list of sandbox types this tool can run in. - If a tool specifies a required_sandbox_type, it can be used in: - 1. Sandboxes of that exact type - 2. Sandboxes that "inherit" from that type (e.g., DOCKER_NOTEBOOK can use DOCKER tools) + If a tool specifies ``required_sandbox_types``, it can be used in: + 1. Sandboxes whose type is in that list + 2. Sandboxes that "inherit" from any of those types (e.g., DOCKER_NOTEBOOK + inherits from DOCKER and can therefore use tools requiring DOCKER) Returns: - Required sandbox type or None if tool works in any sandbox + List of accepted sandbox types, or ``None``/empty list to accept any + sandbox type. """ pass @@ -77,10 +79,11 @@ def is_compatible_with_sandbox(self, sandbox_type: SandboxType) -> bool: Returns: True if the tool can be used in the given sandbox type """ - if self.required_sandbox_type is None: + required = self.required_sandbox_types + if not required: return True - return SandboxType.is_compatible(sandbox_type, self.required_sandbox_type) + return any(SandboxType.is_compatible(sandbox_type, rt) for rt in required) @abstractmethod async def execute(self, sandbox_context: 'Sandbox', **kwargs) -> ToolResult: diff --git a/ms_enclave/sandbox/tools/sandbox_tool.py b/ms_enclave/sandbox/tools/sandbox_tool.py index 7c58469..8acbfb4 100644 --- a/ms_enclave/sandbox/tools/sandbox_tool.py +++ b/ms_enclave/sandbox/tools/sandbox_tool.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional +from typing import Any, List, Optional from ..model import SandboxType from .base import Tool @@ -11,7 +11,7 @@ class SandboxTool(Tool): _name: Optional[str] = None _description: Optional[str] = None _parameters: Optional[ToolParams] = None - _sandbox_type: Optional[SandboxType] = None + _sandbox_types: Optional[List[SandboxType]] = None def __init__( self, @@ -20,7 +20,7 @@ def __init__( description: Optional[str] = None, parameters: Optional[ToolParams] = None, sandbox: Optional[Any] = None, - sandbox_type: Optional[SandboxType] = None, + sandbox_types: Optional[List[SandboxType]] = None, **kwargs, ): """ @@ -38,9 +38,9 @@ def __init__( self._name = name or self.__class__._name self._description = description or self.__class__._description self._parameters = parameters or self.__class__._parameters - self._sandbox_type = sandbox_type or self.__class__._sandbox_type + self._sandbox_types = sandbox_types if sandbox_types is not None else self.__class__._sandbox_types @property - def required_sandbox_type(self) -> Optional[SandboxType]: - """Get the required sandbox type for this tool.""" - return self._sandbox_type + def required_sandbox_types(self) -> Optional[List[SandboxType]]: + """Get the list of sandbox types this tool can run in.""" + return list(self._sandbox_types) if self._sandbox_types else self._sandbox_types diff --git a/ms_enclave/sandbox/tools/sandbox_tools/file_operation.py b/ms_enclave/sandbox/tools/sandbox_tools/file_operation.py index 41669d0..c3104f0 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/file_operation.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/file_operation.py @@ -25,7 +25,7 @@ class FileOperation(SandboxTool): """ _name = 'file_operation' - _sandbox_type = SandboxType.DOCKER + _sandbox_types = [SandboxType.DOCKER] _description = 'Perform file operations like read, write, delete, and list files' _parameters = ToolParams( type='object', diff --git a/ms_enclave/sandbox/tools/sandbox_tools/multi_code_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/multi_code_executor.py index 37d196f..301cf27 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/multi_code_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/multi_code_executor.py @@ -9,9 +9,12 @@ from ms_enclave.sandbox.tools.base import register_tool from ms_enclave.sandbox.tools.sandbox_tool import SandboxTool from ms_enclave.sandbox.tools.tool_info import ToolParams +from ms_enclave.utils import get_logger if TYPE_CHECKING: - from ms_enclave.sandbox.boxes import DockerSandbox + from ms_enclave.sandbox.boxes import DockerSandbox, VolcengineSandbox + +logger = get_logger() @register_tool('multi_code_executor') @@ -19,7 +22,7 @@ class MultiCodeExecutor(SandboxTool): """Execute code in multiple languages in an isolated Docker environment.""" _name = 'multi_code_executor' - _sandbox_type = SandboxType.DOCKER + _sandbox_types = [SandboxType.DOCKER, SandboxType.VOLCENGINE] _description = 'Execute code in various languages (python, cpp, csharp, go, java, nodejs, ts, rust, php, bash, pytest, jest, go_test, lua, r, perl, d_ut, ruby, scala, julia, kotlin_script, verilog, lean, swift, racket) with runtime tuning' # noqa: E501 _parameters = ToolParams( type='object', @@ -107,6 +110,23 @@ async def execute( if not language or not code.strip(): return ToolResult(tool_name=self.name, status=ExecutionStatus.ERROR, output='', error='Invalid input') + # Stateless remote sandbox: delegate to /run_code; features like ``files`` and + # ``compile_timeout`` are not supported by the remote API and are ignored. + sbx_type = getattr(sandbox_context, 'sandbox_type', None) + if sbx_type == SandboxType.VOLCENGINE: + if files: + logger.warning('multi_code_executor: `files` is ignored for VolcEngine/SandboxFusion backend') + if compile_timeout is not None: + logger.warning('multi_code_executor: `compile_timeout` is ignored for VolcEngine/SandboxFusion backend') + try: + volcengine: 'VolcengineSandbox' = sandbox_context # type: ignore[assignment] + resp = await volcengine.run_code(code, language=language, timeout=run_timeout) + return volcengine.build_tool_result(self.name, resp) + except Exception as e: + return ToolResult( + tool_name=self.name, status=ExecutionStatus.ERROR, output='', error=f'Execution failed: {str(e)}' + ) + lang = language.lower().strip() unique_prefix = f'mce_{uuid.uuid4().hex}' workdir = f'/tmp/{unique_prefix}' diff --git a/ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py index 3398bca..b20b41b 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py @@ -21,7 +21,7 @@ class NotebookExecutor(SandboxTool): _name = 'notebook_executor' - _sandbox_type = SandboxType.DOCKER_NOTEBOOK + _sandbox_types = [SandboxType.DOCKER_NOTEBOOK] _description = 'Execute Python code in a Jupyter kernel environment' _parameters = ToolParams( type='object', diff --git a/ms_enclave/sandbox/tools/sandbox_tools/python_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/python_executor.py index 95bf76b..3cf0c41 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/python_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/python_executor.py @@ -10,14 +10,14 @@ from ms_enclave.sandbox.tools.tool_info import ToolParams if TYPE_CHECKING: - from ms_enclave.sandbox.boxes import DockerSandbox + from ms_enclave.sandbox.boxes import DockerSandbox, VolcengineSandbox @register_tool('python_executor') class PythonExecutor(SandboxTool): _name = 'python_executor' - _sandbox_type = SandboxType.DOCKER + _sandbox_types = [SandboxType.DOCKER, SandboxType.VOLCENGINE] _description = 'Execute Python code in an isolated environment using IPython' _parameters = ToolParams( type='object', @@ -38,12 +38,24 @@ class PythonExecutor(SandboxTool): async def execute(self, sandbox_context: 'DockerSandbox', code: str, timeout: Optional[int] = 30) -> ToolResult: """Execute Python code by writing to a temporary file and executing it.""" - script_basename = f'exec_script_{uuid.uuid4().hex}.py' - script_path = f'/tmp/{script_basename}' - if not code.strip(): return ToolResult(tool_name=self.name, status=ExecutionStatus.ERROR, output='', error='No code provided') + # Stateless remote sandbox (e.g. VolcEngine/SandboxFusion): just POST /run_code. + sbx_type = getattr(sandbox_context, 'sandbox_type', None) + if sbx_type == SandboxType.VOLCENGINE: + try: + volcengine: 'VolcengineSandbox' = sandbox_context # type: ignore[assignment] + resp = await volcengine.run_code(code, language='python', timeout=timeout) + return volcengine.build_tool_result(self.name, resp) + except Exception as e: + return ToolResult( + tool_name=self.name, status=ExecutionStatus.ERROR, output='', error=f'Execution failed: {str(e)}' + ) + + script_basename = f'exec_script_{uuid.uuid4().hex}.py' + script_path = f'/tmp/{script_basename}' + try: # Write script to container to avoid long code errors diff --git a/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py index 21c1243..634de01 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py @@ -8,14 +8,14 @@ from ms_enclave.sandbox.tools.tool_info import ToolParams if TYPE_CHECKING: - from ms_enclave.sandbox.boxes import Sandbox + from ms_enclave.sandbox.boxes import Sandbox, VolcengineSandbox @register_tool('shell_executor') class ShellExecutor(SandboxTool): _name = 'shell_executor' - _sandbox_type = SandboxType.DOCKER + _sandbox_types = [SandboxType.DOCKER, SandboxType.VOLCENGINE] _description = 'Execute shell commands in an isolated environment' _parameters = ToolParams( type='object', @@ -48,6 +48,20 @@ async def execute( if not command or (isinstance(command, str) and not command.strip()): return ToolResult(tool_name=self.name, status=ExecutionStatus.ERROR, output='', error='No command provided') + + # Stateless remote sandbox: route through /run_code with bash language. + sbx_type = getattr(sandbox_context, 'sandbox_type', None) + if sbx_type == SandboxType.VOLCENGINE: + try: + volcengine: 'VolcengineSandbox' = sandbox_context # type: ignore[assignment] + cmd_str = command if isinstance(command, str) else ' '.join(command) + resp = await volcengine.run_code(cmd_str, language='bash', timeout=timeout) + return volcengine.build_tool_result(self.name, resp) + except Exception as e: + return ToolResult( + tool_name=self.name, status=ExecutionStatus.ERROR, output='', error=f'Execution failed: {str(e)}' + ) + try: result = await sandbox_context.execute_command(command, timeout=timeout) diff --git a/tests/test_tool.py b/tests/test_tool.py index 617affb..4e51263 100644 --- a/tests/test_tool.py +++ b/tests/test_tool.py @@ -25,7 +25,7 @@ def test_create_python_executor_tool(self): self.assertEqual(tool.name, 'python_executor') self.assertIsNotNone(tool.description) self.assertIsNotNone(tool.schema) - self.assertEqual(tool.required_sandbox_type, SandboxType.DOCKER) + self.assertEqual(tool.required_sandbox_types, [SandboxType.DOCKER, SandboxType.VOLCENGINE]) def test_create_unknown_tool_raises_error(self): """Test that creating unknown tool raises appropriate error.""" From e09a9e3dc6a7efb740f1faadd0df33f4cab9dcea Mon Sep 17 00:00:00 2001 From: Yunlin Mao Date: Mon, 11 May 2026 19:45:13 +0800 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ms_enclave/sandbox/tools/sandbox_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms_enclave/sandbox/tools/sandbox_tool.py b/ms_enclave/sandbox/tools/sandbox_tool.py index 8acbfb4..f19f49a 100644 --- a/ms_enclave/sandbox/tools/sandbox_tool.py +++ b/ms_enclave/sandbox/tools/sandbox_tool.py @@ -43,4 +43,4 @@ def __init__( @property def required_sandbox_types(self) -> Optional[List[SandboxType]]: """Get the list of sandbox types this tool can run in.""" - return list(self._sandbox_types) if self._sandbox_types else self._sandbox_types + return list(self._sandbox_types) if self._sandbox_types is not None else None From e539c6f7e8e2c355ac7cfaac294b288d704b106c Mon Sep 17 00:00:00 2001 From: Yunnglin Date: Mon, 11 May 2026 20:25:15 +0800 Subject: [PATCH 3/3] update volcengine --- ms_enclave/sandbox/boxes/stateless_sandbox.py | 8 +-- ms_enclave/sandbox/boxes/volcengine.py | 48 ++++++++++++----- ms_enclave/sandbox/manager/volcengine.py | 6 +++ ms_enclave/sandbox/model/config.py | 54 +++++++++++++++++-- .../tools/sandbox_tools/shell_executor.py | 3 +- setup.cfg | 2 +- 6 files changed, 100 insertions(+), 21 deletions(-) diff --git a/ms_enclave/sandbox/boxes/stateless_sandbox.py b/ms_enclave/sandbox/boxes/stateless_sandbox.py index 834a123..99b1c13 100644 --- a/ms_enclave/sandbox/boxes/stateless_sandbox.py +++ b/ms_enclave/sandbox/boxes/stateless_sandbox.py @@ -24,13 +24,13 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Union import aiohttp from ms_enclave.utils import get_logger -from ..model import SandboxConfig, SandboxStatus +from ..model import CommandResult, SandboxConfig, SandboxStatus from .base import Sandbox logger = get_logger() @@ -128,7 +128,9 @@ async def get_execution_context(self) -> Any: """Tools receive the sandbox itself as the execution context.""" return self - async def execute_command(self, command, timeout=None, stream=True): # type: ignore[override] + async def execute_command( + self, command: Union[str, List[str]], timeout: Optional[int] = None, stream: bool = True + ) -> CommandResult: raise NotImplementedError('stateless sandbox does not support execute_command') # --------------------------------------------------------------------- # diff --git a/ms_enclave/sandbox/boxes/volcengine.py b/ms_enclave/sandbox/boxes/volcengine.py index f9a7ed6..f7c3bc1 100644 --- a/ms_enclave/sandbox/boxes/volcengine.py +++ b/ms_enclave/sandbox/boxes/volcengine.py @@ -8,9 +8,9 @@ A :class:`VolcengineSandbox` is normally created by :class:`~ms_enclave.sandbox.manager.VolcengineSandboxManager`, which injects -the shared session and manager-level HTTP settings. It is also registered with -:class:`SandboxFactory` for introspection, but direct factory creation without -a ``base_url`` will fail fast in ``StatelessSandbox.__init__``. +the shared session and manager-level HTTP settings. It is also registered +with :class:`SandboxFactory` — when created via the factory, HTTP settings +are read from :class:`VolcengineSandboxConfig`. """ from __future__ import annotations @@ -23,6 +23,13 @@ from .base import register_sandbox from .stateless_sandbox import StatelessSandbox +# Default language mapping for MINOR_RUNNERS that require capitalised identifiers. +_DEFAULT_DATASET_LANGUAGE_MAP: Dict[str, str] = { + 'r': 'R', + 'd_ut': 'D_ut', + 'ts': 'typescript', +} + @register_sandbox(SandboxType.VOLCENGINE) class VolcengineSandbox(StatelessSandbox): @@ -34,28 +41,43 @@ def __init__( sandbox_id: Optional[str] = None, *, base_url: Optional[str] = None, - run_code_path: str = '/run_code', - request_timeout: float = 30.0, - verify_ssl: bool = True, + run_code_path: Optional[str] = None, + request_timeout: Optional[float] = None, + verify_ssl: Optional[bool] = None, extra_headers: Optional[Dict[str, str]] = None, api_key: Optional[str] = None, dataset_language_map: Optional[Dict[str, str]] = None, session: Optional[aiohttp.ClientSession] = None, ) -> None: + # When explicit kwargs are not provided, fall back to config values. + _base_url = base_url if base_url is not None else (config.base_url or '') + _run_code_path = run_code_path if run_code_path is not None else config.run_code_path + _request_timeout = request_timeout if request_timeout is not None else config.request_timeout + _verify_ssl = verify_ssl if verify_ssl is not None else config.verify_ssl + _extra_headers = extra_headers if extra_headers is not None else config.extra_headers + _api_key = api_key if api_key is not None else config.api_key + _dataset_language_map = dataset_language_map if dataset_language_map is not None else config.dataset_language_map + super().__init__( config, - base_url=base_url or '', - request_timeout=request_timeout, - verify_ssl=verify_ssl, - extra_headers=extra_headers, - api_key=api_key, + base_url=_base_url, + request_timeout=_request_timeout, + verify_ssl=_verify_ssl, + extra_headers=_extra_headers, + api_key=_api_key, session=session, sandbox_id=sandbox_id, ) # Convenient reference to the typed sandbox config for tool dispatch. self.config: VolcengineSandboxConfig = config - self._run_code_path: str = run_code_path if run_code_path.startswith('/') else f'/{run_code_path}' - self._dataset_language_map: Dict[str, str] = (dict(dataset_language_map) if dataset_language_map else {}) + self._run_code_path: str = _run_code_path if _run_code_path.startswith('/') else f'/{_run_code_path}' + # Merge user-provided map on top of the defaults so that explicit + # entries take precedence, but MINOR_RUNNERS (r, d_ut, …) get their + # required capitalisation even when the caller supplies nothing. + _merged = dict(_DEFAULT_DATASET_LANGUAGE_MAP) + if _dataset_language_map: + _merged.update(_dataset_language_map) + self._dataset_language_map: Dict[str, str] = _merged @property def sandbox_type(self) -> SandboxType: diff --git a/ms_enclave/sandbox/manager/volcengine.py b/ms_enclave/sandbox/manager/volcengine.py index 1c3121a..136cec8 100644 --- a/ms_enclave/sandbox/manager/volcengine.py +++ b/ms_enclave/sandbox/manager/volcengine.py @@ -268,6 +268,12 @@ async def execute_tool_in_pool( _ = timeout # the semaphore + HTTP timeout already bound wait time if not self._sandboxes: await self.initialize_pool() + if not self._sandboxes: + raise RuntimeError( + 'No sandbox available after pool initialization. ' + 'Ensure the VolcEngine backend is reachable at ' + f'{self.config.base_url}.' + ) # Any sandbox works — pick the first one. sandbox_id = next(iter(self._sandboxes)) return await self.execute_tool(sandbox_id, tool_name, parameters) diff --git a/ms_enclave/sandbox/model/config.py b/ms_enclave/sandbox/model/config.py index 9ba2f94..d9bfd29 100644 --- a/ms_enclave/sandbox/model/config.py +++ b/ms_enclave/sandbox/model/config.py @@ -102,17 +102,65 @@ class DockerNotebookConfig(DockerSandboxConfig): class VolcengineSandboxConfig(SandboxConfig): """Volcengine/SandboxFusion stateless sandbox configuration. - Note: The VolcEngine sandbox is stateless and exposed via an HTTP service + The VolcEngine sandbox is stateless and exposed via an HTTP service started manually by the user (e.g. via ``docker run -it -p 8080:8080 vemlp-cn-beijing.cr.volces.com/preset-images/code-sandbox:server-20250609``). - HTTP-level settings (base_url, api_key, etc.) live on the manager config; - this class only carries sandbox-local options. + + HTTP-level settings can be provided either on this config (for direct + ``SandboxFactory.create_sandbox`` usage) or on + :class:`VolcengineSandboxManagerConfig` (when going through the manager, + which injects its own values at sandbox creation time). """ + # ---- HTTP-level settings (also accepted by SandboxFactory) ---- + base_url: Optional[str] = Field( + None, + description='SandboxFusion HTTP service base URL, e.g. http://localhost:8080', + ) + run_code_path: str = Field( + '/run_code', + description='Path of the run_code endpoint', + ) + request_timeout: float = Field( + 30.0, + description='Per-request HTTP timeout in seconds', + ) + verify_ssl: bool = Field( + True, + description='Whether to verify SSL certificates', + ) + extra_headers: Optional[Dict[str, str]] = Field( + None, + description='Extra HTTP headers to attach to every request', + ) + api_key: Optional[str] = Field( + None, + description='Optional API key; sent as Authorization header', + ) + dataset_language_map: Optional[Dict[str, str]] = Field( + None, + description='Optional language rename map applied before calling /run_code (e.g. {"r": "R"}).', + ) + + # ---- Sandbox-local options ---- tool_language_map: Dict[ str, str] = Field(default_factory=dict, description='Per-tool language override, e.g. {"shell_executor": "bash"}.') + @field_validator('base_url') + @classmethod + def _normalize_base_url(cls, v: Optional[str]) -> Optional[str]: + if v is not None: + v = v.rstrip('/') + if not v: + raise ValueError('base_url, when provided, must be non-empty') + return v + + @field_validator('run_code_path') + @classmethod + def _normalize_run_code_path(cls, v: str) -> str: + return v if v.startswith('/') else f'/{v}' + class VolcengineSandboxManagerConfig(SandboxManagerConfig): """Manager-level configuration for the Volcengine/SandboxFusion backend.""" diff --git a/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py index 634de01..a92d4f1 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py @@ -1,5 +1,6 @@ """Shell command execution tool.""" +import shlex from typing import TYPE_CHECKING, List, Optional, Union from ms_enclave.sandbox.model import ExecutionStatus, SandboxType, ToolResult @@ -54,7 +55,7 @@ async def execute( if sbx_type == SandboxType.VOLCENGINE: try: volcengine: 'VolcengineSandbox' = sandbox_context # type: ignore[assignment] - cmd_str = command if isinstance(command, str) else ' '.join(command) + cmd_str = command if isinstance(command, str) else shlex.join(command) resp = await volcengine.run_code(cmd_str, language='bash', timeout=timeout) return volcengine.build_tool_result(self.name, resp) except Exception as e: diff --git a/setup.cfg b/setup.cfg index ea627c5..4973f01 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,7 +26,7 @@ ignore-words-list = patten,nd,ty,mot,hist,formating,winn,gool,datas,wan,confids [flake8] max-line-length = 120 select = B,C,E,F,P,T4,W,B9 -ignore = F401,F403,F405,F821,W503,E251,W504 +ignore = F401,F403,F405,F821,W503,E251,W504,E501 exclude = docs,*.pyi,.git [darglint]