-
-
Notifications
You must be signed in to change notification settings - Fork 106
[AI Bundle][Agent] Add MultiAgent
#517
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
OskarStark
merged 1 commit into
symfony:main
from
OskarStark:feature/orchestrated-multi-agent
Oct 1, 2025
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
use Symfony\AI\Agent\Agent; | ||
use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; | ||
use Symfony\AI\Agent\MultiAgent\Handoff; | ||
use Symfony\AI\Agent\MultiAgent\MultiAgent; | ||
use Symfony\AI\Agent\StructuredOutput\AgentProcessor; | ||
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; | ||
use Symfony\AI\Platform\Message\Message; | ||
use Symfony\AI\Platform\Message\MessageBag; | ||
|
||
require_once dirname(__DIR__).'/bootstrap.php'; | ||
|
||
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); | ||
|
||
// Create structured output processor for the orchestrator | ||
$structuredOutputProcessor = new AgentProcessor(); | ||
|
||
// Create orchestrator agent for routing decisions | ||
$orchestrator = new Agent( | ||
$platform, | ||
'gpt-4o-mini', | ||
[new SystemPromptInputProcessor('You are an intelligent agent orchestrator that routes user questions to specialized agents.'), $structuredOutputProcessor], | ||
[$structuredOutputProcessor], | ||
logger: logger() | ||
); | ||
|
||
// Create technical agent for handling technical issues | ||
$technical = new Agent( | ||
$platform, | ||
'gpt-4o-mini?max_tokens=150', // set max_tokens here to be faster and cheaper | ||
[new SystemPromptInputProcessor('You are a technical support specialist. Help users resolve bugs, problems, and technical errors.')], | ||
name: 'technical', | ||
logger: logger() | ||
); | ||
|
||
// Create general agent for handling any other questions | ||
$fallback = new Agent( | ||
$platform, | ||
'gpt-4o-mini', | ||
[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.')], | ||
name: 'fallback', | ||
logger: logger() | ||
); | ||
|
||
$multiAgent = new MultiAgent( | ||
orchestrator: $orchestrator, | ||
handoffs: [ | ||
new Handoff(to: $technical, when: ['bug', 'problem', 'technical', 'error']), | ||
], | ||
fallback: $fallback, | ||
logger: logger() | ||
); | ||
|
||
echo "=== Technical Question ===\n"; | ||
$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.'; | ||
echo "Question: $technicalQuestion\n\n"; | ||
$messages = new MessageBag(Message::ofUser($technicalQuestion)); | ||
$result = $multiAgent->call($messages); | ||
echo 'Answer: '.substr($result->getContent(), 0, 300).'...'.\PHP_EOL.\PHP_EOL; | ||
|
||
echo "=== General Question ===\n"; | ||
$generalQuestion = 'Can you give me a lasagne recipe?'; | ||
echo "Question: $generalQuestion\n\n"; | ||
$messages = new MessageBag(Message::ofUser($generalQuestion)); | ||
$result = $multiAgent->call($messages); | ||
echo 'Answer: '.substr($result->getContent(), 0, 300).'...'.\PHP_EOL; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\AI\Agent\MultiAgent; | ||
|
||
use Symfony\AI\Agent\AgentInterface; | ||
use Symfony\AI\Agent\Exception\InvalidArgumentException; | ||
|
||
/** | ||
* Defines a handoff to another agent based on conditions. | ||
* | ||
* @author Oskar Stark <[email protected]> | ||
*/ | ||
final readonly class Handoff | ||
{ | ||
/** | ||
* @param string[] $when Keywords or phrases that indicate this handoff | ||
*/ | ||
public function __construct( | ||
private AgentInterface $to, | ||
private array $when, | ||
) { | ||
if ([] === $when) { | ||
throw new InvalidArgumentException('Handoff must have at least one "when" condition.'); | ||
} | ||
} | ||
|
||
public function getTo(): AgentInterface | ||
{ | ||
return $this->to; | ||
} | ||
|
||
/** | ||
* @return string[] | ||
*/ | ||
public function getWhen(): array | ||
{ | ||
return $this->when; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\AI\Agent\MultiAgent\Handoff; | ||
|
||
/** | ||
* Represents the orchestrator's decision on which agent should handle a request. | ||
* | ||
* @author Oskar Stark <[email protected]> | ||
*/ | ||
final readonly class Decision | ||
{ | ||
/** | ||
* @param string $agentName The name of the selected agent, or empty string if no specific agent is selected | ||
* @param string $reasoning The reasoning behind the selection | ||
*/ | ||
public function __construct( | ||
public string $agentName, | ||
public string $reasoning = 'No reasoning provided', | ||
) { | ||
} | ||
|
||
/** | ||
* Checks if a specific agent was selected. | ||
*/ | ||
public function hasAgent(): bool | ||
{ | ||
return '' !== $this->agentName; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\AI\Agent\MultiAgent; | ||
|
||
use Psr\Log\LoggerInterface; | ||
use Psr\Log\NullLogger; | ||
use Symfony\AI\Agent\AgentInterface; | ||
use Symfony\AI\Agent\Exception\ExceptionInterface; | ||
use Symfony\AI\Agent\Exception\InvalidArgumentException; | ||
use Symfony\AI\Agent\Exception\RuntimeException; | ||
use Symfony\AI\Agent\MultiAgent\Handoff\Decision; | ||
use Symfony\AI\Platform\Message\Message; | ||
use Symfony\AI\Platform\Message\MessageBag; | ||
use Symfony\AI\Platform\Result\ResultInterface; | ||
|
||
/** | ||
* A multi-agent system that coordinates multiple specialized agents. | ||
* | ||
* This agent acts as a central orchestrator, delegating tasks to specialized agents | ||
* based on handoff rules and managing the conversation flow between agents. | ||
* | ||
* @author Oskar Stark <[email protected]> | ||
*/ | ||
final class MultiAgent implements AgentInterface | ||
{ | ||
/** | ||
* @param AgentInterface $orchestrator Agent responsible for analyzing requests and selecting appropriate handoffs | ||
* @param Handoff[] $handoffs Handoff definitions for agent routing | ||
* @param AgentInterface $fallback Fallback agent when no handoff conditions match | ||
* @param non-empty-string $name Name of the multi-agent | ||
* @param LoggerInterface $logger Logger for debugging handoff decisions | ||
*/ | ||
public function __construct( | ||
private AgentInterface $orchestrator, | ||
private array $handoffs, | ||
private AgentInterface $fallback, | ||
private string $name = 'multi-agent', | ||
private LoggerInterface $logger = new NullLogger(), | ||
) { | ||
if ([] === $handoffs) { | ||
throw new InvalidArgumentException('MultiAgent requires at least 1 handoff.'); | ||
} | ||
} | ||
|
||
/** | ||
* @return non-empty-string | ||
*/ | ||
public function getName(): string | ||
{ | ||
return $this->name; | ||
} | ||
|
||
/** | ||
* @throws ExceptionInterface When the agent encounters an error during orchestration or handoffs | ||
*/ | ||
public function call(MessageBag $messages, array $options = []): ResultInterface | ||
{ | ||
$userMessages = $messages->withoutSystemMessage(); | ||
|
||
$userText = $userMessages->getUserMessageText(); | ||
if (null === $userText) { | ||
throw new RuntimeException('No user message found in conversation.'); | ||
} | ||
$this->logger->debug('MultiAgent: Processing user message', ['user_text' => $userText]); | ||
|
||
$this->logger->debug('MultiAgent: Available agents for routing', ['agents' => array_map(fn ($handoff) => [ | ||
'to' => $handoff->getTo()->getName(), | ||
'when' => $handoff->getWhen(), | ||
], $this->handoffs)]); | ||
|
||
$agentSelectionPrompt = $this->buildAgentSelectionPrompt($userText); | ||
|
||
$decision = $this->orchestrator->call(new MessageBag(Message::ofUser($agentSelectionPrompt)), array_merge($options, [ | ||
'output_structure' => Decision::class, | ||
]))->getContent(); | ||
|
||
if (!$decision instanceof Decision) { | ||
$this->logger->debug('MultiAgent: Failed to get decision, falling back to orchestrator'); | ||
|
||
return $this->orchestrator->call($messages, $options); | ||
} | ||
|
||
$this->logger->debug('MultiAgent: Agent selection completed', [ | ||
'selected_agent' => $decision->agentName, | ||
'reasoning' => $decision->reasoning, | ||
]); | ||
|
||
if (!$decision->hasAgent()) { | ||
$this->logger->debug('MultiAgent: Using fallback agent', ['reason' => 'no_agent_selected']); | ||
|
||
return $this->fallback->call($messages, $options); | ||
} | ||
|
||
// Find the target agent by name | ||
$targetAgent = null; | ||
foreach ($this->handoffs as $handoff) { | ||
if ($handoff->getTo()->getName() === $decision->agentName) { | ||
$targetAgent = $handoff->getTo(); | ||
break; | ||
} | ||
} | ||
|
||
if (!$targetAgent) { | ||
$this->logger->debug('MultiAgent: Target agent not found, using fallback agent', [ | ||
'requested_agent' => $decision->agentName, | ||
'reason' => 'agent_not_found', | ||
]); | ||
|
||
return $this->fallback->call($messages, $options); | ||
} | ||
|
||
$this->logger->debug('MultiAgent: Delegating to agent', ['agent_name' => $decision->agentName]); | ||
|
||
$userMessage = $userMessages->getUserMessage(); | ||
if (null === $userMessage) { | ||
throw new RuntimeException('No user message found in conversation.'); | ||
} | ||
|
||
// Call the selected agent with the original user question | ||
return $targetAgent->call(new MessageBag($userMessage), $options); | ||
} | ||
|
||
private function buildAgentSelectionPrompt(string $userQuestion): string | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in a follow up we could make this configurable for the multiagent |
||
{ | ||
$agentDescriptions = []; | ||
$agentNames = []; | ||
|
||
foreach ($this->handoffs as $handoff) { | ||
$triggers = implode(', ', $handoff->getWhen()); | ||
$agentName = $handoff->getTo()->getName(); | ||
$agentDescriptions[] = "- {$agentName}: {$triggers}"; | ||
$agentNames[] = $agentName; | ||
} | ||
|
||
$agentDescriptions[] = "- {$this->fallback->getName()}: fallback agent for general/unmatched queries"; | ||
$agentNames[] = $this->fallback->getName(); | ||
|
||
$agentList = implode("\n", $agentDescriptions); | ||
$validAgents = implode('", "', $agentNames); | ||
|
||
return <<<PROMPT | ||
You are an intelligent agent orchestrator. Based on the user's question, determine which specialized agent should handle the request. | ||
|
||
User question: "{$userQuestion}" | ||
|
||
Available agents and their capabilities: | ||
{$agentList} | ||
|
||
Analyze the user's question and select the most appropriate agent to handle this request. | ||
Return an empty string ("") for agentName if no specific agent matches the request criteria. | ||
|
||
Available agent names: {$validAgents} | ||
|
||
Provide your selection and explain your reasoning. | ||
PROMPT; | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.