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); + } }