fix(reactive): synthesise execution route for type:llm actors in ReactiveConfigParser #10818

Merged
HAL9000 merged 1 commits from bugfix/m3-actor-run-missing-llm-route into master 2026-04-28 03:37:27 +00:00
9 changed files with 1061 additions and 177 deletions
+9
View File
@@ -55,6 +55,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
validate-then-write approach: all model validation occurs in Phase 1, and
state mutations only happen in Phase 2 after all validations succeed.
- **ReactiveConfigParser route synthesis for v3 actors** (#10807): Fixed
`agents actor run` silently returning empty output for v3 `type:llm` actors.
`_build_from_v3()` and `_build()` now synthesise a default single-node
graph route when agents are created without explicit routes, ensuring
`run_single_shot()` can invoke the LLM via `GraphExecutor`. The nested
`actors:` map format also translates the v3 `actor: "provider/model"` key
into separate `provider` and `model` keys so the correct LLM provider is
instantiated.
- **ActorRegistry.add() spec-compliant YAML support** (#4466): The registry now
accepts actor YAML using the spec's `actors:` map format with nested `config:`
blocks, in addition to the legacy top-level `provider`/`model` format. The
+119
View File
@@ -0,0 +1,119 @@
Feature: A2A stdio transport for local-mode communication
As a developer using local-mode agent communication
I want the A2aStdioTransport to handle subprocess communication correctly
So that JSON-RPC messages are sent and received reliably
@coverage
Scenario: Transport initializes with no process
Given a new A2aStdioTransport instance
Then the stdio transport should not be connected
And the stdio transport process should be None
@coverage
Scenario: Send raises when not connected
Given a new A2aStdioTransport instance
When I try to send a request without connecting
Then a RuntimeError should be raised about not connected
@coverage
Scenario: Send raises for non-A2aRequest input
Given a connected A2aStdioTransport with a mock process
When I try to send a non-A2aRequest object
Then a TypeError should be raised about A2aRequest
@coverage
Scenario: Send succeeds with valid request and mock response
Given a connected A2aStdioTransport with a mock process
And the mock process returns a valid JSON-RPC response
When I send a valid A2aRequest
Then I should receive an A2aResponse
@coverage
Scenario: Send raises on invalid JSON response
Given a connected A2aStdioTransport with a mock process
And the mock process returns invalid JSON
When I try to send a valid A2aRequest
Then a RuntimeError should be raised about invalid JSON
@coverage
Scenario: Send raises when subprocess closes unexpectedly
Given a connected A2aStdioTransport with a mock process
And the mock process returns empty response
When I try to send a valid A2aRequest
Then a RuntimeError should be raised about closed unexpectedly
@coverage
Scenario: Send raises when stdin is unavailable
Given a connected A2aStdioTransport with a mock process
And the mock process has no stdin
When I try to send a valid A2aRequest
Then a RuntimeError should be raised about stdin
@coverage
Scenario: Send raises when stdout is unavailable
Given a connected A2aStdioTransport with a mock process
And the mock process has no stdout but valid stdin
When I try to send a valid A2aRequest
Then a RuntimeError should be raised about stdout
@coverage
Scenario: Connect raises for empty agent path
Given a new A2aStdioTransport instance
When I try to connect with empty agent path
Then a ValueError should be raised about agent_path
@coverage
Scenario: Connect raises when already connected
Given a connected A2aStdioTransport with a mock process
When I try to connect again with a valid path
Then a RuntimeError should be raised about already connected
@coverage
Scenario: Disconnect is a no-op when not connected
Given a new A2aStdioTransport instance
When I call disconnect
Then no stdio transport error should be raised
@coverage
Scenario: Disconnect closes stdin and waits for process
Given a connected A2aStdioTransport with a mock process
When I call disconnect
Then the stdio transport should not be connected
And mock stdin close should have been called
And mock process wait should have been called
@coverage
Scenario: Disconnect terminates when wait times out
Given a connected A2aStdioTransport with a mock process
And the mock process times out on first wait
When I call disconnect
Then the stdio transport should not be connected
And mock process terminate should have been called
@coverage
Scenario: Connect with Python module path
Given a new A2aStdioTransport instance
And subprocess Popen is mocked to succeed
When I connect with agent path "cleveragents.a2a.agent"
Then the stdio transport should be connected
@coverage
Scenario: Connect with executable path
Given a new A2aStdioTransport instance
And subprocess Popen is mocked to succeed
When I connect with agent path "/usr/local/bin/agent"
Then the stdio transport should be connected
@coverage
Scenario: Connect with .py file path
Given a new A2aStdioTransport instance
And subprocess Popen is mocked to succeed
When I connect with agent path "agent.py"
Then the stdio transport should be connected
@coverage
Scenario: Connect raises for file not found
Given a new A2aStdioTransport instance
And subprocess Popen raises FileNotFoundError
When I try to connect with agent path "/nonexistent/agent"
Then a RuntimeError should be raised about agent not found
+110
View File
@@ -0,0 +1,110 @@
Feature: v3 actor config parser synthesises execution routes
As a developer using v3 actor YAML configs
I want ReactiveConfigParser to automatically create graph routes for agents
So that run_single_shot() can invoke the LLM instead of silently returning empty
# --- Fix A: _build_from_v3() route synthesis for type:llm / type:tool --------
@tdd_issue @tdd_issue_10807
Scenario: v3 flat LLM actor build produces non-empty routes
Given a flat v3 LLM actor config for route synthesis
When I build the flat v3 config through ReactiveConfigParser
Then the reactive config routes should not be empty
And the reactive config should have exactly one route
And the synthesised route should be a graph route
And the synthesised route entry point should be the router
@tdd_issue @tdd_issue_10807
Scenario: v3 flat tool actor build produces non-empty routes
Given a flat v3 tool actor config for route synthesis
When I build the flat v3 config through ReactiveConfigParser
Then the reactive config routes should not be empty
And the reactive config should have exactly one route
And the synthesised route should be a graph route
@tdd_issue @tdd_issue_10807
Scenario: v3 flat LLM route has router-plus-actor graph structure
Given a flat v3 LLM actor config for route synthesis
When I build the flat v3 config through ReactiveConfigParser
Then the synthesised route should have exactly two nodes
And the synthesised route should have a message router node
And the synthesised route node should reference the actor name
And the synthesised route should have an edge to end
# --- Fix B: _build() route synthesis for nested actors: map format -----------
@tdd_issue @tdd_issue_10807
Scenario: Nested actors map format produces non-empty routes
Given a nested actors map config with cleveragents version 3
When I build the nested actors config through ReactiveConfigParser
Then the reactive config routes should not be empty
And the reactive config should have exactly one route
And the synthesised route should be a graph route
@tdd_issue @tdd_issue_10807
Scenario: Nested actors map uses default_actor for route actor node
Given a nested actors map config with default_actor set
When I build the nested actors config through ReactiveConfigParser
Then the synthesised route should target the default actor
@tdd_issue @tdd_issue_10807
Scenario: Nested actors map without default_actor uses first agent
Given a nested actors map config without default_actor
When I build the nested actors config through ReactiveConfigParser
Then the reactive config routes should not be empty
And the synthesised route should target the first agent
# --- Fix C: nested actors: map translates actor key to provider/model -------
@tdd_issue @tdd_issue_10807
Scenario: Nested actors map with actor key infers provider and model
Given a nested actors map config with actor key "anthropic/claude-sonnet-4-5"
When I build the nested actors config through ReactiveConfigParser
Then the agent config should have provider "anthropic"
And the agent config should have model "claude-sonnet-4-5"
@tdd_issue @tdd_issue_10807
Scenario: Nested actors map with actor key without slash sets model only
Given a nested actors map config with actor key "gpt-4"
When I build the nested actors config through ReactiveConfigParser
Then the agent config should have model "gpt-4"
And the agent config should not have provider set
@tdd_issue @tdd_issue_10807
Scenario: Nested actors map with explicit provider keeps it unchanged
Given a nested actors map config with explicit provider "openai" and model "gpt-4o"
When I build the nested actors config through ReactiveConfigParser
Then the agent config should have provider "openai"
And the agent config should have model "gpt-4o"
# --- Existing route configs are not affected --------------------------------
@tdd_issue @tdd_issue_10807
Scenario: Config with explicit routes does not get extra synthesised routes
Given a config with agents and explicit routes
When I build the explicit routes config through ReactiveConfigParser
Then the reactive config should have exactly one route
And the route should be the explicitly defined one
# --- End-to-end: run_single_shot with synthesised route ---------------------
@tdd_issue @tdd_issue_10807
Scenario: run_single_shot returns non-empty output with synthesised v3 LLM route
Given a ReactiveCleverAgentsApp configured from a flat v3 LLM config
When I call run_single_shot with a test prompt
Then the result should be non-empty
@tdd_issue @tdd_issue_10807
Scenario: run_single_shot returns non-empty output with nested actors map config
Given a ReactiveCleverAgentsApp configured from a nested actors map config
When I call run_single_shot with a test prompt
Then the result should be non-empty
# --- v3 graph actors still work (regression guard) --------------------------
@tdd_issue @tdd_issue_10807
Scenario: v3 graph actor build still produces routes as before
Given a v3 graph actor config dict with route for regression check
When I build the v3 graph config through ReactiveConfigParser
Then the reactive config routes should not be empty
And the reactive config should have exactly one route
@@ -1,28 +0,0 @@
Feature: Architecture pool supervisor milestone assignment
As a project manager
I want spec PRs to be automatically assigned to the current milestone
So that specification changes are properly tracked in project planning
Scenario: PR workflow documentation includes milestone assignment
Given the architecture-pool-supervisor.md file exists
When I read the "PR Workflow for Major Changes" section
Then the section should describe creating a feature branch
And the section should describe committing spec changes
And the section should describe creating a PR with "needs feedback" label
And the section should describe assigning the PR to the current active milestone
And the section should mention using "forgejo_update_pull_request" for milestone assignment
And the section should describe querying milestones using "forgejo_list_repo_milestones"
And the section should describe graceful handling when no active milestone exists
And the section should describe using the earliest milestone for multi-milestone specs
Scenario: Permissions allow milestone assignment
Given the architecture-pool-supervisor.md file exists
When I read the permissions section
Then "forgejo_update_pull_request" should be allowed
And "forgejo_list_repo_milestones" should be allowed
Scenario: Workflow ensures proper PR tracking
Given the architecture-pool-supervisor.md file exists
When I read the "PR Workflow for Major Changes" section
Then the workflow should ensure specification PRs are tracked within milestone planning
And the workflow should ensure PRs remain visible in the project's issue/PR dashboard
+283
View File
@@ -0,0 +1,283 @@
# pyright: reportRedeclaration=false
"""Step definitions for A2A stdio transport coverage (coverage boost)."""
from __future__ import annotations
import json
import subprocess
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.a2a.models import A2aRequest, A2aResponse
from cleveragents.a2a.stdio_transport import A2aStdioTransport
# ── Given steps ──────────────────────────────────────────────────────────
@given("a new A2aStdioTransport instance")
def step_new_transport(context: Any) -> None:
context.transport = A2aStdioTransport()
@given("a connected A2aStdioTransport with a mock process")
def step_connected_transport(context: Any) -> None:
transport = A2aStdioTransport()
mock_proc = MagicMock(spec=subprocess.Popen)
mock_proc.stdin = MagicMock()
mock_proc.stdout = MagicMock()
mock_proc.stderr = MagicMock()
mock_proc.pid = 12345
mock_proc.wait = MagicMock(return_value=0)
transport._process = mock_proc
transport._is_connected = True
context.transport = transport
context.mock_process = mock_proc
@given("the mock process returns a valid JSON-RPC response")
def step_mock_valid_response(context: Any) -> None:
resp = {"jsonrpc": "2.0", "id": "test-id", "result": {"status": "ok"}}
context.mock_process.stdout.readline.return_value = json.dumps(resp) + "\n"
@given("the mock process returns invalid JSON")
def step_mock_invalid_json(context: Any) -> None:
context.mock_process.stdout.readline.return_value = "not-json{{"
@given("the mock process returns empty response")
def step_mock_empty_response(context: Any) -> None:
context.mock_process.stdout.readline.return_value = ""
@given("the mock process has no stdin")
def step_mock_no_stdin(context: Any) -> None:
context.mock_process.stdin = None
@given("the mock process has no stdout but valid stdin")
def step_mock_no_stdout(context: Any) -> None:
context.mock_process.stdin = MagicMock()
context.mock_process.stdin.write = MagicMock()
context.mock_process.stdin.flush = MagicMock()
context.mock_process.stdout = None
@given("the mock process times out on first wait")
def step_mock_timeout(context: Any) -> None:
context.mock_process.wait.side_effect = [
subprocess.TimeoutExpired("test", 5.0),
0,
]
@given("subprocess Popen is mocked to succeed")
def step_mock_popen_success(context: Any) -> None:
mock_proc = MagicMock(spec=subprocess.Popen)
mock_proc.stdin = MagicMock()
mock_proc.stdout = MagicMock()
mock_proc.stderr = MagicMock()
mock_proc.pid = 99999
context.popen_mock = mock_proc
patcher = patch(
"cleveragents.a2a.stdio_transport.subprocess.Popen", return_value=mock_proc
)
context.popen_patcher = patcher
patcher.start()
def cleanup() -> None:
patcher.stop()
context.add_cleanup(cleanup)
@given("subprocess Popen raises FileNotFoundError")
def step_mock_popen_fnf(context: Any) -> None:
patcher = patch(
"cleveragents.a2a.stdio_transport.subprocess.Popen",
side_effect=FileNotFoundError("No such file"),
)
context.popen_patcher = patcher
patcher.start()
def cleanup() -> None:
patcher.stop()
context.add_cleanup(cleanup)
# ── When steps ───────────────────────────────────────────────────────────
def _make_request() -> A2aRequest:
return A2aRequest(method="test.echo", params={"msg": "hello"})
@when("I try to send a request without connecting")
def step_send_without_connect(context: Any) -> None:
try:
context.transport.send(_make_request())
context.raised_error = None
except (RuntimeError, TypeError) as exc:
context.raised_error = exc
@when("I try to send a non-A2aRequest object")
def step_send_non_request(context: Any) -> None:
bad_arg: Any = {"not": "a request"}
try:
context.transport.send(bad_arg)
context.raised_error = None
except TypeError as exc:
context.raised_error = exc
@when("I send a valid A2aRequest")
def step_send_valid_request(context: Any) -> None:
context.response = context.transport.send(_make_request())
@when("I try to send a valid A2aRequest")
def step_try_send_valid_request(context: Any) -> None:
try:
context.transport.send(_make_request())
context.raised_error = None
except RuntimeError as exc:
context.raised_error = exc
@when("I try to connect with empty agent path")
def step_connect_empty(context: Any) -> None:
try:
context.transport.connect("")
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@when("I try to connect again with a valid path")
def step_connect_again(context: Any) -> None:
try:
context.transport.connect("/some/agent")
context.raised_error = None
except RuntimeError as exc:
context.raised_error = exc
@when("I call disconnect")
def step_disconnect(context: Any) -> None:
context.transport.disconnect()
@when('I connect with agent path "{path}"')
def step_connect_with_path(context: Any, path: str) -> None:
context.transport.connect(path)
@when('I try to connect with agent path "{path}"')
def step_try_connect_with_path(context: Any, path: str) -> None:
try:
context.transport.connect(path)
context.raised_error = None
except (RuntimeError, ValueError) as exc:
context.raised_error = exc
# ── Then steps ───────────────────────────────────────────────────────────
@then("the stdio transport should not be connected")
def step_stdio_not_connected(context: Any) -> None:
assert not context.transport.is_connected(), "Expected transport to be disconnected"
@then("the stdio transport should be connected")
def step_stdio_is_connected(context: Any) -> None:
assert context.transport.is_connected(), "Expected transport to be connected"
@then("the stdio transport process should be None")
def step_stdio_process_none(context: Any) -> None:
assert context.transport.get_process() is None
@then("a RuntimeError should be raised about not connected")
def step_runtime_not_connected(context: Any) -> None:
assert isinstance(context.raised_error, RuntimeError)
assert "not connected" in str(context.raised_error).lower()
@then("a TypeError should be raised about A2aRequest")
def step_type_error_request(context: Any) -> None:
assert isinstance(context.raised_error, TypeError)
assert "A2aRequest" in str(context.raised_error)
@then("I should receive an A2aResponse")
def step_received_response(context: Any) -> None:
assert isinstance(context.response, A2aResponse)
@then("a RuntimeError should be raised about invalid JSON")
def step_runtime_invalid_json(context: Any) -> None:
assert isinstance(context.raised_error, RuntimeError)
assert "invalid json" in str(context.raised_error).lower()
@then("a RuntimeError should be raised about closed unexpectedly")
def step_runtime_closed(context: Any) -> None:
assert isinstance(context.raised_error, RuntimeError)
assert "closed unexpectedly" in str(context.raised_error).lower()
@then("a RuntimeError should be raised about stdin")
def step_runtime_stdin(context: Any) -> None:
assert isinstance(context.raised_error, RuntimeError)
assert "stdin" in str(context.raised_error).lower()
@then("a RuntimeError should be raised about stdout")
def step_runtime_stdout(context: Any) -> None:
assert isinstance(context.raised_error, RuntimeError)
assert "stdout" in str(context.raised_error).lower()
@then("a ValueError should be raised about agent_path")
def step_value_error_path(context: Any) -> None:
assert isinstance(context.raised_error, ValueError)
assert "agent_path" in str(context.raised_error)
@then("a RuntimeError should be raised about already connected")
def step_runtime_already_connected(context: Any) -> None:
assert isinstance(context.raised_error, RuntimeError)
assert "already connected" in str(context.raised_error).lower()
@then("no stdio transport error should be raised")
def step_no_stdio_error(context: Any) -> None:
# Disconnect on unconnected transport is a no-op
pass
@then("mock stdin close should have been called")
def step_stdin_closed(context: Any) -> None:
context.mock_process.stdin.close.assert_called_once()
@then("mock process wait should have been called")
def step_wait_called(context: Any) -> None:
context.mock_process.wait.assert_called()
@then("mock process terminate should have been called")
def step_terminate_called(context: Any) -> None:
context.mock_process.terminate.assert_called_once()
@then("a RuntimeError should be raised about agent not found")
def step_runtime_agent_not_found(context: Any) -> None:
assert isinstance(context.raised_error, RuntimeError)
assert "agent not found" in str(context.raised_error).lower()
@@ -0,0 +1,453 @@
# pyright: reportRedeclaration=false
"""Step definitions for v3 actor route synthesis (issue #10807).
Tests that ReactiveConfigParser synthesises execution routes for
type:llm and type:tool actors in both the flat v3 format and the
nested ``actors:`` map format.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from cleveragents.reactive.application import ReactiveCleverAgentsApp
from cleveragents.reactive.config_parser import ReactiveConfig, ReactiveConfigParser
from cleveragents.reactive.route import RouteType
from cleveragents.reactive.stream_router import SimpleLLMAgent
from cleveragents.reactive.stream_router import SimpleLLMAgent
def _run_async(coro: Any) -> Any:
"""Run an async coroutine synchronously for BDD steps."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
# ── Given steps ──────────────────────────────────────────────────────────
@given("a flat v3 LLM actor config for route synthesis")
def step_flat_v3_llm_config(context: Any) -> None:
context.v3_route_config = {
"name": "local/test-llm-route",
"type": "llm",
"description": "A test LLM actor for route synthesis",
"model": "openai/gpt-4",
"system_prompt": "You are helpful",
}
@given("a flat v3 tool actor config for route synthesis")
def step_flat_v3_tool_config(context: Any) -> None:
context.v3_route_config = {
"name": "local/test-tool-route",
"type": "tool",
"description": "A test tool actor for route synthesis",
"model": "",
"tools": ["local/search"],
}
@given("a nested actors map config with cleveragents version 3")
def step_nested_actors_map_config(context: Any) -> None:
context.nested_actors_config = {
"name": "local/rune-strategist",
"cleveragents": {
"version": "3.0",
"default_actor": "strategist",
},
"actors": {
"strategist": {
"type": "llm",
"config": {
"actor": "anthropic/claude-sonnet-4-5",
"temperature": 0.3,
},
},
},
}
@given("a nested actors map config with default_actor set")
def step_nested_actors_default_actor(context: Any) -> None:
context.nested_actors_config = {
"name": "local/multi-actor",
"cleveragents": {
"version": "3.0",
"default_actor": "secondary",
},
"actors": {
"primary": {
"type": "llm",
"config": {"actor": "openai/gpt-4"},
},
"secondary": {
"type": "llm",
"config": {"actor": "anthropic/claude-sonnet-4-5"},
},
},
}
context.expected_default_actor = "secondary"
@given("a nested actors map config without default_actor")
def step_nested_actors_no_default(context: Any) -> None:
context.nested_actors_config = {
"name": "local/single-actor",
"actors": {
"only_agent": {
"type": "llm",
"config": {"actor": "openai/gpt-4"},
},
},
}
context.expected_first_agent = "only_agent"
@given("a config with agents and explicit routes")
def step_config_with_explicit_routes(context: Any) -> None:
context.explicit_routes_config = {
"agents": {
"main": {
"type": "llm",
"config": {"provider": "openai", "model": "gpt-4"},
},
},
"routes": {
"my_graph": {
"type": "graph",
"nodes": {
"main": {"type": "agent", "agent": "main"},
},
"edges": [],
"entry_point": "main",
},
},
}
@given("a ReactiveCleverAgentsApp configured from a flat v3 LLM config")
def step_app_from_flat_v3(context: Any) -> None:
config_data = {
"name": "local/test-e2e-llm",
"type": "llm",
"description": "E2E test LLM actor",
"model": "openai/gpt-4",
"system_prompt": "You are helpful",
}
parser = ReactiveConfigParser()
rc = parser._build(config_data)
# Build a minimal app with a FakeListLLM-backed SimpleLLMAgent so that
# run_single_shot() exercises the actual GraphExecutor → agent path.
from langchain_community.llms import FakeListLLM
app = ReactiveCleverAgentsApp.__new__(ReactiveCleverAgentsApp)
app.config = rc
app.stream_router = MagicMock()
fake_llm = FakeListLLM(responses=["Test LLM response"])
agent = SimpleLLMAgent(
name="local/test-e2e-llm", config=rc.agents["local/test-e2e-llm"].config
)
agent._llm = fake_llm # bypass provider registry
app.stream_router.agents = {"local/test-e2e-llm": agent}
context.e2e_app = app
@given("a ReactiveCleverAgentsApp configured from a nested actors map config")
def step_app_from_nested_actors(context: Any) -> None:
config_data = {
"name": "local/test-e2e-nested",
"cleveragents": {
"version": "3.0",
"default_actor": "strategist",
},
"actors": {
"strategist": {
"type": "llm",
"config": {
"actor": "anthropic/claude-sonnet-4-5",
"temperature": 0.3,
},
},
},
}
parser = ReactiveConfigParser()
rc = parser._build(config_data)
from langchain_community.llms import FakeListLLM
app = ReactiveCleverAgentsApp.__new__(ReactiveCleverAgentsApp)
app.config = rc
app.stream_router = MagicMock()
fake_llm = FakeListLLM(responses=["Nested actors response"])
agent = SimpleLLMAgent(name="strategist", config=rc.agents["strategist"].config)
agent._llm = fake_llm # bypass provider registry
app.stream_router.agents = {"strategist": agent}
context.e2e_app = app
@given("a v3 graph actor config dict with route for regression check")
def step_v3_graph_regression(context: Any) -> None:
context.v3_route_config = {
"name": "local/test-graph-regression",
"type": "graph",
"description": "Regression check for graph route synthesis",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans tasks",
"config": {"model": "gpt-4"},
},
{
"id": "executor",
"type": "tool",
"name": "Executor",
"description": "Executes tasks",
"config": {"tool_name": "exec/run"},
},
],
"edges": [{"from_node": "planner", "to_node": "executor"}],
"entry_node": "planner",
"exit_nodes": ["executor"],
},
}
# ── When steps ───────────────────────────────────────────────────────────
@when("I build the flat v3 config through ReactiveConfigParser")
def step_build_flat_v3(context: Any) -> None:
parser = ReactiveConfigParser()
context.reactive_config = parser._build(context.v3_route_config)
@when("I build the nested actors config through ReactiveConfigParser")
def step_build_nested_actors(context: Any) -> None:
parser = ReactiveConfigParser()
context.reactive_config = parser._build(context.nested_actors_config)
@when("I build the explicit routes config through ReactiveConfigParser")
def step_build_explicit_routes(context: Any) -> None:
parser = ReactiveConfigParser()
context.reactive_config = parser._build(context.explicit_routes_config)
@when("I build the v3 graph config through ReactiveConfigParser")
def step_build_v3_graph(context: Any) -> None:
parser = ReactiveConfigParser()
context.reactive_config = parser._build(context.v3_route_config)
@when("I call run_single_shot with a test prompt")
def step_call_run_single_shot(context: Any) -> None:
context.run_result = _run_async(
context.e2e_app.run_single_shot("Describe a concise strategy")
)
# ── Then steps ───────────────────────────────────────────────────────────
@then("the reactive config routes should not be empty")
def step_routes_not_empty(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
assert rc.routes, (
f"Expected non-empty routes, got empty. Agents: {list(rc.agents.keys())}"
)
@then("the reactive config should have exactly one route")
def step_exactly_one_route(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
assert len(rc.routes) == 1, (
f"Expected exactly 1 route, got {len(rc.routes)}: {list(rc.routes.keys())}"
)
@then("the synthesised route should be a graph route")
def step_route_is_graph(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
route = next(iter(rc.routes.values()))
assert route.type == RouteType.GRAPH, f"Expected GRAPH route, got {route.type}"
@then("the synthesised route entry point should be the router")
def step_route_entry_is_router(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
route = next(iter(rc.routes.values()))
assert route.entry_point == "__router__", (
f"Expected entry_point '__router__', got '{route.entry_point}'"
)
@then("the synthesised route should have exactly two nodes")
def step_route_two_nodes(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
route = next(iter(rc.routes.values()))
assert len(route.nodes) == 2, (
f"Expected 2 nodes (router + actor), got {len(route.nodes)}: "
f"{list(route.nodes.keys())}"
)
@then("the synthesised route should have a message router node")
def step_route_has_router(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
route = next(iter(rc.routes.values()))
assert "__router__" in route.nodes, (
f"Expected '__router__' node in route nodes: {list(route.nodes.keys())}"
)
router_data = route.nodes["__router__"]
assert router_data.get("type") == "message_router", (
f"Expected message_router type, got '{router_data.get('type')}'"
)
rules = router_data.get("rules", [])
assert len(rules) >= 1, "Expected at least one routing rule"
@then("the synthesised route node should reference the actor name")
def step_route_node_references_actor(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
route = next(iter(rc.routes.values()))
actor_name = next(iter(rc.agents.keys()))
assert actor_name in route.nodes, (
f"Expected node '{actor_name}' in route nodes: {list(route.nodes.keys())}"
)
node_data = route.nodes[actor_name]
assert node_data.get("agent") == actor_name, (
f"Expected node agent '{actor_name}', got '{node_data.get('agent')}'"
)
@then("the synthesised route should have an edge to end")
def step_route_edge_to_end(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
route = next(iter(rc.routes.values()))
assert len(route.edges) >= 1, f"Expected at least 1 edge, got {len(route.edges)}"
targets = [e.get("target") for e in route.edges if isinstance(e, dict)]
assert "end" in targets, f"Expected an edge to 'end', got targets: {targets}"
@then("the synthesised route should target the default actor")
def step_route_targets_default(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
route = next(iter(rc.routes.values()))
expected = context.expected_default_actor
# The default actor should appear as an actor node in the route.
assert expected in route.nodes, (
f"Expected '{expected}' as a node in route, "
f"got nodes: {list(route.nodes.keys())}"
)
node_data = route.nodes[expected]
assert node_data.get("type") == "actor", (
f"Expected node type 'actor', got '{node_data.get('type')}'"
)
@then("the synthesised route should target the first agent")
def step_route_targets_first(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
route = next(iter(rc.routes.values()))
expected = context.expected_first_agent
assert expected in route.nodes, (
f"Expected '{expected}' as a node in route, "
f"got nodes: {list(route.nodes.keys())}"
)
node_data = route.nodes[expected]
assert node_data.get("type") == "actor", (
f"Expected node type 'actor', got '{node_data.get('type')}'"
)
@then("the route should be the explicitly defined one")
def step_route_is_explicit(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
assert "my_graph" in rc.routes, (
f"Expected 'my_graph' route, got: {list(rc.routes.keys())}"
)
@then("the result should be non-empty")
def step_result_non_empty(context: Any) -> None:
assert context.run_result, f"Expected non-empty result, got: '{context.run_result}'"
# ── Fix C: actor key → provider/model translation ──────────────────────
@given('a nested actors map config with actor key "{actor_ref}"')
def step_nested_actors_with_actor_key(context: Any, actor_ref: str) -> None:
context.nested_actors_config = {
"actors": {
"test_agent": {
"type": "llm",
"config": {
"actor": actor_ref,
"temperature": 0.3,
},
},
},
}
@given(
'a nested actors map config with explicit provider "{provider}" and model "{model}"'
)
def step_nested_actors_explicit_provider_model(
context: Any, provider: str, model: str
) -> None:
context.nested_actors_config = {
"actors": {
"test_agent": {
"type": "llm",
"config": {
"provider": provider,
"model": model,
"temperature": 0.5,
},
},
},
}
@then('the agent config should have provider "{expected_provider}"')
def step_agent_config_has_provider(context: Any, expected_provider: str) -> None:
rc: ReactiveConfig = context.reactive_config
agent = next(iter(rc.agents.values()))
actual = agent.config.get("provider")
assert actual == expected_provider, (
f"Expected provider '{expected_provider}', got '{actual}'"
)
@then('the agent config should have model "{expected_model}"')
def step_agent_config_has_model(context: Any, expected_model: str) -> None:
rc: ReactiveConfig = context.reactive_config
agent = next(iter(rc.agents.values()))
actual = agent.config.get("model")
assert actual == expected_model, (
f"Expected model '{expected_model}', got '{actual}'"
)
@then("the agent config should not have provider set")
def step_agent_config_no_provider(context: Any) -> None:
rc: ReactiveConfig = context.reactive_config
agent = next(iter(rc.agents.values()))
assert "provider" not in agent.config, (
f"Expected no provider key in config, but found: '{agent.config.get('provider')}'"
)
@@ -1,147 +0,0 @@
"""Step definitions for architecture pool supervisor milestone assignment."""
import re
from pathlib import Path
from typing import Any
from behave import given, then, when
@given("the architecture-pool-supervisor.md file exists")
def step_arch_supervisor_file_exists(context: Any) -> None:
"""Verify the architecture-pool-supervisor.md file exists."""
file_path = Path(".opencode/agents/architecture-pool-supervisor.md")
assert file_path.exists(), f"File {file_path} does not exist"
# Read the file content
with open(file_path, encoding="utf-8") as f:
context.file_content = f.read()
assert context.file_content, "File is empty"
@when('I read the "{section_name}" section')
def step_read_section(context: Any, section_name: str) -> None:
"""Extract a specific section from the file."""
# Find the section header
pattern = rf"## {re.escape(section_name)}\n(.*?)(?=\n## |\Z)"
match = re.search(pattern, context.file_content, re.DOTALL)
assert match, f"Section '{section_name}' not found in file"
context.section_content = match.group(1).strip()
@when("I read the permissions section")
def step_read_permissions_section(context: Any) -> None:
"""Extract the permissions section from the file."""
# Find the permissions section (between --- markers)
pattern = r"^---\n(.*?)\n---"
match = re.search(pattern, context.file_content, re.DOTALL | re.MULTILINE)
assert match, "Permissions section not found in file"
context.permissions_content = match.group(1).strip()
@then("the section should describe creating a feature branch")
def step_verify_feature_branch_description(context: Any) -> None:
"""Verify the section mentions creating a feature branch."""
assert "feature branch" in context.section_content.lower(), (
"Section should describe creating a feature branch"
)
@then("the section should describe committing spec changes")
def step_verify_commit_description(context: Any) -> None:
"""Verify the section mentions committing spec changes."""
assert "commit" in context.section_content.lower(), (
"Section should describe committing spec changes"
)
@then('the section should describe creating a PR with "{label}" label')
def step_verify_pr_label_description(context: Any, label: str) -> None:
"""Verify the section mentions creating a PR with the specified label."""
assert "pr" in context.section_content.lower(), (
"Section should describe creating a PR"
)
assert label.lower() in context.section_content.lower(), (
f"Section should mention '{label}' label"
)
@then("the section should describe assigning the PR to the current active milestone")
def step_verify_milestone_assignment_description(context: Any) -> None:
"""Verify the section describes milestone assignment."""
assert "milestone" in context.section_content.lower(), (
"Section should describe assigning PR to milestone"
)
assert "current active milestone" in context.section_content.lower(), (
"Section should mention 'current active milestone'"
)
@then('the section should mention using "{function_name}" for milestone assignment')
def step_verify_function_mention(context: Any, function_name: str) -> None:
"""Verify the section mentions the specific function."""
assert function_name in context.section_content, (
f"Section should mention '{function_name}' function"
)
@then('the section should describe querying milestones using "{function_name}"')
def step_verify_milestone_query_function(context: Any, function_name: str) -> None:
"""Verify the section mentions querying milestones."""
assert function_name in context.section_content, (
f"Section should mention '{function_name}' for querying milestones"
)
@then("the section should describe graceful handling when no active milestone exists")
def step_verify_graceful_handling(context: Any) -> None:
"""Verify the section describes graceful error handling."""
assert (
"skip" in context.section_content.lower()
or "graceful" in context.section_content.lower()
), "Section should describe graceful handling when no milestone exists"
@then(
"the section should describe using the earliest milestone for multi-milestone specs"
)
def step_verify_multi_milestone_handling(context: Any) -> None:
"""Verify the section describes handling multi-milestone specs."""
assert (
"earliest" in context.section_content.lower()
or "multiple" in context.section_content.lower()
), "Section should describe handling specs spanning multiple milestones"
@then('"{function_name}" should be allowed')
def step_verify_function_allowed(context: Any, function_name: str) -> None:
"""Verify the function is allowed in permissions."""
# Check if the function is listed as allowed
pattern = rf'"{function_name}":\s*allow'
assert re.search(pattern, context.permissions_content), (
f"Function '{function_name}' should be allowed in permissions"
)
@then(
"the workflow should ensure specification PRs are tracked within milestone planning"
)
def step_verify_milestone_tracking(context: Any) -> None:
"""Verify the workflow ensures milestone tracking."""
assert "milestone" in context.section_content.lower(), (
"Workflow should ensure milestone tracking"
)
@then(
"the workflow should ensure PRs remain visible in the project's issue/PR dashboard"
)
def step_verify_pr_visibility(context: Any) -> None:
"""Verify the workflow ensures PR visibility."""
assert (
"dashboard" in context.section_content.lower()
or "visible" in context.section_content.lower()
), "Workflow should ensure PR visibility in dashboard"
+1 -1
View File
@@ -98,7 +98,7 @@ class RobotTestDataGenerator:
@staticmethod
def detail_depth() -> int:
"""Generate a realistic detail depth level."""
return _faker.random_int(min=1, max=10)
return _faker.random_int(min=1, max=9)
@staticmethod
def ulid() -> str:
+86 -1
View File
@@ -64,6 +64,54 @@ class ReactiveConfig(BaseModel):
prompts: dict[str, Any] = Field(default_factory=dict)
def _synthesise_single_node_route(
rc: ReactiveConfig,
actor_name: str,
route_name: str | None = None,
) -> None:
"""Add a minimal graph route with a ``message_router`` + actor to *rc*.
This is the fix for issue #10807: when a v3 ``type:llm`` or
``type:tool`` actor is parsed, or when the nested ``actors:`` map
format produces agents without routes, a graph route must be created
so that ``run_single_shot()`` can find it via ``_get_graph_route()``
and execute the agent through ``GraphExecutor``.
The synthesised route consists of:
* A ``message_router`` node (``__router__``) with a single catch-all
rule that forwards every prompt to the actor node.
* An ``actor`` node wired to the registered agent.
* An edge from the actor node to ``end`` so that ``execute()``
returns the result after the agent processes the prompt.
Without this route the RxPY ``__input__`` stream receives the prompt
but has no downstream subscribers, causing the LLM to never be
invoked and ``run_single_shot()`` to return ``""``.
"""
name = route_name or f"{actor_name}_run"
router_node_name = "__router__"
rc.routes[name] = RouteConfig(
name=name,
type=RouteType.GRAPH,
nodes={
router_node_name: {
"type": "message_router",
"rules": [
{
"match_type": "prefix",
"pattern": "",
"target": actor_name,
},
],
},
actor_name: {"type": "actor", "agent": actor_name},
},
edges=[{"source": actor_name, "target": "end"}],
entry_point=router_node_name,
metadata={"v3_actor": actor_name, "synthesised": True},
)
class ReactiveConfigParser:
def __init__(self) -> None:
self.env_pattern = re.compile(r"\${([A-Za-z0-9_]+)(?::([^}]*))?}")
@@ -121,10 +169,27 @@ class ReactiveConfigParser:
agents_data = data.get("agents") or data.get("actors") or {}
for name, agent_data in (agents_data or {}).items():
raw_config = dict(agent_data.get("config", {}) or {})
# Fix #10807-C: translate v3 nested actor config format to
# reactive format. The v3 spec stores the model as
# ``actor: "provider/model"`` inside each actor's ``config:``
# block. ``SimpleLLMAgent._resolve_llm()`` expects
# ``provider`` and ``model`` keys. When the ``actor`` key is
# present but ``provider``/``model`` are absent, split it here
# so the LLM can be instantiated correctly.
if "actor" in raw_config and not raw_config.get("provider"):
actor_ref = str(raw_config.pop("actor"))
if "/" in actor_ref:
inferred_provider, inferred_model = actor_ref.split("/", 1)
raw_config["provider"] = inferred_provider
if "model" not in raw_config:
raw_config["model"] = inferred_model
elif "model" not in raw_config:
raw_config["model"] = actor_ref
rc.agents[name] = AgentConfig(
name=name,
type=agent_data.get("type", "llm"),
config=agent_data.get("config", {}),
config=raw_config,
)
routes_cfg = data.get("routes", {}) or {}
@@ -175,6 +240,21 @@ class ReactiveConfigParser:
bridge=bridge,
)
# Fix #10807-B: when the YAML defines agents (e.g. via the nested
# ``actors:`` map format from spec-compliant v3 configs) but no
# ``routes:`` key, synthesise a default single-node graph route
# using ``cleveragents.default_actor`` (or the first agent). This
# ensures run_single_shot() can invoke the LLM.
if rc.agents and not rc.routes:
cleveragents_meta = data.get("cleveragents") or {}
default_actor_name = str(cleveragents_meta.get("default_actor") or "")
agent_name = (
default_actor_name
if default_actor_name and default_actor_name in rc.agents
else next(iter(rc.agents))
)
_synthesise_single_node_route(rc, agent_name)
rc.merges = data.get("merges", []) or []
rc.splits = data.get("splits", []) or []
rc.pipelines = {
@@ -293,6 +373,11 @@ class ReactiveConfigParser:
type=actor_type,
config=agent_config,
)
# Fix #10807-A: synthesise a single-node graph route so that
# run_single_shot() can find a graph route and invoke the LLM
# instead of falling through to the RxPY stream path (which
# has no subscribers and silently returns "").
_synthesise_single_node_route(rc, actor_name)
elif actor_type == "graph":
# Map route nodes → agents and edges → graph routes.
route_raw = data.get("route")