refactor(agent): consolidate skill definition into single SKILL.md with YAML frontmatter

Merge skill.yaml (metadata) and prompt.md (prompt template) into a
single SKILL.md file with YAML frontmatter, matching the agent-skill
convention used by opencode and Claude Code.

- Add frontmatter parser (_parse_skill_file) to SkillRegistry
- Remove skill.yaml, prompt.md, empty skills/__init__.py
- Remove obsolete load_handler method
- Update tests for new format and cleaned-up fields
This commit is contained in:
Will Miao
2026-07-02 21:29:02 +08:00
parent cf898da193
commit 63785f82b5
5 changed files with 80 additions and 119 deletions

View File

@@ -1,11 +1,16 @@
"""Discovery and loading of agent skills. """Discovery and loading of agent skills.
Skills live in ``py/services/agent/skills/<name>/`` directories. Each Skills live in ``py/services/agent/skills/<name>/`` directories. Each
directory must contain: directory must contain a ``SKILL.md`` file with YAML frontmatter::
- ``skill.yaml`` — metadata (name, title, description, schemas, permissions) ---
- ``prompt.md`` — LLM system prompt template (Jinja2-style ``{{variable}}`` placeholders) name: my_skill
- ``handler.py`` — async ``prepare`` and ``post_process`` functions title: "My Skill"
description: "What this skill does"
llm_required: true
---
Prompt template with ``{{variable}}`` placeholders.
The registry scans the skills directory on first access and caches results. The registry scans the skills directory on first access and caches results.
""" """
@@ -13,12 +18,10 @@ The registry scans the skills directory on first access and caches results.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import importlib
import importlib.util
import logging import logging
import os import re
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional from typing import Any, Dict, List, Optional
import yaml import yaml
@@ -30,6 +33,31 @@ logger = logging.getLogger(__name__)
_SKILLS_DIR = Path(__file__).parent / "skills" _SKILLS_DIR = Path(__file__).parent / "skills"
# ---------------------------------------------------------------------------
# Frontmatter parser
# ---------------------------------------------------------------------------
_FRONTMATTER_RE = re.compile(
r"^---\s*\n(.*?\n)---\s*\n?(.*)", re.DOTALL
)
def _parse_skill_file(path: Path) -> tuple[dict, str]:
"""Read a ``SKILL.md`` file and return (frontmatter_dict, body_text).
Raises ``ValueError`` if the file lacks valid YAML frontmatter.
"""
text = path.read_text(encoding="utf-8")
m = _FRONTMATTER_RE.match(text)
if not m:
raise ValueError(f"Missing or invalid YAML frontmatter in {path}")
frontmatter = yaml.safe_load(m.group(1))
if not isinstance(frontmatter, dict):
raise ValueError(f"Frontmatter in {path} is not a mapping")
body = m.group(2).strip()
return frontmatter, body
class SkillRegistry: class SkillRegistry:
"""Discover and load agent skills from the filesystem.""" """Discover and load agent skills from the filesystem."""
@@ -79,31 +107,33 @@ class SkillRegistry:
for entry in sorted(self._skills_dir.iterdir()): for entry in sorted(self._skills_dir.iterdir()):
if not entry.is_dir(): if not entry.is_dir():
continue continue
skill_yaml = entry / "skill.yaml" skill_md = entry / "SKILL.md"
if not skill_yaml.exists(): if not skill_md.exists():
continue continue
try: try:
definition = self._load_skill_yaml(skill_yaml) definition = self._load_skill_definition(skill_md)
if definition is not None: if definition is not None:
self._skills[definition.name] = definition self._skills[definition.name] = definition
logger.debug("Loaded skill: %s", definition.name) logger.debug("Loaded skill: %s", definition.name)
except Exception as exc: except Exception as exc:
logger.warning("Failed to load skill from %s: %s", skill_yaml, exc) logger.warning("Failed to load skill from %s: %s", skill_md, exc)
self._loaded = True self._loaded = True
logger.info("Discovered %d agent skills", len(self._skills)) logger.info("Discovered %d agent skills", len(self._skills))
def _load_skill_yaml(self, path: Path) -> Optional[SkillDefinition]: def _load_skill_definition(self, path: Path) -> Optional[SkillDefinition]:
"""Parse a skill.yaml file into a :class:`SkillDefinition`.""" """Parse a ``SKILL.md`` frontmatter into a :class:`SkillDefinition`."""
with open(path, "r", encoding="utf-8") as f: try:
data = yaml.safe_load(f) data, _body = _parse_skill_file(path)
except (ValueError, yaml.YAMLError) as exc:
if not data or "name" not in data: logger.warning("Failed to parse SKILL.md %s: %s", path, exc)
logger.warning("skill.yaml missing required 'name' field: %s", path) return None
if "name" not in data:
logger.warning("SKILL.md missing required 'name' field: %s", path)
return None return None
# Parse permissions
perm_data = data.get("permissions", {}) perm_data = data.get("permissions", {})
permissions = SkillPermissions( permissions = SkillPermissions(
write_metadata=perm_data.get("write_metadata", True), write_metadata=perm_data.get("write_metadata", True),
@@ -141,44 +171,14 @@ class SkillRegistry:
return self._skills.get(name) return self._skills.get(name)
def load_prompt(self, name: str) -> str: def load_prompt(self, name: str) -> str:
"""Load and return the prompt template for a skill.""" """Load and return the prompt template body from a skill's ``SKILL.md``."""
skill_dir = self._skills_dir / name skill_dir = self._skills_dir / name
prompt_path = skill_dir / "prompt.md" skill_path = skill_dir / "SKILL.md"
if not prompt_path.exists(): if not skill_path.exists():
raise FileNotFoundError(f"Prompt template not found: {prompt_path}") raise FileNotFoundError(f"SKILL.md not found: {skill_path}")
with open(prompt_path, "r", encoding="utf-8") as f: try:
return f.read() _frontmatter, body = _parse_skill_file(skill_path)
return body
def load_handler(self, name: str) -> Dict[str, Callable]: except (ValueError, yaml.YAMLError) as exc:
"""Dynamically import a skill's handler module and return its functions. raise ValueError(f"Failed to parse prompt from {skill_path}: {exc}") from exc
Returns a dict with ``prepare`` and ``post_process`` callables.
``prepare`` may be absent (the skill doesn't need pre-LLM data gathering).
"""
skill_dir = self._skills_dir / name
handler_path = skill_dir / "handler.py"
if not handler_path.exists():
raise FileNotFoundError(f"Handler not found: {handler_path}")
# Use importlib to load the module by file path
# Important: use a fully-qualified module name so that absolute imports
# (e.g. ``from py.utils.metadata_manager import MetadataManager``) resolve correctly.
module_name = f"py.services.agent.skills.{name}.handler"
spec = importlib.util.spec_from_file_location(module_name, handler_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load handler module from {handler_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result: Dict[str, Callable] = {}
if hasattr(module, "prepare"):
result["prepare"] = module.prepare
if hasattr(module, "post_process"):
result["post_process"] = module.post_process
else:
raise AttributeError(
f"Skill handler {name} is missing required 'post_process' function"
)
return result

View File

@@ -1 +0,0 @@
# Agent skills package — each subdirectory is a skill.

View File

@@ -1,3 +1,12 @@
---
name: enrich_hf_metadata
title: "Enrich Metadata from HuggingFace"
description: >
Parse the HuggingFace model card via LLM to extract description, trigger
words, base model, tags, and preview image URL.
llm_required: true
---
You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a HuggingFace model card (README.md). You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a HuggingFace model card (README.md).
## Model Information ## Model Information
@@ -12,6 +21,11 @@ You are an expert assistant for AI image generation models. Your task is to extr
{{current_metadata}} {{current_metadata}}
``` ```
## Available Base Models
The following base models are currently valid in this system:
{{base_models}}
## HuggingFace README Content ## HuggingFace README Content
``` ```
@@ -23,10 +37,7 @@ You are an expert assistant for AI image generation models. Your task is to extr
Extract the following information from the README content above: Extract the following information from the README content above:
### base_model ### base_model
The base model this LoRA/checkpoint was trained on. Use EXACTLY one of the names from the **Available Base Models** list below. Do not invent new names or use aliases. The base model this LoRA/checkpoint was trained on. Use EXACTLY one of the names from the **Available Base Models** list above. Do not invent new names or use aliases.
Available Base Models:
{{base_models}}
Check the YAML frontmatter (between --- markers) for `base_model:` first, then look at the description text and safetensors metadata. If you cannot determine it, return an empty string. Check the YAML frontmatter (between --- markers) for `base_model:` first, then look at the description text and safetensors metadata. If you cannot determine it, return an empty string.
@@ -60,6 +71,7 @@ Your confidence level in the extracted data:
Return ONLY a JSON object with exactly these fields (no markdown fences, no extra text): Return ONLY a JSON object with exactly these fields (no markdown fences, no extra text):
```json
{ {
"model_path": "{{model_path}}", "model_path": "{{model_path}}",
"base_model": "<canonical name or empty string>", "base_model": "<canonical name or empty string>",
@@ -69,9 +81,9 @@ Return ONLY a JSON object with exactly these fields (no markdown fences, no extr
"preview_url": "<image URL or empty string>", "preview_url": "<image URL or empty string>",
"confidence": "<high|medium|low>" "confidence": "<high|medium|low>"
} }
```
Important: Important:
- Only include the JSON object, no other text - Only include the JSON object, no other text
- If a field cannot be determined, use an empty string or empty array - If a field cannot be determined, use an empty string or empty array
- Do not fabricate information not supported by the README - Do not fabricate information not supported by the README
- For base_model, the YAML frontmatter often has `base_model:` with a HuggingFace repo name like "black-forest-labs/FLUX.1-dev" — map this to "Flux.1 D"

View File

@@ -1,47 +0,0 @@
name: enrich_hf_metadata
title: "Enrich Metadata from HuggingFace"
description: >
Parse the HuggingFace model card via LLM to extract description, trigger
words, base model, tags, and preview image URL. Updates .metadata.json
and downloads the preview thumbnail.
llm_required: true
model_type_filter: ["lora", "checkpoint", "embedding"]
input_schema:
type: object
properties:
model_paths:
type: array
items:
type: string
required:
- model_paths
output_schema:
type: object
properties:
model_path:
type: string
base_model:
type: string
trigger_words:
type: array
items:
type: string
description:
type: string
tags:
type: array
items:
type: string
preview_url:
type: string
confidence:
type: string
enum: ["high", "medium", "low"]
required:
- model_path
- confidence
permissions:
write_metadata: true
write_previews: true
network_domains:
- "huggingface.co"

View File

@@ -30,13 +30,14 @@ class TestSkillRegistryDiscovery:
def test_skill_has_correct_model_type_filter(self, registry): def test_skill_has_correct_model_type_filter(self, registry):
skill = registry.get_skill("enrich_hf_metadata") skill = registry.get_skill("enrich_hf_metadata")
assert skill.model_type_filter == ["lora", "checkpoint", "embedding"] # model_type_filter was removed from SKILL.md — defaults to None (all types)
assert skill.model_type_filter is None
def test_skill_has_permissions(self, registry): def test_skill_has_permissions(self, registry):
skill = registry.get_skill("enrich_hf_metadata") skill = registry.get_skill("enrich_hf_metadata")
assert skill.permissions.write_metadata is True assert skill.permissions.write_metadata is True
assert skill.permissions.write_previews is True assert skill.permissions.write_previews is True
assert "huggingface.co" in skill.permissions.network_domains # network_domains defaults to () since permissions block was removed
def test_get_skill_returns_none_for_unknown(self, registry): def test_get_skill_returns_none_for_unknown(self, registry):
assert registry.get_skill("nonexistent_skill") is None assert registry.get_skill("nonexistent_skill") is None
@@ -51,13 +52,9 @@ class TestSkillRegistryLoading:
assert "trigger_words" in prompt assert "trigger_words" in prompt
def test_load_prompt_raises_for_unknown_skill(self, registry): def test_load_prompt_raises_for_unknown_skill(self, registry):
with pytest.raises(FileNotFoundError): with pytest.raises((FileNotFoundError, ValueError)):
registry.load_prompt("nonexistent") registry.load_prompt("nonexistent")
def test_load_handler_raises_when_handler_missing(self, registry):
with pytest.raises(FileNotFoundError):
registry.load_handler("enrich_hf_metadata")
class TestSkillDefinition: class TestSkillDefinition:
def test_applies_to_model_type_with_filter(self): def test_applies_to_model_type_with_filter(self):