feat(skill): add agent skills loader
Implemented AgentSkillSpec loader that parses SKILL.md frontmatter and progressive disclosure sections (discover/activate/deactivate) into structured SkillStep objects with stable 1-based ordering. Mapped Agent Skills to AgentSkillToolDescriptor with namespaced naming (namespace/short_name), source="agent_skill", read-only defaults, and AgentSkillResourceSlot bindings for scripts/, references/, and assets/ directories. All resource slots are unconditionally read_only. Added explicit validation for missing frontmatter fields (name, description) and invalid namespace format with actionable error messages. Added docs/reference/agent_skills.md covering folder layout, SKILL.md parsing rules, progressive disclosure model, and tool mapping. Added Behave scenarios covering valid/invalid SKILL.md parsing, namespaced naming, step ordering, missing frontmatter errors, progressive disclosure lifecycle, tool mapping, and resource binding slots. Added Robot Framework integration tests using the deploy-to-staging example skill folder (robot/agent_skills_loader.robot). Added ASV benchmarks for parsing throughput, folder load, progressive disclosure lifecycle, and resource listing (benchmarks/agent_skills_loader_bench.py). ISSUES CLOSED: #160
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
"""ASV benchmarks for Agent Skills Standard loader parsing throughput.
|
||||
|
||||
Measures the performance of:
|
||||
- SKILL.md string parsing (frontmatter + body)
|
||||
- Agent skills folder loading (from disk)
|
||||
- Progressive disclosure lifecycle (discover → activate → deactivate)
|
||||
- Tool descriptor conversion
|
||||
- Resource listing from multi-file folders
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.skills.agent_skills_loader import ( # noqa: E402
|
||||
AgentSkillLoader,
|
||||
AgentSkillSpec,
|
||||
)
|
||||
|
||||
_MINIMAL_SKILL_MD = """\
|
||||
---
|
||||
name: local/bench-minimal
|
||||
description: Minimal benchmark skill.
|
||||
---
|
||||
Step 1: Do this.
|
||||
"""
|
||||
|
||||
_FULL_SKILL_MD = """\
|
||||
---
|
||||
name: local/bench-full
|
||||
description: Full benchmark skill with all optional fields.
|
||||
version: 2.1.0
|
||||
steps:
|
||||
- Verify the environment is ready
|
||||
- Check that all required tools are available
|
||||
- Read the configuration from the reference files
|
||||
- Execute the main workflow script
|
||||
- Monitor progress and capture output
|
||||
- Handle any errors gracefully and log them
|
||||
- Release any acquired resources
|
||||
- Write the result summary to the output
|
||||
- Notify downstream systems if required
|
||||
compatibility:
|
||||
min_agent_version: "3.0.0"
|
||||
metadata:
|
||||
author: benchmark
|
||||
tags: [bench, performance]
|
||||
priority: high
|
||||
allowed-tools:
|
||||
- local/read-file
|
||||
- local/shell
|
||||
- local/git-status
|
||||
- local/search-files
|
||||
---
|
||||
# Full Benchmark Skill
|
||||
|
||||
This skill is used to benchmark the Agent Skills loader performance.
|
||||
|
||||
## Phase 1 — Preparation
|
||||
|
||||
1. Verify the environment is ready.
|
||||
2. Check that all required tools are available.
|
||||
3. Read the configuration from the reference files.
|
||||
|
||||
## Phase 2 — Execution
|
||||
|
||||
4. Execute the main workflow script.
|
||||
5. Monitor progress and capture output.
|
||||
6. Handle any errors gracefully and log them.
|
||||
|
||||
## Phase 3 — Cleanup
|
||||
|
||||
7. Release any acquired resources.
|
||||
8. Write the result summary to the output.
|
||||
9. Notify downstream systems if required.
|
||||
"""
|
||||
|
||||
|
||||
class AgentSkillSpecParsingSuite:
|
||||
"""Benchmark AgentSkillSpec string parsing throughput."""
|
||||
|
||||
def time_parse_minimal(self) -> None:
|
||||
"""Benchmark parsing a minimal SKILL.md string."""
|
||||
AgentSkillSpec.from_string(_MINIMAL_SKILL_MD)
|
||||
|
||||
def time_parse_full(self) -> None:
|
||||
"""Benchmark parsing a fully-populated SKILL.md string."""
|
||||
AgentSkillSpec.from_string(_FULL_SKILL_MD)
|
||||
|
||||
def time_parse_structured_steps(self) -> None:
|
||||
"""Benchmark parsing structured steps from frontmatter."""
|
||||
spec = AgentSkillSpec.from_string(_FULL_SKILL_MD)
|
||||
_ = spec.steps
|
||||
|
||||
|
||||
class AgentSkillFolderLoadSuite:
|
||||
"""Benchmark AgentSkillLoader.from_folder() throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create a temporary skill folder for folder-load benchmarks."""
|
||||
self._tmp = Path(tempfile.mkdtemp(prefix="asv_agent_skill_"))
|
||||
|
||||
# Write SKILL.md
|
||||
(self._tmp / "SKILL.md").write_text(_FULL_SKILL_MD)
|
||||
|
||||
# Write scripts/
|
||||
scripts = self._tmp / "scripts"
|
||||
scripts.mkdir()
|
||||
for i in range(5):
|
||||
(scripts / f"script_{i}.py").write_text(f"# Script {i}\nprint({i})\n")
|
||||
|
||||
# Write references/
|
||||
refs = self._tmp / "references"
|
||||
refs.mkdir()
|
||||
for i in range(3):
|
||||
(refs / f"ref_{i}.md").write_text(f"# Reference {i}\nContent {i}.\n")
|
||||
|
||||
# Write assets/
|
||||
assets = self._tmp / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "config.json").write_text('{"key": "value"}\n')
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Remove the temporary skill folder."""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
def time_load_from_folder(self) -> None:
|
||||
"""Benchmark loading a fully-populated agent skills folder."""
|
||||
AgentSkillLoader.from_folder(self._tmp)
|
||||
|
||||
|
||||
class AgentSkillLifecycleSuite:
|
||||
"""Benchmark the full progressive disclosure lifecycle."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create a temporary skill folder for lifecycle benchmarks."""
|
||||
self._tmp = Path(tempfile.mkdtemp(prefix="asv_agent_skill_lc_"))
|
||||
(self._tmp / "SKILL.md").write_text(_FULL_SKILL_MD)
|
||||
scripts = self._tmp / "scripts"
|
||||
scripts.mkdir()
|
||||
(scripts / "run.py").write_text("print('run')\n")
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Remove the temporary skill folder."""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
def time_discover(self) -> None:
|
||||
"""Benchmark discover() — Tier 1 metadata-only."""
|
||||
loader = AgentSkillLoader.from_folder(self._tmp)
|
||||
loader.discover()
|
||||
|
||||
def time_activate(self) -> None:
|
||||
"""Benchmark discover() + activate() — Tier 1 + Tier 2."""
|
||||
loader = AgentSkillLoader.from_folder(self._tmp)
|
||||
loader.discover()
|
||||
loader.activate()
|
||||
|
||||
def time_full_lifecycle(self) -> None:
|
||||
"""Benchmark discover → activate → list_resources → deactivate."""
|
||||
loader = AgentSkillLoader.from_folder(self._tmp)
|
||||
loader.discover()
|
||||
loader.activate()
|
||||
loader.list_resources()
|
||||
loader.deactivate()
|
||||
|
||||
def time_to_tool_descriptor(self) -> None:
|
||||
"""Benchmark conversion to AgentSkillToolDescriptor."""
|
||||
loader = AgentSkillLoader.from_folder(self._tmp)
|
||||
loader.to_tool_descriptor()
|
||||
|
||||
|
||||
class AgentSkillResourceListSuite:
|
||||
"""Benchmark list_resources() with varying resource counts."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create a temporary folder with many resource files."""
|
||||
self._tmp = Path(tempfile.mkdtemp(prefix="asv_agent_skill_res_"))
|
||||
(self._tmp / "SKILL.md").write_text(_MINIMAL_SKILL_MD)
|
||||
|
||||
scripts = self._tmp / "scripts"
|
||||
scripts.mkdir()
|
||||
for i in range(20):
|
||||
(scripts / f"script_{i:02d}.py").write_text(f"# {i}\n")
|
||||
|
||||
refs = self._tmp / "references"
|
||||
refs.mkdir()
|
||||
for i in range(10):
|
||||
(refs / f"ref_{i:02d}.md").write_text(f"# {i}\n")
|
||||
|
||||
assets = self._tmp / "assets"
|
||||
assets.mkdir()
|
||||
for i in range(5):
|
||||
(assets / f"asset_{i:02d}.json").write_text(f'{{"id": {i}}}\n')
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Remove the temporary folder."""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
def time_list_resources_35_files(self) -> None:
|
||||
"""Benchmark list_resources() with 35 resource files."""
|
||||
loader = AgentSkillLoader.from_folder(self._tmp)
|
||||
loader.list_resources()
|
||||
@@ -0,0 +1,251 @@
|
||||
# Agent Skills Standard Loader
|
||||
|
||||
The **Agent Skills loader** (`cleveragents.skills.agent_skills_loader`) implements the
|
||||
[AgentSkills.io](https://AgentSkills.io) standard for packaging instruction-driven,
|
||||
multi-step workflows as `SKILL.md` files with optional resource sub-directories.
|
||||
|
||||
Agent Skills differ from MCP tools: instead of a single function call, they load
|
||||
procedural *instructions* that an LLM agent follows—potentially invoking multiple
|
||||
tools in sequence.
|
||||
|
||||
---
|
||||
|
||||
## Folder Layout
|
||||
|
||||
```
|
||||
my-skill/
|
||||
├── SKILL.md ← required — frontmatter + instructions
|
||||
├── scripts/ ← optional — Python/shell scripts the agent may run
|
||||
│ └── deploy.py
|
||||
├── references/ ← optional — supplementary Markdown documents
|
||||
│ └── runbook.md
|
||||
└── assets/ ← optional — any binary or static resources
|
||||
└── config.json
|
||||
```
|
||||
|
||||
Only `SKILL.md` is required. The three resource sub-directories are discovered
|
||||
automatically when present and made available on demand during Tier 3 disclosure.
|
||||
|
||||
---
|
||||
|
||||
## SKILL.md Format
|
||||
|
||||
`SKILL.md` files follow Markdown with a YAML frontmatter block:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: local/deploy-to-staging
|
||||
description: Deploy the current branch to the staging environment.
|
||||
steps: # optional — structured steps with stable ordering
|
||||
- Verify all tests pass
|
||||
- Push the branch to the remote
|
||||
- Run the CI pipeline
|
||||
- Execute scripts/deploy.py to deploy
|
||||
- Check the health endpoint
|
||||
version: 1.0.0 # optional
|
||||
compatibility: # optional
|
||||
min_agent_version: "3.0.0"
|
||||
metadata: # optional — arbitrary key/value pairs
|
||||
author: team
|
||||
environment: staging
|
||||
allowed-tools: # optional — restrict which tools the skill may call
|
||||
- builtin/shell
|
||||
- builtin/git-status
|
||||
---
|
||||
|
||||
# Deployment Details
|
||||
|
||||
Additional prose context for each step, loaded on activation.
|
||||
```
|
||||
|
||||
### Required Frontmatter Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Namespaced name: `namespace/short-name` (e.g. `local/deploy-staging`) |
|
||||
| `description` | `string` | Short human-readable description of the skill |
|
||||
|
||||
### Optional Frontmatter Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `steps` | `list[string]` | `[]` | Ordered step instructions parsed into `SkillStep` objects with stable 1-based indexing |
|
||||
| `version` | `string` | `"0.0.0"` | Semantic version of the skill |
|
||||
| `compatibility` | `mapping` | `{}` | Compatibility constraints (e.g. `min_agent_version`) |
|
||||
| `metadata` | `mapping` | `{}` | Arbitrary key/value pairs (author, tags, etc.) |
|
||||
| `allowed-tools` | `list[string]` | `[]` | Tools the skill is permitted to invoke |
|
||||
|
||||
### Structured Steps
|
||||
|
||||
When the `steps` key is present, each list entry becomes a `SkillStep` with a
|
||||
stable 1-based index and non-empty content string:
|
||||
|
||||
```python
|
||||
spec = AgentSkillSpec.from_file(Path("my-skill/SKILL.md"))
|
||||
for step in spec.steps:
|
||||
print(f"{step.index}. {step.content}")
|
||||
# 1. Verify all tests pass
|
||||
# 2. Push the branch to the remote
|
||||
# ...
|
||||
```
|
||||
|
||||
Steps are stored in insertion order and never reordered — `step.index` is
|
||||
always equal to the step's position in the original list.
|
||||
|
||||
---
|
||||
|
||||
## Namespaced Naming
|
||||
|
||||
Skill names follow the same `namespace/short-name` convention as tools and actors
|
||||
(see [Tool Model](tool_model.md)):
|
||||
|
||||
```
|
||||
local/deploy-to-staging ← user-defined, default namespace
|
||||
devops/code-review-pipeline ← custom namespace
|
||||
team/onboarding-workflow ← team namespace
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Both `namespace` and `short-name` must start with an alphanumeric character.
|
||||
- Allowed characters: letters, digits, hyphens (`-`), underscores (`_`).
|
||||
- No spaces or special characters.
|
||||
|
||||
---
|
||||
|
||||
## Three-Tier Progressive Disclosure
|
||||
|
||||
The loader implements the three-tier model defined in the specification
|
||||
(`docs/specification.md` § AgentSkillAdapter):
|
||||
|
||||
| Tier | Method | What loads | Token cost |
|
||||
|------|--------|-----------|------------|
|
||||
| **1 — Metadata** | `discover()` | `name` + `description` only | ~50–100 tokens per skill |
|
||||
| **2 — Instructions** | `activate()` | Full `SKILL.md` Markdown body | Recommended < 5000 tokens |
|
||||
| **3 — Resources** | `list_resources()` | Paths from `scripts/`, `references/`, `assets/` | Variable, on demand |
|
||||
|
||||
This means an actor can have dozens of Agent Skills available but only pay the
|
||||
token cost for their metadata at startup. Full instructions load only when the
|
||||
LLM determines a task matches the skill.
|
||||
|
||||
---
|
||||
|
||||
## Tool Mapping
|
||||
|
||||
Agent Skills are registered in the Tool Registry with `source="agent_skill"`.
|
||||
The `AgentSkillToolDescriptor` carries:
|
||||
|
||||
| Field | Value | Notes |
|
||||
|-------|-------|-------|
|
||||
| `source` | `"agent_skill"` | Fixed |
|
||||
| `name` | From frontmatter `name` | e.g. `local/deploy-to-staging` |
|
||||
| `description` | From frontmatter `description` | Used in system prompt |
|
||||
| `read_only` | `True` | Default — skill itself does not write |
|
||||
| `writes` | `False` | Default |
|
||||
| `agent_skill_path` | Absolute folder path | Runtime uses this to locate `SKILL.md` |
|
||||
| `allowed_tools` | From frontmatter `allowed-tools` | Optional restrict list |
|
||||
| `body` | Empty at discover; full body after activate | Progressive disclosure |
|
||||
| `resource_slots` | List of `AgentSkillResourceSlot` | One slot per discovered resource directory |
|
||||
|
||||
### Resource Binding Slots
|
||||
|
||||
Each discovered sub-directory (`scripts/`, `references/`, `assets/`) becomes a
|
||||
**read-only resource binding slot** in the tool descriptor:
|
||||
|
||||
```python
|
||||
td = loader.to_tool_descriptor()
|
||||
for slot in td.resource_slots:
|
||||
print(slot.name) # "scripts", "references", or "assets"
|
||||
print(slot.resource_type) # "agent_skill_scripts", etc.
|
||||
print(slot.access) # "read_only"
|
||||
print(slot.path) # absolute path on disk
|
||||
```
|
||||
|
||||
Slots are only created for directories that exist — if a skill has no
|
||||
`references/` folder, no `references` slot is emitted. All slots are
|
||||
unconditionally `access="read_only"`.
|
||||
|
||||
### Read-Only Default Rationale
|
||||
|
||||
The skill descriptor is **read-only by default** because the skill's instructions
|
||||
do not themselves perform writes — the LLM agent decides which (potentially
|
||||
write-capable) tools to invoke during execution. Individual tools in `allowed-tools`
|
||||
may write; the skill descriptor's own capability metadata does not.
|
||||
|
||||
---
|
||||
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from cleveragents.skills.agent_skills_loader import AgentSkillLoader, AgentSkillSpec
|
||||
|
||||
# Parse a SKILL.md string directly
|
||||
spec = AgentSkillSpec.from_string("""
|
||||
---
|
||||
name: local/my-skill
|
||||
description: Does something useful.
|
||||
---
|
||||
Step 1: Do this.
|
||||
""")
|
||||
|
||||
# Load a full folder
|
||||
loader = AgentSkillLoader.from_folder(Path("./skills/my-skill"))
|
||||
|
||||
# Tier 1 — discover (metadata only)
|
||||
descriptor = loader.discover()
|
||||
print(descriptor.name) # "local/my-skill"
|
||||
print(descriptor.body) # "" (empty)
|
||||
|
||||
# Tier 2 — activate (load instructions)
|
||||
activated = loader.activate()
|
||||
print(activated.body) # "Step 1: Do this."
|
||||
|
||||
# Tier 3 — list resources on demand
|
||||
resources = loader.list_resources()
|
||||
for path in resources:
|
||||
print(path.name)
|
||||
|
||||
# Deactivate to free token budget
|
||||
loader.deactivate()
|
||||
print(loader.active_body) # None
|
||||
|
||||
# Convert to Tool Registry descriptor
|
||||
tool_descriptor = loader.to_tool_descriptor()
|
||||
print(tool_descriptor.source) # "agent_skill"
|
||||
print(tool_descriptor.read_only) # True
|
||||
print(tool_descriptor.agent_skill_path) # "/abs/path/to/my-skill"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Path Safety
|
||||
|
||||
All file paths returned by `list_resources()`, `script_paths`, `reference_paths`,
|
||||
and `asset_paths` are **absolute paths** that have been verified to reside within
|
||||
the skill folder. The loader uses `Path.resolve()` + `rglob()` to prevent path
|
||||
traversal outside the skill boundary.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Condition | Exception | Message pattern |
|
||||
|-----------|-----------|-----------------|
|
||||
| `SKILL.md` not found in folder | `ValueError` | mentions `"SKILL.md"` |
|
||||
| Folder path does not exist | `FileNotFoundError` | mentions `"not found"` |
|
||||
| Missing `---` frontmatter | `ValueError` | mentions `"frontmatter"` |
|
||||
| Missing required `name` field | `ValueError` | mentions `"name"` |
|
||||
| Missing required `description` field | `ValueError` | mentions `"description"` |
|
||||
| Invalid `namespace/name` format | `ValueError` | mentions `"namespace"` |
|
||||
| Malformed YAML in frontmatter | `ValueError` | mentions `"YAML"` |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Skills Protocol](skills_protocol.md) — `SkillDefinition`, `SkillMetadata`
|
||||
- [Skill CLI](skill_cli.md) — `agents skill` commands
|
||||
- [Tool Model](tool_model.md) — `Tool`, `ToolSource.AGENT_SKILL`
|
||||
- [MCP Adapter](mcp_adapter.md) — MCP tool source (counterpart)
|
||||
- `docs/specification.md` § AgentSkillAdapter — authoritative spec
|
||||
- `ADR-028` — Agent Skills Standard adoption decision
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: local/deploy-to-staging
|
||||
description: Deploy the current branch to the staging environment.
|
||||
version: 1.0.0
|
||||
steps:
|
||||
- Verify all tests pass locally
|
||||
- Confirm the branch is up to date with the remote
|
||||
- Push the branch to the remote repository
|
||||
- Trigger the CI pipeline and wait for it to pass
|
||||
- Run the deployment script from scripts/deploy.py
|
||||
- Verify the deployment using the health check in references/runbook.md
|
||||
- Notify the team via the configured notification channel
|
||||
- Monitor logs for the first 5 minutes after deployment
|
||||
metadata:
|
||||
author: example
|
||||
environment: staging
|
||||
allowed-tools:
|
||||
- builtin/shell
|
||||
- builtin/git-status
|
||||
---
|
||||
# Deploy to Staging
|
||||
|
||||
Follow these steps to deploy the current branch to staging.
|
||||
|
||||
## Pre-flight Checks
|
||||
|
||||
1. Verify all tests pass locally using `builtin/shell`.
|
||||
2. Confirm the branch is up to date with `builtin/git-status`.
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
3. Push the branch to the remote repository.
|
||||
4. Trigger the CI pipeline and wait for it to pass.
|
||||
5. Run the deployment script from `scripts/deploy.py`.
|
||||
6. Verify the deployment using the health check reference in `references/runbook.md`.
|
||||
|
||||
## Post-deployment
|
||||
|
||||
7. Notify the team via the configured notification channel.
|
||||
8. Monitor logs for the first 5 minutes after deployment.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Deployment Runbook
|
||||
|
||||
## Health Check Endpoints
|
||||
|
||||
- `/health` — Returns 200 OK if the service is healthy.
|
||||
- `/ready` — Returns 200 OK if the service is ready to accept traffic.
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
If deployment fails, run the rollback script from the scripts/ directory.
|
||||
|
||||
## Contact
|
||||
|
||||
Alert the on-call engineer if health checks fail after 3 retries.
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Deployment helper script for the deploy-to-staging Agent Skill.
|
||||
|
||||
This script is invoked by the LLM agent following the SKILL.md instructions.
|
||||
It runs the deployment pipeline and returns a structured result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Execute the deployment pipeline."""
|
||||
print("deploy-to-staging: starting deployment")
|
||||
print("deploy-to-staging: deployment complete")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,695 @@
|
||||
@phase2 @skills @agent_skills @agent_skills_loader
|
||||
Feature: Agent Skills Loader
|
||||
As a CleverAgents developer
|
||||
I want to load Agent Skills Standard SKILL.md folders into structured tool definitions
|
||||
So that actors can discover and invoke instruction-driven multi-step workflows
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentSkillSpec — SKILL.md Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_parse
|
||||
Scenario: Parse a minimal SKILL.md with only required frontmatter fields
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/deploy-staging
|
||||
description: Deploy the current branch to the staging environment.
|
||||
---
|
||||
Follow these steps to deploy.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill name should be "local/deploy-staging"
|
||||
And the parsed skill description should be "Deploy the current branch to the staging environment."
|
||||
And the parsed skill body should contain "Follow these steps to deploy."
|
||||
|
||||
@agent_skill_parse
|
||||
Scenario: Parse a SKILL.md with all optional frontmatter fields
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/code-review
|
||||
description: Run a thorough code review checklist.
|
||||
version: 1.2.3
|
||||
compatibility:
|
||||
min_agent_version: "3.0.0"
|
||||
metadata:
|
||||
author: aditya
|
||||
tags: [review, quality]
|
||||
allowed-tools:
|
||||
- local/read-file
|
||||
- local/search-files
|
||||
---
|
||||
## Code Review Steps
|
||||
1. Check style
|
||||
2. Verify tests
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill name should be "local/code-review"
|
||||
And the parsed skill version should be "1.2.3"
|
||||
And the parsed skill allowed tools should include "local/read-file"
|
||||
And the parsed skill allowed tools should include "local/search-files"
|
||||
And the parsed skill metadata should have key "author" with value "aditya"
|
||||
|
||||
@agent_skill_parse
|
||||
Scenario: Parse SKILL.md body text is preserved exactly
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/test-skill
|
||||
description: Test skill for body parsing.
|
||||
---
|
||||
# Multi-line Instructions
|
||||
|
||||
Step 1: Do something important.
|
||||
Step 2: Do another thing.
|
||||
|
||||
```python
|
||||
code_example()
|
||||
```
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill body should contain "Step 1: Do something important."
|
||||
And the parsed skill body should contain "Step 2: Do another thing."
|
||||
And the parsed skill body should contain "code_example()"
|
||||
|
||||
@agent_skill_parse @error_handling
|
||||
Scenario: SKILL.md missing required name field raises error
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
description: A skill without a name.
|
||||
---
|
||||
Some body text.
|
||||
"""
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "name"
|
||||
|
||||
@agent_skill_parse @error_handling
|
||||
Scenario: SKILL.md missing required description field raises error
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/no-desc
|
||||
---
|
||||
Some body text.
|
||||
"""
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "description"
|
||||
|
||||
@agent_skill_parse @error_handling
|
||||
Scenario: SKILL.md with invalid namespaced name raises error
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: invalid-name-no-slash
|
||||
description: A skill with a bad name.
|
||||
---
|
||||
Body text.
|
||||
"""
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "namespace"
|
||||
|
||||
@agent_skill_parse @error_handling
|
||||
Scenario: File without YAML frontmatter delimiter raises error
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
Just plain text without frontmatter.
|
||||
No YAML delimiter here.
|
||||
"""
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "frontmatter"
|
||||
|
||||
@agent_skill_parse @error_handling
|
||||
Scenario: Empty SKILL.md file raises error
|
||||
Given an empty SKILL.md file
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "frontmatter"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentSkillFolder — Folder Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_folder
|
||||
Scenario: Discover a valid Agent Skills folder with SKILL.md
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/folder-skill
|
||||
description: A skill loaded from a folder.
|
||||
---
|
||||
Instructions here.
|
||||
"""
|
||||
When I load the agent skills folder
|
||||
Then the loaded agent skill name should be "local/folder-skill"
|
||||
And the loaded agent skill description should be "A skill loaded from a folder."
|
||||
|
||||
@agent_skill_folder
|
||||
Scenario: Agent Skills folder discovers scripts/ subfolder
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/scripted-skill
|
||||
description: A skill with bundled scripts.
|
||||
---
|
||||
Use the scripts provided.
|
||||
"""
|
||||
And the folder contains a script file "deploy.py" with content "print('deploying')"
|
||||
When I load the agent skills folder
|
||||
Then the loaded agent skill script paths should include "deploy.py"
|
||||
|
||||
@agent_skill_folder
|
||||
Scenario: Agent Skills folder discovers references/ subfolder
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/ref-skill
|
||||
description: A skill with reference documents.
|
||||
---
|
||||
See references/ for more.
|
||||
"""
|
||||
And the folder contains a reference file "guide.md" with content "# Guide"
|
||||
When I load the agent skills folder
|
||||
Then the loaded agent skill reference paths should include "guide.md"
|
||||
|
||||
@agent_skill_folder
|
||||
Scenario: Agent Skills folder discovers assets/ subfolder
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/asset-skill
|
||||
description: A skill with assets.
|
||||
---
|
||||
See assets/ for resources.
|
||||
"""
|
||||
And the folder contains an asset file "template.json" with content "{}"
|
||||
When I load the agent skills folder
|
||||
Then the loaded agent skill asset paths should include "template.json"
|
||||
|
||||
@agent_skill_folder
|
||||
Scenario: Agent Skills folder with no optional subfolders loads successfully
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/bare-skill
|
||||
description: A minimal folder skill with no subfolders.
|
||||
---
|
||||
Minimal instructions.
|
||||
"""
|
||||
When I load the agent skills folder
|
||||
Then the loaded agent skill script paths should be empty
|
||||
And the loaded agent skill reference paths should be empty
|
||||
And the loaded agent skill asset paths should be empty
|
||||
|
||||
@agent_skill_folder @error_handling
|
||||
Scenario: Loading a folder without SKILL.md raises error
|
||||
Given an agent skills folder without a SKILL.md file
|
||||
When I load the agent skills folder expecting an error
|
||||
Then the agent skill load error should mention "SKILL.md"
|
||||
|
||||
@agent_skill_folder @error_handling
|
||||
Scenario: Loading a non-existent folder raises error
|
||||
Given a non-existent agent skills folder path
|
||||
When I load the agent skills folder expecting an error
|
||||
Then the agent skill load error should mention "not found"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool Mapping — AgentSkillSpec → ToolDescriptor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_tool_mapping
|
||||
Scenario: Agent skill maps to a tool with source agent_skill
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/mapped-skill
|
||||
description: Maps to a tool descriptor.
|
||||
---
|
||||
Do the task.
|
||||
"""
|
||||
When I load the agent skills folder
|
||||
And I convert the loaded agent skill to a tool descriptor
|
||||
Then the tool descriptor source should be "agent_skill"
|
||||
And the tool descriptor name should be "local/mapped-skill"
|
||||
|
||||
@agent_skill_tool_mapping
|
||||
Scenario: Agent skill tool descriptor is read-only by default
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/readonly-skill
|
||||
description: Should default to read-only.
|
||||
---
|
||||
Read things.
|
||||
"""
|
||||
When I load the agent skills folder
|
||||
And I convert the loaded agent skill to a tool descriptor
|
||||
Then the tool descriptor should have read_only set to True
|
||||
And the tool descriptor should have writes set to False
|
||||
|
||||
@agent_skill_tool_mapping
|
||||
Scenario: Agent skill tool descriptor includes allowed-tools in metadata
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/allowed-tools-skill
|
||||
description: Skill with allowed tools declared.
|
||||
allowed-tools:
|
||||
- local/read-file
|
||||
- local/git-status
|
||||
---
|
||||
Use allowed tools only.
|
||||
"""
|
||||
When I load the agent skills folder
|
||||
And I convert the loaded agent skill to a tool descriptor
|
||||
Then the tool descriptor allowed tools should include "local/read-file"
|
||||
And the tool descriptor allowed tools should include "local/git-status"
|
||||
|
||||
@agent_skill_tool_mapping
|
||||
Scenario: Agent skill tool descriptor agent_skill_path points to folder
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/path-skill
|
||||
description: Path should be captured in descriptor.
|
||||
---
|
||||
Instructions.
|
||||
"""
|
||||
When I load the agent skills folder
|
||||
And I convert the loaded agent skill to a tool descriptor
|
||||
Then the tool descriptor agent_skill_path should be set
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Progressive Disclosure — Three-Tier Model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@progressive_disclosure
|
||||
Scenario: Discovery tier loads only metadata (name + description)
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/disclosure-skill
|
||||
description: Progressive disclosure test skill.
|
||||
---
|
||||
Full instructions that should only load on activate.
|
||||
Step 1: do this.
|
||||
Step 2: do that.
|
||||
"""
|
||||
When I call discover on the agent skill
|
||||
Then the discover result should have name "local/disclosure-skill"
|
||||
And the discover result should have description "Progressive disclosure test skill."
|
||||
And the discover result body should be empty
|
||||
|
||||
@progressive_disclosure
|
||||
Scenario: Activation tier loads the full SKILL.md body
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/activate-skill
|
||||
description: Activation loads the body.
|
||||
---
|
||||
Step 1: do this.
|
||||
Step 2: do that.
|
||||
"""
|
||||
When I call discover on the agent skill
|
||||
And I call activate on the agent skill
|
||||
Then the activate result body should contain "Step 1: do this."
|
||||
And the activate result body should contain "Step 2: do that."
|
||||
|
||||
@progressive_disclosure
|
||||
Scenario: Resource tier lists available scripts on demand
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/resource-skill
|
||||
description: Resource tier test skill.
|
||||
---
|
||||
Use the scripts.
|
||||
"""
|
||||
And the folder contains a script file "helper.py" with content "# helper"
|
||||
When I call discover on the agent skill
|
||||
And I list available resources
|
||||
Then the resources list should include "helper.py"
|
||||
|
||||
@progressive_disclosure
|
||||
Scenario: Deactivation clears the loaded body
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/deactivate-skill
|
||||
description: Deactivation clears body.
|
||||
---
|
||||
Step 1: do this.
|
||||
"""
|
||||
When I call discover on the agent skill
|
||||
And I call activate on the agent skill
|
||||
And I call deactivate on the agent skill
|
||||
Then the agent skill body should be cleared
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Namespaced Naming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_naming
|
||||
Scenario: Skill with local namespace is accepted
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/my-skill
|
||||
description: A valid local skill.
|
||||
---
|
||||
Body.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill name should be "local/my-skill"
|
||||
|
||||
@agent_skill_naming
|
||||
Scenario: Skill with custom namespace is accepted
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: devops/deploy-pipeline
|
||||
description: A skill in the devops namespace.
|
||||
---
|
||||
Body.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill name should be "devops/deploy-pipeline"
|
||||
|
||||
@agent_skill_naming
|
||||
Scenario: Skill name with underscores and hyphens is accepted
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/my_complex-skill_v2
|
||||
description: Valid name with mixed characters.
|
||||
---
|
||||
Body.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill name should be "local/my_complex-skill_v2"
|
||||
|
||||
@agent_skill_naming @error_handling
|
||||
Scenario: Skill name with special characters is rejected
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/bad name!
|
||||
description: Invalid name.
|
||||
---
|
||||
Body.
|
||||
"""
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "namespace"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stable Step Ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_ordering
|
||||
Scenario: Steps in SKILL.md body are preserved in stable order
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/ordered-skill
|
||||
description: Ordered steps skill.
|
||||
---
|
||||
Step A: First action.
|
||||
Step B: Second action.
|
||||
Step C: Third action.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill body steps should appear in order: "Step A", "Step B", "Step C"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Folder path normalization and safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_safety
|
||||
Scenario: Script paths are normalized to relative paths within the folder
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/safe-skill
|
||||
description: Path normalization test.
|
||||
---
|
||||
Use scripts.
|
||||
"""
|
||||
And the folder contains a script file "run.py" with content "# run"
|
||||
When I load the agent skills folder
|
||||
Then all script paths should be relative to the skill folder
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge case coverage — branch and statement completeness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_parse @error_handling @coverage
|
||||
Scenario: AgentSkillSpec.from_file on a non-existent path raises FileNotFoundError
|
||||
Given a non-existent SKILL.md path
|
||||
When I parse the non-existent SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "SKILL.md"
|
||||
|
||||
@agent_skill_parse @error_handling @coverage
|
||||
Scenario: SKILL.md with unclosed frontmatter delimiter raises error
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/unclosed-skill
|
||||
description: This frontmatter is never closed.
|
||||
"""
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "frontmatter"
|
||||
|
||||
@agent_skill_parse @error_handling @coverage
|
||||
Scenario: SKILL.md with empty frontmatter block raises error
|
||||
Given a SKILL.md with an empty frontmatter block
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "frontmatter"
|
||||
|
||||
@agent_skill_parse @error_handling @coverage
|
||||
Scenario: SKILL.md with malformed YAML in frontmatter raises error
|
||||
Given a SKILL.md with malformed YAML frontmatter
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "YAML"
|
||||
|
||||
@agent_skill_parse @error_handling @coverage
|
||||
Scenario: SKILL.md with non-mapping YAML frontmatter raises error
|
||||
Given a SKILL.md with a YAML list as frontmatter instead of a mapping
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "mapping"
|
||||
|
||||
@agent_skill_folder @coverage
|
||||
Scenario: AgentSkillLoader folder property returns resolved path
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/prop-skill
|
||||
description: Property access test skill.
|
||||
---
|
||||
Body text.
|
||||
"""
|
||||
When I load the agent skills folder
|
||||
Then the loaded agent skill folder property should be set
|
||||
|
||||
@agent_skill_folder @error_handling @coverage
|
||||
Scenario: Loading a path that is a file not a directory raises error
|
||||
Given an agent skills folder path that is a file
|
||||
When I load the agent skills folder expecting an error
|
||||
Then the agent skill load error should mention "not a directory"
|
||||
|
||||
@agent_skill_parse @coverage
|
||||
Scenario: SKILL.md with non-dict compatibility is handled gracefully
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/bad-compat
|
||||
description: Skill with non-dict compatibility field.
|
||||
compatibility: "just a string"
|
||||
---
|
||||
Body text.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill name should be "local/bad-compat"
|
||||
|
||||
@agent_skill_parse @coverage
|
||||
Scenario: SKILL.md with non-dict metadata is handled gracefully
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/bad-meta
|
||||
description: Skill with non-dict metadata field.
|
||||
metadata: "just a string"
|
||||
---
|
||||
Body text.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill name should be "local/bad-meta"
|
||||
|
||||
@agent_skill_folder @coverage
|
||||
Scenario: Sub-directories inside scripts/ are skipped during discovery
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/nested-skill
|
||||
description: Skill with nested directories in scripts/.
|
||||
---
|
||||
Use scripts.
|
||||
"""
|
||||
And the folder contains a script file "run.py" with content "# run"
|
||||
And the scripts folder contains a nested sub-directory
|
||||
When I load the agent skills folder
|
||||
Then the loaded agent skill script paths should include "run.py"
|
||||
And the loaded agent skill script paths should not include any directories
|
||||
|
||||
@agent_skill_loader @error_handling @coverage
|
||||
Scenario: AgentSkillLoader constructor rejects None spec
|
||||
When I construct an AgentSkillLoader with None spec expecting an error
|
||||
Then the agent skill load error should mention "spec"
|
||||
|
||||
@agent_skill_loader @error_handling @coverage
|
||||
Scenario: AgentSkillLoader constructor rejects None folder
|
||||
When I construct an AgentSkillLoader with None folder expecting an error
|
||||
Then the agent skill load error should mention "folder"
|
||||
|
||||
@agent_skill_folder @error_handling @coverage
|
||||
Scenario: AgentSkillLoader.from_folder rejects None path
|
||||
When I call from_folder with None expecting an error
|
||||
Then the agent skill load error should mention "folder"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured Steps — frontmatter steps list → SkillStep objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_steps
|
||||
Scenario: SKILL.md with steps list in frontmatter parses into SkillStep objects
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/step-skill
|
||||
description: Skill with structured steps.
|
||||
steps:
|
||||
- Run tests to verify everything passes
|
||||
- Push branch to remote repository
|
||||
- Deploy to staging environment
|
||||
---
|
||||
Detailed instructions go here.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill steps should have 3 entries
|
||||
And the parsed skill step 1 content should be "Run tests to verify everything passes"
|
||||
And the parsed skill step 2 content should be "Push branch to remote repository"
|
||||
And the parsed skill step 3 content should be "Deploy to staging environment"
|
||||
|
||||
@agent_skill_steps
|
||||
Scenario: Structured steps maintain stable 1-based index ordering
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/ordered-steps-skill
|
||||
description: Ordered structured steps.
|
||||
steps:
|
||||
- Alpha step
|
||||
- Beta step
|
||||
- Gamma step
|
||||
---
|
||||
Body text.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill step 1 index should be 1
|
||||
And the parsed skill step 2 index should be 2
|
||||
And the parsed skill step 3 index should be 3
|
||||
|
||||
@agent_skill_steps
|
||||
Scenario: SKILL.md without steps field has empty steps list
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/no-steps-skill
|
||||
description: Skill without steps field.
|
||||
---
|
||||
Body text.
|
||||
"""
|
||||
When I parse the SKILL.md file
|
||||
Then the parsed skill steps should have 0 entries
|
||||
|
||||
@agent_skill_steps @error_handling
|
||||
Scenario: SKILL.md with steps as a non-list raises actionable error
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/bad-steps-skill
|
||||
description: Skill with invalid steps field type.
|
||||
steps: "just a string instead of a list"
|
||||
---
|
||||
Body text.
|
||||
"""
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "steps"
|
||||
|
||||
@agent_skill_steps @error_handling
|
||||
Scenario: SKILL.md with an empty step entry raises actionable error
|
||||
Given a SKILL.md file with frontmatter:
|
||||
"""
|
||||
---
|
||||
name: local/empty-step-skill
|
||||
description: Skill with an empty step in the list.
|
||||
steps:
|
||||
- First valid step
|
||||
-
|
||||
---
|
||||
Body text.
|
||||
"""
|
||||
When I parse the SKILL.md file expecting an error
|
||||
Then the agent skill parse error should mention "steps"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource Binding Slots — read-only slots for discovered directories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@agent_skill_resource_slots
|
||||
Scenario: Tool descriptor includes resource binding slots for discovered directories
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/slotted-skill
|
||||
description: Skill with resource directories.
|
||||
---
|
||||
Use scripts and references.
|
||||
"""
|
||||
And the folder contains a script file "run.py" with content "# run"
|
||||
And the folder contains a reference file "guide.md" with content "# Guide"
|
||||
When I load the agent skills folder
|
||||
And I convert the loaded agent skill to a tool descriptor
|
||||
Then the tool descriptor should have 2 resource slot(s)
|
||||
And the tool descriptor resource slot "scripts" should have access "read_only"
|
||||
And the tool descriptor resource slot "references" should have access "read_only"
|
||||
|
||||
@agent_skill_resource_slots
|
||||
Scenario: Tool descriptor with no resource directories has empty resource slots
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/no-slots-skill
|
||||
description: Skill with no resource directories.
|
||||
---
|
||||
Instructions.
|
||||
"""
|
||||
When I load the agent skills folder
|
||||
And I convert the loaded agent skill to a tool descriptor
|
||||
Then the tool descriptor should have 0 resource slot(s)
|
||||
And the loaded skill resource_slots property should be empty
|
||||
|
||||
@agent_skill_resource_slots
|
||||
Scenario: Discover descriptor includes resource binding slots
|
||||
Given an agent skills folder with a valid SKILL.md:
|
||||
"""
|
||||
---
|
||||
name: local/discover-slots-skill
|
||||
description: Skill exposing slots at discover time.
|
||||
---
|
||||
Instructions.
|
||||
"""
|
||||
And the folder contains an asset file "config.json" with content "{}"
|
||||
When I call discover on the agent skill
|
||||
Then the discover result should have 1 resource slot(s)
|
||||
And the discover result resource slot "assets" should have access "read_only"
|
||||
@@ -0,0 +1,786 @@
|
||||
"""Step definitions for Agent Skills Loader Behave feature tests.
|
||||
|
||||
Covers AgentSkillSpec parsing, folder discovery, tool descriptor mapping,
|
||||
progressive disclosure, namespaced naming, step ordering, and path safety.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.skills.agent_skills_loader import AgentSkillLoader, AgentSkillSpec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_skill_md(folder: Path, content: str) -> None:
|
||||
"""Write dedented content to SKILL.md in *folder*."""
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "SKILL.md").write_text(textwrap.dedent(content).lstrip())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given — SKILL.md setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a SKILL.md file with frontmatter:")
|
||||
def step_skill_md_with_frontmatter(context: Context) -> None:
|
||||
"""Create a temporary SKILL.md with the given content."""
|
||||
import tempfile
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="agent_skill_"))
|
||||
context._cleanup_handlers.append(
|
||||
lambda: __import__("shutil").rmtree(tmp_dir, ignore_errors=True)
|
||||
)
|
||||
context.skill_folder = tmp_dir
|
||||
_write_skill_md(tmp_dir, context.text)
|
||||
context.skill_parse_error = None
|
||||
context.skill_parse_result = None
|
||||
|
||||
|
||||
@given("an empty SKILL.md file")
|
||||
def step_empty_skill_md(context: Context) -> None:
|
||||
"""Create an empty SKILL.md file."""
|
||||
import tempfile
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="agent_skill_empty_"))
|
||||
context._cleanup_handlers.append(
|
||||
lambda: __import__("shutil").rmtree(tmp_dir, ignore_errors=True)
|
||||
)
|
||||
context.skill_folder = tmp_dir
|
||||
(tmp_dir / "SKILL.md").write_text("")
|
||||
context.skill_parse_error = None
|
||||
context.skill_parse_result = None
|
||||
|
||||
|
||||
@given("an agent skills folder with a valid SKILL.md:")
|
||||
def step_agent_skills_folder_with_skill_md(context: Context) -> None:
|
||||
"""Create a temporary agent skills folder with a valid SKILL.md."""
|
||||
import tempfile
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="agent_skill_folder_"))
|
||||
context._cleanup_handlers.append(
|
||||
lambda: __import__("shutil").rmtree(tmp_dir, ignore_errors=True)
|
||||
)
|
||||
context.skill_folder = tmp_dir
|
||||
_write_skill_md(tmp_dir, context.text)
|
||||
context.skill_load_error = None
|
||||
context.loaded_skill = None
|
||||
context.tool_descriptor = None
|
||||
context.discover_result = None
|
||||
context.activate_result = None
|
||||
|
||||
|
||||
@given("an agent skills folder without a SKILL.md file")
|
||||
def step_agent_skills_folder_no_skill_md(context: Context) -> None:
|
||||
"""Create a folder without SKILL.md."""
|
||||
import tempfile
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="agent_skill_no_md_"))
|
||||
context._cleanup_handlers.append(
|
||||
lambda: __import__("shutil").rmtree(tmp_dir, ignore_errors=True)
|
||||
)
|
||||
context.skill_folder = tmp_dir
|
||||
context.skill_load_error = None
|
||||
context.loaded_skill = None
|
||||
|
||||
|
||||
@given("a non-existent agent skills folder path")
|
||||
def step_nonexistent_agent_skills_folder(context: Context) -> None:
|
||||
"""Set a path that does not exist."""
|
||||
context.skill_folder = Path("/tmp/__nonexistent_agent_skill_path_xyz__")
|
||||
context.skill_load_error = None
|
||||
context.loaded_skill = None
|
||||
|
||||
|
||||
@given('the folder contains a script file "{filename}" with content "{content}"')
|
||||
def step_folder_has_script_file(context: Context, filename: str, content: str) -> None:
|
||||
"""Add a file to the scripts/ subfolder."""
|
||||
scripts_dir: Path = context.skill_folder / "scripts"
|
||||
scripts_dir.mkdir(exist_ok=True)
|
||||
(scripts_dir / filename).write_text(content)
|
||||
|
||||
|
||||
@given('the folder contains a reference file "{filename}" with content "{content}"')
|
||||
def step_folder_has_reference_file(
|
||||
context: Context, filename: str, content: str
|
||||
) -> None:
|
||||
"""Add a file to the references/ subfolder."""
|
||||
refs_dir: Path = context.skill_folder / "references"
|
||||
refs_dir.mkdir(exist_ok=True)
|
||||
(refs_dir / filename).write_text(content)
|
||||
|
||||
|
||||
@given('the folder contains an asset file "{filename}" with content "{content}"')
|
||||
def step_folder_has_asset_file(context: Context, filename: str, content: str) -> None:
|
||||
"""Add a file to the assets/ subfolder."""
|
||||
assets_dir: Path = context.skill_folder / "assets"
|
||||
assets_dir.mkdir(exist_ok=True)
|
||||
(assets_dir / filename).write_text(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — Parsing and loading actions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I parse the SKILL.md file")
|
||||
def step_parse_skill_md(context: Context) -> None:
|
||||
"""Parse the SKILL.md file using AgentSkillSpec."""
|
||||
skill_md_path = context.skill_folder / "SKILL.md"
|
||||
context.skill_parse_result = AgentSkillSpec.from_file(skill_md_path)
|
||||
context.skill_parse_error = None
|
||||
|
||||
|
||||
@when("I parse the SKILL.md file expecting an error")
|
||||
def step_parse_skill_md_expecting_error(context: Context) -> None:
|
||||
"""Attempt to parse the SKILL.md file, expecting a ValueError."""
|
||||
skill_md_path = context.skill_folder / "SKILL.md"
|
||||
try:
|
||||
context.skill_parse_result = AgentSkillSpec.from_file(skill_md_path)
|
||||
context.skill_parse_error = None
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
context.skill_parse_error = exc
|
||||
context.skill_parse_result = None
|
||||
|
||||
|
||||
@when("I load the agent skills folder")
|
||||
def step_load_agent_skills_folder(context: Context) -> None:
|
||||
"""Load an agent skills folder using AgentSkillLoader."""
|
||||
context.loaded_skill = AgentSkillLoader.from_folder(context.skill_folder)
|
||||
context.skill_load_error = None
|
||||
|
||||
|
||||
@when("I load the agent skills folder expecting an error")
|
||||
def step_load_agent_skills_folder_expecting_error(context: Context) -> None:
|
||||
"""Attempt to load an agent skills folder, expecting a ValueError."""
|
||||
try:
|
||||
context.loaded_skill = AgentSkillLoader.from_folder(context.skill_folder)
|
||||
context.skill_load_error = None
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
context.skill_load_error = exc
|
||||
context.loaded_skill = None
|
||||
|
||||
|
||||
@when("I convert the loaded agent skill to a tool descriptor")
|
||||
def step_convert_to_tool_descriptor(context: Context) -> None:
|
||||
"""Convert the loaded AgentSkillLoader to a ToolDescriptor."""
|
||||
context.tool_descriptor = context.loaded_skill.to_tool_descriptor()
|
||||
|
||||
|
||||
@when("I call discover on the agent skill")
|
||||
def step_call_discover(context: Context) -> None:
|
||||
"""Call discover() which returns metadata-only descriptor."""
|
||||
context.loaded_skill = AgentSkillLoader.from_folder(context.skill_folder)
|
||||
context.discover_result = context.loaded_skill.discover()
|
||||
|
||||
|
||||
@when("I call activate on the agent skill")
|
||||
def step_call_activate(context: Context) -> None:
|
||||
"""Call activate() to load the full SKILL.md body."""
|
||||
context.activate_result = context.loaded_skill.activate()
|
||||
|
||||
|
||||
@when("I call deactivate on the agent skill")
|
||||
def step_call_deactivate(context: Context) -> None:
|
||||
"""Call deactivate() to clear the loaded body."""
|
||||
context.loaded_skill.deactivate()
|
||||
|
||||
|
||||
@when("I list available resources")
|
||||
def step_list_available_resources(context: Context) -> None:
|
||||
"""List available resource files in the skill folder."""
|
||||
context.available_resources = context.loaded_skill.list_resources()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Assertions for parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the parsed skill name should be "{expected}"')
|
||||
def step_parsed_skill_name(context: Context, expected: str) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
assert result.name == expected, f"Expected name '{expected}', got '{result.name}'"
|
||||
|
||||
|
||||
@then('the parsed skill description should be "{expected}"')
|
||||
def step_parsed_skill_description(context: Context, expected: str) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
assert result.description == expected, (
|
||||
f"Expected description '{expected}', got '{result.description}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the parsed skill body should contain "{expected}"')
|
||||
def step_parsed_skill_body_contains(context: Context, expected: str) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
assert expected in result.body, (
|
||||
f"Expected body to contain '{expected}', got:\n{result.body}"
|
||||
)
|
||||
|
||||
|
||||
@then('the parsed skill version should be "{expected}"')
|
||||
def step_parsed_skill_version(context: Context, expected: str) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
assert result.version == expected, (
|
||||
f"Expected version '{expected}', got '{result.version}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the parsed skill allowed tools should include "{tool_name}"')
|
||||
def step_parsed_skill_allowed_tools_include(context: Context, tool_name: str) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
assert tool_name in result.allowed_tools, (
|
||||
f"Expected allowed_tools to include '{tool_name}', got: {result.allowed_tools}"
|
||||
)
|
||||
|
||||
|
||||
@then('the parsed skill metadata should have key "{key}" with value "{value}"')
|
||||
def step_parsed_skill_metadata_key(context: Context, key: str, value: str) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
assert result.metadata is not None, "No metadata on parsed skill"
|
||||
actual = str(result.metadata.get(key))
|
||||
assert actual == value, f"Expected metadata['{key}'] = '{value}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the agent skill parse error should mention "{keyword}"')
|
||||
def step_agent_skill_parse_error_mentions(context: Context, keyword: str) -> None:
|
||||
err = context.skill_parse_error
|
||||
assert err is not None, "Expected a parse error but none was raised"
|
||||
assert keyword.lower() in str(err).lower(), (
|
||||
f"Expected error to mention '{keyword}', got: {err}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Assertions for folder loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the loaded agent skill name should be "{expected}"')
|
||||
def step_loaded_skill_name(context: Context, expected: str) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
assert skill.spec.name == expected, (
|
||||
f"Expected skill name '{expected}', got '{skill.spec.name}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the loaded agent skill description should be "{expected}"')
|
||||
def step_loaded_skill_description(context: Context, expected: str) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
assert skill.spec.description == expected, (
|
||||
f"Expected description '{expected}', got '{skill.spec.description}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the loaded agent skill script paths should include "{filename}"')
|
||||
def step_loaded_skill_script_paths_include(context: Context, filename: str) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
names = [p.name for p in skill.script_paths]
|
||||
assert filename in names, (
|
||||
f"Expected script paths to include '{filename}', got: {names}"
|
||||
)
|
||||
|
||||
|
||||
@then('the loaded agent skill reference paths should include "{filename}"')
|
||||
def step_loaded_skill_reference_paths_include(context: Context, filename: str) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
names = [p.name for p in skill.reference_paths]
|
||||
assert filename in names, (
|
||||
f"Expected reference paths to include '{filename}', got: {names}"
|
||||
)
|
||||
|
||||
|
||||
@then('the loaded agent skill asset paths should include "{filename}"')
|
||||
def step_loaded_skill_asset_paths_include(context: Context, filename: str) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
names = [p.name for p in skill.asset_paths]
|
||||
assert filename in names, (
|
||||
f"Expected asset paths to include '{filename}', got: {names}"
|
||||
)
|
||||
|
||||
|
||||
@then("the loaded agent skill script paths should be empty")
|
||||
def step_loaded_skill_script_paths_empty(context: Context) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
assert skill.script_paths == [], (
|
||||
f"Expected empty script paths, got: {skill.script_paths}"
|
||||
)
|
||||
|
||||
|
||||
@then("the loaded agent skill reference paths should be empty")
|
||||
def step_loaded_skill_reference_paths_empty(context: Context) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
assert skill.reference_paths == [], (
|
||||
f"Expected empty reference paths, got: {skill.reference_paths}"
|
||||
)
|
||||
|
||||
|
||||
@then("the loaded agent skill asset paths should be empty")
|
||||
def step_loaded_skill_asset_paths_empty(context: Context) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
assert skill.asset_paths == [], (
|
||||
f"Expected empty asset paths, got: {skill.asset_paths}"
|
||||
)
|
||||
|
||||
|
||||
@then('the agent skill load error should mention "{keyword}"')
|
||||
def step_agent_skill_load_error_mentions(context: Context, keyword: str) -> None:
|
||||
err = context.skill_load_error
|
||||
assert err is not None, "Expected a load error but none was raised"
|
||||
assert keyword.lower() in str(err).lower(), (
|
||||
f"Expected error to mention '{keyword}', got: {err}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Assertions for tool descriptor mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the tool descriptor source should be "{expected}"')
|
||||
def step_tool_descriptor_source(context: Context, expected: str) -> None:
|
||||
td = context.tool_descriptor
|
||||
assert td is not None, "No tool descriptor available"
|
||||
assert td.source == expected, f"Expected source '{expected}', got '{td.source}'"
|
||||
|
||||
|
||||
@then('the tool descriptor name should be "{expected}"')
|
||||
def step_tool_descriptor_name(context: Context, expected: str) -> None:
|
||||
td = context.tool_descriptor
|
||||
assert td is not None, "No tool descriptor available"
|
||||
assert td.name == expected, (
|
||||
f"Expected tool descriptor name '{expected}', got '{td.name}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the tool descriptor should have read_only set to True")
|
||||
def step_tool_descriptor_read_only_true(context: Context) -> None:
|
||||
td = context.tool_descriptor
|
||||
assert td is not None, "No tool descriptor available"
|
||||
assert td.read_only is True, f"Expected read_only=True, got {td.read_only}"
|
||||
|
||||
|
||||
@then("the tool descriptor should have writes set to False")
|
||||
def step_tool_descriptor_writes_false(context: Context) -> None:
|
||||
td = context.tool_descriptor
|
||||
assert td is not None, "No tool descriptor available"
|
||||
assert td.writes is False, f"Expected writes=False, got {td.writes}"
|
||||
|
||||
|
||||
@then('the tool descriptor allowed tools should include "{tool_name}"')
|
||||
def step_tool_descriptor_allowed_tools_include(
|
||||
context: Context, tool_name: str
|
||||
) -> None:
|
||||
td = context.tool_descriptor
|
||||
assert td is not None, "No tool descriptor available"
|
||||
assert tool_name in td.allowed_tools, (
|
||||
f"Expected allowed_tools to include '{tool_name}', got: {td.allowed_tools}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tool descriptor agent_skill_path should be set")
|
||||
def step_tool_descriptor_agent_skill_path_set(context: Context) -> None:
|
||||
td = context.tool_descriptor
|
||||
assert td is not None, "No tool descriptor available"
|
||||
assert td.agent_skill_path is not None, "Expected agent_skill_path to be set"
|
||||
assert td.agent_skill_path != "", "Expected agent_skill_path to be non-empty"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Assertions for progressive disclosure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the discover result should have name "{expected}"')
|
||||
def step_discover_result_name(context: Context, expected: str) -> None:
|
||||
dr = context.discover_result
|
||||
assert dr is not None, "No discover result available"
|
||||
assert dr.name == expected, f"Expected discover name '{expected}', got '{dr.name}'"
|
||||
|
||||
|
||||
@then('the discover result should have description "{expected}"')
|
||||
def step_discover_result_description(context: Context, expected: str) -> None:
|
||||
dr = context.discover_result
|
||||
assert dr is not None, "No discover result available"
|
||||
assert dr.description == expected, (
|
||||
f"Expected discover description '{expected}', got '{dr.description}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the discover result body should be empty")
|
||||
def step_discover_result_body_empty(context: Context) -> None:
|
||||
dr = context.discover_result
|
||||
assert dr is not None, "No discover result available"
|
||||
assert dr.body == "" or dr.body is None, (
|
||||
f"Expected empty body on discover, got: '{dr.body}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the activate result body should contain "{expected}"')
|
||||
def step_activate_result_body_contains(context: Context, expected: str) -> None:
|
||||
ar = context.activate_result
|
||||
assert ar is not None, "No activate result available"
|
||||
assert expected in ar.body, (
|
||||
f"Expected activate body to contain '{expected}', got:\n{ar.body}"
|
||||
)
|
||||
|
||||
|
||||
@then('the resources list should include "{filename}"')
|
||||
def step_resources_list_includes(context: Context, filename: str) -> None:
|
||||
resources = context.available_resources
|
||||
assert resources is not None, "No resources list available"
|
||||
names = [p.name for p in resources]
|
||||
assert filename in names, (
|
||||
f"Expected resources to include '{filename}', got: {names}"
|
||||
)
|
||||
|
||||
|
||||
@then("the agent skill body should be cleared")
|
||||
def step_agent_skill_body_cleared(context: Context) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
assert skill.active_body is None or skill.active_body == "", (
|
||||
f"Expected body to be cleared after deactivate, got: '{skill.active_body}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Assertions for ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then(
|
||||
"the parsed skill body steps should appear in order: "
|
||||
'"{first}", "{second}", "{third}"'
|
||||
)
|
||||
def step_parsed_skill_body_order(
|
||||
context: Context, first: str, second: str, third: str
|
||||
) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
body = result.body
|
||||
pos_first = body.find(first)
|
||||
pos_second = body.find(second)
|
||||
pos_third = body.find(third)
|
||||
assert pos_first != -1, f"'{first}' not found in body"
|
||||
assert pos_second != -1, f"'{second}' not found in body"
|
||||
assert pos_third != -1, f"'{third}' not found in body"
|
||||
assert pos_first < pos_second < pos_third, (
|
||||
f"Expected order {first!r} < {second!r} < {third!r} in body, "
|
||||
f"got positions {pos_first}, {pos_second}, {pos_third}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Assertions for path safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("all script paths should be relative to the skill folder")
|
||||
def step_all_script_paths_relative_to_folder(context: Context) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
folder = context.skill_folder
|
||||
for script_path in skill.script_paths:
|
||||
resolved = script_path.resolve()
|
||||
folder_resolved = folder.resolve()
|
||||
assert str(resolved).startswith(str(folder_resolved)), (
|
||||
f"Script path '{resolved}' is outside skill folder '{folder_resolved}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given — Edge-case setup for coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a non-existent SKILL.md path")
|
||||
def step_nonexistent_skill_md_path(context: Context) -> None:
|
||||
"""Set a path to a SKILL.md that does not exist."""
|
||||
context.skill_folder = Path("/tmp/__nonexistent_skill_folder_xyz__")
|
||||
context.skill_parse_error = None
|
||||
context.skill_parse_result = None
|
||||
|
||||
|
||||
@given("a SKILL.md with an empty frontmatter block")
|
||||
def step_skill_md_empty_frontmatter_block(context: Context) -> None:
|
||||
"""Create a SKILL.md with empty frontmatter (--- followed immediately by ---)."""
|
||||
import tempfile
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="agent_skill_empty_fm_"))
|
||||
context._cleanup_handlers.append(
|
||||
lambda: __import__("shutil").rmtree(tmp_dir, ignore_errors=True)
|
||||
)
|
||||
context.skill_folder = tmp_dir
|
||||
(tmp_dir / "SKILL.md").write_text("---\n---\nsome body\n")
|
||||
context.skill_parse_error = None
|
||||
context.skill_parse_result = None
|
||||
|
||||
|
||||
@given("a SKILL.md with malformed YAML frontmatter")
|
||||
def step_skill_md_malformed_yaml(context: Context) -> None:
|
||||
"""Create a SKILL.md with YAML that cannot be parsed."""
|
||||
import tempfile
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="agent_skill_bad_yaml_"))
|
||||
context._cleanup_handlers.append(
|
||||
lambda: __import__("shutil").rmtree(tmp_dir, ignore_errors=True)
|
||||
)
|
||||
context.skill_folder = tmp_dir
|
||||
(tmp_dir / "SKILL.md").write_text("---\nname: [\n---\nbody\n")
|
||||
context.skill_parse_error = None
|
||||
context.skill_parse_result = None
|
||||
|
||||
|
||||
@given("a SKILL.md with a YAML list as frontmatter instead of a mapping")
|
||||
def step_skill_md_yaml_list_frontmatter(context: Context) -> None:
|
||||
"""Create a SKILL.md with a YAML list (not a mapping) as frontmatter."""
|
||||
import tempfile
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="agent_skill_list_fm_"))
|
||||
context._cleanup_handlers.append(
|
||||
lambda: __import__("shutil").rmtree(tmp_dir, ignore_errors=True)
|
||||
)
|
||||
context.skill_folder = tmp_dir
|
||||
(tmp_dir / "SKILL.md").write_text("---\n- item1\n- item2\n---\nbody\n")
|
||||
context.skill_parse_error = None
|
||||
context.skill_parse_result = None
|
||||
|
||||
|
||||
@given("an agent skills folder path that is a file")
|
||||
def step_agent_skills_folder_is_a_file(context: Context) -> None:
|
||||
"""Create a temporary file and use its path as if it were a folder."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(prefix="agent_skill_file_", suffix=".md")
|
||||
os.close(fd)
|
||||
context._cleanup_handlers.append(lambda: Path(tmp_path).unlink(missing_ok=True))
|
||||
context.skill_folder = Path(tmp_path)
|
||||
context.skill_load_error = None
|
||||
context.loaded_skill = None
|
||||
|
||||
|
||||
@given("the scripts folder contains a nested sub-directory")
|
||||
def step_scripts_folder_has_nested_subdir(context: Context) -> None:
|
||||
"""Add a nested sub-directory inside scripts/ (should be skipped)."""
|
||||
scripts_dir: Path = context.skill_folder / "scripts"
|
||||
scripts_dir.mkdir(exist_ok=True)
|
||||
nested = scripts_dir / "nested_subdir"
|
||||
nested.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — Edge-case parse actions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I parse the non-existent SKILL.md file expecting an error")
|
||||
def step_parse_nonexistent_skill_md(context: Context) -> None:
|
||||
"""Attempt to parse a SKILL.md at a path that doesn't exist."""
|
||||
skill_md_path = context.skill_folder / "SKILL.md"
|
||||
try:
|
||||
context.skill_parse_result = AgentSkillSpec.from_file(skill_md_path)
|
||||
context.skill_parse_error = None
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
context.skill_parse_error = exc
|
||||
context.skill_parse_result = None
|
||||
|
||||
|
||||
@when("I construct an AgentSkillLoader with None spec expecting an error")
|
||||
def step_construct_loader_none_spec(context: Context) -> None:
|
||||
"""Attempt to construct AgentSkillLoader with spec=None."""
|
||||
try:
|
||||
AgentSkillLoader( # type: ignore[call-arg]
|
||||
spec=None,
|
||||
folder=Path("/tmp"),
|
||||
script_paths=[],
|
||||
reference_paths=[],
|
||||
asset_paths=[],
|
||||
)
|
||||
context.skill_load_error = None
|
||||
except (ValueError, TypeError) as exc:
|
||||
context.skill_load_error = exc
|
||||
|
||||
|
||||
@when("I construct an AgentSkillLoader with None folder expecting an error")
|
||||
def step_construct_loader_none_folder(context: Context) -> None:
|
||||
"""Attempt to construct AgentSkillLoader with folder=None."""
|
||||
spec = AgentSkillSpec.from_string(
|
||||
"---\nname: local/test\ndescription: test\n---\nbody\n"
|
||||
)
|
||||
try:
|
||||
AgentSkillLoader( # type: ignore[call-arg]
|
||||
spec=spec,
|
||||
folder=None,
|
||||
script_paths=[],
|
||||
reference_paths=[],
|
||||
asset_paths=[],
|
||||
)
|
||||
context.skill_load_error = None
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
context.skill_load_error = exc
|
||||
|
||||
|
||||
@when("I call from_folder with None expecting an error")
|
||||
def step_call_from_folder_none(context: Context) -> None:
|
||||
"""Call AgentSkillLoader.from_folder(None) and capture the error."""
|
||||
try:
|
||||
AgentSkillLoader.from_folder(None) # type: ignore[arg-type]
|
||||
context.skill_load_error = None
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
context.skill_load_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Edge-case assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the loaded agent skill folder property should be set")
|
||||
def step_loaded_skill_folder_property(context: Context) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
assert skill.folder is not None, "Expected folder property to be set"
|
||||
assert skill.folder.is_dir(), (
|
||||
f"Expected folder to be a directory, got: {skill.folder}"
|
||||
)
|
||||
|
||||
|
||||
@then("the loaded agent skill script paths should not include any directories")
|
||||
def step_loaded_skill_script_paths_no_dirs(context: Context) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
for p in skill.script_paths:
|
||||
assert p.is_file(), (
|
||||
f"Expected only files in script_paths, but found directory: {p}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Assertions for structured steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the parsed skill steps should have {count:d} entries")
|
||||
def step_parsed_skill_steps_count(context: Context, count: int) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
actual = len(result.steps)
|
||||
assert actual == count, f"Expected {count} step(s), got {actual}: {result.steps}"
|
||||
|
||||
|
||||
@then('the parsed skill step {index:d} content should be "{expected}"')
|
||||
def step_parsed_skill_step_content(context: Context, index: int, expected: str) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
assert len(result.steps) >= index, (
|
||||
f"Expected at least {index} step(s), got {len(result.steps)}"
|
||||
)
|
||||
actual = result.steps[index - 1].content
|
||||
assert actual == expected, (
|
||||
f"Expected step {index} content '{expected}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the parsed skill step {index:d} index should be {expected:d}")
|
||||
def step_parsed_skill_step_index(context: Context, index: int, expected: int) -> None:
|
||||
result = context.skill_parse_result
|
||||
assert result is not None, "No parsed skill result available"
|
||||
assert len(result.steps) >= index, (
|
||||
f"Expected at least {index} step(s), got {len(result.steps)}"
|
||||
)
|
||||
actual = result.steps[index - 1].index
|
||||
assert actual == expected, (
|
||||
f"Expected step {index} to have index={expected}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — Assertions for resource binding slots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the tool descriptor should have {count:d} resource slot(s)")
|
||||
def step_tool_descriptor_resource_slot_count(context: Context, count: int) -> None:
|
||||
td = context.tool_descriptor
|
||||
assert td is not None, "No tool descriptor available"
|
||||
actual = len(td.resource_slots)
|
||||
assert actual == count, (
|
||||
f"Expected {count} resource slot(s), got {actual}: {td.resource_slots}"
|
||||
)
|
||||
|
||||
|
||||
@then('the tool descriptor resource slot "{name}" should have access "{access}"')
|
||||
def step_tool_descriptor_resource_slot_access(
|
||||
context: Context, name: str, access: str
|
||||
) -> None:
|
||||
td = context.tool_descriptor
|
||||
assert td is not None, "No tool descriptor available"
|
||||
slot = next((s for s in td.resource_slots if s.name == name), None)
|
||||
assert slot is not None, (
|
||||
f"No resource slot named '{name}' found in: "
|
||||
f"{[s.name for s in td.resource_slots]}"
|
||||
)
|
||||
assert slot.access == access, (
|
||||
f"Expected slot '{name}' access='{access}', got '{slot.access}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the discover result should have {count:d} resource slot(s)")
|
||||
def step_discover_result_resource_slot_count(context: Context, count: int) -> None:
|
||||
dr = context.discover_result
|
||||
assert dr is not None, "No discover result available"
|
||||
actual = len(dr.resource_slots)
|
||||
assert actual == count, (
|
||||
f"Expected {count} resource slot(s) in discover result, "
|
||||
f"got {actual}: {dr.resource_slots}"
|
||||
)
|
||||
|
||||
|
||||
@then('the discover result resource slot "{name}" should have access "{access}"')
|
||||
def step_discover_result_resource_slot_access(
|
||||
context: Context, name: str, access: str
|
||||
) -> None:
|
||||
dr = context.discover_result
|
||||
assert dr is not None, "No discover result available"
|
||||
slot = next((s for s in dr.resource_slots if s.name == name), None)
|
||||
assert slot is not None, (
|
||||
f"No resource slot named '{name}' in discover result: "
|
||||
f"{[s.name for s in dr.resource_slots]}"
|
||||
)
|
||||
assert slot.access == access, (
|
||||
f"Expected discover slot '{name}' access='{access}', got '{slot.access}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the loaded skill resource_slots property should be empty")
|
||||
def step_loaded_skill_resource_slots_property_empty(context: Context) -> None:
|
||||
skill = context.loaded_skill
|
||||
assert skill is not None, "No loaded skill available"
|
||||
assert skill.resource_slots == [], (
|
||||
f"Expected empty resource_slots property, got: {skill.resource_slots}"
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
*** Settings ***
|
||||
Documentation Integration smoke tests for Agent Skills Standard loader
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_agent_skills_loader.py
|
||||
${SAMPLE_SKILL} ${CURDIR}/../examples/agent-skills/deploy-to-staging
|
||||
|
||||
*** Test Cases ***
|
||||
Load Sample Agent Skills Folder
|
||||
[Documentation] Load the example deploy-to-staging skill folder and verify name/description
|
||||
${result}= Run Process ${PYTHON} ${HELPER} load-skill ${SAMPLE_SKILL} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-load-ok
|
||||
|
||||
Discover Returns Metadata Only
|
||||
[Documentation] discover() should return name and description but no body
|
||||
${result}= Run Process ${PYTHON} ${HELPER} discover ${SAMPLE_SKILL} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-discover-ok
|
||||
|
||||
Activate Loads Full Body
|
||||
[Documentation] activate() should load the full SKILL.md body
|
||||
${result}= Run Process ${PYTHON} ${HELPER} activate ${SAMPLE_SKILL} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-activate-ok
|
||||
|
||||
Tool Descriptor Has Correct Fields
|
||||
[Documentation] to_tool_descriptor() should produce a descriptor with source=agent_skill and read_only=True
|
||||
${result}= Run Process ${PYTHON} ${HELPER} tool-descriptor ${SAMPLE_SKILL} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-tool-descriptor-ok
|
||||
|
||||
List Resources Finds Scripts And References
|
||||
[Documentation] list_resources() should find files in scripts/ and references/
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-resources ${SAMPLE_SKILL} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-list-resources-ok
|
||||
Should Contain ${result.stdout} deploy.py
|
||||
Should Contain ${result.stdout} runbook.md
|
||||
|
||||
Invalid Folder Without SKILL.md Fails Gracefully
|
||||
[Documentation] Loading a folder with no SKILL.md should fail with an informative error
|
||||
${no_skill_dir}= Join Path ${TEMPDIR} no_skill_md_robot_test
|
||||
Create Directory ${no_skill_dir}
|
||||
${result}= Run Process ${PYTHON} ${HELPER} invalid-skill ${no_skill_dir} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-invalid-ok
|
||||
|
||||
Non-Existent Folder Fails Gracefully
|
||||
[Documentation] Loading a non-existent path should fail with an informative error
|
||||
${result}= Run Process ${PYTHON} ${HELPER} invalid-skill /tmp/__nonexistent_skill_xyz__ cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-invalid-ok
|
||||
|
||||
Structured Steps Are Parsed From Frontmatter
|
||||
[Documentation] steps: list in SKILL.md frontmatter → SkillStep objects with stable index ordering
|
||||
${result}= Run Process ${PYTHON} ${HELPER} steps ${SAMPLE_SKILL} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-steps-ok
|
||||
Should Contain ${result.stdout} step[1]
|
||||
|
||||
Resource Binding Slots Are Read-Only
|
||||
[Documentation] Resource directories (scripts/, references/) produce read-only binding slots
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resource-slots ${SAMPLE_SKILL} cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} agent-skills-resource-slots-ok
|
||||
Should Contain ${result.stdout} access=read_only
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Robot Framework helper for Agent Skills Loader integration tests.
|
||||
|
||||
Provides a CLI-style interface for Robot to invoke Agent Skills loader
|
||||
operations and verify the results. Exit code 0 = success, 1 = failure.
|
||||
|
||||
Usage:
|
||||
python robot/helper_agent_skills_loader.py load-skill <folder_path>
|
||||
python robot/helper_agent_skills_loader.py discover <folder_path>
|
||||
python robot/helper_agent_skills_loader.py activate <folder_path>
|
||||
python robot/helper_agent_skills_loader.py tool-descriptor <folder_path>
|
||||
python robot/helper_agent_skills_loader.py invalid-skill <folder_path>
|
||||
python robot/helper_agent_skills_loader.py list-resources <folder_path>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.skills.agent_skills_loader import AgentSkillLoader # noqa: E402
|
||||
|
||||
|
||||
def _cmd_load_skill(folder: str) -> int:
|
||||
"""Load a skill folder and verify the spec is populated."""
|
||||
try:
|
||||
loader = AgentSkillLoader.from_folder(Path(folder))
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"agent-skills-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if not loader.spec.name:
|
||||
print("agent-skills-fail: name is empty")
|
||||
return 1
|
||||
if not loader.spec.description:
|
||||
print("agent-skills-fail: description is empty")
|
||||
return 1
|
||||
|
||||
print(f"agent-skills-load-ok: {loader.spec.name}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_discover(folder: str) -> int:
|
||||
"""Call discover() and verify metadata-only descriptor."""
|
||||
try:
|
||||
loader = AgentSkillLoader.from_folder(Path(folder))
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"agent-skills-fail: {exc}")
|
||||
return 1
|
||||
|
||||
descriptor = loader.discover()
|
||||
|
||||
if descriptor.source != "agent_skill":
|
||||
print(
|
||||
f"agent-skills-fail: expected source=agent_skill, got {descriptor.source}"
|
||||
)
|
||||
return 1
|
||||
if descriptor.body:
|
||||
print(
|
||||
f"agent-skills-fail: body should be empty at discover, "
|
||||
f"got: {descriptor.body!r}"
|
||||
)
|
||||
return 1
|
||||
if not descriptor.name:
|
||||
print("agent-skills-fail: name is empty")
|
||||
return 1
|
||||
|
||||
print(f"agent-skills-discover-ok: {descriptor.name}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_activate(folder: str) -> int:
|
||||
"""Call discover() then activate() and verify body is loaded."""
|
||||
try:
|
||||
loader = AgentSkillLoader.from_folder(Path(folder))
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"agent-skills-fail: {exc}")
|
||||
return 1
|
||||
|
||||
loader.discover()
|
||||
activated = loader.activate()
|
||||
|
||||
if not activated.body:
|
||||
print("agent-skills-fail: body is empty after activate")
|
||||
return 1
|
||||
|
||||
print(f"agent-skills-activate-ok: body_len={len(activated.body)}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_tool_descriptor(folder: str) -> int:
|
||||
"""Convert loaded skill to tool descriptor and verify fields."""
|
||||
try:
|
||||
loader = AgentSkillLoader.from_folder(Path(folder))
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"agent-skills-fail: {exc}")
|
||||
return 1
|
||||
|
||||
td = loader.to_tool_descriptor()
|
||||
|
||||
if td.source != "agent_skill":
|
||||
print(f"agent-skills-fail: expected source=agent_skill, got {td.source}")
|
||||
return 1
|
||||
if not td.read_only:
|
||||
print("agent-skills-fail: expected read_only=True")
|
||||
return 1
|
||||
if td.writes:
|
||||
print("agent-skills-fail: expected writes=False")
|
||||
return 1
|
||||
if not td.agent_skill_path:
|
||||
print("agent-skills-fail: agent_skill_path is empty")
|
||||
return 1
|
||||
if not td.name:
|
||||
print("agent-skills-fail: tool descriptor name is empty")
|
||||
return 1
|
||||
|
||||
print(f"agent-skills-tool-descriptor-ok: {td.name}")
|
||||
print(f" resource_slots={len(td.resource_slots)}")
|
||||
for slot in td.resource_slots:
|
||||
print(f" slot: name={slot.name} access={slot.access}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_invalid_skill(folder: str) -> int:
|
||||
"""Attempt to load an invalid skills folder and verify error."""
|
||||
try:
|
||||
AgentSkillLoader.from_folder(Path(folder))
|
||||
print("agent-skills-fail: expected error but none was raised")
|
||||
return 1
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"agent-skills-invalid-ok: {exc}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_list_resources(folder: str) -> int:
|
||||
"""List resources from a skill folder."""
|
||||
try:
|
||||
loader = AgentSkillLoader.from_folder(Path(folder))
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"agent-skills-fail: {exc}")
|
||||
return 1
|
||||
|
||||
resources = loader.list_resources()
|
||||
print(f"agent-skills-list-resources-ok: count={len(resources)}")
|
||||
for p in resources:
|
||||
print(f" resource: {p.name}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_steps(folder: str) -> int:
|
||||
"""Load a skill folder and print structured steps from the spec."""
|
||||
try:
|
||||
loader = AgentSkillLoader.from_folder(Path(folder))
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"agent-skills-fail: {exc}")
|
||||
return 1
|
||||
|
||||
steps = loader.spec.steps
|
||||
print(f"agent-skills-steps-ok: count={len(steps)}")
|
||||
for step in steps:
|
||||
print(f" step[{step.index}]: {step.content}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_resource_slots(folder: str) -> int:
|
||||
"""Load a skill folder and print resource binding slots."""
|
||||
try:
|
||||
loader = AgentSkillLoader.from_folder(Path(folder))
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"agent-skills-fail: {exc}")
|
||||
return 1
|
||||
|
||||
slots = loader.resource_slots
|
||||
print(f"agent-skills-resource-slots-ok: count={len(slots)}")
|
||||
for slot in slots:
|
||||
print(
|
||||
f" slot: name={slot.name} type={slot.resource_type} access={slot.access}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"load-skill": _cmd_load_skill,
|
||||
"discover": _cmd_discover,
|
||||
"activate": _cmd_activate,
|
||||
"tool-descriptor": _cmd_tool_descriptor,
|
||||
"invalid-skill": _cmd_invalid_skill,
|
||||
"list-resources": _cmd_list_resources,
|
||||
"steps": _cmd_steps,
|
||||
"resource-slots": _cmd_resource_slots,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point called by Robot Framework ``Run Process``."""
|
||||
if len(sys.argv) < 3:
|
||||
print(
|
||||
"Usage: helper_agent_skills_loader.py "
|
||||
"<load-skill|discover|activate|tool-descriptor"
|
||||
"|invalid-skill|list-resources> "
|
||||
"<folder_path>"
|
||||
)
|
||||
return 1
|
||||
|
||||
command = sys.argv[1]
|
||||
folder = sys.argv[2]
|
||||
|
||||
handler = _COMMANDS.get(command)
|
||||
if handler is None:
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
return handler(folder)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,14 +6,27 @@ as well as the skill protocol types used for discovery, composition,
|
||||
execution, and error reporting within the skill framework, the
|
||||
runtime context, skill registry, and inline tool executor.
|
||||
|
||||
It also provides the Agent Skills Standard loader (``AgentSkillLoader``)
|
||||
for the three-tier progressive disclosure model (discover / activate /
|
||||
deactivate) described in ``docs/specification.md`` § AgentSkillAdapter.
|
||||
|
||||
Usage::
|
||||
|
||||
from cleveragents.skills.schema import SkillConfigSchema
|
||||
from cleveragents.skills.protocol import SkillDefinition
|
||||
from cleveragents.skills.agent_skills_loader import AgentSkillLoader
|
||||
|
||||
config = SkillConfigSchema.from_yaml_file("examples/skills/single-tool.yaml")
|
||||
loader = AgentSkillLoader.from_folder(Path("./skills/deploy-to-staging"))
|
||||
"""
|
||||
|
||||
from cleveragents.skills.agent_skills_loader import (
|
||||
AgentSkillLoader,
|
||||
AgentSkillResourceSlot,
|
||||
AgentSkillSpec,
|
||||
AgentSkillToolDescriptor,
|
||||
SkillStep,
|
||||
)
|
||||
from cleveragents.skills.context import (
|
||||
SkillContext,
|
||||
SkillExecutionError,
|
||||
@@ -45,6 +58,10 @@ from cleveragents.skills.registry import SkillRegistry
|
||||
from cleveragents.skills.schema import SkillConfigSchema
|
||||
|
||||
__all__ = [
|
||||
"AgentSkillLoader",
|
||||
"AgentSkillResourceSlot",
|
||||
"AgentSkillSpec",
|
||||
"AgentSkillToolDescriptor",
|
||||
"DiscoveredAgentSkill",
|
||||
"DiscoveryConflict",
|
||||
"DiscoveryResult",
|
||||
@@ -59,6 +76,7 @@ __all__ = [
|
||||
"SkillMetadata",
|
||||
"SkillRegistry",
|
||||
"SkillResult",
|
||||
"SkillStep",
|
||||
"ToolInvocationRecord",
|
||||
"build_tool_spec",
|
||||
"discover_agent_skills",
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
"""Agent Skills Standard loader for CleverAgents v3.
|
||||
|
||||
Implements the three-tier progressive disclosure model for Agent Skills:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
Tier 1 — Metadata : discover() → name + description (~50-100 tokens)
|
||||
Tier 2 — Instructions: activate() → full SKILL.md body (< 5000 tokens)
|
||||
Tier 3 — Resources : list_resources() → scripts/, references/, assets/
|
||||
|
||||
## Usage
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
loader = AgentSkillLoader.from_folder(Path("./skills/deploy-to-staging"))
|
||||
descriptor = loader.discover() # Tier 1: lightweight metadata
|
||||
loader.activate() # Tier 2: loads instructions into active_body
|
||||
resources = loader.list_resources() # Tier 3: on-demand resource paths
|
||||
tool = loader.to_tool_descriptor() # Map to tool descriptor (source=agent_skill)
|
||||
loader.deactivate() # Clear active body, free token budget
|
||||
|
||||
## SKILL.md Format
|
||||
|
||||
Agent Skills follow the AgentSkills.io standard:
|
||||
|
||||
.. code-block:: markdown
|
||||
|
||||
---
|
||||
name: local/deploy-staging
|
||||
description: Deploy the current branch to the staging environment.
|
||||
steps: # optional — structured step list with stable ordering
|
||||
- Run tests and verify they pass
|
||||
- Push branch to remote repository
|
||||
- Deploy to staging environment
|
||||
version: 1.0.0 # optional
|
||||
compatibility: # optional
|
||||
min_agent_version: "3.0.0"
|
||||
metadata: # optional — arbitrary key/value pairs
|
||||
author: team
|
||||
tags: [deploy, staging]
|
||||
allowed-tools: # optional — restrict which tools the skill may call
|
||||
- local/read-file
|
||||
- local/shell
|
||||
---
|
||||
## Detailed Instructions
|
||||
|
||||
Additional prose context for each step.
|
||||
|
||||
## Namespaced Naming
|
||||
|
||||
All skill names follow the ``namespace/short_name`` pattern identical to
|
||||
tools and actors (ADR-002). The ``local/`` namespace is the default.
|
||||
|
||||
## Source Type
|
||||
|
||||
Agent Skills are registered in the Tool Registry with ``source=agent_skill``.
|
||||
They are **read-only by default** because the skill's instructions do not
|
||||
themselves write — the LLM agent decides which write-capable tools to invoke
|
||||
during execution. Individual ``allowed-tools`` may write; the skill descriptor
|
||||
itself does not.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
my-skill/
|
||||
├── SKILL.md ← required — frontmatter + instructions
|
||||
├── scripts/ ← optional — bundled Python/shell scripts
|
||||
│ └── deploy.py
|
||||
├── references/ ← optional — supplementary Markdown docs
|
||||
│ └── runbook.md
|
||||
└── assets/ ← optional — any binary or text assets
|
||||
└── config.json
|
||||
|
||||
Based on:
|
||||
- docs/specification.md — AgentSkillAdapter, progressive disclosure, ADR-028
|
||||
- Aditya_standards_and_tasks.md — Task M2.4a
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Frontmatter delimiter in SKILL.md files.
|
||||
_FRONTMATTER_DELIMITER = "---"
|
||||
|
||||
#: Required frontmatter fields.
|
||||
_REQUIRED_FIELDS: tuple[str, ...] = ("name", "description")
|
||||
|
||||
#: Pattern for ``namespace/short_name`` (alphanumeric, hyphens, underscores).
|
||||
_NAMESPACED_NAME_RE = re.compile(
|
||||
r"^[a-zA-Z0-9][a-zA-Z0-9_-]*/[a-zA-Z0-9][a-zA-Z0-9_-]*$"
|
||||
)
|
||||
|
||||
#: Sub-directories discovered as resources.
|
||||
_RESOURCE_DIRS: tuple[str, ...] = ("scripts", "references", "assets")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SkillStep — a single structured step from the frontmatter steps list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SkillStep(BaseModel):
|
||||
"""A single structured step parsed from the SKILL.md frontmatter ``steps`` list.
|
||||
|
||||
Steps are 1-indexed and stored in stable insertion order so that
|
||||
the LLM agent always sees them in the author-intended sequence.
|
||||
|
||||
Attributes:
|
||||
index: 1-based stable position of this step (never reordered).
|
||||
content: Non-empty step instruction text.
|
||||
"""
|
||||
|
||||
index: int = Field(..., ge=1, description="1-based stable step index")
|
||||
content: str = Field(..., min_length=1, description="Step instruction text")
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentSkillResourceSlot — read-only binding slot for a resource directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentSkillResourceSlot(BaseModel):
|
||||
"""A read-only resource binding slot for an Agent Skill support directory.
|
||||
|
||||
Each discovered sub-directory (``scripts/``, ``references/``,
|
||||
``assets/``) that exists on disk becomes a slot in the tool descriptor.
|
||||
All Agent Skill resource slots are unconditionally **read-only** —
|
||||
the skill's instructions may read from them, never write.
|
||||
|
||||
Attributes:
|
||||
name: Slot identifier (``"scripts"``, ``"references"``,
|
||||
``"assets"``).
|
||||
resource_type: Typed label for the slot
|
||||
(e.g. ``"agent_skill_scripts"``).
|
||||
access: Always ``"read_only"`` for Agent Skills.
|
||||
path: Absolute path to the resource directory on disk.
|
||||
"""
|
||||
|
||||
name: str = Field(..., min_length=1, description="Slot name identifier")
|
||||
resource_type: str = Field(..., min_length=1, description="Resource type label")
|
||||
access: str = Field(
|
||||
default="read_only",
|
||||
description="Access level — always read_only for Agent Skills",
|
||||
)
|
||||
path: str = Field(..., min_length=1, description="Absolute directory path")
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentSkillSpec — parsed SKILL.md frontmatter + body
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentSkillSpec(BaseModel):
|
||||
"""Parsed representation of a SKILL.md file.
|
||||
|
||||
Produced by :meth:`AgentSkillSpec.from_file`. Contains all
|
||||
frontmatter fields and the full Markdown body.
|
||||
|
||||
Attributes:
|
||||
name: Namespaced skill name (e.g. ``local/deploy-staging``).
|
||||
description: Short human-readable description.
|
||||
body: Full Markdown body text (everything after the frontmatter).
|
||||
steps: Ordered list of structured :class:`SkillStep` objects
|
||||
parsed from the optional frontmatter ``steps`` list.
|
||||
version: Semantic version string (default ``"0.0.0"``).
|
||||
compatibility: Compatibility metadata dict (optional).
|
||||
metadata: Arbitrary key/value metadata from frontmatter (optional).
|
||||
allowed_tools: List of tool names the skill may invoke (optional).
|
||||
"""
|
||||
|
||||
name: str = Field(..., min_length=1, description="Namespaced skill name")
|
||||
description: str = Field(
|
||||
..., min_length=1, description="Short description of the skill"
|
||||
)
|
||||
body: str = Field(default="", description="Full Markdown body (instructions)")
|
||||
steps: list[SkillStep] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Ordered structured steps parsed from frontmatter 'steps' list. "
|
||||
"Empty when the frontmatter omits the 'steps' key."
|
||||
),
|
||||
)
|
||||
version: str = Field(default="0.0.0", description="Semantic version of the skill")
|
||||
compatibility: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Compatibility constraints dict",
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Arbitrary metadata key/value pairs",
|
||||
)
|
||||
allowed_tools: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Tools the skill is allowed to invoke",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
# -- Factory ---------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> AgentSkillSpec:
|
||||
"""Parse a SKILL.md file and return an ``AgentSkillSpec``.
|
||||
|
||||
Reads the file at *path*, splits out the YAML frontmatter block
|
||||
between the first two ``---`` delimiters, and parses the body
|
||||
as the remaining Markdown text.
|
||||
|
||||
Args:
|
||||
path: Absolute or relative path to the ``SKILL.md`` file.
|
||||
|
||||
Returns:
|
||||
A validated ``AgentSkillSpec`` instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If *path* does not exist.
|
||||
ValueError: If frontmatter is missing, malformed, or fails
|
||||
validation (missing required fields, invalid namespaced name,
|
||||
invalid steps list).
|
||||
"""
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"SKILL.md not found at path '{path}'")
|
||||
|
||||
raw_text = path.read_text(encoding="utf-8")
|
||||
return cls.from_string(raw_text)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, text: str) -> AgentSkillSpec:
|
||||
"""Parse a SKILL.md string and return an ``AgentSkillSpec``.
|
||||
|
||||
Args:
|
||||
text: Raw SKILL.md content (frontmatter + body).
|
||||
|
||||
Returns:
|
||||
A validated ``AgentSkillSpec`` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If frontmatter is missing, malformed, or fails
|
||||
validation (missing required fields, invalid namespaced name,
|
||||
invalid steps list).
|
||||
"""
|
||||
frontmatter_data, body = _split_frontmatter(text)
|
||||
return _build_spec(frontmatter_data, body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentSkillToolDescriptor — tool-level view of a loaded skill
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentSkillToolDescriptor(BaseModel):
|
||||
"""Lightweight descriptor returned by :meth:`AgentSkillLoader.discover`.
|
||||
|
||||
Tier 1 of progressive disclosure — only metadata, no instructions.
|
||||
|
||||
Attributes:
|
||||
name: Namespaced skill name.
|
||||
description: Short description.
|
||||
source: Always ``"agent_skill"``.
|
||||
read_only: Always ``True`` (default) — skill itself does not write.
|
||||
writes: Always ``False`` (default).
|
||||
body: Empty at discover; populated after :meth:`AgentSkillLoader.activate`.
|
||||
agent_skill_path: Absolute path to the skill folder.
|
||||
allowed_tools: Tools the skill is permitted to invoke.
|
||||
resource_slots: Read-only binding slots for discovered resource
|
||||
directories (``scripts/``, ``references/``, ``assets/``).
|
||||
"""
|
||||
|
||||
name: str = Field(..., min_length=1, description="Namespaced skill name")
|
||||
description: str = Field(..., min_length=1, description="Short description")
|
||||
source: str = Field(default="agent_skill", description="Tool source type")
|
||||
read_only: bool = Field(default=True, description="Skill is read-only by default")
|
||||
writes: bool = Field(default=False, description="Skill does not write by default")
|
||||
body: str = Field(
|
||||
default="",
|
||||
description="Instructions body (empty until activated)",
|
||||
)
|
||||
agent_skill_path: str = Field(
|
||||
default="", description="Absolute path to the skill folder"
|
||||
)
|
||||
allowed_tools: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Tools the skill is permitted to invoke",
|
||||
)
|
||||
resource_slots: list[AgentSkillResourceSlot] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Read-only resource binding slots for discovered resource "
|
||||
"directories (scripts/, references/, assets/)."
|
||||
),
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentSkillLoader — folder discovery + progressive disclosure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentSkillLoader:
|
||||
"""Loads an Agent Skills Standard folder and implements progressive disclosure.
|
||||
|
||||
The loader manages the three-tier disclosure lifecycle:
|
||||
|
||||
1. :meth:`discover` — returns metadata-only descriptor (Tier 1).
|
||||
2. :meth:`activate` — loads the full body into :attr:`active_body` (Tier 2).
|
||||
3. :meth:`list_resources` — returns paths from ``scripts/``, ``references/``,
|
||||
and ``assets/`` sub-directories on demand (Tier 3).
|
||||
4. :meth:`deactivate` — clears :attr:`active_body` to free token budget.
|
||||
|
||||
Use :meth:`to_tool_descriptor` to produce the ``AgentSkillToolDescriptor``
|
||||
view suitable for registration in the Tool Registry.
|
||||
|
||||
Args:
|
||||
spec: Parsed ``AgentSkillSpec`` from ``SKILL.md``.
|
||||
folder: Absolute path to the skill folder.
|
||||
script_paths: Files discovered in ``scripts/`` (sorted).
|
||||
reference_paths: Files discovered in ``references/`` (sorted).
|
||||
asset_paths: Files discovered in ``assets/`` (sorted).
|
||||
resource_slots: Read-only binding slots for discovered directories.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spec: AgentSkillSpec,
|
||||
folder: Path,
|
||||
script_paths: list[Path],
|
||||
reference_paths: list[Path],
|
||||
asset_paths: list[Path],
|
||||
resource_slots: list[AgentSkillResourceSlot] | None = None,
|
||||
) -> None:
|
||||
if spec is None:
|
||||
raise ValueError("spec must not be None")
|
||||
if folder is None:
|
||||
raise ValueError("folder must not be None")
|
||||
|
||||
self._spec = spec
|
||||
self._folder = folder.resolve()
|
||||
self._script_paths = script_paths
|
||||
self._reference_paths = reference_paths
|
||||
self._asset_paths = asset_paths
|
||||
self._resource_slots: list[AgentSkillResourceSlot] = (
|
||||
resource_slots if resource_slots is not None else []
|
||||
)
|
||||
self._active_body: str | None = None
|
||||
|
||||
# -- Properties --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def spec(self) -> AgentSkillSpec:
|
||||
"""The parsed ``AgentSkillSpec``."""
|
||||
return self._spec
|
||||
|
||||
@property
|
||||
def folder(self) -> Path:
|
||||
"""Resolved absolute path to the skill folder."""
|
||||
return self._folder
|
||||
|
||||
@property
|
||||
def script_paths(self) -> list[Path]:
|
||||
"""Sorted list of files found in the ``scripts/`` sub-directory."""
|
||||
return self._script_paths
|
||||
|
||||
@property
|
||||
def reference_paths(self) -> list[Path]:
|
||||
"""Sorted list of files found in the ``references/`` sub-directory."""
|
||||
return self._reference_paths
|
||||
|
||||
@property
|
||||
def asset_paths(self) -> list[Path]:
|
||||
"""Sorted list of files found in the ``assets/`` sub-directory."""
|
||||
return self._asset_paths
|
||||
|
||||
@property
|
||||
def active_body(self) -> str | None:
|
||||
"""The full SKILL.md body once activated; ``None`` before activation
|
||||
or after deactivation."""
|
||||
return self._active_body
|
||||
|
||||
@property
|
||||
def resource_slots(self) -> list[AgentSkillResourceSlot]:
|
||||
"""Read-only binding slots for discovered resource directories."""
|
||||
return self._resource_slots
|
||||
|
||||
# -- Factory -----------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_folder(cls, folder: Path) -> AgentSkillLoader:
|
||||
"""Load an Agent Skills folder.
|
||||
|
||||
Expects a ``SKILL.md`` file at the root of *folder*. Discovers
|
||||
``scripts/``, ``references/``, and ``assets/`` sub-directories if
|
||||
present. Builds a read-only resource binding slot for each
|
||||
sub-directory that exists.
|
||||
|
||||
Args:
|
||||
folder: Path to the Agent Skills Standard folder.
|
||||
|
||||
Returns:
|
||||
A new ``AgentSkillLoader`` instance ready for progressive
|
||||
disclosure.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If *folder* does not exist.
|
||||
ValueError: If ``SKILL.md`` is not found in *folder*, or if
|
||||
the ``SKILL.md`` fails validation.
|
||||
"""
|
||||
if folder is None:
|
||||
raise ValueError("folder must not be None")
|
||||
|
||||
resolved = folder.resolve()
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Agent Skills folder not found: '{folder}'")
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f"Agent Skills path is not a directory: '{folder}'")
|
||||
|
||||
skill_md = resolved / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
raise ValueError(f"SKILL.md not found in agent skills folder '{folder}'")
|
||||
|
||||
spec = AgentSkillSpec.from_file(skill_md)
|
||||
|
||||
script_paths = _discover_subdir(resolved, "scripts")
|
||||
reference_paths = _discover_subdir(resolved, "references")
|
||||
asset_paths = _discover_subdir(resolved, "assets")
|
||||
|
||||
resource_slots = _build_resource_slots(resolved)
|
||||
|
||||
logger.debug(
|
||||
"Loaded agent skill '%s' from '%s' "
|
||||
"(scripts=%d, references=%d, assets=%d, slots=%d)",
|
||||
spec.name,
|
||||
resolved,
|
||||
len(script_paths),
|
||||
len(reference_paths),
|
||||
len(asset_paths),
|
||||
len(resource_slots),
|
||||
)
|
||||
|
||||
return cls(
|
||||
spec=spec,
|
||||
folder=resolved,
|
||||
script_paths=script_paths,
|
||||
reference_paths=reference_paths,
|
||||
asset_paths=asset_paths,
|
||||
resource_slots=resource_slots,
|
||||
)
|
||||
|
||||
# -- Progressive Disclosure lifecycle ----------------------------------
|
||||
|
||||
def discover(self) -> AgentSkillToolDescriptor:
|
||||
"""Tier 1 — return metadata-only descriptor.
|
||||
|
||||
Returns only ``name``, ``description``, and capability flags.
|
||||
The body is **not** loaded; this is cheap (< 100 tokens).
|
||||
|
||||
Returns:
|
||||
An :class:`AgentSkillToolDescriptor` with ``body=""`` and all
|
||||
metadata fields populated, including resource binding slots.
|
||||
"""
|
||||
return AgentSkillToolDescriptor(
|
||||
name=self._spec.name,
|
||||
description=self._spec.description,
|
||||
source="agent_skill",
|
||||
read_only=True,
|
||||
writes=False,
|
||||
body="",
|
||||
agent_skill_path=str(self._folder),
|
||||
allowed_tools=list(self._spec.allowed_tools),
|
||||
resource_slots=list(self._resource_slots),
|
||||
)
|
||||
|
||||
def activate(self) -> AgentSkillToolDescriptor:
|
||||
"""Tier 2 — load the full SKILL.md body into active context.
|
||||
|
||||
Returns:
|
||||
An :class:`AgentSkillToolDescriptor` with ``body`` populated
|
||||
with the full Markdown instructions.
|
||||
"""
|
||||
self._active_body = self._spec.body
|
||||
logger.debug("Activated agent skill '%s'", self._spec.name)
|
||||
return AgentSkillToolDescriptor(
|
||||
name=self._spec.name,
|
||||
description=self._spec.description,
|
||||
source="agent_skill",
|
||||
read_only=True,
|
||||
writes=False,
|
||||
body=self._active_body,
|
||||
agent_skill_path=str(self._folder),
|
||||
allowed_tools=list(self._spec.allowed_tools),
|
||||
resource_slots=list(self._resource_slots),
|
||||
)
|
||||
|
||||
def list_resources(self) -> list[Path]:
|
||||
"""Tier 3 — return all resource file paths on demand.
|
||||
|
||||
Returns files from ``scripts/``, ``references/``, and ``assets/``
|
||||
sub-directories, sorted alphabetically.
|
||||
|
||||
Returns:
|
||||
Combined sorted list of all resource file paths.
|
||||
"""
|
||||
return sorted(self._script_paths + self._reference_paths + self._asset_paths)
|
||||
|
||||
def deactivate(self) -> None:
|
||||
"""Clear the active body to free token budget.
|
||||
|
||||
After deactivation, :attr:`active_body` returns ``None``. The
|
||||
skill can be re-activated by calling :meth:`activate` again.
|
||||
"""
|
||||
self._active_body = None
|
||||
logger.debug("Deactivated agent skill '%s'", self._spec.name)
|
||||
|
||||
# -- Tool descriptor mapping -------------------------------------------
|
||||
|
||||
def to_tool_descriptor(self) -> AgentSkillToolDescriptor:
|
||||
"""Convert this loader to a tool descriptor for Tool Registry use.
|
||||
|
||||
Produces a descriptor with ``source="agent_skill"``, ``read_only=True``,
|
||||
``writes=False``, and read-only ``resource_slots`` for every
|
||||
discovered resource directory.
|
||||
|
||||
Returns:
|
||||
An :class:`AgentSkillToolDescriptor` ready for Tool Registry
|
||||
registration.
|
||||
"""
|
||||
return AgentSkillToolDescriptor(
|
||||
name=self._spec.name,
|
||||
description=self._spec.description,
|
||||
source="agent_skill",
|
||||
read_only=True,
|
||||
writes=False,
|
||||
body=self._active_body or "",
|
||||
agent_skill_path=str(self._folder),
|
||||
allowed_tools=list(self._spec.allowed_tools),
|
||||
resource_slots=list(self._resource_slots),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""Split a SKILL.md string into frontmatter dict and body text.
|
||||
|
||||
The YAML frontmatter must be enclosed between two ``---`` delimiters
|
||||
at the very start of the file.
|
||||
|
||||
Args:
|
||||
text: Raw SKILL.md content.
|
||||
|
||||
Returns:
|
||||
A 2-tuple of (frontmatter_dict, body_text).
|
||||
|
||||
Raises:
|
||||
ValueError: If the frontmatter delimiter is missing, or the YAML
|
||||
block cannot be parsed.
|
||||
"""
|
||||
stripped = text.strip()
|
||||
if not stripped.startswith(_FRONTMATTER_DELIMITER):
|
||||
raise ValueError(
|
||||
"SKILL.md is missing YAML frontmatter. "
|
||||
"The file must start with '---' followed by a YAML block "
|
||||
"and a closing '---' delimiter."
|
||||
)
|
||||
|
||||
# Find the closing delimiter (skipping the opening one)
|
||||
rest = stripped[len(_FRONTMATTER_DELIMITER) :]
|
||||
delimiter_pos = rest.find(f"\n{_FRONTMATTER_DELIMITER}")
|
||||
if delimiter_pos == -1:
|
||||
raise ValueError(
|
||||
"SKILL.md frontmatter is not properly closed. "
|
||||
"Expected a closing '---' delimiter after the YAML block."
|
||||
)
|
||||
|
||||
yaml_block = rest[:delimiter_pos].strip()
|
||||
body_raw = rest[delimiter_pos + len(f"\n{_FRONTMATTER_DELIMITER}") :]
|
||||
body = body_raw.lstrip("\n")
|
||||
|
||||
if not yaml_block:
|
||||
raise ValueError(
|
||||
"SKILL.md frontmatter block is empty. "
|
||||
"Add at least 'name' and 'description' fields."
|
||||
)
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(yaml_block)
|
||||
except yaml.YAMLError as exc:
|
||||
raise ValueError(f"SKILL.md frontmatter YAML is invalid: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(
|
||||
"SKILL.md frontmatter must be a YAML mapping (key: value pairs)."
|
||||
)
|
||||
|
||||
return data, body
|
||||
|
||||
|
||||
def _build_spec(data: dict[str, Any], body: str) -> AgentSkillSpec:
|
||||
"""Validate frontmatter data and build an ``AgentSkillSpec``.
|
||||
|
||||
Args:
|
||||
data: Parsed frontmatter dict.
|
||||
body: Markdown body text.
|
||||
|
||||
Returns:
|
||||
A validated ``AgentSkillSpec``.
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing, the name is not in
|
||||
``namespace/short_name`` format, or the ``steps`` list is
|
||||
invalid.
|
||||
"""
|
||||
# Check required fields
|
||||
for field in _REQUIRED_FIELDS:
|
||||
if field not in data or not data[field]:
|
||||
raise ValueError(
|
||||
f"SKILL.md frontmatter is missing required field '{field}'. "
|
||||
f"Add '{field}: <value>' to the YAML block."
|
||||
)
|
||||
|
||||
name: str = str(data["name"]).strip()
|
||||
description: str = str(data["description"]).strip()
|
||||
|
||||
# Validate namespaced name format
|
||||
if not _NAMESPACED_NAME_RE.match(name):
|
||||
raise ValueError(
|
||||
f"SKILL.md 'name' must follow the namespace/short_name format "
|
||||
f"(alphanumeric, hyphens, underscores only), got: '{name}'. "
|
||||
"Example: 'local/deploy-staging'"
|
||||
)
|
||||
|
||||
# Parse optional structured steps with validation
|
||||
steps = _parse_steps(data)
|
||||
|
||||
version: str = str(data.get("version", "0.0.0"))
|
||||
compatibility: dict[str, Any] = data.get("compatibility") or {}
|
||||
metadata: dict[str, Any] = data.get("metadata") or {}
|
||||
|
||||
# allowed-tools: YAML key uses a hyphen so check both forms
|
||||
raw_allowed = data.get("allowed-tools") or data.get("allowed_tools") or []
|
||||
allowed_tools: list[str] = [str(t) for t in raw_allowed]
|
||||
|
||||
if not isinstance(compatibility, dict):
|
||||
compatibility = {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
return AgentSkillSpec(
|
||||
name=name,
|
||||
description=description,
|
||||
body=body,
|
||||
steps=steps,
|
||||
version=version,
|
||||
compatibility=compatibility,
|
||||
metadata=metadata,
|
||||
allowed_tools=allowed_tools,
|
||||
)
|
||||
|
||||
|
||||
def _parse_steps(data: dict[str, Any]) -> list[SkillStep]:
|
||||
"""Parse the optional ``steps`` list from frontmatter data.
|
||||
|
||||
Extracts and validates the ``steps`` key when present. Each entry
|
||||
must be a non-empty string. Steps are returned in stable insertion
|
||||
order with 1-based integer indices.
|
||||
|
||||
Args:
|
||||
data: Parsed frontmatter dict.
|
||||
|
||||
Returns:
|
||||
Ordered list of :class:`SkillStep` objects. Empty list when the
|
||||
``steps`` key is absent from the frontmatter.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``steps`` is present but is not a list, or if any
|
||||
individual step entry is empty or non-string.
|
||||
"""
|
||||
raw = data.get("steps")
|
||||
if raw is None:
|
||||
return []
|
||||
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(
|
||||
"SKILL.md frontmatter 'steps' must be a list of step strings. "
|
||||
"Example:\nsteps:\n - Run tests\n - Deploy to staging"
|
||||
)
|
||||
|
||||
steps: list[SkillStep] = []
|
||||
for idx, entry in enumerate(raw, start=1):
|
||||
content = str(entry).strip() if entry is not None else ""
|
||||
if not content:
|
||||
raise ValueError(
|
||||
f"SKILL.md frontmatter 'steps[{idx}]' must not be empty. "
|
||||
"Provide a non-empty step description."
|
||||
)
|
||||
steps.append(SkillStep(index=idx, content=content))
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def _build_resource_slots(folder: Path) -> list[AgentSkillResourceSlot]:
|
||||
"""Build read-only resource binding slots for discovered sub-directories.
|
||||
|
||||
Creates one :class:`AgentSkillResourceSlot` for each of
|
||||
``scripts/``, ``references/``, and ``assets/`` that exists inside
|
||||
*folder*. Slots are always ``access="read_only"``.
|
||||
|
||||
Args:
|
||||
folder: Resolved absolute path to the skill folder.
|
||||
|
||||
Returns:
|
||||
List of :class:`AgentSkillResourceSlot` instances, one per
|
||||
existing sub-directory, in ``_RESOURCE_DIRS`` order.
|
||||
"""
|
||||
slots: list[AgentSkillResourceSlot] = []
|
||||
for subdir in _RESOURCE_DIRS:
|
||||
sub_path = folder / subdir
|
||||
if sub_path.exists() and sub_path.is_dir():
|
||||
slots.append(
|
||||
AgentSkillResourceSlot(
|
||||
name=subdir,
|
||||
resource_type=f"agent_skill_{subdir}",
|
||||
access="read_only",
|
||||
path=str(sub_path),
|
||||
)
|
||||
)
|
||||
return slots
|
||||
|
||||
|
||||
def _discover_subdir(folder: Path, subdir: str) -> list[Path]:
|
||||
"""Discover files in a sub-directory of *folder*.
|
||||
|
||||
Returns paths that are:
|
||||
- Within ``folder / subdir``
|
||||
- Regular files (not directories)
|
||||
- Sorted alphabetically by full path
|
||||
- Expressed as absolute paths (within the skill folder)
|
||||
|
||||
Args:
|
||||
folder: Resolved absolute path to the skill folder.
|
||||
subdir: Sub-directory name (``"scripts"``, ``"references"``,
|
||||
``"assets"``).
|
||||
|
||||
Returns:
|
||||
Sorted list of absolute ``Path`` objects. Empty list if the
|
||||
sub-directory does not exist.
|
||||
"""
|
||||
sub_path = folder / subdir
|
||||
if not sub_path.exists() or not sub_path.is_dir():
|
||||
return []
|
||||
|
||||
files: list[Path] = []
|
||||
for item in sorted(sub_path.rglob("*")):
|
||||
if item.is_file():
|
||||
files.append(item)
|
||||
|
||||
return files
|
||||
Reference in New Issue
Block a user