Skip to content

Commit 50d8995

Browse files
leeyyicursoragent
andcommitted
feature: 新增 Cube/E2B 沙箱代码执行器与工作空间运行时
- 在 trpc_agent_sdk.code_executors.cube 提供 CubeCodeExecutor 与 CubeWorkspaceRuntime, 复用 e2b-code-interpreter 远程沙箱; 通过 PEP 562 lazy __getattr__ 实现按需加载, 不安装 [cube] extra 也可正常 import 包 - 公共原语 CubeSandboxClient 集中封装沙箱生命周期 (open_new/open_existing/close/destroy)、结构化命令执行 (commands_run 吞掉 CommandExitException) 与文件传输 (upload_path/download_path 自动 dispatch 文件/目录, 目录走 tar 协议保留符号链接和权限) - 配置按 ISP 拆分: CubeCodeExecutorConfig 只承载 sandbox/执行相关字段, CubeWorkspaceRuntimeConfig 只承载 workspace 相关字段; create_cube_workspace_runtime 通过可选 workspace_cfg 注入 - 提供 examples/code_executors/cube_demo.py 端到端示例并在 pyproject.toml 增加 [cube] 可选依赖组 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1f39974 commit 50d8995

29 files changed

Lines changed: 6390 additions & 276 deletions
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env python3
2+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# tRPC-Agent-Python is licensed under Apache-2.0.
7+
"""End-to-end demo for `CubeCodeExecutor` and `CubeWorkspaceRuntime`.
8+
9+
Requires the optional ``[cube]`` extra and the following environment
10+
variables (same names hermes uses):
11+
12+
- ``CUBE_TEMPLATE_ID``: Cube template id (e.g. ``std-XXXXXXXX``)
13+
- ``E2B_API_URL``: Cube/E2B-compatible gateway URL
14+
- ``E2B_API_KEY``: API key for the gateway
15+
16+
Usage::
17+
18+
pip install 'trpc-agent-py[cube]'
19+
export CUBE_TEMPLATE_ID=...
20+
export E2B_API_URL=...
21+
export E2B_API_KEY=...
22+
python examples/code_executors/cube_demo.py
23+
24+
The demo walks through:
25+
1. ``CubeCodeExecutor.create`` (no sandbox_id -> fresh sandbox)
26+
2. ``execute_code`` for Python and Bash code blocks
27+
3. Workspace runtime: ``create_workspace`` -> ``put_files`` -> ``run_program`` -> ``collect_outputs``
28+
4. ``destroy`` (kills the remote sandbox)
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import asyncio
34+
import os
35+
import sys
36+
37+
from trpc_agent_sdk.code_executors import CubeCodeExecutor
38+
from trpc_agent_sdk.code_executors import CubeCodeExecutorConfig
39+
from trpc_agent_sdk.code_executors import create_cube_workspace_runtime
40+
from trpc_agent_sdk.code_executors._types import CodeBlock
41+
from trpc_agent_sdk.code_executors._types import CodeExecutionInput
42+
from trpc_agent_sdk.code_executors._types import WorkspaceOutputSpec
43+
from trpc_agent_sdk.code_executors._types import WorkspacePutFileInfo
44+
from trpc_agent_sdk.code_executors._types import WorkspaceRunProgramSpec
45+
46+
47+
def _require_env() -> None:
48+
missing = [name for name in ("CUBE_TEMPLATE_ID", "E2B_API_URL", "E2B_API_KEY") if not os.getenv(name)]
49+
if missing:
50+
sys.stderr.write(f"missing required env vars: {', '.join(missing)}\n")
51+
sys.exit(2)
52+
53+
54+
async def _run() -> None:
55+
_require_env()
56+
57+
cfg = CubeCodeExecutorConfig(
58+
execute_timeout=30.0,
59+
idle_timeout=600,
60+
)
61+
62+
executor = await CubeCodeExecutor.create(cfg)
63+
print(f"created sandbox: {executor.sandbox_id}")
64+
65+
try:
66+
# 1. execute_code with two blocks (python and bash).
67+
result = await executor.execute_code(
68+
invocation_context=None, # type: ignore[arg-type]
69+
code_execution_input=CodeExecutionInput(code_blocks=[
70+
CodeBlock(code="print('hello from cube py')", language="python"),
71+
CodeBlock(code="echo hello from cube bash", language="bash"),
72+
]),
73+
)
74+
print("execute_code result:")
75+
print(result.output)
76+
77+
# 2. Workspace runtime end-to-end.
78+
runtime = create_cube_workspace_runtime(executor)
79+
manager = runtime.manager()
80+
fs = runtime.fs()
81+
runner = runtime.runner()
82+
83+
ws = await manager.create_workspace("demo-1")
84+
print(f"workspace path: {ws.path}")
85+
86+
await fs.put_files(ws, [
87+
WorkspacePutFileInfo(path="work/script.py",
88+
content=b"print('script ran')\n"),
89+
])
90+
91+
run_result = await runner.run_program(
92+
ws,
93+
WorkspaceRunProgramSpec(cmd="python3", args=["work/script.py"], timeout=15.0),
94+
)
95+
print(f"run_program exit={run_result.exit_code} stdout={run_result.stdout!r}")
96+
97+
outputs = await fs.collect_outputs(ws, WorkspaceOutputSpec(globs=["work/*.py"], inline=True))
98+
for ref in outputs.files:
99+
print(f"output: {ref.name} ({len(ref.content)} chars)")
100+
101+
await manager.cleanup("demo-1")
102+
finally:
103+
await executor.destroy()
104+
print("sandbox destroyed")
105+
106+
107+
if __name__ == "__main__":
108+
asyncio.run(_run())

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ mem0 = [
9898
"sentence-transformers",
9999
]
100100

101+
cube = [
102+
"e2b-code-interpreter>=2.0.0",
103+
]
104+
101105
langchain_tool = [
102106
"langchain_tavily",
103107
"langchain",
@@ -140,6 +144,7 @@ all = [
140144
"aiofiles",
141145
"wecom-aibot-sdk-python>=0.1.5",
142146
"a2a-sdk<1.0.0,>=0.3.22",
147+
"e2b-code-interpreter>=2.0.0",
143148
]
144149

145150
[project.scripts]

tests/code_executors/container/test_container_ws_runtime.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,27 @@ async def test_collect_outputs_empty(self):
833833

834834
assert len(manifest.files) == 0
835835

836+
async def test_collect_outputs_empty_globs_short_circuits(self):
837+
"""Empty pattern list must skip the bash glob entirely.
838+
839+
Regression guard for ``_enumerate_matches``: when callers pass
840+
``globs=[]`` (or an all-whitespace list normalised to empty),
841+
the helper returns ``[]`` immediately rather than synthesising
842+
a degenerate ``patterns=()`` shell command. We verify by
843+
asserting ``exec_run`` is never invoked.
844+
"""
845+
ws = _make_ws()
846+
cc = _mock_container_client()
847+
cfg = RuntimeConfig()
848+
fs = ContainerWorkspaceFS(cc, cfg)
849+
850+
spec = WorkspaceOutputSpec(globs=[])
851+
manifest = await fs.collect_outputs(ws, spec)
852+
853+
assert manifest.files == []
854+
assert manifest.limits_hit is False
855+
cc.exec_run.assert_not_called()
856+
836857
async def test_collect_outputs_max_total_bytes(self):
837858
ws = _make_ws()
838859
cc = _mock_container_client()

tests/code_executors/cube/__init__.py

Whitespace-only changes.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Shared fixtures for the cube/ test suite.
7+
8+
Exposes a ``fake_e2b`` fixture that patches
9+
:func:`trpc_agent_sdk.code_executors.cube._e2b._import_e2b` to return a
10+
fake vendor module with stub classes / enums that match the surface the
11+
production code consults. This keeps the whole test suite independent
12+
of the real ``e2b-code-interpreter`` dependency.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from types import SimpleNamespace
18+
from unittest.mock import AsyncMock
19+
from unittest.mock import MagicMock
20+
21+
import pytest
22+
23+
24+
class _FakeSandboxException(Exception):
25+
"""Mirrors e2b_code_interpreter.SandboxException."""
26+
27+
28+
class _FakeSandboxNotFoundException(_FakeSandboxException):
29+
"""Mirrors e2b_code_interpreter.SandboxNotFoundException."""
30+
31+
32+
class _FakeCommandExitException(Exception):
33+
"""Mirrors e2b_code_interpreter.CommandExitException.
34+
35+
Carries stdout/stderr/exit_code, matching how the real vendor raises
36+
it. ``commands_run`` reads these via getattr so the attribute names
37+
are the contract here.
38+
"""
39+
40+
def __init__(self, stdout: str = "", stderr: str = "", exit_code: int = 1):
41+
super().__init__(f"cmd exit {exit_code}")
42+
self.stdout = stdout
43+
self.stderr = stderr
44+
self.exit_code = exit_code
45+
46+
47+
def _make_fake_e2b() -> SimpleNamespace:
48+
ns = SimpleNamespace()
49+
ns.SandboxException = _FakeSandboxException
50+
ns.SandboxNotFoundException = _FakeSandboxNotFoundException
51+
ns.CommandExitException = _FakeCommandExitException
52+
ns.SandboxState = SimpleNamespace(
53+
RUNNING=SimpleNamespace(value="running"),
54+
PAUSED=SimpleNamespace(value="paused"),
55+
STOPPED=SimpleNamespace(value="stopped"),
56+
)
57+
ns.FileType = SimpleNamespace(DIR="dir", FILE="file")
58+
ns.AsyncSandbox = MagicMock()
59+
return ns
60+
61+
62+
@pytest.fixture
63+
def fake_e2b(monkeypatch):
64+
"""Patch ``_import_e2b`` everywhere the cube package imports it."""
65+
ns = _make_fake_e2b()
66+
# The production code does ``from ._e2b import _import_e2b`` in
67+
# _sandbox.py and _code_executor.py, which rebinds the symbol in
68+
# those modules' globals — so we must patch every import site, not
69+
# just the original definition.
70+
monkeypatch.setattr(
71+
"trpc_agent_sdk.code_executors.cube._e2b._import_e2b",
72+
lambda: ns,
73+
)
74+
monkeypatch.setattr(
75+
"trpc_agent_sdk.code_executors.cube._sandbox._import_e2b",
76+
lambda: ns,
77+
)
78+
monkeypatch.setattr(
79+
"trpc_agent_sdk.code_executors.cube._code_executor._import_e2b",
80+
lambda: ns,
81+
)
82+
return ns
83+
84+
85+
def _make_fake_async_sandbox(sandbox_id: str = "sbx-1"):
86+
"""Build a MagicMock shaped like ``e2b_code_interpreter.AsyncSandbox``.
87+
88+
All the methods the production client touches are ``AsyncMock``s so
89+
tests can configure ``return_value`` / ``side_effect`` as needed.
90+
"""
91+
sbx = MagicMock()
92+
sbx.sandbox_id = sandbox_id
93+
sbx.kill = AsyncMock(return_value=None)
94+
sbx.set_timeout = AsyncMock(return_value=None)
95+
# get_info returns a state holder by default; tests override.
96+
info = SimpleNamespace(state=SimpleNamespace(value="running"))
97+
sbx.get_info = AsyncMock(return_value=info)
98+
sbx.commands = MagicMock()
99+
sbx.commands.run = AsyncMock()
100+
sbx.files = MagicMock()
101+
sbx.files.read = AsyncMock(return_value=b"")
102+
sbx.files.write = AsyncMock(return_value=None)
103+
sbx.files.get_info = AsyncMock(return_value=SimpleNamespace(type="file"))
104+
return sbx
105+
106+
107+
@pytest.fixture
108+
def fake_async_sandbox(fake_e2b):
109+
"""A fresh fake AsyncSandbox whose ``get_info`` defaults to RUNNING."""
110+
sbx = _make_fake_async_sandbox()
111+
sbx.get_info = AsyncMock(return_value=SimpleNamespace(state=fake_e2b.SandboxState.RUNNING))
112+
return sbx

0 commit comments

Comments
 (0)