forked from cleveragents/cleveragents-core
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user