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
This commit is contained in:
2026-02-24 12:38:21 +00:00
committed by Forgejo
parent f7993d7309
commit 25c571541f
2 changed files with 123 additions and 15 deletions
+109
View File
@@ -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/<name>` 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
+14 -15
View File
@@ -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