Skip to content

Commit 9b52de4

Browse files
OskarStarkclaude
andcommitted
Add multi-agent orchestration with bundle configuration
- Implement MultiAgent class with handoff routing logic - Add Handoff class for defining agent delegation rules - Require minimum 2 handoffs (single handoff should use agent directly) - Add comprehensive bundle configuration for multi-agent systems - Add documentation for multi-agent bundle configuration - Include debug logging with context display in custom logger - Provide example demonstrating multi-agent orchestration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 2f05b00 commit 9b52de4

File tree

8 files changed

+534
-1
lines changed

8 files changed

+534
-1
lines changed

demo/config/packages/ai.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ ai:
4949
- agent: 'blog'
5050
name: 'symfony_blog'
5151
description: 'Can answer questions based on the Symfony blog.'
52+
orchestrator:
53+
model:
54+
class: 'Symfony\AI\Platform\Bridge\OpenAi\Gpt'
55+
name: !php/const Symfony\AI\Platform\Bridge\OpenAi\Gpt::GPT_4O_MINI
56+
prompt: 'You are an intelligent agent orchestrator that routes user questions to specialized agents.'
57+
tools: false
58+
technical:
59+
model:
60+
class: 'Symfony\AI\Platform\Bridge\OpenAi\Gpt'
61+
name: !php/const Symfony\AI\Platform\Bridge\OpenAi\Gpt::GPT_4O_MINI
62+
prompt: 'You are a technical support specialist. Help users resolve bugs, problems, and technical errors.'
63+
tools: false
64+
general:
65+
model:
66+
class: 'Symfony\AI\Platform\Bridge\OpenAi\Gpt'
67+
name: !php/const Symfony\AI\Platform\Bridge\OpenAi\Gpt::GPT_4O_MINI
68+
prompt: 'You are a helpful general assistant. Assist users with any questions or tasks they may have.'
69+
tools: false
5270
store:
5371
chroma_db:
5472
symfonycon:
@@ -68,6 +86,14 @@ ai:
6886
- 'Symfony\AI\Store\Document\Transformer\TextTrimTransformer'
6987
vectorizer: 'ai.vectorizer.openai'
7088
store: 'ai.store.chroma_db.symfonycon'
89+
multi_agent:
90+
support:
91+
orchestrator: 'ai.agent.orchestrator'
92+
handoffs:
93+
- to: 'ai.agent.technical'
94+
when: ['bug', 'problem', 'technical', 'error', 'code', 'debug']
95+
- to: 'ai.agent.general'
96+
when: []
7197

7298
services:
7399
_defaults:

examples/bootstrap.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,17 @@ function logger(): LoggerInterface
5353
default => ConsoleOutput::VERBOSITY_NORMAL,
5454
};
5555

56-
return new ConsoleLogger(new ConsoleOutput($verbosity));
56+
return new class(new ConsoleOutput($verbosity)) extends ConsoleLogger {
57+
public function log($level, $message, array $context = []): void
58+
{
59+
if (!empty($context)) {
60+
$contextString = json_encode($context, \JSON_UNESCAPED_SLASHES);
61+
$message .= ': '.$contextString;
62+
}
63+
64+
parent::log($level, $message, []);
65+
}
66+
};
5767
}
5868

