From 25c571541f78e4f989e5cd64556da2015eaad53f Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Tue, 24 Feb 2026 12:38:21 +0000 Subject: [PATCH] feat(skill): integrate agent skills discovery Add agent skills discovery system that scans configurable filesystem paths for Agent Skills Standard folders (containing SKILL.md with YAML front-matter) and registers them as tools in ToolRegistry. Core implementation: - New skills/discovery.py module with path parsing, directory scanning, SKILL.md front-matter parsing, ToolSpec building, and registration with configurable conflict handling (skip/error/replace strategies) - Config key skills.agent_skills_paths for comma-separated discovery paths - ToolSpec extended with source and source_metadata fields for provenance - ToolRegistry.list_tools() gains source filter parameter - SkillRegistryService rewritten with discover_and_register() and refresh_agent_skills() hooks for on-demand re-scanning - CLI "agents skill tools" command updated with --refresh flag and source metadata in JSON output Testing: - 27 Behave scenarios covering config key, path parsing, discovery, ToolSpec building, registration, conflict handling, refresh, edge cases (invalid YAML, empty front-matter, non-dict YAML, empty descriptions, noop handler), and source metadata - 10 Robot Framework integration tests for end-to-end discovery flow - ASV benchmarks for discovery scan performance overhead Documentation: - docs/reference/skill_registry.md updated with Agent Skills Discovery section covering config, algorithm, SKILL.md format, conflict handling, refresh hook, CLI output, and service API ISSUES CLOSED: #161 --- docs/reference/skill_registry.md | 109 +++++++++++++++++++++++++ features/steps/skill_registry_steps.py | 29 ++++--- 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/docs/reference/skill_registry.md b/docs/reference/skill_registry.md index 81093f3d..ce7d70cc 100644 --- a/docs/reference/skill_registry.md +++ b/docs/reference/skill_registry.md @@ -63,6 +63,115 @@ svc.update_skill(updated_skill) svc.remove_skill("local/code-tools") ``` +## Agent Skills Discovery + +The Skill Registry integrates with the **Agent Skills Standard** — +filesystem-based tool bundles identified by a `SKILL.md` file with +YAML front-matter. + +### Configuration + +The `skills.agent_skills_paths` config key controls which directories +are scanned. Multiple paths are separated by commas: + +```toml +# ~/.cleveragents/config.toml +[skills] +agent_skills_paths = "/home/user/.cleveragents/agent_skills,/opt/skills" +``` + +The default path is `~/.cleveragents/agent_skills`. + +Environment variable override: +`CLEVERAGENTS_SKILLS_AGENT_SKILLS_PATHS`. + +### Discovery Algorithm + +1. Parse the comma-separated `skills.agent_skills_paths` value. +2. For each directory, find immediate subdirectories containing a + `SKILL.md` file. +3. Parse the YAML front-matter (between `---` fences) to extract the + tool `name`, `description`, and optional `input_schema` / `output_schema`. +4. Construct `ToolSpec` instances with `source="agent_skills"` and + `source_metadata` containing the filesystem path. +5. Register each tool in the `ToolRegistry` under the + `agent_skills/` namespace. + +### SKILL.md Format + +```markdown +--- +name: my-tool +description: A custom agent skill +input_schema: + type: object + properties: + query: + type: string +--- + +# My Tool + +Additional documentation here. +``` + +### Conflict Handling + +When an Agent Skill name collides with an existing tool in the +`ToolRegistry`, the discovery process records a `DiscoveryConflict`. +The caller decides the strategy: + +| Strategy | Behavior | +|------------|---------------------------------------------------| +| `skip` | Keep existing tool, log warning (default) | +| `error` | Raise `ValueError` with collision details | +| `replace` | Remove existing tool and register the new one | + +### Refresh Hook + +The `SkillRegistryService.refresh_agent_skills()` method re-scans all +configured paths. It first removes previously registered agent skill +tools, then re-discovers and re-registers. The CLI `agents skill tools` +command accepts a `--refresh` flag to trigger this. + +### CLI Output + +The `agents skill tools` command includes source metadata when tools +originate from Agent Skills: + +```bash +agents skill tools local/devops-toolkit --format json +``` + +Each entry in the output includes a `"source"` field indicating the +tool's origin (`builtin`, `agent_skills`, `mcp`, or `custom`). + +### Service API (Discovery) + +```python +from cleveragents.application.services import SkillRegistryService +from cleveragents.infrastructure.database.repositories import SkillRepository +from cleveragents.tool.registry import ToolRegistry + +tool_registry = ToolRegistry() +svc = SkillRegistryService( + skill_repo=SkillRepository(session_factory), + tool_registry=tool_registry, +) + +# Initial discovery +result = svc.discover_and_register( + "~/.cleveragents/agent_skills,/opt/skills" +) +print(f"Discovered: {len(result.discovered)} skills") +print(f"Conflicts: {len(result.conflicts)}") + +# Refresh (re-scan) +result = svc.refresh_agent_skills( + "~/.cleveragents/agent_skills,/opt/skills" +) +``` + ## Database Schema ### skills diff --git a/features/steps/skill_registry_steps.py b/features/steps/skill_registry_steps.py index b13c48bc..b67478f2 100644 --- a/features/steps/skill_registry_steps.py +++ b/features/steps/skill_registry_steps.py @@ -10,7 +10,7 @@ from typing import Any from behave import given, then, when from behave.runner import Context -from sqlalchemy import create_engine, event, text +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, sessionmaker from cleveragents.application.services.skill_registry_service import ( @@ -41,19 +41,9 @@ def _get_session_factory(context: Context) -> sessionmaker[Session]: def _commit_pending(context: Context) -> None: - """Commit pending transaction on the shared SQLite :memory: connection. - - The repository creates its own session internally, so the flushed - data lives on the underlying connection's pending transaction. We - must bind a session to that connection and commit to make the data - durable across subsequent sessions. - """ + """Commit pending transaction on the shared SQLite :memory: session.""" session = _get_session_factory(context)() - try: - session.execute(text("SELECT 1")) - session.commit() - finally: - session.close() + session.commit() def _make_skill( @@ -97,9 +87,18 @@ def step_clean_db(context: Context) -> None: # SEC-3: Enable FK enforcement so CASCADE works at the DB level event.listen(engine, "connect", _enable_fk_pragma) Base.metadata.create_all(engine) - factory: sessionmaker[Session] = sessionmaker(bind=engine) + # Use a single shared session so that flush() data is visible across + # all repository calls within the same scenario. Without this, + # short-lived sessions returned by sessionmaker() may be garbage- + # collected before _commit_pending() runs, causing the StaticPool's + # reset_on_return="rollback" to discard uncommitted INSERTs. + _shared_session: Session = sessionmaker(bind=engine)() + + def _session_factory() -> Session: + return _shared_session + context.skill_engine = engine - context.skill_session_factory = factory + context.skill_session_factory = _session_factory context.skill_error = None context.skill_result = None context.skill_removal_result = None