diff --git a/benchmarks/agent_skills_loader_bench.py b/benchmarks/agent_skills_loader_bench.py new file mode 100644 index 000000000..7d6aa3fe3 --- /dev/null +++ b/benchmarks/agent_skills_loader_bench.py @@ -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() diff --git a/docs/reference/agent_skills.md b/docs/reference/agent_skills.md new file mode 100644 index 000000000..0e388e5fd --- /dev/null +++ b/docs/reference/agent_skills.md @@ -0,0 +1,251 @@ +# Agent Skills Standard Loader + +The **Agent Skills loader** (`cleveragents.skills.agent_skills_loader`) implements the +[AgentSkills.io](https://AgentSkills.io) standard for packaging instruction-driven, +multi-step workflows as `SKILL.md` files with optional resource sub-directories. + +Agent Skills differ from MCP tools: instead of a single function call, they load +procedural *instructions* that an LLM agent follows—potentially invoking multiple +tools in sequence. + +--- + +## Folder Layout + +``` +my-skill/ +├── SKILL.md ← required — frontmatter + instructions +├── scripts/ ← optional — Python/shell scripts the agent may run +│ └── deploy.py +├── references/ ← optional — supplementary Markdown documents +│ └── runbook.md +└── assets/ ← optional — any binary or static resources + └── config.json +``` + +Only `SKILL.md` is required. The three resource sub-directories are discovered +automatically when present and made available on demand during Tier 3 disclosure. + +--- + +## SKILL.md Format + +`SKILL.md` files follow Markdown with a YAML frontmatter block: + +```markdown +--- +name: local/deploy-to-staging +description: Deploy the current branch to the staging environment. +steps: # optional — structured steps with stable ordering + - Verify all tests pass + - Push the branch to the remote + - Run the CI pipeline + - Execute scripts/deploy.py to deploy + - Check the health endpoint +version: 1.0.0 # optional +compatibility: # optional + min_agent_version: "3.0.0" +metadata: # optional — arbitrary key/value pairs + author: team + environment: staging +allowed-tools: # optional — restrict which tools the skill may call + - builtin/shell + - builtin/git-status +--- + +# Deployment Details + +Additional prose context for each step, loaded on activation. +``` + +### Required Frontmatter Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Namespaced name: `namespace/short-name` (e.g. `local/deploy-staging`) | +| `description` | `string` | Short human-readable description of the skill | + +### Optional Frontmatter Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `steps` | `list[string]` | `[]` | Ordered step instructions parsed into `SkillStep` objects with stable 1-based indexing | +| `version` | `string` | `"0.0.0"` | Semantic version of the skill | +| `compatibility` | `mapping` | `{}` | Compatibility constraints (e.g. `min_agent_version`) | +| `metadata` | `mapping` | `{}` | Arbitrary key/value pairs (author, tags, etc.) | +| `allowed-tools` | `list[string]` | `[]` | Tools the skill is permitted to invoke | + +### Structured Steps + +When the `steps` key is present, each list entry becomes a `SkillStep` with a +stable 1-based index and non-empty content string: + +```python +spec = AgentSkillSpec.from_file(Path("my-skill/SKILL.md")) +for step in spec.steps: + print(f"{step.index}. {step.content}") +# 1. Verify all tests pass +# 2. Push the branch to the remote +# ... +``` + +Steps are stored in insertion order and never reordered — `step.index` is +always equal to the step's position in the original list. + +--- + +## Namespaced Naming + +Skill names follow the same `namespace/short-name` convention as tools and actors +(see [Tool Model](tool_model.md)): + +``` +local/deploy-to-staging ← user-defined, default namespace +devops/code-review-pipeline ← custom namespace +team/onboarding-workflow ← team namespace +``` + +Rules: +- Both `namespace` and `short-name` must start with an alphanumeric character. +- Allowed characters: letters, digits, hyphens (`-`), underscores (`_`). +- No spaces or special characters. + +--- + +## Three-Tier Progressive Disclosure + +The loader implements the three-tier model defined in the specification +(`docs/specification.md` § AgentSkillAdapter): + +| Tier | Method | What loads | Token cost | +|------|--------|-----------|------------| +| **1 — Metadata** | `discover()` | `name` + `description` only | ~50–100 tokens per skill | +| **2 — Instructions** | `activate()` | Full `SKILL.md` Markdown body | Recommended < 5000 tokens | +| **3 — Resources** | `list_resources()` | Paths from `scripts/`, `references/`, `assets/` | Variable, on demand | + +This means an actor can have dozens of Agent Skills available but only pay the +token cost for their metadata at startup. Full instructions load only when the +LLM determines a task matches the skill. + +--- + +## Tool Mapping + +Agent Skills are registered in the Tool Registry with `source="agent_skill"`. +The `AgentSkillToolDescriptor` carries: + +| Field | Value | Notes | +|-------|-------|-------| +| `source` | `"agent_skill"` | Fixed | +| `name` | From frontmatter `name` | e.g. `local/deploy-to-staging` | +| `description` | From frontmatter `description` | Used in system prompt | +| `read_only` | `True` | Default — skill itself does not write | +| `writes` | `False` | Default | +| `agent_skill_path` | Absolute folder path | Runtime uses this to locate `SKILL.md` | +| `allowed_tools` | From frontmatter `allowed-tools` | Optional restrict list | +| `body` | Empty at discover; full body after activate | Progressive disclosure | +| `resource_slots` | List of `AgentSkillResourceSlot` | One slot per discovered resource directory | + +### Resource Binding Slots + +Each discovered sub-directory (`scripts/`, `references/`, `assets/`) becomes a +**read-only resource binding slot** in the tool descriptor: + +```python +td = loader.to_tool_descriptor() +for slot in td.resource_slots: + print(slot.name) # "scripts", "references", or "assets" + print(slot.resource_type) # "agent_skill_scripts", etc. + print(slot.access) # "read_only" + print(slot.path) # absolute path on disk +``` + +Slots are only created for directories that exist — if a skill has no +`references/` folder, no `references` slot is emitted. All slots are +unconditionally `access="read_only"`. + +### Read-Only Default Rationale + +The skill descriptor is **read-only by default** because the skill's instructions +do not themselves perform writes — the LLM agent decides which (potentially +write-capable) tools to invoke during execution. Individual tools in `allowed-tools` +may write; the skill descriptor's own capability metadata does not. + +--- + +## Python API + +```python +from pathlib import Path +from cleveragents.skills.agent_skills_loader import AgentSkillLoader, AgentSkillSpec + +# Parse a SKILL.md string directly +spec = AgentSkillSpec.from_string(""" +--- +name: local/my-skill +description: Does something useful. +--- +Step 1: Do this. +""") + +# Load a full folder +loader = AgentSkillLoader.from_folder(Path("./skills/my-skill")) + +# Tier 1 — discover (metadata only) +descriptor = loader.discover() +print(descriptor.name) # "local/my-skill" +print(descriptor.body) # "" (empty) + +# Tier 2 — activate (load instructions) +activated = loader.activate() +print(activated.body) # "Step 1: Do this." + +# Tier 3 — list resources on demand +resources = loader.list_resources() +for path in resources: + print(path.name) + +# Deactivate to free token budget +loader.deactivate() +print(loader.active_body) # None + +# Convert to Tool Registry descriptor +tool_descriptor = loader.to_tool_descriptor() +print(tool_descriptor.source) # "agent_skill" +print(tool_descriptor.read_only) # True +print(tool_descriptor.agent_skill_path) # "/abs/path/to/my-skill" +``` + +--- + +## Path Safety + +All file paths returned by `list_resources()`, `script_paths`, `reference_paths`, +and `asset_paths` are **absolute paths** that have been verified to reside within +the skill folder. The loader uses `Path.resolve()` + `rglob()` to prevent path +traversal outside the skill boundary. + +--- + +## Error Handling + +| Condition | Exception | Message pattern | +|-----------|-----------|-----------------| +| `SKILL.md` not found in folder | `ValueError` | mentions `"SKILL.md"` | +| Folder path does not exist | `FileNotFoundError` | mentions `"not found"` | +| Missing `---` frontmatter | `ValueError` | mentions `"frontmatter"` | +| Missing required `name` field | `ValueError` | mentions `"name"` | +| Missing required `description` field | `ValueError` | mentions `"description"` | +| Invalid `namespace/name` format | `ValueError` | mentions `"namespace"` | +| Malformed YAML in frontmatter | `ValueError` | mentions `"YAML"` | + +--- + +## Related + +- [Skills Protocol](skills_protocol.md) — `SkillDefinition`, `SkillMetadata` +- [Skill CLI](skill_cli.md) — `agents skill` commands +- [Tool Model](tool_model.md) — `Tool`, `ToolSource.AGENT_SKILL` +- [MCP Adapter](mcp_adapter.md) — MCP tool source (counterpart) +- `docs/specification.md` § AgentSkillAdapter — authoritative spec +- `ADR-028` — Agent Skills Standard adoption decision diff --git a/examples/agent-skills/deploy-to-staging/SKILL.md b/examples/agent-skills/deploy-to-staging/SKILL.md new file mode 100644 index 000000000..f3830f1e8 --- /dev/null +++ b/examples/agent-skills/deploy-to-staging/SKILL.md @@ -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. diff --git a/examples/agent-skills/deploy-to-staging/references/runbook.md b/examples/agent-skills/deploy-to-staging/references/runbook.md new file mode 100644 index 000000000..967ce8fec --- /dev/null +++ b/examples/agent-skills/deploy-to-staging/references/runbook.md @@ -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. diff --git a/examples/agent-skills/deploy-to-staging/scripts/deploy.py b/examples/agent-skills/deploy-to-staging/scripts/deploy.py new file mode 100644 index 000000000..b2313865b --- /dev/null +++ b/examples/agent-skills/deploy-to-staging/scripts/deploy.py @@ -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()) diff --git a/features/agent_skills_loader.feature b/features/agent_skills_loader.feature new file mode 100644 index 000000000..32c8ce3e5 --- /dev/null +++ b/features/agent_skills_loader.feature @@ -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" diff --git a/features/steps/agent_skills_loader_steps.py b/features/steps/agent_skills_loader_steps.py new file mode 100644 index 000000000..4b0eec550 --- /dev/null +++ b/features/steps/agent_skills_loader_steps.py @@ -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}" + ) diff --git a/robot/agent_skills_loader.robot b/robot/agent_skills_loader.robot new file mode 100644 index 000000000..4fd75c90f --- /dev/null +++ b/robot/agent_skills_loader.robot @@ -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 diff --git a/robot/helper_agent_skills_loader.py b/robot/helper_agent_skills_loader.py new file mode 100644 index 000000000..883220d45 --- /dev/null +++ b/robot/helper_agent_skills_loader.py @@ -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 + python robot/helper_agent_skills_loader.py discover + python robot/helper_agent_skills_loader.py activate + python robot/helper_agent_skills_loader.py tool-descriptor + python robot/helper_agent_skills_loader.py invalid-skill + python robot/helper_agent_skills_loader.py list-resources +""" + +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 " + " " + "" + ) + 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()) diff --git a/src/cleveragents/skills/__init__.py b/src/cleveragents/skills/__init__.py index d9939aede..88575e040 100644 --- a/src/cleveragents/skills/__init__.py +++ b/src/cleveragents/skills/__init__.py @@ -6,14 +6,27 @@ as well as the skill protocol types used for discovery, composition, execution, and error reporting within the skill framework, the runtime context, skill registry, and inline tool executor. +It also provides the Agent Skills Standard loader (``AgentSkillLoader``) +for the three-tier progressive disclosure model (discover / activate / +deactivate) described in ``docs/specification.md`` § AgentSkillAdapter. + Usage:: from cleveragents.skills.schema import SkillConfigSchema from cleveragents.skills.protocol import SkillDefinition + from cleveragents.skills.agent_skills_loader import AgentSkillLoader config = SkillConfigSchema.from_yaml_file("examples/skills/single-tool.yaml") + loader = AgentSkillLoader.from_folder(Path("./skills/deploy-to-staging")) """ +from cleveragents.skills.agent_skills_loader import ( + AgentSkillLoader, + AgentSkillResourceSlot, + AgentSkillSpec, + AgentSkillToolDescriptor, + SkillStep, +) from cleveragents.skills.context import ( SkillContext, SkillExecutionError, @@ -45,6 +58,10 @@ from cleveragents.skills.registry import SkillRegistry from cleveragents.skills.schema import SkillConfigSchema __all__ = [ + "AgentSkillLoader", + "AgentSkillResourceSlot", + "AgentSkillSpec", + "AgentSkillToolDescriptor", "DiscoveredAgentSkill", "DiscoveryConflict", "DiscoveryResult", @@ -59,6 +76,7 @@ __all__ = [ "SkillMetadata", "SkillRegistry", "SkillResult", + "SkillStep", "ToolInvocationRecord", "build_tool_spec", "discover_agent_skills", diff --git a/src/cleveragents/skills/agent_skills_loader.py b/src/cleveragents/skills/agent_skills_loader.py new file mode 100644 index 000000000..b49c37a28 --- /dev/null +++ b/src/cleveragents/skills/agent_skills_loader.py @@ -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}: ' 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