Files
HAL9000 02e51b9298
CI / build (pull_request) Successful in 34s
CI / helm (pull_request) Successful in 38s
CI / lint (pull_request) Successful in 45s
CI / typecheck (pull_request) Successful in 1m4s
CI / security (pull_request) Successful in 1m10s
CI / quality (pull_request) Successful in 1m16s
CI / push-validation (pull_request) Successful in 18s
CI / unit_tests (pull_request) Successful in 6m32s
CI / docker (pull_request) Successful in 1m37s
CI / coverage (pull_request) Successful in 13m26s
CI / integration_tests (pull_request) Successful in 23m55s
CI / status-check (pull_request) Successful in 4s
fix(actor): fix lint errors and add regression tests for lsp_binding field (#1432)
- Remove trailing whitespace from blank lines in _extract_lsp_bindings()
  (W293 at lines 160, 164, 167, 172, 183, 188, 203)
- Simplify over-long line 180 (E501): auto_detect=node.lsp_binding.auto
- Add Behave scenario verifying NodeLspBinding typed field populates
  CompilationMetadata.lsp_bindings (features/actor_lsp_binding_field.feature)
- Add corresponding @given step using NodeLspBinding in actor_compiler_steps.py
- Add Robot Framework regression test case and lsp-binding-field helper command
  confirming node.lsp_binding is read by compile_actor()
- Update CHANGELOG.md and CONTRIBUTORS.md

ISSUES CLOSED: #1432
2026-05-30 00:36:20 -04:00

564 lines
18 KiB
Python

"""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,
NodeLspBinding,
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",
provider="openai",
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 node using the lsp_binding typed field")
def step_given_lsp_typed_field(context: Context) -> None:
nodes = [
NodeDefinition(
id="coder",
type=NodeType.AGENT,
name="Coder",
description="Writes code with LSP support",
config={"agent": "coder_agent"},
lsp_binding=NodeLspBinding(
server="local/pyright",
languages=["python"],
auto=False,
),
),
]
context.actor_config = _build_graph_config(
"workflows/lsp_typed", 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",
provider="openai",
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)