From a5f20b5b853345a85ef22c82100053a9b00b2bf0 Mon Sep 17 00:00:00 2001 From: "lixuefei.nice" Date: Tue, 8 Sep 2026 17:01:01 +0800 Subject: [PATCH] fix: tolerate structured skill frontmatter --- tests/skills/test_adk_skill_registry.py | 81 +++++++++++++++++++++++++ veadk/skills/registry.py | 64 ++++++++++++++++++- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/tests/skills/test_adk_skill_registry.py b/tests/skills/test_adk_skill_registry.py index 7d989d3fa..f9fc42753 100644 --- a/tests/skills/test_adk_skill_registry.py +++ b/tests/skills/test_adk_skill_registry.py @@ -202,6 +202,87 @@ def materialize_remote_skill( assert second.instructions == "Body v2." +def test_registry_get_skill_loads_community_frontmatter_extensions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + skill_dir = tmp_path / "novel-storyboard" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + """--- +name: novel-storyboard +version: 1.3.0 +description: | + 给 AI 短剧出分镜。 +allowed-tools: + - Read + - Write + - Bash + - Task + - Glob +triggers: + - novel-storyboard + - 分镜 + - storyboard +metadata: + license: Apache-2.0 + requires: + bins: + - node + optional: + - codex + runtimes: + - claude-code + - codex +--- +## novel-storyboard + +Skill body. +""", + encoding="utf-8", + ) + remote_skill = VeADKSkill( + name="novel-storyboard", + description="Storyboard skill.", + path="novel-storyboard", + skill_space_id="sp-test", + id="skill-novel-storyboard", + source_type="skillhub", + version_id="v1", + ) + + monkeypatch.setattr( + registry_module, + "load_skills_from_cloud", + lambda skill_source_id: [remote_skill], + ) + monkeypatch.setattr( + registry_module, + "materialize_remote_skill", + lambda skill, *, cache_dir=None: skill_dir, + ) + + registry = VeSkillRegistry(skill_source_id="sp-test", cache_dir=tmp_path) + skill = asyncio.run(registry.get_skill(name="novel-storyboard")) + + assert skill.name == "novel-storyboard" + assert "Skill body." in skill.instructions + assert skill.frontmatter.allowed_tools is None + assert skill.frontmatter.metadata["allowed-tools"] == [ + "Read", + "Write", + "Bash", + "Task", + "Glob", + ] + assert skill.frontmatter.metadata["triggers"] == [ + "novel-storyboard", + "分镜", + "storyboard", + ] + assert skill.frontmatter.metadata["requires"]["bins"] == ["node"] + + def test_registry_get_skill_raises_when_name_is_missing( monkeypatch: pytest.MonkeyPatch, ): diff --git a/veadk/skills/registry.py b/veadk/skills/registry.py index 7d646fce7..133aa23fe 100644 --- a/veadk/skills/registry.py +++ b/veadk/skills/registry.py @@ -18,9 +18,12 @@ import asyncio from pathlib import Path +from typing import Any -from google.adk.skills import Frontmatter, load_skill_from_dir +from google.adk.skills import Frontmatter from google.adk.skills import Skill as ADKSkill +from google.adk.skills import models as adk_skill_models +from google.adk.skills import _utils as adk_skill_utils try: from google.adk.skills import SkillRegistry @@ -75,7 +78,7 @@ async def get_skill(self, *, name: str) -> ADKSkill: skill, cache_dir=self.cache_dir, ) - return await asyncio.to_thread(load_skill_from_dir, skill_dir) + return await asyncio.to_thread(load_compatible_adk_skill_from_dir, skill_dir) def search_tool_description(self) -> str | None: return ( @@ -98,3 +101,60 @@ def _find_skill( if skill.name == name: return skill return None + + +def load_compatible_adk_skill_from_dir(skill_dir: str | Path) -> ADKSkill: + """Load an ADK skill while tolerating community frontmatter extensions.""" + resolved_skill_dir = Path(skill_dir).resolve() + parsed, body, _ = adk_skill_utils._parse_skill_md(resolved_skill_dir) + frontmatter = _compatible_frontmatter(parsed) + + if resolved_skill_dir.name != frontmatter.name: + raise ValueError( + f"Skill name '{frontmatter.name}' does not match directory" + f" name '{resolved_skill_dir.name}'." + ) + + references = adk_skill_utils._load_dir(resolved_skill_dir / "references") + assets = adk_skill_utils._load_dir(resolved_skill_dir / "assets") + raw_scripts = adk_skill_utils._load_dir(resolved_skill_dir / "scripts") + scripts = { + name: adk_skill_models.Script(src=content) + for name, content in raw_scripts.items() + } + + return adk_skill_models.Skill( + frontmatter=frontmatter, + instructions=body, + resources=adk_skill_models.Resources( + references=references, + assets=assets, + scripts=scripts, + ), + ) + + +def _compatible_frontmatter(parsed: dict[str, Any]) -> Frontmatter: + metadata = parsed.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + else: + metadata = dict(metadata) + + frontmatter_data: dict[str, Any] = { + "name": parsed.get("name"), + "description": parsed.get("description"), + "metadata": metadata, + } + for key in ("license", "compatibility", "allowed-tools"): + value = parsed.get(key) + if isinstance(value, str): + frontmatter_data[key] = value + elif value is not None: + metadata.setdefault(key, value) + + for key, value in parsed.items(): + if key not in frontmatter_data and key not in metadata: + metadata[key] = value + + return Frontmatter.model_validate(frontmatter_data)