"""Step definitions for actor compiler feature tests. Tests for features/actor_compiler.feature — validates that the actor compiler correctly translates GRAPH-type ActorConfigSchema definitions into LangGraph node/edge structures, detects cycles, and produces compilation metadata. """ from __future__ import annotations import json from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from cleveragents.actor.compiler import ( ActorCompilationError, SubgraphCycleError, compile_actor, ) from cleveragents.actor.schema import ( ActorConfigSchema, ActorType, EdgeDefinition, NodeDefinition, NodeType, RouteDefinition, ) # ──────────────────────────────────────────────────────────── # Helper builders # ──────────────────────────────────────────────────────────── def _build_graph_config( name: str, nodes: list[NodeDefinition], edges: list[EdgeDefinition], entry_node: str, exit_nodes: list[str], ) -> ActorConfigSchema: route = RouteDefinition( nodes=nodes, edges=edges, entry_node=entry_node, exit_nodes=exit_nodes, ) return ActorConfigSchema( name=name, type=ActorType.GRAPH, description="Test graph actor", model="gpt-4", route=route, ) # ──────────────────────────────────────────────────────────── # Given steps # ──────────────────────────────────────────────────────────── @given("a GRAPH actor config with a linear two-node topology") def step_given_linear_two_node(context: Context) -> None: nodes = [ NodeDefinition( id="planner", type=NodeType.AGENT, name="Planner", description="Plans tasks", config={"agent": "planner_agent"}, ), NodeDefinition( id="executor", type=NodeType.TOOL, name="Executor", description="Runs tasks", config={"tool_name": "exec/run"}, ), ] edges = [EdgeDefinition(from_node="planner", to_node="executor")] context.actor_config = _build_graph_config( "workflows/simple", nodes, edges, "planner", ["executor"] ) @given("a GRAPH actor config with an agent and a tool node") def step_given_agent_and_tool(context: Context) -> None: nodes = [ NodeDefinition( id="writer", type=NodeType.AGENT, name="Writer", description="Writes code", config={"agent": "writer_agent"}, ), NodeDefinition( id="linter", type=NodeType.TOOL, name="Linter", description="Lints code", config={"tool_name": "lint/run"}, ), ] edges = [EdgeDefinition(from_node="writer", to_node="linter")] context.actor_config = _build_graph_config( "workflows/lint", nodes, edges, "writer", ["linter"] ) @given("a GRAPH actor config with LSP bindings on the agent node") def step_given_lsp_bindings(context: Context) -> None: nodes = [ NodeDefinition( id="coder", type=NodeType.AGENT, name="Coder", description="Writes code", config={ "agent": "coder_agent", "lsp_bindings": [ { "lsp_server_name": "local/pyright", "languages": ["python"], "auto_detect": True, } ], }, ), ] context.actor_config = _build_graph_config( "workflows/lsp", nodes, [], "coder", ["coder"] ) @given("a GRAPH actor config with a conditional routing node") def step_given_conditional(context: Context) -> None: nodes = [ NodeDefinition( id="start_node", type=NodeType.AGENT, name="Agent", description="Starts", config={"agent": "a"}, ), NodeDefinition( id="checker", type=NodeType.CONDITIONAL, name="Checker", description="Checks result", config={"function": "check_fn"}, ), NodeDefinition( id="end_node", type=NodeType.AGENT, name="Finisher", description="Ends", config={"agent": "b"}, ), ] edges = [ EdgeDefinition(from_node="start_node", to_node="checker"), EdgeDefinition( from_node="checker", to_node="end_node", condition="state.get('ok')", ), ] context.actor_config = _build_graph_config( "workflows/cond", nodes, edges, "start_node", ["end_node"] ) @given('a GRAPH actor config with a subgraph node referencing "{ref_name}"') def step_given_subgraph_ref(context: Context, ref_name: str) -> None: nodes = [ NodeDefinition( id="main", type=NodeType.AGENT, name="Main", description="Main agent", config={"agent": "main_agent"}, ), NodeDefinition( id="sub", type=NodeType.SUBGRAPH, name="Sub", description="Subgraph", config={"actor_ref": ref_name}, ), ] edges = [EdgeDefinition(from_node="main", to_node="sub")] context.actor_config = _build_graph_config( "workflows/outer", nodes, edges, "main", ["sub"] ) inner_nodes = [ NodeDefinition( id="inner_agent", type=NodeType.AGENT, name="Inner", description="Inner agent", config={"agent": "inner"}, ), ] inner = _build_graph_config( ref_name, inner_nodes, [], "inner_agent", ["inner_agent"] ) context.actor_resolver = lambda name: inner if name == ref_name else None @given("a GRAPH actor config with a single node") def step_given_single_node(context: Context) -> None: nodes = [ NodeDefinition( id="solo", type=NodeType.AGENT, name="Solo", description="Solo agent", config={"agent": "solo"}, ), ] context.actor_config = _build_graph_config( "workflows/solo", nodes, [], "solo", ["solo"] ) @given('a GRAPH actor config "{outer_name}" referencing "{inner_name}"') def step_given_outer_referencing_inner( context: Context, outer_name: str, inner_name: str ) -> None: nodes = [ NodeDefinition( id="main", type=NodeType.AGENT, name="Main", description="Main agent", config={"agent": "main_agent"}, ), NodeDefinition( id="sub", type=NodeType.SUBGRAPH, name="Sub", description="Subgraph", config={"actor_ref": inner_name}, ), ] edges = [EdgeDefinition(from_node="main", to_node="sub")] context.actor_config = _build_graph_config( outer_name, nodes, edges, "main", ["sub"] ) context.outer_name = outer_name context.inner_name = inner_name @given('a resolver where "{inner_name}" references "{back_ref}"') def step_given_resolver_cycle(context: Context, inner_name: str, back_ref: str) -> None: inner_nodes = [ NodeDefinition( id="child", type=NodeType.SUBGRAPH, name="Child", description="Back-ref", config={"actor_ref": back_ref}, ), ] inner = _build_graph_config(inner_name, inner_nodes, [], "child", ["child"]) def resolver(name: str) -> ActorConfigSchema | None: if name == inner_name: return inner return None context.actor_resolver = resolver @given('a resolver where "{inner_name}" has no subgraph nodes') def step_given_resolver_no_subgraph(context: Context, inner_name: str) -> None: inner_nodes = [ NodeDefinition( id="leaf", type=NodeType.AGENT, name="Leaf", description="Leaf agent", config={"agent": "leaf"}, ), ] inner = _build_graph_config(inner_name, inner_nodes, [], "leaf", ["leaf"]) def resolver(name: str) -> ActorConfigSchema | None: if name == inner_name: return inner return None context.actor_resolver = resolver @given("an LLM actor config") def step_given_llm_config(context: Context) -> None: context.actor_config = ActorConfigSchema( name="assistants/simple", type=ActorType.LLM, description="Simple LLM", model="gpt-4", ) @given("a GRAPH actor config with route set to None") def step_given_graph_no_route(context: Context) -> None: context.actor_config = ActorConfigSchema.model_construct( name="workflows/broken", type=ActorType.GRAPH, description="No route", model="gpt-4", version="1.0", tools=[], memory=None, context=None, context_view=None, system_prompt=None, route=None, env_vars={}, ) @given('a GRAPH actor config with nodes "alpha", "beta", and "gamma"') def step_given_three_nodes(context: Context) -> None: nodes = [ NodeDefinition( id="gamma", type=NodeType.AGENT, name="Gamma", description="G", config={"agent": "g"}, ), NodeDefinition( id="alpha", type=NodeType.AGENT, name="Alpha", description="A", config={"agent": "a"}, ), NodeDefinition( id="beta", type=NodeType.TOOL, name="Beta", description="B", config={"tool_name": "b/run"}, ), ] edges = [ EdgeDefinition(from_node="alpha", to_node="beta"), EdgeDefinition(from_node="beta", to_node="gamma"), ] context.actor_config = _build_graph_config( "workflows/tri", nodes, edges, "alpha", ["gamma"] ) @given('a GRAPH actor config with entry "{entry}" and exit "{exit_node}"') def step_given_entry_exit(context: Context, entry: str, exit_node: str) -> None: nodes = [ NodeDefinition( id=entry, type=NodeType.AGENT, name="Start", description="Start", config={"agent": "s"}, ), NodeDefinition( id=exit_node, type=NodeType.AGENT, name="End", description="End", config={"agent": "e"}, ), ] edges = [EdgeDefinition(from_node=entry, to_node=exit_node)] context.actor_config = _build_graph_config( "workflows/ee", nodes, edges, entry, [exit_node] ) # ──────────────────────────────────────────────────────────── # When steps # ──────────────────────────────────────────────────────────── @when("I compile the actor config") def step_when_compile(context: Context) -> None: context.compile_error = None try: context.compiled = compile_actor(context.actor_config) except Exception as exc: context.compile_error = exc context.compiled = None @when("I compile the actor config with a resolver") def step_when_compile_with_resolver(context: Context) -> None: resolver = getattr(context, "actor_resolver", None) context.compile_error = None try: context.compiled = compile_actor(context.actor_config, actor_resolver=resolver) except Exception as exc: context.compile_error = exc context.compiled = None @when("I attempt to compile the non-graph actor") def step_when_compile_non_graph(context: Context) -> None: context.compile_error = None try: context.compiled = compile_actor(context.actor_config) except Exception as exc: context.compile_error = exc context.compiled = None @when("I attempt to compile the routeless actor") def step_when_compile_routeless(context: Context) -> None: context.compile_error = None try: context.compiled = compile_actor(context.actor_config) except Exception as exc: context.compile_error = exc context.compiled = None # ──────────────────────────────────────────────────────────── # Then steps # ──────────────────────────────────────────────────────────── @then("the compilation should succeed") def step_then_succeed(context: Context) -> None: assert context.compile_error is None, ( f"Expected compilation to succeed but got: {context.compile_error}" ) assert context.compiled is not None @then("the compiled actor should have {count:d} nodes") def step_then_node_count(context: Context, count: int) -> None: assert context.compiled is not None assert len(context.compiled.nodes) == count, ( f"Expected {count} nodes, got {len(context.compiled.nodes)}" ) @then('the compiled actor entry point should be "{entry}"') def step_then_entry_point(context: Context, entry: str) -> None: assert context.compiled is not None assert context.compiled.entry_point == entry @then('the compilation metadata should list node IDs "{id1}" and "{id2}"') def step_then_metadata_node_ids(context: Context, id1: str, id2: str) -> None: assert context.compiled is not None node_ids = context.compiled.metadata.node_ids assert id1 in node_ids, f"{id1} not in {node_ids}" assert id2 in node_ids, f"{id2} not in {node_ids}" @then("the compilation metadata should have {count:d} tool node") def step_then_tool_node_count(context: Context, count: int) -> None: assert context.compiled is not None assert len(context.compiled.metadata.tool_nodes) == count @then("the compilation metadata should have {count:d} LSP binding") def step_then_lsp_binding_count(context: Context, count: int) -> None: assert context.compiled is not None assert len(context.compiled.metadata.lsp_bindings) == count @then('the LSP binding should reference server "{server}"') def step_then_lsp_server(context: Context, server: str) -> None: assert context.compiled is not None servers = [b.lsp_server_name for b in context.compiled.metadata.lsp_bindings] assert server in servers, f"{server} not in {servers}" @then('the compilation metadata subgraph refs should map "{node}" to "{ref}"') def step_then_subgraph_ref(context: Context, node: str, ref: str) -> None: assert context.compiled is not None refs = context.compiled.metadata.subgraph_refs assert refs.get(node) == ref, f"Expected {node}->{ref}, got {refs}" @then("the compilation should fail with SubgraphCycleError") def step_then_fail_cycle(context: Context) -> None: assert context.compile_error is not None assert isinstance(context.compile_error, SubgraphCycleError), ( f"Expected SubgraphCycleError, got {type(context.compile_error).__name__}" ) @then("the compilation should fail with ActorCompilationError") def step_then_fail_compilation(context: Context) -> None: assert context.compile_error is not None assert isinstance(context.compile_error, ActorCompilationError), ( f"Expected ActorCompilationError, got {type(context.compile_error).__name__}" ) @then('the compiler error message should contain "{text}"') def step_then_compiler_error_contains(context: Context, text: str) -> None: assert context.compile_error is not None msg = str(context.compile_error) assert text.lower() in msg.lower(), f"'{text}' not in '{msg}'" @then("the metadata node IDs should be sorted alphabetically") def step_then_sorted_ids(context: Context) -> None: assert context.compiled is not None ids = context.compiled.metadata.node_ids assert ids == sorted(ids), f"Node IDs not sorted: {ids}" @then('the metadata entry node should be "{entry}"') def step_then_metadata_entry(context: Context, entry: str) -> None: assert context.compiled is not None assert context.compiled.metadata.entry_node == entry @then('the metadata exit nodes should contain "{exit_node}"') def step_then_metadata_exit(context: Context, exit_node: str) -> None: assert context.compiled is not None assert exit_node in context.compiled.metadata.exit_nodes @then("the compilation metadata should be JSON-serializable") def step_then_json_serializable(context: Context) -> None: assert context.compiled is not None data = context.compiled.metadata.model_dump(mode="json") serialized = json.dumps(data) assert isinstance(serialized, str)