Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions demo/config/packages/ai.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 33 additions & 1 deletion examples/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<comment>%s</comment>', $contextMessage));
}
}
}
};
}

function output(): ConsoleOutput
Expand Down
76 changes: 76 additions & 0 deletions examples/multi-agent/orchestrator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor;
use Symfony\AI\Agent\MultiAgent\Handoff;
use Symfony\AI\Agent\MultiAgent\MultiAgent;
use Symfony\AI\Agent\StructuredOutput\AgentProcessor;
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

require_once dirname(__DIR__).'/bootstrap.php';

$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());

// Create structured output processor for the orchestrator
$structuredOutputProcessor = new AgentProcessor();

// Create orchestrator agent for routing decisions
$orchestrator = new Agent(
$platform,
'gpt-4o-mini',
[new SystemPromptInputProcessor('You are an intelligent agent orchestrator that routes user questions to specialized agents.'), $structuredOutputProcessor],
[$structuredOutputProcessor],
logger: logger()
);

// Create technical agent for handling technical issues
$technical = new Agent(
$platform,
'gpt-4o-mini?max_tokens=150', // set max_tokens here to be faster and cheaper
[new SystemPromptInputProcessor('You are a technical support specialist. Help users resolve bugs, problems, and technical errors.')],
name: 'technical',
logger: logger()
);

// Create general agent for handling any other questions
$fallback = new Agent(
$platform,
'gpt-4o-mini',
[new SystemPromptInputProcessor('You are a helpful general assistant. Assist users with any questions or tasks they may have. You should never ever answer technical question.')],
name: 'fallback',
logger: logger()
);

$multiAgent = new MultiAgent(
orchestrator: $orchestrator,
handoffs: [
new Handoff(to: $technical, when: ['bug', 'problem', 'technical', 'error']),
],
fallback: $fallback,
logger: logger()
);

echo "=== Technical Question ===\n";
$technicalQuestion = 'I get this error in my php code: "Call to undefined method App\Controller\UserController::getName()" - this is my line of code: $user->getName() where $user is an instance of User entity.';
echo "Question: $technicalQuestion\n\n";
$messages = new MessageBag(Message::ofUser($technicalQuestion));
$result = $multiAgent->call($messages);
echo 'Answer: '.substr($result->getContent(), 0, 300).'...'.\PHP_EOL.\PHP_EOL;

echo "=== General Question ===\n";
$generalQuestion = 'Can you give me a lasagne recipe?';
echo "Question: $generalQuestion\n\n";
$messages = new MessageBag(Message::ofUser($generalQuestion));
$result = $multiAgent->call($messages);
echo 'Answer: '.substr($result->getContent(), 0, 300).'...'.\PHP_EOL;
48 changes: 48 additions & 0 deletions src/agent/src/MultiAgent/Handoff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\Agent\MultiAgent;

use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Agent\Exception\InvalidArgumentException;

/**
* Defines a handoff to another agent based on conditions.
*
* @author Oskar Stark <[email protected]>
*/
final readonly class Handoff
{
/**
* @param string[] $when Keywords or phrases that indicate this handoff
*/
public function __construct(
private AgentInterface $to,
private array $when,
) {
if ([] === $when) {
throw new InvalidArgumentException('Handoff must have at least one "when" condition.');
}
}

public function getTo(): AgentInterface
{
return $this->to;
}

/**
* @return string[]
*/
public function getWhen(): array
{
return $this->when;
}
}
38 changes: 38 additions & 0 deletions src/agent/src/MultiAgent/Handoff/Decision.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\Agent\MultiAgent\Handoff;

/**
* Represents the orchestrator's decision on which agent should handle a request.
*
* @author Oskar Stark <[email protected]>
*/
final readonly class Decision
{
/**
* @param string $agentName The name of the selected agent, or empty string if no specific agent is selected
* @param string $reasoning The reasoning behind the selection
*/
public function __construct(
public string $agentName,
public string $reasoning = 'No reasoning provided',
) {
}

/**
* Checks if a specific agent was selected.
*/
public function hasAgent(): bool
{
return '' !== $this->agentName;
}
}
166 changes: 166 additions & 0 deletions src/agent/src/MultiAgent/MultiAgent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\Agent\MultiAgent;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Agent\Exception\ExceptionInterface;
use Symfony\AI\Agent\Exception\InvalidArgumentException;
use Symfony\AI\Agent\Exception\RuntimeException;
use Symfony\AI\Agent\MultiAgent\Handoff\Decision;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Result\ResultInterface;

