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:
2026-02-26 09:10:35 +00:00
parent e3fcce413b
commit cb82fc51df
11 changed files with 3143 additions and 0 deletions
+219
View File
@@ -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()
+251
View File
@@ -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 | ~50100 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())
+695
View File
@@ -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"
+786
View File
@@ -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}"
)
+88
View File
@@ -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
+220
View File
@@ -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())
+18
View File
@@ -6,14 +6,27 @@ as well as the skill protocol types used for discovery, composition,
execution, and error reporting within the skill framework, the execution, and error reporting within the skill framework, the
runtime context, skill registry, and inline tool executor. 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:: Usage::
from cleveragents.skills.schema import SkillConfigSchema from cleveragents.skills.schema import SkillConfigSchema
from cleveragents.skills.protocol import SkillDefinition from cleveragents.skills.protocol import SkillDefinition
from cleveragents.skills.agent_skills_loader import AgentSkillLoader
config = SkillConfigSchema.from_yaml_file("examples/skills/single-tool.yaml") 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 ( from cleveragents.skills.context import (
SkillContext, SkillContext,
SkillExecutionError, SkillExecutionError,
@@ -45,6 +58,10 @@ from cleveragents.skills.registry import SkillRegistry
from cleveragents.skills.schema import SkillConfigSchema from cleveragents.skills.schema import SkillConfigSchema
__all__ = [ __all__ = [
"AgentSkillLoader",
"AgentSkillResourceSlot",
"AgentSkillSpec",
"AgentSkillToolDescriptor",
"DiscoveredAgentSkill", "DiscoveredAgentSkill",
"DiscoveryConflict", "DiscoveryConflict",
"DiscoveryResult", "DiscoveryResult",
@@ -59,6 +76,7 @@ __all__ = [
"SkillMetadata", "SkillMetadata",
"SkillRegistry", "SkillRegistry",
"SkillResult", "SkillResult",
"SkillStep",
"ToolInvocationRecord", "ToolInvocationRecord",
"build_tool_spec", "build_tool_spec",
"discover_agent_skills", "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