diff --git a/docs/content/docs/framework/agent/skills.en.mdx b/docs/content/docs/framework/agent/skills.en.mdx
index 13c20bef..82964cc3 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 should be exposed through VeADK's ADK registry. `search_skills` fetches the remote list live, and `get_skill` downloads and loads a concrete skill only when requested:
+
+```python
+from google.adk.tools.skill_toolset import SkillToolset
+from veadk import Agent
+from veadk.skills import VeSkillRegistry
+
+registry = VeSkillRegistry(skill_source_id=skill_space_id)
+agent = Agent(
+ tools=[SkillToolset(registry=registry)],
+)
+```
+
+This path does not download every skill at initialization time. The cache only stores concrete downloaded skill versions; remote metadata is fetched again on every search / get, so remote version changes are picked up before loading.
## 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..81dd1e92 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 技能推荐通过 VeADK 的 ADK Registry 接入。`search_skills` 会实时拉取远端技能列表,`get_skill` 会在需要某个技能时按需下载并加载:
+
+```python
+from google.adk.tools.skill_toolset import SkillToolset
+from veadk import Agent
+from veadk.skills import VeSkillRegistry
+
+registry = VeSkillRegistry(skill_source_id=skill_space_id)
+agent = Agent(
+ tools=[SkillToolset(registry=registry)],
+)
+```
+
+该方式不会在初始化阶段全量下载技能;缓存仅用于已下载的具体技能版本。远端 metadata 每次 search / get 都会重新拉取,因此远端版本变化后会按新版本重新下载。
## 技能目录结构
-一个本地技能由独立目录构成,目录中包含一个 `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/examples/15_legacy_skills/main.py b/examples/15_legacy_skills/main.py
new file mode 100644
index 00000000..6083ce78
--- /dev/null
+++ b/examples/15_legacy_skills/main.py
@@ -0,0 +1,460 @@
+# 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.
+
+import asyncio
+import json
+import os
+from pathlib import Path
+from time import perf_counter
+
+from dotenv import load_dotenv
+
+# VeADK reads model settings during import, so load the local env first.
+AGENT_ROOT = Path(__file__).parent
+load_dotenv(AGENT_ROOT / ".env")
+
+from google.adk.skills import load_skill_from_dir # noqa: E402
+from google.adk.tools.skill_toolset import SkillToolset # noqa: E402
+from google.genai import types # noqa: E402
+
+from veadk import Agent, Runner # noqa: E402
+from veadk.skills import VeSkillRegistry # noqa: E402
+from veadk.utils.logger import get_logger # noqa: E402
+
+# 本地测试 Skill 目录
+SKILLS_ROOT = AGENT_ROOT / "local_skills"
+SKILL_DIR = SKILLS_ROOT / "company-qa"
+
+# 远端 SkillSpace,ss- 开头会走旧 SkillSpace 加载逻辑
+SKILLSPACE_ID = "ss-yep2o9dgxswl3fpmpdle"
+
+# 远端 skill 下载后的本地缓存目录
+SKILLS_CACHE_DIR = AGENT_ROOT / ".veadk_skills_cache"
+
+logger = get_logger(__name__)
+
+
+MODEL_REQUEST_CONFIG = types.GenerateContentConfig(
+ temperature=float(os.getenv("MODEL_AGENT_TEMPERATURE", "0.2")),
+ top_p=float(os.getenv("MODEL_AGENT_TOP_P", "0.8")),
+ max_output_tokens=int(os.getenv("MODEL_AGENT_MAX_OUTPUT_TOKENS", "1024")),
+)
+
+MODEL_EXTRA_CONFIG = {
+ "extra_body": {
+ "thinking": {
+ "type": os.getenv("MODEL_AGENT_THINKING_TYPE", "disabled"),
+ },
+ },
+}
+
+MODEL_DEBUG_ENABLED = os.getenv("MODEL_AGENT_DEBUG", "true").lower() in {
+ "1",
+ "true",
+ "yes",
+ "on",
+}
+MODEL_DEBUG_INCLUDE_CONTENT = os.getenv(
+ "MODEL_AGENT_DEBUG_INCLUDE_CONTENT",
+ "true",
+).lower() in {"1", "true", "yes", "on"}
+MODEL_DEBUG_MAX_TEXT_CHARS = int(os.getenv("MODEL_AGENT_DEBUG_MAX_TEXT_CHARS", "0"))
+
+
+def _truncate_text(text: str) -> str:
+ if MODEL_DEBUG_MAX_TEXT_CHARS <= 0 or len(text) <= MODEL_DEBUG_MAX_TEXT_CHARS:
+ return text
+ return text[:MODEL_DEBUG_MAX_TEXT_CHARS] + (
+ f"..."
+ )
+
+
+def _text_debug_value(text: str) -> str:
+ if not MODEL_DEBUG_INCLUDE_CONTENT:
+ return ""
+ return repr(_truncate_text(text))
+
+
+def _model_dump(value):
+ if value is None:
+ return None
+ if hasattr(value, "model_dump"):
+ return value.model_dump(exclude_none=True)
+ if isinstance(value, dict):
+ return {key: _model_dump(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_model_dump(item) for item in value]
+ return value
+
+
+def _dump_function_call(function_call) -> dict:
+ return {
+ "id": function_call.id,
+ "name": function_call.name,
+ "args": _model_dump(function_call.args),
+ "partial_args": _model_dump(function_call.partial_args),
+ "will_continue": function_call.will_continue,
+ }
+
+
+def _dump_function_response(function_response) -> dict:
+ return {
+ "id": function_response.id,
+ "name": function_response.name,
+ "response": _model_dump(function_response.response),
+ "parts": _model_dump(function_response.parts),
+ "will_continue": function_response.will_continue,
+ "scheduling": function_response.scheduling,
+ }
+
+
+def _dump_part(part: types.Part) -> dict:
+ payload = {
+ "thought": part.thought,
+ "thought_signature": part.thought_signature,
+ "part_metadata": _model_dump(part.part_metadata),
+ }
+
+ if part.text is not None:
+ payload["type"] = "text"
+ payload["text_repr"] = _text_debug_value(part.text)
+ elif part.function_call is not None:
+ payload["type"] = "function_call"
+ payload["function_call"] = _dump_function_call(part.function_call)
+ elif part.function_response is not None:
+ payload["type"] = "function_response"
+ payload["function_response"] = _dump_function_response(part.function_response)
+ elif part.tool_call is not None:
+ payload["type"] = "tool_call"
+ payload["tool_call"] = _model_dump(part.tool_call)
+ elif part.tool_response is not None:
+ payload["type"] = "tool_response"
+ payload["tool_response"] = _model_dump(part.tool_response)
+ elif part.executable_code is not None:
+ payload["type"] = "executable_code"
+ payload["executable_code"] = _model_dump(part.executable_code)
+ elif part.code_execution_result is not None:
+ payload["type"] = "code_execution_result"
+ payload["code_execution_result"] = _model_dump(part.code_execution_result)
+ elif part.file_data is not None:
+ payload["type"] = "file_data"
+ payload["file_data"] = _model_dump(part.file_data)
+ payload["video_metadata"] = _model_dump(part.video_metadata)
+ elif part.inline_data is not None:
+ payload["type"] = "inline_data"
+ payload["inline_data"] = {
+ "mime_type": part.inline_data.mime_type,
+ "data_bytes": len(part.inline_data.data or b""),
+ }
+ payload["video_metadata"] = _model_dump(part.video_metadata)
+ else:
+ payload["type"] = "unknown"
+ payload["raw"] = _model_dump(part)
+
+ return {key: value for key, value in payload.items() if value is not None}
+
+
+def _dump_contents(contents: list[types.Content]) -> list[dict]:
+ dumped = []
+ for index, content in enumerate(contents):
+ parts = [_dump_part(part) for part in content.parts or []]
+ dumped.append(
+ {
+ "index": index,
+ "role": content.role,
+ "parts_count": len(parts),
+ "parts": parts,
+ }
+ )
+ return dumped
+
+
+def _to_json(value) -> str:
+ return json.dumps(value, ensure_ascii=False, indent=2, default=str)
+
+
+def _debug_model_request(callback_context, llm_request):
+ if not MODEL_DEBUG_ENABLED:
+ return None
+
+ callback_context.state["_model_request_started_at"] = perf_counter()
+ request_index = int(callback_context.state.get("_model_debug_request_index", 0)) + 1
+ callback_context.state["_model_debug_request_index"] = request_index
+ config = llm_request.config.model_dump(exclude_none=True)
+
+ logger.info(
+ "model_request_start\n"
+ + _to_json(
+ {
+ "request_index": request_index,
+ "agent_name": getattr(callback_context, "agent_name", None),
+ "invocation_id": getattr(callback_context, "invocation_id", None),
+ "model": llm_request.model,
+ "config": config,
+ "tools": sorted(llm_request.tools_dict.keys()),
+ "contents": _dump_contents(llm_request.contents),
+ "cache_config": _model_dump(llm_request.cache_config),
+ "cache_metadata": _model_dump(llm_request.cache_metadata),
+ "cacheable_contents_token_count": (
+ llm_request.cacheable_contents_token_count
+ ),
+ "previous_interaction_id": llm_request.previous_interaction_id,
+ }
+ )
+ )
+ return None
+
+
+def _debug_model_response(callback_context, llm_response):
+ if not MODEL_DEBUG_ENABLED:
+ return None
+
+ started_at = callback_context.state.get("_model_request_started_at")
+ elapsed_ms = (
+ round((perf_counter() - started_at) * 1000, 2)
+ if isinstance(started_at, float)
+ else None
+ )
+ usage = (
+ llm_response.usage_metadata.model_dump(exclude_none=True)
+ if llm_response.usage_metadata
+ else None
+ )
+
+ logger.info(
+ "model_response_end\n"
+ + _to_json(
+ {
+ "elapsed_ms": elapsed_ms,
+ "model_version": llm_response.model_version,
+ "content": _dump_contents([llm_response.content])
+ if llm_response.content
+ else None,
+ "finish_reason": llm_response.finish_reason,
+ "turn_complete": llm_response.turn_complete,
+ "partial": llm_response.partial,
+ "usage": usage,
+ "error_code": llm_response.error_code,
+ "error_message": llm_response.error_message,
+ }
+ )
+ )
+ return None
+
+
+def _debug_model_error(callback_context, llm_request, error):
+ if not MODEL_DEBUG_ENABLED:
+ return None
+
+ started_at = callback_context.state.get("_model_request_started_at")
+ elapsed_ms = (
+ round((perf_counter() - started_at) * 1000, 2)
+ if isinstance(started_at, float)
+ else None
+ )
+
+ logger.exception(
+ "model_request_error\n"
+ + _to_json(
+ {
+ "elapsed_ms": elapsed_ms,
+ "model": llm_request.model,
+ "error": repr(error),
+ }
+ )
+ )
+ return None
+
+
+def _debug_tool_start(tool, args, tool_context):
+ if not MODEL_DEBUG_ENABLED:
+ return None
+
+ logger.info(
+ "tool_start\n"
+ + _to_json(
+ {
+ "tool": getattr(tool, "name", type(tool).__name__),
+ "args": _model_dump(args),
+ "agent_name": getattr(tool_context, "agent_name", None),
+ "invocation_id": getattr(tool_context, "invocation_id", None),
+ }
+ )
+ )
+ return None
+
+
+def _debug_tool_end(tool, args, tool_context, tool_response):
+ if not MODEL_DEBUG_ENABLED:
+ return None
+
+ logger.info(
+ "tool_end\n"
+ + _to_json(
+ {
+ "tool": getattr(tool, "name", type(tool).__name__),
+ "args": _model_dump(args),
+ "response": _model_dump(tool_response),
+ "agent_name": getattr(tool_context, "agent_name", None),
+ "invocation_id": getattr(tool_context, "invocation_id", None),
+ }
+ )
+ )
+ return None
+
+
+def _debug_tool_error(tool, args, tool_context, error):
+ if not MODEL_DEBUG_ENABLED:
+ return None
+
+ logger.exception(
+ "tool_error\n"
+ + _to_json(
+ {
+ "tool": getattr(tool, "name", type(tool).__name__),
+ "args": _model_dump(args),
+ "error": repr(error),
+ "agent_name": getattr(tool_context, "agent_name", None),
+ "invocation_id": getattr(tool_context, "invocation_id", None),
+ }
+ )
+ )
+ return None
+
+
+def ensure_demo_skill():
+ """创建一个真实的本地 VeADK Skill,用于测试旧入口。"""
+ references_dir = SKILL_DIR / "references"
+ references_dir.mkdir(parents=True, exist_ok=True)
+
+ (SKILL_DIR / "SKILL.md").write_text(
+ """---
+name: company-qa
+description: 根据公司资料回答问题,并在回答中说明依据。
+---
+
+当用户询问公司制度、团队流程或报销规则时:
+1. 先读取 references/company.md。
+2. 只根据资料回答。
+3. 如果资料里没有答案,明确说资料未覆盖。
+""",
+ encoding="utf-8",
+ )
+
+ (references_dir / "company.md").write_text(
+ """公司报销规则:
+- 单笔超过 500 元需要直属负责人审批。
+- 差旅报销需要提供发票和行程单。
+- 餐补标准为每人每天 80 元。
+""",
+ encoding="utf-8",
+ )
+
+
+def check_skillspace_env():
+ """检查加载远端 SkillSpace 所需的凭证。"""
+ if not os.getenv("VOLCENGINE_ACCESS_KEY"):
+ raise ValueError("VOLCENGINE_ACCESS_KEY environment variable is not set")
+ if not os.getenv("VOLCENGINE_SECRET_KEY"):
+ raise ValueError("VOLCENGINE_SECRET_KEY environment variable is not set")
+
+
+# 创建本地测试 Skill
+ensure_demo_skill()
+
+# 本地 skill 走 ADK 原生加载,远端 SkillSpace 走 VeSkillRegistry 按需加载。
+check_skillspace_env()
+local_skill = load_skill_from_dir(SKILL_DIR)
+remote_registry = VeSkillRegistry(
+ skill_source_id=SKILLSPACE_ID,
+ cache_dir=SKILLS_CACHE_DIR,
+)
+skill_toolset = SkillToolset(
+ skills=[local_skill],
+ registry=remote_registry,
+)
+
+# 定义测试 Agent
+personal_assistant = Agent(
+ name="legacy_skill_test_agent",
+ description="一个用于验证 VeADK 旧 skills 入口的测试助手。",
+ instruction="""你是一个友好的智能测试助手,请用简洁明了的中文回答用户的问题。
+回答时请说明依据来自哪个 skill 或资料。
+如果资料中没有相关信息,请明确说明资料未覆盖。
+""",
+ model_name=os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-8-251228"),
+ model_provider=os.getenv("MODEL_AGENT_PROVIDER", "openai"),
+ model_api_base=os.getenv(
+ "MODEL_AGENT_API_BASE",
+ "https://ark.cn-beijing.volces.com/api/v3/",
+ ),
+ model_api_key=os.getenv("MODEL_AGENT_API_KEY", "test-key"),
+ generate_content_config=MODEL_REQUEST_CONFIG,
+ model_extra_config=MODEL_EXTRA_CONFIG,
+ before_model_callback=_debug_model_request,
+ after_model_callback=_debug_model_response,
+ on_model_error_callback=_debug_model_error,
+ before_tool_callback=_debug_tool_start,
+ after_tool_callback=_debug_tool_end,
+ on_tool_error_callback=_debug_tool_error,
+ tools=[skill_toolset],
+)
+
+# 使用 veadk web 进行调试
+root_agent = personal_assistant
+
+
+async def main():
+ """运行测试助手的主函数"""
+ print("欢迎使用 VeADK 旧 skills 入口测试助手!")
+ print("输入 'exit' 或 'quit' 退出程序。")
+ print("=" * 50)
+
+ print("已加载本地 Skills:", [local_skill.name])
+ print("SkillSpace ID:", SKILLSPACE_ID)
+ print("Skill 缓存目录:", SKILLS_CACHE_DIR)
+ print("Agent 工具:", [type(tool).__name__ for tool in personal_assistant.tools])
+ print("是否使用旧 skills_tool:", "skills_tool" in personal_assistant.instruction)
+ print("=" * 50)
+
+ if not os.getenv("MODEL_AGENT_API_KEY"):
+ print("提示:当前未设置 MODEL_AGENT_API_KEY。")
+ print("如果只是验证 Agent 构建和 Skill 加载,这是正常的。")
+ print("如果要真实对话,请先在 .env 中配置 MODEL_AGENT_API_KEY。")
+ print("=" * 50)
+
+ runner = Runner(
+ agent=personal_assistant,
+ app_name="legacy_skill_test_demo",
+ user_id="demo_user",
+ )
+
+ while True:
+ user_input = input("请输入您的问题: ")
+
+ if user_input.lower() in ["exit", "quit", "退出"]:
+ print("再见!")
+ break
+
+ try:
+ response = await runner.run(messages=user_input)
+ print(f"助手: {response}")
+ except Exception as e:
+ print(f"发生错误: {e}")
+
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/tests/skills/test_adk_skill_materializer.py b/tests/skills/test_adk_skill_materializer.py
new file mode 100644
index 00000000..87ca762b
--- /dev/null
+++ b/tests/skills/test_adk_skill_materializer.py
@@ -0,0 +1,355 @@
+# 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 tempfile
+import zipfile
+from pathlib import Path
+
+import pytest
+from google.adk.skills import load_skill_from_dir
+
+from veadk.skills import SkillMaterializeError
+from veadk.skills import materializer
+from veadk.skills.materializer import materialize_remote_skill, skill_version_key
+from veadk.skills.skill import Skill as VeADKSkill
+
+
+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_skillhub_skill_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",
+ )
+
+ 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,
+ )
+
+ skill_dir = materialize_remote_skill(remote_skill, cache_dir=tmp_path)
+ skill = load_skill_from_dir(skill_dir)
+
+ assert skill.name == "hub-skill"
+ assert skill.instructions == "Hub body."
+ assert "readme.txt" in skill.resources.references
+ assert (
+ tmp_path / "skillhub" / "sp-test" / "hub-skill" / "v1" / "hub-skill"
+ ).is_dir()
+
+
+def test_default_cache_dir_uses_temp_dir(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.delenv("VEADK_SKILLS_CACHE_DIR", raising=False)
+
+ assert materializer._default_cache_dir() == (
+ Path(tempfile.gettempdir()) / "veadk" / "skills"
+ )
+
+
+def test_default_cache_dir_can_be_overridden_by_env(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setenv("VEADK_SKILLS_CACHE_DIR", str(tmp_path / "custom-cache"))
+
+ assert materializer._default_cache_dir() == tmp_path / "custom-cache"
+
+
+def test_legacy_skillspace_skill_uses_tos_path_version(
+ 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",
+ )
+
+ 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,
+ )
+
+ skill_dir = materialize_remote_skill(remote_skill, cache_dir=tmp_path)
+ skill = load_skill_from_dir(skill_dir)
+
+ assert skill_version_key(remote_skill) == "v1"
+ assert skill.name == "legacy-skill"
+ assert skill.instructions == "Legacy body."
+ assert skill_dir == (
+ tmp_path / "skillspace" / "space-1" / "legacy-skill" / "v1" / "legacy-skill"
+ )
+
+
+def test_remote_skill_reuses_cache_when_version_is_unchanged(
+ 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",
+ )
+ calls = 0
+
+ def download_skillhub_skill(skill: VeADKSkill, save_path: Path) -> bool:
+ nonlocal calls
+ calls += 1
+ save_path.write_bytes(
+ _zip_bytes(
+ {
+ "SKILL.md": (
+ "---\nname: hub-skill\ndescription: Hub skill.\n---\n"
+ "Hub body.\n"
+ )
+ }
+ )
+ )
+ return True
+
+ monkeypatch.setattr(
+ materializer,
+ "download_skillhub_skill",
+ download_skillhub_skill,
+ )
+
+ first_dir = materialize_remote_skill(remote_skill, cache_dir=tmp_path)
+ second_dir = materialize_remote_skill(remote_skill, cache_dir=tmp_path)
+
+ assert first_dir == second_dir
+ assert calls == 1
+
+
+def test_remote_skill_downloads_new_version_when_version_changes(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ version = "v1"
+
+ def make_skill(version_id: str) -> VeADKSkill:
+ return VeADKSkill(
+ name="hub-skill",
+ description="Hub skill.",
+ path="hub-skill",
+ skill_space_id="sp-test",
+ id="skill-id",
+ source_type="skillhub",
+ version_id=version_id,
+ )
+
+ 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"
+ f"Body {version}.\n"
+ )
+ }
+ )
+ )
+ return True
+
+ monkeypatch.setattr(
+ materializer,
+ "download_skillhub_skill",
+ download_skillhub_skill,
+ )
+
+ first_dir = materialize_remote_skill(make_skill("v1"), cache_dir=tmp_path)
+ version = "v2"
+ second_dir = materialize_remote_skill(make_skill("v2"), cache_dir=tmp_path)
+ skill = load_skill_from_dir(second_dir)
+
+ assert first_dir != second_dir
+ assert skill.instructions == "Body v2."
+ assert not first_dir.exists()
+
+
+def test_remote_skill_redownloads_when_cached_skill_is_invalid(
+ 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",
+ )
+ cached_dir = tmp_path / "skillhub" / "sp-test" / "hub-skill" / "v1" / "hub-skill"
+ cached_dir.mkdir(parents=True)
+ (cached_dir / "SKILL.md").write_text("not-frontmatter", encoding="utf-8")
+ calls = 0
+
+ def download_skillhub_skill(skill: VeADKSkill, save_path: Path) -> bool:
+ nonlocal calls
+ calls += 1
+ save_path.write_bytes(
+ _zip_bytes(
+ {
+ "SKILL.md": (
+ "---\nname: hub-skill\ndescription: Hub skill.\n---\n"
+ "Recovered.\n"
+ )
+ }
+ )
+ )
+ return True
+
+ monkeypatch.setattr(
+ materializer,
+ "download_skillhub_skill",
+ download_skillhub_skill,
+ )
+
+ skill_dir = materialize_remote_skill(remote_skill, cache_dir=tmp_path)
+ skill = load_skill_from_dir(skill_dir)
+
+ assert calls == 1
+ assert skill.instructions == "Recovered."
+
+
+def test_remote_skill_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",
+ )
+
+ 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(SkillMaterializeError, match="valid zip archive"):
+ materialize_remote_skill(remote_skill, cache_dir=tmp_path)
+
+
+def test_remote_skill_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.Path,
+ "mkdir",
+ lambda self, **kwargs: (_ for _ in ()).throw(
+ PermissionError("permission denied")
+ ),
+ )
+
+ with pytest.raises(SkillMaterializeError, match="VEADK_SKILLS_CACHE_DIR"):
+ materialize_remote_skill(remote_skill, cache_dir=tmp_path / "cache")
+
+
+def test_remote_skill_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",
+ )
+
+ 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(SkillMaterializeError, match="Unsafe path"):
+ materialize_remote_skill(remote_skill, cache_dir=tmp_path)
diff --git a/tests/skills/test_adk_skill_registry.py b/tests/skills/test_adk_skill_registry.py
new file mode 100644
index 00000000..7d989d3f
--- /dev/null
+++ b/tests/skills/test_adk_skill_registry.py
@@ -0,0 +1,217 @@
+# 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 asyncio
+from pathlib import Path
+
+import pytest
+
+from veadk.skills import VeSkillRegistry
+from veadk.skills import registry as registry_module
+from veadk.skills.skill import Skill as VeADKSkill
+
+
+def _write_adk_skill(
+ path: Path,
+ *,
+ name: str,
+ description: str = "Demo skill.",
+ body: str = "Skill body.",
+) -> None:
+ path.mkdir(parents=True, exist_ok=True)
+ (path / "SKILL.md").write_text(
+ f"---\nname: {name}\ndescription: {description}\n---\n{body}\n",
+ encoding="utf-8",
+ )
+
+
+def test_registry_requires_one_skill_source_id():
+ with pytest.raises(ValueError, match="exactly one skill_source_id"):
+ VeSkillRegistry(skill_source_id="")
+
+ with pytest.raises(ValueError, match="exactly one skill_source_id"):
+ VeSkillRegistry(skill_source_id="sp-one,sp-two")
+
+
+def test_registry_search_ignores_query_and_fetches_all_remote_skills(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ calls: list[str] = []
+ remote_skills = [
+ VeADKSkill(
+ name="alpha",
+ description="Alpha skill.",
+ path="alpha",
+ skill_space_id="sp-test",
+ id="skill-alpha",
+ slug="alpha-slug",
+ source_type="skillhub",
+ version_id="v1",
+ ),
+ VeADKSkill(
+ name="beta",
+ description="Beta skill.",
+ path="skills/s-2/v3/beta.zip",
+ skill_space_id="sp-test",
+ id="skill-beta",
+ ),
+ ]
+
+ def load_skills_from_cloud(skill_source_id: str) -> list[VeADKSkill]:
+ calls.append(skill_source_id)
+ return remote_skills
+
+ monkeypatch.setattr(
+ registry_module,
+ "load_skills_from_cloud",
+ load_skills_from_cloud,
+ )
+
+ registry = VeSkillRegistry(skill_source_id="sp-test")
+ first = asyncio.run(registry.search_skills(query="alpha only"))
+ second = asyncio.run(registry.search_skills(query=""))
+
+ assert calls == ["sp-test", "sp-test"]
+ assert [skill.name for skill in first] == ["alpha", "beta"]
+ assert [skill.name for skill in second] == ["alpha", "beta"]
+ assert first[0].metadata == {}
+ assert first[1].metadata == {}
+
+
+def test_registry_get_skill_refreshes_metadata_and_loads_requested_skill(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ calls: list[str] = []
+ materialized: list[str] = []
+ alpha = VeADKSkill(
+ name="alpha",
+ description="Alpha skill.",
+ path="alpha",
+ skill_space_id="sp-test",
+ id="skill-alpha",
+ source_type="skillhub",
+ version_id="v1",
+ )
+ beta = VeADKSkill(
+ name="beta",
+ description="Beta skill.",
+ path="beta",
+ skill_space_id="sp-test",
+ id="skill-beta",
+ source_type="skillhub",
+ version_id="v1",
+ )
+
+ def load_skills_from_cloud(skill_source_id: str) -> list[VeADKSkill]:
+ calls.append(skill_source_id)
+ return [alpha, beta]
+
+ def materialize_remote_skill(
+ skill: VeADKSkill,
+ *,
+ cache_dir: Path | None = None,
+ ) -> Path:
+ materialized.append(skill.name)
+ skill_dir = (cache_dir or tmp_path) / skill.name
+ _write_adk_skill(skill_dir, name=skill.name, body=f"{skill.name} body.")
+ return skill_dir
+
+ monkeypatch.setattr(
+ registry_module,
+ "load_skills_from_cloud",
+ load_skills_from_cloud,
+ )
+ monkeypatch.setattr(
+ registry_module,
+ "materialize_remote_skill",
+ materialize_remote_skill,
+ )
+
+ registry = VeSkillRegistry(skill_source_id="sp-test", cache_dir=tmp_path)
+ skill = asyncio.run(registry.get_skill(name="beta"))
+
+ assert calls == ["sp-test"]
+ assert materialized == ["beta"]
+ assert skill.name == "beta"
+ assert skill.instructions == "beta body."
+
+
+def test_registry_get_skill_fetches_remote_list_every_time(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ calls = 0
+ version = "v1"
+
+ def load_skills_from_cloud(skill_source_id: str) -> list[VeADKSkill]:
+ nonlocal calls
+ calls += 1
+ return [
+ VeADKSkill(
+ name="alpha",
+ description="Alpha skill.",
+ path="alpha",
+ skill_space_id=skill_source_id,
+ id="skill-alpha",
+ source_type="skillhub",
+ version_id=version,
+ )
+ ]
+
+ def materialize_remote_skill(
+ skill: VeADKSkill,
+ *,
+ cache_dir: Path | None = None,
+ ) -> Path:
+ skill_dir = (cache_dir or tmp_path) / skill.version_id / skill.name
+ _write_adk_skill(skill_dir, name=skill.name, body=f"Body {skill.version_id}.")
+ return skill_dir
+
+ monkeypatch.setattr(
+ registry_module,
+ "load_skills_from_cloud",
+ load_skills_from_cloud,
+ )
+ monkeypatch.setattr(
+ registry_module,
+ "materialize_remote_skill",
+ materialize_remote_skill,
+ )
+
+ registry = VeSkillRegistry(skill_source_id="sp-test", cache_dir=tmp_path)
+ first = asyncio.run(registry.get_skill(name="alpha"))
+ version = "v2"
+ second = asyncio.run(registry.get_skill(name="alpha"))
+
+ assert calls == 2
+ assert first.instructions == "Body v1."
+ assert second.instructions == "Body v2."
+
+
+def test_registry_get_skill_raises_when_name_is_missing(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setattr(
+ registry_module,
+ "load_skills_from_cloud",
+ lambda skill_source_id: [],
+ )
+
+ registry = VeSkillRegistry(skill_source_id="sp-test")
+
+ with pytest.raises(ValueError, match="Skill 'missing' not found"):
+ asyncio.run(registry.get_skill(name="missing"))
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..72884909 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,19 @@ 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. "
+ "For remote skill spaces, use veadk.skills.VeSkillRegistry "
+ "with 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..1e5ab879 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.exceptions import SkillLoadError, SkillMaterializeError
+from veadk.skills.registry import VeSkillRegistry
+
+__all__ = [
+ "SkillLoadError",
+ "SkillMaterializeError",
+ "VeSkillRegistry",
+]
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..a8db7833
--- /dev/null
+++ b/veadk/skills/materializer.py
@@ -0,0 +1,405 @@
+# 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 remote skills into local directories loadable by ADK."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import tempfile
+import zipfile
+from pathlib import Path
+
+import frontmatter
+from google.adk.skills import load_skill_from_dir
+
+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,
+)
+from veadk.utils.logger import get_logger
+
+logger = get_logger(__name__)
+
+_SKILL_MD_NAMES = ("SKILL.md", "skill.md")
+
+
+def materialize_remote_skill(
+ skill: Skill,
+ *,
+ cache_dir: Path | None = None,
+) -> Path:
+ """Download one remote VeADK skill and return an ADK-loadable skill dir."""
+ base_cache_dir = _default_cache_dir() if cache_dir is None else Path(cache_dir)
+ _ensure_cache_dir(base_cache_dir)
+
+ 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 = (
+ base_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:
+ try:
+ load_skill_from_dir(cached)
+ logger.info(f"Using cached ADK skill '{skill.name}' from {cached}")
+ return cached
+ except Exception as e:
+ logger.warning(
+ f"Cached ADK skill '{skill.name}' at {cached} is invalid: {e}. "
+ "Redownloading."
+ )
+ shutil.rmtree(version_dir)
+ elif version_dir.exists():
+ shutil.rmtree(version_dir)
+
+ 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()
+
+ final_dir = _normalize_extracted_skill_dir(staging_dir, version_dir, skill)
+ try:
+ load_skill_from_dir(final_dir)
+ except Exception as e:
+ raise SkillMaterializeError(
+ f"Skill directory '{final_dir}' failed ADK load validation: {e}"
+ ) from e
+
+ _cleanup_old_versions(version_dir)
+ return final_dir
+
+
+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(tempfile.gettempdir()) / "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 VeSkillRegistry or set "
+ "VEADK_SKILLS_CACHE_DIR to a writable directory."
+ ) from e
+
+
+def skill_version_key(skill: Skill) -> str:
+ """Return the cache version key for a remote skill metadata record."""
+ if skill.version_id:
+ return str(skill.version_id)
+
+ legacy_version = _legacy_skillspace_version_key(skill.path)
+ if legacy_version:
+ return legacy_version
+
+ 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 _legacy_skillspace_version_key(path: str) -> str | None:
+ path_parts = [part for part in path.split("/") if part]
+ if len(path_parts) >= 3 and path_parts[0] == "skills":
+ return path_parts[2]
+ return None
+
+
+def _cleanup_old_versions(current_version_dir: Path) -> None:
+ skill_dir = current_version_dir.parent
+ try:
+ for version_dir in skill_dir.iterdir():
+ if version_dir == current_version_dir or not version_dir.is_dir():
+ continue
+ shutil.rmtree(version_dir)
+ except Exception as e:
+ logger.warning(
+ f"Failed to clean old cached skill versions under {skill_dir}: {e}"
+ )
+
+
+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
diff --git a/veadk/skills/registry.py b/veadk/skills/registry.py
new file mode 100644
index 00000000..27b1621c
--- /dev/null
+++ b/veadk/skills/registry.py
@@ -0,0 +1,92 @@
+# 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 skill registry backed by a VeADK remote skill space."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+from google.adk.skills import Frontmatter, Skill as ADKSkill, SkillRegistry
+from google.adk.skills import load_skill_from_dir
+
+from veadk.skills.materializer import materialize_remote_skill
+from veadk.skills.skill import Skill as VeADKSkill
+from veadk.skills.utils import load_skills_from_cloud
+
+
+class VeSkillRegistry(SkillRegistry):
+ """ADK ``SkillRegistry`` implementation for one remote VeADK skill space."""
+
+ def __init__(
+ self,
+ *,
+ skill_source_id: str,
+ cache_dir: Path | None = None,
+ ) -> None:
+ normalized_skill_source_id = skill_source_id.strip()
+ if not normalized_skill_source_id or "," in normalized_skill_source_id:
+ raise ValueError("VeSkillRegistry requires exactly one skill_source_id.")
+
+ self.skill_source_id = normalized_skill_source_id
+ self.cache_dir = cache_dir
+
+ async def search_skills(self, *, query: str) -> list[Frontmatter]:
+ """Return all remote skills in this skill space, ignoring ``query``."""
+ del query
+ skills = await asyncio.to_thread(
+ load_skills_from_cloud,
+ self.skill_source_id,
+ )
+ return [self._to_frontmatter(skill) for skill in skills]
+
+ async def get_skill(self, *, name: str) -> ADKSkill:
+ """Refresh remote metadata, then load the requested skill on demand."""
+ remote_skills = await asyncio.to_thread(
+ load_skills_from_cloud,
+ self.skill_source_id,
+ )
+ skill = self._find_skill(remote_skills, name)
+ if skill is None:
+ raise ValueError(f"Skill '{name}' not found in '{self.skill_source_id}'.")
+
+ skill_dir = await asyncio.to_thread(
+ materialize_remote_skill,
+ skill,
+ cache_dir=self.cache_dir,
+ )
+ return await asyncio.to_thread(load_skill_from_dir, skill_dir)
+
+ def search_tool_description(self) -> str | None:
+ return (
+ "Search all skills available in the configured VeADK remote skill space. "
+ "The query is ignored; every remote skill is returned for discovery."
+ )
+
+ def _to_frontmatter(self, skill: VeADKSkill) -> Frontmatter:
+ return Frontmatter(
+ name=skill.name,
+ description=skill.description,
+ )
+
+ def _find_skill(
+ self,
+ remote_skills: list[VeADKSkill],
+ name: str,
+ ) -> VeADKSkill | None:
+ for skill in remote_skills:
+ if skill.name == name:
+ return skill
+ return None