diff --git a/docs/content/docs/framework/agent/skills.en.mdx b/docs/content/docs/framework/agent/skills.en.mdx index 13c20bef..17b73853 100644 --- a/docs/content/docs/framework/agent/skills.en.mdx +++ b/docs/content/docs/framework/agent/skills.en.mdx @@ -2,60 +2,94 @@ title: "Skills" --- -Skills are reusable "prompt packages" that inject specific domain knowledge, operating procedures, and scripts into an Agent. Instead of stuffing all prompts into `instruction` at once, skills use **progressive prompt loading**: the Agent loads a skill's full content only when needed, which keeps the context length under control and improves execution accuracy. +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. -VeADK provides three skill execution modes: +## Recommended: ADK SkillToolset -| Mode | Description | -| :- | :- | -| `local` | Skills live in a local directory and are loaded and executed locally by the Agent. | -| `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. | +Local skills can be loaded directly by Google ADK and passed to a VeADK Agent as a standard toolset: -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. +```python +from google.adk.skills import load_skill_from_dir +from google.adk.tools.skill_toolset import SkillToolset +from veadk import Agent + +skill = load_skill_from_dir("/abs/path/to/skills/kb-skill") +agent = Agent( + tools=[SkillToolset(skills=[skill])], +) +``` + +On this path, skill loading, prompt injection, and tool exposure are handled by ADK. The model sees ADK's official skill tools: `list_skills`, `load_skill`, `load_skill_resource`, and `run_skill_script`. If a skill depends on `scripts/`, configure an appropriate `code_executor` explicitly on the `SkillToolset` or Agent; VeADK does not create a local code executor by default. + +Cloud SkillHub / SkillSpace skills can be downloaded and converted into ADK `Skill` objects first, then passed the same way: + +```python +from google.adk.tools.skill_toolset import SkillToolset +from veadk import Agent +from veadk.skills import load_skills_from_source + +skills = load_skills_from_source(skill_space_id) +agent = Agent( + tools=[SkillToolset(skills=skills)], +) +``` + +`load_skills_from_source` fetches remote metadata, downloads the zip, extracts it safely, runs ADK validation, and fails fast when anything is invalid. ## Skill Directory Structure -A local skill consists of its own directory containing a single `SKILL.md` file: +An ADK local skill consists of its own directory containing a `SKILL.md` file. The frontmatter must include `name` and `description`, and the directory name must match `name`: - + + + + -`SKILL.md` declares `name` and `description` in frontmatter, followed by the skill body: - -```markdown title="skills/kb_skill/SKILL.md" +```markdown title="skills/kb-skill/SKILL.md" --- -name: kb_skill +name: kb-skill +description: Query the knowledge base and compose an answer. --- ...body (operating steps, constraints, script descriptions, etc.) ``` - -The `name` in `SKILL.md` must match the directory name. - +## Deprecated: VeADK Legacy Local Entry + +`Agent(skills=..., skills_mode="local")` keeps its legacy behavior, but this local entry point is deprecated. Whether `skills` contains local directories or remote skill sources, once the final mode is `local`, VeADK uses the legacy local loading path: it loads skill metadata, writes the skill list into `instruction`, and mounts the legacy `SkillsToolset`. + +The `skills` parameter still supports three skill execution modes. Only the legacy `local` loading and execution path is deprecated: + +| Mode | Description | +| :- | :- | +| `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. | + +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. -## Local Mode +### Local Mode -In `local` mode, pass the skill directory path to `skills` and set `skills_mode` to `local`: +In `local` mode, pass the skills root directory path to `skills` and set `skills_mode` to `local`: ```python from veadk import Agent agent = Agent( - skills=["/abs/path/to/skills/kb_skill"], + skills=["/abs/path/to/skills"], skills_mode="local", ) ``` During initialization, the Agent loads each skill's metadata (`name` and `description`) and injects it into the system prompt, guiding the model to invoke the corresponding skill at the right time. In local mode, VeADK also automatically mounts a set of supporting tools (read/write files, edit files, run shell commands, register skills, etc.) for the Agent to use when executing a skill. -## Sandbox Mode +### Sandbox Mode In `skills_sandbox` mode, skills are hosted in a cloud Skill Space. Pass the cloud skill space identifier to `skills` and add the `execute_skills` tool; the Agent invokes that tool to execute the chosen skill in the sandbox when needed: @@ -65,11 +99,12 @@ from veadk.tools.builtin_tools.execute_skills import execute_skills agent = Agent( skills=[skill_space_id], + skills_mode="skills_sandbox", tools=[execute_skills], ) ``` -## Skill Checklist +### 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: @@ -77,7 +112,7 @@ A skill can carry a checklist in its definition to constrain the Agent to comple from veadk import Agent agent = Agent( - skills=["/abs/path/to/skills/kb_skill"], + skills=["/abs/path/to/skills"], skills_mode="local", enable_skills_checklist=True, ) @@ -85,7 +120,7 @@ agent = Agent( When a skill defines a checklist, VeADK automatically initializes the state of all checklist items when the skill is invoked, and prompts the model in the system prompt to complete them one by one. -## Dynamically Loading Skills +### Dynamically Loading Skills With `enable_dynamic_load_skills=True`, new skills can be discovered and loaded at runtime, without declaring all skills up front at Agent initialization: @@ -102,7 +137,7 @@ agent = Agent( | Parameter | Type | Description | | :- | :- | :- | -| `skills` | `list[str]` | The skill list; each element is a local skill directory path or a cloud skill space identifier. | -| `skills_mode` | `"local" \| "skills_sandbox" \| "aio_sandbox"` | Skill execution mode; inferred automatically when omitted. | -| `enable_skills_checklist` | `bool` | Whether to enable the skill checklist; defaults to `False`. | -| `enable_dynamic_load_skills` | `bool` | Whether to enable dynamic loading of skills; defaults to `False`. | +| `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. | +| `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 bb484496..21c63734 100644 --- a/docs/content/docs/framework/agent/skills.mdx +++ b/docs/content/docs/framework/agent/skills.mdx @@ -2,60 +2,94 @@ title: "技能" --- -技能(Skills)是一种可复用的「提示词包」,用于为 Agent 注入特定的领域知识、操作流程与脚本。与一次性把所有提示词塞入 `instruction` 不同,技能采用**渐进式加载(progressive prompt loading)**:Agent 仅在需要时才加载某个技能的完整内容,从而控制上下文长度、提升执行准确性。 +技能(Skills)是一种可复用的「提示词包」,用于为 Agent 注入特定的领域知识、操作流程与脚本。本地技能加载/执行场景推荐使用 Google ADK 官方的 `load_skill_from_dir` / `SkillToolset` 方式接入;VeADK 旧的 `Agent(skills=..., skills_mode="local")` 本地入口仍保持兼容,但已标记为 deprecated。`skills_sandbox` 与 `aio_sandbox` 模式仍按原方式使用。 -VeADK 提供三种技能运行模式: +## 推荐:ADK SkillToolset -| 模式 | 说明 | -| :- | :- | -| `local` | 技能位于本地目录,直接由 Agent 加载并在本地执行。 | -| `skills_sandbox` | 技能托管在云端技能空间(Skill Space),通过 `execute_skills` 工具在沙箱中执行。 | -| `aio_sandbox` | All-in-one 沙箱模式,适用于 AgentKit 托管的工具运行时。 | +本地技能可直接通过 Google ADK 加载,并作为标准工具集传给 VeADK Agent: -未显式设置 `skills_mode` 时,VeADK 会根据运行环境自动推断:本地运行默认为 `local`;在 AgentKit 工具运行时中,会根据工具类型自动选择 `skills_sandbox` 或 `aio_sandbox`。 +```python +from google.adk.skills import load_skill_from_dir +from google.adk.tools.skill_toolset import SkillToolset +from veadk import Agent + +skill = load_skill_from_dir("/abs/path/to/skills/kb-skill") +agent = Agent( + tools=[SkillToolset(skills=[skill])], +) +``` + +这一路径下,技能加载、prompt 注入和工具暴露都由 ADK 处理。模型可见的工具包括 `list_skills`、`load_skill`、`load_skill_resource` 和 `run_skill_script`。如果技能依赖 `scripts/` 执行,请在 `SkillToolset` 或 Agent 上显式配置合适的 `code_executor`;VeADK 不会默认创建本地代码执行器。 + +云端 SkillHub / SkillSpace 技能可先下载并转换为 ADK `Skill`,再按同样方式传入: + +```python +from google.adk.tools.skill_toolset import SkillToolset +from veadk import Agent +from veadk.skills import load_skills_from_source + +skills = load_skills_from_source(skill_space_id) +agent = Agent( + tools=[SkillToolset(skills=skills)], +) +``` + +`load_skills_from_source` 会在调用时拉取远端 metadata、下载 zip、安全解压、执行 ADK 校验,并在失败时立即报错。 ## 技能目录结构 -一个本地技能由独立目录构成,目录中包含一个 `SKILL.md` 文件: +一个 ADK 本地技能由独立目录构成,目录中包含一个 `SKILL.md` 文件。`SKILL.md` 必须包含 `name` 与 `description` frontmatter,且目录名必须与 `name` 一致: - + + + + -`SKILL.md` 使用 frontmatter 声明 `name` 与 `description`,其后为技能正文: - -```markdown title="skills/kb_skill/SKILL.md" +```markdown title="skills/kb-skill/SKILL.md" --- -name: kb_skill +name: kb-skill +description: 查询知识库并整理答案的技能。 --- ...正文部分(操作步骤、约束、脚本说明等) ``` - -`SKILL.md` 中的 `name` 必须与所在目录名保持一致。 - +## Deprecated:VeADK 旧本地入口 + +`Agent(skills=..., skills_mode="local")` 会继续保持旧行为,但该本地入口已 deprecated。无论 `skills` 传入本地目录还是远端技能源,只要最终以 `local` 模式执行,VeADK 都会走旧的本地加载路径:加载技能元信息、把技能列表写入 `instruction`,并挂载旧的 `SkillsToolset`。 + +VeADK `skills` 参数仍支持三种技能运行模式,其中仅 `local` 的旧本地加载/执行路径标记为 deprecated: + +| 模式 | 说明 | +| :- | :- | +| `local` | Deprecated。技能由 VeADK 旧 `skills_tool` 和配套文件/Shell 工具加载,建议迁移到 ADK `SkillToolset`。 | +| `skills_sandbox` | 技能托管在云端技能空间(Skill Space),通过 `execute_skills` 工具在沙箱中执行。 | +| `aio_sandbox` | All-in-one 沙箱模式,适用于 AgentKit 托管的工具运行时。 | + +未显式设置 `skills_mode` 时,VeADK 会根据运行环境自动推断:本地运行默认为 `local`;在 AgentKit 工具运行时中,会根据工具类型自动选择 `skills_sandbox` 或 `aio_sandbox`。 -## 本地模式 +### 本地模式 -在 `local` 模式下,向 `skills` 传入技能目录的路径,并将 `skills_mode` 设置为 `local`: +在 `local` 模式下,向 `skills` 传入技能根目录的路径,并将 `skills_mode` 设置为 `local`: ```python from veadk import Agent agent = Agent( - skills=["/abs/path/to/skills/kb_skill"], + skills=["/abs/path/to/skills"], skills_mode="local", ) ``` Agent 在初始化时会加载技能元信息(`name` 与 `description`)并将其注入系统提示词,引导模型在合适的时机调用对应技能。本地模式下,VeADK 还会自动挂载一组配套工具(读写文件、编辑文件、执行 Shell 命令、注册技能等),供 Agent 在执行技能时使用。 -## 沙箱模式 +### 沙箱模式 在 `skills_sandbox` 模式下,技能托管在云端技能空间。向 `skills` 传入云端技能空间标识,并引入 `execute_skills` 工具,Agent 会在需要时调用该工具在沙箱中执行所选技能: @@ -65,11 +99,12 @@ from veadk.tools.builtin_tools.execute_skills import execute_skills agent = Agent( skills=[skill_space_id], + skills_mode="skills_sandbox", tools=[execute_skills], ) ``` -## 技能检查清单(Checklist) +### 技能检查清单(Checklist) 技能可在其定义中携带一份检查清单(checklist),用于约束 Agent 按步骤完成任务。开启后,Agent 在执行技能时需逐项完成检查项,并通过 `update_check_list` 工具将每一项标记为已完成: @@ -77,7 +112,7 @@ agent = Agent( from veadk import Agent agent = Agent( - skills=["/abs/path/to/skills/kb_skill"], + skills=["/abs/path/to/skills"], skills_mode="local", enable_skills_checklist=True, ) @@ -85,7 +120,7 @@ agent = Agent( 当某个技能定义了 checklist 时,VeADK 会在该技能被调用时自动初始化所有检查项的状态,并在系统提示词中提示模型逐项完成。 -## 动态加载技能 +### 动态加载技能 通过 `enable_dynamic_load_skills=True`,可在运行时动态发现并加载新的技能,而无需在 Agent 初始化时一次性声明全部技能: @@ -102,7 +137,7 @@ agent = Agent( | 参数 | 类型 | 说明 | | :- | :- | :- | -| `skills` | `list[str]` | 技能列表,元素为本地技能目录路径或云端技能空间标识。 | -| `skills_mode` | `"local" \| "skills_sandbox" \| "aio_sandbox"` | 技能运行模式,缺省时自动推断。 | -| `enable_skills_checklist` | `bool` | 是否启用技能检查清单,默认 `False`。 | -| `enable_dynamic_load_skills` | `bool` | 是否启用技能的动态加载,默认 `False`。 | +| `skills` | `list[str]` | 技能列表,元素为本地技能根目录路径或云端技能空间标识;当 `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/tests/skills/test_adk_skill_materializer.py b/tests/skills/test_adk_skill_materializer.py new file mode 100644 index 00000000..6426f8da --- /dev/null +++ b/tests/skills/test_adk_skill_materializer.py @@ -0,0 +1,273 @@ +# 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. + +from __future__ import annotations + +import io +import zipfile +from pathlib import Path + +import pytest + +from veadk.skills import SkillLoadError, load_skills_from_source +from veadk.skills import materializer +from veadk.skills.skill import Skill as VeADKSkill + + +def _write_skill(path: Path, *, name: str, description: str = "Demo skill.") -> None: + path.mkdir(parents=True, exist_ok=True) + (path / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\nSkill body.\n", + encoding="utf-8", + ) + + +def _zip_bytes(files: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + return buffer.getvalue() + + +def test_load_skills_from_source_loads_local_skill_dir(tmp_path: Path): + skill_dir = tmp_path / "demo-skill" + _write_skill(skill_dir, name="demo-skill") + + skills = load_skills_from_source(str(skill_dir)) + + assert [skill.name for skill in skills] == ["demo-skill"] + assert skills[0].instructions == "Skill body." + + +def test_load_skills_from_source_loads_local_skills_root(tmp_path: Path): + _write_skill(tmp_path / "alpha-skill", name="alpha-skill") + _write_skill(tmp_path / "beta-skill", name="beta-skill") + + skills = load_skills_from_source(str(tmp_path)) + + assert [skill.name for skill in skills] == ["alpha-skill", "beta-skill"] + + +def test_load_skills_from_source_fails_when_skill_md_missing(tmp_path: Path): + bad_dir = tmp_path / "bad-skill" + bad_dir.mkdir() + + with pytest.raises(SkillLoadError, match="SKILL.md"): + load_skills_from_source(str(bad_dir)) + + +def test_load_skills_from_source_fails_when_dir_name_mismatches(tmp_path: Path): + skill_dir = tmp_path / "actual-dir" + _write_skill(skill_dir, name="declared-skill") + + with pytest.raises(SkillLoadError, match="does not match directory name"): + load_skills_from_source(str(skill_dir)) + + +def test_load_skills_from_source_rejects_duplicate_skill_names(tmp_path: Path): + first = tmp_path / "first" / "dup-skill" + second = tmp_path / "second" / "dup-skill" + _write_skill(first, name="dup-skill") + _write_skill(second, name="dup-skill") + + with pytest.raises(SkillLoadError, match="Duplicate skill name 'dup-skill'"): + load_skills_from_source([str(first), str(second)]) + + +def test_skillhub_source_downloads_and_normalizes_root_zip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + remote_skill = VeADKSkill( + name="hub-skill", + description="Hub skill.", + path="hub-skill", + skill_space_id="sp-test", + id="skill-id", + source_type="skillhub", + version_id="v1", + ) + + monkeypatch.setattr( + materializer, + "load_skills_from_cloud", + lambda source: [remote_skill], + ) + + def download_skillhub_skill(skill: VeADKSkill, save_path: Path) -> bool: + save_path.write_bytes( + _zip_bytes( + { + "SKILL.md": ( + "---\nname: hub-skill\ndescription: Hub skill.\n---\n" + "Hub body.\n" + ), + "references/readme.txt": "reference", + } + ) + ) + return True + + monkeypatch.setattr( + materializer, + "download_skillhub_skill", + download_skillhub_skill, + ) + + skills = load_skills_from_source("sp-test", cache_dir=tmp_path) + + assert [skill.name for skill in skills] == ["hub-skill"] + assert "readme.txt" in skills[0].resources.references + assert ( + tmp_path / "skillhub" / "sp-test" / "hub-skill" / "v1" / "hub-skill" + ).is_dir() + + +def test_legacy_skillspace_source_downloads_top_level_zip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + remote_skill = VeADKSkill( + name="legacy-skill", + description="Legacy skill.", + path="skills/s-123/v1/legacy-skill.zip", + skill_space_id="space-1", + bucket_name="bucket", + id="s-123", + ) + + monkeypatch.setattr( + materializer, + "load_skills_from_cloud", + lambda source: [remote_skill], + ) + + def download_legacy_skill(skill: VeADKSkill, zip_path: Path) -> bool: + zip_path.write_bytes( + _zip_bytes( + { + "legacy-skill/SKILL.md": ( + "---\nname: legacy-skill\ndescription: Legacy skill.\n---\n" + "Legacy body.\n" + ) + } + ) + ) + return True + + monkeypatch.setattr( + materializer, + "_download_legacy_skill_space_skill", + download_legacy_skill, + ) + + skills = load_skills_from_source("space-1", cache_dir=tmp_path) + + assert [skill.name for skill in skills] == ["legacy-skill"] + assert skills[0].instructions == "Legacy body." + + +def test_remote_source_fails_fast_on_bad_zip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + remote_skill = VeADKSkill( + name="bad-zip", + description="Bad zip.", + path="bad-zip", + skill_space_id="sp-test", + id="skill-id", + source_type="skillhub", + ) + + monkeypatch.setattr( + materializer, + "load_skills_from_cloud", + lambda source: [remote_skill], + ) + + def download_skillhub_skill(skill: VeADKSkill, save_path: Path) -> bool: + save_path.write_bytes(b"not a zip") + return True + + monkeypatch.setattr( + materializer, + "download_skillhub_skill", + download_skillhub_skill, + ) + + with pytest.raises(SkillLoadError, match="valid zip archive"): + load_skills_from_source("sp-test", cache_dir=tmp_path) + + +def test_remote_source_reports_cache_dir_creation_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + remote_skill = VeADKSkill( + name="cache-skill", + description="Cache skill.", + path="cache-skill", + skill_space_id="sp-test", + id="skill-id", + source_type="skillhub", + ) + + monkeypatch.setattr( + materializer, + "load_skills_from_cloud", + lambda source: [remote_skill], + ) + monkeypatch.setattr( + materializer.Path, + "mkdir", + lambda self, **kwargs: (_ for _ in ()).throw( + PermissionError("permission denied") + ), + ) + + with pytest.raises( + SkillLoadError, + match="VEADK_SKILLS_CACHE_DIR", + ): + load_skills_from_source("sp-test", cache_dir=tmp_path / "cache") + + +def test_remote_source_rejects_zip_slip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + remote_skill = VeADKSkill( + name="unsafe-skill", + description="Unsafe skill.", + path="unsafe-skill", + skill_space_id="sp-test", + id="skill-id", + source_type="skillhub", + ) + + monkeypatch.setattr( + materializer, + "load_skills_from_cloud", + lambda source: [remote_skill], + ) + + def download_skillhub_skill(skill: VeADKSkill, save_path: Path) -> bool: + save_path.write_bytes(_zip_bytes({"../escape.txt": "nope"})) + return True + + monkeypatch.setattr( + materializer, + "download_skillhub_skill", + download_skillhub_skill, + ) + + with pytest.raises(SkillLoadError, match="Unsafe path"): + load_skills_from_source("sp-test", cache_dir=tmp_path) diff --git a/tests/skills/test_agent_adk_skill_toolset.py b/tests/skills/test_agent_adk_skill_toolset.py new file mode 100644 index 00000000..0bc30939 --- /dev/null +++ b/tests/skills/test_agent_adk_skill_toolset.py @@ -0,0 +1,102 @@ +# 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. + +from __future__ import annotations + +import warnings +from pathlib import Path + +import pytest +from google.adk.skills import load_skill_from_dir +from google.adk.tools.skill_toolset import SkillToolset + +from veadk import Agent +from veadk.prompts.agent_default_prompt import DEFAULT_INSTRUCTION +from veadk.skills import utils as skill_utils +from veadk.skills.skill import Skill as VeADKSkill +from veadk.tools.skills_tools.skills_toolset import SkillsToolset + + +def _write_skill(path: Path, *, name: str, description: str = "Demo skill.") -> None: + path.mkdir(parents=True, exist_ok=True) + (path / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\nSkill body.\n", + encoding="utf-8", + ) + + +def test_adk_skill_toolset_path_does_not_mount_legacy_skills_toolset(tmp_path: Path): + skill_dir = tmp_path / "adk-skill" + _write_skill(skill_dir, name="adk-skill") + skill_toolset = SkillToolset(skills=[load_skill_from_dir(skill_dir)]) + + agent = Agent( + name="adk_skill_agent", + model_api_key="test-key", + tools=[skill_toolset], + ) + + assert skill_toolset in agent.tools + assert not any(isinstance(tool, SkillsToolset) for tool in agent.tools) + assert agent.instruction == DEFAULT_INSTRUCTION + + +def test_legacy_agent_skills_path_warns_and_keeps_legacy_behavior(tmp_path: Path): + skills_root = tmp_path / "skills" + _write_skill(skills_root / "legacy-skill", name="legacy-skill") + + with pytest.warns(DeprecationWarning, match=r"Agent\(skills=.*deprecated"): + agent = Agent( + name="legacy_skill_agent", + model_api_key="test-key", + skills=[str(skills_root)], + skills_mode="local", + ) + + assert any(isinstance(tool, SkillsToolset) for tool in agent.tools) + assert "You have the following skills" in agent.instruction + assert "skills_tool" in agent.instruction + + +def test_sandbox_agent_skills_path_does_not_warn_as_deprecated( + monkeypatch: pytest.MonkeyPatch, +): + remote_skill = VeADKSkill( + name="sandbox-skill", + description="Sandbox skill.", + path="sandbox-skill", + skill_space_id="space-1", + ) + monkeypatch.setattr( + skill_utils, + "load_skills_from_cloud", + lambda source: [remote_skill], + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + agent = Agent( + name="sandbox_skill_agent", + model_api_key="test-key", + skills=["space-1"], + skills_mode="skills_sandbox", + ) + + assert any(isinstance(tool, SkillsToolset) for tool in agent.tools) + assert "execute_skills" in agent.instruction + assert not any( + issubclass(item.category, DeprecationWarning) + and "skills_mode='local'" in str(item.message) + for item in caught + ) diff --git a/veadk/agent.py b/veadk/agent.py index 357be5a8..11734bbc 100644 --- a/veadk/agent.py +++ b/veadk/agent.py @@ -15,6 +15,7 @@ from __future__ import annotations import os +import warnings from typing import TYPE_CHECKING, AsyncGenerator, Dict, Literal, Optional, Union from google.adk.flows.llm_flows.base_llm_flow import BaseLlmFlow @@ -497,6 +498,18 @@ def load_skills(self): ) logger.info(f"Determined skills_mode: {self.skills_mode}") + if self.skills_mode == "local": + warning_message = ( + "Agent(skills=..., skills_mode='local') is deprecated for legacy " + "local skill loading, including local paths and remote sources " + "loaded for local execution. For Google ADK-compatible local " + "skills, load skills with google.adk.skills.load_skill_from_dir or " + "veadk.skills.load_skills_from_source, then pass " + "google.adk.tools.skill_toolset.SkillToolset via Agent(tools=[...])." + ) + warnings.warn(warning_message, DeprecationWarning, stacklevel=2) + logger.warning(warning_message) + for item in self.skills: if not item or str(item).strip() == "": continue diff --git a/veadk/skills/__init__.py b/veadk/skills/__init__.py index 7f463206..86267422 100644 --- a/veadk/skills/__init__.py +++ b/veadk/skills/__init__.py @@ -11,3 +11,12 @@ # 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. + +from veadk.skills.adk_loader import load_skills_from_source +from veadk.skills.exceptions import SkillLoadError, SkillMaterializeError + +__all__ = [ + "SkillLoadError", + "SkillMaterializeError", + "load_skills_from_source", +] diff --git a/veadk/skills/adk_loader.py b/veadk/skills/adk_loader.py new file mode 100644 index 00000000..1a75d79b --- /dev/null +++ b/veadk/skills/adk_loader.py @@ -0,0 +1,74 @@ +# 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. + +"""Google ADK-compatible skill loading helpers.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +from google.adk.skills import Skill, load_skill_from_dir + +from veadk.skills.exceptions import SkillLoadError +from veadk.skills.materializer import materialize_skill_sources + + +def load_skills_from_source( + source: str | Sequence[str], + *, + cache_dir: Path | None = None, +) -> list[Skill]: + """Load local or remote VeADK skill sources as Google ADK ``Skill`` objects. + + Args: + source: A local skill directory, a local skills root, a remote skill-space + identifier, or a sequence mixing those forms. + cache_dir: Optional cache directory used for downloaded remote skills. + + Returns: + A list of standard ``google.adk.skills.Skill`` objects that can be + passed to ``google.adk.tools.skill_toolset.SkillToolset``. + + Raises: + SkillLoadError: If any source cannot be materialized, ADK validation + fails, or duplicate skill names are found. + """ + sources = [source] if isinstance(source, str) else list(source) + + try: + skill_dirs = materialize_skill_sources(sources, cache_dir=cache_dir) + except SkillLoadError: + raise + except Exception as e: + raise SkillLoadError( + f"Failed to materialize skill source {source!r}: {e}" + ) from e + + loaded_skills: list[Skill] = [] + seen_names: set[str] = set() + for skill_dir in skill_dirs: + try: + skill = load_skill_from_dir(skill_dir) + except Exception as e: + raise SkillLoadError( + f"Skill directory '{skill_dir}' failed ADK load validation: {e}" + ) from e + + if skill.name in seen_names: + raise SkillLoadError(f"Duplicate skill name '{skill.name}' in {source!r}.") + seen_names.add(skill.name) + loaded_skills.append(skill) + + return loaded_skills diff --git a/veadk/skills/exceptions.py b/veadk/skills/exceptions.py new file mode 100644 index 00000000..4c03d54b --- /dev/null +++ b/veadk/skills/exceptions.py @@ -0,0 +1,23 @@ +# 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. + +"""Exceptions raised while loading skills through the ADK-compatible path.""" + + +class SkillLoadError(RuntimeError): + """A skill source failed to materialize or load as a Google ADK skill.""" + + +class SkillMaterializeError(SkillLoadError): + """A skill source could not be converted into local ADK skill directories.""" diff --git a/veadk/skills/materializer.py b/veadk/skills/materializer.py new file mode 100644 index 00000000..59dd5306 --- /dev/null +++ b/veadk/skills/materializer.py @@ -0,0 +1,425 @@ +# 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. + +"""Materialize VeADK skill sources into local directories loadable by ADK.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import zipfile +from pathlib import Path + +import frontmatter + +from veadk.skills.exceptions import SkillMaterializeError +from veadk.skills.skill import Skill +from veadk.skills.utils import ( + _get_agentkit_endpoint, + _get_cloud_credentials, + download_skillhub_skill, + load_skills_from_cloud, +) +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + +_SKILL_MD_NAMES = ("SKILL.md", "skill.md") + + +def materialize_skill_sources( + sources: list[str], + *, + cache_dir: Path | None = None, +) -> list[Path]: + """Convert local or remote skill sources to local ADK skill directories. + + Local sources are returned directly. Remote sources are listed through the + existing VeADK cloud metadata APIs, downloaded immediately, extracted safely, + and normalized so the final directory name matches the skill frontmatter + ``name`` as required by ``google.adk.skills.load_skill_from_dir``. + """ + materialized: list[Path] = [] + for raw_source in sources: + source = str(raw_source).strip() + if not source: + continue + + path = Path(source).expanduser() + if path.exists() and path.is_dir(): + materialized.extend(_materialize_local_source(path)) + continue + + materialized.extend(_materialize_remote_source(source, cache_dir=cache_dir)) + + if not materialized: + raise SkillMaterializeError("No skill directories were materialized.") + + return materialized + + +def _materialize_local_source(source: Path) -> list[Path]: + source = source.resolve() + if _find_skill_md(source): + return [source] + + skill_dirs = [ + child.resolve() + for child in sorted(source.iterdir(), key=lambda p: p.name) + if child.is_dir() + ] + if not skill_dirs: + raise SkillMaterializeError( + f"Local skill source '{source}' has no SKILL.md or skill.md file " + "and no child skill dirs." + ) + return skill_dirs + + +def _materialize_remote_source(source: str, *, cache_dir: Path | None) -> list[Path]: + skills = load_skills_from_cloud(source) + if not skills: + raise SkillMaterializeError( + f"Remote skill source '{source}' returned no skills." + ) + + base_cache_dir = _default_cache_dir() if cache_dir is None else Path(cache_dir) + _ensure_cache_dir(base_cache_dir) + + dirs: list[Path] = [] + for skill in skills: + try: + dirs.append(_materialize_remote_skill(skill, base_cache_dir)) + except Exception as e: + if isinstance(e, SkillMaterializeError): + raise + raise SkillMaterializeError( + f"Failed to materialize remote skill '{skill.name}' " + f"from source '{source}': {e}" + ) from e + return dirs + + +def _materialize_remote_skill(skill: Skill, cache_dir: Path) -> Path: + source_type = "skillhub" if skill.source_type == "skillhub" else "skillspace" + source_id = skill.skill_space_id or skill.id or "unknown-source" + version_key = _skill_version_key(skill) + version_dir = ( + cache_dir + / source_type + / _safe_cache_part(source_id) + / _safe_cache_part(skill.name) + / _safe_cache_part(version_key) + ) + + cached = _cached_skill_dir(version_dir) + if cached is not None: + logger.info(f"Using cached ADK skill '{skill.name}' from {cached}") + return cached + + zip_path = version_dir / f"{_safe_cache_part(skill.name)}.zip" + staging_dir = version_dir / "__staging__" + if staging_dir.exists(): + shutil.rmtree(staging_dir) + version_dir.mkdir(parents=True, exist_ok=True) + + _download_remote_skill(skill, zip_path) + try: + _safe_extract_zip(zip_path, staging_dir) + finally: + if zip_path.exists(): + zip_path.unlink() + + return _normalize_extracted_skill_dir(staging_dir, version_dir, skill) + + +def _download_remote_skill(skill: Skill, zip_path: Path) -> None: + zip_path.parent.mkdir(parents=True, exist_ok=True) + + if skill.source_type == "skillhub": + if not download_skillhub_skill(skill, zip_path): + raise SkillMaterializeError( + f"Failed to download SkillHub skill '{skill.name}'." + ) + return + + if not _download_legacy_skill_space_skill(skill, zip_path): + raise SkillMaterializeError( + f"Failed to download skill-space skill '{skill.name}'." + ) + + +def _download_legacy_skill_space_skill(skill: Skill, zip_path: Path) -> bool: + if not skill.bucket_name or not skill.path: + raise SkillMaterializeError( + f"Skill-space skill '{skill.name}' is missing bucket or TOS path." + ) + + access_key, secret_key, session_token = _get_cloud_credentials() + service, region, host = _get_agentkit_endpoint() + scheme = os.getenv("AGENTKIT_TOP_SCHEME", "https").lower() + cloud_provider = (os.getenv("CLOUD_PROVIDER") or "").lower() + + if cloud_provider == "vestack": + return _download_legacy_skill_via_vestack( + skill=skill, + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + service=service, + region=region, + host=host, + scheme=scheme, + zip_path=zip_path, + ) + + from veadk.integrations.ve_tos.ve_tos import VeTOS + + tos_client = VeTOS( + ak=access_key, + sk=secret_key, + session_token=session_token, + bucket_name=skill.bucket_name, + region=region, + ) + return tos_client.download( + bucket_name=skill.bucket_name, + object_key=skill.path, + save_path=str(zip_path), + ) + + +def _download_legacy_skill_via_vestack( + *, + skill: Skill, + access_key: str, + secret_key: str, + session_token: str, + service: str, + region: str, + host: str, + scheme: str, + zip_path: Path, +) -> bool: + import requests + + from veadk.utils.volcengine_sign import ve_request + + path_parts = skill.path.split("/") + if len(path_parts) < 3: + logger.error(f"Invalid TosPath format for skill '{skill.name}': {skill.path}") + return False + + skill_id = skill.id or path_parts[1] + skill_version = path_parts[2] + response = ve_request( + request_body={ + "SkillId": skill_id, + "SkillVersion": skill_version, + }, + action="GenTempTosObjectDownloadUrl", + ak=access_key, + sk=secret_key, + service=service, + version="2025-10-30", + region=region, + host=host, + header={"X-Security-Token": session_token}, + scheme=scheme, # type: ignore[arg-type] + ) + + if isinstance(response, str): + response = json.loads(response) + if ( + isinstance(response, dict) + and "ResponseMetadata" in response + and "Error" in response["ResponseMetadata"] + ): + logger.error( + f"Failed to get temporary download URL for '{skill.name}': " + f"{response['ResponseMetadata']['Error']}" + ) + return False + + signed_url = ( + response.get("Result", {}).get("SignedUrl") + if isinstance(response, dict) + else None + ) + if not signed_url: + logger.error( + f"Failed to get SignedUrl from GenTempTosObjectDownloadUrl response: {response}" + ) + return False + + try: + http_response = requests.get(signed_url, timeout=60) + http_response.raise_for_status() + zip_path.parent.mkdir(parents=True, exist_ok=True) + zip_path.write_bytes(http_response.content) + return True + except Exception as e: + logger.error(f"Failed to download skill '{skill.name}' from vestack: {e}") + return False + + +def _safe_extract_zip(zip_path: Path, dest_dir: Path) -> None: + dest_root = dest_dir.resolve() + dest_dir.mkdir(parents=True, exist_ok=True) + + try: + with zipfile.ZipFile(zip_path, "r") as zf: + for member in zf.infolist(): + member_name = member.filename + if ( + member_name.startswith(("/", "\\")) + or Path(member_name).is_absolute() + ): + raise SkillMaterializeError( + f"Unsafe absolute path in zip archive: '{member_name}'" + ) + target = (dest_root / member_name).resolve() + if target != dest_root and dest_root not in target.parents: + raise SkillMaterializeError( + f"Unsafe path detected in zip archive: '{member_name}'" + ) + zf.extractall(path=str(dest_root)) + except zipfile.BadZipFile as e: + raise SkillMaterializeError( + f"Downloaded file '{zip_path}' is not a valid zip archive." + ) from e + + +def _normalize_extracted_skill_dir( + staging_dir: Path, + version_dir: Path, + source_skill: Skill, +) -> Path: + skill_dir = _find_extracted_skill_dir(staging_dir) + skill_md = _find_skill_md(skill_dir) + if skill_md is None: + raise SkillMaterializeError( + f"Skill '{source_skill.name}' has no SKILL.md or skill.md after extraction." + ) + + declared_name = frontmatter.load(str(skill_md)).metadata.get("name") + if not declared_name: + raise SkillMaterializeError( + f"Skill '{source_skill.name}' SKILL.md has no 'name' in frontmatter." + ) + declared_name = str(declared_name) + if not _is_safe_dir_name(declared_name): + raise SkillMaterializeError( + f"Skill '{source_skill.name}' has unsafe frontmatter name: {declared_name!r}." + ) + + final_dir = version_dir / declared_name + if final_dir.exists(): + shutil.rmtree(final_dir) + + if skill_dir == staging_dir: + staging_dir.rename(final_dir) + else: + shutil.move(str(skill_dir), str(final_dir)) + if staging_dir.exists(): + shutil.rmtree(staging_dir) + + logger.info( + f"Materialized remote skill '{source_skill.name}' " + f"(declared name='{declared_name}') to {final_dir}" + ) + return final_dir + + +def _find_extracted_skill_dir(staging_dir: Path) -> Path: + if _find_skill_md(staging_dir): + return staging_dir + + candidates = [ + path.parent + for path in staging_dir.rglob("*") + if path.is_file() and path.name in _SKILL_MD_NAMES + ] + if not candidates: + return staging_dir + + return sorted( + candidates, + key=lambda p: (len(p.relative_to(staging_dir).parts), str(p)), + )[0] + + +def _cached_skill_dir(version_dir: Path) -> Path | None: + if not version_dir.exists(): + return None + candidates = [ + child + for child in version_dir.iterdir() + if child.is_dir() and child.name != "__staging__" and _find_skill_md(child) + ] + if len(candidates) != 1: + return None + return candidates[0] + + +def _find_skill_md(skill_dir: Path) -> Path | None: + for name in _SKILL_MD_NAMES: + path = skill_dir / name + if path.exists() and path.is_file(): + return path + return None + + +def _default_cache_dir() -> Path: + configured = os.getenv("VEADK_SKILLS_CACHE_DIR") + if configured: + return Path(configured).expanduser() + return Path.home() / ".veadk" / "skills" + + +def _ensure_cache_dir(cache_dir: Path) -> None: + try: + cache_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise SkillMaterializeError( + f"Unable to create VeADK skills cache directory '{cache_dir}': {e}. " + "Pass cache_dir=... to load_skills_from_source or set " + "VEADK_SKILLS_CACHE_DIR to a writable directory." + ) from e + + +def _skill_version_key(skill: Skill) -> str: + if skill.version_id: + return skill.version_id + + metadata = skill.model_dump(mode="json", exclude_none=True) + digest = hashlib.sha256( + json.dumps(metadata, sort_keys=True, ensure_ascii=True).encode("utf-8") + ).hexdigest()[:16] + return f"metadata-{digest}" + + +def _safe_cache_part(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in value) + cleaned = cleaned.strip("._-") + return cleaned or "unknown" + + +def _is_safe_dir_name(value: str) -> bool: + if not value or value in {".", ".."}: + return False + path = Path(value) + return not path.is_absolute() and len(path.parts) == 1