Files
cleveragents-core/features/steps/agent_skills_loader_steps.py
aditya cb82fc51df feat(skill): add agent skills loader
Implemented AgentSkillSpec loader that parses SKILL.md frontmatter and
progressive disclosure sections (discover/activate/deactivate) into
structured SkillStep objects with stable 1-based ordering.

Mapped Agent Skills to AgentSkillToolDescriptor with namespaced naming
(namespace/short_name), source="agent_skill", read-only defaults, and
AgentSkillResourceSlot bindings for scripts/, references/, and assets/
directories. All resource slots are unconditionally read_only.

Added explicit validation for missing frontmatter fields (name,
description) and invalid namespace format with actionable error messages.

Added docs/reference/agent_skills.md covering folder layout, SKILL.md
parsing rules, progressive disclosure model, and tool mapping.

Added Behave scenarios covering valid/invalid SKILL.md parsing,
namespaced naming, step ordering, missing frontmatter errors, progressive
disclosure lifecycle, tool mapping, and resource binding slots.

Added Robot Framework integration tests using the deploy-to-staging
example skill folder (robot/agent_skills_loader.robot).

Added ASV benchmarks for parsing throughput, folder load, progressive
disclosure lifecycle, and resource listing
(benchmarks/agent_skills_loader_bench.py).

ISSUES CLOSED: #160
2026-02-26 09:10:35 +00:00

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}"
)