Skip to content

Commit 49f4b7d

Browse files
authored
fix: tolerate structured skill frontmatter (#1080)
1 parent bad8248 commit 49f4b7d

2 files changed

Lines changed: 143 additions & 2 deletions

File tree

tests/skills/test_adk_skill_registry.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,87 @@ def materialize_remote_skill(
202202
assert second.instructions == "Body v2."
203203

204204

205+
def test_registry_get_skill_loads_community_frontmatter_extensions(
206+
tmp_path: Path,
207+
monkeypatch: pytest.MonkeyPatch,
208+
):
209+
skill_dir = tmp_path / "novel-storyboard"
210+
skill_dir.mkdir()
211+
(skill_dir / "SKILL.md").write_text(
212+
"""---
213+
name: novel-storyboard
214+
version: 1.3.0
215+
description: |
216+
给 AI 短剧出分镜。
217+
allowed-tools:
218+
- Read
219+
- Write
220+
- Bash
221+
- Task
222+
- Glob
223+
triggers:
224+
- novel-storyboard
225+
- 分镜
226+
- storyboard
227+
metadata:
228+
license: Apache-2.0
229+
requires:
230+
bins:
231+
- node
232+
optional:
233+
- codex
234+
runtimes:
235+
- claude-code
236+
- codex
237+
---
238+
## novel-storyboard
239+
240+
Skill body.
241+
""",
242+
encoding="utf-8",
243+
)
244+
remote_skill = VeADKSkill(
245+
name="novel-storyboard",
246+
description="Storyboard skill.",
247+
path="novel-storyboard",
248+
skill_space_id="sp-test",
249+
id="skill-novel-storyboard",
250+
source_type="skillhub",
251+
version_id="v1",
252+
)
253+
254+
monkeypatch.setattr(
255+
registry_module,
256+
"load_skills_from_cloud",
257+
lambda skill_source_id: [remote_skill],
258+
)
259+
monkeypatch.setattr(
260+
registry_module,
261+
"materialize_remote_skill",
262+
lambda skill, *, cache_dir=None: skill_dir,
263+
)
264+
265+
registry = VeSkillRegistry(skill_source_id="sp-test", cache_dir=tmp_path)
266+
skill = asyncio.run(registry.get_skill(name="novel-storyboard"))
267+
268+
assert skill.name == "novel-storyboard"
269+
assert "Skill body." in skill.instructions
270+
assert skill.frontmatter.allowed_tools is None
271+
assert skill.frontmatter.metadata["allowed-tools"] == [
272+
"Read",
273+
"Write",
274+
"Bash",
275+
"Task",
276+
"Glob",
277+
]
278+
assert skill.frontmatter.metadata["triggers"] == [
279+
"novel-storyboard",
280+
"分镜",
281+
"storyboard",
282+
]
283+
assert skill.frontmatter.metadata["requires"]["bins"] == ["node"]
284+
285+
205286
def test_registry_get_skill_raises_when_name_is_missing(
206287
monkeypatch: pytest.MonkeyPatch,
207288
):

veadk/skills/registry.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@
1818

1919
import asyncio
2020
from pathlib import Path
21+
from typing import Any
2122

22-
from google.adk.skills import Frontmatter, load_skill_from_dir
23+
from google.adk.skills import Frontmatter
2324
from google.adk.skills import Skill as ADKSkill
25+
from google.adk.skills import models as adk_skill_models
26+
from google.adk.skills import _utils as adk_skill_utils
2427

2528
try:
2629
from google.adk.skills import SkillRegistry
@@ -75,7 +78,7 @@ async def get_skill(self, *, name: str) -> ADKSkill:
7578
skill,
7679
cache_dir=self.cache_dir,
7780
)
78-
return await asyncio.to_thread(load_skill_from_dir, skill_dir)
81+
return await asyncio.to_thread(load_compatible_adk_skill_from_dir, skill_dir)
7982

8083
def search_tool_description(self) -> str | None:
8184
return (
@@ -98,3 +101,60 @@ def _find_skill(
98101
if skill.name == name:
99102
return skill
100103
return None
104+
105+
106+
def load_compatible_adk_skill_from_dir(skill_dir: str | Path) -> ADKSkill:
107+
"""Load an ADK skill while tolerating community frontmatter extensions."""
108+
resolved_skill_dir = Path(skill_dir).resolve()
109+
parsed, body, _ = adk_skill_utils._parse_skill_md(resolved_skill_dir)
110+
frontmatter = _compatible_frontmatter(parsed)
111+
112+
if resolved_skill_dir.name != frontmatter.name:
113+
raise ValueError(
114+
f"Skill name '{frontmatter.name}' does not match directory"
115+
f" name '{resolved_skill_dir.name}'."
116+
)
117+
118+
references = adk_skill_utils._load_dir(resolved_skill_dir / "references")
119+
assets = adk_skill_utils._load_dir(resolved_skill_dir / "assets")
120+
raw_scripts = adk_skill_utils._load_dir(resolved_skill_dir / "scripts")
121+
scripts = {
122+
name: adk_skill_models.Script(src=content)
123+
for name, content in raw_scripts.items()
124+
}
125+
126+
return adk_skill_models.Skill(
127+
frontmatter=frontmatter,
128+
instructions=body,
129+
resources=adk_skill_models.Resources(
130+
references=references,
131+
assets=assets,
132+
scripts=scripts,
133+
),
134+
)
135+
136+
137+
def _compatible_frontmatter(parsed: dict[str, Any]) -> Frontmatter:
138+
metadata = parsed.get("metadata")
139+
if not isinstance(metadata, dict):
140+
metadata = {}
141+
else:
142+
metadata = dict(metadata)
143+
144+
frontmatter_data: dict[str, Any] = {
145+
"name": parsed.get("name"),
146+
"description": parsed.get("description"),
147+
"metadata": metadata,
148+
}
149+
for key in ("license", "compatibility", "allowed-tools"):
150+
value = parsed.get(key)
151+
if isinstance(value, str):
152+
frontmatter_data[key] = value
153+
elif value is not None:
154+
metadata.setdefault(key, value)
155+
156+
for key, value in parsed.items():
157+
if key not in frontmatter_data and key not in metadata:
158+
metadata[key] = value
159+
160+
return Frontmatter.model_validate(frontmatter_data)

0 commit comments

Comments
 (0)