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
- 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
564 lines
18 KiB
Python
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)
|