Files
cleveragents-core/features/steps/reactive_application_coverage_steps.py
CoreRasurae f2f7aa5dc9 feat(core): add v3 lifecycle models, automation levels, subplan support, and security hardening
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
2026-02-12 20:19:42 +00:00

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 == ""