Skip to content

Commit 6e217e3

Browse files
committed
[AI Bundle][Agent] Add MultiAgent
1 parent b50bce3 commit 6e217e3

File tree

17 files changed

+1798
-11
lines changed

17 files changed

+1798
-11
lines changed

demo/config/packages/ai.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ ai:
4040
- agent: 'blog'
4141
name: 'symfony_blog'
4242
description: 'Can answer questions based on the Symfony blog.'
43+
orchestrator:
44+
model: 'gpt-4o-mini'
45+
prompt: 'You are an intelligent agent orchestrator that routes user questions to specialized agents.'
46+
tools: false
47+
technical:
48+
model: 'gpt-4o-mini'
49+
prompt: 'You are a technical support specialist. Help users resolve bugs, problems, and technical errors.'
50+
tools: false
51+
fallback:
52+
model: 'gpt-4o-mini'
53+
prompt: 'You are a helpful general assistant. Assist users with any questions or tasks they may have.'
54+
tools: false
55+
multi_agent:
56+
support:
57+
orchestrator: 'orchestrator'
58+
handoffs:
59+
technical: ['bug', 'problem', 'technical', 'error', 'code', 'debug']
60+
fallback: 'fallback'
4361
store:
4462
chroma_db:
4563
symfonycon:

examples/bootstrap.php

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,39 @@ function http_client(): HttpClientInterface
5050

