forked from HAL9000/cleveragents-core
1e987a2009
Co-authored-by: Luis Mendes <luis.mendes@cleverthis.com> Co-committed-by: Luis Mendes <luis.mendes@cleverthis.com>
522 lines
19 KiB
Python
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)}"
|