Spaces:
Sleeping
Sleeping
| """ | |
| Skill Loader - Progressive Disclosure Implementation. | |
| This module provides progressive disclosure loading for agent skills: | |
| - Frontmatter: Always loaded (~50 words) | |
| - Summary: Loaded on skill activation (~100 words) | |
| - Full: Loaded on demand (entire skill content) | |
| Based on Anthropic's Agent Skills pattern. | |
| """ | |
| import logging | |
| import re | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from pathlib import Path | |
| from typing import Any | |
| import yaml | |
| from .schemas import TeamRole | |
| logger = logging.getLogger(__name__) | |
| # Skill directory relative to this file | |
| SKILLS_DIR = Path(__file__).parent.parent / "prompts" / "skills" | |
| REFERENCES_DIR = "references" | |
| class LoadLevel(str, Enum): | |
| """Progressive disclosure levels.""" | |
| FRONTMATTER = "frontmatter" # Always loaded (metadata only) | |
| SUMMARY = "summary" # Loaded on activation (~100 words) | |
| FULL = "full" # Full content on demand | |
| class SkillMetadata: | |
| """Metadata extracted from skill frontmatter.""" | |
| name: str | |
| version: str = "1.0.0" | |
| triggers: list[str] = field(default_factory=list) | |
| summary: str = "" | |
| priority: str = "medium" | |
| estimated_tokens: int = 500 | |
| author: str = "system" | |
| tags: list[str] = field(default_factory=list) | |
| class SkillContent: | |
| """Loaded skill content at a specific disclosure level.""" | |
| level: LoadLevel | |
| metadata: SkillMetadata | |
| content: str | |
| references: dict[str, str] = field(default_factory=dict) | |
| def _normalize_role(role: str | TeamRole) -> str: | |
| """Convert role to directory name.""" | |
| if isinstance(role, TeamRole): | |
| return role.value | |
| return role.lower().strip() | |
| def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: | |
| """ | |
| Parse YAML frontmatter from markdown content. | |
| Returns: | |
| Tuple of (frontmatter_dict, remaining_content) | |
| """ | |
| # Match YAML frontmatter between --- markers | |
| pattern = r"^---\s*\n(.*?)\n---\s*\n(.*)$" | |
| match = re.match(pattern, content, re.DOTALL) | |
| if match: | |
| frontmatter_yaml = match.group(1) | |
| body = match.group(2) | |
| try: | |
| frontmatter = yaml.safe_load(frontmatter_yaml) or {} | |
| except yaml.YAMLError as e: | |
| logger.warning(f"Failed to parse frontmatter: {e}") | |
| frontmatter = {} | |
| return frontmatter, body | |
| return {}, content | |
| def _extract_summary(content: str, max_words: int = 100) -> str: | |
| """ | |
| Extract summary from content (first max_words words). | |
| Strips headers, focuses on core instructions. | |
| """ | |
| # Remove markdown headers | |
| lines = [] | |
| for line in content.split("\n"): | |
| # Skip header lines but keep content | |
| if line.startswith("#"): | |
| continue | |
| lines.append(line) | |
| cleaned = " ".join(lines) | |
| # Token estimation: ~4 chars per token | |
| max_chars = max_words * 4 | |
| if len(cleaned) <= max_chars: | |
| return cleaned.strip() | |
| # Truncate at word boundary | |
| truncated = cleaned[:max_chars] | |
| last_space = truncated.rfind(" ") | |
| if last_space > max_chars * 0.8: # At least 80% of max | |
| truncated = truncated[:last_space] | |
| return truncated.strip() + "..." | |
| class SkillLoader: | |
| """ | |
| Progressive disclosure loader for agent skills. | |
| Loads skill content at different disclosure levels: | |
| - FRONTMATTER: Always, for routing decisions | |
| - SUMMARY: On skill activation, core instructions | |
| - FULL: On demand, complete context | |
| Example: | |
| >>> loader = SkillLoader() | |
| >>> metadata = await loader.load_metadata("product_owner") | |
| >>> if should_activate(metadata): | |
| ... summary = await loader.load("product_owner", LoadLevel.SUMMARY) | |
| """ | |
| def __init__(self, base_path: Path | None = None): | |
| self.base_path = base_path or SKILLS_DIR | |
| self._metadata_cache: dict[str, SkillMetadata] = {} | |
| self._content_cache: dict[str, str] = {} | |
| def _get_skill_path(self, role: str) -> Path: | |
| """Get path to skill folder for role.""" | |
| normalized = _normalize_role(role) | |
| return self.base_path / normalized | |
| def _get_skill_file_path(self, role: str, filename: str = "SKILL.md") -> Path: | |
| """Get path to a file within the skill folder.""" | |
| return self._get_skill_path(role) / filename | |
| def _list_references(self, role: str) -> list[Path]: | |
| """List all reference files in the skill's references folder.""" | |
| skill_path = self._get_skill_path(role) | |
| refs_path = skill_path / REFERENCES_DIR | |
| if not refs_path.exists(): | |
| return [] | |
| return sorted(refs_path.glob("*.md")) | |
| async def load_metadata(self, role: str | TeamRole) -> SkillMetadata | None: | |
| """ | |
| Load skill metadata (from frontmatter) - always fast operation. | |
| This is the lightest weight load - just parses YAML frontmatter. | |
| """ | |
| role_value = _normalize_role(role) | |
| if role_value in self._metadata_cache: | |
| return self._metadata_cache[role_value] | |
| skill_path = self._get_skill_file_path(role_value, "SKILL.md") | |
| if not skill_path.exists(): | |
| logger.debug(f"Skill file not found: {skill_path}") | |
| return None | |
| try: | |
| content = skill_path.read_text(encoding="utf-8") | |
| frontmatter, _ = _parse_frontmatter(content) | |
| metadata = SkillMetadata( | |
| name=frontmatter.get("name", role_value), | |
| version=frontmatter.get("version", "1.0.0"), | |
| triggers=frontmatter.get("triggers", []), | |
| summary=frontmatter.get("summary", ""), | |
| priority=frontmatter.get("priority", "medium"), | |
| estimated_tokens=frontmatter.get("estimated_tokens", 500), | |
| author=frontmatter.get("author", "system"), | |
| tags=frontmatter.get("tags", []), | |
| ) | |
| self._metadata_cache[role_value] = metadata | |
| return metadata | |
| except Exception as e: | |
| logger.error(f"Failed to load metadata for {role_value}: {e}") | |
| return None | |
| async def load( | |
| self, | |
| role: str | TeamRole, | |
| level: LoadLevel = LoadLevel.FRONTMATTER, | |
| reference_query: str | None = None, | |
| ) -> SkillContent | None: | |
| """ | |
| Load skill content at specified disclosure level. | |
| Args: | |
| role: The role to load skill for | |
| level: Disclosure level (frontmatter, summary, full) | |
| reference_query: Optional query for semantic search in references | |
| Returns: | |
| SkillContent with metadata and content at requested level | |
| """ | |
| role_value = _normalize_role(role) | |
| # Get metadata (always needed) | |
| metadata = await self.load_metadata(role_value) | |
| if not metadata: | |
| return None | |
| # Handle each level | |
| if level == LoadLevel.FRONTMATTER: | |
| # Just return metadata summary as content | |
| return SkillContent( | |
| level=level, | |
| metadata=metadata, | |
| content=metadata.summary | |
| or f"Skill: {metadata.name} v{metadata.version}", | |
| ) | |
| # Load full content for SUMMARY or FULL | |
| skill_path = self._get_skill_file_path(role_value, "SKILL.md") | |
| if not skill_path.exists(): | |
| logger.warning(f"Skill file not found: {skill_path}") | |
| return None | |
| # Check cache for full content | |
| cache_key = f"{role_value}:full" | |
| if cache_key not in self._content_cache: | |
| self._content_cache[cache_key] = skill_path.read_text(encoding="utf-8") | |
| full_content = self._content_cache[cache_key] | |
| # Parse frontmatter to get body | |
| _, body = _parse_frontmatter(full_content) | |
| if level == LoadLevel.SUMMARY: | |
| # Extract summary (first ~100 words) | |
| summary = _extract_summary(body, max_words=100) | |
| return SkillContent( | |
| level=level, | |
| metadata=metadata, | |
| content=summary, | |
| ) | |
| # FULL level - load with references if query provided | |
| references: dict[str, str] = {} | |
| if reference_query: | |
| # Semantic search in references would go here | |
| # For now, load all references | |
| for ref_file in self._list_references(role_value): | |
| ref_content = ref_file.read_text(encoding="utf-8") | |
| references[ref_file.stem] = ref_content | |
| return SkillContent( | |
| level=level, | |
| metadata=metadata, | |
| content=body, | |
| references=references, | |
| ) | |
| async def should_activate(self, role: str | TeamRole, user_input: str) -> bool: | |
| """ | |
| Determine if skill should activate based on user input. | |
| Matches against triggers in frontmatter. | |
| """ | |
| metadata = await self.load_metadata(role) | |
| if not metadata or not metadata.triggers: | |
| return True # No triggers = always activate | |
| input_lower = user_input.lower() | |
| return any(trigger.lower() in input_lower for trigger in metadata.triggers) | |
| async def list_skills(self) -> list[str]: | |
| """List all available skills.""" | |
| if not self.base_path.exists(): | |
| return [] | |
| skills = [] | |
| for item in self.base_path.iterdir(): | |
| if item.is_dir() and (item / "SKILL.md").exists(): | |
| skills.append(item.name) | |
| return sorted(skills) | |
| def clear_cache(self): | |
| """Clear all caches (useful for development).""" | |
| self._metadata_cache.clear() | |
| self._content_cache.clear() | |
| logger.info("Skill loader cache cleared") | |
| # Singleton instance | |
| _loader: SkillLoader | None = None | |
| def get_skill_loader() -> SkillLoader: | |
| """Get singleton SkillLoader instance.""" | |
| global _loader | |
| if _loader is None: | |
| _loader = SkillLoader() | |
| return _loader | |
| # Convenience functions for common operations | |
| async def get_skill_summary(role: str | TeamRole) -> str | None: | |
| """Get skill summary at SUMMARY disclosure level.""" | |
| loader = get_skill_loader() | |
| content = await loader.load(role, LoadLevel.SUMMARY) | |
| return content.content if content else None | |
| async def get_skill_metadata(role: str | TeamRole) -> SkillMetadata | None: | |
| """Get skill metadata.""" | |
| loader = get_skill_loader() | |
| return await loader.load_metadata(role) | |