diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..39b1230 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,75 @@ +# Skills Capability + +Closes #22. Partially addresses #40 (deferred tool loading via search + load pattern). + +## Summary + +Implements a `Skills` capability that enables progressive tool loading: agents discover +skills via `search_skills(query)` and activate them via `load_skill(name)`, keeping +unloaded tools hidden from the model's context window. + +## Design + +### `Skill` dataclass + +A skill bundles a name, description, optional instructions, and a set of tools: + +```python +Skill( + name='math', # lowercase, hyphens allowed + description='Arithmetic', # shown in search results + tools=[add, subtract], # callables or a FunctionToolset + instructions='...', # included when skill is loaded +) +``` + +Tools can be plain callables (registered as `tool_plain`) or a pre-built +`FunctionToolset` for more control. + +### `Skills` capability + +An `AbstractCapability` subclass providing: + +| Method | Purpose | +|--------|---------| +| `get_instructions()` | Tells the agent about the skill catalog | +| `get_toolset()` | Registers `search_skills`, `load_skill`, and all skill tools | +| `prepare_tools()` | Hides tools from unloaded skills each model request | +| `for_run()` | Isolates per-run loaded-skills state | +| `from_spec(dirs=[...])` | Loads markdown skills from directories (Tier S serializable) | + +### Markdown skill files + +Skills can be defined as `.md` files with YAML frontmatter: + +```markdown +--- +name: my-skill +description: Does something useful +--- +Detailed instructions for the agent... +``` + +Loaded via `load_skills_from_directory(path)` or `Skills.from_spec(dirs=[path])`. +Markdown skills are pure knowledge (no tools) -- pair with Python skills as needed. + +### Progressive disclosure flow + +1. Agent sees `search_skills` and `load_skill` tools (always visible) +2. Agent calls `search_skills("math")` -- gets name + description matches +3. Agent calls `load_skill("math")` -- gets instructions + tool names, tools become visible +4. Agent can now call `add(1, 2)` etc. + +## Files changed + +- `src/pydantic_harness/skills.py` -- `Skill`, `Skills`, `load_skills_from_directory` +- `src/pydantic_harness/__init__.py` -- exports +- `tests/test_skills.py` -- 47 tests covering all code paths +- `pyproject.toml` -- pyright test override for private usage + +## Prior art considered + +- vstorm-co/pydantic-ai-skills (SkillsCapability + SkillsToolset) +- OpenAI Agents SDK ToolSearchTool +- Anthropic Tool Search +- Microsoft Agent Framework SKILL.md spec diff --git a/src/pydantic_harness/__init__.py b/src/pydantic_harness/__init__.py index 9d728b6..e060005 100644 --- a/src/pydantic_harness/__init__.py +++ b/src/pydantic_harness/__init__.py @@ -7,4 +7,10 @@ # Each capability module is imported and re-exported here. # Capabilities are listed alphabetically. -__all__: list[str] = [] +from pydantic_harness.skills import Skill, Skills, load_skills_from_directory # noqa: F401 + +__all__: list[str] = [ + 'Skill', + 'Skills', + 'load_skills_from_directory', +] diff --git a/src/pydantic_harness/skills/__init__.py b/src/pydantic_harness/skills/__init__.py new file mode 100644 index 0000000..57cf8e9 --- /dev/null +++ b/src/pydantic_harness/skills/__init__.py @@ -0,0 +1,6 @@ +"""Skills capability for progressive tool loading.""" + +from pydantic_harness.skills._capability import Skills +from pydantic_harness.skills._toolset import Skill, load_skills_from_directory + +__all__ = ['Skill', 'Skills', 'load_skills_from_directory'] diff --git a/src/pydantic_harness/skills/_capability.py b/src/pydantic_harness/skills/_capability.py new file mode 100644 index 0000000..c6c2528 --- /dev/null +++ b/src/pydantic_harness/skills/_capability.py @@ -0,0 +1,212 @@ +"""Skills capability -- progressive skill discovery and loading.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from pydantic_ai._run_context import AgentDepsT, RunContext +from pydantic_ai.capabilities.abstract import AbstractCapability +from pydantic_ai.tools import ToolDefinition +from pydantic_ai.toolsets.function import FunctionToolset + +from pydantic_harness.skills._toolset import Skill, load_skills_from_directory + + +@dataclass +class Skills(AbstractCapability[AgentDepsT]): + """Capability for progressive skill discovery and loading. + + Provides ``search_skills``, ``load_skill``, and ``unload_skill`` + meta-tools. Tools belonging to registered skills are hidden until + the agent explicitly loads the skill that owns them. + + Per-run state (which skills are loaded) is isolated via + :meth:`for_run`. + + Example:: + + from pydantic_ai import Agent + from pydantic_harness.skills import Skill, Skills + + def add(a: int, b: int) -> int: + \"\"\"Add two numbers.\"\"\" + return a + b + + math_skill = Skill( + name='math', + description='Basic arithmetic operations', + tools=[add], + ) + agent = Agent('openai:gpt-4o', capabilities=[Skills(skills=[math_skill])]) + """ + + skills: list[Skill] = field(default_factory=lambda: list[Skill]()) + """Registered skills.""" + + _loaded_skill_names: set[str] = field(default_factory=lambda: set[str](), init=False, repr=False) + """Names of skills that have been loaded in the current run (per-run state).""" + + @classmethod + def get_serialization_name(cls) -> str | None: # noqa: D102 + return 'Skills' + + @classmethod + def from_spec(cls, *args: Any, **kwargs: Any) -> Skills[Any]: + """Create from spec arguments. + + Accepts ``dirs`` (list of directory paths) to load markdown skills. + """ + dirs: list[str] = kwargs.pop('dirs', []) or list(args) + all_skills: list[Skill] = [] + for d in dirs: + all_skills.extend(load_skills_from_directory(d)) + return cls(skills=all_skills, **kwargs) + + async def for_run(self, ctx: RunContext[AgentDepsT]) -> Skills[AgentDepsT]: + """Return a fresh copy with empty loaded-skills state.""" + clone: Skills[AgentDepsT] = Skills(skills=self.skills) + return clone + + def get_instructions(self) -> str | None: + """Provide baseline instructions for skill discovery.""" + if not self.skills: + return None + return ( + 'You have access to a skill catalog. ' + 'Use `search_skills` to find relevant skills by keyword, ' + 'then `load_skill` to activate a skill and make its tools available. ' + 'Use `unload_skill` when you no longer need a skill, to free context. ' + "Only loaded skills' tools appear in your tool list." + ) + + def get_toolset(self) -> FunctionToolset[AgentDepsT] | None: + """Build the toolset containing meta-tools and all skill tools.""" + if not self.skills: + return None + + toolset: FunctionToolset[AgentDepsT] = FunctionToolset() + + # Register meta-tools + toolset.add_function(self._search_skills, takes_ctx=False, name='search_skills') + toolset.add_function(self._load_skill, takes_ctx=False, name='load_skill') + toolset.add_function(self._unload_skill, takes_ctx=False, name='unload_skill') + + # Register each skill's tools (they will be hidden until loaded) + for skill in self.skills: + if isinstance(skill.tools, FunctionToolset): + for tool in skill.tools.tools.values(): + toolset.add_tool(tool) + else: + for fn in skill.tools: + toolset.add_function(fn, takes_ctx=False) + + return toolset + + async def prepare_tools( + self, + ctx: RunContext[AgentDepsT], + tool_defs: list[ToolDefinition], + ) -> list[ToolDefinition]: + """Hide tools belonging to skills that have not been loaded yet.""" + # Build set of tool names that should be hidden + hidden: set[str] = set() + for skill in self.skills: + if skill.name not in self._loaded_skill_names: + hidden.update(skill.tool_names()) + + # Always keep meta-tools visible + meta_tools = {'search_skills', 'load_skill', 'unload_skill'} + + return [td for td in tool_defs if td.name in meta_tools or td.name not in hidden] + + # -- Meta-tool implementations -- + + def _search_skills(self, query: str) -> list[dict[str, str]]: + """Search available skills by keyword. + + Returns a list of matching skills with their name, description, + and whether they are currently loaded, ranked by relevance. + + The query is split into words and each word is matched + case-insensitively against the skill name and description. + Skills matching at least one word are returned, ordered by the + number of matching words (most relevant first). + + Args: + query: A keyword or phrase to search for in skill names and descriptions. + """ + words = query.lower().split() + if not words: + return [] + + scored: list[tuple[int, Skill]] = [] + for skill in self.skills: + haystack = f'{skill.name} {skill.description}'.lower() + matches = sum(1 for w in words if w in haystack) + if matches: + scored.append((matches, skill)) + + # Sort by match count descending, then by name for stability + scored.sort(key=lambda pair: (-pair[0], pair[1].name)) + + return [ + { + 'name': skill.name, + 'description': skill.description, + 'loaded': 'yes' if skill.name in self._loaded_skill_names else 'no', + } + for _, skill in scored + ] + + def _load_skill(self, name: str) -> str: + """Load a skill by name, making its tools available. + + After loading, the skill's tools will appear in subsequent tool + lists and any associated instructions will be included. + + Args: + name: The exact name of the skill to load (as returned by ``search_skills``). + """ + skill = self._find_skill(name) + if skill is None: + available = ', '.join(s.name for s in self.skills) + return f'Skill {name!r} not found. Available skills: {available}' + + self._loaded_skill_names.add(name) + + parts = [f'Skill {name!r} loaded.'] + tool_names = skill.tool_names() + if tool_names: + parts.append(f'Available tools: {", ".join(tool_names)}') + if skill.instructions: + parts.append(f'Instructions:\n{skill.instructions}') + return '\n'.join(parts) + + def _unload_skill(self, name: str) -> str: + """Unload a skill by name, removing its tools from the context. + + Use this when you no longer need a skill's tools, to free up + space in the context window. + + Args: + name: The exact name of the skill to unload. + """ + skill = self._find_skill(name) + if skill is None: + available = ', '.join(s.name for s in self.skills) + return f'Skill {name!r} not found. Available skills: {available}' + + if name not in self._loaded_skill_names: + return f'Skill {name!r} is not currently loaded.' + + self._loaded_skill_names.discard(name) + return f'Skill {name!r} unloaded. Its tools are no longer available.' + + # -- Helpers -- + + def _find_skill(self, name: str) -> Skill | None: + for skill in self.skills: + if skill.name == name: + return skill + return None diff --git a/src/pydantic_harness/skills/_toolset.py b/src/pydantic_harness/skills/_toolset.py new file mode 100644 index 0000000..6104365 --- /dev/null +++ b/src/pydantic_harness/skills/_toolset.py @@ -0,0 +1,126 @@ +"""Skill dataclass, markdown parsing, and directory loading.""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from pydantic_ai.toolsets.function import FunctionToolset + + +@dataclass +class Skill: + """A self-contained skill that an agent can discover and load on demand. + + Args: + name: Short, unique identifier (lowercase, hyphens allowed). + description: One-line summary shown in search results. + tools: Callables (or :class:`~pydantic_ai.FunctionToolset`) whose + tools become available when the skill is loaded. + instructions: Optional long-form guidance included in the system + prompt when the skill is loaded. + """ + + name: str + description: str + tools: Sequence[Callable[..., Any]] | FunctionToolset[Any] = field( + default_factory=lambda: list[Callable[..., Any]]() + ) + instructions: str | None = None + + def __post_init__(self) -> None: # noqa: D105 + if not re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?', self.name): + raise ValueError(f'Skill name must be lowercase alphanumeric with optional hyphens, got {self.name!r}') + + def tool_names(self) -> list[str]: + """Return the names of all tools provided by this skill.""" + if isinstance(self.tools, FunctionToolset): + return list(self.tools.tools.keys()) + return [_func_name(fn) for fn in self.tools] + + +def _func_name(fn: Callable[..., Any]) -> str: + """Best-effort name extraction from a callable.""" + return getattr(fn, '__name__', None) or getattr(fn, '__qualname__', str(fn)) + + +def load_skills_from_directory(directory: str | Path) -> list[Skill]: + """Load skills from markdown files in *directory*. + + Each ``.md`` file is parsed as a skill definition: YAML frontmatter + provides ``name`` and ``description``, and the body becomes the + ``instructions``. Frontmatter is delimited by ``---`` lines. + + Example file ``my-skill.md``:: + + --- + name: my-skill + description: Does something useful + --- + Detailed instructions for the agent... + + Skills loaded from markdown carry no tools -- they are pure + knowledge packages. Pair them with Python-defined skills or + attach tools separately. + + Args: + directory: Path to scan for ``.md`` files (non-recursive). + + Returns: + List of :class:`Skill` instances, one per file. + + Raises: + ValueError: If a file has invalid or missing frontmatter. + """ + dirpath = Path(directory) + skills: list[Skill] = [] + for md_file in sorted(dirpath.glob('*.md')): + text = md_file.read_text(encoding='utf-8') + skill = _parse_skill_markdown(text, source=str(md_file)) + skills.append(skill) + return skills + + +def _parse_skill_markdown(text: str, *, source: str = '') -> Skill: + """Parse a markdown string with YAML frontmatter into a :class:`Skill`. + + Raises: + ValueError: If frontmatter is missing or incomplete. + """ + stripped = text.strip() + if not stripped.startswith('---'): + raise ValueError(f'Missing YAML frontmatter in {source}') + + # Find closing delimiter + end = stripped.find('---', 3) + if end == -1: + raise ValueError(f'Unclosed YAML frontmatter in {source}') + + frontmatter_text = stripped[3:end].strip() + body = stripped[end + 3 :].strip() or None + + # Minimal YAML-like parsing (key: value lines) to avoid a hard + # dependency on PyYAML for this simple case. + # Unknown keys (e.g. from agentskills.io: tools, dependencies, etc.) + # are silently ignored so that external skill catalogs stay compatible. + fm: dict[str, str] = {} + for line in frontmatter_text.splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + if ':' not in line: + continue + key, _, value = line.partition(':') + fm[key.strip()] = value.strip() + + name = fm.get('name') + description = fm.get('description') + if not name: + raise ValueError(f'Frontmatter missing required "name" field in {source}') + if not description: + raise ValueError(f'Frontmatter missing required "description" field in {source}') + + return Skill(name=name, description=description, instructions=body) diff --git a/tests/_skills/__init__.py b/tests/_skills/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_skills/test_skills.py b/tests/_skills/test_skills.py new file mode 100644 index 0000000..933682a --- /dev/null +++ b/tests/_skills/test_skills.py @@ -0,0 +1,593 @@ +"""Tests for the Skills capability.""" + +from __future__ import annotations + +import asyncio +import textwrap +from pathlib import Path + +import pytest +from pydantic_ai.tools import ToolDefinition +from pydantic_ai.toolsets.function import FunctionToolset + +from pydantic_harness.skills import Skill, Skills, load_skills_from_directory +from pydantic_harness.skills._toolset import _parse_skill_markdown + +# --------------------------------------------------------------------------- +# Skill dataclass +# --------------------------------------------------------------------------- + + +class TestSkill: + def test_valid_name(self) -> None: + s = Skill(name='my-skill', description='A skill') + assert s.name == 'my-skill' + + def test_valid_single_char_name(self) -> None: + s = Skill(name='a', description='A skill') + assert s.name == 'a' + + def test_valid_numeric_name(self) -> None: + s = Skill(name='s3', description='A skill') + assert s.name == 's3' + + def test_invalid_name_uppercase(self) -> None: + with pytest.raises(ValueError, match='lowercase alphanumeric'): + Skill(name='MySkill', description='bad') + + def test_invalid_name_underscore(self) -> None: + with pytest.raises(ValueError, match='lowercase alphanumeric'): + Skill(name='my_skill', description='bad') + + def test_invalid_name_leading_hyphen(self) -> None: + with pytest.raises(ValueError, match='lowercase alphanumeric'): + Skill(name='-bad', description='bad') + + def test_invalid_name_trailing_hyphen(self) -> None: + with pytest.raises(ValueError, match='lowercase alphanumeric'): + Skill(name='bad-', description='bad') + + def test_invalid_name_empty(self) -> None: + with pytest.raises(ValueError, match='lowercase alphanumeric'): + Skill(name='', description='bad') + + def test_tool_names_from_callables(self) -> None: + def add(a: int, b: int) -> int: # pragma: no cover – tool stub + """Add two numbers.""" + return a + b + + def subtract(a: int, b: int) -> int: # pragma: no cover – tool stub + """Subtract two numbers.""" + return a - b + + s = Skill(name='math', description='Math', tools=[add, subtract]) + assert s.tool_names() == ['add', 'subtract'] + + def test_tool_names_from_empty(self) -> None: + s = Skill(name='empty', description='No tools') + assert s.tool_names() == [] + + def test_instructions_default_none(self) -> None: + s = Skill(name='plain', description='No instructions') + assert s.instructions is None + + def test_instructions_set(self) -> None: + s = Skill(name='guided', description='Has instructions', instructions='Do the thing.') + assert s.instructions == 'Do the thing.' + + +# --------------------------------------------------------------------------- +# Markdown parsing +# --------------------------------------------------------------------------- + + +class TestParseSkillMarkdown: + def test_basic(self) -> None: + md = textwrap.dedent("""\ + --- + name: my-skill + description: A useful skill + --- + Some instructions here. + """) + skill = _parse_skill_markdown(md) + assert skill.name == 'my-skill' + assert skill.description == 'A useful skill' + assert skill.instructions == 'Some instructions here.' + + def test_no_body(self) -> None: + md = textwrap.dedent("""\ + --- + name: bare + description: No body + --- + """) + skill = _parse_skill_markdown(md) + assert skill.name == 'bare' + assert skill.instructions is None + + def test_missing_frontmatter(self) -> None: + with pytest.raises(ValueError, match='Missing YAML frontmatter'): + _parse_skill_markdown('No frontmatter here') + + def test_unclosed_frontmatter(self) -> None: + with pytest.raises(ValueError, match='Unclosed YAML frontmatter'): + _parse_skill_markdown('---\nname: oops\n') + + def test_missing_name(self) -> None: + md = '---\ndescription: No name\n---\n' + with pytest.raises(ValueError, match='missing required "name"'): + _parse_skill_markdown(md) + + def test_missing_description(self) -> None: + md = '---\nname: no-desc\n---\n' + with pytest.raises(ValueError, match='missing required "description"'): + _parse_skill_markdown(md) + + def test_multiline_body(self) -> None: + md = textwrap.dedent("""\ + --- + name: multi + description: Multi-line body + --- + Line one. + + Line two. + """) + skill = _parse_skill_markdown(md) + assert 'Line one.' in skill.instructions # type: ignore[operator] + assert 'Line two.' in skill.instructions # type: ignore[operator] + + def test_comment_lines_in_frontmatter(self) -> None: + md = textwrap.dedent("""\ + --- + # This is a comment + name: commented + description: Has comments + --- + """) + skill = _parse_skill_markdown(md) + assert skill.name == 'commented' + + def test_lines_without_colon_ignored(self) -> None: + md = textwrap.dedent("""\ + --- + name: tolerant + description: Ignores bad lines + this line has no colon + --- + """) + skill = _parse_skill_markdown(md) + assert skill.name == 'tolerant' + + def test_unknown_frontmatter_keys_ignored(self) -> None: + """agentskills.io compatibility: extra fields like tools, dependencies, etc. are silently ignored.""" + md = textwrap.dedent("""\ + --- + name: external-skill + description: From agentskills.io + tools: [fetch, parse] + dependencies: [httpx] + author: someone + version: 1.0.0 + --- + Instructions for the skill. + """) + skill = _parse_skill_markdown(md) + assert skill.name == 'external-skill' + assert skill.description == 'From agentskills.io' + assert skill.instructions == 'Instructions for the skill.' + + +# --------------------------------------------------------------------------- +# Directory loading +# --------------------------------------------------------------------------- + + +class TestLoadSkillsFromDirectory: + def test_loads_md_files(self, tmp_path: Path) -> None: + (tmp_path / 'alpha.md').write_text('---\nname: alpha\ndescription: First\n---\nAlpha body') + (tmp_path / 'beta.md').write_text('---\nname: beta\ndescription: Second\n---\n') + + skills = load_skills_from_directory(tmp_path) + assert len(skills) == 2 + assert skills[0].name == 'alpha' + assert skills[1].name == 'beta' + + def test_ignores_non_md(self, tmp_path: Path) -> None: + (tmp_path / 'readme.txt').write_text('not a skill') + (tmp_path / 'ok.md').write_text('---\nname: ok\ndescription: Valid\n---\n') + + skills = load_skills_from_directory(tmp_path) + assert len(skills) == 1 + assert skills[0].name == 'ok' + + def test_empty_directory(self, tmp_path: Path) -> None: + skills = load_skills_from_directory(tmp_path) + assert skills == [] + + def test_string_path(self, tmp_path: Path) -> None: + (tmp_path / 'one.md').write_text('---\nname: one\ndescription: Test\n---\n') + skills = load_skills_from_directory(str(tmp_path)) + assert len(skills) == 1 + + +# --------------------------------------------------------------------------- +# Skills capability +# --------------------------------------------------------------------------- + + +def _make_tool_def(name: str) -> ToolDefinition: + return ToolDefinition(name=name, description=f'Tool {name}') + + +class TestSkillsCapability: + def test_get_instructions_with_skills(self) -> None: + cap = Skills(skills=[Skill(name='s1', description='Skill one')]) + instructions = cap.get_instructions() + assert instructions is not None + assert 'search_skills' in instructions + assert 'load_skill' in instructions + assert 'unload_skill' in instructions + + def test_get_instructions_no_skills(self) -> None: + cap: Skills[None] = Skills() + assert cap.get_instructions() is None + + def test_get_toolset_with_tools(self) -> None: + def add(a: int, b: int) -> int: # pragma: no cover – tool stub + """Add.""" + return a + b + + cap = Skills(skills=[Skill(name='math', description='Math', tools=[add])]) + toolset = cap.get_toolset() + assert toolset is not None + # Should contain meta-tools + skill tools + assert 'search_skills' in toolset.tools + assert 'load_skill' in toolset.tools + assert 'unload_skill' in toolset.tools + assert 'add' in toolset.tools + + def test_get_toolset_with_function_toolset(self) -> None: + toolset_in: FunctionToolset[None] = FunctionToolset() + + def multiply(a: int, b: int) -> int: # pragma: no cover – tool stub + """Multiply.""" + return a * b + + toolset_in.add_function(multiply, takes_ctx=False) + cap = Skills(skills=[Skill(name='calc', description='Calculator', tools=toolset_in)]) + toolset = cap.get_toolset() + assert toolset is not None + assert 'multiply' in toolset.tools + + def test_get_toolset_no_skills(self) -> None: + cap: Skills[None] = Skills() + assert cap.get_toolset() is None + + def test_serialization_name(self) -> None: + assert Skills.get_serialization_name() == 'Skills' + + +class TestSkillsMetaTools: + def test_search_skills_matching(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic operations'), + Skill(name='web', description='Web scraping'), + ] + ) + results = cap._search_skills('math') + assert len(results) == 1 + assert results[0]['name'] == 'math' + assert results[0]['loaded'] == 'no' + + def test_search_skills_description_match(self) -> None: + cap = Skills( + skills=[ + Skill(name='calc', description='Arithmetic operations'), + ] + ) + results = cap._search_skills('arithmetic') + assert len(results) == 1 + assert results[0]['name'] == 'calc' + + def test_search_skills_case_insensitive(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + results = cap._search_skills('MATH') + assert len(results) == 1 + + def test_search_skills_no_match(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + results = cap._search_skills('cooking') + assert results == [] + + def test_search_skills_shows_loaded_status(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + cap._loaded_skill_names.add('math') + results = cap._search_skills('math') + assert results[0]['loaded'] == 'yes' + + def test_load_skill_success(self) -> None: + def add(a: int, b: int) -> int: # pragma: no cover – tool stub + """Add.""" + return a + b + + cap = Skills( + skills=[ + Skill( + name='math', + description='Arithmetic', + tools=[add], + instructions='Use add for addition.', + ), + ] + ) + result = cap._load_skill('math') + assert 'math' in cap._loaded_skill_names + assert "Skill 'math' loaded." in result + assert 'add' in result + assert 'Use add for addition.' in result + + def test_load_skill_not_found(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + result = cap._load_skill('nonexistent') + assert 'not found' in result + assert 'math' in result # suggests available skills + + def test_load_skill_no_tools(self) -> None: + cap = Skills( + skills=[ + Skill(name='knowledge', description='Just instructions', instructions='Be helpful.'), + ] + ) + result = cap._load_skill('knowledge') + assert "Skill 'knowledge' loaded." in result + assert 'Be helpful.' in result + + def test_load_skill_no_instructions(self) -> None: + def add(a: int, b: int) -> int: # pragma: no cover – tool stub + """Add.""" + return a + b + + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic', tools=[add]), + ] + ) + result = cap._load_skill('math') + assert "Skill 'math' loaded." in result + assert 'Instructions' not in result + + def test_search_skills_multi_word_ranks_by_match_count(self) -> None: + cap = Skills( + skills=[ + Skill(name='web-scraper', description='Scrape web pages'), + Skill(name='web-api', description='HTTP API client'), + Skill(name='math', description='Arithmetic operations'), + ] + ) + results = cap._search_skills('web scrape') + assert len(results) == 2 + # web-scraper matches both words, web-api matches only "web" + assert results[0]['name'] == 'web-scraper' + assert results[1]['name'] == 'web-api' + + def test_search_skills_empty_query(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + results = cap._search_skills('') + assert results == [] + + def test_search_skills_whitespace_query(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + results = cap._search_skills(' ') + assert results == [] + + def test_search_skills_partial_word_match(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic operations'), + ] + ) + # "arith" is a substring of "arithmetic" + results = cap._search_skills('arith') + assert len(results) == 1 + assert results[0]['name'] == 'math' + + def test_unload_skill_success(self) -> None: + def add(a: int, b: int) -> int: # pragma: no cover – tool stub + """Add.""" + return a + b + + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic', tools=[add]), + ] + ) + cap._loaded_skill_names.add('math') + result = cap._unload_skill('math') + assert 'math' not in cap._loaded_skill_names + assert "Skill 'math' unloaded." in result + + def test_unload_skill_not_found(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + result = cap._unload_skill('nonexistent') + assert 'not found' in result + assert 'math' in result # suggests available skills + + def test_unload_skill_not_loaded(self) -> None: + cap = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + result = cap._unload_skill('math') + assert 'not currently loaded' in result + + def test_unload_skill_hides_tools_again(self) -> None: + def add(a: int, b: int) -> int: # pragma: no cover – tool stub + """Add.""" + return a + b + + cap: Skills[None] = Skills( + skills=[ + Skill(name='math', description='Arithmetic', tools=[add]), + ] + ) + cap._loaded_skill_names.add('math') + tool_defs = [ + _make_tool_def('search_skills'), + _make_tool_def('load_skill'), + _make_tool_def('unload_skill'), + _make_tool_def('add'), + ] + # Tools visible while loaded + result = asyncio.run(cap.prepare_tools(None, tool_defs)) # type: ignore[arg-type] + assert 'add' in [td.name for td in result] + + # Unload and verify tools are hidden + cap._unload_skill('math') + result = asyncio.run(cap.prepare_tools(None, tool_defs)) # type: ignore[arg-type] + names = [td.name for td in result] + assert 'add' not in names + assert 'unload_skill' in names + + +class TestSkillsPrepareTools: + def test_hides_unloaded_skill_tools(self) -> None: + def add(a: int, b: int) -> int: # pragma: no cover – tool stub + """Add.""" + return a + b + + cap: Skills[None] = Skills( + skills=[ + Skill(name='math', description='Arithmetic', tools=[add]), + ] + ) + tool_defs = [ + _make_tool_def('search_skills'), + _make_tool_def('load_skill'), + _make_tool_def('add'), + ] + # Pretend we have a RunContext -- prepare_tools only uses self state + result = asyncio.run(cap.prepare_tools(None, tool_defs)) # type: ignore[arg-type] + names = [td.name for td in result] + assert 'search_skills' in names + assert 'load_skill' in names + assert 'add' not in names + + def test_shows_loaded_skill_tools(self) -> None: + def add(a: int, b: int) -> int: # pragma: no cover – tool stub + """Add.""" + return a + b + + cap: Skills[None] = Skills( + skills=[ + Skill(name='math', description='Arithmetic', tools=[add]), + ] + ) + cap._loaded_skill_names.add('math') + tool_defs = [ + _make_tool_def('search_skills'), + _make_tool_def('load_skill'), + _make_tool_def('add'), + ] + result = asyncio.run(cap.prepare_tools(None, tool_defs)) # type: ignore[arg-type] + names = [td.name for td in result] + assert 'add' in names + + def test_non_skill_tools_always_visible(self) -> None: + cap: Skills[None] = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + tool_defs = [ + _make_tool_def('search_skills'), + _make_tool_def('load_skill'), + _make_tool_def('other_agent_tool'), + ] + result = asyncio.run(cap.prepare_tools(None, tool_defs)) # type: ignore[arg-type] + names = [td.name for td in result] + assert 'other_agent_tool' in names + + +class TestSkillsForRun: + def test_for_run_isolates_state(self) -> None: + cap: Skills[None] = Skills( + skills=[ + Skill(name='math', description='Arithmetic'), + ] + ) + cap._loaded_skill_names.add('math') + + run_cap = asyncio.run(cap.for_run(None)) # type: ignore[arg-type] + assert isinstance(run_cap, Skills) + # New instance should have empty loaded set + assert len(run_cap._loaded_skill_names) == 0 + # Original should be unchanged + assert 'math' in cap._loaded_skill_names + + def test_for_run_preserves_skills(self) -> None: + skill = Skill(name='math', description='Arithmetic') + cap: Skills[None] = Skills(skills=[skill]) + run_cap = asyncio.run(cap.for_run(None)) # type: ignore[arg-type] + assert run_cap.skills is cap.skills + + +class TestSkillsFromSpec: + def test_from_spec_with_dirs(self, tmp_path: Path) -> None: + (tmp_path / 'test.md').write_text('---\nname: test\ndescription: Test skill\n---\n') + cap = Skills.from_spec(dirs=[str(tmp_path)]) + assert len(cap.skills) == 1 + assert cap.skills[0].name == 'test' + + def test_from_spec_with_positional_dirs(self, tmp_path: Path) -> None: + (tmp_path / 'test.md').write_text('---\nname: test\ndescription: Test skill\n---\n') + cap = Skills.from_spec(str(tmp_path)) + assert len(cap.skills) == 1 + + def test_from_spec_empty(self) -> None: + cap = Skills.from_spec() + assert cap.skills == [] + + +class TestSkillToolNamesFromToolset: + def test_tool_names_from_function_toolset(self) -> None: + toolset: FunctionToolset[None] = FunctionToolset() + + def greet(name: str) -> str: # pragma: no cover – tool stub + """Say hello.""" + return f'Hello, {name}!' + + toolset.add_function(greet, takes_ctx=False) + s = Skill(name='greeting', description='Greetings', tools=toolset) + assert s.tool_names() == ['greet']