From 1995baf20191acec54fcef36b3fc89f6a3fa8a78 Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Tue, 26 May 2026 20:29:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20skills=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=8F=AF=E8=87=AA=E5=8A=A8=E6=81=A2=E5=A4=8D=E7=9A=84?= =?UTF-8?q?=20Cube=20sandbox=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CubeClientConfig 和 create_cube_sandbox_client,统一 Cube sandbox client 创建入口。 - 在 CubeSandboxClient 中支持可选 auto_recover,sandbox 过期或不存在时自动创建新 sandbox,并重试当前失败操作一次。 - 将 Cube 返回的 “requested resource does not exist” 识别为 sandbox 失效信号,用于触发自动恢复。 - 支持通过 CubeSandboxClient 直接创建 CubeWorkspaceRuntime,并暴露 sandbox 生命周期辅助方法。 - 新增共享 workspace runtime resolver helper,并通过 repository.get_workspace_runtime(ctx) 统一获取 skill repository 的 runtime。 - 更新 skill load/run/exec/stager 路径,统一使用 repository 级 workspace runtime,确保同一 repository 下的工具共享同一个 workspace runtime 上下文。 --- examples/skills_with_cube/.env | 9 + examples/skills_with_cube/README.md | 99 ++++++++ examples/skills_with_cube/agent/__init__.py | 5 + examples/skills_with_cube/agent/agent.py | 41 +++ examples/skills_with_cube/agent/config.py | 19 ++ examples/skills_with_cube/agent/prompts.py | 36 +++ examples/skills_with_cube/agent/tools.py | 116 +++++++++ examples/skills_with_cube/run_agent.py | 146 +++++++++++ .../skills/python-math/SKILL.md | 31 +++ .../skills/python-math/scripts/fib.py | 24 ++ tests/code_executors/cube/test_bug_hunt.py | 7 +- .../code_executors/cube/test_code_executor.py | 12 +- tests/code_executors/cube/test_runtime.py | 43 ++++ tests/code_executors/cube/test_sandbox.py | 21 +- tests/skills/stager/test_base_stager.py | 18 ++ tests/skills/tools/test_copy_stager.py | 1 + tests/skills/tools/test_skill_load.py | 31 +++ tests/skills/tools/test_skill_run.py | 13 + trpc_agent_sdk/code_executors/__init__.py | 2 + .../code_executors/_base_workspace_runtime.py | 17 ++ .../code_executors/cube/__init__.py | 4 + .../code_executors/cube/_code_executor.py | 13 +- .../code_executors/cube/_runtime.py | 44 +++- .../code_executors/cube/_sandbox.py | 234 ++++++++++++------ trpc_agent_sdk/code_executors/cube/_types.py | 25 +- trpc_agent_sdk/skills/_repository.py | 12 +- trpc_agent_sdk/skills/_toolset.py | 1 + trpc_agent_sdk/skills/stager/_base_stager.py | 2 +- trpc_agent_sdk/skills/tools/_skill_exec.py | 2 +- trpc_agent_sdk/skills/tools/_skill_load.py | 4 +- trpc_agent_sdk/skills/tools/_skill_run.py | 20 +- .../skills/tools/_workspace_exec.py | 5 +- 32 files changed, 926 insertions(+), 131 deletions(-) create mode 100644 examples/skills_with_cube/.env create mode 100644 examples/skills_with_cube/README.md create mode 100644 examples/skills_with_cube/agent/__init__.py create mode 100644 examples/skills_with_cube/agent/agent.py create mode 100644 examples/skills_with_cube/agent/config.py create mode 100644 examples/skills_with_cube/agent/prompts.py create mode 100644 examples/skills_with_cube/agent/tools.py create mode 100644 examples/skills_with_cube/run_agent.py create mode 100644 examples/skills_with_cube/skills/python-math/SKILL.md create mode 100644 examples/skills_with_cube/skills/python-math/scripts/fib.py diff --git a/examples/skills_with_cube/.env b/examples/skills_with_cube/.env new file mode 100644 index 00000000..e24d24f8 --- /dev/null +++ b/examples/skills_with_cube/.env @@ -0,0 +1,9 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name + + +CUBE_TEMPLATE_ID=your-cube-template-id +E2B_API_URL=your-e2b-api-url +E2B_API_KEY=your-e2b-api-key diff --git a/examples/skills_with_cube/README.md b/examples/skills_with_cube/README.md new file mode 100644 index 00000000..6870353f --- /dev/null +++ b/examples/skills_with_cube/README.md @@ -0,0 +1,99 @@ +# Skills Cube 与 stage_inputs 示例 + +本示例演示在腾讯云 Agent 沙箱 Cube 工作区中执行 `skill_run`,并通过 `host://`、`workspace://`、`skill://` 等输入方案演示 `stage_inputs` 如何把本地输入上传/复制到远端沙箱。 + +## 关键特性 + +- `create_cube_sandbox_client(CubeClientConfig(auto_recover=True))`:创建远端 Cube 沙箱;底层 sandbox 过期/不存在时,`CubeSandboxClient` 会自动创建新 sandbox 并重试当前操作一次 +- `agent/tools.py` 中 `build_cube_skill_run_payload` 生成固定形态的 `skill_run` 负载供模型调用 +- `host://` 输入会从运行示例的本机路径上传到 Cube 沙箱,不依赖 Docker bind mount +- `run_agent.py` 会准备示例 `/tmp/skillrun-inputs/sales.csv`,demo 结束后销毁 Cube 沙箱 + +## Agent 层级结构说明 + +- 根节点:`LlmAgent`,挂载 `SkillToolSet`(Cube 运行时 + 技能仓库) +- 无子 Agent + +## 关键代码解释 + +- `agent/tools.py`:通过 `CubeClientConfig(auto_recover=True)` 创建 `CubeSandboxClient`,再通过 `create_cube_workspace_runtime` 创建 workspace runtime +- `agent/agent.py`:异步创建 agent,并把 workspace runtime 返回给 runner 做最终销毁 +- `run_agent.py`:组装含 `inputs` 数组的 JSON 提示词,驱动单次 `skill_run` 演示,并在 finally 中销毁沙箱 + +## 环境与运行 + +- Python 3.12;仓库根目录安装 Cube extra:`pip install -e '.[cube]'` +- 配置 `TRPC_AGENT_API_KEY`、`TRPC_AGENT_BASE_URL`、`TRPC_AGENT_MODEL_NAME` +- 配置 Cube 环境变量:`CUBE_TEMPLATE_ID`、`E2B_API_URL`、`E2B_API_KEY` +- 可选:`SKILLS_ROOT`、`CUBE_EXECUTE_TIMEOUT`(默认 `30`)、`CUBE_IDLE_TIMEOUT`(默认 `600`) + +```bash +cd examples/skills_with_cube +python3 run_agent.py +``` + +### 验证 sandbox 重建后的 Skill runtime 恢复 + +为了验证业务主动重建 sandbox 后,Skill 工具链仍能继续使用当前 workspace runtime,可以开启下面的环境变量: + +```bash +SKILLS_WITH_CUBE_RECREATE_BETWEEN_RUNS=1 python3 run_agent.py +``` + +该模式会连续发起两次相同的 `skill_run` 请求:第一次正常运行;第二次请求前通过 workspace runtime 主动重建 Cube sandbox。只要第二次请求也能正常完成,就说明 Skill 工具链可以继续使用新的 runtime,而不是继续访问过期 sandbox。 + +### 验证 sandbox 失效后的自动恢复 + +为了验证更接近真实场景的自动恢复路径,可以开启下面的临时测试开关: + +```bash +SKILLS_WITH_CUBE_KILL_BETWEEN_RUNS=1 python3 run_agent.py +``` + +该模式同样会连续发起两次相同的 `skill_run` 请求。第一次请求成功后,示例会直接 kill 远端 Cube sandbox,但保留本地 `CubeSandboxClient` 中的旧句柄。第二次请求继续使用旧句柄访问远端 sandbox,此时 Cube 会返回类似 `Code.unknown: The requested resource does not exist` 的错误;如果 `auto_recover=True` 生效,日志中会出现: + +```txt +Cube sandbox expired; recreating sandbox client: Code.unknown: The requested resource does not exist +Cube sandbox client using sandbox: +``` + +随后第二次 `skill_run` 仍应返回 `exit_code=0`,说明旧 sandbox 被平台/外部销毁后,`CubeSandboxClient` 已自动恢复并继续执行当前操作。 + +注意:`SKILLS_WITH_CUBE_KILL_BETWEEN_RUNS` 是为了验证恢复机制而加入的临时代码,会使用私有句柄直接 kill 远端 sandbox。正式提交示例或生产代码时可以删除该测试开关及对应 helper。 + +## 期望运行结果 + +```txt +[START] skills_with_cube +... +created Cube sandbox ... +... +🔧 [Invoke Tool:: skill_run({... 'inputs': [ + 'host:///tmp/skillrun-inputs/sales.csv', + 'workspace://skills/python-math/SKILL.md', + 'skill://python-math/scripts/fib.py', +], ...}) +📊 [Tool Result: { + 'stdout': '', 'stderr': '', 'exit_code': 0, + 'output_files': [ + {'name': 'out/fib.txt', 'content': '0\n1\n1\n2\n3\n5\n8\n13\n21\n34\n', ...}, + {'name': 'out/staged_inputs_tree.txt', 'content': + 'work/inputs:\nsales.csv\n---\nwork/staged_inputs:\nfib.py\npython-math_skill.md\n', ...}, + ], + ... +}] +... +``` + +## 结果分析(是否符合要求) + +符合本示例测试要求:Cube 沙箱成功创建并完成 `skill_run` 调用链;`host://` / `workspace://` / `skill://` 三种 input scheme 都成功落入远端工作区,输出文件 `out/fib.txt` 和 `out/staged_inputs_tree.txt` 正常产出,进程以 `exit_code=0` 结束。 + +如果使用 `SKILLS_WITH_CUBE_KILL_BETWEEN_RUNS=1`,还需要确认第二次请求中出现自动恢复日志,并且第二次 `skill_run` 仍然成功。这表示真实的“旧 sandbox 不存在 -> client 自动重建 -> 当前操作重试”链路通过。 + +## 适用场景建议 + +- 需要在远端 Cube 沙箱内执行技能、并验证本地输入上传到沙箱时参考本示例 +- 调试 `workspace://` 时应确保源文件已存在于当前 workspace,再复制或链接到目标路径 +- 长生命周期 agent 建议开启 `CubeClientConfig(auto_recover=True)`,避免 Cube sandbox 因超时或平台清理后导致后续技能调用持续失败 +- 自动恢复会创建全新的 sandbox,远端 workspace 内容不会自动从旧 sandbox 迁移;Skill staging 和 workspace 创建逻辑需要能在新 sandbox 上重新执行 diff --git a/examples/skills_with_cube/agent/__init__.py b/examples/skills_with_cube/agent/__init__.py new file mode 100644 index 00000000..bc6e483f --- /dev/null +++ b/examples/skills_with_cube/agent/__init__.py @@ -0,0 +1,5 @@ +# 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. diff --git a/examples/skills_with_cube/agent/agent.py b/examples/skills_with_cube/agent/agent.py new file mode 100644 index 00000000..2adb7f8d --- /dev/null +++ b/examples/skills_with_cube/agent/agent.py @@ -0,0 +1,41 @@ +# 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. +"""Agent for Cube-backed skill runs.""" + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import OpenAIModel + +from .config import get_model_config +from .prompts import INSTRUCTION +from .tools import create_skill_tool_set + + +def _create_model() -> LLMModel: + """ Create a model""" + api_key, url, model_name = get_model_config() + model = OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + return model + + +async def create_agent(): + """Create a Cube-backed skill run agent and its workspace runtime.""" + + # Create tools + skill_tool_set, skill_repository, workspace_runtime = await create_skill_tool_set() + agent = LlmAgent( + name="skill_run_agent_with_cube", + description="A professional skill run assistant that can use Agent Skills.", + model=_create_model(), + # Use state variables for template replacement - Demonstration of the {var} syntax + instruction=INSTRUCTION, + tools=[skill_tool_set], + skill_repository=skill_repository, + ) + return agent, workspace_runtime + + +root_agent = None diff --git a/examples/skills_with_cube/agent/config.py b/examples/skills_with_cube/agent/config.py new file mode 100644 index 00000000..db0d491b --- /dev/null +++ b/examples/skills_with_cube/agent/config.py @@ -0,0 +1,19 @@ +# 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. +""" Agent config module""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Get model config from environment variables""" + api_key = os.getenv('TRPC_AGENT_API_KEY', '') + url = os.getenv('TRPC_AGENT_BASE_URL', '') + model_name = os.getenv('TRPC_AGENT_MODEL_NAME', '') + if not api_key or not url or not model_name: + raise ValueError('''TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, + and TRPC_AGENT_MODEL_NAME must be set in environment variables''') + return api_key, url, model_name diff --git a/examples/skills_with_cube/agent/prompts.py b/examples/skills_with_cube/agent/prompts.py new file mode 100644 index 00000000..690c7fd1 --- /dev/null +++ b/examples/skills_with_cube/agent/prompts.py @@ -0,0 +1,36 @@ +# 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. +""" prompts for agent""" + +INSTRUCTION = """ +Be a concise, helpful assistant that can use Agent Skills. + +When a task may need tools, first ask to list skills or suggest one. +Load a skill only when needed, then run commands from its docs exactly. +Prefer safe defaults; ask clarifying questions if anything is ambiguous. +When running, include output_files patterns if files are expected. +Summarize results, note saved files, and propose next steps briefly. + +Inside a Cube skill workspace, inputs staged from host:// are uploaded +copies in the remote sandbox. Treat inputs/ and work/inputs/ as input +data and write new results under out/ or $OUTPUT_DIR. + +When chaining multiple skills, read previous results directly from +out/ (or $OUTPUT_DIR) and write new files back to out/. Prefer using +skill_run inputs/outputs fields to map files instead of shell commands +like cp or mv where possible. + +When using a skill, follow this workflow: +1. First call skill_load to load the skill documentation +2. Always call skill_list_docs immediately after skill_load to verify what documents have been loaded, + including documents from subdirectories (e.g., references/ folder) +3. If needed, use skill_select_docs to add additional documents +4. Call skill_list_docs again after skill_select_docs to confirm the final set of loaded documents +5. Finally use skill_run to execute commands + +This ensures you can verify that all relevant documentation files, including those in subdirectories, +are properly loaded before executing commands. +""" diff --git a/examples/skills_with_cube/agent/tools.py b/examples/skills_with_cube/agent/tools.py new file mode 100644 index 00000000..35150e32 --- /dev/null +++ b/examples/skills_with_cube/agent/tools.py @@ -0,0 +1,116 @@ +# 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. +"""Tools for the Cube-backed skill agent.""" +import os +from pathlib import Path +from typing import Any + +from trpc_agent_sdk.code_executors import WorkspaceInputSpec +from trpc_agent_sdk.code_executors.cube import CubeClientConfig +from trpc_agent_sdk.code_executors.cube import CubeWorkspaceRuntime +from trpc_agent_sdk.code_executors.cube import CubeWorkspaceRuntimeConfig +from trpc_agent_sdk.code_executors.cube import create_cube_sandbox_client +from trpc_agent_sdk.code_executors.cube import create_cube_workspace_runtime +from trpc_agent_sdk.skills import ENV_SKILLS_ROOT +from trpc_agent_sdk.skills import SkillToolSet +from trpc_agent_sdk.skills import create_default_skill_repository + + +def _get_skill_paths() -> str: + """Get the skill paths.""" + skills_root = os.getenv(ENV_SKILLS_ROOT) + if skills_root: + return skills_root + current_path = Path(__file__).parent + path = str(current_path.parent / "skills") + # convert to file URL + # path = "file://" + path + # "http://{host}:{port}/{path}/{filename}.{extension}" + # path = "http://localhost:8000/skills/skills.tar.gz" + return path + + +def _cube_client_config() -> CubeClientConfig: + """Build Cube executor config from environment variables.""" + return CubeClientConfig( + execute_timeout=float(os.getenv("CUBE_EXECUTE_TIMEOUT", "30")), + idle_timeout=int(os.getenv("CUBE_IDLE_TIMEOUT", "600")), + auto_recover=True, + ) + + +async def create_skill_tool_set() -> tuple[SkillToolSet, Any, CubeWorkspaceRuntime]: + """Create a Cube-backed skill tool set and its Cube runtime.""" + tool_kwargs = { + "save_as_artifacts": True, + "omit_inline_content": False, + } + + cfg = _cube_client_config() + sandbox_client = await create_cube_sandbox_client(cfg) + workspace_runtime = create_cube_workspace_runtime( + sandbox_client=sandbox_client, + execute_timeout=cfg.execute_timeout, + workspace_cfg=CubeWorkspaceRuntimeConfig(), + ) + print(f"[skills_with_cube] using Cube sandbox: {workspace_runtime.sandbox_id}", flush=True) + skill_paths = _get_skill_paths() + repository = create_default_skill_repository(skill_paths, workspace_runtime=workspace_runtime) + toolset = SkillToolSet(repository=repository, run_tool_kwargs=tool_kwargs) + return toolset, repository, workspace_runtime + + +def build_cube_stage_inputs_specs(inputs_host: str = "/tmp/skillrun-inputs") -> list[WorkspaceInputSpec]: + """Build example input specs for Cube runtime. + + The returned specs demonstrate the supported input schemes used by + ``CubeWorkspaceFS.stage_inputs``: + + - ``host://`` : upload from a host path into the remote Cube sandbox + - ``workspace://``: reuse a file already present in current workspace + - ``skill://`` : reference a file under workspace ``skills/`` + """ + return [ + WorkspaceInputSpec( + src=f"host://{inputs_host}/sales.csv", + dst="work/inputs/sales.csv", + mode="link", + ), + WorkspaceInputSpec( + # This file exists after skill staging, so the workspace:// demo is stable. + src="workspace://skills/python-math/SKILL.md", + dst="work/staged_inputs/python-math_skill.md", + mode="copy", + ), + WorkspaceInputSpec( + src="skill://python-math/scripts/fib.py", + dst="work/staged_inputs/fib.py", + mode="copy", + ), + ] + + +def build_cube_skill_run_payload(skill_name: str = "python-math", + inputs_host: str = "/tmp/skillrun-inputs") -> dict[str, Any]: + """Build a full ``skill_run`` payload for Cube mode demonstration. + + This payload can be used directly when invoking the ``skill_run`` tool: + it stages input schemes into the remote Cube workspace and writes outputs + under ``out/``. + """ + return { + "skill": + skill_name, + "cwd": + f"$SKILLS_DIR/{skill_name}", + "command": ("python scripts/fib.py 10 > out/fib.txt && " + "(ls -R work/inputs; echo '---'; ls -R work/staged_inputs) > out/staged_inputs_tree.txt"), + "inputs": [spec.model_dump() for spec in build_cube_stage_inputs_specs(inputs_host=inputs_host)], + "output_files": [ + "out/fib.txt", + "out/staged_inputs_tree.txt", + ], + } diff --git a/examples/skills_with_cube/run_agent.py b/examples/skills_with_cube/run_agent.py new file mode 100644 index 00000000..017e651e --- /dev/null +++ b/examples/skills_with_cube/run_agent.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +# 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. +""" +Example demonstrating the skills run flow in TRPC Agent framework. +""" +import asyncio +import json +import os +import uuid + +from dotenv import load_dotenv +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +load_dotenv() + + +async def _kill_remote_sandbox_for_auto_recover_test(workspace_runtime) -> None: + """Delete the remote Cube sandbox without clearing the local client. + + This is intentionally test-only code: keeping the stale local handle lets + the next workspace operation hit SandboxNotFoundException and exercise the + CubeSandboxClient auto-recovery path. + """ + client = workspace_runtime._client # pylint: disable=protected-access + sandbox = client._require() # pylint: disable=protected-access + old_sandbox_id = sandbox.sandbox_id + await sandbox.kill() + print( + f"[skills_with_cube] killed remote Cube sandbox for auto-recover test: {old_sandbox_id}", + flush=True, + ) + + +async def run_skill_run_demo(): + """Run the skill run agent demo to demonstrate the various capabilities of an LLM agent.""" + + app_name = "skill_run_agent_demo" + + from agent.agent import create_agent + from agent.tools import build_cube_skill_run_payload + + root_agent, runtime_handle = await create_agent() + session_service = InMemorySessionService() + runner = Runner(app_name=app_name, agent=root_agent, session_service=session_service) + + user_id = "demo_user" + + cube_payload = build_cube_skill_run_payload( + skill_name="python-math", + inputs_host="/tmp/skillrun-inputs", + ) + cube_stage_inputs_request = f""" + Cube stage_inputs demonstration. + Please call skill_run once using this payload shape exactly: + {json.dumps(cube_payload, ensure_ascii=False)} + + Notes: + 1) The current runtime is Cube, so host:// inputs are uploaded into the remote sandbox. + 2) If artifact service is unavailable, continue with host://, workspace://, skill://. + 3) After running, explain which input schemes were staged successfully and include output file summaries. + """ + + demo_queries = [cube_stage_inputs_request] + recreate_between_runs = os.getenv("SKILLS_WITH_CUBE_RECREATE_BETWEEN_RUNS", "").lower() in {"1", "true", "yes"} + kill_between_runs = os.getenv("SKILLS_WITH_CUBE_KILL_BETWEEN_RUNS", "").lower() in {"1", "true", "yes"} + if recreate_between_runs or kill_between_runs: + demo_queries.append(cube_stage_inputs_request) + + try: + for idx, query in enumerate(demo_queries): + if idx == 1: + if kill_between_runs: + print("[skills_with_cube] killing Cube sandbox before the next request...", flush=True) + await _kill_remote_sandbox_for_auto_recover_test(runtime_handle) + else: + print("[skills_with_cube] recreating Cube sandbox before the next request...", flush=True) + await runtime_handle.recreate() + print(f"[skills_with_cube] using Cube sandbox: {runtime_handle.sandbox_id}", flush=True) + + current_session_id = str(uuid.uuid4()) + + print(f"🆔 Session ID: {current_session_id[:8]}...") + print(f"📝 User: {query}") + + user_content = Content(parts=[Part.from_text(text=query)]) + + print("🤖 Assistant: ", end="", flush=True) + async for event in runner.run_async(user_id=user_id, session_id=current_session_id, new_message=user_content): + if not event.content or not event.content.parts: + continue + + for part in event.content.parts: + if event.partial: + if part.text: + print(part.text, end="", flush=True) + continue + + if part.thought: + continue + if part.function_call: + print(f"\n🔧 [Invoke Tool:: {part.function_call.name}({part.function_call.args})]") + elif part.function_response: + print(f"📊 [Tool Result: {part.function_response.response}]") + # elif part.text: + # print(f"\n✅ {part.text}") + + print("\n" + "-" * 40) + finally: + await runner.close() + await runtime_handle.destroy() + + +if __name__ == "__main__": + os.system("echo 'hello from skillrun' > /tmp/skillrun-notes.txt") + os.system("echo 'this is another line' >> /tmp/skillrun-notes.txt") + os.system("mkdir -p /tmp/skillrun-inputs") + os.system("""cat > /tmp/skillrun-inputs/sales.csv << 'EOF' +region,amount +north,100 +south,200 +EOF +""") + # Create sample CSV file for data analysis skill + os.system("""cat > /tmp/sales_data.csv << 'EOF' +Date,Product,Sales,Quantity,Region +2024-01-01,Product A,1000,10,North +2024-01-02,Product B,1500,15,South +2024-01-03,Product A,1200,12,North +2024-01-04,Product C,800,8,East +2024-01-05,Product B,2000,20,South +2024-01-06,Product A,900,9,West +2024-01-07,Product C,1100,11,East +2024-01-08,Product B,1800,18,North +EOF +""") + asyncio.run(run_skill_run_demo()) + os.system("rm -rf /tmp/skillrun-inputs/*") + os.system("rm -rf /tmp/sales_data.csv") diff --git a/examples/skills_with_cube/skills/python-math/SKILL.md b/examples/skills_with_cube/skills/python-math/SKILL.md new file mode 100644 index 00000000..e6a53dec --- /dev/null +++ b/examples/skills_with_cube/skills/python-math/SKILL.md @@ -0,0 +1,31 @@ +--- +name: python-math +description: Small Python utilities for math and text files. +--- + +Overview + +Run short Python scripts inside the skill workspace. Results can be +returned as text and saved as output files. + +Examples + +1) Print the first N Fibonacci numbers + + Command: + + python3 scripts/fib.py 10 > out/fib.txt + +2) Sum a list of integers + + Command: + + python3 - <<'PY' +from sys import stdin +nums = [int(x) for x in stdin.read().split()] +print(sum(nums)) +PY + +Output Files + +- out/fib.txt diff --git a/examples/skills_with_cube/skills/python-math/scripts/fib.py b/examples/skills_with_cube/skills/python-math/scripts/fib.py new file mode 100644 index 00000000..7f2933fa --- /dev/null +++ b/examples/skills_with_cube/skills/python-math/scripts/fib.py @@ -0,0 +1,24 @@ +# 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. + +import sys + + +def fib(n: int): + a, b = 0, 1 + for _ in range(n): + print(a) + a, b = b, a + b + + +if __name__ == "__main__": + n = 10 + if len(sys.argv) > 1: + try: + n = int(sys.argv[1]) + except Exception: + n = 10 + fib(n) diff --git a/tests/code_executors/cube/test_bug_hunt.py b/tests/code_executors/cube/test_bug_hunt.py index a2c54f9d..e3229c9e 100644 --- a/tests/code_executors/cube/test_bug_hunt.py +++ b/tests/code_executors/cube/test_bug_hunt.py @@ -569,7 +569,8 @@ async def test_bug12_commands_run_translates_timeout_to_structured_result( side_effect=fake_e2b.TimeoutException() ) client = CubeSandboxClient( - fake_async_sandbox, idle_timeout=60, execute_timeout=30.0, + fake_async_sandbox, + CubeCodeExecutorConfig(template="t", api_url="u", api_key="k", idle_timeout=60, execute_timeout=30.0), ) result = await client.commands_run("sleep 9999", timeout=1.5) assert isinstance(result, CubeCommandResult) @@ -608,13 +609,11 @@ async def test_bug12_execute_code_surfaces_deadline_exceeded_outcome( fake_async_sandbox.commands.run = AsyncMock( side_effect=fake_e2b.TimeoutException() ) - client = CubeSandboxClient( - fake_async_sandbox, idle_timeout=60, execute_timeout=2.0, - ) cfg = CubeCodeExecutorConfig( template="t", api_url="u", api_key="k", idle_timeout=60, execute_timeout=2.0, ) + client = CubeSandboxClient(fake_async_sandbox, cfg) executor = CubeCodeExecutor(client, cfg) # execute_code MUST return a result, not raise. diff --git a/tests/code_executors/cube/test_code_executor.py b/tests/code_executors/cube/test_code_executor.py index 2b014ed2..24d4e62f 100644 --- a/tests/code_executors/cube/test_code_executor.py +++ b/tests/code_executors/cube/test_code_executor.py @@ -125,7 +125,7 @@ async def test_no_sandbox_id_opens_new(self, fake_e2b, monkeypatch, mock_client) monkeypatch.setattr( CubeSandboxClient, "open_existing", - classmethod(lambda cls, sid, cfg: open_existing(sid, cfg)), + classmethod(lambda cls, cfg: open_existing(cfg)), ) cfg = _cfg(sandbox_id=None) ex = await CubeCodeExecutor.create(cfg) @@ -143,12 +143,12 @@ async def test_with_sandbox_id_opens_existing(self, fake_e2b, monkeypatch, mock_ monkeypatch.setattr( CubeSandboxClient, "open_existing", - classmethod(lambda cls, sid, cfg: open_existing(sid, cfg)), + classmethod(lambda cls, cfg: open_existing(cfg)), ) cfg = _cfg(sandbox_id="sbx-42") await CubeCodeExecutor.create(cfg) open_new.assert_not_awaited() - open_existing.assert_awaited_once_with("sbx-42", cfg) + open_existing.assert_awaited_once_with(cfg) class TestAttach: @@ -164,11 +164,11 @@ async def test_with_sandbox_id_calls_open_existing(self, fake_e2b, monkeypatch, monkeypatch.setattr( CubeSandboxClient, "open_existing", - classmethod(lambda cls, sid, cfg: called(sid, cfg)), + classmethod(lambda cls, cfg: called(cfg)), ) cfg = _cfg(sandbox_id="sbx-1") ex = await CubeCodeExecutor.attach(cfg) - called.assert_awaited_once_with("sbx-1", cfg) + called.assert_awaited_once_with(cfg) assert ex.sandbox_client is mock_client @pytest.mark.asyncio @@ -183,7 +183,7 @@ async def test_never_calls_open_new(self, fake_e2b, monkeypatch): monkeypatch.setattr( CubeSandboxClient, "open_existing", - classmethod(lambda cls, sid, cfg: on_existing(sid, cfg)), + classmethod(lambda cls, cfg: on_existing(cfg)), ) with pytest.raises(RuntimeError, match="test stopper"): await CubeCodeExecutor.attach(_cfg(sandbox_id="sbx-1")) diff --git a/tests/code_executors/cube/test_runtime.py b/tests/code_executors/cube/test_runtime.py index d0719d4b..c2144f56 100644 --- a/tests/code_executors/cube/test_runtime.py +++ b/tests/code_executors/cube/test_runtime.py @@ -8,6 +8,7 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -772,3 +773,45 @@ def test_provider_and_flag_forwarded(self, mock_client): rt = create_cube_workspace_runtime(ex, provider=provider, enable_provider_env=True) assert rt._runner._run_env_provider is provider assert rt._runner._enable_provider_env is True + + +class TestCubeWorkspaceRuntimeAutoRecover: + + @pytest.mark.asyncio + async def test_recreates_and_retries_when_sandbox_is_missing(self, fake_e2b, monkeypatch): + cfg = CubeCodeExecutorConfig(template="tpl", api_url="url", api_key="key", auto_recover=True) + sandbox1 = MagicMock() + sandbox1.sandbox_id = "old" + sandbox1.kill = AsyncMock(return_value=None) + sandbox1.set_timeout = AsyncMock(return_value=None) + sandbox1.commands = MagicMock() + sandbox1.commands.run = AsyncMock(side_effect=fake_e2b.SandboxNotFoundException("gone")) + client1 = CubeSandboxClient(sandbox1, cfg) + + sandbox2 = MagicMock() + sandbox2.sandbox_id = "new" + sandbox2.set_timeout = AsyncMock(return_value=None) + sandbox2.commands = MagicMock() + sandbox2.commands.run = AsyncMock(return_value=SimpleNamespace(stdout="", stderr="", exit_code=0)) + client2 = CubeSandboxClient(sandbox2, cfg) + + executor1 = MagicMock() + executor1.config = cfg + executor1.sandbox_id = "old" + executor1.sandbox_client = client1 + + open_new = AsyncMock(return_value=client2) + monkeypatch.setattr(rt_mod.CubeSandboxClient, "open_new", open_new) + monkeypatch.setattr(rt_mod.time, "time_ns", lambda: 123) + + runtime = create_cube_workspace_runtime( + executor1, + workspace_cfg=CubeWorkspaceRuntimeConfig(), + ) + info = await runtime.manager().create_workspace("id") + + assert info.path == "/workspace/cube_agent/ws_id_123" + assert runtime.sandbox_id == "new" + open_new.assert_awaited_once_with(cfg) + sandbox1.kill.assert_awaited_once() + sandbox2.commands.run.assert_awaited_once() diff --git a/tests/code_executors/cube/test_sandbox.py b/tests/code_executors/cube/test_sandbox.py index 7ae806e8..41a82df0 100644 --- a/tests/code_executors/cube/test_sandbox.py +++ b/tests/code_executors/cube/test_sandbox.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +from dataclasses import replace from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -24,7 +25,7 @@ from trpc_agent_sdk.code_executors.cube import _sandbox from trpc_agent_sdk.code_executors.cube._sandbox import ( CubeCommandResult, - CubeSandboxClient, + CubeSandboxClient as _CubeSandboxClient, ) from trpc_agent_sdk.code_executors.cube._types import CubeCodeExecutorConfig @@ -41,6 +42,24 @@ def _cfg(**overrides) -> CubeCodeExecutorConfig: return CubeCodeExecutorConfig(**base) +class CubeSandboxClient(_CubeSandboxClient): + """Test adapter for constructing clients from timeout kwargs.""" + + def __init__(self, sandbox, cfg=None, *, idle_timeout=None, execute_timeout=None): + if cfg is None: + cfg = _cfg( + idle_timeout=idle_timeout if idle_timeout is not None else 60, + execute_timeout=execute_timeout if execute_timeout is not None else 30.0, + ) + super().__init__(sandbox, cfg) + + @classmethod + async def open_existing(cls, sandbox_id_or_cfg, cfg=None): + if cfg is None: + return await super().open_existing(sandbox_id_or_cfg) + return await super().open_existing(replace(cfg, sandbox_id=sandbox_id_or_cfg)) + + # --------------------------------------------------------------------------- # Construction & sandbox_id # --------------------------------------------------------------------------- diff --git a/tests/skills/stager/test_base_stager.py b/tests/skills/stager/test_base_stager.py index aecc7743..bb2f0dc8 100644 --- a/tests/skills/stager/test_base_stager.py +++ b/tests/skills/stager/test_base_stager.py @@ -64,6 +64,7 @@ def _make_repository(path="/skills/test-skill"): repo = MagicMock() repo.path = MagicMock(return_value=path) repo.workspace_runtime = _make_runtime() + repo.get_workspace_runtime = MagicMock(return_value=repo.workspace_runtime) return repo @@ -270,6 +271,23 @@ async def test_fresh_staging(self, mock_digest): result = await stager.stage_skill(request) assert result.workspace_skill_dir == "skills/test-skill" + @patch("trpc_agent_sdk.skills.stager._base_stager.compute_dir_digest", return_value="new_digest") + async def test_stage_skill_uses_repository_runtime(self, mock_digest): + stager = Stager() + repo = _make_repository() + request = _make_request(repo=repo) + runtime = repo.get_workspace_runtime.return_value + + mock_file = MagicMock() + mock_file.content = json.dumps({"version": 1, "skills": {}}) + runtime.fs(request.ctx).collect = AsyncMock(return_value=[mock_file]) + + result = await stager.stage_skill(request) + + assert result.workspace_skill_dir == "skills/test-skill" + repo.get_workspace_runtime.assert_called_once_with(request.ctx) + runtime.fs(request.ctx).stage_directory.assert_awaited_once() + @patch("trpc_agent_sdk.skills.stager._base_stager.compute_dir_digest", return_value="same_digest") async def test_cached_staging_with_links(self, mock_digest): stager = Stager() diff --git a/tests/skills/tools/test_copy_stager.py b/tests/skills/tools/test_copy_stager.py index fe02c80b..57876804 100644 --- a/tests/skills/tools/test_copy_stager.py +++ b/tests/skills/tools/test_copy_stager.py @@ -128,6 +128,7 @@ async def test_stage_delegates_to_parent(self, mock_digest): runtime.fs = MagicMock(return_value=fs) runtime.runner = MagicMock(return_value=runner) repo.workspace_runtime = runtime + repo.get_workspace_runtime = MagicMock(return_value=runtime) from trpc_agent_sdk.skills.stager._types import SkillStageRequest request = SkillStageRequest( diff --git a/tests/skills/tools/test_skill_load.py b/tests/skills/tools/test_skill_load.py index 7c4987bf..4d7e9d18 100644 --- a/tests/skills/tools/test_skill_load.py +++ b/tests/skills/tools/test_skill_load.py @@ -31,6 +31,7 @@ from trpc_agent_sdk.skills.tools._skill_load import ( SkillLoadTool, ) +from trpc_agent_sdk.skills.stager import SkillStageResult def _make_ctx(repository=None): @@ -115,6 +116,36 @@ def test_include_all_docs_sets_star(self): assert ctx.actions.state_delta[docs_state_key(ctx, "test-skill")] == "*" +class TestWorkspaceRuntimeResolver: + @pytest.mark.asyncio + async def test_ensure_staged_uses_repository_runtime(self): + repo_runtime = MagicMock() + repo = MagicMock() + repo.workspace_runtime = repo_runtime + + resolved_runtime = MagicMock() + manager = MagicMock() + manager.create_workspace = AsyncMock(return_value=MagicMock()) + resolved_runtime.manager = MagicMock(return_value=manager) + repo.get_workspace_runtime = MagicMock(return_value=resolved_runtime) + + stager = MagicMock() + stager.stage_skill = AsyncMock(return_value=SkillStageResult(workspace_skill_dir="skills/test-skill")) + + ctx = _make_ctx(repo) + tool = SkillLoadTool( + repository=repo, + skill_stager=stager, + create_ws_name_cb=lambda _: "ws", + ) + + await tool._ensure_staged(ctx=ctx, skill_name="test-skill") + + resolved_runtime.manager.assert_called_once_with(ctx) + repo.get_workspace_runtime.assert_called_once_with(ctx) + repo_runtime.manager.assert_not_called() + + # --------------------------------------------------------------------------- # _set_state_delta_for_skill_tools # --------------------------------------------------------------------------- diff --git a/tests/skills/tools/test_skill_run.py b/tests/skills/tools/test_skill_run.py index b1e43ae5..a6c8e438 100644 --- a/tests/skills/tools/test_skill_run.py +++ b/tests/skills/tools/test_skill_run.py @@ -113,6 +113,19 @@ def test_get_repository(self): ctx = MagicMock() assert tool._get_repository(ctx) is repo + def test_repository_get_workspace_runtime_is_used(self): + repo_runtime = MagicMock() + repo = MagicMock() + repo.workspace_runtime = repo_runtime + resolved_runtime = MagicMock() + repo.get_workspace_runtime = MagicMock(return_value=resolved_runtime) + ctx = MagicMock() + + tool = SkillRunTool(repository=repo) + + assert tool._get_repository(ctx).get_workspace_runtime(ctx) is resolved_runtime + repo.get_workspace_runtime.assert_called_once_with(ctx) + def test_is_skill_loaded(self): tool = _make_tool() ctx = MagicMock() diff --git a/trpc_agent_sdk/code_executors/__init__.py b/trpc_agent_sdk/code_executors/__init__.py index 75c2f1f9..eacd4e7c 100644 --- a/trpc_agent_sdk/code_executors/__init__.py +++ b/trpc_agent_sdk/code_executors/__init__.py @@ -20,6 +20,7 @@ from ._base_workspace_runtime import DefaultWorkspace from ._base_workspace_runtime import new_default_workspace_runtime from ._base_workspace_runtime import WorkspaceRuntimeResolver +from ._base_workspace_runtime import get_workspace_runtime_with_resolver from ._code_executor_context import CodeExecutorContext from ._constants import DEFAULT_CREATE_TIMEOUT_SEC from ._constants import DEFAULT_FILE_MODE @@ -104,6 +105,7 @@ "DefaultWorkspace", "new_default_workspace_runtime", "WorkspaceRuntimeResolver", + "get_workspace_runtime_with_resolver", "CodeExecutorContext", "DEFAULT_CREATE_TIMEOUT_SEC", "DEFAULT_FILE_MODE", diff --git a/trpc_agent_sdk/code_executors/_base_workspace_runtime.py b/trpc_agent_sdk/code_executors/_base_workspace_runtime.py index 1ce6af35..c43e6b68 100644 --- a/trpc_agent_sdk/code_executors/_base_workspace_runtime.py +++ b/trpc_agent_sdk/code_executors/_base_workspace_runtime.py @@ -536,3 +536,20 @@ def new_default_workspace_runtime( WorkspaceRuntimeResolver: TypeAlias = Callable[[InvocationContext], BaseWorkspaceRuntime] """Callback to resolve a workspace runtime.""" + +def get_workspace_runtime_with_resolver(ctx: InvocationContext, resolver: Optional[WorkspaceRuntimeResolver] = None, + workspace_runtime: Optional[BaseWorkspaceRuntime] = None) -> BaseWorkspaceRuntime: + """ + Get workspace runtime. + Args: + ctx: InvocationContext + resolver: WorkspaceRuntimeResolver + workspace_runtime: Optional[BaseWorkspaceRuntime] + Returns: + BaseWorkspaceRuntime + """ + if resolver is not None: + workspace_runtime = resolver(ctx) + if workspace_runtime is None: + raise ValueError("Workspace runtime not found") + return workspace_runtime diff --git a/trpc_agent_sdk/code_executors/cube/__init__.py b/trpc_agent_sdk/code_executors/cube/__init__.py index 7cd114cb..47fe50e8 100644 --- a/trpc_agent_sdk/code_executors/cube/__init__.py +++ b/trpc_agent_sdk/code_executors/cube/__init__.py @@ -21,13 +21,17 @@ from ._runtime import create_cube_workspace_runtime from ._sandbox import CubeCommandResult from ._sandbox import CubeSandboxClient +from ._sandbox import create_cube_sandbox_client from ._transfer import OnExisting +from ._types import CubeClientConfig from ._types import CubeCodeExecutorConfig from ._types import CubeWorkspaceRuntimeConfig __all__ = [ "CubeCodeExecutor", + "CubeClientConfig", "CubeCodeExecutorConfig", + "create_cube_sandbox_client", "CubeCommandResult", "CubeProgramRunner", "CubeSandboxClient", diff --git a/trpc_agent_sdk/code_executors/cube/_code_executor.py b/trpc_agent_sdk/code_executors/cube/_code_executor.py index 967da100..5c4b3a4a 100644 --- a/trpc_agent_sdk/code_executors/cube/_code_executor.py +++ b/trpc_agent_sdk/code_executors/cube/_code_executor.py @@ -29,6 +29,7 @@ from .._types import create_code_execution_result from ._sandbox import CubeCommandResult from ._sandbox import CubeSandboxClient +from ._sandbox import create_cube_sandbox_client from ._types import CubeCodeExecutorConfig _PYTHON_LANGUAGES = frozenset({"python", "py", "python3", ""}) @@ -110,10 +111,7 @@ def __init__( @classmethod async def create(cls, cfg: CubeCodeExecutorConfig) -> "CubeCodeExecutor": """Strict factory. Attaches when ``cfg.sandbox_id`` is set, else creates.""" - if cfg.sandbox_id: - client = await CubeSandboxClient.open_existing(cfg.sandbox_id, cfg) - else: - client = await CubeSandboxClient.open_new(cfg) + client = await create_cube_sandbox_client(cfg) return cls(client, cfg) @classmethod @@ -126,7 +124,7 @@ async def attach(cls, cfg: CubeCodeExecutorConfig) -> "CubeCodeExecutor": if not cfg.sandbox_id: raise ValueError("CubeCodeExecutor.attach requires cfg.sandbox_id to be set; " "use CubeCodeExecutor.create(cfg) to create a fresh sandbox.") - client = await CubeSandboxClient.open_existing(cfg.sandbox_id, cfg) + client = await CubeSandboxClient.open_existing(cfg) return cls(client, cfg) @classmethod @@ -144,14 +142,13 @@ async def create_or_recreate( created. PAUSED state and other errors propagate unchanged so that operator-managed pauses are not silently overwritten. """ - if not cfg.sandbox_id: - return await cls.create(cfg) try: return await cls.create(cfg) except e2b.SandboxNotFoundException: if on_stale is not None: await on_stale() - return await cls.create(replace(cfg, sandbox_id=None)) + cfg = replace(cfg, sandbox_id=None) + return await cls.create(cfg) @property def sandbox_id(self) -> str: diff --git a/trpc_agent_sdk/code_executors/cube/_runtime.py b/trpc_agent_sdk/code_executors/cube/_runtime.py index 8056d3f5..c9c01557 100644 --- a/trpc_agent_sdk/code_executors/cube/_runtime.py +++ b/trpc_agent_sdk/code_executors/cube/_runtime.py @@ -54,6 +54,7 @@ from ._paths import shell_quote from ._sandbox import CubeSandboxClient from ._types import CubeWorkspaceRuntimeConfig +from ._types import DEFAULT_EXECUTE_TIMEOUT _RE_SAFE_ID = re.compile(r"[^a-zA-Z0-9_-]") @@ -414,15 +415,28 @@ def __init__( enable_provider_env: bool = False, ): self._client = client - self._fs = CubeWorkspaceFS(client, execute_timeout) - self._manager = CubeWorkspaceManager(client, remote_workspace, execute_timeout) + self._fs = CubeWorkspaceFS(self._client, execute_timeout) + self._manager = CubeWorkspaceManager(self._client, remote_workspace, execute_timeout) self._runner = CubeProgramRunner( - client, + self._client, execute_timeout, provider=provider, enable_provider_env=enable_provider_env, ) + @property + def sandbox_id(self) -> str | None: + """Current Cube sandbox id.""" + return self._client.sandbox_id + + async def recreate(self) -> None: + """Force sandbox recreation when the client supports it.""" + await self._client.recreate() + + async def destroy(self) -> None: + """Destroy the current Cube sandbox/client.""" + await self._client.destroy() + @override def manager(self, ctx: Optional[InvocationContext] = None) -> CubeWorkspaceManager: return self._manager @@ -446,8 +460,9 @@ def describe(self, ctx: Optional[InvocationContext] = None) -> WorkspaceCapabili def create_cube_workspace_runtime( - executor: CubeCodeExecutor, - *, + executor: CubeCodeExecutor | None = None, + sandbox_client: CubeSandboxClient | None = None, + execute_timeout: float = DEFAULT_EXECUTE_TIMEOUT, workspace_cfg: Optional[CubeWorkspaceRuntimeConfig] = None, provider: Optional[RunEnvProvider] = None, enable_provider_env: bool = False, @@ -466,13 +481,26 @@ def create_cube_workspace_runtime( For lower-level integrations, construct :class:`CubeWorkspaceRuntime` directly with an explicit client + ``remote_workspace`` + ``execute_timeout``. + Args: + executor: CubeCodeExecutor instance, will deprecated, will be removed in the future + sandbox_client: CubeSandboxClient instance, required + execute_timeout: execute timeout, default to DEFAULT_EXECUTE_TIMEOUT + workspace_cfg: workspace configuration, default to CubeWorkspaceRuntimeConfig() + provider: provider, default to None + enable_provider_env: enable provider environment, default to False + Returns: + CubeWorkspaceRuntime instance """ + if executor: + sandbox_client = executor.sandbox_client + execute_timeout = executor.config.execute_timeout + if not sandbox_client: + raise ValueError("sandbox_client is required") ws_cfg = workspace_cfg or CubeWorkspaceRuntimeConfig() - exec_cfg = executor.config return CubeWorkspaceRuntime( - executor.sandbox_client, + sandbox_client, remote_workspace=ws_cfg.remote_workspace, - execute_timeout=exec_cfg.execute_timeout, + execute_timeout=execute_timeout, provider=provider, enable_provider_env=enable_provider_env, ) diff --git a/trpc_agent_sdk/code_executors/cube/_sandbox.py b/trpc_agent_sdk/code_executors/cube/_sandbox.py index 327e15c0..e6d432ef 100644 --- a/trpc_agent_sdk/code_executors/cube/_sandbox.py +++ b/trpc_agent_sdk/code_executors/cube/_sandbox.py @@ -37,10 +37,14 @@ import asyncio from dataclasses import dataclass +from dataclasses import replace from pathlib import Path from typing import Any +from typing import Awaitable +from typing import Callable from typing import Mapping from typing import Optional +from typing import TypeVar import e2b_code_interpreter as e2b from e2b_code_interpreter import AsyncSandbox @@ -52,12 +56,21 @@ from ._transfer import download_directory_via_tar from ._transfer import reserve_local_destination from ._transfer import upload_directory_via_tar -from ._types import CubeCodeExecutorConfig +from ._types import CubeClientConfig # The unix user we run sandbox commands and FS ops as. Standard cube/e2b # templates ship with `root`; downstream callers do not need to override # this and we deliberately do not expose a knob to keep the surface small. _GUEST_USER = "root" +_T = TypeVar("_T") + + +def _is_stale_sandbox_error(exc: BaseException) -> bool: + """Return whether ``exc`` means the remote sandbox disappeared.""" + if isinstance(exc, e2b.SandboxNotFoundException): + return True + message = str(exc).lower() + return "code.unknown" in message and "requested resource does not exist" in message @dataclass @@ -112,18 +125,20 @@ class CubeSandboxClient: the constructor directly. """ - def __init__(self, sandbox: AsyncSandbox, *, idle_timeout: int, execute_timeout: float): + def __init__(self, sandbox: AsyncSandbox, cfg: CubeClientConfig): self._sbx: Optional[AsyncSandbox] = sandbox - self._idle_timeout = idle_timeout - self._execute_timeout = execute_timeout + self._cfg = cfg + self._recreate_cfg = replace(cfg, sandbox_id=None) + self._idle_timeout = cfg.idle_timeout + self._execute_timeout = cfg.execute_timeout + self._recreate_lock = asyncio.Lock() @property def sandbox_id(self) -> str: - sbx = self._require() - return sbx.sandbox_id + return self._require().sandbox_id @classmethod - async def open_new(cls, cfg: CubeCodeExecutorConfig) -> "CubeSandboxClient": + async def open_new(cls, cfg: CubeClientConfig) -> "CubeSandboxClient": """Create a brand-new remote sandbox.""" sbx = await e2b.AsyncSandbox.create( template=cfg.resolve_template(), @@ -131,10 +146,13 @@ async def open_new(cls, cfg: CubeCodeExecutorConfig) -> "CubeSandboxClient": api_key=cfg.resolve_api_key(), timeout=cfg.idle_timeout, ) - return cls(sbx, idle_timeout=cfg.idle_timeout, execute_timeout=cfg.execute_timeout) + return cls(sbx, cfg) @classmethod - async def open_existing(cls, sandbox_id: str, cfg: CubeCodeExecutorConfig) -> "CubeSandboxClient": + async def open_existing( + cls, + cfg: CubeClientConfig, + ) -> "CubeSandboxClient": """Attach to an existing remote sandbox and assert it is RUNNING. Raises: @@ -144,12 +162,14 @@ async def open_existing(cls, sandbox_id: str, cfg: CubeCodeExecutorConfig) -> "C PAUSED); caller should not silently overwrite locator state. """ + if not cfg.sandbox_id: + raise ValueError("CubeSandboxClient.open_existing requires cfg.sandbox_id") sbx = await e2b.AsyncSandbox.connect( - sandbox_id, + cfg.sandbox_id, api_url=cfg.resolve_api_url(), api_key=cfg.resolve_api_key(), ) - client = cls(sbx, idle_timeout=cfg.idle_timeout, execute_timeout=cfg.execute_timeout) + client = cls(sbx, cfg) await client.assert_running() return client @@ -180,6 +200,11 @@ async def destroy(self) -> None: finally: self._sbx = None + async def recreate(self) -> None: + """Explicitly replace the current sandbox with a fresh one.""" + async with self._recreate_lock: + await self._recreate_locked() + async def assert_running(self) -> None: """Verify the sandbox is RUNNING; reject PAUSED and surface stale ids. @@ -188,11 +213,7 @@ async def assert_running(self) -> None: - PAUSED state raises :class:`SandboxException` so callers do not silently discard operator-managed pause state. """ - sbx = self._require() - info = await sbx.get_info(request_timeout=self._execute_timeout) - if info.state != e2b.SandboxState.RUNNING: - raise e2b.SandboxException(f"Cube sandbox {sbx.sandbox_id} is in state {info.state.value!r}, " - f"expected {e2b.SandboxState.RUNNING.value!r}.") + await self._with_recovery(self._assert_running_once) async def set_timeout(self, seconds: int) -> None: """Best-effort idle-timeout renewal. @@ -202,11 +223,7 @@ async def set_timeout(self, seconds: int) -> None: truncated by ``int(...)`` (e.g. ``0.9`` → ``0``, which most vendor APIs interpret as "no timeout" / "expire immediately"). """ - sbx = self._require() - try: - await sbx.set_timeout(seconds) - except Exception as exc: # pylint: disable=broad-exception-caught - logger.debug("Cube sandbox %s set_timeout failed: %s", sbx.sandbox_id, exc) + await self._with_recovery(lambda: self._set_timeout_once(seconds)) async def commands_run( self, @@ -228,59 +245,13 @@ async def commands_run( provided) is encoded as a bash heredoc because the e2b SDK's ``stdin`` flag is not a data channel. """ - sbx = self._require() - if stdin is not None: - command = wrap_stdin_heredoc(command, stdin) - timeout_sec = float(timeout if timeout is not None else self._execute_timeout) - kwargs: dict[str, Any] = { - "envs": dict(env or {}), - "user": _GUEST_USER, - "timeout": timeout_sec, - } - if cwd: - kwargs["cwd"] = cwd - - loop = asyncio.get_running_loop() - start = loop.time() - timed_out = False - try: - result = await sbx.commands.run(command, **kwargs) - except e2b.CommandExitException as exc: - result = exc - except BaseException as exc: - # Timeouts surface here as one of several types depending on - # which transport layer fires first: - # - e2b.TimeoutException (vendor SDK layer) - # - httpcore.ReadTimeout / httpcore.TimeoutException - # (transport layer — can race ahead of the e2b mapping on - # slow Cube deployments) - # The httpcore path is only reachable via the transitive - # dependency, so we match by type-name instead of importing - # httpcore just to subclass-check. We still re-raise anything - # that is not timeout-flavoured so real errors stay visible. - name = type(exc).__name__ - if "Timeout" not in name: - raise - result = None - timed_out = True - duration = loop.time() - start - - await self.set_timeout(self._idle_timeout) - - if timed_out: - return CubeCommandResult( - stdout="", - stderr=f"Command timed out after {timeout_sec:g}s", - exit_code=-1, - duration=float(duration), - timed_out=True, - ) - return CubeCommandResult( - stdout=str(getattr(result, "stdout", "") or ""), - stderr=str(getattr(result, "stderr", "") or ""), - exit_code=int(getattr(result, "exit_code", 0) or 0), - duration=float(duration), - ) + return await self._with_recovery(lambda: self._commands_run_once( + command, + cwd=cwd, + env=env, + stdin=stdin, + timeout=timeout, + )) async def upload_path(self, local: Path, remote_abs: str) -> None: """Upload a host file or directory to an absolute remote path. @@ -330,22 +301,125 @@ async def download_path( async def read_file_bytes(self, remote_abs: str) -> bytes: """Read a remote file's raw bytes.""" - sbx = self._require() - data = await sbx.files.read(remote_abs, format="bytes", user=_GUEST_USER) + data = await self._with_recovery( + lambda: self._require().files.read(remote_abs, format="bytes", user=_GUEST_USER)) return data if isinstance(data, bytes) else bytes(data or b"") async def write_file_bytes(self, remote_abs: str, data: bytes) -> None: """Write raw bytes to a remote file.""" - sbx = self._require() - await sbx.files.write(remote_abs, data, user=_GUEST_USER) + await self._with_recovery(lambda: self._require().files.write(remote_abs, data, user=_GUEST_USER)) async def _is_remote_dir(self, remote_abs: str) -> bool: """Return whether ``remote_abs`` resolves to a directory inside the sandbox.""" - sbx = self._require() - info = await sbx.files.get_info(remote_abs, user=_GUEST_USER) + info = await self._with_recovery(lambda: self._require().files.get_info(remote_abs, user=_GUEST_USER)) return info.type == e2b.FileType.DIR + async def _assert_running_once(self) -> None: + sbx = self._require() + info = await sbx.get_info(request_timeout=self._execute_timeout) + if info.state != e2b.SandboxState.RUNNING: + raise e2b.SandboxException(f"Cube sandbox {sbx.sandbox_id} is in state {info.state.value!r}, " + f"expected {e2b.SandboxState.RUNNING.value!r}.") + + async def _set_timeout_once(self, seconds: int) -> None: + sbx = self._require() + try: + await sbx.set_timeout(seconds) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.debug("Cube sandbox %s set_timeout failed: %s", sbx.sandbox_id, exc) + + async def _commands_run_once( + self, + command: str, + *, + cwd: Optional[str] = None, + env: Optional[Mapping[str, str]] = None, + stdin: Optional[bytes] = None, + timeout: Optional[float] = None, + ) -> CubeCommandResult: + sbx = self._require() + if stdin is not None: + command = wrap_stdin_heredoc(command, stdin) + timeout_sec = float(timeout if timeout is not None else self._execute_timeout) + kwargs: dict[str, Any] = { + "envs": dict(env or {}), + "user": _GUEST_USER, + "timeout": timeout_sec, + } + if cwd: + kwargs["cwd"] = cwd + + loop = asyncio.get_running_loop() + start = loop.time() + timed_out = False + try: + result = await sbx.commands.run(command, **kwargs) + except e2b.CommandExitException as exc: + result = exc + except BaseException as exc: + # Timeouts surface here as one of several types depending on + # which transport layer fires first: + # - e2b.TimeoutException (vendor SDK layer) + # - httpcore.ReadTimeout / httpcore.TimeoutException + # (transport layer — can race ahead of the e2b mapping on + # slow Cube deployments) + # The httpcore path is only reachable via the transitive + # dependency, so we match by type-name instead of importing + # httpcore just to subclass-check. We still re-raise anything + # that is not timeout-flavoured so real errors stay visible. + name = type(exc).__name__ + if "Timeout" not in name: + raise + result = None + timed_out = True + duration = loop.time() - start + + await self.set_timeout(self._idle_timeout) + + if timed_out: + return CubeCommandResult( + stdout="", + stderr=f"Command timed out after {timeout_sec:g}s", + exit_code=-1, + duration=float(duration), + timed_out=True, + ) + return CubeCommandResult( + stdout=str(getattr(result, "stdout", "") or ""), + stderr=str(getattr(result, "stderr", "") or ""), + exit_code=int(getattr(result, "exit_code", 0) or 0), + duration=float(duration), + ) + + async def _with_recovery(self, op: Callable[[], Awaitable[_T]]) -> _T: + sandbox = self._sbx + try: + return await op() + except Exception as exc: + if not self._cfg.auto_recover or not _is_stale_sandbox_error(exc): + raise + logger.info("Cube sandbox expired; recreating sandbox client: %s", exc) + async with self._recreate_lock: + if self._sbx is sandbox: + await self._recreate_locked() + return await op() + + async def _recreate_locked(self) -> None: + if self._sbx is not None: + await self.destroy() + fresh = await type(self).open_new(self._recreate_cfg) + self._sbx = fresh._require() + fresh.close() + logger.info("Cube sandbox client using sandbox: %s", self.sandbox_id) + def _require(self) -> AsyncSandbox: if self._sbx is None: raise RuntimeError("CubeSandboxClient is closed.") return self._sbx + + +async def create_cube_sandbox_client(cfg: CubeClientConfig) -> CubeSandboxClient: + """Create or attach a Cube sandbox client from config.""" + if cfg.sandbox_id: + return await CubeSandboxClient.open_existing(cfg) + return await CubeSandboxClient.open_new(cfg) diff --git a/trpc_agent_sdk/code_executors/cube/_types.py b/trpc_agent_sdk/code_executors/cube/_types.py index 261649b2..f5f1a231 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 dataclasses import field from typing import Optional DEFAULT_REMOTE_WORKSPACE = "/workspace/cube_agent" @@ -21,17 +22,15 @@ @dataclass -class CubeCodeExecutorConfig: - """Configuration for :class:`CubeCodeExecutor`. +class CubeClientConfig: + """Configuration for :class:`CubeSandboxClient`. Holds only the sandbox-lifecycle and command-execution settings the - bare code executor consumes. Workspace-runtime knobs (e.g. the + bare sandbox client consumes. Workspace-runtime knobs (e.g. the remote workspace root) live in :class:`CubeWorkspaceRuntimeConfig` - so executor-only callers never see fields they don't use (ISP). + so client-only callers never see fields they don't use (ISP). - The optional ``e2b-code-interpreter`` dependency must be installed - (it transitively pulls in ``e2b``). Credentials may be supplied here - or through ``E2B_API_URL`` / ``E2B_API_KEY``. The Cube template id + Credentials may be supplied here or through ``E2B_API_URL`` / ``E2B_API_KEY``. The Cube template id may be supplied here or through ``CUBE_TEMPLATE_ID``. """ @@ -47,6 +46,14 @@ class CubeCodeExecutorConfig: sandbox_id: Optional[str] = None """Existing remote sandbox id. When set, factories attach instead of create.""" + auto_recover: bool = False + """Whether ``CubeSandboxClient`` should recreate expired sandboxes. + + Disabled by default to preserve the original lifecycle contract. When + enabled, sandbox operations recreate a fresh sandbox after + ``SandboxNotFoundException`` and retry the failed operation once. + """ + execute_timeout: float = DEFAULT_EXECUTE_TIMEOUT """Default per-command timeout in seconds. @@ -99,6 +106,10 @@ def resolve_api_key(self) -> str: return value +# Deprecated, will be removed in the future +CubeCodeExecutorConfig = CubeClientConfig + + @dataclass class CubeWorkspaceRuntimeConfig: """Configuration for :class:`CubeWorkspaceRuntime`. diff --git a/trpc_agent_sdk/skills/_repository.py b/trpc_agent_sdk/skills/_repository.py index 36582872..c4917d6d 100644 --- a/trpc_agent_sdk/skills/_repository.py +++ b/trpc_agent_sdk/skills/_repository.py @@ -27,6 +27,8 @@ import yaml from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime +from trpc_agent_sdk.code_executors import WorkspaceRuntimeResolver +from trpc_agent_sdk.code_executors import get_workspace_runtime_with_resolver from trpc_agent_sdk.code_executors import create_local_workspace_runtime from trpc_agent_sdk.log import logger @@ -51,13 +53,18 @@ class BaseSkillRepository(abc.ABC): must satisfy. Parsing internals are left entirely to subclasses. """ - def __init__(self, workspace_runtime: BaseWorkspaceRuntime, visibility_filter: VisibilityFilter | None = None): + def __init__(self, workspace_runtime: BaseWorkspaceRuntime, visibility_filter: VisibilityFilter | None = None, + workspace_runtime_resolver: Optional[WorkspaceRuntimeResolver] = None): self._workspace_runtime = workspace_runtime self._visibility_filter = visibility_filter + self._workspace_runtime_resolver = workspace_runtime_resolver @property def workspace_runtime(self) -> BaseWorkspaceRuntime: return self._workspace_runtime + + def get_workspace_runtime(self, ctx: InvocationContext) -> BaseWorkspaceRuntime: + return get_workspace_runtime_with_resolver(ctx, self._workspace_runtime_resolver, self._workspace_runtime) @property def visibility_filter(self) -> VisibilityFilter | None: @@ -136,6 +143,7 @@ def __init__( self, *roots: str, workspace_runtime: Optional[BaseWorkspaceRuntime] = None, + workspace_runtime_resolver: Optional[WorkspaceRuntimeResolver] = None, resolver: Optional[SkillRootResolver] = None, enable_hot_reload: bool = False, ): @@ -151,7 +159,7 @@ def __init__( """ if workspace_runtime is None: workspace_runtime = create_local_workspace_runtime() - super().__init__(workspace_runtime) + super().__init__(workspace_runtime, workspace_runtime_resolver=workspace_runtime_resolver) self._resolver = resolver or SkillRootResolver() self._skill_paths: dict[str, str] = {} # name -> base dir self._all_descriptions: dict[str, str] = {} # name -> description diff --git a/trpc_agent_sdk/skills/_toolset.py b/trpc_agent_sdk/skills/_toolset.py index cdaab4de..d1d186ed 100644 --- a/trpc_agent_sdk/skills/_toolset.py +++ b/trpc_agent_sdk/skills/_toolset.py @@ -100,6 +100,7 @@ def __init__(self, self._repository = repository or FsSkillRepository( *(paths or []), enable_hot_reload=enable_hot_reload, + workspace_runtime_resolver=workspace_runtime_resolver, ) self._skill_config = skill_config or DEFAULT_SKILL_CONFIG self._create_ws_name_cb = create_ws_name_cb or default_create_ws_name_callback diff --git a/trpc_agent_sdk/skills/stager/_base_stager.py b/trpc_agent_sdk/skills/stager/_base_stager.py index 3119193d..2775912b 100644 --- a/trpc_agent_sdk/skills/stager/_base_stager.py +++ b/trpc_agent_sdk/skills/stager/_base_stager.py @@ -79,7 +79,7 @@ async def stage_skill(self, request: SkillStageRequest) -> SkillStageResult: ctx = request.ctx ws = request.workspace root = request.repository.path(request.skill_name) - runtime = request.repository.workspace_runtime + runtime = request.repository.get_workspace_runtime(ctx) name = request.skill_name digest = compute_dir_digest(root) md = await self.load_workspace_metadata(ctx, runtime, ws) diff --git a/trpc_agent_sdk/skills/tools/_skill_exec.py b/trpc_agent_sdk/skills/tools/_skill_exec.py index b9f6eb62..0b0a428b 100644 --- a/trpc_agent_sdk/skills/tools/_skill_exec.py +++ b/trpc_agent_sdk/skills/tools/_skill_exec.py @@ -419,7 +419,7 @@ async def _run_async_impl( repository = self._run_tool._get_repository(tool_context) # Workspace creation - workspace_runtime = repository.workspace_runtime + workspace_runtime = repository.get_workspace_runtime(tool_context) manager = workspace_runtime.manager(tool_context) workspace_id = self._create_ws_name_cb(tool_context) ws = await manager.create_workspace(workspace_id, tool_context) diff --git a/trpc_agent_sdk/skills/tools/_skill_load.py b/trpc_agent_sdk/skills/tools/_skill_load.py index 869f90d5..fc33e677 100644 --- a/trpc_agent_sdk/skills/tools/_skill_load.py +++ b/trpc_agent_sdk/skills/tools/_skill_load.py @@ -16,6 +16,8 @@ from typing import Optional from typing_extensions import override +from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime +from trpc_agent_sdk.code_executors import WorkspaceRuntimeResolver from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.filter import BaseFilter from trpc_agent_sdk.tools import BaseTool @@ -107,7 +109,7 @@ async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[s async def _ensure_staged(self, *, ctx: InvocationContext, skill_name: str) -> None: repository = self._get_repository(ctx) - runtime = repository.workspace_runtime + runtime = repository.get_workspace_runtime(ctx) manager = runtime.manager(ctx) ws_id = self._create_ws_name_cb(ctx) ws = await manager.create_workspace(ws_id, ctx) diff --git a/trpc_agent_sdk/skills/tools/_skill_run.py b/trpc_agent_sdk/skills/tools/_skill_run.py index 8760f825..97353863 100644 --- a/trpc_agent_sdk/skills/tools/_skill_run.py +++ b/trpc_agent_sdk/skills/tools/_skill_run.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from pydantic import Field +from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime from trpc_agent_sdk.code_executors import CodeFile from trpc_agent_sdk.code_executors import DIR_OUT from trpc_agent_sdk.code_executors import DIR_SKILLS @@ -30,6 +31,7 @@ from trpc_agent_sdk.code_executors import WorkspacePutFileInfo from trpc_agent_sdk.code_executors import WorkspaceRunProgramSpec from trpc_agent_sdk.code_executors import WorkspaceRunResult +from trpc_agent_sdk.code_executors import WorkspaceRuntimeResolver from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.filter import BaseFilter from trpc_agent_sdk.log import logger @@ -507,6 +509,7 @@ async def _prepare_editor_env( self, ctx: InvocationContext, ws: WorkspaceInfo, + workspace_runtime: BaseWorkspaceRuntime, env: dict[str, str], editor_text: str, ) -> None: @@ -526,7 +529,6 @@ async def _prepare_editor_env( # Try using workspace FS (works for container runtimes too) try: script_content = _build_editor_wrapper_script(content_abs) - workspace_runtime = self._get_repository(ctx).workspace_runtime fs = workspace_runtime.fs(ctx) await fs.put_files( ws, @@ -619,10 +621,10 @@ async def _auto_export_workspace_out( self, ctx: InvocationContext, ws: WorkspaceInfo, + workspace_runtime: BaseWorkspaceRuntime, ) -> list[CodeFile]: """Collect up to _AUTO_EXPORT_MAX files from out/** automatically.""" try: - workspace_runtime = self._get_repository(ctx).workspace_runtime fs = workspace_runtime.fs(ctx) files = await fs.collect(ws, [_AUTO_EXPORT_PATTERN], ctx) if not files: @@ -669,7 +671,7 @@ async def _run_async_impl( repository = self._get_repository(tool_context) workspace_id = self._create_ws_name_cb(tool_context) - workspace_runtime = repository.workspace_runtime + workspace_runtime = repository.get_workspace_runtime(tool_context) manager = workspace_runtime.manager(tool_context) ws = await manager.create_workspace(workspace_id, tool_context) @@ -695,15 +697,15 @@ async def _run_async_impl( await fs.stage_inputs(ws, inputs.inputs, tool_context) cwd = self._resolve_cwd(inputs.cwd, workspace_skill_dir) - result = await self._run_program(tool_context, ws, cwd, inputs) + result = await self._run_program(tool_context, ws, workspace_runtime, cwd, inputs) # Collect explicit outputs files: list[SkillRunFile] - files, manifest = await self._prepare_outputs(tool_context, ws, inputs) + files, manifest = await self._prepare_outputs(tool_context, ws, workspace_runtime, inputs) # Auto-export out/** only when no explicit outputs requested if not files and manifest is None and not inputs.outputs and not inputs.output_files: - auto_raw = await self._auto_export_workspace_out(tool_context, ws) + auto_raw = await self._auto_export_workspace_out(tool_context, ws, workspace_runtime) if auto_raw: files = self._to_run_files(auto_raw) @@ -756,6 +758,7 @@ async def _run_program( self, ctx: InvocationContext, ws: WorkspaceInfo, + workspace_runtime: BaseWorkspaceRuntime, cwd: str, input_data: SkillRunInput, ) -> WorkspaceRunResult: @@ -784,12 +787,11 @@ async def _run_program( pass # Stage editor helper if requested - await self._prepare_editor_env(ctx, ws, env, input_data.editor_text) + await self._prepare_editor_env(ctx, ws, workspace_runtime, env, input_data.editor_text) # Build command (with venv activation or command restrictions) cmd, cmd_args = self._build_command(input_data.command, ws.path, cwd) - workspace_runtime = repository.workspace_runtime runner = workspace_runtime.runner(ctx) ret = await runner.run_program( ws, @@ -843,10 +845,10 @@ async def _prepare_outputs( self, ctx: InvocationContext, ws: WorkspaceInfo, + workspace_runtime: BaseWorkspaceRuntime, input_data: SkillRunInput, ) -> tuple[list[SkillRunFile], Optional[ManifestOutput]]: """Collect files via OutputSpec or legacy output_files patterns.""" - workspace_runtime = self._get_repository(ctx).workspace_runtime fs = workspace_runtime.fs(ctx) if input_data.outputs and not input_data.output_files: diff --git a/trpc_agent_sdk/skills/tools/_workspace_exec.py b/trpc_agent_sdk/skills/tools/_workspace_exec.py index 2d608308..544cc276 100644 --- a/trpc_agent_sdk/skills/tools/_workspace_exec.py +++ b/trpc_agent_sdk/skills/tools/_workspace_exec.py @@ -20,6 +20,7 @@ from trpc_agent_sdk.code_executors import BaseProgramSession from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime from trpc_agent_sdk.code_executors import WorkspaceRuntimeResolver +from trpc_agent_sdk.code_executors import get_workspace_runtime_with_resolver from trpc_agent_sdk.code_executors import DEFAULT_EXEC_YIELD_MS from trpc_agent_sdk.code_executors import DEFAULT_SESSION_KILL_SEC from trpc_agent_sdk.code_executors import DEFAULT_SESSION_TTL_SEC @@ -192,9 +193,7 @@ def __init__( self._sessions: dict[str, _ExecSession] = {} def _runtime(self, ctx: InvocationContext) -> BaseWorkspaceRuntime: - if self._workspace_runtime_resolver is not None: - return self._workspace_runtime_resolver(ctx) - return self._workspace_runtime + return get_workspace_runtime_with_resolver(ctx, self._workspace_runtime_resolver, self._workspace_runtime) async def _workspace(self, ctx: InvocationContext) -> tuple[BaseWorkspaceRuntime, WorkspaceInfo]: runtime = self._runtime(ctx)