Files
cleveragents-core/features/steps/skill_protocol_steps.py
T

710 lines
26 KiB
Python

"""Step definitions for Skill protocol types tests."""
from typing import Any
from behave import then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.domain.models.core.skill import (
Skill,
SkillAgentSource,
SkillInlineTool,
SkillMcpSource,
SkillResolver,
)
from cleveragents.domain.models.core.tool import (
ToolCapability,
ToolSource,
)
from cleveragents.skills.protocol import (
SkillDefinition,
SkillError,
SkillErrorType,
SkillMetadata,
SkillResult,
map_tool_error,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_skill(**overrides: Any) -> Skill:
"""Create a Skill with sensible defaults, allowing overrides."""
defaults: dict[str, Any] = {
"name": "local/test-skill",
"description": "A test skill",
}
defaults.update(overrides)
return Skill(**defaults)
def _make_metadata_skill_with_write_tools() -> Skill:
"""Create a skill with inline write-capable tools."""
return Skill(
name="local/write-skill",
description="Skill with writes",
anonymous_tools=[
SkillInlineTool(
description="Write tool",
source=ToolSource.CUSTOM,
code="x = 1",
capability=ToolCapability(
writes=True,
side_effects=["filesystem"],
),
),
],
)
def _make_metadata_skill_with_read_only_tools() -> Skill:
"""Create a skill with only read-only inline tools."""
return Skill(
name="local/readonly-skill",
description="Read only skill",
anonymous_tools=[
SkillInlineTool(
description="Read tool",
source=ToolSource.CUSTOM,
code="x = 1",
capability=ToolCapability(read_only=True),
),
],
)
# ---------------------------------------------------------------------------
# SkillErrorType Enum
# ---------------------------------------------------------------------------
@when("I list all skill_protocol error types")
def skill_protocol_list_error_types(context: Context) -> None:
"""Collect all SkillErrorType members."""
context.skill_protocol_error_types = list(SkillErrorType)
@then('the skill_protocol error types should include "{value}"')
def skill_protocol_check_error_type_member(context: Context, value: str) -> None:
"""Check that a specific error type is in the list."""
values = [e.value for e in context.skill_protocol_error_types]
assert value in values, f"Expected '{value}' in {values}"
@then("there should be {count:d} skill_protocol error types")
def skill_protocol_check_error_type_count(context: Context, count: int) -> None:
"""Check total number of error types."""
actual = len(context.skill_protocol_error_types)
assert actual == count, f"Expected {count} error types, got {actual}"
# ---------------------------------------------------------------------------
# SkillError Creation
# ---------------------------------------------------------------------------
@when(
'I create a skill_protocol error with type "{err_type}" and message "{msg}" for skill "{skill_name}"'
)
def skill_protocol_create_error(
context: Context, err_type: str, msg: str, skill_name: str
) -> None:
"""Create a SkillError."""
context.skill_protocol_error = SkillError(
error_type=SkillErrorType(err_type),
message=msg,
skill_name=skill_name,
)
@when('I create a skill_protocol error with tool_name "{tool_name}"')
def skill_protocol_create_error_with_tool(context: Context, tool_name: str) -> None:
"""Create a SkillError with tool_name."""
context.skill_protocol_error = SkillError(
error_type=SkillErrorType.TOOL_EXECUTION_FAILURE,
message="Tool failed",
skill_name="local/test-skill",
tool_name=tool_name,
)
@when("I create a skill_protocol error with details")
def skill_protocol_create_error_with_details(context: Context) -> None:
"""Create a SkillError with details dict."""
context.skill_protocol_error = SkillError(
error_type=SkillErrorType.TOOL_EXECUTION_FAILURE,
message="Tool failed",
skill_name="local/test-skill",
details={"trace_id": "abc-123", "attempt": 1},
)
@then("the skill_protocol error should be created")
def skill_protocol_check_error_created(context: Context) -> None:
"""Verify error was created."""
assert context.skill_protocol_error is not None
@then('the skill_protocol error type should be "{expected}"')
def skill_protocol_check_error_type(context: Context, expected: str) -> None:
"""Check error type value."""
assert context.skill_protocol_error.error_type.value == expected
@then('the skill_protocol error message should be "{expected}"')
def skill_protocol_check_error_message(context: Context, expected: str) -> None:
"""Check error message."""
assert context.skill_protocol_error.message == expected
@then('the skill_protocol error skill_name should be "{expected}"')
def skill_protocol_check_error_skill_name(context: Context, expected: str) -> None:
"""Check error skill_name."""
assert context.skill_protocol_error.skill_name == expected
@then("the skill_protocol error tool_name should be none")
def skill_protocol_check_error_tool_none(context: Context) -> None:
"""Check error tool_name is None."""
assert context.skill_protocol_error.tool_name is None
@then('the skill_protocol error tool_name should be "{expected}"')
def skill_protocol_check_error_tool_name(context: Context, expected: str) -> None:
"""Check error tool_name value."""
assert context.skill_protocol_error.tool_name == expected
@then('the skill_protocol error details should have key "{key}"')
def skill_protocol_check_error_details_key(context: Context, key: str) -> None:
"""Check error details contains a key."""
assert key in context.skill_protocol_error.details
@then("the skill_protocol error should be immutable")
def skill_protocol_check_error_immutable(context: Context) -> None:
"""Verify SkillError is frozen."""
try:
context.skill_protocol_error.message = "changed"
raise AssertionError("Expected frozen model to reject assignment")
except ValidationError:
pass
# ---------------------------------------------------------------------------
# SkillMetadata Creation
# ---------------------------------------------------------------------------
@when("I create a skill_protocol metadata from a basic skill")
def skill_protocol_create_metadata_basic(context: Context) -> None:
"""Create SkillMetadata from a basic skill."""
skill = _make_skill()
context.skill_protocol_metadata = SkillMetadata.from_skill(skill)
@when("I create a skill_protocol metadata from a skill with tool refs")
def skill_protocol_create_metadata_refs(context: Context) -> None:
"""Create SkillMetadata from a skill with tool refs."""
skill = _make_skill(tool_refs=["local/edit-file", "local/read-file"])
context.skill_protocol_metadata = SkillMetadata.from_skill(skill)
@when("I create a skill_protocol metadata from a skill with write tools")
def skill_protocol_create_metadata_write(context: Context) -> None:
"""Create SkillMetadata from a skill with write tools."""
skill = _make_metadata_skill_with_write_tools()
context.skill_protocol_metadata = SkillMetadata.from_skill(skill)
@when("I create a skill_protocol metadata from a skill with mcp sources")
def skill_protocol_create_metadata_mcp(context: Context) -> None:
"""Create SkillMetadata from a skill with MCP sources."""
skill = Skill(
name="local/mcp-skill",
description="MCP skill",
mcp_servers=[SkillMcpSource(server="npx @mcp/fs", tools=["read"])],
)
context.skill_protocol_metadata = SkillMetadata.from_skill(skill)
@when("I create a skill_protocol metadata from a skill with agent skill sources")
def skill_protocol_create_metadata_agent(context: Context) -> None:
"""Create SkillMetadata from a skill with agent skill sources."""
skill = Skill(
name="local/agent-skill",
description="Agent skill",
agent_skills=[SkillAgentSource(path="./skills/review")],
)
context.skill_protocol_metadata = SkillMetadata.from_skill(skill)
@when('I create a skill_protocol metadata with version "{version}"')
def skill_protocol_create_metadata_version(context: Context, version: str) -> None:
"""Create SkillMetadata with a specific version."""
skill = _make_skill()
context.skill_protocol_metadata = SkillMetadata.from_skill(skill, version=version)
@when("I create a skill_protocol metadata from a skill with read_only tools")
def skill_protocol_create_metadata_readonly(context: Context) -> None:
"""Create SkillMetadata from a skill with read-only tools."""
skill = _make_metadata_skill_with_read_only_tools()
context.skill_protocol_metadata = SkillMetadata.from_skill(skill)
@when("I create a skill_protocol metadata from an empty skill")
def skill_protocol_create_metadata_empty(context: Context) -> None:
"""Create SkillMetadata from an empty skill."""
skill = _make_skill()
context.skill_protocol_metadata = SkillMetadata.from_skill(skill)
@when("I try to create skill_protocol metadata from None")
def skill_protocol_create_metadata_none(context: Context) -> None:
"""Try to create SkillMetadata from None."""
context.skill_protocol_value_error = None
try:
SkillMetadata.from_skill(None) # type: ignore[arg-type]
except ValueError as e:
context.skill_protocol_value_error = e
@then("the skill_protocol metadata should be created")
def skill_protocol_check_metadata_created(context: Context) -> None:
"""Verify metadata was created."""
assert context.skill_protocol_metadata is not None
@then('the skill_protocol metadata name should be "{expected}"')
def skill_protocol_check_metadata_name(context: Context, expected: str) -> None:
"""Check metadata name."""
assert context.skill_protocol_metadata.name == expected
@then('the skill_protocol metadata description should be "{expected}"')
def skill_protocol_check_metadata_desc(context: Context, expected: str) -> None:
"""Check metadata description."""
assert context.skill_protocol_metadata.description == expected
@then('the skill_protocol metadata version should be "{expected}"')
def skill_protocol_check_metadata_version(context: Context, expected: str) -> None:
"""Check metadata version."""
assert context.skill_protocol_metadata.version == expected
@then("the skill_protocol metadata read_only should be true")
def skill_protocol_check_metadata_readonly_true(context: Context) -> None:
"""Check metadata read_only is True."""
assert context.skill_protocol_metadata.read_only is True
@then("the skill_protocol metadata read_only should be false")
def skill_protocol_check_metadata_readonly_false(context: Context) -> None:
"""Check metadata read_only is False."""
assert context.skill_protocol_metadata.read_only is False
@then("the skill_protocol metadata writes should be true")
def skill_protocol_check_metadata_writes_true(context: Context) -> None:
"""Check metadata writes is True."""
assert context.skill_protocol_metadata.writes is True
@then("the skill_protocol metadata writes should be false")
def skill_protocol_check_metadata_writes_false(context: Context) -> None:
"""Check metadata writes is False."""
assert context.skill_protocol_metadata.writes is False
@then("the skill_protocol metadata tool_count should be {expected:d}")
def skill_protocol_check_metadata_tool_count(context: Context, expected: int) -> None:
"""Check metadata tool_count."""
actual = context.skill_protocol_metadata.tool_count
assert actual == expected, f"Expected {expected} tools, got {actual}"
@then('the skill_protocol metadata source_types should contain "{expected}"')
def skill_protocol_check_metadata_source_type(context: Context, expected: str) -> None:
"""Check metadata source_types contains a value."""
assert expected in context.skill_protocol_metadata.source_types, (
f"Expected '{expected}' in {context.skill_protocol_metadata.source_types}"
)
@then("the skill_protocol metadata should be immutable")
def skill_protocol_check_metadata_immutable(context: Context) -> None:
"""Verify SkillMetadata is frozen."""
try:
context.skill_protocol_metadata.name = "changed"
raise AssertionError("Expected frozen model to reject assignment")
except ValidationError:
pass
# ---------------------------------------------------------------------------
# SkillDefinition
# ---------------------------------------------------------------------------
@when("I create a skill_protocol definition with resolved tools")
def skill_protocol_create_definition(context: Context) -> None:
"""Create a SkillDefinition with resolved tools."""
skill = Skill(
name="local/def-skill",
description="Definition skill",
tool_refs=["local/my-tool"],
)
resolver = SkillResolver()
resolved = resolver.resolve_tools(skill, {})
metadata = SkillMetadata.from_skill(skill, resolved=resolved)
context.skill_protocol_definition = SkillDefinition(
skill=skill,
resolved_tools=resolved,
metadata=metadata,
)
@when("I create a skill_protocol definition with input and output schemas")
def skill_protocol_create_definition_schemas(context: Context) -> None:
"""Create a SkillDefinition with schemas."""
skill = Skill(
name="local/schema-skill",
description="Schema skill",
tool_refs=["local/my-tool"],
)
resolver = SkillResolver()
resolved = resolver.resolve_tools(skill, {})
metadata = SkillMetadata.from_skill(skill, resolved=resolved)
context.skill_protocol_definition = SkillDefinition(
skill=skill,
resolved_tools=resolved,
metadata=metadata,
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
output_schema={"type": "object", "properties": {"result": {"type": "string"}}},
)
@when("I try to create a skill_protocol definition with invalid input_schema")
def skill_protocol_create_definition_invalid_input(context: Context) -> None:
"""Try to create a SkillDefinition with invalid input_schema."""
skill = _make_skill()
metadata = SkillMetadata.from_skill(skill)
context.skill_protocol_validation_error = None
try:
SkillDefinition(
skill=skill,
resolved_tools=[],
metadata=metadata,
input_schema={"properties": {"x": {"type": "string"}}}, # missing "type"
)
except ValidationError as e:
context.skill_protocol_validation_error = e
@when("I try to create a skill_protocol definition with invalid output_schema")
def skill_protocol_create_definition_invalid_output(context: Context) -> None:
"""Try to create a SkillDefinition with invalid output_schema."""
skill = _make_skill()
metadata = SkillMetadata.from_skill(skill)
context.skill_protocol_validation_error = None
try:
SkillDefinition(
skill=skill,
resolved_tools=[],
metadata=metadata,
output_schema={"description": "no type key"},
)
except ValidationError as e:
context.skill_protocol_validation_error = e
@when("I try to create a skill_protocol definition with writes inconsistency")
def skill_protocol_create_definition_writes_inconsistency(context: Context) -> None:
"""Try to create a SkillDefinition where metadata says read_only but tools have writes."""
skill = Skill(
name="local/inconsistent",
description="Inconsistent skill",
anonymous_tools=[
SkillInlineTool(
description="Writer",
source=ToolSource.CUSTOM,
code="x = 1",
capability=ToolCapability(writes=True, side_effects=["fs"]),
),
],
)
# Build resolved tools -- they include a write-capable inline tool
resolver = SkillResolver()
resolved = resolver.resolve_tools(skill, {})
# Force metadata to claim read_only=True (bypassing from_skill)
metadata = SkillMetadata(
name=skill.name,
description=skill.description,
tool_count=len(resolved),
writes=False,
read_only=True,
)
context.skill_protocol_validation_error = None
try:
SkillDefinition(
skill=skill,
resolved_tools=resolved,
metadata=metadata,
)
except ValidationError as e:
context.skill_protocol_validation_error = e
@then("the skill_protocol definition should be created")
def skill_protocol_check_definition_created(context: Context) -> None:
"""Verify definition was created."""
assert context.skill_protocol_definition is not None
@then('the skill_protocol definition skill name should be "{expected}"')
def skill_protocol_check_definition_name(context: Context, expected: str) -> None:
"""Check definition skill name."""
assert context.skill_protocol_definition.skill.name == expected
@then("the skill_protocol definition should have {count:d} resolved tools")
def skill_protocol_check_definition_tool_count(context: Context, count: int) -> None:
"""Check definition resolved_tools count."""
actual = len(context.skill_protocol_definition.resolved_tools)
assert actual == count, f"Expected {count}, got {actual}"
@then('the skill_protocol definition input_schema should have key "{key}"')
def skill_protocol_check_definition_input_key(context: Context, key: str) -> None:
"""Check definition input_schema has key."""
assert context.skill_protocol_definition.input_schema is not None
assert key in context.skill_protocol_definition.input_schema
@then('the skill_protocol definition output_schema should have key "{key}"')
def skill_protocol_check_definition_output_key(context: Context, key: str) -> None:
"""Check definition output_schema has key."""
assert context.skill_protocol_definition.output_schema is not None
assert key in context.skill_protocol_definition.output_schema
@then("a skill_protocol validation error should be raised")
def skill_protocol_check_validation_error(context: Context) -> None:
"""Verify a validation error was raised."""
assert context.skill_protocol_validation_error is not None, (
"Expected a validation error"
)
@then("the skill_protocol definition should be immutable")
def skill_protocol_check_definition_immutable(context: Context) -> None:
"""Verify SkillDefinition is frozen."""
try:
context.skill_protocol_definition.input_schema = {"type": "object"}
raise AssertionError("Expected frozen model to reject assignment")
except ValidationError:
pass
# ---------------------------------------------------------------------------
# SkillResult
# ---------------------------------------------------------------------------
@when("I create a skill_protocol result with success")
def skill_protocol_create_result_success(context: Context) -> None:
"""Create a successful SkillResult."""
context.skill_protocol_result = SkillResult(
skill_name="local/my-skill",
tool_name="local/my-tool",
success=True,
output_data="hello",
)
@when("I create a skill_protocol result with failure")
def skill_protocol_create_result_failure(context: Context) -> None:
"""Create a failed SkillResult."""
err = SkillError(
error_type=SkillErrorType.TOOL_EXECUTION_FAILURE,
message="Tool timed out",
skill_name="local/my-skill",
tool_name="local/my-tool",
)
context.skill_protocol_result = SkillResult(
skill_name="local/my-skill",
tool_name="local/my-tool",
success=False,
error=err,
)
@when("I create a skill_protocol result with duration and resource changes")
def skill_protocol_create_result_duration(context: Context) -> None:
"""Create a SkillResult with duration and resource changes."""
context.skill_protocol_result = SkillResult(
skill_name="local/my-skill",
tool_name="local/my-tool",
success=True,
duration_ms=150.5,
resource_changes=[{"type": "file_modified", "path": "/tmp/test.txt"}],
)
@then("the skill_protocol result should be created")
def skill_protocol_check_result_created(context: Context) -> None:
"""Verify result was created."""
assert context.skill_protocol_result is not None
@then("the skill_protocol result success should be true")
def skill_protocol_check_result_success_true(context: Context) -> None:
"""Check result success is True."""
assert context.skill_protocol_result.success is True
@then("the skill_protocol result success should be false")
def skill_protocol_check_result_success_false(context: Context) -> None:
"""Check result success is False."""
assert context.skill_protocol_result.success is False
@then('the skill_protocol result skill_name should be "{expected}"')
def skill_protocol_check_result_skill(context: Context, expected: str) -> None:
"""Check result skill_name."""
assert context.skill_protocol_result.skill_name == expected
@then('the skill_protocol result tool_name should be "{expected}"')
def skill_protocol_check_result_tool(context: Context, expected: str) -> None:
"""Check result tool_name."""
assert context.skill_protocol_result.tool_name == expected
@then("the skill_protocol result error should be none")
def skill_protocol_check_result_error_none(context: Context) -> None:
"""Check result error is None."""
assert context.skill_protocol_result.error is None
@then("the skill_protocol result error should not be none")
def skill_protocol_check_result_error_present(context: Context) -> None:
"""Check result error is not None."""
assert context.skill_protocol_result.error is not None
@then('the skill_protocol result error type should be "{expected}"')
def skill_protocol_check_result_error_type(context: Context, expected: str) -> None:
"""Check result error type."""
assert context.skill_protocol_result.error is not None
assert context.skill_protocol_result.error.error_type.value == expected
@then('the skill_protocol result output_data should be "{expected}"')
def skill_protocol_check_result_output(context: Context, expected: str) -> None:
"""Check result output_data."""
assert context.skill_protocol_result.output_data == expected
@then("the skill_protocol result duration_ms should be {expected:g}")
def skill_protocol_check_result_duration(context: Context, expected: float) -> None:
"""Check result duration_ms."""
assert context.skill_protocol_result.duration_ms == expected
@then("the skill_protocol result should have {count:d} resource changes")
def skill_protocol_check_result_changes(context: Context, count: int) -> None:
"""Check result resource_changes count."""
actual = len(context.skill_protocol_result.resource_changes)
assert actual == count, f"Expected {count}, got {actual}"
@then("the skill_protocol result should be immutable")
def skill_protocol_check_result_immutable(context: Context) -> None:
"""Verify SkillResult is frozen."""
try:
context.skill_protocol_result.success = False
raise AssertionError("Expected frozen model to reject assignment")
except ValidationError:
pass
# ---------------------------------------------------------------------------
# Error Mapping
# ---------------------------------------------------------------------------
@when('I map a skill_protocol ValueError with message "{msg}"')
def skill_protocol_map_value_error(context: Context, msg: str) -> None:
"""Map a ValueError through map_tool_error."""
context.skill_protocol_mapped_error = map_tool_error(
ValueError(msg), skill_name="local/test-skill"
)
@when("I map a skill_protocol PermissionError")
def skill_protocol_map_permission_error(context: Context) -> None:
"""Map a PermissionError through map_tool_error."""
context.skill_protocol_mapped_error = map_tool_error(
PermissionError("Access denied"), skill_name="local/test-skill"
)
@when('I map a skill_protocol RuntimeError with message "{msg}"')
def skill_protocol_map_runtime_error(context: Context, msg: str) -> None:
"""Map a RuntimeError through map_tool_error."""
context.skill_protocol_mapped_error = map_tool_error(
RuntimeError(msg), skill_name="local/test-skill"
)
@when('I map a skill_protocol error with tool_name "{tool_name}"')
def skill_protocol_map_with_tool(context: Context, tool_name: str) -> None:
"""Map an error with tool_name."""
context.skill_protocol_mapped_error = map_tool_error(
RuntimeError("fail"),
skill_name="local/test-skill",
tool_name=tool_name,
)
@when("I try to map a skill_protocol error with empty skill_name")
def skill_protocol_map_empty_skill_name(context: Context) -> None:
"""Try to map an error with empty skill_name."""
context.skill_protocol_value_error = None
try:
map_tool_error(RuntimeError("fail"), skill_name="")
except ValueError as e:
context.skill_protocol_value_error = e
@then('the skill_protocol mapped error type should be "{expected}"')
def skill_protocol_check_mapped_type(context: Context, expected: str) -> None:
"""Check mapped error type."""
assert context.skill_protocol_mapped_error.error_type.value == expected
@then('the skill_protocol mapped error tool_name should be "{expected}"')
def skill_protocol_check_mapped_tool(context: Context, expected: str) -> None:
"""Check mapped error tool_name."""
assert context.skill_protocol_mapped_error.tool_name == expected
@then('the skill_protocol mapped error details should have key "{key}"')
def skill_protocol_check_mapped_details(context: Context, key: str) -> None:
"""Check mapped error details has key."""
assert key in context.skill_protocol_mapped_error.details
@then("a skill_protocol value error should be raised")
def skill_protocol_check_value_error(context: Context) -> None:
"""Verify a ValueError was raised."""
assert context.skill_protocol_value_error is not None, "Expected a ValueError"