5151
function logger(): LoggerInterface
5252
{
53-
return new ConsoleLogger(output());
53+
$output = output();
54+
55+
return new class($output) extends ConsoleLogger {
56+
private ConsoleOutput $output;
57+
58+
public function __construct(ConsoleOutput $output)
59+
{
60+
parent::__construct($output);
61+
$this->output = $output;
62+
}
63+
64+
/**
65+
* @param Stringable|string $message
66+
*/
67+
public function log($level, $message, array $context = []): void
68+
{
69+
// Call parent to handle the base logging
70+
parent::log($level, $message, $context);
71+
72+
// Add context display for debug verbosity
73+
if ($this->output->getVerbosity() >= ConsoleOutput::VERBOSITY_DEBUG && [] !== $context) {
74+
// Filter out special keys that are already handled
75+
$displayContext = array_filter($context, function ($key) {
76+
return !in_array($key, ['exception', 'error', 'object'], true);
77+
}, \ARRAY_FILTER_USE_KEY);
78+
79+
if ([] !== $displayContext) {
80+
$contextMessage = ' '.json_encode($displayContext, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
81+
$this->output->writeln(sprintf('<comment>%s</comment>', $contextMessage));
82+
}
83+
}
84+
}
85+
};
5486
}
5587

5688
function output(): ConsoleOutput
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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\Agent\StructuredOutput\AgentProcessor;
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 structured output processor for the orchestrator
26+
$structuredOutputProcessor = new AgentProcessor();
27+
28+
// Create orchestrator agent for routing decisions
29+
$orchestrator = new Agent(
30+
$platform,
31+
'gpt-4o-mini',
32+
[new SystemPromptInputProcessor('You are an intelligent agent orchestrator that routes user questions to specialized agents.'), $structuredOutputProcessor],
33+
[$structuredOutputProcessor],
34+
logger: logger()
35+
);
36+
37+
// Create technical agent for handling technical issues
38+
$technical = new Agent(
39+
$platform,
40+
'gpt-4o-mini?max_tokens=150', // set max_tokens here to be faster and cheaper
41+
[new SystemPromptInputProcessor('You are a technical support specialist. Help users resolve bugs, problems, and technical errors.')],
42+
name: 'technical',
43+
logger: logger()
44+
);
45+
46+
// Create general agent for handling any other questions
47+
$fallback = new Agent(
48+
$platform,
49+
'gpt-4o-mini',
50+
[new SystemPromptInputProcessor('You are a helpful general assistant. Assist users with any questions or tasks they may have. You should never ever answer technical question.')],
51+
name: 'fallback',
52+
logger: logger()
53+
);
54+
55+
$multiAgent = new MultiAgent(
56+
orchestrator: $orchestrator,
57+
handoffs: [
58+
new Handoff(to: $technical, when: ['bug', 'problem', 'technical', 'error']),
59+
],
60+
fallback: $fallback,
61+
logger: logger()
62+
);
63+
64+
echo "=== Technical Question ===\n";
65+
$technicalQuestion = '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.';
66+
echo "Question: $technicalQuestion\n\n";
67+
$messages = new MessageBag(Message::ofUser($technicalQuestion));
68+
$result = $multiAgent->call($messages);
69+
echo 'Answer: '.substr($result->getContent(), 0, 300).'...'.\PHP_EOL.\PHP_EOL;
70+
71+
echo "=== General Question ===\n";
72+
$generalQuestion = 'Can you give me a lasagne recipe?';
73+
echo "Question: $generalQuestion\n\n";
74+
$messages = new MessageBag(Message::ofUser($generalQuestion));
75+
$result = $multiAgent->call($messages);
76+
echo 'Answer: '.substr($result->getContent(), 0, 300).'...'.\PHP_EOL;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
use Symfony\AI\Agent\Exception\InvalidArgumentException;
16+
17+
/**
18+
* Defines a handoff to another agent based on conditions.
19+
*
20+
* @author Oskar Stark <[email protected]>
21+
*/
22+
final readonly class Handoff
23+
{
24+
/**
25+
* @param string[] $when Keywords or phrases that indicate this handoff
26+
*/
27+
public function __construct(
28+
private AgentInterface $to,
29+
private array $when,
30+
) {
31+
if ([] === $when) {
32+
throw new InvalidArgumentException('Handoff must have at least one "when" condition.');
33+
}
34+
}
35+
36+
public function getTo(): AgentInterface
37+
{
38+
return $this->to;
39+
}
40+
41+
/**
42+
* @return string[]
43+
*/
44+
public function getWhen(): array
45+
{
46+
return $this->when;
47+
}
48+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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\Handoff;
13+
14+
/**
15+
* Represents the orchestrator's decision on which agent should handle a request.
16+
*
17+
* @author Oskar Stark <[email protected]>
18+
*/
19+
final readonly class Decision
20+
{
21+
/**
22+
* @param string $agentName The name of the selected agent, or empty string if no specific agent is selected
23+
* @param string $reasoning The reasoning behind the selection
24+
*/
25+
public function __construct(
26+
public string $agentName,
27+
public string $reasoning = 'No reasoning provided',
28+
) {
29+
}
30+
31+
/**
32+
* Checks if a specific agent was selected.
33+
*/
34+
public function hasAgent(): bool
35+
{
36+
return '' !== $this->agentName;
37+
}
38+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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\Agent\MultiAgent\Handoff\Decision;
21+
use Symfony\AI\Platform\Message\Message;
22+
use Symfony\AI\Platform\Message\MessageBag;
23+
use Symfony\AI\Platform\Result\ResultInterface;
24+
25+
/**
26+
* A multi-agent system that coordinates multiple specialized agents.
27+
*
28+
* This agent acts as a central orchestrator, delegating tasks to specialized agents
29+
* based on handoff rules and managing the conversation flow between agents.
30+
*
31+
* @author Oskar Stark <[email protected]>
32+
*/
33+
final class MultiAgent implements AgentInterface
34+
{
35+
/**
36+
* @param AgentInterface $orchestrator Agent responsible for analyzing requests and selecting appropriate handoffs
37+
* @param Handoff[] $handoffs Handoff definitions for agent routing
38+
* @param AgentInterface $fallback Fallback agent when no handoff conditions match
39+
* @param non-empty-string $name Name of the multi-agent
40+
* @param LoggerInterface $logger Logger for debugging handoff decisions
41+
*/
42+
public function __construct(
43+
private AgentInterface $orchestrator,
44+
private array $handoffs,
45+
private AgentInterface $fallback,
46+
private string $name = 'multi-agent',
47+
private LoggerInterface $logger = new NullLogger(),
48+
) {
49+
if ([] === $handoffs) {
50+
throw new InvalidArgumentException('MultiAgent requires at least 1 handoff.');
51+
}
52+
}
53+
54+
/**
55+
* @return non-empty-string
56+
*/
57+
public function getName(): string
58+
{
59+
return $this->name;
60+
}
61+
62+
/**
63+
* @throws ExceptionInterface When the agent encounters an error during orchestration or handoffs
64+
*/
65+
public function call(MessageBag $messages, array $options = []): ResultInterface
66+
{
67+
$userMessages = $messages->withoutSystemMessage();
68+
69+
$userText = $userMessages->getUserMessageText();
70+
if (null === $userText) {
71+
throw new RuntimeException('No user message found in conversation.');
72+
}
73+
$this->logger->debug('MultiAgent: Processing user message', ['user_text' => $userText]);
74+
75+
$this->logger->debug('MultiAgent: Available agents for routing', ['agents' => array_map(fn ($handoff) => [
76+
'to' => $handoff->getTo()->getName(),
77+
'when' => $handoff->getWhen(),
78+
], $this->handoffs)]);
79+
80+
$agentSelectionPrompt = $this->buildAgentSelectionPrompt($userText);
81+
82+
$decision = $this->orchestrator->call(new MessageBag(Message::ofUser($agentSelectionPrompt)), array_merge($options, [
83+
'output_structure' => Decision::class,
84+
]))->getContent();
85+
86+
if (!$decision instanceof Decision) {
87+
$this->logger->debug('MultiAgent: Failed to get decision, falling back to orchestrator');
88+
89+
return $this->orchestrator->call($messages, $options);
90+
}
91+
92+
$this->logger->debug('MultiAgent: Agent selection completed', [
93+
'selected_agent' => $decision->agentName,
94+
'reasoning' => $decision->reasoning,
95+
]);
96+
97+
if (!$decision->hasAgent()) {
98+
$this->logger->debug('MultiAgent: Using fallback agent', ['reason' => 'no_agent_selected']);
99+
100+
return $this->fallback->call($messages, $options);
101+
}
102+
103+
// Find the target agent by name
104+
$targetAgent = null;
105+
foreach ($this->handoffs as $handoff) {
106+
if ($handoff->getTo()->getName() === $decision->agentName) {
107+
$targetAgent = $handoff->getTo();
108+
break;
109+
}
110+
}
111+
112+
if (!$targetAgent) {
113+
$this->logger->debug('MultiAgent: Target agent not found, using fallback agent', [
114+
'requested_agent' => $decision->agentName,
115+
'reason' => 'agent_not_found',
116+
]);
117+
118+
return $this->fallback->call($messages, $options);
119+
}
120+
121+
$this->logger->debug('MultiAgent: Delegating to agent', ['agent_name' => $decision->agentName]);
122+
123+
$userMessage = $userMessages->getUserMessage();
124+
if (null === $userMessage) {
125+
throw new RuntimeException('No user message found in conversation.');
126+
}
127+
128+
// Call the selected agent with the original user question
129+
return $targetAgent->call(new MessageBag($userMessage), $options);
130+
}
131+
132+
private function buildAgentSelectionPrompt(string $userQuestion): string
133+
{
134+
$agentDescriptions = [];
135+
$agentNames = [];
136+
137+
foreach ($this->handoffs as $handoff) {
138+
$triggers = implode(', ', $handoff->getWhen());
139+
$agentName = $handoff->getTo()->getName();
140+
$agentDescriptions[] = "- {$agentName}: {$triggers}";
141+
$agentNames[] = $agentName;
142+
}
143+
144+
$agentDescriptions[] = "- {$this->fallback->getName()}: fallback agent for general/unmatched queries";
145+
$agentNames[] = $this->fallback->getName();
146+
147+
$agentList = implode("\n", $agentDescriptions);
148+
$validAgents = implode('", "', $agentNames);
149+
150+
return <<<PROMPT
151+
You are an intelligent agent orchestrator. Based on the user's question, determine which specialized agent should handle the request.
152+
153+
User question: "{$userQuestion}"
154+
155+
Available agents and their capabilities:
156+
{$agentList}
157+
158+
Analyze the user's question and select the most appropriate agent to handle this request.
159+
Return an empty string ("") for agentName if no specific agent matches the request criteria.
160+
161+
Available agent names: {$validAgents}
162+
163+
Provide your selection and explain your reasoning.
164+
PROMPT;
165+
}
166+
}

0 commit comments

Comments
 (0)