301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""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
|