Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions tests/skills/test_adk_skill_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down
64 changes: 62 additions & 2 deletions veadk/skills/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand All @@ -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)
Loading