feat: add route strategy to CompositeAgent with tests and examples

This commit is contained in:
2025-07-01 04:19:58 +00:00
parent 9c8cae1fd4
commit ee69a20ad7
8 changed files with 581 additions and 75 deletions
+88 -13
View File
@@ -5,28 +5,42 @@ This module provides a composite agent that can combine multiple agents
to work together as a single unit.
"""
import logging
from typing import TYPE_CHECKING
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from ..core.exceptions import ConfigurationError
from ..core.exceptions import ExecutionError
from ..templates.renderer import TemplateRenderer
from .base import Agent
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..routing.router import Router
class CompositeAgent(Agent):
"""
CompositeAgent combines multiple agents into a single agent.
This agent delegates processing to its child agents and combines their
responses according to a configurable strategy.
responses according to a configurable strategy. It can also encapsulate
a router to embed a routing flow within another CompositeAgent.
Attributes:
name (str): The name of the agent.
config (Dict[str, Any]): The agent's configuration.
template_renderer (TemplateRenderer): Renderer for processing templates.
agents (Dict[str, Agent]): Dictionary of child agents.
combination_strategy (str): Strategy for combining responses.
agents (Dict[str, Agent]): Dictionary of child agents for 'sequential'
and 'parallel' strategies.
strategy (str): Strategy for combining responses. Can be
'sequential', 'parallel', or 'route'.
route_name (Optional[str]): The name of the router to execute when using
the 'route' strategy.
"""
def __init__(
@@ -42,7 +56,24 @@ class CompositeAgent(Agent):
"""
super().__init__(name, config, template_renderer)
self.agents: Dict[str, Agent] = {}
self.combination_strategy = config.get("combination_strategy", "sequential")
logger.debug(f"CompositeAgent '{name}' raw config: {self.config}")
self.strategy = self.config.get("strategy", "route")
self.route_name: Optional[str] = None
if self.strategy == "route":
if "agents" in self.config:
raise ConfigurationError(
f"CompositeAgent '{name}' with 'route' strategy must not have an "
"'agents' section in its configuration. "
)
self.route_name = self.config.get("route")
if not self.route_name:
raise ConfigurationError(
f"CompositeAgent '{name}' with 'route' strategy requires a 'route' "
"attribute in its configuration."
)
def add_agent(self, name: str, agent: Agent) -> None:
"""
@@ -58,7 +89,7 @@ class CompositeAgent(Agent):
self, message: str, context: Optional[Dict[str, Any]] = None
) -> str:
"""
Process a message by delegating to child agents.
Process a message by delegating to child agents or a route.
Args:
message (str): The message to process.
@@ -70,15 +101,58 @@ class CompositeAgent(Agent):
if context is None:
context = {}
if not self.agents:
return "No child agents configured."
if not self.strategy == "route" and not self.agents:
raise ConfigurationError(
f"CompositeAgent '{self.name}' with strategy '{self.strategy}' has no "
"child agents configured."
)
if self.combination_strategy == "sequential":
if self.strategy == "sequential":
return await self._process_sequential(message, context)
elif self.combination_strategy == "parallel":
elif self.strategy == "parallel":
return await self._process_parallel(message, context)
elif self.strategy == "route":
return await self._process_route(message, context)
else:
return f"Unknown combination strategy: {self.combination_strategy}"
raise ConfigurationError(
f"Unknown combination strategy '{self.strategy}' for CompositeAgent "
f"'{self.name}'."
)
async def _process_route(self, message: str, context: Dict[str, Any]) -> str:
"""
Process a message by passing it to a configured router.
Args:
message (str): The message to process.
context (Dict[str, Any]): Additional context for processing.
Returns:
str: The processed response from the router.
Raises:
ExecutionError: If the routers are not found in the context or
the specified router does not exist.
"""
routers_opt: Optional[Dict[str, "Router"]] = context.get("_routers")
# Treat “routers” as *missing* only when the key is absent (None),
# not when an empty dict is supplied.
if routers_opt is None:
raise ExecutionError(
"Routers not found in context, required for 'route' strategy in "
"CompositeAgent."
)
routers: Dict[str, "Router"] = routers_opt
assert self.route_name is not None
router = routers.get(self.route_name)
if not router:
raise ExecutionError(
f"Router '{self.route_name}' not found for CompositeAgent "
f"'{self.name}'."
)
return await router.process_message(message, context)
async def _process_sequential(self, message: str, context: Dict[str, Any]) -> str:
"""
@@ -125,7 +199,8 @@ class CompositeAgent(Agent):
Returns:
List[str]: List of capabilities.
"""
capabilities = ["composite"]
for agent in self.agents.values():
capabilities.extend(agent.get_capabilities())
capabilities = ["composite", self.strategy]
if self.strategy != "route":
for agent in self.agents.values():
capabilities.extend(agent.get_capabilities())
return list(set(capabilities)) # Remove duplicates
+10 -5
View File
@@ -16,6 +16,7 @@ from cleveragents.agents.chain import ChainAgent
from cleveragents.agents.llm import LLMAgent
from cleveragents.agents.tool import ToolAgent
from cleveragents.core.exceptions import AgentCreationError
from cleveragents.core.exceptions import ConfigurationError
from cleveragents.templates.renderer import TemplateRenderer
@@ -82,15 +83,19 @@ class AgentFactory:
agent_class = self.agent_types[agent_type]
if agent_class == Agent:
raise AgentCreationError("Cannot instantiate abstract class Agent")
# ↓ NEW hand the agent only its specific configuration payload
agent_specific_config: Dict[str, Any] = agent_config.get(
"config", agent_config
)
# Create agent instance
agent = agent_class(name, agent_config, self.template_renderer) # type: ignore
agent = agent_class(name, agent_specific_config, self.template_renderer) # type: ignore
return agent
except Exception as e:
if isinstance(e, AgentCreationError):
except Exception as e: # noqa: BLE001 (behave wants the original error types)
if isinstance(e, (AgentCreationError, ConfigurationError)):
# Propagate AgentCreationError or ConfigurationError unchanged
raise
else:
raise AgentCreationError(f"Failed to create agent '{name}': {str(e)}")
raise AgentCreationError(f"Failed to create agent '{name}': {str(e)}")
def register_agent_type(self, type_name: str, agent_class: Type[Agent]) -> None:
"""
+89 -56
View File
@@ -6,8 +6,10 @@ a large language model (LLM) such as GPT-4, Claude, etc. It handles
communication with different LLM providers and manages prompt formatting.
"""
import json
import logging
import os
import pprint
from typing import Any
from typing import Dict
from typing import List
@@ -20,6 +22,7 @@ DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant."
from cleveragents.agents.base import AgentWithMemory
from cleveragents.core.exceptions import AgentCreationError
from cleveragents.core.exceptions import ConfigurationError
from cleveragents.core.exceptions import ExecutionError
from cleveragents.templates.renderer import TemplateRenderer
@@ -64,18 +67,20 @@ class LLMAgent(AgentWithMemory):
super().__init__(name, config, template_renderer)
# The actual agent settings may be nested inside a "config" dictionary.
config_data = config.get("config", config)
self.agent_config = config.get("config", config)
# Extract configuration
self.provider = config_data.get("provider", "openai").lower()
self.model = config_data.get("model", "gpt-3.5-turbo")
self.api_key = config_data.get(
self.provider = self.agent_config.get("provider", "openai").lower()
self.model = self.agent_config.get("model", "gpt-3.5-turbo")
self.api_key = self.agent_config.get(
"api_key", os.environ.get(f"{self.provider.upper()}_API_KEY")
)
# Always prioritise the configured system message, falling back to the default.
self.system_message = config_data.get("system_message", DEFAULT_SYSTEM_MESSAGE)
self.temperature = float(config_data.get("temperature", 0.7))
self.max_tokens = int(config_data.get("max_tokens", 1000))
self.system_message = self.agent_config.get(
"system_message", DEFAULT_SYSTEM_MESSAGE
)
self.temperature = float(self.agent_config.get("temperature", 0.7))
self.max_tokens = int(self.agent_config.get("max_tokens", 1000))
# Check that we have an API key
if not self.api_key:
@@ -91,28 +96,76 @@ class LLMAgent(AgentWithMemory):
"""
Process a message using the LLM and generate a response.
This method formats the message according to the agent's prompt template,
sends it to the LLM, and processes the response.
This implementation isolates each agents memory to avoid cross-pollution
in multi-step pipelines and constructs a minimal, explicit context for
template rendering so that unrelated objects (e.g. routers) are never
exposed to the template engine.
Args:
message: The message to process.
context: Additional context information for processing the message.
context: Shared application context passed along the pipeline.
Returns:
The LLM's response to the message.
The LLMs response.
Raises:
ExecutionError: If message processing fails.
"""
if context is None:
context = {}
context = context or {}
# --- BEGIN: ADDED FOR DEBUGGING ---
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f"--- LLMAgent '{self.name}' received message ---")
# Log a truncated version of the message to avoid flooding the console
logger.debug(f"MESSAGE: {message[:200]}...")
logger.debug(f"CONTEXT KEYS: {list(context.keys())}")
# --- END: ADDED FOR DEBUGGING ---
# ------------------------------------------------------------------ #
# Memory isolation #
# ------------------------------------------------------------------ #
# Each agent keeps its own conversation history inside the shared
# context, keyed by agent name. This prevents a downstream agent from
# accidentally loading the entire chains history.
agent_memory = context.get("memory", {}).get(self.name, {})
self.load_memory(agent_memory)
# ------------------------------------------------------------------ #
# Build a clean rendering context #
# ------------------------------------------------------------------ #
# Start with keys from the shared context but drop internal data that
# should not be exposed to the template engine.
render_context: Dict[str, Any] = {
k: v
for k, v in context.items()
if k not in {"_routers", "memory", "history", "initial_message"}
and not k.startswith("_")
}
try:
# Format the message
formatted_message = self.format_prompt(message, context)
message_data = json.loads(message, strict=False)
if isinstance(message_data, dict):
render_context.update(message_data)
except (json.JSONDecodeError, TypeError):
# Message is not a JSON string, which is perfectly fine.
# It will be handled as a plain string.
pass
# Add the message to the conversation history
self.memory["messages"].append(
# --- BEGIN: ADDED FOR DEBUGGING ---
if logger.isEnabledFor(logging.DEBUG):
# Use pprint for readable, multi-line output of the context dict
pretty_context = pprint.pformat(render_context, indent=2)
logger.debug(
f"--- LLMAgent '{self.name}' final render context ---\n{pretty_context}"
)
# --- END: ADDED FOR DEBUGGING ---
try:
# Format the prompt with the enriched, *clean* context.
formatted_message = self.format_prompt(message, render_context)
# Append the latest user message to memory.
self.memory.setdefault("messages", []).append(
{"role": "user", "content": formatted_message}
)
@@ -145,14 +198,20 @@ class LLMAgent(AgentWithMemory):
f"Agent '{self.name}' received response:\n---RESPONSE---\n{processed_response}\n---END RESPONSE---"
)
# Add the response to the conversation history
# Persist assistant response to memory
self.memory["messages"].append(
{"role": "assistant", "content": processed_response}
)
# Make the updated memory available to downstream agents, scoped
# by agent name to keep histories isolated.
context.setdefault("memory", {})[self.name] = self.save_memory()
return processed_response
except Exception as e:
raise ExecutionError(f"Failed to process message: {str(e)}")
if isinstance(e, ExecutionError):
raise
raise ExecutionError(f"Failed to process message: {str(e)}") from e
async def _generate_openai_response(self, messages: List[Dict[str, str]]) -> str:
"""
@@ -276,56 +335,30 @@ class LLMAgent(AgentWithMemory):
self, message: str, context: Optional[Dict[str, Any]] = None
) -> str:
"""
Build the prompt string, always including the system message.
Render the prompt using either a named or an inline template.
If a prompt_template is specified it can be either
• the *name* of a template that is already registered in the
TemplateRenderer, or
• a raw template string.
When neither case applies we fall back to the legacy default
formatting.
When `prompt_template` is not set we simply return the original
`message`. Otherwise we attempt to render the template; if rendering
fails we fall back to the raw user message to avoid hard failures.
"""
prompt_context = context.copy() if context else {}
# ensure the tests default user object is present
prompt_context.setdefault(
"user",
{
"name": "Test User",
"role": "Developer",
"preferences": {"language": "English"},
},
)
template_spec = self.config.get("prompt_template")
system_msg = self.system_message or ""
context = context or {}
template_spec = self.agent_config.get("prompt_template")
if not template_spec:
# No template configured; return the raw user message unchanged.
return message
# context supplied to the template
render_context: Dict[str, Any] = {
**prompt_context,
"system_message": system_msg,
"message": message,
"context": prompt_context,
}
# Ensure the raw message is always available to the template.
context.setdefault("message", message)
try:
# 1️⃣ treat prompt_template as the name of a registered template
if template_spec in self.template_renderer.list_templates():
rendered = self.template_renderer.render(
template_spec, render_context
).strip()
rendered = self.template_renderer.render(template_spec, context).strip()
else:
# 2️⃣ treat it as a raw template string
rendered = self.template_renderer.render_string(
template_spec, render_context
template_spec, context
).strip()
return rendered.rstrip()
except Exception:
# any error → fall back to default formatting
logger.exception("Failed to render prompt template, using raw message.")
return message
@@ -346,7 +379,7 @@ class LLMAgent(AgentWithMemory):
"""
try:
# Check if a response template is specified
response_template = self.config.get("response_template")
response_template = self.agent_config.get("response_template")
if response_template:
# Use the specified template
+1 -1
View File
@@ -398,7 +398,7 @@ class ToolAgent(Agent):
# Check if the message is in JSON format
try:
data = json.loads(message)
data = json.loads(message, strict=False)
if isinstance(data, dict) and "tool" in data and "input" in data:
return data["tool"], data["input"]
except json.JSONDecodeError:
+21
View File
@@ -13,8 +13,14 @@ from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Type
from cleveragents.agents.base import Agent
from cleveragents.agents.chain import ChainAgent
from cleveragents.agents.composite import CompositeAgent
from cleveragents.agents.factory import AgentFactory
from cleveragents.agents.llm import LLMAgent
from cleveragents.agents.tool import ToolAgent
from cleveragents.core.config import ConfigurationManager
from cleveragents.core.exceptions import AgentCreationError
from cleveragents.core.exceptions import CleverAgentsException
@@ -200,6 +206,9 @@ class CleverAgentsApp:
# Get global context
context = self.config_manager.get("context.global", {})
# Inject all routers into the context so CompositeAgents can find them.
context["_routers"] = self.routers
# Process the prompt
self.logger.info(f"Processing prompt via route '{target_route_name}'")
result = await router.process_message(prompt, context)
@@ -274,6 +283,18 @@ class CleverAgentsApp:
if not self.agent_factory:
raise AgentCreationError("Unknown agent type: None")
# Register built-in agent types if they are not already registered
registered_types = self.agent_factory.get_agent_types()
type_map: Dict[str, Type[Agent]] = {
"llm": LLMAgent,
"tool": ToolAgent,
"chain": ChainAgent,
"composite": CompositeAgent,
}
for type_name, agent_class in type_map.items():
if type_name not in registered_types:
self.agent_factory.register_agent_type(type_name, agent_class)
# Build agents
agent_configs = self.config_manager.get("agents", {})
for agent_name in agent_configs:
@@ -0,0 +1,151 @@
cleveragents:
default_router: main_workflow
template_engine: JINJA2
# Agent definitions
agents:
# Main research agent
researcher:
type: llm
config:
model: gpt-4
provider: openai
api_key: ${OPENAI_API_KEY}
temperature: 0.7
prompt_template: research_prompt
# --- Agents used within the CompositeAgent's sub-network ---
financial_analyst:
type: llm
config:
model: claude-3-opus-latest
provider: anthropic
api_key: ${ANTHROPIC_API_KEY}
temperature: 0.2
prompt_template: financial_analysis_prompt
technical_analyst:
type: llm
config:
model: gpt-4
provider: openai
api_key: ${OPENAI_API_KEY}
temperature: 0.3
prompt_template: technical_analysis_prompt
summarizer:
type: llm
config:
model: gpt-3.5-turbo
provider: openai
api_key: ${OPENAI_API_KEY}
temperature: 0.4
prompt_template: summary_prompt
# system_message: |
# You are a professional summarizer. Create a concise summary of the following analysis:
#
# {{analysis_results}}
#
# Your summary should be clear, comprehensive, and highlight the most important points.
# Keep it under 500 words.
# --- CompositeAgent Definition ---
# This agent encapsulates the analysis and summarization workflow.
# It uses the 'route' strategy to process messages using its own router.
analysis_unit:
type: composite
config:
strategy: route
route: analysis_router # Specifies the router for the sub-network
# Prompt templates
prompts:
research_prompt:
content: |
You are a research specialist. Your task is to gather information on {{topic}}.
Focus on the following aspects:
- Historical context
- Current trends
- Future predictions
Provide a comprehensive but concise summary.
financial_analysis_prompt:
content: |
You are a financial analyst. Analyze the following research from a financial perspective:
{{research_results}}
Consider market trends, investment opportunities, and financial risks.
Provide your analysis in a structured format with bullet points for key findings.
technical_analysis_prompt:
content: |
You are a technical analyst. Analyze the following research from a technical perspective:
{{research_results}}
Consider technological trends, implementation challenges, and technical opportunities.
Provide your analysis in a structured format with bullet points for key findings.
summary_prompt:
content: |
You are a professional summarizer. Create a concise summary of the following analysis:
{{analysis_results}}
Your summary should be clear, comprehensive, and highlight the most important points.
Keep it under 500 words.
# Routing configuration
routes:
# Main workflow router
main_workflow:
- source: input
destination: researcher
transform: '{"topic": "{{message}}"}'
# The output from the researcher is sent to the composite 'analysis_unit'.
- source: researcher
destination: analysis_unit
# The message from 'researcher' becomes the input for the 'analysis_router'.
# The final output from the 'analysis_unit' is sent to the main output.
- source: analysis_unit
destination: output
# Sub-network router, used by the 'analysis_unit' CompositeAgent
analysis_router:
# The 'input' here is the message received by the 'analysis_unit' agent.
# A condition routes the message to the appropriate analyst.
- source: input
destination: financial_analyst
condition:
type: keywords
keywords: [ "finance", "market", "investment", "economic", "money", "cost" ]
match_all: false
transform: '{"research_results": "{{message}}"}'
# If the financial condition is not met, this route is taken.
- source: input
destination: technical_analyst
transform: '{"research_results": "{{message}}"}'
# The results from either analyst are routed to the summarizer.
- source: financial_analyst
destination: summarizer
transform: '{"analysis_results": "{{message}}"}'
- source: technical_analyst
destination: summarizer
transform: '{"analysis_results": "{{message}}"}'
# The summarizer's output becomes the final result of this sub-network.
- source: summarizer
destination: output
# Global context variables
context:
global:
company_name: "CleverThis"
industry: "Technology"
@@ -0,0 +1,35 @@
Feature: CompositeAgent Route Strategy
As a developer, I want to use the "route" strategy in a CompositeAgent
to encapsulate a routing flow, so that I can build modular and reusable
agent networks.
Scenario: CompositeAgent successfully processes a message using the route strategy
Given a configuration for a CompositeAgent with a "route" strategy pointing to "test_route"
And a router named "test_route" that returns "Response from test_route"
When I create the CompositeAgent named "router_agent"
And I process the message "hello" with the agent, providing the router in the context
Then the response should be "Response from test_route"
Scenario: CompositeAgent fails when the specified route does not exist
Given a configuration for a CompositeAgent with a "route" strategy pointing to "non_existent_route"
And an empty dictionary of routers
When I create the CompositeAgent named "router_agent"
And I try to process the message "hello" with the agent, providing the routers in the context
Then an ExecutionError should be raised with a message about the missing route "non_existent_route"
Scenario: CompositeAgent fails when the routers are not provided in the context
Given a configuration for a CompositeAgent with a "route" strategy pointing to "test_route"
When I create the CompositeAgent named "router_agent"
And I try to process the message "hello" with the agent without providing routers in the context
Then an ExecutionError should be raised with a message about missing routers in context
Scenario: CompositeAgent configuration fails if 'route' strategy is used with 'agents' section
Given a configuration for a CompositeAgent with a "route" strategy and an "agents" section
When I try to create the CompositeAgent named "router_agent"
Then a ConfigurationError should be raised with a message about not having an "agents" section
Scenario: CompositeAgent configuration fails if 'route' strategy is used without a 'route' attribute
Given a configuration for a CompositeAgent with a "route" strategy but no "route" attribute
When I try to create the CompositeAgent named "router_agent"
Then a ConfigurationError should be raised with a message about the missing "route" attribute
@@ -0,0 +1,186 @@
import asyncio
from unittest.mock import AsyncMock
from behave import given
from behave import then
from behave import when
from cleveragents.agents.composite import CompositeAgent
from cleveragents.agents.factory import AgentFactory
from cleveragents.core.exceptions import ConfigurationError
from cleveragents.core.exceptions import ExecutionError
from cleveragents.templates.renderer import TemplateRenderer
def setup_context_with_config(context, agent_config):
"""Helper to set up context with agent config and a factory."""
# The AgentFactory expects agent definitions under the top-level "agents" key.
context.agent_config = {"agents": agent_config}
context.template_renderer = TemplateRenderer()
context.agent_factory = AgentFactory(
context.agent_config, context.template_renderer
)
context.agent_factory.register_agent_type("composite", CompositeAgent)
@given(
'a configuration for a CompositeAgent with a "route" strategy pointing to "{route_name}"'
)
def step_impl(context, route_name):
agent_config = {
"router_agent": {
"type": "composite",
"config": {
"strategy": "route",
"route": route_name,
},
}
}
setup_context_with_config(context, agent_config)
@given('a router named "{router_name}" that returns "{response}"')
def step_impl(context, router_name, response):
mock_router = AsyncMock()
mock_router.process_message.return_value = response
if not hasattr(context, "routers"):
context.routers = {}
context.routers[router_name] = mock_router
@given("an empty dictionary of routers")
def step_impl(context):
context.routers = {}
@when('I create the CompositeAgent named "{agent_name}"')
def step_impl(context, agent_name):
context.agent = context.agent_factory.create_agent(agent_name)
assert isinstance(context.agent, CompositeAgent)
@when(
'I process the message "{message}" with the agent, providing the router in the context'
)
def step_impl(context, message):
async def run():
context.response = await context.agent.process(
message, context={"_routers": context.routers}
)
asyncio.run(run())
@then('the response should be "{expected_response}"')
def step_impl(context, expected_response):
assert (
context.response == expected_response
), f"Expected '{expected_response}', got '{context.response}'"
@when(
'I try to process the message "{message}" with the agent, providing the routers in the context'
)
def step_impl(context, message):
context.error = None
try:
async def run():
await context.agent.process(message, context={"_routers": context.routers})
asyncio.run(run())
except ExecutionError as e:
context.error = e
@then(
'an ExecutionError should be raised with a message about the missing route "{route_name}"'
)
def step_impl(context, route_name):
assert context.error is not None, "ExecutionError was not raised"
assert isinstance(context.error, ExecutionError)
assert f"Router '{route_name}' not found" in str(context.error)
@when(
'I try to process the message "{message}" with the agent without providing routers in the context'
)
def step_impl(context, message):
context.error = None
try:
async def run():
await context.agent.process(message, context={})
asyncio.run(run())
except ExecutionError as e:
context.error = e
@then(
"an ExecutionError should be raised with a message about missing routers in context"
)
def step_impl(context):
assert context.error is not None, "ExecutionError was not raised"
assert isinstance(context.error, ExecutionError)
assert "Routers not found in context" in str(context.error)
@given(
'a configuration for a CompositeAgent with a "route" strategy and an "agents" section'
)
def step_impl(context):
agent_config = {
"router_agent": {
"type": "composite",
"config": {
"strategy": "route",
"route": "some_route",
"agents": ["some_agent"], # This is invalid
},
}
}
setup_context_with_config(context, agent_config)
@when('I try to create the CompositeAgent named "{agent_name}"')
def step_impl(context, agent_name):
context.error = None
try:
context.agent_factory.create_agent(agent_name)
except ConfigurationError as e:
context.error = e
@then(
'a ConfigurationError should be raised with a message about not having an "agents" section'
)
def step_impl(context):
assert context.error is not None, "ConfigurationError was not raised"
assert isinstance(context.error, ConfigurationError)
assert "must not have an 'agents' section" in str(context.error)
@given(
'a configuration for a CompositeAgent with a "route" strategy but no "route" attribute'
)
def step_impl(context):
agent_config = {
"router_agent": {
"type": "composite",
"config": {
"strategy": "route",
# Missing 'route' attribute
},
}
}
setup_context_with_config(context, agent_config)
@then(
'a ConfigurationError should be raised with a message about the missing "route" attribute'
)
def step_impl(context):
assert context.error is not None, "ConfigurationError was not raised"
assert isinstance(context.error, ConfigurationError)
assert "requires a 'route' attribute" in str(context.error)