"""Unified skill lifecycle manager. ``SkillsManager`` is the single facade that owns skill discovery, loading, and prompt renderation. The runtime creates one at startup and downstream layers read the cached prompt strings. Typical usage — **config-driven** (runner passes configuration):: config = SkillsManagerConfig( skills_config=SkillsConfig.from_agent_vars(...), project_root=agent_path, ) mgr = SkillsManager(config) mgr.load() print(mgr.protocols_prompt) # default skill protocols print(mgr.skills_catalog_prompt) # community skills XML Typical usage — **bare** (exported agents, SDK users):: mgr = SkillsManager() # default config mgr.load() # loads all 6 default skills, no community discovery """ from __future__ import annotations import logging from dataclasses import dataclass, field from pathlib import Path from framework.skills.config import SkillsConfig logger = logging.getLogger(__name__) @dataclass class SkillsManagerConfig: """Everything the runtime needs to configure skills. Attributes: skills_config: Per-skill enable/disable and overrides. project_root: Agent directory for community skill discovery. When ``None``, community discovery is skipped. skip_community_discovery: Explicitly skip community scanning even when ``project_root`` is set. interactive: Whether trust gating can prompt the user interactively. When ``False``, untrusted project skills are silently skipped. """ skills_config: SkillsConfig = field(default_factory=SkillsConfig) project_root: Path | None = None skip_community_discovery: bool = False interactive: bool = True class SkillsManager: """Unified skill lifecycle: discovery → loading → prompt renderation. The runtime creates one instance during init and owns it for the lifetime of the process. Downstream layers (``ExecutionStream``, ``GraphExecutor``, ``NodeContext``, ``EventLoopNode``) receive the cached prompt strings via property accessors. """ def __init__(self, config: SkillsManagerConfig | None = None) -> None: self._config = config or SkillsManagerConfig() self._loaded = False self._catalog_prompt: str = "" self._protocols_prompt: str = "" self._allowlisted_dirs: list[str] = [] self._default_mgr: object = None # DefaultSkillManager, set after load() # Hot-reload state self._watched_dirs: list[str] = [] self._watcher_task: object = None # asyncio.Task, set by start_watching() # ------------------------------------------------------------------ # Factory for backwards-compat bridge # ------------------------------------------------------------------ @classmethod def from_precomputed( cls, skills_catalog_prompt: str = "", protocols_prompt: str = "", ) -> SkillsManager: """Wrap pre-rendered prompt strings (legacy callers). Returns a manager that skips discovery/loading and just returns the provided strings. Used by the deprecation bridge in ``AgentRuntime`` when callers pass raw prompt strings. """ mgr = cls.__new__(cls) mgr._config = SkillsManagerConfig() mgr._loaded = True # skip load() mgr._catalog_prompt = skills_catalog_prompt mgr._protocols_prompt = protocols_prompt mgr._allowlisted_dirs = [] mgr._default_mgr = None return mgr # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ def load(self) -> None: """Discover, load, and cache skill prompts. Idempotent.""" if self._loaded: return self._loaded = True try: self._do_load() except Exception: logger.warning("Skill system init failed (non-fatal)", exc_info=True) def _do_load(self) -> None: """Internal load — may raise; caller catches.""" from framework.skills.catalog import SkillCatalog from framework.skills.defaults import DefaultSkillManager from framework.skills.discovery import DiscoveryConfig, SkillDiscovery skills_config = self._config.skills_config # 1. Skill discovery -- always run to pick up framework skills; # community/project skills only when project_root is available. discovery = SkillDiscovery( DiscoveryConfig( project_root=self._config.project_root, skip_framework_scope=False, ) ) discovered = discovery.discover() self._watched_dirs = discovery.scanned_directories # Trust-gate project-scope skills (AS-13) if self._config.project_root is not None and not self._config.skip_community_discovery: from framework.skills.trust import TrustGate discovered = TrustGate(interactive=self._config.interactive).filter_and_gate( discovered, project_dir=self._config.project_root ) catalog = SkillCatalog(discovered) self._allowlisted_dirs = catalog.allowlisted_dirs catalog_prompt = catalog.to_prompt() # Pre-activated community skills if skills_config.skills: pre_activated = catalog.build_pre_activated_prompt(skills_config.skills) if pre_activated: if catalog_prompt: catalog_prompt = f"{catalog_prompt}\n\n{pre_activated}" else: catalog_prompt = pre_activated # 2. Default skills -- discovered via _default_skills/ and included # in the catalog for progressive disclosure (no longer force-injected # as protocols_prompt). DefaultSkillManager still handles config, # logging, and metadata. default_mgr = DefaultSkillManager(config=skills_config) default_mgr.load() default_mgr.log_active_skills() self._default_mgr = default_mgr # 3. Cache self._catalog_prompt = catalog_prompt self._protocols_prompt = "" # all skills use progressive disclosure now if catalog_prompt: logger.info( "Skill system ready: catalog=%d chars", len(catalog_prompt), ) # ------------------------------------------------------------------ # Hot-reload: watch skill directories for SKILL.md changes. # ------------------------------------------------------------------ async def start_watching(self) -> None: """Start a background task watching skill directories for changes. When a ``SKILL.md`` file is added/modified/removed, the cached ``skills_catalog_prompt`` is rebuilt. The next node iteration picks up the new prompt automatically via the ``dynamic_prompt_provider``. Silently no-ops when ``watchfiles`` is not installed or when no directories are being watched (e.g. bare mode, no project_root). """ import asyncio try: import watchfiles # noqa: F401 -- optional dep check except ImportError: logger.debug("watchfiles not installed; skill hot-reload disabled") return if not self._watched_dirs: logger.debug("No skill directories to watch; hot-reload skipped") return if self._watcher_task is not None: return # already watching self._watcher_task = asyncio.create_task( self._watch_loop(), name="skills-hot-reload", ) logger.info( "Skill hot-reload enabled (watching %d directories)", len(self._watched_dirs), ) async def stop_watching(self) -> None: """Cancel the background watcher task (if running).""" import asyncio task = self._watcher_task if task is None: return self._watcher_task = None if not task.done(): # type: ignore[attr-defined] task.cancel() # type: ignore[attr-defined] try: await task # type: ignore[misc] except asyncio.CancelledError: pass async def _watch_loop(self) -> None: """Background coroutine that watches SKILL.md files and triggers reload.""" import asyncio import watchfiles def _filter(_change: object, path: str) -> bool: return path.endswith("SKILL.md") try: async for changes in watchfiles.awatch( *self._watched_dirs, watch_filter=_filter, debounce=1000, ): paths = [p for _, p in changes] logger.info("SKILL.md changes detected: %s", paths) try: self._reload() except Exception: logger.exception("Skill reload failed; keeping previous prompts") except asyncio.CancelledError: raise except Exception: logger.exception("Skill watcher crashed; hot-reload disabled for this session") def _reload(self) -> None: """Re-run discovery and rebuild cached prompts.""" # Reset loaded flag so _do_load actually re-runs. self._loaded = False self._do_load() self._loaded = True logger.info( "Skills reloaded: protocols=%d chars, catalog=%d chars", len(self._protocols_prompt), len(self._catalog_prompt), ) # ------------------------------------------------------------------ # Prompt accessors (consumed by downstream layers) # ------------------------------------------------------------------ @property def skills_catalog_prompt(self) -> str: """Community skills XML catalog for system prompt injection.""" return self._catalog_prompt @property def protocols_prompt(self) -> str: """Default skill operational protocols for system prompt injection.""" return self._protocols_prompt @property def allowlisted_dirs(self) -> list[str]: """Skill base directories for Tier 3 resource access (AS-6).""" return self._allowlisted_dirs @property def batch_init_nudge(self) -> str | None: """Batch init nudge text for DS-12 auto-detection, or None if disabled.""" if self._default_mgr is None: return None return self._default_mgr.batch_init_nudge # type: ignore[union-attr] @property def context_warn_ratio(self) -> float | None: """Token usage ratio for DS-13 context preservation warning, or None if disabled.""" if self._default_mgr is None: return None return self._default_mgr.context_warn_ratio # type: ignore[union-attr] @property def is_loaded(self) -> bool: return self._loaded