diff --git a/demo/config/packages/ai.yaml b/demo/config/packages/ai.yaml
index 09a2bd1e7..19912c323 100644
--- a/demo/config/packages/ai.yaml
+++ b/demo/config/packages/ai.yaml
@@ -40,6 +40,24 @@ ai:
- agent: 'blog'
name: 'symfony_blog'
description: 'Can answer questions based on the Symfony blog.'
+ orchestrator:
+ model: 'gpt-4o-mini'
+ prompt: 'You are an intelligent agent orchestrator that routes user questions to specialized agents.'
+ tools: false
+ technical:
+ model: 'gpt-4o-mini'
+ prompt: 'You are a technical support specialist. Help users resolve bugs, problems, and technical errors.'
+ tools: false
+ fallback:
+ model: 'gpt-4o-mini'
+ prompt: 'You are a helpful general assistant. Assist users with any questions or tasks they may have.'
+ tools: false
+ multi_agent:
+ support:
+ orchestrator: 'orchestrator'
+ handoffs:
+ technical: ['bug', 'problem', 'technical', 'error', 'code', 'debug']
+ fallback: 'fallback'
store:
chroma_db:
symfonycon:
diff --git a/examples/bootstrap.php b/examples/bootstrap.php
index 702f63711..a489578b1 100644
--- a/examples/bootstrap.php
+++ b/examples/bootstrap.php
@@ -50,7 +50,39 @@ function http_client(): HttpClientInterface
function logger(): LoggerInterface
{
- return new ConsoleLogger(output());
+ $output = output();
+
+ return new class($output) extends ConsoleLogger {
+ private ConsoleOutput $output;
+
+ public function __construct(ConsoleOutput $output)
+ {
+ parent::__construct($output);
+ $this->output = $output;
+ }
+
+ /**
+ * @param Stringable|string $message
+ */
+ public function log($level, $message, array $context = []): void
+ {
+ // Call parent to handle the base logging
+ parent::log($level, $message, $context);
+
+ // Add context display for debug verbosity
+ if ($this->output->getVerbosity() >= ConsoleOutput::VERBOSITY_DEBUG && [] !== $context) {
+ // Filter out special keys that are already handled
+ $displayContext = array_filter($context, function ($key) {
+ return !in_array($key, ['exception', 'error', 'object'], true);
+ }, \ARRAY_FILTER_USE_KEY);
+
+ if ([] !== $displayContext) {
+ $contextMessage = ' '.json_encode($displayContext, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
+ $this->output->writeln(sprintf('%s', $contextMessage));
+ }
+ }
+ }
+ };
}
function output(): ConsoleOutput
diff --git a/examples/multi-agent/orchestrator.php b/examples/multi-agent/orchestrator.php
new file mode 100644
index 000000000..4cdcde1dd
--- /dev/null
+++ b/examples/multi-agent/orchestrator.php
@@ -0,0 +1,76 @@
+
+ *
+ * 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;
diff --git a/src/agent/src/MultiAgent/Handoff.php b/src/agent/src/MultiAgent/Handoff.php
new file mode 100644
index 000000000..0d815d44c
--- /dev/null
+++ b/src/agent/src/MultiAgent/Handoff.php
@@ -0,0 +1,48 @@
+
+ *
+ * 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
+ */
+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;
+ }
+}
diff --git a/src/agent/src/MultiAgent/Handoff/Decision.php b/src/agent/src/MultiAgent/Handoff/Decision.php
new file mode 100644
index 000000000..e8edbe309
--- /dev/null
+++ b/src/agent/src/MultiAgent/Handoff/Decision.php
@@ -0,0 +1,38 @@
+
+ *
+ * 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
+ */
+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;
+ }
+}
diff --git a/src/agent/src/MultiAgent/MultiAgent.php b/src/agent/src/MultiAgent/MultiAgent.php
new file mode 100644
index 000000000..dd5508078
--- /dev/null
+++ b/src/agent/src/MultiAgent/MultiAgent.php
@@ -0,0 +1,166 @@
+
+ *
+ * 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
+ */
+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
+ {
+ $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 <<
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\Agent\Tests\MultiAgent\Handoff;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\AI\Agent\MultiAgent\Handoff\Decision;
+
+/**
+ * @author Oskar Stark
+ */
+class DecisionTest extends TestCase
+{
+ public function testConstructorWithAgentName()
+ {
+ $decision = new Decision('technical', 'This is a technical question');
+
+ $this->assertSame('technical', $decision->agentName);
+ $this->assertSame('This is a technical question', $decision->reasoning);
+ $this->assertTrue($decision->hasAgent());
+ }
+
+ public function testConstructorWithEmptyAgentName()
+ {
+ $decision = new Decision('', 'No specific agent needed');
+
+ $this->assertSame('', $decision->agentName);
+ $this->assertSame('No specific agent needed', $decision->reasoning);
+ $this->assertFalse($decision->hasAgent());
+ }
+
+ public function testConstructorWithDefaultReasoning()
+ {
+ $decision = new Decision('general');
+
+ $this->assertSame('general', $decision->agentName);
+ $this->assertSame('No reasoning provided', $decision->reasoning);
+ $this->assertTrue($decision->hasAgent());
+ }
+
+ public function testConstructorWithEmptyAgentAndDefaultReasoning()
+ {
+ $decision = new Decision('');
+
+ $this->assertSame('', $decision->agentName);
+ $this->assertSame('No reasoning provided', $decision->reasoning);
+ $this->assertFalse($decision->hasAgent());
+ }
+
+ public function testHasAgentReturnsTrueForNonEmptyAgent()
+ {
+ $decision = new Decision('support');
+
+ $this->assertTrue($decision->hasAgent());
+ }
+
+ public function testHasAgentReturnsFalseForEmptyAgent()
+ {
+ $decision = new Decision('');
+
+ $this->assertFalse($decision->hasAgent());
+ }
+}
diff --git a/src/agent/tests/MultiAgent/HandoffTest.php b/src/agent/tests/MultiAgent/HandoffTest.php
new file mode 100644
index 000000000..e67a6b38b
--- /dev/null
+++ b/src/agent/tests/MultiAgent/HandoffTest.php
@@ -0,0 +1,75 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\Agent\Tests\MultiAgent;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\AI\Agent\Exception\InvalidArgumentException;
+use Symfony\AI\Agent\MockAgent;
+use Symfony\AI\Agent\MultiAgent\Handoff;
+
+/**
+ * @author Oskar Stark
+ */
+class HandoffTest extends TestCase
+{
+ public function testConstructorWithValidConditions()
+ {
+ $agent = new MockAgent(name: 'technical');
+ $when = ['code', 'debug', 'programming'];
+
+ $handoff = new Handoff($agent, $when);
+
+ $this->assertSame($agent, $handoff->getTo());
+ $this->assertSame($when, $handoff->getWhen());
+ }
+
+ public function testConstructorWithSingleCondition()
+ {
+ $agent = new MockAgent(name: 'error-handler');
+ $when = ['error'];
+
+ $handoff = new Handoff($agent, $when);
+
+ $this->assertSame($agent, $handoff->getTo());
+ $this->assertSame($when, $handoff->getWhen());
+ }
+
+ public function testConstructorThrowsExceptionForEmptyWhenArray()
+ {
+ $agent = new MockAgent();
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Handoff must have at least one "when" condition');
+
+ new Handoff($agent, []);
+ }
+
+ public function testGetToReturnsAgent()
+ {
+ $agent = new MockAgent(name: 'technical');
+
+ $handoff = new Handoff($agent, ['code']);
+
+ $this->assertSame($agent, $handoff->getTo());
+ $this->assertSame('technical', $handoff->getTo()->getName());
+ }
+
+ public function testGetWhenReturnsConditions()
+ {
+ $agent = new MockAgent(name: 'billing');
+ $when = ['payment', 'billing', 'invoice', 'subscription'];
+
+ $handoff = new Handoff($agent, $when);
+
+ $this->assertSame($when, $handoff->getWhen());
+ }
+}
diff --git a/src/agent/tests/MultiAgent/MultiAgentTest.php b/src/agent/tests/MultiAgent/MultiAgentTest.php
new file mode 100644
index 000000000..6b07b541a
--- /dev/null
+++ b/src/agent/tests/MultiAgent/MultiAgentTest.php
@@ -0,0 +1,376 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\Agent\Tests\MultiAgent;
+
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\AI\Agent\AgentInterface;
+use Symfony\AI\Agent\Exception\InvalidArgumentException;
+use Symfony\AI\Agent\Exception\RuntimeException;
+use Symfony\AI\Agent\MockAgent;
+use Symfony\AI\Agent\MultiAgent\Handoff;
+use Symfony\AI\Agent\MultiAgent\Handoff\Decision;
+use Symfony\AI\Agent\MultiAgent\MultiAgent;
+use Symfony\AI\Platform\Message\Content\Text;
+use Symfony\AI\Platform\Message\Message;
+use Symfony\AI\Platform\Message\MessageBag;
+use Symfony\AI\Platform\Message\SystemMessage;
+use Symfony\AI\Platform\Message\UserMessage;
+use Symfony\AI\Platform\Result\ResultInterface;
+use Symfony\AI\Platform\Result\TextResult;
+
+/**
+ * @author Oskar Stark
+ */
+class MultiAgentTest extends TestCase
+{
+ public function testConstructorThrowsExceptionForEmptyHandoffs()
+ {
+ $orchestrator = new MockAgent(name: 'orchestrator');
+ $fallback = new MockAgent(name: 'fallback');
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('MultiAgent requires at least 1 handoff.');
+
+ new MultiAgent($orchestrator, [], $fallback);
+ }
+
+ public function testGetName()
+ {
+ $orchestrator = new MockAgent(name: 'orchestrator');
+ $fallback = new MockAgent(name: 'fallback');
+ $handoff = new Handoff(new MockAgent(name: 'technical'), ['technical', 'coding']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback, 'custom-multi-agent');
+
+ $this->assertSame('custom-multi-agent', $multiAgent->getName());
+ }
+
+ public function testGetNameWithDefaultName()
+ {
+ $orchestrator = new MockAgent(name: 'orchestrator');
+ $fallback = new MockAgent(name: 'fallback');
+ $handoff = new Handoff(new MockAgent(name: 'technical'), ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ $this->assertSame('multi-agent', $multiAgent->getName());
+ }
+
+ public function testCallThrowsExceptionWhenNoUserMessage()
+ {
+ $orchestrator = new MockAgent(name: 'orchestrator');
+ $fallback = new MockAgent(name: 'fallback');
+ $handoff = new Handoff(new MockAgent(name: 'technical'), ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ $messages = new MessageBag(new SystemMessage('System prompt'));
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('No user message found in conversation.');
+
+ $multiAgent->call($messages);
+ }
+
+ public function testCallDelegatesToSelectedAgent()
+ {
+ $decision = new Decision('technical', 'This is a technical question');
+
+ // Create a mock result that returns the Decision object
+ $orchestratorResult = $this->createMock(ResultInterface::class);
+ $orchestratorResult->method('getContent')->willReturn($decision);
+
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->method('call')->willReturn($orchestratorResult);
+
+ $expectedResult = new TextResult('Technical response');
+ $technicalAgent = $this->createMock(AgentInterface::class);
+ $technicalAgent->method('getName')->willReturn('technical');
+ $technicalAgent->method('call')->willReturn($expectedResult);
+
+ $fallback = new MockAgent(name: 'fallback');
+ $handoff = new Handoff($technicalAgent, ['technical', 'coding']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ $messages = new MessageBag(Message::ofUser('How do I implement a function?'));
+
+ $result = $multiAgent->call($messages);
+
+ $this->assertSame($expectedResult, $result);
+ }
+
+ public function testCallUsesOrchestratorWhenDecisionIsNotReturned()
+ {
+ // Create a mock result that returns a non-Decision content
+ $firstResult = $this->createMock(ResultInterface::class);
+ $firstResult->method('getContent')->willReturn('Not a Decision object');
+
+ $expectedResult = new TextResult('Orchestrator response');
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->method('call')
+ ->willReturnOnConsecutiveCalls(
+ $firstResult,
+ $expectedResult
+ );
+
+ $fallback = new MockAgent(name: 'fallback');
+ $handoff = new Handoff(new MockAgent(name: 'technical'), ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ $messages = new MessageBag(Message::ofUser('Hello'));
+
+ $result = $multiAgent->call($messages);
+
+ $this->assertSame($expectedResult, $result);
+ }
+
+ public function testCallUsesFallbackWhenNoAgentSelected()
+ {
+ $decision = new Decision('', 'No specific agent matches');
+
+ // Create a mock result that returns the Decision object
+ $orchestratorResult = $this->createMock(ResultInterface::class);
+ $orchestratorResult->method('getContent')->willReturn($decision);
+
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->method('call')->willReturn($orchestratorResult);
+
+ $expectedResult = new TextResult('Fallback response');
+ $fallback = $this->createMock(AgentInterface::class);
+ $fallback->method('getName')->willReturn('fallback');
+ $fallback->method('call')->willReturn($expectedResult);
+
+ $handoff = new Handoff(new MockAgent(name: 'technical'), ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ $messages = new MessageBag(Message::ofUser('General question'));
+
+ $result = $multiAgent->call($messages);
+
+ $this->assertSame($expectedResult, $result);
+ }
+
+ public function testCallUsesFallbackWhenTargetAgentNotFound()
+ {
+ $decision = new Decision('nonexistent', 'Selected non-existent agent');
+
+ // Create a mock result that returns the Decision object
+ $orchestratorResult = $this->createMock(ResultInterface::class);
+ $orchestratorResult->method('getContent')->willReturn($decision);
+
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->method('call')->willReturn($orchestratorResult);
+
+ $expectedResult = new TextResult('Fallback response');
+ $fallback = $this->createMock(AgentInterface::class);
+ $fallback->method('getName')->willReturn('fallback');
+ $fallback->method('call')->willReturn($expectedResult);
+
+ $handoff = new Handoff(new MockAgent(name: 'technical'), ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ $messages = new MessageBag(Message::ofUser('Question'));
+
+ $result = $multiAgent->call($messages);
+
+ $this->assertSame($expectedResult, $result);
+ }
+
+ public function testCallWithMultipleHandoffs()
+ {
+ $decision = new Decision('creative', 'This is a creative task');
+
+ // Create a mock result that returns the Decision object
+ $orchestratorResult = $this->createMock(ResultInterface::class);
+ $orchestratorResult->method('getContent')->willReturn($decision);
+
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->method('call')->willReturn($orchestratorResult);
+
+ $technicalAgent = new MockAgent(name: 'technical');
+ $expectedResult = new TextResult('Creative response');
+ $creativeAgent = $this->createMock(AgentInterface::class);
+ $creativeAgent->method('getName')->willReturn('creative');
+ $creativeAgent->method('call')->willReturn($expectedResult);
+
+ $fallback = new MockAgent(name: 'fallback');
+
+ $handoffs = [
+ new Handoff($technicalAgent, ['technical', 'coding']),
+ new Handoff($creativeAgent, ['creative', 'writing']),
+ ];
+
+ $multiAgent = new MultiAgent($orchestrator, $handoffs, $fallback);
+
+ $messages = new MessageBag(Message::ofUser('Write a poem'));
+
+ $result = $multiAgent->call($messages);
+
+ $this->assertSame($expectedResult, $result);
+ }
+
+ public function testCallPassesOptionsToAgents()
+ {
+ $options = ['temperature' => 0.7, 'max_tokens' => 100];
+
+ $decision = new Decision('technical', 'Technical question');
+
+ // Create a mock result that returns the Decision object
+ $orchestratorResult = $this->createMock(ResultInterface::class);
+ $orchestratorResult->method('getContent')->willReturn($decision);
+
+ // Create a mock that verifies options are passed correctly
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->expects($this->once())
+ ->method('call')
+ ->with(
+ $this->isInstanceOf(MessageBag::class),
+ $this->callback(fn ($opts) => isset($opts['temperature']) && 0.7 === $opts['temperature']
+ && isset($opts['max_tokens']) && 100 === $opts['max_tokens']
+ && isset($opts['output_structure']) && Decision::class === $opts['output_structure']
+ )
+ )
+ ->willReturn($orchestratorResult);
+
+ $technicalAgent = $this->createMock(AgentInterface::class);
+ $technicalAgent->method('getName')->willReturn('technical');
+ $technicalAgent->expects($this->once())
+ ->method('call')
+ ->with(
+ $this->isInstanceOf(MessageBag::class),
+ $options
+ )
+ ->willReturn(new TextResult('Response'));
+
+ $fallback = new MockAgent(name: 'fallback');
+ $handoff = new Handoff($technicalAgent, ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ $messages = new MessageBag(Message::ofUser('Technical question'));
+
+ $multiAgent->call($messages, $options);
+ }
+
+ public function testCallWithLogging()
+ {
+ $logger = $this->createMock(LoggerInterface::class);
+
+ // Expect 4 debug log messages
+ $logger->expects($this->exactly(4))
+ ->method('debug');
+
+ $decision = new Decision('technical', 'Technical question');
+
+ // Create a mock result that returns the Decision object
+ $orchestratorResult = $this->createMock(ResultInterface::class);
+ $orchestratorResult->method('getContent')->willReturn($decision);
+
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->method('call')->willReturn($orchestratorResult);
+
+ $technicalAgent = $this->createMock(AgentInterface::class);
+ $technicalAgent->method('getName')->willReturn('technical');
+ $technicalAgent->method('call')->willReturn(new TextResult('Response'));
+
+ $fallback = new MockAgent(name: 'fallback');
+ $handoff = new Handoff($technicalAgent, ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback, 'test', $logger);
+
+ $messages = new MessageBag(Message::ofUser('Technical question'));
+
+ $multiAgent->call($messages);
+ }
+
+ public function testCallExtractsTextFromComplexUserMessage()
+ {
+ $decision = new Decision('technical', 'Technical question');
+
+ // Create a mock result that returns the Decision object
+ $orchestratorResult = $this->createMock(ResultInterface::class);
+ $orchestratorResult->method('getContent')->willReturn($decision);
+
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->method('call')->willReturn($orchestratorResult);
+
+ $expectedResult = new TextResult('Technical response');
+ $technicalAgent = $this->createMock(AgentInterface::class);
+ $technicalAgent->method('getName')->willReturn('technical');
+ $technicalAgent->method('call')->willReturn($expectedResult);
+
+ $fallback = new MockAgent(name: 'fallback');
+ $handoff = new Handoff($technicalAgent, ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ // Create a complex user message with multiple text parts
+ $userMessage = new UserMessage(
+ new Text('Part 1'),
+ new Text('Part 2'),
+ );
+
+ $messages = new MessageBag($userMessage);
+
+ $result = $multiAgent->call($messages);
+
+ $this->assertSame($expectedResult, $result);
+ }
+
+ public function testBuildAgentSelectionPromptIncludesFallback()
+ {
+ $decision = new Decision('');
+
+ // Create a mock result that returns the Decision object
+ $orchestratorResult = $this->createMock(ResultInterface::class);
+ $orchestratorResult->method('getContent')->willReturn($decision);
+
+ $orchestrator = $this->createMock(AgentInterface::class);
+ $orchestrator->method('getName')->willReturn('orchestrator');
+ $orchestrator->expects($this->once())
+ ->method('call')
+ ->with(
+ $this->callback(function (MessageBag $messages) {
+ $text = $messages->getUserMessageText();
+
+ return str_contains($text, 'general-fallback: fallback agent for general/unmatched queries');
+ }),
+ $this->anything()
+ )
+ ->willReturn($orchestratorResult);
+
+ $fallback = $this->createMock(AgentInterface::class);
+ $fallback->method('getName')->willReturn('general-fallback');
+ $fallback->method('call')->willReturn(new TextResult('Fallback response'));
+
+ $handoff = new Handoff(new MockAgent(name: 'technical'), ['technical']);
+
+ $multiAgent = new MultiAgent($orchestrator, [$handoff], $fallback);
+
+ $messages = new MessageBag(Message::ofUser('Question'));
+
+ $multiAgent->call($messages);
+ }
+}
diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php
index 172b887cb..3c95cf6be 100644
--- a/src/ai-bundle/config/options.php
+++ b/src/ai-bundle/config/options.php
@@ -385,6 +385,33 @@
->end()
->end()
->end()
+ ->arrayNode('multi_agent')
+ ->info('Multi-agent orchestration configuration')
+ ->useAttributeAsKey('name')
+ ->arrayPrototype()
+ ->children()
+ ->stringNode('orchestrator')
+ ->info('Service ID of the orchestrator agent')
+ ->isRequired()
+ ->end()
+ ->arrayNode('handoffs')
+ ->info('Handoff rules mapping agent service IDs to trigger keywords')
+ ->isRequired()
+ ->requiresAtLeastOneElement()
+ ->useAttributeAsKey('service')
+ ->arrayPrototype()
+ ->info('Keywords or phrases that trigger handoff to this agent')
+ ->requiresAtLeastOneElement()
+ ->scalarPrototype()->end()
+ ->end()
+ ->end()
+ ->stringNode('fallback')
+ ->info('Service ID of the fallback agent for unmatched requests')
+ ->isRequired()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
->arrayNode('store')
->children()
->arrayNode('azure_search')
@@ -688,5 +715,74 @@
->end()
->end()
->end()
+ ->validate()
+ ->ifTrue(function ($v) {
+ if (!isset($v['agent']) || !isset($v['multi_agent'])) {
+ return false;
+ }
+
+ $agentNames = array_keys($v['agent']);
+ $multiAgentNames = array_keys($v['multi_agent']);
+ $duplicates = array_intersect($agentNames, $multiAgentNames);
+
+ return !empty($duplicates);
+ })
+ ->then(function ($v) {
+ $agentNames = array_keys($v['agent'] ?? []);
+ $multiAgentNames = array_keys($v['multi_agent'] ?? []);
+ $duplicates = array_intersect($agentNames, $multiAgentNames);
+
+ throw new \InvalidArgumentException(\sprintf('Agent names and multi-agent names must be unique. Duplicate name(s) found: "%s"', implode(', ', $duplicates)));
+ })
+ ->end()
+ ->validate()
+ ->ifTrue(function ($v) {
+ if (!isset($v['multi_agent']) || !isset($v['agent'])) {
+ return false;
+ }
+
+ $agentNames = array_keys($v['agent']);
+
+ foreach ($v['multi_agent'] as $multiAgentName => $multiAgent) {
+ // Check orchestrator exists
+ if (!\in_array($multiAgent['orchestrator'], $agentNames, true)) {
+ return true;
+ }
+
+ // Check fallback exists
+ if (!\in_array($multiAgent['fallback'], $agentNames, true)) {
+ return true;
+ }
+
+ // Check handoff agents exist
+ foreach (array_keys($multiAgent['handoffs']) as $handoffAgent) {
+ if (!\in_array($handoffAgent, $agentNames, true)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ })
+ ->then(function ($v) {
+ $agentNames = array_keys($v['agent']);
+
+ foreach ($v['multi_agent'] as $multiAgentName => $multiAgent) {
+ if (!\in_array($multiAgent['orchestrator'], $agentNames, true)) {
+ throw new \InvalidArgumentException(\sprintf('The agent "%s" referenced in multi-agent "%s" as orchestrator does not exist', $multiAgent['orchestrator'], $multiAgentName));
+ }
+
+ if (!\in_array($multiAgent['fallback'], $agentNames, true)) {
+ throw new \InvalidArgumentException(\sprintf('The agent "%s" referenced in multi-agent "%s" as fallback does not exist', $multiAgent['fallback'], $multiAgentName));
+ }
+
+ foreach (array_keys($multiAgent['handoffs']) as $handoffAgent) {
+ if (!\in_array($handoffAgent, $agentNames, true)) {
+ throw new \InvalidArgumentException(\sprintf('The agent "%s" referenced in multi-agent "%s" as handoff target does not exist', $handoffAgent, $multiAgentName));
+ }
+ }
+ }
+ })
+ ->end()
;
};
diff --git a/src/ai-bundle/doc/index.rst b/src/ai-bundle/doc/index.rst
index 83e2e5108..887db3c89 100644
--- a/src/ai-bundle/doc/index.rst
+++ b/src/ai-bundle/doc/index.rst
@@ -432,6 +432,136 @@ The system uses explicit configuration to determine memory behavior:
In both cases, memory content is prepended to the system message, allowing the agent to utilize the context effectively.
+Multi-Agent Orchestration
+-------------------------
+
+The AI Bundle provides a configuration system for creating multi-agent orchestrators that route requests to specialized agents based on defined handoff rules.
+
+**Multi-Agent vs Agent-as-Tool**
+
+The AI Bundle supports two different approaches for combining multiple agents:
+
+1. **Agent-as-Tool**: An agent can use another agent as a tool during its processing. The main agent decides when and how to call the secondary agent, similar to any other tool. This is useful when:
+
+ - The main agent needs optional access to specialized capabilities
+ - The decision to use the secondary agent is context-dependent
+ - You want the main agent to control the entire conversation flow
+ - The secondary agent provides supplementary information
+
+ Example: A general assistant that can optionally query a research agent for detailed information.
+
+2. **Multi-Agent Orchestration**: A dedicated orchestrator analyzes each request and routes it to the most appropriate specialized agent. This is useful when:
+
+ - You have distinct domains that require different expertise
+ - You want clear separation of concerns between agents
+ - The routing decision should be made upfront based on the request type
+ - Each agent should handle the entire conversation for its domain
+
+ Example: A customer service system that routes to technical support, billing, or general inquiries based on the user's question.
+
+**Key Differences**
+
+* **Control Flow**: Agent-as-tool maintains control in the primary agent; Multi-Agent delegates full control to the selected agent
+* **Decision Making**: Agent-as-tool decides during processing; Multi-Agent decides before processing
+* **Response Generation**: Agent-as-tool integrates tool responses; Multi-Agent returns the selected agent's complete response
+* **Use Case**: Agent-as-tool for augmentation; Multi-Agent for specialization
+
+**Configuration**
+
+.. code-block:: yaml
+
+ # config/packages/ai.yaml
+ ai:
+ multi_agent:
+ # Define named multi-agent systems
+ support:
+ # The main orchestrator agent that analyzes requests
+ orchestrator: 'orchestrator'
+
+ # Handoff rules mapping agents to trigger keywords
+ # At least 1 handoff required
+ handoffs:
+ technical: ['bug', 'problem', 'technical', 'error', 'code', 'debug']
+
+ # Fallback agent for unmatched requests (required)
+ fallback: 'general'
+
+.. important::
+
+ The orchestrator agent MUST have ``structured_output: true`` (the default) to work correctly.
+ The multi-agent system uses structured output to reliably parse agent selection decisions.
+
+Each multi-agent configuration automatically registers a service with the ID pattern ``ai.multi_agent.{name}``.
+
+For the example above, the service ``ai.multi_agent.support`` is registered and can be injected::
+
+ use Symfony\AI\Agent\AgentInterface;
+ use Symfony\AI\Platform\Message\Message;
+ use Symfony\AI\Platform\Message\MessageBag;
+ use Symfony\Component\DependencyInjection\Attribute\Autowire;
+
+ final class SupportController
+ {
+ public function __construct(
+ #[Autowire(service: 'ai.multi_agent.support')]
+ private AgentInterface $supportAgent,
+ ) {
+ }
+
+ public function askSupport(string $question): string
+ {
+ $messages = new MessageBag(Message::ofUser($question));
+ $response = $this->supportAgent->call($messages);
+
+ return $response->getContent();
+ }
+ }
+
+**Handoff Rules and Fallback**
+
+Handoff rules are defined as a key-value mapping where:
+
+* **Key**: The name of the target agent (automatically prefixed with ``ai.agent.``)
+* **Value**: An array of keywords or phrases that trigger this handoff
+
+Example of creating a Handoff in PHP::
+
+ use Symfony\AI\Agent\MultiAgent\Handoff;
+
+ $technicalHandoff = new Handoff(
+ to: $technicalAgent,
+ when: ['code', 'debug', 'implementation', 'refactor', 'programming']
+ );
+
+ $documentationHandoff = new Handoff(
+ to: $documentationAgent,
+ when: ['document', 'readme', 'explain', 'tutorial']
+ );
+
+The ``fallback`` parameter (required) specifies an agent to handle requests that don't match any handoff rules. This ensures all requests have a proper handler.
+
+**How It Works**
+
+1. The orchestrator agent receives the initial request
+2. It analyzes the request content and matches it against handoff rules
+3. If keywords match a handoff's conditions, the request is delegated to that agent
+4. If no specific conditions match, the request is delegated to the fallback agent
+5. The selected agent processes the request and returns the response
+
+**Example: Customer Service Bot**
+
+.. code-block:: yaml
+
+ ai:
+ multi_agent:
+ customer_service:
+ orchestrator: 'analyzer'
+ handoffs:
+ tech_support: ['error', 'bug', 'crash', 'not working', 'broken']
+ billing: ['payment', 'invoice', 'billing', 'subscription', 'price']
+ product_info: ['features', 'how to', 'tutorial', 'guide', 'documentation']
+ fallback: 'general_support' # Fallback for general inquiries
+
Usage
-----
diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php
index 1eff064cc..5a4efb1e8 100644
--- a/src/ai-bundle/src/AiBundle.php
+++ b/src/ai-bundle/src/AiBundle.php
@@ -21,6 +21,8 @@
use Symfony\AI\Agent\InputProcessorInterface;
use Symfony\AI\Agent\Memory\MemoryInputProcessor;
use Symfony\AI\Agent\Memory\StaticMemoryProvider;
+use Symfony\AI\Agent\MultiAgent\Handoff;
+use Symfony\AI\Agent\MultiAgent\MultiAgent;
use Symfony\AI\Agent\OutputProcessorInterface;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Agent\Toolbox\FaultTolerantToolbox;
@@ -138,6 +140,10 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
$builder->setAlias(AgentInterface::class, 'ai.agent.'.$agentName);
}
+ foreach ($config['multi_agent'] ?? [] as $multiAgentName => $multiAgent) {
+ $this->processMultiAgentConfig($multiAgentName, $multiAgent, $builder);
+ }
+
foreach ($config['store'] ?? [] as $type => $store) {
$this->processStoreConfig($type, $store, $builder);
}
@@ -1187,4 +1193,49 @@ private function processIndexerConfig(int|string $name, array $config, Container
$container->setDefinition('ai.indexer.'.$name, $definition);
}
+
+ /**
+ * @param array $config
+ */
+ private function processMultiAgentConfig(string $name, array $config, ContainerBuilder $container): void
+ {
+ $orchestratorServiceId = self::normalizeAgentServiceId($config['orchestrator']);
+
+ $handoffReferences = [];
+
+ foreach ($config['handoffs'] as $agentName => $whenConditions) {
+ // Create handoff definitions directly (not as separate services)
+ // The container will inline simple value objects like Handoff
+ $handoffReferences[] = new Definition(Handoff::class, [
+ new Reference(self::normalizeAgentServiceId($agentName)),
+ $whenConditions,
+ ]);
+ }
+
+ $multiAgentId = 'ai.multi_agent.'.$name;
+ $multiAgentDefinition = new Definition(MultiAgent::class, [
+ new Reference($orchestratorServiceId),
+ $handoffReferences,
+ new Reference(self::normalizeAgentServiceId($config['fallback'])),
+ $name,
+ ]);
+
+ $multiAgentDefinition->addTag('ai.multi_agent', ['name' => $name]);
+ $multiAgentDefinition->addTag('ai.agent', ['name' => $name]);
+
+ $container->setDefinition($multiAgentId, $multiAgentDefinition);
+ $container->registerAliasForArgument($multiAgentId, AgentInterface::class, (new Target($name.'MultiAgent'))->getParsedName());
+ }
+
+ /**
+ * Ensures an agent name has the 'ai.agent.' prefix for service resolution.
+ *
+ * @param non-empty-string $agentName
+ *
+ * @return non-empty-string
+ */
+ private static function normalizeAgentServiceId(string $agentName): string
+ {
+ return str_starts_with($agentName, 'ai.agent.') ? $agentName : 'ai.agent.'.$agentName;
+ }
}
diff --git a/src/ai-bundle/src/DependencyInjection/ProcessorCompilerPass.php b/src/ai-bundle/src/DependencyInjection/ProcessorCompilerPass.php
index 6498a0be4..b9ecb648d 100644
--- a/src/ai-bundle/src/DependencyInjection/ProcessorCompilerPass.php
+++ b/src/ai-bundle/src/DependencyInjection/ProcessorCompilerPass.php
@@ -11,6 +11,7 @@
namespace Symfony\AI\AiBundle\DependencyInjection;
+use Symfony\AI\Agent\MultiAgent\MultiAgent;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
@@ -23,6 +24,13 @@ public function process(ContainerBuilder $container): void
$outputProcessors = $container->findTaggedServiceIds('ai.agent.output_processor');
foreach ($container->findTaggedServiceIds('ai.agent') as $serviceId => $tags) {
+ $agentDefinition = $container->getDefinition($serviceId);
+
+ // Skip MultiAgent services - they have a different constructor signature
+ if (MultiAgent::class === $agentDefinition->getClass()) {
+ continue;
+ }
+
$agentInputProcessors = [];
$agentOutputProcessors = [];
foreach ($inputProcessors as $processorId => $processorTags) {
@@ -57,7 +65,6 @@ public function process(ContainerBuilder $container): void
usort($agentInputProcessors, $sortCb);
usort($agentOutputProcessors, $sortCb);
- $agentDefinition = $container->getDefinition($serviceId);
$agentDefinition
->setArgument(2, array_column($agentInputProcessors, 1))
->setArgument(3, array_column($agentOutputProcessors, 1));
diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php
index efc53c8f8..c4348381f 100644
--- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php
+++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php
@@ -18,6 +18,8 @@
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Agent\Memory\MemoryInputProcessor;
use Symfony\AI\Agent\Memory\StaticMemoryProvider;
+use Symfony\AI\Agent\MultiAgent\Handoff;
+use Symfony\AI\Agent\MultiAgent\MultiAgent;
use Symfony\AI\AiBundle\AiBundle;
use Symfony\AI\Store\Document\Filter\TextContainsFilter;
use Symfony\AI\Store\Document\Loader\InMemoryLoader;
@@ -27,6 +29,7 @@
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Translation\TranslatableMessage;
@@ -1430,9 +1433,7 @@ public function testModelConfigurationWithQueryParameters()
'ai' => [
'agent' => [
'test' => [
- 'model' => [
- 'name' => 'gpt-4o-mini?temperature=0.5&max_tokens=2000',
- ],
+ 'model' => 'gpt-4o-mini?temperature=0.5&max_tokens=2000',
],
],
],
@@ -1496,9 +1497,7 @@ public function testModelConfigurationTypeConversion()
'ai' => [
'agent' => [
'test' => [
- 'model' => [
- 'name' => 'gpt-4o-mini?temperature=0.5&max_tokens=2000&stream=true&presence_penalty=0',
- ],
+ 'model' => 'gpt-4o-mini?temperature=0.5&max_tokens=2000&stream=true&presence_penalty=0',
],
],
],
@@ -1516,9 +1515,7 @@ public function testVectorizerModelConfigurationWithQueryParameters()
'ai' => [
'vectorizer' => [
'test' => [
- 'model' => [
- 'name' => 'text-embedding-3-small?dimensions=512',
- ],
+ 'model' => 'text-embedding-3-small?dimensions=512',
],
],
],
@@ -2087,6 +2084,440 @@ public function testIndexerWithSourceFiltersAndTransformers()
$this->assertSame('logger', (string) $arguments[6]);
}
+ public function testValidMultiAgentConfiguration()
+ {
+ $container = $this->buildContainer([
+ 'ai' => [
+ 'agent' => [
+ 'dispatcher' => [
+ 'model' => 'gpt-4o-mini',
+ ],
+ 'technical' => [
+ 'model' => 'gpt-4',
+ ],
+ 'general' => [
+ 'model' => 'claude-3-opus-20240229',
+ ],
+ ],
+ 'multi_agent' => [
+ 'support' => [
+ 'orchestrator' => 'dispatcher',
+ 'fallback' => 'general',
+ 'handoffs' => [
+ 'technical' => ['code', 'debug', 'error'],
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ // Verify the MultiAgent service is created
+ $this->assertTrue($container->hasDefinition('ai.multi_agent.support'));
+
+ $multiAgentDefinition = $container->getDefinition('ai.multi_agent.support');
+
+ // Verify the class is correct
+ $this->assertSame(MultiAgent::class, $multiAgentDefinition->getClass());
+
+ // Verify arguments
+ $arguments = $multiAgentDefinition->getArguments();
+ $this->assertCount(4, $arguments);
+
+ // First argument: orchestrator agent reference
+ $this->assertInstanceOf(Reference::class, $arguments[0]);
+ $this->assertSame('ai.agent.dispatcher', (string) $arguments[0]);
+
+ // Second argument: handoffs array
+ $handoffs = $arguments[1];
+ $this->assertIsArray($handoffs);
+ $this->assertCount(1, $handoffs);
+
+ // Verify handoff structure
+ $handoff = $handoffs[0];
+ $this->assertInstanceOf(Definition::class, $handoff);
+ $this->assertSame(Handoff::class, $handoff->getClass());
+ $handoffArgs = $handoff->getArguments();
+ $this->assertCount(2, $handoffArgs);
+ $this->assertInstanceOf(Reference::class, $handoffArgs[0]);
+ $this->assertSame('ai.agent.technical', (string) $handoffArgs[0]);
+ $this->assertSame(['code', 'debug', 'error'], $handoffArgs[1]);
+
+ // Third argument: fallback agent reference
+ $this->assertInstanceOf(Reference::class, $arguments[2]);
+ $this->assertSame('ai.agent.general', (string) $arguments[2]);
+
+ // Fourth argument: name
+ $this->assertSame('support', $arguments[3]);
+
+ // Verify the MultiAgent service has proper tags
+ $tags = $multiAgentDefinition->getTags();
+ $this->assertArrayHasKey('ai.agent', $tags);
+ $this->assertSame([['name' => 'support']], $tags['ai.agent']);
+
+ // Verify alias is created
+ $this->assertTrue($container->hasAlias('Symfony\AI\Agent\AgentInterface $supportMultiAgent'));
+ }
+
+ public function testMultiAgentWithMultipleHandoffs()
+ {
+ $container = $this->buildContainer([
+ 'ai' => [
+ 'agent' => [
+ 'orchestrator' => [
+ 'model' => 'gpt-4o-mini',
+ ],
+ 'code_expert' => [
+ 'model' => 'gpt-4',
+ ],
+ 'billing_expert' => [
+ 'model' => 'gpt-4',
+ ],
+ 'general_assistant' => [
+ 'model' => 'claude-3-opus-20240229',
+ ],
+ ],
+ 'multi_agent' => [
+ 'customer_service' => [
+ 'orchestrator' => 'orchestrator',
+ 'fallback' => 'general_assistant',
+ 'handoffs' => [
+ 'code_expert' => ['bug', 'code', 'programming', 'technical'],
+ 'billing_expert' => ['payment', 'invoice', 'subscription', 'refund'],
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ $this->assertTrue($container->hasDefinition('ai.multi_agent.customer_service'));
+
+ $multiAgentDefinition = $container->getDefinition('ai.multi_agent.customer_service');
+ $handoffs = $multiAgentDefinition->getArgument(1);
+
+ $this->assertIsArray($handoffs);
+ $this->assertCount(2, $handoffs);
+
+ // Both handoffs should be Definition objects
+ foreach ($handoffs as $handoff) {
+ $this->assertInstanceOf(Definition::class, $handoff);
+ $this->assertSame(Handoff::class, $handoff->getClass());
+ $handoffArgs = $handoff->getArguments();
+ $this->assertCount(2, $handoffArgs);
+ $this->assertInstanceOf(Reference::class, $handoffArgs[0]);
+ $this->assertIsArray($handoffArgs[1]);
+ }
+
+ // Verify first handoff (code_expert)
+ $codeHandoff = $handoffs[0];
+ $codeHandoffArgs = $codeHandoff->getArguments();
+ $this->assertSame('ai.agent.code_expert', (string) $codeHandoffArgs[0]);
+ $this->assertSame(['bug', 'code', 'programming', 'technical'], $codeHandoffArgs[1]);
+
+ // Verify second handoff (billing_expert)
+ $billingHandoff = $handoffs[1];
+ $billingHandoffArgs = $billingHandoff->getArguments();
+ $this->assertSame('ai.agent.billing_expert', (string) $billingHandoffArgs[0]);
+ $this->assertSame(['payment', 'invoice', 'subscription', 'refund'], $billingHandoffArgs[1]);
+ }
+
+ public function testEmptyHandoffsThrowsException()
+ {
+ $this->expectException(InvalidConfigurationException::class);
+ $this->expectExceptionMessage('The path "ai.multi_agent.support.handoffs" should have at least 1 element(s) defined.');
+
+ $this->buildContainer([
+ 'ai' => [
+ 'agent' => [
+ 'orchestrator' => [
+ 'model' => 'gpt-4o-mini',
+ ],
+ 'general' => [
+ 'model' => 'claude-3-opus-20240229',
+ ],
+ ],
+ 'multi_agent' => [
+ 'support' => [
+ 'orchestrator' => 'orchestrator',
+ 'fallback' => 'general',
+ 'handoffs' => [],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ public function testEmptyWhenConditionsThrowsException()
+ {
+ $this->expectException(InvalidConfigurationException::class);
+ $this->expectExceptionMessage('The path "ai.multi_agent.support.handoffs.technical" should have at least 1 element(s) defined.');
+
+ $this->buildContainer([
+ 'ai' => [
+ 'agent' => [
+ 'orchestrator' => [
+ 'model' => 'gpt-4o-mini',
+ ],
+ 'technical' => [
+ 'model' => 'gpt-4',
+ ],
+ 'general' => [
+ 'model' => 'claude-3-opus-20240229',
+ ],
+ ],
+ 'multi_agent' => [
+ 'support' => [
+ 'orchestrator' => 'orchestrator',
+ 'fallback' => 'general',
+ 'handoffs' => [
+ 'technical' => [],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ public function testMultiAgentReferenceToNonExistingAgentThrowsException()
+ {
+ $this->expectException(InvalidConfigurationException::class);
+ $this->expectExceptionMessage('The agent "non_existing" referenced in multi-agent "support" as orchestrator does not exist');
+
+ $this->buildContainer([
+ 'ai' => [
+ 'agent' => [
+ 'general' => [
+ 'model' => 'claude-3-opus-20240229',
+ ],
+ ],
+ 'multi_agent' => [
+ 'support' => [
+ 'orchestrator' => 'non_existing',
+ 'fallback' => 'general',
+ 'handoffs' => [
+ 'general' => ['help'],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ public function testAgentAndMultiAgentNameConflictThrowsException()
+ {
+ $this->expectException(InvalidConfigurationException::class);
+ $this->expectExceptionMessage('Agent names and multi-agent names must be unique. Duplicate name(s) found: "support"');
+
+ $this->buildContainer([
+ 'ai' => [
+ 'agent' => [
+ 'support' => [
+ 'model' => 'gpt-4o-mini',
+ ],
+ ],
+ 'multi_agent' => [
+ 'support' => [
+ 'orchestrator' => 'dispatcher',
+ 'fallback' => 'general',
+ 'handoffs' => [
+ 'technical' => ['code', 'debug'],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ public function testMultipleAgentAndMultiAgentNameConflictsThrowsException()
+ {
+ $this->expectException(InvalidConfigurationException::class);
+ $this->expectExceptionMessage('Agent names and multi-agent names must be unique. Duplicate name(s) found: "support, billing"');
+
+ $this->buildContainer([
+ 'ai' => [
+ 'agent' => [
+ 'support' => [
+ 'model' => 'gpt-4o-mini',
+ ],
+ 'billing' => [
+ 'model' => 'gpt-4o-mini',
+ ],
+ ],
+ 'multi_agent' => [
+ 'support' => [
+ 'orchestrator' => 'dispatcher',
+ 'fallback' => 'general',
+ 'handoffs' => [
+ 'technical' => ['code', 'debug'],
+ ],
+ ],
+ 'billing' => [
+ 'orchestrator' => 'dispatcher',
+ 'fallback' => 'general',
+ 'handoffs' => [
+ 'payments' => ['payment', 'invoice'],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ #[TestDox('Comprehensive multi-agent configuration with all features works correctly')]
+ public function testComprehensiveMultiAgentHappyPath()
+ {
+ $container = $this->buildContainer([
+ 'ai' => [
+ 'agent' => [
+ // Orchestrator agent - lightweight dispatcher with tools
+ 'orchestrator' => [
+ 'model' => 'gpt-4o-mini',
+ 'prompt' => [
+ 'text' => 'You are a dispatcher that routes requests to specialized agents.',
+ 'include_tools' => true,
+ ],
+ 'tools' => [
+ ['service' => 'routing_tool', 'description' => 'Routes requests to appropriate agents'],
+ ],
+ ],
+ // Code expert agent with memory and tools
+ 'code_expert' => [
+ 'model' => 'gpt-4',
+ 'prompt' => [
+ 'text' => 'You are a senior software engineer specialized in debugging and code optimization.',
+ 'include_tools' => true,
+ ],
+ 'memory' => 'code_memory_service',
+ 'tools' => [
+ ['service' => 'code_analyzer', 'description' => 'Analyzes code for issues'],
+ ['service' => 'test_runner', 'description' => 'Runs unit tests'],
+ ],
+ ],
+ // Documentation expert
+ 'docs_expert' => [
+ 'model' => 'claude-3-opus-20240229',
+ 'prompt' => 'You are a technical documentation specialist.',
+ ],
+ // General support agent with memory
+ 'general_support' => [
+ 'model' => 'claude-3-sonnet-20240229',
+ 'prompt' => [
+ 'text' => 'You are a helpful general support assistant.',
+ ],
+ 'memory' => 'general_memory_service',
+ ],
+ ],
+ 'multi_agent' => [
+ // Customer support multi-agent system
+ 'customer_support' => [
+ 'orchestrator' => 'orchestrator',
+ 'fallback' => 'general_support',
+ 'handoffs' => [
+ 'code_expert' => ['bug', 'error', 'code', 'debug', 'performance', 'optimization'],
+ 'docs_expert' => ['documentation', 'docs', 'readme', 'api', 'guide', 'tutorial'],
+ ],
+ ],
+ // Development multi-agent system (can reuse agents)
+ 'development_assistant' => [
+ 'orchestrator' => 'orchestrator',
+ 'fallback' => 'code_expert',
+ 'handoffs' => [
+ 'docs_expert' => ['comment', 'docblock', 'documentation'],
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ // Verify all agents are created
+ $this->assertTrue($container->hasDefinition('ai.agent.orchestrator'));
+ $this->assertTrue($container->hasDefinition('ai.agent.code_expert'));
+ $this->assertTrue($container->hasDefinition('ai.agent.docs_expert'));
+ $this->assertTrue($container->hasDefinition('ai.agent.general_support'));
+
+ // Verify multi-agent services are created
+ $this->assertTrue($container->hasDefinition('ai.multi_agent.customer_support'));
+ $this->assertTrue($container->hasDefinition('ai.multi_agent.development_assistant'));
+
+ // Test customer_support multi-agent configuration
+ $customerSupportDef = $container->getDefinition('ai.multi_agent.customer_support');
+ $this->assertSame(MultiAgent::class, $customerSupportDef->getClass());
+
+ $csArguments = $customerSupportDef->getArguments();
+ $this->assertCount(4, $csArguments);
+
+ // Orchestrator reference
+ $this->assertInstanceOf(Reference::class, $csArguments[0]);
+ $this->assertSame('ai.agent.orchestrator', (string) $csArguments[0]);
+
+ // Handoffs
+ $csHandoffs = $csArguments[1];
+ $this->assertIsArray($csHandoffs);
+ $this->assertCount(2, $csHandoffs);
+
+ // Code expert handoff
+ $codeHandoff = $csHandoffs[0];
+ $this->assertInstanceOf(Definition::class, $codeHandoff);
+ $codeHandoffArgs = $codeHandoff->getArguments();
+ $this->assertSame('ai.agent.code_expert', (string) $codeHandoffArgs[0]);
+ $this->assertSame(['bug', 'error', 'code', 'debug', 'performance', 'optimization'], $codeHandoffArgs[1]);
+
+ // Docs expert handoff
+ $docsHandoff = $csHandoffs[1];
+ $this->assertInstanceOf(Definition::class, $docsHandoff);
+ $docsHandoffArgs = $docsHandoff->getArguments();
+ $this->assertSame('ai.agent.docs_expert', (string) $docsHandoffArgs[0]);
+ $this->assertSame(['documentation', 'docs', 'readme', 'api', 'guide', 'tutorial'], $docsHandoffArgs[1]);
+
+ // Fallback
+ $this->assertInstanceOf(Reference::class, $csArguments[2]);
+ $this->assertSame('ai.agent.general_support', (string) $csArguments[2]);
+
+ // Name
+ $this->assertSame('customer_support', $csArguments[3]);
+
+ // Verify tags and aliases
+ $csTags = $customerSupportDef->getTags();
+ $this->assertArrayHasKey('ai.agent', $csTags);
+ $this->assertSame([['name' => 'customer_support']], $csTags['ai.agent']);
+
+ $this->assertTrue($container->hasAlias('Symfony\AI\Agent\AgentInterface $customerSupportMultiAgent'));
+ $this->assertTrue($container->hasAlias('Symfony\AI\Agent\AgentInterface $developmentAssistantMultiAgent'));
+
+ // Test development_assistant multi-agent configuration
+ $devAssistantDef = $container->getDefinition('ai.multi_agent.development_assistant');
+ $daArguments = $devAssistantDef->getArguments();
+
+ // Verify it uses code_expert as fallback
+ $this->assertInstanceOf(Reference::class, $daArguments[2]);
+ $this->assertSame('ai.agent.code_expert', (string) $daArguments[2]);
+
+ // Verify it has only docs_expert handoff
+ $daHandoffs = $daArguments[1];
+ $this->assertCount(1, $daHandoffs);
+
+ // Verify agent components are properly configured
+
+ // Code expert should have memory processor
+ $this->assertTrue($container->hasDefinition('ai.agent.code_expert.memory_input_processor'));
+ $this->assertTrue($container->hasDefinition('ai.agent.code_expert.static_memory_provider'));
+
+ // Code expert should have tool processor
+ $this->assertTrue($container->hasDefinition('ai.tool.agent_processor.code_expert'));
+
+ // Code expert should have system prompt processor
+ $this->assertTrue($container->hasDefinition('ai.agent.code_expert.system_prompt_processor'));
+
+ // Docs expert should have only system prompt processor, no memory
+ $this->assertFalse($container->hasDefinition('ai.agent.docs_expert.memory_input_processor'));
+ $this->assertTrue($container->hasDefinition('ai.agent.docs_expert.system_prompt_processor'));
+
+ // General support should have memory processor
+ $this->assertTrue($container->hasDefinition('ai.agent.general_support.memory_input_processor'));
+
+ // Orchestrator should have tools processor
+ $this->assertTrue($container->hasDefinition('ai.tool.agent_processor.orchestrator'));
+ }
+
private function buildContainer(array $configuration): ContainerBuilder
{
$container = new ContainerBuilder();
diff --git a/src/ai-bundle/tests/DependencyInjection/ProcessorCompilerPassTest.php b/src/ai-bundle/tests/DependencyInjection/ProcessorCompilerPassTest.php
index aa1f21ce2..5caf6706a 100644
--- a/src/ai-bundle/tests/DependencyInjection/ProcessorCompilerPassTest.php
+++ b/src/ai-bundle/tests/DependencyInjection/ProcessorCompilerPassTest.php
@@ -15,10 +15,12 @@
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\Input;
use Symfony\AI\Agent\InputProcessorInterface;
+use Symfony\AI\Agent\MultiAgent\MultiAgent;
use Symfony\AI\Agent\Output;
use Symfony\AI\Agent\OutputProcessorInterface;
use Symfony\AI\AiBundle\DependencyInjection\ProcessorCompilerPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
class ProcessorCompilerPassTest extends TestCase
@@ -100,6 +102,55 @@ public function testProcess()
$container->getDefinition('agent2')->getArgument(3)
);
}
+
+ public function testProcessSkipsMultiAgent()
+ {
+ $container = new ContainerBuilder();
+
+ // Regular Agent service - should be processed
+ $container
+ ->register('agent1', Agent::class)
+ ->setArguments([null, null, [], []])
+ ->addTag('ai.agent');
+
+ // MultiAgent service - should NOT be processed
+ $orchestratorRef = new Reference('orchestrator');
+ $handoffs = [new Definition('Symfony\AI\Agent\MultiAgent\Handoff')];
+ $fallbackRef = new Reference('fallback');
+ $name = 'support';
+
+ $container
+ ->register('multi_agent', MultiAgent::class)
+ ->setArguments([$orchestratorRef, $handoffs, $fallbackRef, $name])
+ ->addTag('ai.agent');
+
+ // Add processors
+ $container
+ ->register(DummyInputProcessor1::class, DummyInputProcessor1::class)
+ ->addTag('ai.agent.input_processor');
+ $container
+ ->register(DummyOutputProcessor1::class, DummyOutputProcessor1::class)
+ ->addTag('ai.agent.output_processor');
+
+ (new ProcessorCompilerPass())->process($container);
+
+ // Regular agent should have processors injected
+ $this->assertEquals(
+ [new Reference(DummyInputProcessor1::class)],
+ $container->getDefinition('agent1')->getArgument(2)
+ );
+ $this->assertEquals(
+ [new Reference(DummyOutputProcessor1::class)],
+ $container->getDefinition('agent1')->getArgument(3)
+ );
+
+ // MultiAgent arguments should remain unchanged
+ $multiAgentDef = $container->getDefinition('multi_agent');
+ $this->assertInstanceOf(Reference::class, $multiAgentDef->getArgument(0));
+ $this->assertIsArray($multiAgentDef->getArgument(1));
+ $this->assertInstanceOf(Reference::class, $multiAgentDef->getArgument(2));
+ $this->assertSame('support', $multiAgentDef->getArgument(3));
+ }
}
class DummyInputProcessor1 implements InputProcessorInterface
diff --git a/src/platform/src/Message/MessageBag.php b/src/platform/src/Message/MessageBag.php
index 1a932df7d..edf893b2b 100644
--- a/src/platform/src/Message/MessageBag.php
+++ b/src/platform/src/Message/MessageBag.php
@@ -11,6 +11,7 @@
namespace Symfony\AI\Platform\Message;
+use Symfony\AI\Platform\Message\Content\Text;
use Symfony\AI\Platform\Metadata\MetadataAwareTrait;
/**
@@ -54,6 +55,34 @@ public function getSystemMessage(): ?SystemMessage
return null;
}
+ public function getUserMessage(): ?UserMessage
+ {
+ foreach ($this->messages as $message) {
+ if ($message instanceof UserMessage) {
+ return $message;
+ }
+ }
+
+ return null;
+ }
+
+ public function getUserMessageText(): ?string
+ {
+ $userMessage = $this->getUserMessage();
+ if (null === $userMessage) {
+ return null;
+ }
+
+ $textParts = [];
+ foreach ($userMessage->content as $content) {
+ if ($content instanceof Text) {
+ $textParts[] = $content->text;
+ }
+ }
+
+ return implode(' ', $textParts);
+ }
+
public function with(MessageInterface $message): self
{
$messages = clone $this;
diff --git a/src/platform/tests/Message/MessageBagTest.php b/src/platform/tests/Message/MessageBagTest.php
index 9c9ffd737..0959a1dba 100644
--- a/src/platform/tests/Message/MessageBagTest.php
+++ b/src/platform/tests/Message/MessageBagTest.php
@@ -165,4 +165,96 @@ public function testItCanHandleMetadata()
$this->assertCount(1, $metadata);
}
+
+ public function testGetUserMessage()
+ {
+ $messageBag = new MessageBag(
+ Message::forSystem('My amazing system prompt.'),
+ Message::ofAssistant('It is time to sleep.'),
+ Message::ofUser('Hello, world!'),
+ Message::ofAssistant('How can I help you?'),
+ );
+
+ $userMessage = $messageBag->getUserMessage();
+
+ $this->assertInstanceOf(UserMessage::class, $userMessage);
+ $this->assertInstanceOf(Text::class, $userMessage->content[0]);
+ $this->assertSame('Hello, world!', $userMessage->content[0]->text);
+ }
+
+ public function testGetUserMessageReturnsNullWithoutUserMessage()
+ {
+ $messageBag = new MessageBag(
+ Message::forSystem('My amazing system prompt.'),
+ Message::ofAssistant('It is time to sleep.'),
+ );
+
+ $this->assertNull($messageBag->getUserMessage());
+ }
+
+ public function testGetUserMessageReturnsFirstUserMessage()
+ {
+ $messageBag = new MessageBag(
+ Message::forSystem('My amazing system prompt.'),
+ Message::ofUser('First user message'),
+ Message::ofAssistant('Response'),
+ Message::ofUser('Second user message'),
+ );
+
+ $userMessage = $messageBag->getUserMessage();
+
+ $this->assertInstanceOf(UserMessage::class, $userMessage);
+ $this->assertInstanceOf(Text::class, $userMessage->content[0]);
+ $this->assertSame('First user message', $userMessage->content[0]->text);
+ }
+
+ public function testGetUserMessageText()
+ {
+ $messageBag = new MessageBag(
+ Message::forSystem('My amazing system prompt.'),
+ Message::ofUser('Hello, world!'),
+ Message::ofAssistant('How can I help you?'),
+ );
+
+ $userText = $messageBag->getUserMessageText();
+
+ $this->assertSame('Hello, world!', $userText);
+ }
+
+ public function testGetUserMessageTextReturnsNullWithoutUserMessage()
+ {
+ $messageBag = new MessageBag(
+ Message::forSystem('My amazing system prompt.'),
+ Message::ofAssistant('It is time to sleep.'),
+ );
+
+ $this->assertNull($messageBag->getUserMessageText());
+ }
+
+ public function testGetUserMessageTextWithMultipleTextParts()
+ {
+ $messageBag = new MessageBag(
+ Message::forSystem('My amazing system prompt.'),
+ Message::ofUser('Part one', 'Part two', 'Part three'),
+ Message::ofAssistant('Response'),
+ );
+
+ $userText = $messageBag->getUserMessageText();
+
+ $this->assertSame('Part one Part two Part three', $userText);
+ }
+
+ public function testGetUserMessageTextIgnoresNonTextContent()
+ {
+ $messageBag = new MessageBag(
+ Message::forSystem('My amazing system prompt.'),
+ Message::ofUser('Text content', new ImageUrl('http://example.com/image.png')),
+ Message::ofAssistant('Response'),
+ );
+
+ $userText = $messageBag->getUserMessageText();
+
+ // Should only return the text content, ignoring the image
+ $this->assertSame('Text content', $userText);
+ }
}