feat: enhance CompositeAgent with parallel strategy and add tests

This commit is contained in:
2025-07-04 18:38:08 +00:00
parent a9b8a1a6a3
commit 437f2e5651
5 changed files with 213 additions and 35 deletions
+56 -17
View File
@@ -5,6 +5,8 @@ This module provides a composite agent that can combine multiple agents
to work together as a single unit.
"""
import asyncio
import json
import logging
from typing import TYPE_CHECKING
from typing import Any
@@ -101,12 +103,6 @@ class CompositeAgent(Agent):
if context is None:
context = {}
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.strategy == "sequential":
return await self._process_sequential(message, context)
elif self.strategy == "parallel":
@@ -166,8 +162,17 @@ class CompositeAgent(Agent):
str: The processed response.
"""
current_message = message
for name, agent in self.agents.items():
current_message = await agent.process(current_message, context)
child_agent_names = self.config.get("agents", [])
for agent_name in child_agent_names:
if agent_name in self.agents:
agent = self.agents[agent_name]
current_message = await agent.process(current_message, context)
else:
logger.warning(
"Agent '%s' not found for CompositeAgent '%s'.",
agent_name,
self.name,
)
return current_message
async def _process_parallel(self, message: str, context: Dict[str, Any]) -> str:
@@ -181,16 +186,50 @@ class CompositeAgent(Agent):
Returns:
str: The combined processed response.
"""
# In a real implementation, this would use asyncio.gather to run in parallel
responses = {}
for name, agent in self.agents.items():
responses[name] = await agent.process(message, context)
output_format = self.config.get("output_format", "text")
# Combine responses
combined = "\n\n".join(
[f"Agent '{name}': {response}" for name, response in responses.items()]
)
return combined
child_agent_names = self.config.get("agents", [])
if not child_agent_names:
logger.warning(
"CompositeAgent '%s' with parallel strategy has no child agents.",
self.name,
)
return ""
tasks = []
valid_agent_names = []
for agent_name in child_agent_names:
if agent_name in self.agents:
agent = self.agents[agent_name]
tasks.append(agent.process(message, context))
valid_agent_names.append(agent_name)
else:
logger.warning(
"Agent '%s' not found for CompositeAgent '%s'.",
agent_name,
self.name,
)
if not tasks:
return ""
results = await asyncio.gather(*tasks)
if output_format == "json":
response_dict = {
name: result for name, result in zip(valid_agent_names, results)
}
return json.dumps(response_dict)
elif output_format == "text":
responses = [
f"{name}: {result}" for name, result in zip(valid_agent_names, results)
]
return "\n\n".join(responses)
else:
raise ConfigurationError(
f"Invalid format '{output_format}' for parallel strategy in CompositeAgent '{self.name}'. "
"Must be 'text' or 'json'."
)
def get_capabilities(self) -> List[str]:
"""
+39 -17
View File
@@ -6,6 +6,9 @@ configuration. It supports different types of agents and ensures they are
properly initialized with the necessary dependencies.
"""
from __future__ import annotations
import inspect
from typing import Any
from typing import Dict
from typing import Optional
@@ -13,6 +16,7 @@ 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.llm import LLMAgent
from cleveragents.agents.tool import ToolAgent
from cleveragents.core.exceptions import AgentCreationError
@@ -46,16 +50,19 @@ class AgentFactory:
"llm": LLMAgent,
"tool": ToolAgent,
"chain": ChainAgent,
"composite": CompositeAgent,
}
self.template_renderer = template_renderer
self.config = config
self.agents_cache: Dict[str, Agent] = {}
def create_agent(self, name: str) -> Agent:
"""
Create an agent instance.
Create an agent instance, handling dependencies.
This method creates an agent instance based on the configuration
for the agent with the given name.
for the agent with the given name. It uses a cache to avoid re-creating
agents and handles dependency injection for CompositeAgents.
Args:
name: The name of the agent to create.
@@ -66,36 +73,46 @@ class AgentFactory:
Raises:
AgentCreationError: If the agent cannot be created.
"""
if name in self.agents_cache:
return self.agents_cache[name]
try:
# Get agent configuration
agent_config = self.config.get("agents", {}).get(name)
if not agent_config:
raise AgentCreationError(f"Agent '{name}' not found in configuration")
# Get agent type
agent_type = agent_config.get("type", "llm").lower()
# Check if agent type is registered
if agent_type not in self.agent_types:
raise AgentCreationError(f"Unknown agent type: {agent_type}")
# Get agent class
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_specific_config, self.template_renderer) # type: ignore
if inspect.isabstract(agent_class):
raise AgentCreationError(
f"Cannot instantiate abstract class {agent_class.__name__}"
)
agent_specific_config: Dict[str, Any] = agent_config.get("config", {})
agent = agent_class(name, agent_specific_config, self.template_renderer) # type: ignore[abstract]
# Cache the agent instance immediately to handle circular dependencies.
self.agents_cache[name] = agent
# If the agent is a CompositeAgent, create and add its child agents.
if isinstance(agent, CompositeAgent):
child_agent_names = agent_specific_config.get("agents", [])
for child_name in child_agent_names:
child_agent = self.create_agent(child_name)
agent.add_agent(child_name, child_agent)
return agent
except Exception as e: # noqa: BLE001 (behave wants the original error types)
except Exception as e:
if isinstance(e, (AgentCreationError, ConfigurationError)):
# Propagate AgentCreationError or ConfigurationError unchanged
raise
raise AgentCreationError(f"Failed to create agent '{name}': {str(e)}")
raise AgentCreationError(
f"Failed to create agent '{name}': {str(e)}"
) from e
def register_agent_type(self, type_name: str, agent_class: Type[Agent]) -> None:
"""
@@ -117,6 +134,11 @@ class AgentFactory:
if not issubclass(agent_class, Agent):
raise AgentCreationError("Agent class must be a subclass of Agent")
if inspect.isabstract(agent_class):
raise AgentCreationError(
f"Cannot register abstract agent class '{agent_class.__name__}'"
)
self.agent_types[type_name] = agent_class
def get_agent_types(self) -> Dict[str, Type[Agent]]:
@@ -0,0 +1,17 @@
Feature: CompositeAgent Parallel Strategy
As a developer, I want to use a CompositeAgent with a parallel strategy
so that I can process a message with multiple agents concurrently and
combine their outputs in different formats.
Scenario: CompositeAgent with parallel strategy and text output
Given a CompositeAgent configuration with parallel strategy and format "text"
When I create the CompositeAgent named "ParallelAgent"
And I process the message "Test" with the composite agent
Then the response should be the plain text combination of child agent outputs
Scenario: CompositeAgent with parallel strategy and JSON output
Given a CompositeAgent configuration with parallel strategy and format "json"
When I create the CompositeAgent named "ParallelAgent"
And I process the message "Test" with the composite agent
Then the response should be a JSON object with child agent outputs
@@ -0,0 +1,101 @@
import asyncio
import json
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
import yaml
from behave import given
from behave import then
from behave import when
from cleveragents.agents.base import Agent
from cleveragents.agents.composite import CompositeAgent
from cleveragents.agents.factory import AgentFactory
from cleveragents.core.config import ConfigurationManager
from cleveragents.templates.renderer import TemplateRenderer
class MockAgent(Agent):
"""A mock agent that returns a configured response."""
def __init__(
self, name: str, config: Dict[str, Any], template_renderer: TemplateRenderer
):
super().__init__(name, config, template_renderer)
self.response = self.config.get("mock_response", f"Response from {name}")
async def process(
self, message: str, context: Optional[Dict[str, Any]] = None
) -> str:
return self.response
def get_capabilities(self) -> List[str]:
return ["mock"]
def setup_context_with_config(context, agent_configs: Dict[str, Any]):
"""Set up the test context with configuration and agent factory."""
context.config_manager = ConfigurationManager()
full_config = {"agents": agent_configs}
context.config_manager.config = full_config
context.template_renderer = TemplateRenderer()
context.agent_factory = AgentFactory(
config=context.config_manager.config,
template_renderer=context.template_renderer,
)
context.agent_factory.register_agent_type("mock", MockAgent)
@given(
'a CompositeAgent configuration with parallel strategy and format "{output_format}"'
)
def step_impl(context, output_format: str):
"""Set up a CompositeAgent with parallel strategy and specified output format."""
config_yaml = f"""
agents:
AgentA:
type: mock
config:
mock_response: "Hello from A"
AgentB:
type: mock
config:
mock_response: "Hello from B"
ParallelAgent:
type: composite
config:
strategy: parallel
output_format: {output_format}
agents:
- AgentA
- AgentB
"""
agent_configs = yaml.safe_load(config_yaml)["agents"]
setup_context_with_config(context, agent_configs)
@when('I process the message "{message}" with the composite agent')
def step_impl(context, message: str):
"""Process a message using the created composite agent."""
context.response = asyncio.run(context.agent.process(message))
@then("the response should be the plain text combination of child agent outputs")
def step_impl(context):
"""Verify the plain text output from the parallel strategy."""
expected_response = "AgentA: Hello from A\n\nAgentB: Hello from B"
assert (
context.response == expected_response
), f"Expected:\n{expected_response}\nGot:\n{context.response}"
@then("the response should be a JSON object with child agent outputs")
def step_impl(context):
"""Verify the JSON output from the parallel strategy."""
response_dict = json.loads(context.response)
expected_dict = {"AgentA": "Hello from A", "AgentB": "Hello from B"}
assert (
response_dict == expected_dict
), f"Expected:\n{expected_dict}\nGot:\n{response_dict}"
@@ -20,7 +20,6 @@ def setup_context_with_config(context, agent_config):
context.agent_factory = AgentFactory(
context.agent_config, context.template_renderer
)
context.agent_factory.register_agent_type("composite", CompositeAgent)
@given(