cb82fc51df
Implemented AgentSkillSpec loader that parses SKILL.md frontmatter and progressive disclosure sections (discover/activate/deactivate) into structured SkillStep objects with stable 1-based ordering. Mapped Agent Skills to AgentSkillToolDescriptor with namespaced naming (namespace/short_name), source="agent_skill", read-only defaults, and AgentSkillResourceSlot bindings for scripts/, references/, and assets/ directories. All resource slots are unconditionally read_only. Added explicit validation for missing frontmatter fields (name, description) and invalid namespace format with actionable error messages. Added docs/reference/agent_skills.md covering folder layout, SKILL.md parsing rules, progressive disclosure model, and tool mapping. Added Behave scenarios covering valid/invalid SKILL.md parsing, namespaced naming, step ordering, missing frontmatter errors, progressive disclosure lifecycle, tool mapping, and resource binding slots. Added Robot Framework integration tests using the deploy-to-staging example skill folder (robot/agent_skills_loader.robot). Added ASV benchmarks for parsing throughput, folder load, progressive disclosure lifecycle, and resource listing (benchmarks/agent_skills_loader_bench.py). ISSUES CLOSED: #160
787 lines
30 KiB
Python
787 lines
30 KiB
Python
"""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}"
|
|
)
|