From bd53aeb22fe5726c2d0a747bd53859ef11e17b45 Mon Sep 17 00:00:00 2001 From: "hanzhi.421" Date: Mon, 26 Oct 2026 20:32:00 +0800 Subject: [PATCH 1/2] feat(skills): execute AIO skills and basic tools in session-bound sandboxes --- .../docs/framework/agent/skills.en.mdx | 26 +- docs/content/docs/framework/agent/skills.mdx | 26 +- .../2026-10-26-aio-sandbox-skills.md | 48 ++ .../2026-10-26-aio-sandbox-skills.zh.md | 48 ++ tests/skills/test_aio_sandbox_skills.py | 414 +++++++++++++++++ veadk/agent.py | 12 +- veadk/skills/check_skills_callback.py | 7 + veadk/tools/skills_tools/aio_sandbox.py | 419 ++++++++++++++++++ veadk/tools/skills_tools/skills_toolset.py | 7 +- 9 files changed, 996 insertions(+), 11 deletions(-) create mode 100644 prd-spec/features/aio-sandbox-skills/2026-10-26-aio-sandbox-skills.md create mode 100644 prd-spec/features/aio-sandbox-skills/2026-10-26-aio-sandbox-skills.zh.md create mode 100644 tests/skills/test_aio_sandbox_skills.py create mode 100644 veadk/tools/skills_tools/aio_sandbox.py diff --git a/docs/content/docs/framework/agent/skills.en.mdx b/docs/content/docs/framework/agent/skills.en.mdx index 82964cc30..277d75aac 100644 --- a/docs/content/docs/framework/agent/skills.en.mdx +++ b/docs/content/docs/framework/agent/skills.en.mdx @@ -2,7 +2,7 @@ title: "Skills" --- -Skills are reusable "prompt packages" that inject specific domain knowledge, operating procedures, and scripts into an Agent. For local skill loading and execution, the recommended integration is Google ADK's official `load_skill_from_dir` / `SkillToolset` path. VeADK's legacy `Agent(skills=..., skills_mode="local")` local entry point remains compatible, but is deprecated. The `skills_sandbox` and `aio_sandbox` modes keep their existing behavior. +Skills are reusable "prompt packages" that inject specific domain knowledge, operating procedures, and scripts into an Agent. For local skill loading and execution, the recommended integration is Google ADK's official `load_skill_from_dir` / `SkillToolset` path. VeADK's legacy `Agent(skills=..., skills_mode="local")` local entry point remains compatible, but is deprecated. `skills_sandbox` executes through cloud Skill Spaces; `aio_sandbox` loads skills and executes file and Shell tools in a bound sandbox. ## Recommended: ADK SkillToolset @@ -70,7 +70,7 @@ The `skills` parameter still supports three skill execution modes. Only the lega | :- | :- | | `local` | Deprecated. Skills are loaded by VeADK's legacy `skills_tool` plus file/Shell helper tools; migrate to ADK `SkillToolset` for local execution. | | `skills_sandbox` | Skills are hosted in a cloud Skill Space and executed in a sandbox through the `execute_skills` tool. | -| `aio_sandbox` | All-in-one sandbox mode, suitable for the AgentKit-hosted tool runtime. | +| `aio_sandbox` | Runtime owns inference; a bound All-in-one sandbox provides Skill files and basic tools. `skills` contains absolute sandbox directories. | When `skills_mode` is not set explicitly, VeADK infers it from the runtime environment: local runs default to `local`; in the AgentKit tool runtime, it automatically selects `skills_sandbox` or `aio_sandbox` based on the tool type. @@ -104,6 +104,24 @@ agent = Agent( ) ``` +### AIO Sandbox Mode + +```python +from veadk import Agent + +agent = Agent( + model_name="doubao-seed-evolving", + skills_mode="aio_sandbox", + skills=["/workspace/skills"], +) +``` + +Configure `VOLCENGINE_ACCESS_KEY`, `VOLCENGINE_SECRET_KEY`, `AGENTKIT_TOOL_ID` and model credentials, then create a Session and run the Agent through ADK/VeADK Runner. Construction never reads host Skill files. Before first execution, VeADK creates or reuses an AgentKit sandbox for the app/user/session identity; subsequent turns retain the same sandbox. Expired or failed sandboxes raise errors without automatic replacement or local fallback. Applications own instance lifecycle; idle instances expire by TTL. + +VeADK retains `SkillsToolset` and automatically supplies `skills_tool`, `bash_tool`, `read_file_tool`, `write_file_tool`, and `edit_file_tool`. Metadata comes from sandbox `GET /v1/skills`; instructions are read lazily through the remote file API. Commands and file operations execute inside the sandbox; relative paths use `/workspace`. Host environment variables are not forwarded to commands. Additional caller-supplied custom tools remain the caller's responsibility. + +`skills` accepts multiple absolute sandbox directories, sent as repeated `path` query parameters. Omitted or empty lists use the sandbox default `/workspace/skills`. Cloud SkillSpace IDs are not accepted here; existing AIO callers must migrate to sandbox paths. The sandbox must support `/v1/skills`, file APIs and Bash APIs. Optional `SANDBOX_API_KEY` authenticates sandbox HTTP requests; configured ADK `inbound_auth` credentials are reused. Bash timeout/cancellation attempts to kill remote commands and close temporary shells. Cleanup failures are reported; mutations are not retried automatically. + ### Skill Checklist A skill can carry a checklist in its definition to constrain the Agent to complete the task step by step. When enabled, the Agent must complete each checklist item while executing the skill, and mark each item as done through the `update_check_list` tool: @@ -137,7 +155,7 @@ agent = Agent( | Parameter | Type | Description | | :- | :- | :- | -| `skills` | `list[str]` | Skill list; each item is a local skills root path or a cloud skill-space identifier. The legacy local loading path is deprecated when `skills_mode="local"`. | -| `skills_mode` | `"local" \| "skills_sandbox" \| "aio_sandbox"` | Skill execution mode; inferred automatically when omitted. The legacy `local` path is deprecated, while `skills_sandbox` / `aio_sandbox` keep their existing behavior. | +| `skills` | `list[str]` | Skill list; AIO items are absolute sandbox directories; other modes use local skills root paths or cloud skill-space identifiers. The legacy local loading path is deprecated when `skills_mode="local"`. | +| `skills_mode` | `"local" \| "skills_sandbox" \| "aio_sandbox"` | Skill execution mode; inferred automatically when omitted. The legacy `local` path is deprecated, `skills_sandbox` retains its behavior, and `aio_sandbox` uses remote directories and basic tools. | | `enable_skills_checklist` | `bool` | Whether to enable the legacy skill checklist; defaults to `False`. | | `enable_dynamic_load_skills` | `bool` | Whether to enable dynamic loading for legacy skills; defaults to `False`. | diff --git a/docs/content/docs/framework/agent/skills.mdx b/docs/content/docs/framework/agent/skills.mdx index 81dd1e92e..26d94b133 100644 --- a/docs/content/docs/framework/agent/skills.mdx +++ b/docs/content/docs/framework/agent/skills.mdx @@ -2,7 +2,7 @@ title: "技能" --- -技能(Skills)是一种可复用的「提示词包」,用于为 Agent 注入特定的领域知识、操作流程与脚本。本地技能加载/执行场景推荐使用 Google ADK 官方的 `load_skill_from_dir` / `SkillToolset` 方式接入;VeADK 旧的 `Agent(skills=..., skills_mode="local")` 本地入口仍保持兼容,但已标记为 deprecated。`skills_sandbox` 与 `aio_sandbox` 模式仍按原方式使用。 +技能(Skills)是一种可复用的「提示词包」,用于为 Agent 注入特定的领域知识、操作流程与脚本。本地技能加载/执行场景推荐使用 Google ADK 官方的 `load_skill_from_dir` / `SkillToolset` 方式接入;VeADK 旧的 `Agent(skills=..., skills_mode="local")` 本地入口仍保持兼容,但已标记为 deprecated。`skills_sandbox` 通过云端技能空间执行;`aio_sandbox` 从绑定的沙箱加载技能并执行文件和 Shell 工具。 ## 推荐:ADK SkillToolset @@ -70,7 +70,7 @@ VeADK `skills` 参数仍支持三种技能运行模式,其中仅 `local` 的 | :- | :- | | `local` | Deprecated。技能由 VeADK 旧 `skills_tool` 和配套文件/Shell 工具加载,建议迁移到 ADK `SkillToolset`。 | | `skills_sandbox` | 技能托管在云端技能空间(Skill Space),通过 `execute_skills` 工具在沙箱中执行。 | -| `aio_sandbox` | All-in-one 沙箱模式,适用于 AgentKit 托管的工具运行时。 | +| `aio_sandbox` | Runtime 执行推理,绑定的 All-in-one 沙箱提供 Skill 文件和基础工具;`skills` 为沙箱内绝对目录。 | 未显式设置 `skills_mode` 时,VeADK 会根据运行环境自动推断:本地运行默认为 `local`;在 AgentKit 工具运行时中,会根据工具类型自动选择 `skills_sandbox` 或 `aio_sandbox`。 @@ -104,6 +104,24 @@ agent = Agent( ) ``` +### AIO 沙箱模式 + +```python +from veadk import Agent + +agent = Agent( + model_name="doubao-seed-evolving", + skills_mode="aio_sandbox", + skills=["/workspace/skills"], +) +``` + +配置 `VOLCENGINE_ACCESS_KEY`、`VOLCENGINE_SECRET_KEY`、`AGENTKIT_TOOL_ID` 和模型凭据后,通过 ADK/VeADK Runner 创建 Session 并运行 Agent。构造 Agent 不读取宿主机 Skill 文件;首次执行前按 app/user/session 身份创建或复用 AgentKit 沙箱,同一对话使用同一沙箱。沙箱过期或失败会报错,不自动更换实例或回退到本地。实例生命周期由应用管理,闲置实例按 TTL 过期。 + +保留 VeADK `SkillsToolset`,自动提供 `skills_tool`、`bash_tool`、`read_file_tool`、`write_file_tool`、`edit_file_tool`。元数据通过沙箱 `GET /v1/skills` 获取,说明通过远端文件接口按需读取;命令和文件操作均在沙箱执行,相对路径基于 `/workspace`。宿主机环境变量不会传入命令环境;调用方额外传入的自定义工具仍由调用方负责。 + +`skills` 可配置多个沙箱绝对目录,以重复 `path` 查询参数发送;省略或传空列表使用沙箱默认 `/workspace/skills`。这里不接受云端 SkillSpace ID;已有 AIO 调用方需要改为沙箱路径。沙箱需支持 `/v1/skills`、文件 API 和 Bash API。可选 `SANDBOX_API_KEY` 用于沙箱 HTTP 认证,ADK 配置的 `inbound_auth` 凭据沿用。Bash 超时/取消会尝试终止远端命令并关闭临时 Shell;清理失败会明确报告,写操作不自动重试。 + ### 技能检查清单(Checklist) 技能可在其定义中携带一份检查清单(checklist),用于约束 Agent 按步骤完成任务。开启后,Agent 在执行技能时需逐项完成检查项,并通过 `update_check_list` 工具将每一项标记为已完成: @@ -137,7 +155,7 @@ agent = Agent( | 参数 | 类型 | 说明 | | :- | :- | :- | -| `skills` | `list[str]` | 技能列表,元素为本地技能根目录路径或云端技能空间标识;当 `skills_mode="local"` 时,该旧本地加载路径已 deprecated。 | -| `skills_mode` | `"local" \| "skills_sandbox" \| "aio_sandbox"` | 技能运行模式,缺省时自动推断;其中 `local` 旧路径已 deprecated,`skills_sandbox` / `aio_sandbox` 仍保持原用法。 | +| `skills` | `list[str]` | 技能列表;`aio_sandbox` 下为沙箱绝对目录,其他模式下为本地技能根目录路径或云端技能空间标识;当 `skills_mode="local"` 时,该旧本地加载路径已 deprecated。 | +| `skills_mode` | `"local" \| "skills_sandbox" \| "aio_sandbox"` | 技能运行模式,缺省时自动推断;其中 `local` 旧路径已 deprecated,`skills_sandbox` 保持原用法,`aio_sandbox` 使用远端目录与基础工具。 | | `enable_skills_checklist` | `bool` | 是否启用旧技能检查清单,默认 `False`。 | | `enable_dynamic_load_skills` | `bool` | 是否启用旧技能的动态加载,默认 `False`。 | diff --git a/prd-spec/features/aio-sandbox-skills/2026-10-26-aio-sandbox-skills.md b/prd-spec/features/aio-sandbox-skills/2026-10-26-aio-sandbox-skills.md new file mode 100644 index 000000000..d97d79997 --- /dev/null +++ b/prd-spec/features/aio-sandbox-skills/2026-10-26-aio-sandbox-skills.md @@ -0,0 +1,48 @@ +# AIO sandbox skills and basic tools + +[中文版](2026-10-26-aio-sandbox-skills.zh.md) + +Change ID: `aio-sandbox-skills` · Date: 2026-10-26 · Status: implemented, with full-regression limitations below. + +## Background and scope + +VeADK's `aio_sandbox` branch previously returned no tools, while Skill initialization still resolved sources on the Runtime host. The goal is to retain `Agent(skills_mode="aio_sandbox", skills=[...])` and the existing `SkillsToolset`, with inference in Runtime and Skill files, commands and file operations in the sandbox. No new Agent field or execution-mode value is introduced. HTTP App/session APIs, Skill installation, other runtimes and additional discovery APIs are outside scope. + +## Requirements and design + +- **FR-1 — Entry point:** Mount `SkillsToolset` even with empty `skills`. In AIO mode, `skills` contains absolute sandbox directories; an empty list uses the sandbox's default directory. Construction must not read host Skill files or load cloud SkillSpace sources. `local` and `skills_sandbox` retain their behavior; AIO callers using SkillSpace IDs must migrate to sandbox directories. +- **FR-2 — Session binding:** Before inference, create or reuse an AgentKit sandbox for the ADK app/user/session identity. Store only tool/session identifiers and the identity digest in ADK state. Subsequent turns reuse that sandbox; expired or non-Ready bindings fail without replacement or local fallback. The application owns instance lifecycle; idle instances expire by TTL. +- **FR-3 — Skill loading:** Obtain names, descriptions and instruction-file paths through `GET /v1/skills`, passing repeated `path` parameters for multiple directories. Read instructions lazily through the sandbox file API. Tool descriptions and closures belong to the execution context; do not rewrite shared Agent instructions or share another Session's Skill catalog. +- **FR-4 — Basic tools:** Route `skills_tool`, `bash_tool`, `read_file_tool`, `write_file_tool` and `edit_file_tool` to the same sandbox. Preserve existing tool names and model-visible arguments. Reads retain line numbering; edits require a unique match unless replacing all occurrences. Caller-supplied custom tools remain the caller's responsibility. +- **FR-5 — Failures and isolation:** Never forward the host environment to commands. Preserve endpoint query parameters and existing authentication; keep credentials out of session state and diagnostics. Reject failed/malformed responses and redirects. Bash has a finite timeout, bounded output and remote kill/close on interruption; cleanup failures remain visible. Do not automatically retry mutations. Applications serialize concurrent turns of one ADK Session. + +Component-contract impact is limited to Agent Skill initialization and the AIO toolset branch described above; no Studio, model configuration or HTTP API contract changes. The implementation uses the existing lease resolver and a small data-plane helper, without monkey patching or a new backend hierarchy. + +## Tasks and acceptance + +| Requirement | Task | Acceptance criterion | Result | +| --- | --- | --- | --- | +| FR-1 | T-1: update Agent initialization and mode dispatch | AC-1: AIO mounts with empty skills and never loads host/cloud Skill sources | pass | +| FR-2/3 | T-2: add session binding and remote discovery/loading | AC-2: isolated catalogs, multiple directories, lazy instruction reads and stable multi-turn binding | pass | +| FR-4/5 | T-3: forward tools and handle failures/cleanup | AC-3: remote file roundtrip, edit semantics, timeout/cancellation, safe transport errors and no local fallback | pass | +| FR-1–5 | T-4: run authorized live acceptance | AC-4: a new Session using `doubao-seed-evolving` calls bash and returns the configured `AGENTKIT_TOOL_ID`; host-only environment is absent; all five tools execute successfully | pass | + +Affected files: `veadk/agent.py`, `veadk/skills/check_skills_callback.py`, `veadk/tools/skills_tools/skills_toolset.py`, `veadk/tools/skills_tools/aio_sandbox.py`, `tests/skills/test_aio_sandbox_skills.py` and the paired Skills user documentation. + +## Review and verification + +The user approved the mode/toolset approach and authorized real model and sandbox acceptance. Direct review covered session isolation, compatibility, cancellation, cleanup and credential handling; no `review-spec` skill was available. The English/Chinese documents carry the same requirements and results. The user authorized commit, push and PR creation; no deployment is included. + +Verification date: 2026-10-26; scope: the working diff on `feat/newapi`, rebased onto `upstream/main` at `23014771`. + +| Check | Command or procedure | Outcome | +| --- | --- | --- | +| Related Python tests | `uv run --extra dev pytest tests/skills tests/tools/test_skills_bash_tool.py tests/tools/test_skills_session_path.py tests/agents -q` | pass: 153 tests | +| Live acceptance | New ADK Session, `doubao-seed-evolving`, actual tool-call responses | pass: Tool ID matched; host environment isolated; file write/read/edit, Skill loading and sandbox reuse verified; temporary fixtures removed | +| Lint, secrets and whitespace | `uv run --extra dev pre-commit run --all-files`; `git diff --check` | pass | +| User documentation | `npm --prefix docs run types:check`; `npm --prefix docs run build` | pass | +| Full regression | `uv run --extra dev pytest -n 2 -m "not codex_smoke and not piagent_smoke" --maxfail=3` | fail before rebasing (base `c662cc8d`): 3409 passed, 8 skipped, 6 failed before stopping; 3 migration-delivery tests require missing `zip`, and 3 Harness resource tests require missing `llama_index` | + +## Limits and risks + +The targeted and live acceptance passed; full-repository regression is not green. Missing executables/optional dependencies were not installed as part of this feature. A transport failure can leave a remote mutation's completion unknown, so such operations are not retried automatically. A sandbox must expose the Skill, file and Bash APIs used by the toolset. No new dependency or unrelated component change is included. diff --git a/prd-spec/features/aio-sandbox-skills/2026-10-26-aio-sandbox-skills.zh.md b/prd-spec/features/aio-sandbox-skills/2026-10-26-aio-sandbox-skills.zh.md new file mode 100644 index 000000000..7473eb676 --- /dev/null +++ b/prd-spec/features/aio-sandbox-skills/2026-10-26-aio-sandbox-skills.zh.md @@ -0,0 +1,48 @@ +# AIO 沙箱 Skill 与基础工具 + +[English](2026-10-26-aio-sandbox-skills.md) + +变更 ID:`aio-sandbox-skills` · 日期:2026-10-26 · 状态:implemented,全仓回归限制见下文。 + +## 背景与范围 + +VeADK 的 `aio_sandbox` 分支此前不返回工具,Skill 初始化仍在 Runtime 宿主机解析来源。目标是保留 `Agent(skills_mode="aio_sandbox", skills=[...])` 和现有 `SkillsToolset`,由 Runtime 推理,沙箱提供 Skill 文件、命令执行及文件操作。不新增 Agent 字段或执行模式值。HTTP App/Session 接口、Skill 安装、其他运行时及额外发现接口不在范围内。 + +## 需求与设计 + +- **FR-1 — 使用入口:** 空 `skills` 也挂载 `SkillsToolset`。AIO 模式下,`skills` 为沙箱内绝对目录,空列表使用沙箱默认目录。构造时不读取宿主机 Skill 文件或加载云端 SkillSpace 来源。`local` 和 `skills_sandbox` 行为保持不变;使用 SkillSpace ID 的 AIO 调用方需改为沙箱目录。 +- **FR-2 — Session 绑定:** 推理前,按 ADK app/user/session 身份创建或复用 AgentKit 沙箱。ADK state 仅保存 tool/session 标识和身份摘要。后续对话复用同一沙箱;过期或非 Ready 绑定直接失败,不更换实例、不回退本地。应用负责实例生命周期,闲置实例按 TTL 过期。 +- **FR-3 — Skill 加载:** 通过 `GET /v1/skills` 获取名称、描述和说明文件路径;多个目录使用重复 `path` 参数。通过沙箱文件 API 按需读取说明。工具描述和闭包归属执行上下文,不修改共享 Agent instruction,不共用其他 Session 的 Skill 目录视图。 +- **FR-4 — 基础工具:** `skills_tool`、`bash_tool`、`read_file_tool`、`write_file_tool`、`edit_file_tool` 均访问同一沙箱。保留已有工具名称及模型可见参数。读取保留行号;编辑要求唯一匹配,除非显式全部替换。调用方额外传入的自定义工具仍由调用方负责。 +- **FR-5 — 失败与隔离:** 不将宿主机环境传入命令。保留 endpoint 查询参数及已有认证;凭据不进入 Session state 或诊断信息。拒绝失败/格式错误响应及重定向。Bash 使用有限超时、输出上限,中断时远端 kill/close,清理失败明确可见。不自动重试写操作。同一 ADK Session 的并发 turn 由应用串行化。 + +组件契约影响仅涉及上述 Agent Skill 初始化和 AIO 工具集分支,不修改 Studio、模型配置或 HTTP API 契约。实现复用已有 lease 解析器,增加小型数据面 helper,不使用 monkey patch 或新增后端继承体系。 + +## 任务与验收 + +| 需求 | 任务 | 验收标准 | 结果 | +| --- | --- | --- | --- | +| FR-1 | T-1:调整 Agent 初始化和模式分派 | AC-1:空 skills 也挂载 AIO,不加载宿主机/云端 Skill 来源 | pass | +| FR-2/3 | T-2:实现 Session 绑定和远端发现/加载 | AC-2:目录视图隔离、多个目录、按需读取说明、多轮绑定稳定 | pass | +| FR-4/5 | T-3:转发工具并处理失败/清理 | AC-3:远端文件往返、编辑语义、超时/取消、安全传输错误及不回退本地 | pass | +| FR-1–5 | T-4:执行已授权真实验收 | AC-4:新 Session 使用 `doubao-seed-evolving` 调用 bash,返回配置的 `AGENTKIT_TOOL_ID`;宿主机专有环境变量不存在;五个工具均执行成功 | pass | + +影响文件:`veadk/agent.py`、`veadk/skills/check_skills_callback.py`、`veadk/tools/skills_tools/skills_toolset.py`、`veadk/tools/skills_tools/aio_sandbox.py`、`tests/skills/test_aio_sandbox_skills.py` 及双语 Skills 用户文档。 + +## 评审与验证 + +用户已确认模式/工具集方案,并授权真实模型和沙箱验收。直接评审覆盖 Session 隔离、兼容性、取消、清理及凭据处理;无可用 `review-spec` 技能。中英文文档的需求及结果一致。用户已授权提交、推送和创建 PR,不包含部署。 + +验证日期:2026-10-26;范围:`feat/newapi` 工作区差异,已变基到 `upstream/main` 的 `23014771`。 + +| 检查 | 命令或过程 | 结果 | +| --- | --- | --- | +| 相关 Python 测试 | `uv run --extra dev pytest tests/skills tests/tools/test_skills_bash_tool.py tests/tools/test_skills_session_path.py tests/agents -q` | pass:153 个测试 | +| 真实验收 | 新 ADK Session、`doubao-seed-evolving`、实际工具调用返回值 | pass:Tool ID 一致,宿主机环境隔离,文件写/读/改、Skill 加载及沙箱复用均验证;临时验收文件已清理 | +| Lint、凭据与空白检查 | `uv run --extra dev pre-commit run --all-files`;`git diff --check` | pass | +| 用户文档 | `npm --prefix docs run types:check`;`npm --prefix docs run build` | pass | +| 全仓回归 | `uv run --extra dev pytest -n 2 -m "not codex_smoke and not piagent_smoke" --maxfail=3` | fail(变基前,基线 `c662cc8d`):停止前 3409 通过、8 跳过、6 失败;3 个迁移交付测试缺少 `zip`,3 个 Harness 资源测试缺少 `llama_index` | + +## 限制与风险 + +定向及真实验收通过,全仓回归未全绿。本功能未安装缺失的可执行程序/可选依赖。传输失败时远端写操作可能已完成,因此不自动重试。沙箱须提供工具集使用的 Skill、文件和 Bash API。不新增依赖或修改无关组件。 diff --git a/tests/skills/test_aio_sandbox_skills.py b/tests/skills/test_aio_sandbox_skills.py new file mode 100644 index 000000000..351a6cf22 --- /dev/null +++ b/tests/skills/test_aio_sandbox_skills.py @@ -0,0 +1,414 @@ +"""Session-bound AIO skills must never execute on the Runtime host.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest + +from veadk import Agent +from veadk.tools.skills_tools.skills_toolset import SkillsToolset + + +@pytest.fixture(autouse=True) +def isolate_cloud(monkeypatch): + from veadk.skills import check_skills_callback + from veadk.tools.skills_tools import aio_sandbox + + def forbidden(*args, **kwargs): + raise AssertionError("Unit tests must not access cloud or host Skill sources") + + monkeypatch.setattr(check_skills_callback, "load_skills_from_cloud", forbidden) + monkeypatch.setattr(check_skills_callback, "load_skills_from_directory", forbidden) + monkeypatch.setattr(aio_sandbox, "create_agentkit_client", forbidden) + monkeypatch.setattr(aio_sandbox, "ensure_agentkit_session_lease", forbidden) + + +def context(session_id="s1", state=None): + session = SimpleNamespace( + app_name="app", + user_id="user", + id=session_id, + state={} if state is None else state, + ) + return SimpleNamespace(session=session, state=session.state) + + +def test_aio_construction_never_loads_host_sources(): + with patch("veadk.skills.check_skills_callback._load_sources") as load: + agent = Agent( + name="aio", + skills_mode="aio_sandbox", + skills=["/remote/skills"], + model_api_key="test", + ) + load.assert_not_called() + assert any(isinstance(t, SkillsToolset) for t in agent.tools) + empty = Agent(name="aio", skills_mode="aio_sandbox", model_api_key="test") + assert any(isinstance(t, SkillsToolset) for t in empty.tools) + + +def test_aio_rejects_cloud_ids_as_paths(): + with pytest.raises(ValueError, match="absolute"): + Agent( + name="aio", + skills_mode="aio_sandbox", + skills=["ss-cloud"], + model_api_key="test", + ) + + +@pytest.mark.asyncio +async def test_binding_identity_and_reuse(monkeypatch): + from veadk.tools.skills_tools import aio_sandbox as aio + + monkeypatch.setenv("AGENTKIT_TOOL_ID", "t-test") + lease = SimpleNamespace( + tool_id="t-test", + session_id="physical", + status="Ready", + endpoint="https://sandbox/", + internal_endpoint="", + expire_at="2099-01-01T00:00:00Z", + ) + ensure = Mock(return_value=lease) + client = Mock() + client.get_session.return_value = lease + monkeypatch.setattr(aio, "ensure_agentkit_session_lease", ensure) + monkeypatch.setattr(aio, "create_agentkit_client", lambda: client) + one, two = context(), context("s2") + await aio.prepare_aio_sandbox(one) + await aio.prepare_aio_sandbox(one) + await aio.prepare_aio_sandbox(two) + assert ensure.call_count == 2 + assert ( + ensure.call_args_list[0].kwargs["tool_user_session_id"] + != ensure.call_args_list[1].kwargs["tool_user_session_id"] + ) + assert set(one.state[aio.STATE_KEY]) == {"tool_id", "session_id", "identity"} + lease.status = "Failed" + with pytest.raises(RuntimeError, match="Ready"): + await aio.prepare_aio_sandbox(one) + assert ensure.call_count == 2 + + +@pytest.mark.asyncio +async def test_remote_catalog_tools_and_query_are_session_scoped(monkeypatch): + from veadk.tools.skills_tools import aio_sandbox as aio + + calls = [] + + def handler(req): + calls.append(req) + if req.url.path == "/v1/skills": + return httpx.Response( + 200, + json={ + "success": True, + "data": { + "skills": [ + { + "name": req.url.host, + "description": "remote skill", + "path": "/workspace/skills/demo/SKILL.md", + } + ] + }, + }, + ) + return httpx.Response( + 200, json={"success": True, "data": {"content": "remote instructions"}} + ) + + original = httpx.AsyncClient + monkeypatch.setattr( + aio.httpx, + "AsyncClient", + lambda **kw: original(transport=httpx.MockTransport(handler), **kw), + ) + monkeypatch.setattr( + aio, + "resolve_endpoint", + AsyncMock(side_effect=lambda ctx: "https://" + ctx.session.id + "/?route=keep"), + ) + tools = SkillsToolset({}, "aio_sandbox") + tools._aio_paths = ["/workspace/skills", "/other"] + one, two = ( + await tools.get_tools(context("one")), + await tools.get_tools(context("two")), + ) + assert {t.name for t in one} == { + "skills_tool", + "bash_tool", + "read_file_tool", + "write_file_tool", + "edit_file_tool", + } + assert "one" in one[0].description and "two" not in one[0].description + assert "two" in two[0].description + assert calls[0].url.params.get_list("path") == ["/workspace/skills", "/other"] + assert calls[0].url.params["route"] == "keep" + assert await one[0].func("one") == ( + "Base directory for this skill: /workspace/skills/demo\n\nremote instructions" + ) + assert calls[-1].url.params["route"] == "keep" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response", + [ + httpx.Response(401, text="secret-body"), + httpx.Response(200, json={"success": False, "message": "secret-body"}), + httpx.Response(200, text="invalid secret-body"), + ], +) +async def test_transport_errors_redacted_and_not_retried(monkeypatch, response): + from veadk.tools.skills_tools import aio_sandbox as aio + + seen = [] + + def handler(req): + seen.append(req) + return response + + original = httpx.AsyncClient + monkeypatch.setattr( + aio.httpx, + "AsyncClient", + lambda **kw: original(transport=httpx.MockTransport(handler), **kw), + ) + with pytest.raises(RuntimeError) as error: + await aio.Sandbox("https://sandbox/?token=secret").call( + "POST", "/v1/file/write", {"file": "/test", "content": "x"} + ) + assert "secret" not in str(error.value) + assert len(seen) == 1 + + +@pytest.mark.asyncio +async def test_bash_polling_failure_and_cleanup(): + from veadk.tools.skills_tools.aio_sandbox import Sandbox + + sandbox = Sandbox("https://sandbox") + calls = [] + + async def call(method, path, body=None, **kwargs): + calls.append((path, body)) + if path == "/v1/bash/exec": + return { + "status": "running", + "command_id": "cmd", + "stdout": "start", + "offset": 5, + "stderr_offset": 0, + } + if path == "/v1/bash/output": + return { + "stdout": "end", + "stderr": "", + "offset": 8, + "stderr_offset": 0, + "command": {"status": "completed", "exit_code": 0}, + } + return {} + + sandbox.call = call + assert await sandbox.bash("echo sandbox", 5) == "startend" + assert calls[-1][0].endswith("/close") + assert all("env" not in body for _, body in calls if body) + + +@pytest.mark.asyncio +async def test_bash_cancellation_kills_and_closes(): + from veadk.tools.skills_tools.aio_sandbox import Sandbox + + sandbox = Sandbox("https://sandbox") + entered = asyncio.Event() + calls = [] + + async def call(method, path, body=None, **kwargs): + calls.append(path) + if path == "/v1/bash/exec": + entered.set() + await asyncio.Event().wait() + return {} + + sandbox.call = call + task = asyncio.create_task(sandbox.bash("sleep 100", 10)) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert "/v1/bash/kill" in calls + assert calls[-1].endswith("/close") + + +@pytest.mark.asyncio +async def test_remote_file_contract_and_editor_errors(monkeypatch): + from veadk.tools.skills_tools import aio_sandbox as aio + + sandbox = aio.Sandbox("https://sandbox") + calls = [] + + async def call(method, path, body=None, **kwargs): + calls.append((path, body)) + if path == "/v1/skills": + return {"skills": []} + if path == "/v1/file/read": + return {"content": "second\nthird\n"} + return {"output": "edited"} + + sandbox.call = call + monkeypatch.setattr(aio, "Sandbox", lambda *args: sandbox) + monkeypatch.setattr( + aio, "resolve_endpoint", AsyncMock(return_value="https://sandbox") + ) + tools = {t.name: t for t in await aio.build_aio_tools(context(), [])} + assert ( + await tools["read_file_tool"].func("test", 2, 2) + == " 2|second\n 3|third" + ) + assert calls[-1] == ( + "/v1/file/read", + {"file": "/workspace/test", "start_line": 1, "end_line": 3}, + ) + await tools["write_file_tool"].func("test", "contents") + assert calls[-1] == ( + "/v1/file/write", + {"file": "/workspace/test", "content": "contents"}, + ) + await tools["edit_file_tool"].func("test", "old", "new", False) + assert calls[-1][1]["replace_mode"] is None + await tools["edit_file_tool"].func("test", "old", "new", True) + assert calls[-1][1]["replace_mode"] == "ALL" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "exit_code,status", [(1, "completed"), (None, "timed_out"), (None, "killed")] +) +async def test_bash_terminal_errors_are_not_success(exit_code, status): + from veadk.tools.skills_tools.aio_sandbox import Sandbox + + sandbox = Sandbox("https://sandbox") + + async def call(method, path, body=None, **kwargs): + return ( + {"status": status, "exit_code": exit_code} + if path == "/v1/bash/exec" + else {} + ) + + sandbox.call = call + with pytest.raises(RuntimeError, match="AIO bash failed"): + await sandbox.bash("false", 5) + + +@pytest.mark.asyncio +async def test_timeout_kills_and_closes_and_cleanup_failures_are_visible(): + from veadk.tools.skills_tools.aio_sandbox import Sandbox + + sandbox = Sandbox("https://sandbox") + calls = [] + + async def call(method, path, body=None, **kwargs): + calls.append(path) + if path == "/v1/bash/exec": + await asyncio.Event().wait() + return {} + + sandbox.call = call + with pytest.raises(asyncio.TimeoutError): + await sandbox.bash("sleep 100", 0.01) + assert "/v1/bash/kill" in calls and calls[-1].endswith("/close") + + async def failing_close(method, path, body=None, **kwargs): + if path.endswith("/close"): + raise RuntimeError("unavailable") + return {"status": "completed", "exit_code": 0} + + sandbox.call = failing_close + with pytest.raises(RuntimeError, match="cleanup failed"): + await sandbox.bash("true", 5) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "data", + [{"skills": [{"name": "bad"}]}, {"skills": None}, {"error": "private details"}], +) +async def test_invalid_catalog_and_editor_failure_never_succeed(monkeypatch, data): + from veadk.tools.skills_tools import aio_sandbox as aio + + original = httpx.AsyncClient + monkeypatch.setattr( + aio.httpx, + "AsyncClient", + lambda **kw: original( + transport=httpx.MockTransport( + lambda req: httpx.Response(200, json={"success": True, "data": data}) + ), + **kw, + ), + ) + monkeypatch.setattr( + aio, "resolve_endpoint", AsyncMock(return_value="https://sandbox") + ) + with pytest.raises(RuntimeError): + await aio.build_aio_tools(context(), []) + + +@pytest.mark.asyncio +async def test_cancellation_during_shell_close_is_preserved(): + from veadk.tools.skills_tools.aio_sandbox import Sandbox + + sandbox = Sandbox("https://sandbox") + closing = asyncio.Event() + released = asyncio.Event() + closed = asyncio.Event() + + async def call(method, path, body=None, **kwargs): + if path.endswith("/close"): + closing.set() + await released.wait() + closed.set() + return {"status": "completed", "exit_code": 0} + + sandbox.call = call + task = asyncio.create_task(sandbox.bash("true", 5)) + await closing.wait() + task.cancel() + released.set() + with pytest.raises(asyncio.CancelledError): + await task + assert closed.is_set() + + +@pytest.mark.asyncio +async def test_api_key_stays_in_headers_and_redirect_is_not_followed(monkeypatch): + from veadk.tools.skills_tools import aio_sandbox as aio + + monkeypatch.setenv("SANDBOX_API_KEY", "test-sandbox-key") + headers = await aio._headers(context()) + calls = [] + + def handler(req): + calls.append(req) + return httpx.Response(302, headers={"location": "https://untrusted/"}) + + original = httpx.AsyncClient + monkeypatch.setattr( + aio.httpx, + "AsyncClient", + lambda **kw: original(transport=httpx.MockTransport(handler), **kw), + ) + with pytest.raises(RuntimeError, match="HTTP 302"): + await aio.Sandbox("https://sandbox/?route=preserved", headers).call( + "GET", "/v1/skills" + ) + assert len(calls) == 1 + assert calls[0].headers["x-api-key"] == "test-sandbox-key" + assert calls[0].url.params["route"] == "preserved" + assert b"test-sandbox-key" not in calls[0].content diff --git a/veadk/agent.py b/veadk/agent.py index c8fbf8893..248985f61 100644 --- a/veadk/agent.py +++ b/veadk/agent.py @@ -565,7 +565,11 @@ def model_post_init(self, __context: Any) -> None: else: self.after_agent_callback = save_session_to_long_term_memory - if self.skills or self.enable_dynamic_load_skills: + if ( + self.skills + or self.enable_dynamic_load_skills + or self.skills_mode == "aio_sandbox" + ): self.load_skills() if self.enable_skills_checklist: logger.info("Skills checklist enabled") @@ -756,7 +760,11 @@ def load_skills(self): initialize_skills(self) - if self.enable_dynamic_load_skills: + if self.enable_dynamic_load_skills or self.skills_mode == "aio_sandbox": + if self.skills_mode == "aio_sandbox": + from veadk.tools.skills_tools.aio_sandbox import prepare_aio_sandbox + + check_skills = prepare_aio_sandbox if self.before_agent_callback: if isinstance(self.before_agent_callback, list): self.before_agent_callback.append(check_skills) diff --git a/veadk/skills/check_skills_callback.py b/veadk/skills/check_skills_callback.py index 76f7509ce..cadb92cde 100644 --- a/veadk/skills/check_skills_callback.py +++ b/veadk/skills/check_skills_callback.py @@ -154,6 +154,13 @@ def initialize_skills(agent): if agent.skills_mode not in {"local", "skills_sandbox", "aio_sandbox"}: raise ValueError(f"Unsupported skill mode {agent.skills_mode}") toolset = SkillsToolset({}, agent.skills_mode) + if agent.skills_mode == "aio_sandbox": + from veadk.tools.skills_tools.aio_sandbox import validate_paths + + validate_paths(agent.skills) + toolset._aio_paths = list(agent.skills) + agent.tools.append(toolset) + return toolset._refresh_state = _RefreshState(base_instruction=agent.instruction) sources, issues = _load_sources(agent.skills, toolset) _apply(agent, toolset, _flatten(sources), sources, issues) diff --git a/veadk/tools/skills_tools/aio_sandbox.py b/veadk/tools/skills_tools/aio_sandbox.py new file mode 100644 index 000000000..e7aa30802 --- /dev/null +++ b/veadk/tools/skills_tools/aio_sandbox.py @@ -0,0 +1,419 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# SPDX-License-Identifier: Apache-2.0 + +"""Session-bound Skill discovery and tools for the AIO sandbox data plane.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import math +import os +import posixpath +import sys +import uuid +from datetime import datetime, timezone + +import httpx +from google.adk.tools import FunctionTool + +from veadk.agents._remote_sandbox.client import create_agentkit_client +from veadk.agents._remote_sandbox.session import ensure_agentkit_session_lease +from veadk.agents._remote_sandbox.timeout import timeout as execution_timeout +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) +STATE_KEY = "_veadk_aio_sandbox" +_MAX_RESPONSE = 4 * 1024 * 1024 +_MAX_OUTPUT = 50_000 +_TERMINAL = {"completed", "killed", "timed_out"} + + +def validate_paths(paths): + if any(not path.startswith("/") or "\0" in path for path in paths): + raise ValueError("aio_sandbox skills must be absolute sandbox directory paths") + + +def _identity(context): + session = context.session + return hashlib.sha256( + json.dumps([session.app_name, session.user_id, session.id]).encode() + ).hexdigest() + + +def _bound_session(binding, identity): + from agentkit.sdk.tools.types import GetSessionRequest + + if ( + not isinstance(binding, dict) + or binding.get("identity") != identity + or binding.get("tool_id") != os.environ.get("AGENTKIT_TOOL_ID") + or not binding.get("session_id") + ): + raise RuntimeError("AIO sandbox binding does not match this Session") + try: + session = create_agentkit_client().get_session( + GetSessionRequest( + ToolId=binding["tool_id"], SessionId=binding["session_id"] + ) + ) + except Exception: + raise RuntimeError("Unable to resolve bound AIO sandbox") from None + if (session.status or "").lower() != "ready": + raise RuntimeError("Bound AIO sandbox is not Ready") + expiry = getattr(session, "expire_at", None) + if expiry: + try: + expires = datetime.fromisoformat(expiry.replace("Z", "+00:00")) + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + if expires <= datetime.now(timezone.utc): + raise RuntimeError("Bound AIO sandbox has expired") + except ValueError: + raise RuntimeError("Invalid AIO sandbox expiry") from None + return session + + +async def prepare_aio_sandbox(callback_context): + """Bind once before inference; persist identifiers through ADK state deltas.""" + identity = _identity(callback_context) + binding = callback_context.state.get(STATE_KEY) + if binding is not None: + await asyncio.to_thread(_bound_session, binding, identity) + return None + tool_id = os.environ.get("AGENTKIT_TOOL_ID") + if not tool_id: + raise ValueError("AGENTKIT_TOOL_ID is required for aio_sandbox") + try: + lease = await asyncio.to_thread( + ensure_agentkit_session_lease, + tool_id=tool_id, + tool_user_session_id="veadk-aio-" + identity, + ) + except Exception: + raise RuntimeError("Unable to bind AIO sandbox Session") from None + callback_context.state[STATE_KEY] = { + "tool_id": tool_id, + "session_id": lease.session_id, + "identity": identity, + } + return None + + +async def resolve_endpoint(context): + binding = context.state.get(STATE_KEY) + if binding is None: + raise RuntimeError("AIO sandbox Session has not been bound") + session = await asyncio.to_thread(_bound_session, binding, _identity(context)) + endpoint = session.endpoint or session.internal_endpoint + if not endpoint: + raise RuntimeError("Bound AIO sandbox has no endpoint") + return endpoint + + +async def _headers(context): + headers = {} + key = os.environ.get("SANDBOX_API_KEY") + if key: + headers["X-API-Key"] = key + invocation = getattr(context, "_invocation_context", None) + if invocation is not None and invocation.credential_service: + from google.adk.agents.callback_context import CallbackContext + from veadk.utils.auth import build_auth_config + + credential = await invocation.credential_service.load_credential( + auth_config=build_auth_config( + credential_key="inbound_auth", + auth_method="header", + header_scheme="bearer", + ), + callback_context=CallbackContext(invocation), + ) + if credential: + token = credential.api_key + if not token and credential.http and credential.http.credentials: + token = credential.http.credentials.token + if not token and credential.oauth2: + token = credential.oauth2.access_token + if token: + headers["inbound_auth"] = token + return headers + + +class Sandbox: + """No shared connection, filesystem or environment from the Runtime host.""" + + def __init__(self, endpoint, headers=None): + try: + self.url = httpx.URL(endpoint) + except Exception: + raise ValueError("Invalid AIO sandbox endpoint") from None + if ( + self.url.scheme not in {"https", "http"} + or not self.url.host + or self.url.userinfo + or self.url.fragment + ): + raise ValueError("Invalid AIO sandbox endpoint") + self.headers = headers or {} + + async def call(self, method, path, body=None, *, paths=None): + url = self.url.copy_with(path=self.url.path.rstrip("/") + path) + if paths: + url = url.copy_merge_params([("path", p) for p in paths]) + try: + async with httpx.AsyncClient( + headers=self.headers, + timeout=httpx.Timeout(30, connect=10), + follow_redirects=False, + trust_env=False, + ) as client: + async with client.stream(method, url, json=body) as response: + if response.status_code != 200: + raise RuntimeError(f"AIO sandbox HTTP {response.status_code}") + raw = bytearray() + async for chunk in response.aiter_bytes(): + raw.extend(chunk) + if len(raw) > _MAX_RESPONSE: + raise RuntimeError( + "AIO sandbox response exceeds size limit" + ) + except httpx.HTTPError: + raise RuntimeError( + "AIO sandbox transport failure; operation may have completed" + ) from None + try: + envelope = json.loads(raw) + except (ValueError, UnicodeError): + raise RuntimeError("Invalid AIO sandbox response") from None + if not isinstance(envelope, dict) or envelope.get("success") is not True: + raise RuntimeError("AIO sandbox operation failed") + data = envelope.get("data") + if data is None: + return {} + if not isinstance(data, dict): + raise RuntimeError("Invalid AIO sandbox result") + if data.get("error"): + raise RuntimeError("AIO sandbox editor failed") + return data + + async def bash(self, command, timeout): + if not command: + raise ValueError("command must not be empty") + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("timeout must be finite and positive") + sid = "veadk-" + uuid.uuid4().hex + terminal = False + output = "" + truncated = False + + def append(result): + nonlocal output, truncated + chunk = (result.get("stdout") or "") + (result.get("stderr") or "") + truncated = truncated or len(output) + len(chunk) > _MAX_OUTPUT + output = (output + chunk)[:_MAX_OUTPUT] + + async def cleanup(): + failed = False + if not terminal: + try: + await self.call( + "POST", + "/v1/bash/kill", + {"session_id": sid, "signal": "SIGKILL"}, + ) + except Exception: + failed = True + try: + await self.call("POST", f"/v1/bash/sessions/{sid}/close", {}) + except Exception: + failed = True + if failed: + raise RuntimeError( + "AIO sandbox shell cleanup failed; execution state is unknown" + ) + + try: + async with execution_timeout(timeout): + await self.call( + "POST", + "/v1/bash/sessions/create", + {"session_id": sid, "exec_dir": "/workspace"}, + ) + result = await self.call( + "POST", + "/v1/bash/exec", + { + "session_id": sid, + "command": command, + "async_mode": True, + "hard_timeout": timeout, + "max_output_length": _MAX_OUTPUT, + }, + ) + append(result) + while result.get("status") not in _TERMINAL: + if not result.get("command_id"): + raise RuntimeError("Invalid AIO bash command response") + data = await self.call( + "POST", + "/v1/bash/output", + { + "session_id": sid, + "command_id": result["command_id"], + "offset": result.get("offset", 0), + "stderr_offset": result.get("stderr_offset", 0), + "wait": True, + "wait_timeout": 1, + }, + ) + append(data) + if data.get("stdout_gap") or data.get("stderr_gap"): + raise RuntimeError("AIO bash output was lost") + info = data.get("command") + if not isinstance(info, dict): + raise RuntimeError("Invalid AIO bash output response") + result.update(info) + result.update( + offset=data.get("offset", 0), + stderr_offset=data.get("stderr_offset", 0), + ) + if result.get("status") not in _TERMINAL: + await asyncio.sleep(0.1) + terminal = True + if type(result.get("exit_code")) not in {int, type(None)}: + raise RuntimeError("Invalid AIO bash exit code") + if result["status"] != "completed" or result.get("exit_code") != 0: + raise RuntimeError( + f"AIO bash failed ({result['status']}, exit_code={result.get('exit_code')})" + ) + return output + ("\n[output truncated]" if truncated else "") + finally: + primary = sys.exc_info()[1] + task = asyncio.create_task(cleanup()) + try: + await asyncio.wait_for(asyncio.shield(task), 10) + except asyncio.CancelledError: + # A new cancellation can arrive while successful execution is + # closing its shell. Finish cleanup, then preserve cancellation. + try: + await asyncio.wait_for(asyncio.shield(task), 10) + except (Exception, asyncio.CancelledError): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + logger.error( + "AIO sandbox shell cleanup failed; execution state is unknown" + ) + raise + except Exception: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if primary is None: + raise RuntimeError( + "AIO sandbox shell cleanup failed; execution state is unknown" + ) from None + logger.error( + "AIO sandbox shell cleanup failed; execution state is unknown" + ) + + +def _path(file_path): + if not file_path or "\0" in file_path: + raise ValueError("file_path must be nonempty") + return posixpath.normpath(posixpath.join("/workspace", file_path)) + + +async def build_aio_tools(context, paths): + sandbox = Sandbox(await resolve_endpoint(context), await _headers(context)) + catalog = await sandbox.call("GET", "/v1/skills", paths=paths) + skills = catalog.get("skills") + if not isinstance(skills, list): + raise RuntimeError("Invalid AIO Skill catalog") + by_name = {} + for skill in skills: + if not isinstance(skill, dict) or any( + not isinstance(skill.get(k), str) or not skill[k].strip() + for k in ("name", "description", "path") + ): + raise RuntimeError("Invalid AIO Skill metadata") + if skill["name"] in by_name or not skill["path"].startswith("/"): + raise RuntimeError("Invalid or duplicate AIO Skill metadata") + by_name[skill["name"]] = skill + + async def skills_tool(command: str) -> str: + """Load a Skill by name, then follow its instructions using sandbox tools.""" + if command not in by_name: + raise ValueError("Unknown sandbox Skill") + data = await sandbox.call( + "POST", "/v1/file/read", {"file": by_name[command]["path"]} + ) + if not isinstance(data.get("content"), str): + raise RuntimeError("Invalid AIO Skill content") + return ( + f"Base directory for this skill: {posixpath.dirname(by_name[command]['path'])}\n\n" + + data["content"] + ) + + async def bash_tool(command: str, description: str, timeout: float = 600.0) -> str: + """Execute a command inside the bound sandbox, in /workspace. No host environment is forwarded.""" + return await sandbox.bash(command, timeout) + + async def read_file_tool(file_path: str, offset: int, limit: int) -> str: + """Read a sandbox file with line numbers. Offset is 1-based; zero limit reads all lines.""" + if offset < 0 or limit < 0: + raise ValueError("offset and limit must be nonnegative") + # FileRead ranges are zero-based; format the selected text like local tools. + start = max(offset - 1, 0) + body = {"file": _path(file_path), "start_line": start} + if limit: + body["end_line"] = start + limit + data = await sandbox.call("POST", "/v1/file/read", body) + if not isinstance(data.get("content"), str): + raise RuntimeError("Invalid AIO file content") + return ( + "\n".join( + f"{i:6d}|{line[:2000]}" + for i, line in enumerate(data["content"].splitlines(), start + 1) + ) + or "File is empty." + ) + + async def write_file_tool(file_path: str, content: str) -> str: + """Write a file in the bound sandbox, overwriting existing content.""" + await sandbox.call( + "POST", "/v1/file/write", {"file": _path(file_path), "content": content} + ) + return f"Successfully wrote to {file_path}" + + async def edit_file_tool( + file_path: str, old_string: str, new_string: str, replace_all: bool + ) -> str: + """Edit a sandbox file by exact match; require a unique match unless replace_all is true.""" + if not old_string or old_string == new_string: + raise ValueError("old_string must be nonempty and differ from new_string") + data = await sandbox.call( + "POST", + "/v1/file/str_replace_editor", + { + "command": "str_replace", + "path": _path(file_path), + "old_str": old_string, + "new_str": new_string, + "replace_mode": "ALL" if replace_all else None, + }, + ) + return data.get("output", "File edited successfully") + + skill_tool = FunctionTool(skills_tool) + skill_tool.description += ( + "\nAvailable Skills (paths are inside the sandbox):\n" + + json.dumps(skills, ensure_ascii=False) + ) + return [ + skill_tool, + *( + FunctionTool(fn) + for fn in (bash_tool, read_file_tool, write_file_tool, edit_file_tool) + ), + ] diff --git a/veadk/tools/skills_tools/skills_toolset.py b/veadk/tools/skills_tools/skills_toolset.py index ea53768f6..6ab645366 100644 --- a/veadk/tools/skills_tools/skills_toolset.py +++ b/veadk/tools/skills_tools/skills_toolset.py @@ -70,6 +70,7 @@ def __init__( super().__init__() self.skills_mode = skills_mode + self._aio_paths = [] self._refresh_state = None self._tools = self.build_tools(skills) @@ -126,7 +127,11 @@ async def get_tools( return [] case "aio_sandbox": - return [] + from veadk.tools.skills_tools.aio_sandbox import build_aio_tools + + if readonly_context is None: + raise ValueError("aio_sandbox tools require a Session context") + return await build_aio_tools(readonly_context, self._aio_paths) case _: logger.warning( From 30ea7f12abf0f13d57435e238122e6f849edd00d Mon Sep 17 00:00:00 2001 From: "hanzhi.421" Date: Mon, 26 Oct 2026 20:50:08 +0800 Subject: [PATCH 2/2] fix(skills): add complete Apache license headers for CI --- tests/skills/test_aio_sandbox_skills.py | 14 ++++++++++++++ veadk/tools/skills_tools/aio_sandbox.py | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/skills/test_aio_sandbox_skills.py b/tests/skills/test_aio_sandbox_skills.py index 351a6cf22..c844e3a71 100644 --- a/tests/skills/test_aio_sandbox_skills.py +++ b/tests/skills/test_aio_sandbox_skills.py @@ -1,3 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Session-bound AIO skills must never execute on the Runtime host.""" import asyncio diff --git a/veadk/tools/skills_tools/aio_sandbox.py b/veadk/tools/skills_tools/aio_sandbox.py index e7aa30802..39792a9d5 100644 --- a/veadk/tools/skills_tools/aio_sandbox.py +++ b/veadk/tools/skills_tools/aio_sandbox.py @@ -1,5 +1,16 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. -# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. """Session-bound Skill discovery and tools for the AIO sandbox data plane."""