fix(reactive): synthesise execution route for type:llm actors in ReactiveConfigParser #10818
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user