mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-07-06 01:11:17 -03:00
- agent_handlers.py: 'Agent skill' -> 'LLM enrichment' in all log messages - skill_registry.py: 'agent skills' -> 'prompt-based skills' in discovery log - llm_service.py: docstring 'agent skills' -> 'LLM-based enrichment features'
211 lines
7.2 KiB
Python
211 lines
7.2 KiB
Python
"""Discovery and loading of prompt-based skills.
|
|
|
|
Skills live in ``py/services/agent/skills/<name>/`` directories. Each
|
|
directory must contain a ``prompt.md`` file with YAML frontmatter::
|
|
|
|
---
|
|
name: my_skill
|
|
title: "My Skill"
|
|
description: "What this skill does"
|
|
llm_required: true
|
|
---
|
|
|
|
Prompt template with ``{{variable}}`` placeholders.
|
|
|
|
Legacy ``SKILL.md`` files are also supported for backward compatibility.
|
|
|
|
The registry scans the skills directory on first access and caches results.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import yaml
|
|
|
|
from .skill_definition import SkillDefinition, SkillPermissions
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Directory where built-in skills are stored
|
|
_SKILLS_DIR = Path(__file__).parent / "skills"
|
|
|
|
#: Preferred file names for prompt definition files (tried in order).
|
|
#: ``prompt.md`` is the current convention; ``SKILL.md`` is the legacy name
|
|
#: kept for backward compatibility.
|
|
_PROMPT_FILE_NAMES: tuple[str, ...] = ("prompt.md", "SKILL.md")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 prompt definition file (``prompt.md`` or legacy ``SKILL.md``) 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:
|
|
"""Discover and load agent skills from the filesystem."""
|
|
|
|
_instance: Optional["SkillRegistry"] = None
|
|
_lock: asyncio.Lock = asyncio.Lock()
|
|
|
|
def __init__(self, skills_dir: Path = _SKILLS_DIR) -> None:
|
|
self._skills_dir = skills_dir
|
|
self._skills: Dict[str, SkillDefinition] = {}
|
|
self._loaded: bool = False
|
|
|
|
# ------------------------------------------------------------------
|
|
# Singleton access
|
|
# ------------------------------------------------------------------
|
|
|
|
@classmethod
|
|
async def get_instance(cls) -> "SkillRegistry":
|
|
"""Return the lazily-initialised global ``SkillRegistry``."""
|
|
|
|
if cls._instance is None:
|
|
async with cls._lock:
|
|
if cls._instance is None:
|
|
registry = cls()
|
|
registry._discover()
|
|
cls._instance = registry
|
|
return cls._instance
|
|
|
|
@classmethod
|
|
def reset_instance(cls) -> None:
|
|
"""Reset the cached singleton — primarily for tests."""
|
|
|
|
cls._instance = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Discovery
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _find_prompt_file(skill_dir: Path) -> Path | None:
|
|
"""Return the first prompt definition file that exists in *skill_dir*.
|
|
|
|
Tries ``_PROMPT_FILE_NAMES`` in order so that new conventions
|
|
(``prompt.md``) take precedence while legacy ``SKILL.md`` files
|
|
still load without changes.
|
|
"""
|
|
for name in _PROMPT_FILE_NAMES:
|
|
candidate = skill_dir / name
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
def _discover(self) -> None:
|
|
"""Scan the skills directory and load all valid skill definitions."""
|
|
|
|
self._skills.clear()
|
|
if not self._skills_dir.is_dir():
|
|
logger.warning("Skills directory does not exist: %s", self._skills_dir)
|
|
self._loaded = True
|
|
return
|
|
|
|
for entry in sorted(self._skills_dir.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
prompt_file = self._find_prompt_file(entry)
|
|
if prompt_file is None:
|
|
continue
|
|
try:
|
|
definition = self._load_skill_definition(prompt_file)
|
|
if definition is not None:
|
|
self._skills[definition.name] = definition
|
|
logger.debug("Loaded skill: %s", definition.name)
|
|
except Exception as exc:
|
|
logger.warning("Failed to load skill from %s: %s", prompt_file, exc)
|
|
|
|
self._loaded = True
|
|
logger.info("Discovered %d prompt-based skills", len(self._skills))
|
|
|
|
def _load_skill_definition(self, path: Path) -> Optional[SkillDefinition]:
|
|
"""Parse a prompt definition file's frontmatter into a
|
|
:class:`SkillDefinition`."""
|
|
|
|
try:
|
|
data, _body = _parse_skill_file(path)
|
|
except (ValueError, yaml.YAMLError) as exc:
|
|
logger.warning("Failed to parse prompt file %s: %s", path, exc)
|
|
return None
|
|
|
|
if "name" not in data:
|
|
logger.warning("Prompt file %s missing required 'name' field", path)
|
|
return None
|
|
|
|
perm_data = data.get("permissions", {})
|
|
permissions = SkillPermissions(
|
|
write_metadata=perm_data.get("write_metadata", True),
|
|
write_previews=perm_data.get("write_previews", True),
|
|
network_domains=tuple(perm_data.get("network_domains", [])),
|
|
)
|
|
|
|
return SkillDefinition(
|
|
name=data["name"],
|
|
title=data.get("title", data["name"]),
|
|
description=data.get("description", ""),
|
|
llm_required=data.get("llm_required", False),
|
|
input_schema=data.get("input_schema", {}),
|
|
output_schema=data.get("output_schema", {}),
|
|
model_type_filter=data.get("model_type_filter"),
|
|
permissions=permissions,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public API
|
|
# ------------------------------------------------------------------
|
|
|
|
def list_skills(self) -> List[SkillDefinition]:
|
|
"""Return all discovered skill definitions."""
|
|
|
|
if not self._loaded:
|
|
self._discover()
|
|
return list(self._skills.values())
|
|
|
|
def get_skill(self, name: str) -> Optional[SkillDefinition]:
|
|
"""Return the skill definition for ``name``, or ``None`` if not found."""
|
|
|
|
if not self._loaded:
|
|
self._discover()
|
|
return self._skills.get(name)
|
|
|
|
def load_prompt(self, name: str) -> str:
|
|
"""Load and return the prompt template body for the named skill."""
|
|
|
|
skill_dir = self._skills_dir / name
|
|
skill_path = self._find_prompt_file(skill_dir)
|
|
if skill_path is None:
|
|
raise FileNotFoundError(
|
|
f"Prompt file not found for skill '{name}' in {skill_dir} "
|
|
f"(tried {list(_PROMPT_FILE_NAMES)})"
|
|
)
|
|
try:
|
|
_frontmatter, body = _parse_skill_file(skill_path)
|
|
return body
|
|
except (ValueError, yaml.YAMLError) as exc:
|
|
raise ValueError(f"Failed to parse prompt from {skill_path}: {exc}") from exc
|