f2f7aa5dc9
Implement multiple Stage A/B/E/SEC milestones for the v3 lifecycle system: - Stage A5.3+A5.4: Add LifecycleActionModel and LifecyclePlanModel SQLAlchemy models with to_domain()/from_domain() conversion methods - Stage A5.6: Implement ActionRepository with full CRUD, namespace/state queries, referential integrity checks, and retry decorator - Stage E1: Add subplan domain models (ExecutionMode, SubplanMergeStrategy, SubplanConfig, SubplanStatus, SubplanAttempt, SubplanFailureHandler) with computed properties on Plan (is_subplan, is_root_plan, depth, has_subplans) - Stage A6: Add AutomationLevel enum (MANUAL, REVIEW_BEFORE_APPLY, FULL_AUTOMATION), settings integration, PlanLifecycleService auto-progression, pause/resume, and CLI commands (--automation-level, set-automation-level) - Stage SEC1: Remove eval()/exec() from stream_router.py, replace with named operation and transform registries; code blocks and unregistered transforms now raise StreamRoutingError - Add langchain-anthropic dependency - Update BDD tests for security changes and relax ADR directory requirement
1199 lines
42 KiB
Python
1199 lines
42 KiB
Python
"""Behave steps for reactive application coverage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.core.exceptions import CleverAgentsException, UnsafeConfigurationError
|
|
from cleveragents.reactive.application import ReactiveCleverAgentsApp
|
|
from cleveragents.reactive.config_parser import AgentConfig, ReactiveConfig
|
|
from cleveragents.reactive.context_manager import ContextManager
|
|
from cleveragents.reactive.route import BridgeConfig, RouteConfig, RouteType
|
|
from cleveragents.reactive.stream_router import SimpleLLMAgent, SimpleToolAgent
|
|
|
|
|
|
def _write_config_file(data: dict[str, Any]) -> Path:
|
|
temp_dir = Path(tempfile.mkdtemp())
|
|
config_path = temp_dir / "config.yml"
|
|
config_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
|
return config_path
|
|
|
|
|
|
def _run_async(coro):
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
if loop.is_closed():
|
|
raise RuntimeError
|
|
if loop.is_running():
|
|
return asyncio.run(coro)
|
|
except Exception: # pylint: disable=broad-except
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
return loop.run_until_complete(coro)
|
|
|
|
|
|
class RotatingOperators:
|
|
def __init__(self, sequences: list[list[dict[str, Any]]]):
|
|
self.sequences = sequences
|
|
self.index = 0
|
|
|
|
def __iter__(self):
|
|
sequence = self.sequences[self.index]
|
|
self.index = (self.index + 1) % len(self.sequences)
|
|
return iter(sequence)
|
|
|
|
|
|
class ProcessAgent:
|
|
def process(self, message):
|
|
return f"{message}_process"
|
|
|
|
|
|
class SkipOnceContainsDict(dict):
|
|
def __init__(self, skip_keys: set[str]):
|
|
super().__init__()
|
|
self._skip_keys = {key: True for key in skip_keys}
|
|
|
|
def __contains__(self, key):
|
|
if self._skip_keys.get(key):
|
|
self._skip_keys[key] = False
|
|
return True
|
|
return super().__contains__(key)
|
|
|
|
|
|
class EndSentinel:
|
|
def __eq__(self, other):
|
|
return other == "end"
|
|
|
|
|
|
class ErrorEmittingAgent:
|
|
def __init__(self, router):
|
|
self.router = router
|
|
|
|
def process_message_sync(self, message, metadata=None):
|
|
self.router.streams["__error__"].on_next("boom")
|
|
return "ignored"
|
|
|
|
|
|
class EmptyAgent:
|
|
def __call__(self, message):
|
|
return ""
|
|
|
|
|
|
@given("a temporary reactive config with a stream route")
|
|
def step_temp_stream_config(context: Context) -> None:
|
|
data = {
|
|
"agents": {"echo_actor": {"type": "custom", "config": {}}},
|
|
"routes": {
|
|
"echo_stream": {
|
|
"type": "stream",
|
|
"agents": ["echo_actor"],
|
|
"operators": [{"type": "map", "params": {"agent": "echo_actor"}}],
|
|
}
|
|
},
|
|
"global_context": {},
|
|
}
|
|
context.config_path = _write_config_file(data)
|
|
|
|
|
|
@given("a temporary reactive config that requires unsafe mode")
|
|
def step_temp_unsafe_config(context: Context) -> None:
|
|
data = {
|
|
"agents": {"echo_actor": {"type": "custom", "config": {}}},
|
|
"routes": {
|
|
"echo_stream": {
|
|
"type": "stream",
|
|
"agents": ["echo_actor"],
|
|
"operators": [{"type": "map", "params": {"agent": "echo_actor"}}],
|
|
}
|
|
},
|
|
"global_context": {"unsafe": True},
|
|
}
|
|
context.config_path = _write_config_file(data)
|
|
|
|
|
|
@when(
|
|
'I initialize the reactive app with verbose "{verbose}" and temperature override "{temperature}"'
|
|
)
|
|
def step_init_app_with_override(
|
|
context: Context, verbose: str, temperature: str
|
|
) -> None:
|
|
context.original_log_level = logging.getLogger().level
|
|
context.init_error = None
|
|
context.temperature_override = float(temperature)
|
|
try:
|
|
context.app = ReactiveCleverAgentsApp(
|
|
[context.config_path],
|
|
verbose=int(verbose),
|
|
unsafe=True,
|
|
temperature_override=context.temperature_override,
|
|
)
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
context.init_error = exc
|
|
|
|
|
|
@when("I initialize the reactive app without the unsafe flag")
|
|
def step_init_app_without_unsafe(context: Context) -> None:
|
|
context.init_error = None
|
|
try:
|
|
context.app = ReactiveCleverAgentsApp(
|
|
[context.config_path],
|
|
verbose=0,
|
|
unsafe=False,
|
|
)
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
context.init_error = exc
|
|
|
|
|
|
@then("the reactive app should load the configuration")
|
|
def step_assert_loaded(context: Context) -> None:
|
|
assert context.init_error is None
|
|
assert context.app.config is not None
|
|
|
|
|
|
@then("the root logger level should be INFO")
|
|
def step_root_logger_info(context: Context) -> None:
|
|
assert logging.getLogger().level == logging.INFO
|
|
if hasattr(context, "original_log_level"):
|
|
logging.getLogger().setLevel(context.original_log_level)
|
|
|
|
|
|
@then('the config should include the temperature override "{temperature}"')
|
|
def step_temp_override_set(context: Context, temperature: str) -> None:
|
|
assert context.app.config is not None
|
|
assert context.app.config.global_context.get("_temperature_override") == float(
|
|
temperature
|
|
)
|
|
|
|
|
|
@then("the stream route should be registered")
|
|
def step_stream_route_registered(context: Context) -> None:
|
|
assert "echo_stream" in context.app.stream_router.streams
|
|
|
|
|
|
@then("an unsafe configuration error should be raised")
|
|
def step_unsafe_error(context: Context) -> None:
|
|
assert isinstance(context.init_error, UnsafeConfigurationError)
|
|
|
|
|
|
@given("a reactive app instance")
|
|
def step_reactive_app_instance(context: Context) -> None:
|
|
context.original_log_level = logging.getLogger().level
|
|
context.app = ReactiveCleverAgentsApp()
|
|
|
|
|
|
@when("I configure logging for all verbosity levels")
|
|
def step_configure_logging_levels(context: Context) -> None:
|
|
context.logged_levels = {}
|
|
for verbose in (0, 1, 2, 3, 4):
|
|
context.app._configure_logging(verbose) # pylint: disable=protected-access
|
|
context.logged_levels[verbose] = logging.getLogger().level
|
|
|
|
|
|
@then("each verbosity should map to the expected logging level")
|
|
def step_logging_level_mappings(context: Context) -> None:
|
|
expected = {
|
|
0: logging.CRITICAL,
|
|
1: logging.ERROR,
|
|
2: logging.WARNING,
|
|
3: logging.INFO,
|
|
4: logging.DEBUG,
|
|
}
|
|
assert context.logged_levels == expected
|
|
logging.getLogger().setLevel(context.original_log_level)
|
|
|
|
|
|
@given("a reactive app with stream operators and varied agent types")
|
|
def step_app_with_varied_agents(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
agents = {
|
|
"tool_actor": AgentConfig(
|
|
name="tool_actor",
|
|
type="custom",
|
|
config={"tools": [{"operation": "uppercase"}]},
|
|
),
|
|
"llm_agent": AgentConfig(name="llm_agent", type="llm", config={}),
|
|
"plain_actor": AgentConfig(name="plain_actor", type="custom", config={}),
|
|
"loop_actor1": AgentConfig(name="loop_actor1", type="custom", config={}),
|
|
"loop_actor2": AgentConfig(name="loop_actor2", type="custom", config={}),
|
|
"loop_actor3": AgentConfig(name="loop_actor3", type="custom", config={}),
|
|
}
|
|
route = RouteConfig(
|
|
name="agent_route",
|
|
type=RouteType.STREAM,
|
|
agents=["tool_actor", "plain_actor"],
|
|
operators=[{"type": "map", "params": {"agent": "tool_actor"}}],
|
|
)
|
|
all_operators = [
|
|
{"params": {"actor": "loop_actor1"}},
|
|
{"params": {"actor": "loop_actor2"}},
|
|
{"params": {"actor": "loop_actor3"}},
|
|
]
|
|
object.__setattr__(route, "operators", all_operators)
|
|
context.app.config = ReactiveConfig(agents=agents, routes={"agent_route": route})
|
|
|
|
|
|
@when("I register agents from the reactive config")
|
|
def step_register_agents(context: Context) -> None:
|
|
context.app._register_agents_from_config() # pylint: disable=protected-access
|
|
|
|
|
|
@then("the stream router should include tool and llm agent aliases")
|
|
def step_agent_aliases_present(context: Context) -> None:
|
|
agents = context.app.stream_router.agents
|
|
assert "tool_actor" in agents
|
|
assert "tool_agent" in agents
|
|
assert "llm_agent" in agents
|
|
assert "llm_actor" in agents
|
|
|
|
|
|
@then("the operator referenced actors should be registered")
|
|
def step_operator_actors_registered(context: Context) -> None:
|
|
agents = context.app.stream_router.agents
|
|
assert "loop_actor1" in agents
|
|
assert "loop_actor2" in agents
|
|
assert "loop_actor3" in agents
|
|
|
|
|
|
@then("the tool actor should be registered as a SimpleToolAgent")
|
|
def step_tool_agent_type(context: Context) -> None:
|
|
assert isinstance(context.app.stream_router.agents["tool_actor"], SimpleToolAgent)
|
|
|
|
|
|
@then("the llm agent should be registered as a SimpleLLMAgent")
|
|
def step_llm_agent_type(context: Context) -> None:
|
|
assert isinstance(context.app.stream_router.agents["llm_agent"], SimpleLLMAgent)
|
|
|
|
|
|
@given("a reactive app without configuration")
|
|
def step_app_without_config(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
|
|
|
|
@when("I load configuration from a missing file")
|
|
def step_load_missing_config(context: Context) -> None:
|
|
context.load_error = None
|
|
missing_path = Path(tempfile.mkdtemp()) / "missing_reactive_config.yml"
|
|
try:
|
|
context.app.load_configuration([missing_path])
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
context.load_error = exc
|
|
|
|
|
|
@then("a configuration load error should be raised")
|
|
def step_assert_load_error(context: Context) -> None:
|
|
assert isinstance(context.load_error, CleverAgentsException)
|
|
assert "Failed to load configuration" in str(context.load_error)
|
|
|
|
|
|
@given("a reactive app with a stream route that has a bridge config")
|
|
def step_app_stream_bridge(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
route = RouteConfig(
|
|
name="bad_stream",
|
|
type=RouteType.STREAM,
|
|
operators=[{"type": "map", "params": {"agent": "dummy"}}],
|
|
bridge=BridgeConfig(),
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"bad_stream": route})
|
|
|
|
|
|
@when("I validate the reactive routes")
|
|
def step_validate_routes(context: Context) -> None:
|
|
context.validation_error = None
|
|
try:
|
|
context.app._validate_routes() # pylint: disable=protected-access
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
context.validation_error = exc
|
|
|
|
|
|
@then("route validation should fail for bridge config")
|
|
def step_validation_failed(context: Context) -> None:
|
|
assert isinstance(context.validation_error, CleverAgentsException)
|
|
|
|
|
|
@given("a reactive app with stream and graph routes and a merge")
|
|
def step_app_routes_with_merge(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
agents = {
|
|
"echo_actor": AgentConfig(name="echo_actor", type="custom", config={}),
|
|
}
|
|
stream_route = RouteConfig(
|
|
name="echo_stream",
|
|
type=RouteType.STREAM,
|
|
agents=["echo_actor"],
|
|
operators=[{"type": "map", "params": {"agent": "echo_actor"}}],
|
|
)
|
|
graph_route = RouteConfig(
|
|
name="graph_route",
|
|
type=RouteType.GRAPH,
|
|
nodes={"node1": {"type": "actor", "actor": "echo_actor"}},
|
|
edges=[
|
|
{"source": "start", "target": "node1"},
|
|
{"source": "node1", "target": "end"},
|
|
],
|
|
)
|
|
context.app.config = ReactiveConfig(
|
|
agents=agents,
|
|
routes={"echo_stream": stream_route, "graph_route": graph_route},
|
|
merges=[{"sources": ["merge_source"], "target": "merge_target"}],
|
|
)
|
|
context.app.stream_router.create_stream("merge_source")
|
|
context.app.stream_router.create_stream("merge_target")
|
|
|
|
|
|
@when("I build the reactive routes")
|
|
def step_build_routes(context: Context) -> None:
|
|
context.app._build_routes() # pylint: disable=protected-access
|
|
|
|
|
|
@then("the stream route should be created")
|
|
def step_stream_route_created(context: Context) -> None:
|
|
assert "echo_stream" in context.app.stream_router.streams
|
|
|
|
|
|
@then("a route bridge should be initialized for graph routes")
|
|
def step_route_bridge_initialized(context: Context) -> None:
|
|
assert context.app.route_bridge is not None
|
|
|
|
|
|
@then("merge subscriptions should forward messages")
|
|
def step_merge_forwarding(context: Context) -> None:
|
|
captured: list[Any] = []
|
|
context.app.stream_router.streams["merge_target"].subscribe(
|
|
lambda msg: captured.append(getattr(msg, "content", msg))
|
|
)
|
|
context.app.stream_router.streams["merge_source"].on_next("merged")
|
|
assert captured == ["merged"]
|
|
|
|
|
|
@then("rxpy stream presence should be detected")
|
|
def step_detect_rxpy_stream(context: Context) -> None:
|
|
assert context.app._is_rxpy_stream_present() is True # pylint: disable=protected-access
|
|
|
|
|
|
@then("the first graph route should be returned")
|
|
def step_first_graph_route(context: Context) -> None:
|
|
route = context.app._get_graph_route() # pylint: disable=protected-access
|
|
assert route is not None
|
|
assert route.name == "graph_route"
|
|
|
|
|
|
@given("a reactive app with a workflow controller graph route")
|
|
def step_workflow_controller_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("workflow_controller", lambda msg: msg)
|
|
route = RouteConfig(
|
|
name="workflow_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{
|
|
"match_type": "prefix",
|
|
"pattern": "",
|
|
"target": "workflow_controller",
|
|
}
|
|
],
|
|
},
|
|
"workflow_controller": {"type": "actor", "actor": "workflow_controller"},
|
|
},
|
|
edges=[{"source": "workflow_controller", "target": "router"}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"workflow_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the workflow controller route with prompt "{prompt}"')
|
|
def step_execute_workflow_route(context: Context, prompt: str) -> None:
|
|
context.workflow_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then("the workflow route should return the extracted message")
|
|
def step_workflow_return(context: Context) -> None:
|
|
assert context.workflow_result == "ROUTE_ASK_TOPIC:Hello"
|
|
|
|
|
|
@then("the graph context should include default writing metadata")
|
|
def step_graph_context_defaults(context: Context) -> None:
|
|
ctx = context.app.config.global_context
|
|
assert "stage_order" in ctx
|
|
assert "writing_stage" in ctx
|
|
assert "paper_details" in ctx
|
|
assert "latex_generation" in ctx["stage_order"]
|
|
|
|
|
|
@given("a reactive app with a chained graph route")
|
|
def step_chained_graph_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
# SEC1: Use named operation instead of legacy code block.
|
|
SimpleToolAgent.register_operation(
|
|
"append_tool", lambda content, meta, ctx: str(content) + "_tool"
|
|
)
|
|
context.app.stream_router.register_agent(
|
|
"tool_actor",
|
|
SimpleToolAgent([{"operation": "append_tool"}]),
|
|
)
|
|
context.app.stream_router.register_agent(
|
|
"callable_actor", lambda msg: f"{msg}_callable"
|
|
)
|
|
context.app.stream_router.register_agent("process_actor", ProcessAgent())
|
|
|
|
class EdgeObj:
|
|
def __init__(self, source: str, target: str, condition: Any = None):
|
|
self.source = source
|
|
self.target = target
|
|
self.condition = condition
|
|
|
|
route = RouteConfig(
|
|
name="chain_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{"match_type": "prefix", "pattern": "", "target": None},
|
|
{
|
|
"match_type": "prefix",
|
|
"pattern": "TOOL:",
|
|
"target": "tool_node",
|
|
"extract_message": True,
|
|
"separator": ":",
|
|
},
|
|
],
|
|
},
|
|
"tool_node": {"type": "actor", "actor": "tool_actor"},
|
|
"callable_node": {"type": "actor", "actor": "callable_actor"},
|
|
"process_node": {"type": "actor", "actor": "process_actor"},
|
|
},
|
|
edges=[{"source": "tool_node", "target": "callable_node"}],
|
|
)
|
|
edges: list[Any] = [
|
|
{
|
|
"source": "tool_node",
|
|
"target": "router",
|
|
"condition": {"type": "context_value"},
|
|
},
|
|
{"source": "tool_node", "target": "callable_node"},
|
|
EdgeObj(
|
|
"callable_node",
|
|
"process_node",
|
|
{"type": "context_value", "key": "route_key", "value": "go"},
|
|
),
|
|
{"source": "process_node", "target": "end"},
|
|
]
|
|
object.__setattr__(route, "edges", edges)
|
|
context.app.config = ReactiveConfig(
|
|
routes={"chain_graph": route}, global_context={"route_key": "go"}
|
|
)
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the chained graph route with prompt "{prompt}"')
|
|
def step_execute_chain_route(context: Context, prompt: str) -> None:
|
|
context.chain_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then('the chained graph route should return "{expected}"')
|
|
def step_chain_result(context: Context, expected: str) -> None:
|
|
assert context.chain_result == expected
|
|
|
|
|
|
@given("a reactive app with a graph route without router rules")
|
|
def step_graph_route_without_rules(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
route = RouteConfig(
|
|
name="empty_rules",
|
|
type=RouteType.GRAPH,
|
|
nodes={"actor": {"type": "actor", "actor": "missing"}},
|
|
edges=[],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"empty_rules": route})
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the graph route with prompt "{prompt}"')
|
|
def step_execute_graph_route(context: Context, prompt: str) -> None:
|
|
context.graph_prompt = prompt
|
|
context.graph_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then("the graph route should return an empty string")
|
|
def step_graph_empty_result(context: Context) -> None:
|
|
assert context.graph_result == ""
|
|
|
|
|
|
@given("a reactive app with a graph route targeting a missing agent")
|
|
def step_missing_agent_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
route = RouteConfig(
|
|
name="missing_agent",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{
|
|
"match_type": "contains",
|
|
"pattern": "MISSING",
|
|
"target": "missing_node",
|
|
}
|
|
],
|
|
}
|
|
},
|
|
edges=[],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"missing_agent": route})
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the missing agent route with prompt "{prompt}"')
|
|
def step_execute_missing_agent(context: Context, prompt: str) -> None:
|
|
context.missing_agent_prompt = prompt
|
|
context.missing_agent_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then("the graph route should return the original prompt")
|
|
def step_missing_agent_result(context: Context) -> None:
|
|
assert context.missing_agent_result == context.missing_agent_prompt
|
|
|
|
|
|
@given("a reactive app with a suffix matching graph route")
|
|
def step_suffix_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
route = RouteConfig(
|
|
name="suffix_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{
|
|
"match_type": "suffix",
|
|
"pattern": "!",
|
|
"target": "end",
|
|
}
|
|
],
|
|
}
|
|
},
|
|
edges=[],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"suffix_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the suffix route with prompt "{prompt}"')
|
|
def step_execute_suffix_route(context: Context, prompt: str) -> None:
|
|
context.suffix_prompt = prompt
|
|
context.suffix_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then("the suffix route should return the original prompt")
|
|
def step_suffix_result(context: Context) -> None:
|
|
assert context.suffix_result == context.suffix_prompt
|
|
|
|
|
|
@given("a reactive app with a stream route configured")
|
|
def step_app_stream_route(context: Context) -> None:
|
|
class PrefixAgent:
|
|
def process_message_sync(self, message, metadata=None):
|
|
return "DISCOVERY_RESPONSE:Hello\nROUTE_ASK_TOPIC:Prompt"
|
|
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("echo_actor", PrefixAgent())
|
|
route = RouteConfig(
|
|
name="echo_stream",
|
|
type=RouteType.STREAM,
|
|
agents=["echo_actor"],
|
|
operators=[{"type": "map", "params": {"agent": "echo_actor"}}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"echo_stream": route})
|
|
context.app._build_routes() # pylint: disable=protected-access
|
|
|
|
|
|
@when("I run a single shot without allowing rxpy")
|
|
def step_run_single_shot_blocked(context: Context) -> None:
|
|
context.single_shot_error = None
|
|
try:
|
|
_run_async(context.app.run_single_shot("hello"))
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
context.single_shot_error = exc
|
|
|
|
|
|
@then("the single shot should fail with a reactive stream error")
|
|
def step_single_shot_error(context: Context) -> None:
|
|
assert isinstance(context.single_shot_error, CleverAgentsException)
|
|
|
|
|
|
@when("I run a single shot allowing rxpy")
|
|
def step_run_single_shot_allowed(context: Context) -> None:
|
|
context.single_shot_result = _run_async(
|
|
context.app.run_single_shot("hello", allow_rxpy_in_run_mode=True)
|
|
)
|
|
|
|
|
|
@then("the stream output should have routing prefixes removed")
|
|
def step_stream_prefix_stripped(context: Context) -> None:
|
|
assert context.single_shot_result == "Hello\nPrompt"
|
|
|
|
|
|
@given("a reactive app with a graph route configured")
|
|
def step_app_graph_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent(
|
|
"prefix_actor", lambda _msg: "ROUTE_ASK_TOPIC:Graph"
|
|
)
|
|
route = RouteConfig(
|
|
name="graph_route",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{
|
|
"match_type": "prefix",
|
|
"pattern": "CMD:",
|
|
"target": "actor_node",
|
|
"extract_message": True,
|
|
"separator": ":",
|
|
}
|
|
],
|
|
},
|
|
"actor_node": {"type": "actor", "actor": "prefix_actor"},
|
|
},
|
|
edges=[{"source": "actor_node", "target": "end"}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"graph_route": route})
|
|
|
|
|
|
@when('I run a graph single shot with prompt "{prompt}"')
|
|
def step_run_graph_single_shot(context: Context, prompt: str) -> None:
|
|
context.graph_single_shot_result = _run_async(context.app.run_single_shot(prompt))
|
|
|
|
|
|
@then("the graph output should have routing prefixes removed")
|
|
def step_graph_prefix_stripped(context: Context) -> None:
|
|
assert context.graph_single_shot_result == "Graph"
|
|
|
|
|
|
@given("a context manager for run with context")
|
|
def step_context_manager_for_run(context: Context) -> None:
|
|
temp_dir = Path(tempfile.mkdtemp())
|
|
context.context_manager = ContextManager("reactive_test", context_dir=temp_dir)
|
|
|
|
|
|
@when('I run the reactive app with context and prompt "{prompt}"')
|
|
def step_run_with_context(context: Context, prompt: str) -> None:
|
|
context.run_with_context_result = _run_async(
|
|
context.app.run_with_context(prompt, context_manager=context.context_manager)
|
|
)
|
|
|
|
|
|
@then("the context manager should have stored user and assistant messages")
|
|
def step_context_messages(context: Context) -> None:
|
|
messages = context.context_manager.get_conversation_history()
|
|
assert len(messages) == 2
|
|
assert messages[0]["role"] == "user"
|
|
assert messages[1]["role"] == "assistant"
|
|
|
|
|
|
@then("the context manager should persist the global context")
|
|
def step_context_global_context(context: Context) -> None:
|
|
ctx = context.context_manager.get_global_context()
|
|
assert "writing_stage" in ctx
|
|
|
|
|
|
@when("I call the reactive helper methods without configuration")
|
|
def step_call_helpers_without_config(context: Context) -> None:
|
|
context.initial_agents = dict(context.app.stream_router.agents)
|
|
context.helper_error = None
|
|
try:
|
|
context.app._register_agents_from_config() # pylint: disable=protected-access
|
|
context.app._validate_routes() # pylint: disable=protected-access
|
|
context.app._build_routes() # pylint: disable=protected-access
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
context.helper_error = exc
|
|
context.rxpy_present = context.app._is_rxpy_stream_present() # pylint: disable=protected-access
|
|
context.graph_route = context.app._get_graph_route() # pylint: disable=protected-access
|
|
|
|
|
|
@then("the reactive helper methods should return defaults")
|
|
def step_helpers_return_defaults(context: Context) -> None:
|
|
assert context.helper_error is None
|
|
assert context.rxpy_present is False
|
|
assert context.graph_route is None
|
|
assert context.app.stream_router.agents == context.initial_agents
|
|
|
|
|
|
@given("a reactive app with deferred route and operator agents")
|
|
def step_deferred_route_agents(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
deferred = ["route_actor", "op1_actor", "op2_actor", "op3_actor"]
|
|
context.deferred_agents = deferred
|
|
agents = {
|
|
name: AgentConfig(name=name, type="custom", config={}) for name in deferred
|
|
}
|
|
route = RouteConfig(
|
|
name="deferred_route",
|
|
type=RouteType.STREAM,
|
|
agents=["route_actor"],
|
|
operators=[
|
|
{"type": "map", "params": {"actor": "op1_actor"}},
|
|
{"type": "map", "params": {"agent": "op2_actor"}},
|
|
{"type": "map", "params": {"actor": "op3_actor"}},
|
|
],
|
|
)
|
|
context.app.config = ReactiveConfig(agents=agents, routes={"deferred_route": route})
|
|
|
|
|
|
@when("I register deferred route agents from the reactive config")
|
|
def step_register_deferred_agents(context: Context) -> None:
|
|
context.app._register_agents_from_config() # pylint: disable=protected-access
|
|
|
|
|
|
@then("deferred route agents and operator aliases should be registered")
|
|
def step_deferred_agents_registered(context: Context) -> None:
|
|
agents = context.app.stream_router.agents
|
|
for name in context.deferred_agents:
|
|
assert name in agents
|
|
alias = f"{name[:-6]}_agent"
|
|
assert alias in agents
|
|
|
|
|
|
@given(
|
|
"a reactive app with a stream route referencing an unknown actor and a missing merge target"
|
|
)
|
|
def step_stream_unknown_actor_merge(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
route = RouteConfig(
|
|
name="fallback_stream",
|
|
type=RouteType.STREAM,
|
|
agents=[],
|
|
operators=[{"type": "map", "params": {"actor": "fallback_actor"}}],
|
|
)
|
|
context.app.config = ReactiveConfig(
|
|
routes={"fallback_stream": route},
|
|
merges=[{"sources": ["missing_source"], "target": "missing_target"}],
|
|
)
|
|
|
|
|
|
@then("a fallback stream agent should be registered")
|
|
def step_fallback_agent_registered(context: Context) -> None:
|
|
assert "fallback_actor" in context.app.stream_router.agents
|
|
|
|
|
|
@then("the missing merge target should not be created")
|
|
def step_missing_merge_target(context: Context) -> None:
|
|
assert "missing_target" not in context.app.stream_router.streams
|
|
|
|
|
|
@given("a reactive app with a graph route using a non-dict edge condition")
|
|
def step_graph_non_dict_condition(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("echo_actor", lambda msg: msg)
|
|
route = RouteConfig(
|
|
name="cond_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{"match_type": "prefix", "pattern": "", "target": "echo_node"}
|
|
],
|
|
},
|
|
"echo_node": {"type": "actor", "actor": "echo_actor"},
|
|
},
|
|
edges=[{"source": "echo_node", "target": "end", "condition": "always"}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"cond_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@given("a reactive app with a graph route using an empty suffix rule")
|
|
def step_graph_empty_suffix(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
route = RouteConfig(
|
|
name="suffix_empty",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [{"match_type": "suffix", "pattern": "", "target": "end"}],
|
|
}
|
|
},
|
|
edges=[],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"suffix_empty": route})
|
|
context.route = route
|
|
|
|
|
|
@given("a reactive app with a graph route extracting without a separator")
|
|
def step_graph_extract_no_separator(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
route = RouteConfig(
|
|
name="extract_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{
|
|
"match_type": "prefix",
|
|
"pattern": "PREFIX",
|
|
"target": "end",
|
|
"extract_message": True,
|
|
"separator": ":",
|
|
}
|
|
],
|
|
}
|
|
},
|
|
edges=[],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"extract_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@given("a reactive app with a graph route using a process agent")
|
|
def step_graph_process_agent(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("proc_actor", ProcessAgent())
|
|
route = RouteConfig(
|
|
name="process_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{
|
|
"match_type": "prefix",
|
|
"pattern": "PROC:",
|
|
"target": "proc_node",
|
|
"extract_message": True,
|
|
"separator": ":",
|
|
}
|
|
],
|
|
},
|
|
"proc_node": {"type": "actor", "actor": "proc_actor"},
|
|
},
|
|
edges=[{"source": "proc_node", "target": "end"}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"process_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@given("a reactive app with a graph route using a non-callable agent")
|
|
def step_graph_non_callable_agent(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("noop_actor", object())
|
|
route = RouteConfig(
|
|
name="noop_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{"match_type": "prefix", "pattern": "", "target": "noop_node"}
|
|
],
|
|
},
|
|
"noop_node": {"type": "actor", "actor": "noop_actor"},
|
|
},
|
|
edges=[{"source": "noop_node", "target": "end"}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"noop_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@given("a reactive app with a graph route returning an empty response")
|
|
def step_graph_empty_response(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("empty_actor", EmptyAgent())
|
|
route = RouteConfig(
|
|
name="empty_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{"match_type": "prefix", "pattern": "", "target": "empty_node"}
|
|
],
|
|
},
|
|
"empty_node": {"type": "actor", "actor": "empty_actor"},
|
|
},
|
|
edges=[{"source": "empty_node", "target": "end"}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"empty_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@then("the graph route should return the prompt unchanged")
|
|
def step_graph_prompt_unchanged(context: Context) -> None:
|
|
assert context.graph_result == context.graph_prompt
|
|
|
|
|
|
@then('the graph route should return "{expected}"')
|
|
def step_graph_route_expected(context: Context, expected: str) -> None:
|
|
assert context.graph_result == expected
|
|
|
|
|
|
@given("a reactive app with a chained graph route that breaks to the router")
|
|
def step_chained_break_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("starter_actor", lambda _msg: "DONE")
|
|
context.app.stream_router.register_agent("tool_actor", SimpleToolAgent())
|
|
context.app.stream_router.register_agent("weird_actor", object())
|
|
route = RouteConfig(
|
|
name="chain_break",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{"match_type": "prefix", "pattern": "START", "target": "start"},
|
|
{"match_type": "prefix", "pattern": "DONE", "target": "end"},
|
|
],
|
|
},
|
|
"start": {"type": "actor", "actor": "starter_actor"},
|
|
"tool_node": {"type": "actor", "actor": "tool_actor"},
|
|
"weird_node": {"type": "actor", "actor": "weird_actor"},
|
|
},
|
|
edges=[
|
|
{"source": "start", "target": "tool_node"},
|
|
{"source": "tool_node", "target": "weird_node"},
|
|
{"source": "weird_node", "target": "router"},
|
|
],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"chain_break": route})
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the chained break route with prompt "{prompt}"')
|
|
def step_execute_chained_break(context: Context, prompt: str) -> None:
|
|
context.chained_break_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then('the chained break route should return "{expected}"')
|
|
def step_chained_break_result(context: Context, expected: str) -> None:
|
|
assert context.chained_break_result == expected
|
|
|
|
|
|
@given("a reactive app with a chained graph route that has no chained targets")
|
|
def step_chained_empty_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("starter_actor", lambda _msg: "DONE")
|
|
context.app.stream_router.register_agent("tool_actor", SimpleToolAgent())
|
|
route = RouteConfig(
|
|
name="chain_empty",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{"match_type": "prefix", "pattern": "BEGIN", "target": "start"},
|
|
{"match_type": "prefix", "pattern": "DONE", "target": "end"},
|
|
],
|
|
},
|
|
"start": {"type": "actor", "actor": "starter_actor"},
|
|
"tool_node": {"type": "actor", "actor": "tool_actor"},
|
|
},
|
|
edges=[{"source": "start", "target": "tool_node"}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"chain_empty": route})
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the chained empty route with prompt "{prompt}"')
|
|
def step_execute_chained_empty(context: Context, prompt: str) -> None:
|
|
context.chained_empty_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then('the chained empty route should return "{expected}"')
|
|
def step_chained_empty_result(context: Context, expected: str) -> None:
|
|
assert context.chained_empty_result == expected
|
|
|
|
|
|
@given("a reactive app with a graph route lacking edges")
|
|
def step_looping_graph_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("loop_actor", lambda _msg: "LOOP")
|
|
route = RouteConfig(
|
|
name="loop_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [{"match_type": "prefix", "pattern": "", "target": "loop"}],
|
|
},
|
|
"loop": {"type": "actor", "actor": "loop_actor"},
|
|
},
|
|
edges=[],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"loop_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the looping graph route with prompt "{prompt}"')
|
|
def step_execute_looping_graph(context: Context, prompt: str) -> None:
|
|
context.looping_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then('the looping graph route should return "{expected}"')
|
|
def step_looping_graph_result(context: Context, expected: str) -> None:
|
|
assert context.looping_result == expected
|
|
|
|
|
|
@given("a reactive app with a graph route using an end-like sentinel target")
|
|
def step_sentinel_graph_route(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent("value_actor", lambda _msg: "value")
|
|
route = RouteConfig(
|
|
name="sentinel_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{
|
|
"match_type": "prefix",
|
|
"pattern": "SENTINEL",
|
|
"target": "value_node",
|
|
}
|
|
],
|
|
},
|
|
"value_node": {"type": "actor", "actor": "value_actor"},
|
|
},
|
|
edges=[{"source": "value_node", "target": EndSentinel()}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"sentinel_graph": route})
|
|
context.route = route
|
|
|
|
|
|
@when('I execute the sentinel graph route with prompt "{prompt}"')
|
|
def step_execute_sentinel_graph(context: Context, prompt: str) -> None:
|
|
context.sentinel_result = context.app._execute_graph_route( # pylint: disable=protected-access
|
|
prompt, context.route
|
|
)
|
|
|
|
|
|
@then('the sentinel graph route should return "{expected}"')
|
|
def step_sentinel_graph_result(context: Context, expected: str) -> None:
|
|
assert context.sentinel_result == expected
|
|
|
|
|
|
@given("a reactive app with a graph route that emits a discovery prefix")
|
|
def step_graph_route_with_prefix(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent(
|
|
"prefix_actor", lambda _msg: "DISCOVERY_RESPONSE:Hello"
|
|
)
|
|
route = RouteConfig(
|
|
name="prefix_graph",
|
|
type=RouteType.GRAPH,
|
|
nodes={
|
|
"router": {
|
|
"type": "message_router",
|
|
"rules": [
|
|
{
|
|
"match_type": "prefix",
|
|
"pattern": "CMD:",
|
|
"target": "prefix_node",
|
|
"extract_message": True,
|
|
"separator": ":",
|
|
}
|
|
],
|
|
},
|
|
"prefix_node": {"type": "actor", "actor": "prefix_actor"},
|
|
},
|
|
edges=[{"source": "prefix_node", "target": "end"}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"prefix_graph": route})
|
|
|
|
|
|
@given("a context manager with global context")
|
|
def step_context_manager_with_global(context: Context) -> None:
|
|
temp_dir = Path(tempfile.mkdtemp())
|
|
context.context_manager = ContextManager("reactive_global", context_dir=temp_dir)
|
|
context.context_manager.global_context = {"session": "demo"}
|
|
|
|
|
|
@when('I run a graph single shot with context and prompt "{prompt}"')
|
|
def step_run_graph_single_shot_context(context: Context, prompt: str) -> None:
|
|
context.graph_context_result = _run_async(
|
|
context.app.run_single_shot(prompt, context_manager=context.context_manager)
|
|
)
|
|
|
|
|
|
@then('the graph output should be "{expected}"')
|
|
def step_graph_output_expected(context: Context, expected: str) -> None:
|
|
assert context.graph_context_result == expected
|
|
|
|
|
|
@then("the reactive config should include the context global values")
|
|
def step_config_includes_globals(context: Context) -> None:
|
|
assert context.app.config is not None
|
|
for key, value in context.context_manager.global_context.items():
|
|
assert context.app.config.global_context.get(key) == value
|
|
|
|
|
|
@given("a reactive app with a stream route that emits an error")
|
|
def step_stream_route_emits_error(context: Context) -> None:
|
|
context.app = ReactiveCleverAgentsApp()
|
|
context.app.stream_router.register_agent(
|
|
"error_actor", ErrorEmittingAgent(context.app.stream_router)
|
|
)
|
|
route = RouteConfig(
|
|
name="error_stream",
|
|
type=RouteType.STREAM,
|
|
agents=["error_actor"],
|
|
operators=[{"type": "map", "params": {"agent": "error_actor"}}],
|
|
)
|
|
context.app.config = ReactiveConfig(routes={"error_stream": route})
|
|
context.app._build_routes() # pylint: disable=protected-access
|
|
|
|
|
|
@when("I run a single shot that triggers a stream error")
|
|
def step_run_single_shot_error(context: Context) -> None:
|
|
context.single_shot_error = None
|
|
try:
|
|
_run_async(context.app.run_single_shot("boom", allow_rxpy_in_run_mode=True))
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
context.single_shot_error = exc
|
|
|
|
|
|
@then("the single shot should raise a reactive stream error")
|
|
def step_single_shot_stream_error(context: Context) -> None:
|
|
assert isinstance(context.single_shot_error, CleverAgentsException)
|
|
|
|
|
|
@when("I run a single shot with no routes")
|
|
def step_run_single_shot_no_routes(context: Context) -> None:
|
|
context.single_shot_result = _run_async(context.app.run_single_shot("ping"))
|
|
|
|
|
|
@then("the single shot should return an empty string")
|
|
def step_single_shot_empty_result(context: Context) -> None:
|
|
assert context.single_shot_result == ""
|