Source code for jeevesagent.skills.registry
"""SkillRegistry — manages a collection of available skills.
Built once when an :class:`Agent` is constructed. Holds every
:class:`Skill` discovered from the user's sources, applies
last-source-wins override semantics by name, and provides the two
hooks the framework needs to surface skills to the model:
* :meth:`catalog_section` — the markdown bullet list injected into
the system prompt at startup (the cheap "metadata" tier of
progressive disclosure)
* :meth:`load` — return a skill's full body when the model calls
the ``load_skill`` tool
Override semantics matches LangChain DeepAgents: when two sources
ship a skill with the same ``name``, the LATER source wins. This
lets users layer system → user → project skills and override at
any level::
skills=[
"~/.jeeves/skills/system/", # base
"~/.jeeves/skills/user/", # user override
"./.jeeves-skills/", # project override
]
"""
from __future__ import annotations
from collections.abc import Iterable, Iterator, Mapping
from pathlib import Path
from ..tools.registry import Tool
from .skill import Skill, SkillError, SkillMetadata
from .source import SkillSource
SkillSpec = Skill | SkillSource | str | Path | tuple[str | Path, str]
"""Anything an :class:`Agent`'s ``skills=`` argument accepts."""
[docs]
class SkillRegistry:
"""A keyed collection of :class:`Skill` instances."""
def __init__(
self, items: Iterable[SkillSpec] | None = None
) -> None:
self._skills: dict[str, Skill] = {}
# Track which skills' pending tools we've already pushed
# into the agent's tool host; load_skill becomes idempotent.
self._loaded: set[str] = set()
if items is not None:
for item in items:
self._ingest(item)
# ---- ingestion -----------------------------------------------------
def _ingest(self, item: SkillSpec) -> None:
"""Add one user-supplied spec to the registry, applying
last-wins override semantics."""
if isinstance(item, Skill):
self._add_one(item)
return
source = SkillSource.coerce(item)
for skill in source.discover():
self._add_one(skill)
def _add_one(self, skill: Skill) -> None:
# Last source wins by name — that's the documented behaviour.
# We keep override silent because it's load-bearing for the
# layered-sources pattern; users WILL override base skills
# by design.
self._skills[skill.name] = skill
[docs]
def add(self, skill: Skill) -> None:
"""Append (or override) a single skill after construction."""
self._add_one(skill)
[docs]
def remove(self, name: str) -> Skill | None:
"""Drop a skill by name. Returns the removed instance or
``None`` if no such skill was registered."""
return self._skills.pop(name, None)
# ---- lookup --------------------------------------------------------
def __contains__(self, name: object) -> bool:
return isinstance(name, str) and name in self._skills
def __len__(self) -> int:
return len(self._skills)
def __iter__(self) -> Iterator[Skill]:
return iter(self._skills.values())
[docs]
def names(self) -> list[str]:
return list(self._skills.keys())
[docs]
def get(self, name: str) -> Skill | None:
return self._skills.get(name)
# ---- agent-facing helpers ------------------------------------------
[docs]
def catalog_section(self) -> str:
"""The markdown bullet list that gets appended to the
agent's system prompt.
Empty registry → empty string (so the constructor can
unconditionally call this without polluting the system
prompt with a blank "Available skills" header)."""
if not self._skills:
return ""
bullets = "\n".join(
s.metadata.to_catalog_line()
for s in sorted(self._skills.values(), key=lambda s: s.name)
)
return (
"## Available skills\n\n"
"Each is a packaged playbook for a specific task. Call "
"`load_skill(name)` to read the full instructions for "
"any of these when the user's request matches its "
"description; otherwise just answer normally.\n\n"
f"{bullets}\n"
)
[docs]
def load(self, name: str) -> str:
"""Return the full body of a skill (the load_skill tool's
result). Raises :class:`SkillError` for unknown names so
the model gets a clear error in the tool result.
Does NOT register pending Tools. For the full load-and-
register flow, see :meth:`load_with_tools`."""
skill = self._skills.get(name)
if skill is None:
available = ", ".join(sorted(self._skills)) or "(none)"
raise SkillError(
f"Unknown skill {name!r}. Available: {available}"
)
return skill.load_body()
[docs]
def is_loaded(self, name: str) -> bool:
"""Whether the skill's pending tools have been registered."""
return name in self._loaded