/**
* A multi-agent system that coordinates multiple specialized agents.
*
* This agent acts as a central orchestrator, delegating tasks to specialized agents
* based on handoff rules and managing the conversation flow between agents.
*
* @author Oskar Stark <[email protected]>
*/
final class MultiAgent implements AgentInterface
{
/**
* @param AgentInterface $orchestrator Agent responsible for analyzing requests and selecting appropriate handoffs
* @param Handoff[] $handoffs Handoff definitions for agent routing
* @param AgentInterface $fallback Fallback agent when no handoff conditions match
* @param non-empty-string $name Name of the multi-agent
* @param LoggerInterface $logger Logger for debugging handoff decisions
*/
public function __construct(
private AgentInterface $orchestrator,
private array $handoffs,
private AgentInterface $fallback,
private string $name = 'multi-agent',
private LoggerInterface $logger = new NullLogger(),
) {
if ([] === $handoffs) {
throw new InvalidArgumentException('MultiAgent requires at least 1 handoff.');
}
}

/**
* @return non-empty-string
*/
public function getName(): string
{
return $this->name;
}

/**
* @throws ExceptionInterface When the agent encounters an error during orchestration or handoffs
*/
public function call(MessageBag $messages, array $options = []): ResultInterface
{
$userMessages = $messages->withoutSystemMessage();

$userText = $userMessages->getUserMessageText();
if (null === $userText) {
throw new RuntimeException('No user message found in conversation.');
}
$this->logger->debug('MultiAgent: Processing user message', ['user_text' => $userText]);

$this->logger->debug('MultiAgent: Available agents for routing', ['agents' => array_map(fn ($handoff) => [
'to' => $handoff->getTo()->getName(),
'when' => $handoff->getWhen(),
], $this->handoffs)]);

$agentSelectionPrompt = $this->buildAgentSelectionPrompt($userText);

$decision = $this->orchestrator->call(new MessageBag(Message::ofUser($agentSelectionPrompt)), array_merge($options, [
'output_structure' => Decision::class,
]))->getContent();

if (!$decision instanceof Decision) {
$this->logger->debug('MultiAgent: Failed to get decision, falling back to orchestrator');

return $this->orchestrator->call($messages, $options);
}

$this->logger->debug('MultiAgent: Agent selection completed', [
'selected_agent' => $decision->agentName,
'reasoning' => $decision->reasoning,
]);

if (!$decision->hasAgent()) {
$this->logger->debug('MultiAgent: Using fallback agent', ['reason' => 'no_agent_selected']);

return $this->fallback->call($messages, $options);
}

// Find the target agent by name
$targetAgent = null;
foreach ($this->handoffs as $handoff) {
if ($handoff->getTo()->getName() === $decision->agentName) {
$targetAgent = $handoff->getTo();
break;
}
}

if (!$targetAgent) {
$this->logger->debug('MultiAgent: Target agent not found, using fallback agent', [
'requested_agent' => $decision->agentName,
'reason' => 'agent_not_found',
]);

return $this->fallback->call($messages, $options);
}

$this->logger->debug('MultiAgent: Delegating to agent', ['agent_name' => $decision->agentName]);

$userMessage = $userMessages->getUserMessage();
if (null === $userMessage) {
throw new RuntimeException('No user message found in conversation.');
}

// Call the selected agent with the original user question
return $targetAgent->call(new MessageBag($userMessage), $options);
}

private function buildAgentSelectionPrompt(string $userQuestion): string
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in a follow up we could make this configurable for the multiagent

{
$agentDescriptions = [];
$agentNames = [];

foreach ($this->handoffs as $handoff) {
$triggers = implode(', ', $handoff->getWhen());
$agentName = $handoff->getTo()->getName();
$agentDescriptions[] = "- {$agentName}: {$triggers}";
$agentNames[] = $agentName;
}

$agentDescriptions[] = "- {$this->fallback->getName()}: fallback agent for general/unmatched queries";
$agentNames[] = $this->fallback->getName();

$agentList = implode("\n", $agentDescriptions);
$validAgents = implode('", "', $agentNames);

return <<<PROMPT
You are an intelligent agent orchestrator. Based on the user's question, determine which specialized agent should handle the request.

User question: "{$userQuestion}"

Available agents and their capabilities:
{$agentList}

Analyze the user's question and select the most appropriate agent to handle this request.
Return an empty string ("") for agentName if no specific agent matches the request criteria.

Available agent names: {$validAgents}

Provide your selection and explain your reasoning.
PROMPT;
}
}
Loading