diff --git a/src/cleveragents/agents/composite.py b/src/cleveragents/agents/composite.py index 714244d2e..0c509996c 100644 --- a/src/cleveragents/agents/composite.py +++ b/src/cleveragents/agents/composite.py @@ -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 diff --git a/src/cleveragents/agents/factory.py b/src/cleveragents/agents/factory.py index fae9272db..b3eda8a57 100644 --- a/src/cleveragents/agents/factory.py +++ b/src/cleveragents/agents/factory.py @@ -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: """ diff --git a/src/cleveragents/agents/llm.py b/src/cleveragents/agents/llm.py index 873e4d148..7c1c06a9e 100644 --- a/src/cleveragents/agents/llm.py +++ b/src/cleveragents/agents/llm.py @@ -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 agent’s 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 LLM’s 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 chain’s 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 diff --git a/src/cleveragents/agents/tool.py b/src/cleveragents/agents/tool.py index 2ed1414c4..0a70b7a58 100644 --- a/src/cleveragents/agents/tool.py +++ b/src/cleveragents/agents/tool.py @@ -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: diff --git a/src/cleveragents/core/application.py b/src/cleveragents/core/application.py index 5c3a74e22..b1fe781ea 100644 --- a/src/cleveragents/core/application.py +++ b/src/cleveragents/core/application.py @@ -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: diff --git a/src/examples/composite_agent_route_example.yaml b/src/examples/composite_agent_route_example.yaml new file mode 100644 index 000000000..14365cba1 --- /dev/null +++ b/src/examples/composite_agent_route_example.yaml @@ -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" diff --git a/tests/features/composite_agent_route_strategy.feature b/tests/features/composite_agent_route_strategy.feature new file mode 100644 index 000000000..e26858024 --- /dev/null +++ b/tests/features/composite_agent_route_strategy.feature @@ -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 diff --git a/tests/features/steps/composite_agent_route_steps.py b/tests/features/steps/composite_agent_route_steps.py new file mode 100644 index 000000000..4f7e6ca60 --- /dev/null +++ b/tests/features/steps/composite_agent_route_steps.py @@ -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)