Files
temp/features/steps/m2_actor_tool_smoke_steps.py
2026-04-02 16:53:03 +00:00

522 lines
19 KiB
Python

"""Step definitions for m2_actor_tool_smoke.feature.
All step names are prefixed with 'M2' to avoid AmbiguousStep conflicts
with existing actor/skill/tool step files.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.actor.loader import ActorLoader
from cleveragents.actor.schema import ActorConfigSchema
from cleveragents.domain.models.core.skill import (
Skill,
SkillInlineTool,
)
from cleveragents.domain.models.core.tool import (
Tool,
ToolCapability,
ToolSource,
ToolType,
)
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
from cleveragents.skills.context import SkillContext
from cleveragents.skills.protocol import SkillDefinition, SkillMetadata
from cleveragents.skills.registry import SkillRegistry
from cleveragents.skills.schema import SkillConfigSchema
from cleveragents.tool.context import ToolExecutionContext
from cleveragents.tool.lifecycle import (
ToolDescriptor,
ToolResult,
ToolRuntime,
)
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runtime import ToolSpec
# ── Fixture paths ──────────────────────────────────────────────────
_FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" / "m2"
_ACTORS_DIR = _FIXTURES_DIR / "actors"
_HIERARCHICAL_ACTOR = _ACTORS_DIR / "m2_hierarchical_actor.yaml"
_SKILL_PACK = _FIXTURES_DIR / "m2_skill_pack.yaml"
# ── Mock ToolInstance for lifecycle tests ──────────────────────────
class _M2MockToolInstance:
"""Minimal ToolInstance for M2 lifecycle testing."""
def __init__(self, name: str) -> None:
self._name = name
self._activated = False
self._deactivated = False
self._execute_count = 0
def discover(self) -> ToolDescriptor:
return ToolDescriptor(
name=self._name,
description=f"M2 mock tool {self._name}",
source="custom",
)
def activate(self, ctx: ToolExecutionContext) -> None:
self._activated = True
def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
self._execute_count += 1
return ToolResult(success=True, data={"echo": params.get("text", "")})
def deactivate(self, ctx: ToolExecutionContext) -> None:
self._deactivated = True
@property
def is_activated(self) -> bool:
return self._activated
@property
def is_deactivated(self) -> bool:
return self._deactivated
# ════════════════════════════════════════════════════════════════════
# Actor YAML loading
# ════════════════════════════════════════════════════════════════════
@given("I load the M2 hierarchical actor fixture")
def step_m2_load_hierarchical_actor(context: Context) -> None:
context._m2_actor = ActorConfigSchema.from_yaml_file(_HIERARCHICAL_ACTOR)
@then('the M2 actor name should be "{name}"')
def step_m2_actor_name(context: Context, name: str) -> None:
actor: ActorConfigSchema = context._m2_actor
assert actor.name == name, f"Expected '{name}', got '{actor.name}'"
@then('the M2 actor type should be "{actor_type}"')
def step_m2_actor_type(context: Context, actor_type: str) -> None:
actor: ActorConfigSchema = context._m2_actor
assert actor.type.value == actor_type, (
f"Expected type '{actor_type}', got '{actor.type.value}'"
)
@then("the M2 actor should have {count:d} graph nodes")
def step_m2_actor_node_count(context: Context, count: int) -> None:
actor: ActorConfigSchema = context._m2_actor
assert actor.route is not None, "Expected a route definition"
assert len(actor.route.nodes) == count, (
f"Expected {count} nodes, got {len(actor.route.nodes)}"
)
@then('the M2 actor entry node should be "{entry}"')
def step_m2_actor_entry_node(context: Context, entry: str) -> None:
actor: ActorConfigSchema = context._m2_actor
assert actor.route is not None
assert actor.route.entry_node == entry, (
f"Expected entry '{entry}', got '{actor.route.entry_node}'"
)
@then('the M2 actor exit nodes should include "{exit_node}"')
def step_m2_actor_exit_nodes(context: Context, exit_node: str) -> None:
actor: ActorConfigSchema = context._m2_actor
assert actor.route is not None
assert exit_node in actor.route.exit_nodes, (
f"Expected '{exit_node}' in exit nodes: {actor.route.exit_nodes}"
)
# ── Actor loader discovery from fixture dir ───────────────────────
@given("I create an M2 actor loader from the fixture directory")
def step_m2_create_loader(context: Context) -> None:
context._m2_loader = ActorLoader(search_roots=[_ACTORS_DIR])
@when("I run M2 actor discovery")
def step_m2_run_discovery(context: Context) -> None:
loader: ActorLoader = context._m2_loader
context._m2_discovered = loader.discover()
@then("the M2 loader should find {count:d} actors")
def step_m2_loader_count(context: Context, count: int) -> None:
loader: ActorLoader = context._m2_loader
actors = loader.list_actors()
assert len(actors) == count, (
f"Expected {count} actors, got {len(actors)}: {[a.name for a in actors]}"
)
@then('the M2 loader should contain actor "{name}"')
def step_m2_loader_contains(context: Context, name: str) -> None:
loader: ActorLoader = context._m2_loader
config = loader.get(name)
assert config is not None, f"Actor '{name}' not found in M2 loader"
# ════════════════════════════════════════════════════════════════════
# Skill pack loading and registry
# ════════════════════════════════════════════════════════════════════
@given("I load the M2 skill pack fixture")
def step_m2_load_skill_pack(context: Context) -> None:
context._m2_skill_config = SkillConfigSchema.from_yaml_file(_SKILL_PACK)
@then('the M2 skill name should be "{name}"')
def step_m2_skill_name(context: Context, name: str) -> None:
cfg: SkillConfigSchema = context._m2_skill_config
assert cfg.name == name, f"Expected '{name}', got '{cfg.name}'"
@then("the M2 skill should have {count:d} tool references")
def step_m2_skill_tool_refs(context: Context, count: int) -> None:
cfg: SkillConfigSchema = context._m2_skill_config
assert len(cfg.tools) == count, f"Expected {count} tool refs, got {len(cfg.tools)}"
@then("the M2 skill should have {count:d} inline tools")
def step_m2_skill_inline_tools(context: Context, count: int) -> None:
cfg: SkillConfigSchema = context._m2_skill_config
assert len(cfg.inline_tools) == count, (
f"Expected {count} inline tools, got {len(cfg.inline_tools)}"
)
def _build_m2_skill_definition() -> SkillDefinition:
"""Build a SkillDefinition from the M2 fixture for registry tests."""
skill = Skill(
name="m2test/file-ops-pack",
description="M2 smoke test skill pack with file operation tools",
tool_refs=["builtin/read_file", "builtin/list_files"],
anonymous_tools=[
SkillInlineTool(
description="Echo input back for testing",
source=ToolSource.CUSTOM,
code="def m2_echo(text: str) -> str:\n return f'echo: {text}'",
timeout=300,
),
],
)
metadata = SkillMetadata.from_skill(skill)
return SkillDefinition(
skill=skill,
metadata=metadata,
)
@given("I register the M2 skill pack in a skill registry")
def step_m2_register_skill_pack(context: Context) -> None:
registry = SkillRegistry()
defn = _build_m2_skill_definition()
registry.register(defn)
context._m2_skill_registry = registry
@when('I resolve tools for M2 skill "{name}"')
def step_m2_resolve_tools(context: Context, name: str) -> None:
registry: SkillRegistry = context._m2_skill_registry
context._m2_resolved_tools = registry.resolve_tools(name)
@then("the M2 resolved tools should include a tool_ref entry")
def step_m2_resolved_has_tool_ref(context: Context) -> None:
entries = context._m2_resolved_tools
has_ref = any(not e.is_inline for e in entries)
assert has_ref, "Expected at least one tool_ref entry in resolved tools"
@then("the M2 resolved tools should include an inline entry")
def step_m2_resolved_has_inline(context: Context) -> None:
entries = context._m2_resolved_tools
has_inline = any(e.is_inline for e in entries)
assert has_inline, "Expected at least one inline entry in resolved tools"
@when("I list all M2 skills in the registry")
def step_m2_list_skills(context: Context) -> None:
registry: SkillRegistry = context._m2_skill_registry
context._m2_skill_list = registry.list_all()
@then('the M2 skill list should contain "{name}"')
def step_m2_skill_list_contains(context: Context, name: str) -> None:
skill_list = context._m2_skill_list
names = [m.name for m in skill_list]
assert name in names, f"Expected '{name}' in {names}"
@then("the M2 skill list should have {count:d} entries")
def step_m2_skill_list_count(context: Context, count: int) -> None:
skill_list = context._m2_skill_list
assert len(skill_list) == count, f"Expected {count} entries, got {len(skill_list)}"
# ── Skill context invocation ──────────────────────────────────────
@given("I create an M2 skill context with sandbox")
def step_m2_create_skill_context(context: Context) -> None:
tmp = Path(tempfile.mkdtemp(prefix="m2_skill_ctx_"))
context._m2_skill_ctx = SkillContext(
plan_id="m2-plan-001",
project_id="m2-project-001",
sandbox_path=tmp,
)
@when('I register a tool invocation for "{tool_name}" in the M2 context')
def step_m2_register_invocation(context: Context, tool_name: str) -> None:
ctx: SkillContext = context._m2_skill_ctx
ctx.register_tool_invocation(
tool_name=tool_name,
input_data={"path": "test.txt"},
output_data={"content": "hello"},
duration_ms=1.5,
)
@then("the M2 skill context should have {count:d} invocation records")
def step_m2_skill_ctx_invocations(context: Context, count: int) -> None:
ctx: SkillContext = context._m2_skill_ctx
assert len(ctx.change_tracker) == count, (
f"Expected {count} records, got {len(ctx.change_tracker)}"
)
# ════════════════════════════════════════════════════════════════════
# Tool lifecycle
# ════════════════════════════════════════════════════════════════════
@given('I create an M2 tool runtime with a mock tool "{name}"')
def step_m2_create_tool_runtime(context: Context, name: str) -> None:
tool = Tool(
name=name,
description=f"M2 mock tool {name}",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
capability=ToolCapability(writes=False, read_only=True, checkpointable=False),
timeout=300,
)
instance = _M2MockToolInstance(name)
runtime = ToolRuntime(event_bus=ReactiveEventBus())
runtime.register_tool(tool, instance)
context._m2_runtime = runtime
context._m2_mock_instance = instance
@given('I create an M2 execution context with plan "{plan_id}"')
def step_m2_create_exec_ctx(context: Context, plan_id: str) -> None:
context._m2_exec_ctx = ToolExecutionContext(plan_id=plan_id)
@when('I discover M2 tool "{name}"')
def step_m2_discover_tool(context: Context, name: str) -> None:
runtime: ToolRuntime = context._m2_runtime
context._m2_descriptor = runtime.discover(name)
@then('the M2 discovered descriptor name should be "{name}"')
def step_m2_descriptor_name(context: Context, name: str) -> None:
desc: ToolDescriptor = context._m2_descriptor
assert desc.name == name, f"Expected '{name}', got '{desc.name}'"
@when('I activate M2 tool "{name}"')
def step_m2_activate_tool(context: Context, name: str) -> None:
runtime: ToolRuntime = context._m2_runtime
ctx: ToolExecutionContext = context._m2_exec_ctx
runtime.activate(name, ctx)
@when('I execute M2 tool "{name}" with params text "{text}"')
def step_m2_execute_tool(context: Context, name: str, text: str) -> None:
runtime: ToolRuntime = context._m2_runtime
ctx: ToolExecutionContext = context._m2_exec_ctx
context._m2_exec_result = runtime.execute(name, {"text": text}, ctx)
@then("the M2 execution result should be successful")
def step_m2_exec_result_success(context: Context) -> None:
result: ToolResult = context._m2_exec_result
assert result.success, f"Expected success, got error: {result.error}"
@when('I deactivate M2 tool "{name}"')
def step_m2_deactivate_tool(context: Context, name: str) -> None:
runtime: ToolRuntime = context._m2_runtime
ctx: ToolExecutionContext = context._m2_exec_ctx
runtime.deactivate(name, ctx)
@then("the M2 tool should be deactivated")
def step_m2_tool_deactivated(context: Context) -> None:
instance: _M2MockToolInstance = context._m2_mock_instance
assert instance.is_deactivated, "Expected mock tool to be deactivated"
# ── Tool registry ─────────────────────────────────────────────────
@given("I create an M2 tool registry")
def step_m2_create_tool_registry(context: Context) -> None:
context._m2_tool_registry = ToolRegistry()
def _m2_noop_handler(**kwargs: Any) -> dict[str, bool]:
"""Typed no-op handler for M2 tool registry test specs."""
return {"ok": True}
@when('I register an M2 tool spec "{name}"')
def step_m2_register_tool_spec(context: Context, name: str) -> None:
registry: ToolRegistry = context._m2_tool_registry
spec = ToolSpec(
name=name,
description=f"M2 test tool {name}",
handler=_m2_noop_handler,
)
registry.register(spec)
@then('getting M2 tool "{name}" should return a spec')
def step_m2_get_tool_spec(context: Context, name: str) -> None:
registry: ToolRegistry = context._m2_tool_registry
spec = registry.get(name)
assert spec is not None, f"Expected spec for '{name}', got None"
@then('listing M2 tools should include "{name}"')
def step_m2_list_tools_include(context: Context, name: str) -> None:
registry: ToolRegistry = context._m2_tool_registry
specs = registry.list_tools()
names = [s.name for s in specs]
assert name in names, f"Expected '{name}' in {names}"
# ════════════════════════════════════════════════════════════════════
# MCP stub tool discovery and invocation
# ════════════════════════════════════════════════════════════════════
def _get_mcp_stub_class() -> type:
"""Lazily import McpStubServer to avoid top-level sys.path mutation."""
import sys as _sys
_mocks_dir = str(Path(__file__).resolve().parent.parent / "mocks")
if _mocks_dir not in _sys.path:
_sys.path.insert(0, _mocks_dir)
from mcp_stub_server import McpStubServer
return McpStubServer
@given("I start the M2 MCP stub server")
def step_m2_start_mcp_stub(context: Context) -> None:
cls = _get_mcp_stub_class()
server = cls()
server.start()
context._m2_mcp_stub = server
@when("I discover tools from the M2 MCP stub")
def step_m2_discover_mcp_tools(context: Context) -> None:
server = context._m2_mcp_stub
context._m2_mcp_tools = server.discover()
@then("the M2 stub should expose {count:d} tools")
def step_m2_stub_tool_count(context: Context, count: int) -> None:
tools = context._m2_mcp_tools
assert len(tools) == count, f"Expected {count} tools, got {len(tools)}"
@then('the M2 stub tools should include "{name}"')
def step_m2_stub_tools_include(context: Context, name: str) -> None:
tools = context._m2_mcp_tools
names = [t.name for t in tools]
assert name in names, f"Expected '{name}' in {names}"
@when('I invoke M2 MCP stub tool "{name}" with query "{query}"')
def step_m2_invoke_mcp_search(context: Context, name: str, query: str) -> None:
server = context._m2_mcp_stub
context._m2_mcp_result = server.invoke(name, {"query": query})
@then("the M2 MCP stub result should contain results")
def step_m2_mcp_result_has_results(context: Context) -> None:
result = context._m2_mcp_result
assert "results" in result, f"Expected 'results' key in {result}"
assert len(result["results"]) > 0, "Expected non-empty results"
@then("I stop the M2 MCP stub server")
def step_m2_stop_mcp_stub(context: Context) -> None:
server = context._m2_mcp_stub
server.stop()
assert not server.is_running, "Expected MCP stub to be stopped"
# ── MCP stub lifecycle guards ─────────────────────────────────────
@given("I have an M2 MCP stub server that is stopped")
def step_m2_mcp_stub_stopped(context: Context) -> None:
cls = _get_mcp_stub_class()
context._m2_mcp_stub = cls()
@when("I try to discover tools from the stopped M2 stub")
def step_m2_discover_stopped(context: Context) -> None:
server = context._m2_mcp_stub
try:
server.discover()
context._m2_mcp_error = None
except RuntimeError as exc:
context._m2_mcp_error = exc
@then("an M2 RuntimeError should be raised")
def step_m2_runtime_error_raised(context: Context) -> None:
err = context._m2_mcp_error
assert err is not None, "Expected RuntimeError but none was raised"
assert isinstance(err, RuntimeError)
@when('I start and invoke M2 MCP stub tool "{name}" with url "{url}"')
def step_m2_start_and_invoke_fetch(context: Context, name: str, url: str) -> None:
server = context._m2_mcp_stub
server.start()
context._m2_mcp_result = server.invoke(name, {"url": url})
@then("the M2 MCP stub fetch result should have status {status:d}")
def step_m2_mcp_fetch_status(context: Context, status: int) -> None:
result = context._m2_mcp_result
assert result.get("status") == status, (
f"Expected status {status}, got {result.get('status')}"
)
@then("the M2 MCP stub invocation log should have {count:d} entries")
def step_m2_mcp_invocation_log(context: Context, count: int) -> None:
server = context._m2_mcp_stub
log = server.invocation_log
assert len(log) == count, f"Expected {count} entries, got {len(log)}"