mirror of
https://github.com/symfony/ai.git
synced 2026-03-23 23:42:18 +01:00
378 lines
14 KiB
PHP
378 lines
14 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the Symfony package.
|
|
*
|
|
* (c) Fabien Potencier <fabien@symfony.com>
|
|
*
|
|
* 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 <oskarstark@googlemail.com>
|
|
*/
|
|
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(static fn ($opts) => isset($opts['temperature']) && 0.7 === $opts['temperature']
|
|
&& isset($opts['max_tokens']) && 100 === $opts['max_tokens']
|
|
&& isset($opts['response_format']) && Decision::class === $opts['response_format']
|
|
)
|
|
)
|
|
->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(static function (MessageBag $messages) {
|
|
$userMessage = $messages->getUserMessage();
|
|
$text = $userMessage?->asText();
|
|
|
|
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);
|
|
}
|
|
}
|