feat: add route strategy to CompositeAgent with tests and examples
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user