Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/mkdocs/en/code_executor.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,42 @@ cfg = CubeCodeExecutorConfig(
executor = await CubeCodeExecutor.create(cfg)
```

### Tencent Cloud Agent Sandbox

Tencent Cloud Agent Sandbox (AGS) exposes an E2B-compatible endpoint through
a regional domain. Use `TencentAGSClientConfig` to select that endpoint without
turning the domain into an `api_url` manually:

```python
from trpc_agent_sdk.code_executors.cube import (
TencentAGSClientConfig,
create_cube_sandbox_client,
create_cube_workspace_runtime,
)

cfg = TencentAGSClientConfig(
template="your-template-id",
api_key="your-ags-api-key",
domain="ap-guangzhou.tencentags.com",
request_timeout=60,
metadata={"application": "my-agent"},
auto_recover=True,
)
sandbox_client = await create_cube_sandbox_client(cfg)
runtime = create_cube_workspace_runtime(sandbox_client=sandbox_client)
```

`domain`, `api_key`, and `template` may instead be supplied through
`E2B_DOMAIN`, `E2B_API_KEY`, and `CUBE_TEMPLATE_ID`. Local E2B key-format
validation defaults to `False` for AGS keys; remote authentication is still
performed by AGS. A domain must be a bare hostname; pass a full URL through
`api_url` instead. When a domain is selected, the config also supplies the
derived `https://api.<domain>` control-plane URL so a stale `E2B_API_URL`
cannot override it. An explicit `api_url` selects the control-plane endpoint;
when `domain` is also supplied it remains the sandbox-routing domain. With
environment-only configuration, `E2B_DOMAIN` takes precedence over
`E2B_API_URL` for this provider-specific config.

`CubeCodeExecutor` accepts the same `code_block_delimiters` as the other
executors; by default it adds a `bash` delimiter on top of the default
`python` and `tool_code` delimiters so plain `\`\`\`bash\n ... \n\`\`\``
Expand Down
2 changes: 1 addition & 1 deletion docs/mkdocs/en/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,7 @@ LLM calls skill_run(skill="python-math", command="python3 scripts/fib.py 10")
- **Cube executor** (remote E2B sandbox): [trpc_agent_sdk/code_executors/cube/_runtime.py](../../../trpc_agent_sdk/code_executors/cube/_runtime.py)
- Executes inside a remote Cube/E2B sandbox; suitable for environments without local Docker, or when strong remote isolation is required
- Construct via `create_cube_workspace_runtime(executor, workspace_cfg=...)`; see [code_executor.md](code_executor.md#cubeworkspaceruntime) for details
- Requires the optional `[cube]` extra (`pip install 'trpc-agent-py[cube]'`) and the `E2B_API_URL` / `E2B_API_KEY` / `CUBE_TEMPLATE_ID` environment variables (or equivalent cfg fields)
- Requires the optional `[cube]` extra (`pip install 'trpc-agent-py[cube]'`) and either a generic E2B endpoint (`E2B_API_URL`) or `TencentAGSClientConfig` with `E2B_DOMAIN`; credentials and template come from `E2B_API_KEY` / `CUBE_TEMPLATE_ID` or equivalent cfg fields

**Container executor notes**:
- The run base directory is writable; when `$SKILLS_ROOT` is set, it is mounted in read-only mode
Expand Down
33 changes: 33 additions & 0 deletions docs/mkdocs/zh/code_executor.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,39 @@ cfg = CubeCodeExecutorConfig(
executor = await CubeCodeExecutor.create(cfg)
```

### 腾讯云 Agent Sandbox

腾讯云 Agent Sandbox(AGS)通过地域域名提供 E2B 兼容接口。使用
`TencentAGSClientConfig` 可以直接配置该域名,无需手工将其转换成
`api_url`:

```python
from trpc_agent_sdk.code_executors.cube import (
TencentAGSClientConfig,
create_cube_sandbox_client,
create_cube_workspace_runtime,
)

cfg = TencentAGSClientConfig(
template="your-template-id",
api_key="your-ags-api-key",
domain="ap-guangzhou.tencentags.com",
request_timeout=60,
metadata={"application": "my-agent"},
auto_recover=True,
)
sandbox_client = await create_cube_sandbox_client(cfg)
runtime = create_cube_workspace_runtime(sandbox_client=sandbox_client)
```

`domain`、`api_key` 和 `template` 也可以分别通过 `E2B_DOMAIN`、
`E2B_API_KEY` 和 `CUBE_TEMPLATE_ID` 提供。AGS API Key 默认关闭 E2B SDK
的本地格式校验,但 AGS 服务端鉴权仍然生效。`domain` 必须是裸域名;完整 URL
请通过 `api_url` 传入。选择 domain 时,配置还会显式传入派生的
`https://api.<domain>` 控制面 URL,避免残留的 `E2B_API_URL` 覆盖它。显式
`api_url` 用于选择控制面端点;同时提供 `domain` 时,后者仍用于沙箱路由。
仅使用环境变量时,该提供商配置中的 `E2B_DOMAIN` 优先于 `E2B_API_URL`。

`CubeCodeExecutor` 也支持 `code_block_delimiters`,默认在标准的 `python`
和 `tool_code` 之外又加了一个 `bash` 分隔符,所以普通的
\`\`\`bash\n ... \n\`\`\` 围栏也能被识别。
Expand Down
2 changes: 1 addition & 1 deletion docs/mkdocs/zh/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -1102,7 +1102,7 @@ LLM 调用 skill_run(skill="python-math", command="python3 scripts/fib.py 10")
- **Cube 执行器**(远端 E2B 沙箱):[trpc_agent_sdk/code_executors/cube/_runtime.py](../../../trpc_agent_sdk/code_executors/cube/_runtime.py)
- 在远端 Cube/E2B 沙箱中执行;适合宿主上没有 Docker、或者需要强远端隔离的场景
- 通过 `create_cube_workspace_runtime(executor, workspace_cfg=...)` 构造;详见 [code_executor.md](code_executor.md#cubeworkspaceruntime)
- 需要安装可选 extra `[cube]`(`pip install 'trpc-agent-py[cube]'`),并配置 `E2B_API_URL` / `E2B_API_KEY` / `CUBE_TEMPLATE_ID` 环境变量(或对应 cfg 字段)
- 需要安装可选 extra `[cube]`(`pip install 'trpc-agent-py[cube]'`),并配置通用 E2B 地址(`E2B_API_URL`),或使用 `TencentAGSClientConfig` 配合 `E2B_DOMAIN`;凭证与模板可通过 `E2B_API_KEY` / `CUBE_TEMPLATE_ID` 或对应 cfg 字段提供

**容器执行器注意事项**:
- 运行基础目录可写;当设置了 `$SKILLS_ROOT` 时,会以只读方式挂载
Expand Down
187 changes: 187 additions & 0 deletions tests/code_executors/cube/test_tencent_ags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
#
# Copyright (C) 2026 Tencent. All rights reserved.
#
# tRPC-Agent-Python is licensed under Apache-2.0.
"""Tests for Tencent Cloud Agent Sandbox configuration."""

from __future__ import annotations

from unittest.mock import AsyncMock

import pytest
from e2b import ConnectionConfig

from trpc_agent_sdk.code_executors.cube import CubeSandboxClient
from trpc_agent_sdk.code_executors.cube import TencentAGSClientConfig


def _cfg(**overrides) -> TencentAGSClientConfig:
values = {
"template": "tmpl",
"api_key": "ark-secret",
"domain": "ap-guangzhou.tencentags.com",
"idle_timeout": 123,
"execute_timeout": 45.0,
}
values.update(overrides)
return TencentAGSClientConfig(**values)


class TestTencentAGSClientConfig:

def test_defaults_disable_local_api_key_format_validation(self):
assert _cfg().validate_api_key is False

def test_domain_falls_back_to_environment(self, monkeypatch):
monkeypatch.setenv("E2B_DOMAIN", "ap-shanghai.tencentags.com")
assert _cfg(domain=None).resolve_domain() == "ap-shanghai.tencentags.com"

def test_missing_endpoint_raises(self, monkeypatch):
monkeypatch.delenv("E2B_API_URL", raising=False)
monkeypatch.delenv("E2B_DOMAIN", raising=False)
with pytest.raises(ValueError, match="E2B_DOMAIN"):
_cfg(domain=None)._e2b_connection_kwargs()

@pytest.mark.parametrize("request_timeout", [0, -1.0])
def test_rejects_non_positive_request_timeout(self, request_timeout):
with pytest.raises(ValueError, match="request_timeout must be > 0"):
_cfg(request_timeout=request_timeout)

@pytest.mark.parametrize("request_timeout", [True, "30"])
def test_rejects_non_numeric_request_timeout(self, request_timeout):
with pytest.raises(TypeError, match="request_timeout must be a number"):
_cfg(request_timeout=request_timeout)

def test_rejects_non_bool_api_key_validation(self):
with pytest.raises(TypeError, match="validate_api_key must be a bool"):
_cfg(validate_api_key=0)

@pytest.mark.parametrize("domain", [
"https://ap-guangzhou.tencentags.com",
"ap-guangzhou.tencentags.com/v1",
"ap-guangzhou.tencentags.com:443",
" ap-guangzhou.tencentags.com",
])
def test_rejects_domain_that_is_not_a_bare_hostname(self, domain):
with pytest.raises(ValueError, match="domain must be a bare hostname"):
_cfg(domain=domain)

def test_rejects_invalid_environment_domain_when_resolved(self, monkeypatch):
monkeypatch.setenv("E2B_DOMAIN", "https://ap-guangzhou.tencentags.com")
cfg = _cfg(domain=None, api_url=None)

with pytest.raises(ValueError, match="domain must be a bare hostname"):
cfg._e2b_connection_kwargs()


class TestTencentAGSClientConnectionOptions:

@pytest.mark.asyncio
async def test_create_uses_domain_and_creation_metadata(self, fake_e2b, fake_async_sandbox):
fake_e2b.AsyncSandbox.create = AsyncMock(return_value=fake_async_sandbox)
cfg = _cfg(
request_timeout=30.0,
metadata={"application": "agent-test"},
)

await CubeSandboxClient.open_new(cfg)

fake_e2b.AsyncSandbox.create.assert_awaited_once_with(
template="tmpl",
timeout=123,
api_url="https://api.ap-guangzhou.tencentags.com",
domain="ap-guangzhou.tencentags.com",
api_key="ark-secret",
validate_api_key=False,
request_timeout=30.0,
metadata={"application": "agent-test"},
)

@pytest.mark.asyncio
async def test_attach_omits_creation_only_options(self, fake_e2b, fake_async_sandbox):
fake_e2b.AsyncSandbox.connect = AsyncMock(return_value=fake_async_sandbox)
cfg = _cfg(
sandbox_id="sbx-42",
request_timeout=30.0,
metadata={"application": "agent-test"},
)

await CubeSandboxClient.open_existing(cfg)

fake_e2b.AsyncSandbox.connect.assert_awaited_once_with(
"sbx-42",
api_url="https://api.ap-guangzhou.tencentags.com",
domain="ap-guangzhou.tencentags.com",
api_key="ark-secret",
validate_api_key=False,
request_timeout=30.0,
)

@pytest.mark.asyncio
async def test_explicit_api_url_takes_precedence_over_domain(self, fake_e2b, fake_async_sandbox):
fake_e2b.AsyncSandbox.create = AsyncMock(return_value=fake_async_sandbox)

await CubeSandboxClient.open_new(_cfg(api_url="https://private-gateway.example.com"))

kwargs = fake_e2b.AsyncSandbox.create.await_args.kwargs
assert kwargs["api_url"] == "https://private-gateway.example.com"
assert kwargs["domain"] == "ap-guangzhou.tencentags.com"

@pytest.mark.asyncio
async def test_explicit_domain_takes_precedence_over_environment_api_url(self, fake_e2b, fake_async_sandbox,
monkeypatch):
fake_e2b.AsyncSandbox.create = AsyncMock(return_value=fake_async_sandbox)
monkeypatch.setenv("E2B_API_URL", "https://stale.example.com")

await CubeSandboxClient.open_new(_cfg(domain="ap-shanghai.tencentags.com"))

kwargs = fake_e2b.AsyncSandbox.create.await_args.kwargs
assert kwargs["domain"] == "ap-shanghai.tencentags.com"
assert kwargs["api_url"] == "https://api.ap-shanghai.tencentags.com"

connection_kwargs = _cfg(domain="ap-shanghai.tencentags.com")._e2b_connection_kwargs()
sdk_config = ConnectionConfig(
api_key=connection_kwargs["api_key"],
api_url=connection_kwargs["api_url"],
domain=connection_kwargs["domain"],
)
assert sdk_config.domain == "ap-shanghai.tencentags.com"
assert sdk_config.api_url == "https://api.ap-shanghai.tencentags.com"

@pytest.mark.asyncio
async def test_environment_domain_takes_precedence_over_environment_api_url(self, fake_e2b, fake_async_sandbox,
monkeypatch):
fake_e2b.AsyncSandbox.create = AsyncMock(return_value=fake_async_sandbox)
monkeypatch.setenv("E2B_DOMAIN", "ap-shanghai.tencentags.com")
monkeypatch.setenv("E2B_API_URL", "https://stale.example.com")

await CubeSandboxClient.open_new(_cfg(domain=None))

kwargs = fake_e2b.AsyncSandbox.create.await_args.kwargs
assert kwargs["domain"] == "ap-shanghai.tencentags.com"
assert kwargs["api_url"] == "https://api.ap-shanghai.tencentags.com"

@pytest.mark.asyncio
async def test_environment_api_url_is_used_when_endpoint_fields_are_empty(self, fake_e2b, fake_async_sandbox,
monkeypatch):
fake_e2b.AsyncSandbox.create = AsyncMock(return_value=fake_async_sandbox)
monkeypatch.delenv("E2B_DOMAIN", raising=False)
monkeypatch.setenv("E2B_API_URL", "https://env-gateway.example.com")

await CubeSandboxClient.open_new(_cfg(domain=None))

kwargs = fake_e2b.AsyncSandbox.create.await_args.kwargs
assert kwargs["api_url"] == "https://env-gateway.example.com"
assert "domain" not in kwargs

@pytest.mark.asyncio
async def test_metadata_is_copied_before_passing_to_sdk(self, fake_e2b, fake_async_sandbox):
fake_e2b.AsyncSandbox.create = AsyncMock(return_value=fake_async_sandbox)
metadata = {"application": "agent-test"}

await CubeSandboxClient.open_new(_cfg(metadata=metadata))

passed_metadata = fake_e2b.AsyncSandbox.create.await_args.kwargs["metadata"]
assert passed_metadata == metadata
assert passed_metadata is not metadata
2 changes: 2 additions & 0 deletions trpc_agent_sdk/code_executors/cube/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ._sandbox import CubeCommandResult
from ._sandbox import CubeSandboxClient
from ._sandbox import create_cube_sandbox_client
from ._tencent_ags import TencentAGSClientConfig
from ._transfer import OnExisting
from ._types import CubeClientConfig
from ._types import CubeCodeExecutorConfig
Expand All @@ -39,6 +40,7 @@
"CubeWorkspaceManager",
"CubeWorkspaceRuntime",
"CubeWorkspaceRuntimeConfig",
"TencentAGSClientConfig",
"OnExisting",
"create_cube_workspace_runtime",
]
13 changes: 2 additions & 11 deletions trpc_agent_sdk/code_executors/cube/_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,7 @@ def sandbox_id(self) -> str:
@classmethod
async def open_new(cls, cfg: CubeClientConfig) -> "CubeSandboxClient":
"""Create a brand-new remote sandbox."""
sbx = await e2b.AsyncSandbox.create(
template=cfg.resolve_template(),
api_url=cfg.resolve_api_url(),
api_key=cfg.resolve_api_key(),
timeout=cfg.idle_timeout,
)
sbx = await e2b.AsyncSandbox.create(**cfg._e2b_create_kwargs())
return cls(sbx, cfg)

@classmethod
Expand All @@ -164,11 +159,7 @@ async def open_existing(
"""
if not cfg.sandbox_id:
raise ValueError("CubeSandboxClient.open_existing requires cfg.sandbox_id")
sbx = await e2b.AsyncSandbox.connect(
cfg.sandbox_id,
api_url=cfg.resolve_api_url(),
api_key=cfg.resolve_api_key(),
)
sbx = await e2b.AsyncSandbox.connect(cfg.sandbox_id, **cfg._e2b_connection_kwargs())
client = cls(sbx, cfg)
await client.assert_running()
return client
Expand Down
Loading
Loading