5969
function print_token_usage(Metadata $metadata): void
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor;
14+
use Symfony\AI\Agent\MultiAgent\Handoff;
15+
use Symfony\AI\Agent\MultiAgent\MultiAgent;
16+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
17+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
18+
use Symfony\AI\Platform\Message\Message;
19+
use Symfony\AI\Platform\Message\MessageBag;
20+
21+
require_once dirname(__DIR__).'/bootstrap.php';
22+
23+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
24+
25+
// Create orchestrator agent for routing decisions
26+
$orchestrator = new Agent(
27+
$platform,
28+
new Gpt(Gpt::GPT_4O_MINI),
29+
[new SystemPromptInputProcessor('You are an intelligent agent orchestrator that routes user questions to specialized agents.')],
30+
logger: logger()
31+
);
32+
33+
// Create technical agent for handling technical issues
34+
$technical = new Agent(
35+
$platform,
36+
new Gpt(Gpt::GPT_4O_MINI),
37+
[new SystemPromptInputProcessor('You are a technical support specialist. Help users resolve bugs, problems, and technical errors.')],
38+
name: 'technical',
39+
logger: logger()
40+
);
41+
42+
// Create general agent for handling any other questions
43+
$general = new Agent(
44+
$platform,
45+
new Gpt(Gpt::GPT_4O_MINI),
46+
[new SystemPromptInputProcessor('You are a helpful general assistant. Assist users with any questions or tasks they may have. You should neverr ever answer technical question.')],
47+
name: 'general',
48+
logger: logger()
49+
);
50+
51+
$multiAgent = new MultiAgent(
52+
orchestrator: $orchestrator,
53+
handoffs: [
54+
new Handoff(to: $technical, when: ['bug', 'problem', 'technical', 'error']),
55+
new Handoff(to: $general),
56+
],
57+
logger: logger()
58+
);
59+
60+
echo "=== Technical Question ===\n";
61+
$messages = new MessageBag(
62+
Message::ofUser('I get this error in my php code: "Call to undefined method App\Controller\UserController::getName()" - this is my line of code: $user->getName() where $user is an instance of User entity.')
63+
);
64+
$result = $multiAgent->call($messages);
65+
echo substr($result->getContent(), 0, 300).'...'.\PHP_EOL.\PHP_EOL;
66+
67+
echo "=== General Question ===\n";
68+
$messages = new MessageBag(
69+
Message::ofUser('Can you give me a lasagne recipe?')
70+
);
71+
$result = $multiAgent->call($messages);
72+
echo substr($result->getContent(), 0, 300).'...'.\PHP_EOL;
73+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Agent\MultiAgent;
13+
14+
use Symfony\AI\Agent\AgentInterface;
15+
16+
/**
17+
* Defines a handoff to another agent based on conditions.
18+
*
19+
* @author Oskar Stark <[email protected]>
20+
*/
21+
final readonly class Handoff
22+
{
23+
/**
24+
* @param string[] $when Keywords or phrases that indicate this handoff
25+
*/
26+
public function __construct(
27+
private AgentInterface $to,
28+
private array $when = [],
29+
) {
30+
}
31+
32+
public function getTo(): AgentInterface
33+
{
34+
return $this->to;
35+
}
36+
37+
/**
38+
* @return string[]
39+
*/
40+
public function getWhen(): array
41+
{
42+
return $this->when;
43+
}
44+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Agent\MultiAgent;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Psr\Log\NullLogger;
16+
use Symfony\AI\Agent\AgentInterface;
17+
use Symfony\AI\Agent\Exception\ExceptionInterface;
18+
use Symfony\AI\Agent\Exception\InvalidArgumentException;
19+
use Symfony\AI\Agent\Exception\RuntimeException;
20+
use Symfony\AI\Platform\Message\Content\Text;
21+
use Symfony\AI\Platform\Message\Message;
22+
use Symfony\AI\Platform\Message\MessageBag;
23+
use Symfony\AI\Platform\Message\UserMessage;
24+
use Symfony\AI\Platform\Result\ResultInterface;
25+
26+
/**
27+
* A multi-agent system that coordinates multiple specialized agents.
28+
*
29+
* This agent acts as a central orchestrator, delegating tasks to specialized agents
30+
* based on handoff rules and managing the conversation flow between agents.
31+
*
32+
* @author Oskar Stark <[email protected]>
33+
*/
34+
final class MultiAgent implements AgentInterface
35+
{
36+
/**
37+
* @param Handoff[] $handoffs Handoff definitions for agent routing
38+
*/
39+
public function __construct(
40+
private AgentInterface $orchestrator,
41+
private array $handoffs,
42+
private string $name = 'multi-agent',
43+
private LoggerInterface $logger = new NullLogger(),
44+
) {
45+
if ([] === $handoffs) {
46+
throw new InvalidArgumentException('Handoffs array cannot be empty.');
47+
}
48+
49+
if (\count($handoffs) < 2) {
50+
throw new InvalidArgumentException('MultiAgent requires at least 2 handoffs. For a single handoff, use the agent directly.');
51+
}
52+
}
53+
54+
public function getName(): string
55+
{
56+
return $this->name;
57+
}
58+
59+
/**
60+
* @param array<string, mixed> $options
61+
*
62+
* @throws ExceptionInterface When the agent encounters an error during orchestration or handoffs
63+
*/
64+
public function call(MessageBag $messages, array $options = []): ResultInterface
65+
{
66+
$userMessages = $messages->withoutSystemMessage();
67+
68+
// Ask orchestrator which agent to target using JSON response format
69+
$userText = self::extractUserMessage($userMessages);
70+
$this->logger->debug('MultiAgent: Processing user message', ['user_text' => $userText]);
71+
72+
// Log available handoffs and agents
73+
$agentDetails = array_map(fn ($handoff) => [
74+
'to' => $handoff->getTo()->getName(),
75+
'when' => $handoff->getWhen(),
76+
], $this->handoffs);
77+
$this->logger->debug('MultiAgent: Available agents for routing', ['agents' => $agentDetails]);
78+
79+
$agentSelectionPrompt = $this->buildAgentSelectionPrompt($userText);
80+
$agentSelectionMessages = new MessageBag(Message::ofUser($agentSelectionPrompt));
81+
82+
$selectionResult = $this->orchestrator->call($agentSelectionMessages, $options);
83+
$responseContent = $selectionResult->getContent();
84+
$this->logger->debug('MultiAgent: Received orchestrator response', ['response' => $responseContent]);
85+
86+
// Parse JSON response
87+
$selectionData = json_decode($responseContent, true);
88+
if (\JSON_ERROR_NONE !== json_last_error()) {
89+
$this->logger->debug('MultiAgent: JSON parsing failed, falling back to orchestrator', ['json_error' => json_last_error_msg()]);
90+
91+
return $this->orchestrator->call($messages, $options);
92+
}
93+
94+
$agentName = $selectionData['agentName'] ?? null;
95+
$reasoning = $selectionData['reasoning'] ?? 'No reasoning provided';
96+
$this->logger->debug('MultiAgent: Agent selection completed', [
97+
'selected_agent' => $agentName,
98+
'reasoning' => $reasoning,
99+
]);
100+
101+
// If no specific agent is selected, fall back to orchestrator
102+
if (!$agentName || 'null' === $agentName) {
103+
$this->logger->debug('MultiAgent: Falling back to orchestrator', ['reason' => 'no_agent_selected']);
104+
105+
return $this->orchestrator->call($messages, $options);
106+
}
107+
108+
// Find the target agent by name
109+
$targetAgent = null;
110+
foreach ($this->handoffs as $handoff) {
111+
if ($handoff->getTo()->getName() === $agentName) {
112+
$targetAgent = $handoff->getTo();
113+
break;
114+
}
115+
}
116+
117+
if (!$targetAgent) {
118+
$this->logger->debug('MultiAgent: Target agent not found, falling back to orchestrator', [
119+
'requested_agent' => $agentName,
120+
'reason' => 'agent_not_found',
121+
]);
122+
123+
return $this->orchestrator->call($messages, $options);
124+
}
125+
126+
$this->logger->debug('MultiAgent: Delegating to agent', ['agent_name' => $agentName]);
127+
$originalMessages = new MessageBag(self::findUserMessage($userMessages));
128+
129+
return $targetAgent->call($originalMessages, $options);
130+
}
131+
132+
private static function extractUserMessage(MessageBag $messages): string
133+
{
134+
foreach ($messages->getMessages() as $message) {
135+
if ($message instanceof UserMessage) {
136+
$textParts = [];
137+
foreach ($message->content as $content) {
138+
if ($content instanceof Text) {
139+
$textParts[] = $content->text;
140+
}
141+
}
142+
143+
return implode(' ', $textParts);
144+
}
145+
}
146+
147+
throw new RuntimeException('No user message found in conversation.');
148+
}
149+
150+
private static function findUserMessage(MessageBag $messages): UserMessage
151+
{
152+
foreach ($messages->getMessages() as $message) {
153+
if ($message instanceof UserMessage) {
154+
return $message;
155+
}
156+
}
157+
158+
throw new RuntimeException('No user message found in conversation.');
159+
}
160+
161+
private function buildAgentSelectionPrompt(string $userQuestion): string
162+
{
163+
$agentDescriptions = [];
164+
$agentNames = ['null'];
165+
166+
foreach ($this->handoffs as $handoff) {
167+
$triggers = implode(', ', $handoff->getWhen());
168+
$agentName = $handoff->getTo()->getName();
169+
$agentDescriptions[] = "- {$agentName}: {$triggers}";
170+
$agentNames[] = $agentName;
171+
}
172+
173+
$agentList = implode("\n", $agentDescriptions);
174+
$validAgents = implode('", "', $agentNames);
175+
176+
return <<<PROMPT
177+
You are an intelligent agent orchestrator. Based on the user's question, determine which specialized agent should handle the request.
178+
179+
User question: "{$userQuestion}"
180+
181+
Available agents and their capabilities:
182+
{$agentList}
183+
184+
Analyze the user's question and select the most appropriate agent. If no specific agent is needed, select "null".
185+
186+
Respond with JSON in this exact format:
187+
{
188+
"agentName": "<one of: \"{$validAgents}\">",
189+
"reasoning": "<your reasoning for the selection>"
190+
}
191+
192+
The agentName must be exactly one of the available agent names or "none".
193+
PROMPT;
194+
}
195+
}
196+

0 commit comments

Comments
 (0)