diff --git a/docs/mkdocs/en/code_executor.md b/docs/mkdocs/en/code_executor.md index a41eef25d..8c59674bb 100644 --- a/docs/mkdocs/en/code_executor.md +++ b/docs/mkdocs/en/code_executor.md @@ -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.` 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\`\`\`` diff --git a/docs/mkdocs/en/skill.md b/docs/mkdocs/en/skill.md index dcdc2eaae..07b137deb 100644 --- a/docs/mkdocs/en/skill.md +++ b/docs/mkdocs/en/skill.md @@ -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 diff --git a/docs/mkdocs/zh/code_executor.md b/docs/mkdocs/zh/code_executor.md index 30af7405e..b1e8bd8a7 100644 --- a/docs/mkdocs/zh/code_executor.md +++ b/docs/mkdocs/zh/code_executor.md @@ -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.` 控制面 URL,避免残留的 `E2B_API_URL` 覆盖它。显式 +`api_url` 用于选择控制面端点;同时提供 `domain` 时,后者仍用于沙箱路由。 +仅使用环境变量时,该提供商配置中的 `E2B_DOMAIN` 优先于 `E2B_API_URL`。 + `CubeCodeExecutor` 也支持 `code_block_delimiters`,默认在标准的 `python` 和 `tool_code` 之外又加了一个 `bash` 分隔符,所以普通的 \`\`\`bash\n ... \n\`\`\` 围栏也能被识别。 diff --git a/docs/mkdocs/zh/skill.md b/docs/mkdocs/zh/skill.md index abc60e02a..566877f56 100644 --- a/docs/mkdocs/zh/skill.md +++ b/docs/mkdocs/zh/skill.md @@ -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` 时,会以只读方式挂载 diff --git a/tests/code_executors/cube/test_tencent_ags.py b/tests/code_executors/cube/test_tencent_ags.py new file mode 100644 index 000000000..b9c3e98f0 --- /dev/null +++ b/tests/code_executors/cube/test_tencent_ags.py @@ -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 diff --git a/trpc_agent_sdk/code_executors/cube/__init__.py b/trpc_agent_sdk/code_executors/cube/__init__.py index 47fe50e89..9857b71b6 100644 --- a/trpc_agent_sdk/code_executors/cube/__init__.py +++ b/trpc_agent_sdk/code_executors/cube/__init__.py @@ -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 @@ -39,6 +40,7 @@ "CubeWorkspaceManager", "CubeWorkspaceRuntime", "CubeWorkspaceRuntimeConfig", + "TencentAGSClientConfig", "OnExisting", "create_cube_workspace_runtime", ] diff --git a/trpc_agent_sdk/code_executors/cube/_sandbox.py b/trpc_agent_sdk/code_executors/cube/_sandbox.py index e6d432efb..c594a6059 100644 --- a/trpc_agent_sdk/code_executors/cube/_sandbox.py +++ b/trpc_agent_sdk/code_executors/cube/_sandbox.py @@ -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 @@ -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 diff --git a/trpc_agent_sdk/code_executors/cube/_tencent_ags.py b/trpc_agent_sdk/code_executors/cube/_tencent_ags.py new file mode 100644 index 000000000..6d015199c --- /dev/null +++ b/trpc_agent_sdk/code_executors/cube/_tencent_ags.py @@ -0,0 +1,119 @@ +# 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. +"""Tencent Cloud Agent Sandbox configuration for the Cube/E2B client.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any +from typing import Mapping +from typing import Optional + +from ._types import CubeClientConfig + +ENV_TENCENT_AGS_DOMAIN = "E2B_DOMAIN" + + +@dataclass +class TencentAGSClientConfig(CubeClientConfig): + """Connect :class:`CubeSandboxClient` to Tencent Cloud Agent Sandbox. + + Tencent AGS exposes an E2B-compatible endpoint selected by ``domain``. + Its API keys do not use the public E2B key format, so local key-format + validation is disabled by default while remote authentication remains in + force. + + An explicit ``api_url`` takes precedence over an explicit ``domain``. + Environment values are consulted only when neither endpoint field is set, + preserving the usual explicit-configuration-over-environment precedence. + The API URL form supports private gateways and proxies without requiring a + second provider-specific configuration type. + """ + + domain: Optional[str] = None + """Tencent AGS domain. Falls back to ``E2B_DOMAIN``.""" + + validate_api_key: bool = False + """Whether the E2B SDK should validate the API key format locally.""" + + request_timeout: Optional[float] = None + """Optional E2B control-plane request timeout in seconds.""" + + metadata: Optional[Mapping[str, str]] = None + """Metadata attached when a new sandbox is created.""" + + def __post_init__(self) -> None: + super().__post_init__() + if self.domain is not None: + self._validate_domain(self.domain) + if not isinstance(self.validate_api_key, bool): + raise TypeError("validate_api_key must be a bool") + if self.request_timeout is not None: + if isinstance(self.request_timeout, bool) or not isinstance(self.request_timeout, (int, float)): + raise TypeError("request_timeout must be a number of seconds") + if self.request_timeout <= 0: + raise ValueError("request_timeout must be > 0 seconds") + + def resolve_domain(self) -> str: + """Resolve the Tencent AGS domain from the field or environment.""" + value = self.domain or os.getenv(ENV_TENCENT_AGS_DOMAIN) + if not value: + raise ValueError("Tencent AGS requires `domain`, E2B_DOMAIN, or an explicit " + "`api_url` / E2B_API_URL.") + return self._validate_domain(value) + + @staticmethod + def _validate_domain(value: str) -> str: + if not isinstance(value, str): + raise TypeError("domain must be a string containing a bare hostname") + if (not value or value != value.strip() or any(char.isspace() for char in value) + or any(marker in value for marker in (":", "/", "\\", "?", "#"))): + raise ValueError("domain must be a bare hostname such as " + "'ap-guangzhou.tencentags.com'; use api_url for a full URL") + return value + + @staticmethod + def _api_url_for_domain(domain: str) -> str: + """Build the control-plane URL using the E2B SDK's domain rule.""" + return f"https://api.{domain}" + + def _e2b_connection_kwargs(self) -> dict[str, Any]: + configured_domain = self.domain or os.getenv(ENV_TENCENT_AGS_DOMAIN) + domain = self.resolve_domain() if configured_domain else None + + if self.api_url: + endpoint = {"api_url": self.api_url} + if domain is not None: + endpoint["domain"] = domain + elif domain is not None: + # ConnectionConfig resolves api_url as: explicit value, + # E2B_API_URL, then https://api.. Pass the derived URL + # explicitly so a stale E2B_API_URL cannot override this domain. + endpoint = { + "api_url": self._api_url_for_domain(domain), + "domain": domain, + } + else: + try: + endpoint = {"api_url": self.resolve_api_url()} + except ValueError as exc: + raise ValueError("Tencent AGS requires `domain`, E2B_DOMAIN, or an explicit " + "`api_url` / E2B_API_URL.") from exc + kwargs: dict[str, Any] = { + **endpoint, + "api_key": self.resolve_api_key(), + "validate_api_key": self.validate_api_key, + } + if self.request_timeout is not None: + kwargs["request_timeout"] = self.request_timeout + return kwargs + + def _e2b_create_kwargs(self) -> dict[str, Any]: + kwargs = super()._e2b_create_kwargs() + if self.metadata is not None: + kwargs["metadata"] = dict(self.metadata) + return kwargs diff --git a/trpc_agent_sdk/code_executors/cube/_types.py b/trpc_agent_sdk/code_executors/cube/_types.py index 4d25c609b..9a8b04a53 100644 --- a/trpc_agent_sdk/code_executors/cube/_types.py +++ b/trpc_agent_sdk/code_executors/cube/_types.py @@ -9,6 +9,7 @@ import os from dataclasses import dataclass +from typing import Any from typing import Optional DEFAULT_REMOTE_WORKSPACE = "/workspace/cube_agent" @@ -104,6 +105,26 @@ def resolve_api_key(self) -> str: raise ValueError(f"Cube sandbox requires `api_key` or {ENV_API_KEY} env.") return value + def _e2b_connection_kwargs(self) -> dict[str, Any]: + """Build SDK options shared by sandbox creation and attachment. + + This is the provider adaptation seam for E2B-compatible services. + Subclasses may select a different endpoint mechanism or add transport + options without coupling :class:`CubeSandboxClient` to a provider. + """ + return { + "api_url": self.resolve_api_url(), + "api_key": self.resolve_api_key(), + } + + def _e2b_create_kwargs(self) -> dict[str, Any]: + """Build SDK options for creating a sandbox.""" + return { + "template": self.resolve_template(), + "timeout": self.idle_timeout, + **self._e2b_connection_kwargs(), + } + # Deprecated, will be removed in the future CubeCodeExecutorConfig = CubeClientConfig