[Agent] Fix dispatch of multiple tool instances of the same class

This commit is contained in:
Christopher Hertel
2026-03-20 11:40:48 +01:00
parent 451fd283c3
commit fe195ea16b
15 changed files with 242 additions and 96 deletions

View File

@@ -1,6 +1,19 @@
UPGRADE FROM 0.6 to 0.7
=======================
Agent
-----
* The `Symfony\AI\Agent\Toolbox\ToolFactory\AbstractToolFactory` class has been removed. If you extended it to create
a custom tool factory, implement `ToolFactoryInterface` yourself and use `Symfony\AI\Platform\Contract\JsonSchema\Factory`
directly if needed.
* The `ToolFactoryInterface::getTool()` method signature has changed to accept `object|string` instead of `string`:
```diff
-public function getTool(string $reference): iterable;
+public function getTool(object|string $reference): iterable;
```
AI Bundle
---------

View File

@@ -0,0 +1,64 @@
<?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.
*/
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor;
use Symfony\AI\Agent\Toolbox\AgentProcessor;
use Symfony\AI\Agent\Toolbox\Tool\Subagent;
use Symfony\AI\Agent\Toolbox\Toolbox;
use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory;
use Symfony\AI\Platform\Bridge\Gemini\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('GEMINI_API_KEY'), http_client());
// Create a specialized agent for mathematical calculations
$mathAgent = new Agent(
$platform,
'gemini-2.5-flash',
[new SystemPromptInputProcessor('You are a mathematical calculator. When given a math problem, solve it and return only the numerical result with a brief explanation.')],
);
// Create a specialized agent for unit conversions
$conversionAgent = new Agent(
$platform,
'gemini-2.5-flash',
[new SystemPromptInputProcessor('You are a unit conversion specialist. Convert values between different units of measurement and return the result with a brief explanation.')],
);
$mathTool = new Subagent($mathAgent);
$conversionTool = new Subagent($conversionAgent);
$memoryFactory = new MemoryToolFactory();
$memoryFactory->addTool(
$mathTool,
'calculate',
'Performs mathematical calculations. Use this when you need to solve math problems or do arithmetic.',
);
$memoryFactory->addTool(
$conversionTool,
'convert_units',
'Converts values between units of measurement (e.g. km to miles, kg to pounds, Celsius to Fahrenheit).',
);
// Create the main orchestrating agent with both subagents as tools
$toolbox = new Toolbox([$mathTool, $conversionTool], toolFactory: $memoryFactory, logger: logger());
$processor = new AgentProcessor($toolbox);
$agent = new Agent($platform, 'gemini-2.5-flash', [$processor], [$processor]);
// Ask a question that requires both calculation and conversion
$messages = new MessageBag(Message::ofUser('I drove 150 kilometers. How many miles is that? Also, what is 150 divided by 8?'));
$result = $agent->call($messages);
echo $result->getContent().\PHP_EOL;

View File

@@ -14,9 +14,7 @@ use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor;
use Symfony\AI\Agent\Toolbox\AgentProcessor;
use Symfony\AI\Agent\Toolbox\Tool\Subagent;
use Symfony\AI\Agent\Toolbox\Toolbox;
use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory;
use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory;
use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory;
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
@@ -26,33 +24,41 @@ require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
// Create a specialized agent for mathematical calculations
$mathSystemPrompt = new SystemPromptInputProcessor('You are a mathematical calculator. When given a math problem, solve it and return only the numerical result with a brief explanation.');
$mathAgent = new Agent($platform, 'gpt-5.2', [$mathSystemPrompt]);
$mathAgent = new Agent(
$platform,
'gpt-4o-mini',
[new SystemPromptInputProcessor('You are a mathematical calculator. When given a math problem, solve it and return only the numerical result with a brief explanation.')],
);
// Create a specialized agent for unit conversions
$conversionAgent = new Agent(
$platform,
'gpt-4o-mini',
[new SystemPromptInputProcessor('You are a unit conversion specialist. Convert values between different units of measurement and return the result with a brief explanation.')],
);
// Wrap the math agent as a tool
$mathTool = new Subagent($mathAgent);
$conversionTool = new Subagent($conversionAgent);
// Use MemoryToolFactory to register the tool with metadata
$memoryFactory = new MemoryToolFactory();
$memoryFactory->addTool(
$mathTool,
'calculate',
'Performs mathematical calculations. Use this when you need to solve math problems or do arithmetic.',
);
$memoryFactory->addTool(
$conversionTool,
'convert_units',
'Converts values between units of measurement (e.g. km to miles, kg to pounds, Celsius to Fahrenheit).',
);
// Combine with ReflectionToolFactory using ChainFactory
$chainFactory = new ChainFactory([
$memoryFactory,
new ReflectionToolFactory(),
]);
// Create the main agent with the math agent as a tool
$toolbox = new Toolbox([$mathTool], toolFactory: $chainFactory, logger: logger());
// Create the main orchestrating agent with both subagents as tools
$toolbox = new Toolbox([$mathTool, $conversionTool], toolFactory: $memoryFactory, logger: logger());
$processor = new AgentProcessor($toolbox);
$agent = new Agent($platform, 'gpt-5-mini', [$processor], [$processor]);
$agent = new Agent($platform, 'gpt-4o-mini', [$processor], [$processor]);
// Ask a question that requires mathematical calculation
$messages = new MessageBag(Message::ofUser('I have 15 apples and I want to share them equally among 4 friends. How many apples does each friend get and how many are left over?'));
// Ask a question that requires both calculation and conversion
$messages = new MessageBag(Message::ofUser('I drove 150 kilometers. How many miles is that? Also, what is 150 divided by 8?'));
$result = $agent->call($messages);
echo $result->getContent().\PHP_EOL;

View File

@@ -4,6 +4,8 @@ CHANGELOG
0.7
---
* [BC BREAK] Remove `AbstractToolFactory` in favor of standalone `ReflectionToolFactory` and `MemoryToolFactory`
* [BC BREAK] Change `ToolFactoryInterface::getTool()` signature from `string $reference` to `object|string $reference`
* Add `ToolCallRequested` event dispatched before tool execution
0.4

View File

@@ -1,44 +0,0 @@
<?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\Toolbox\ToolFactory;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException;
use Symfony\AI\Agent\Toolbox\ToolFactoryInterface;
use Symfony\AI\Platform\Contract\JsonSchema\Factory;
use Symfony\AI\Platform\Tool\ExecutionReference;
use Symfony\AI\Platform\Tool\Tool;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
abstract class AbstractToolFactory implements ToolFactoryInterface
{
public function __construct(
private readonly Factory $factory = new Factory(),
) {
}
protected function convertAttribute(string $className, AsTool $attribute): Tool
{
try {
return new Tool(
new ExecutionReference($className, $attribute->method),
$attribute->name,
$attribute->description,
$this->factory->buildParameters($className, $attribute->method)
);
} catch (\ReflectionException $e) {
throw ToolConfigurationException::invalidMethod($className, $attribute->method, $e);
}
}
}

View File

@@ -27,7 +27,7 @@ final class ChainFactory implements ToolFactoryInterface
) {
}
public function getTool(string $reference): iterable
public function getTool(object|string $reference): iterable
{
$invalid = 0;
foreach ($this->factories as $factory) {

View File

@@ -11,38 +11,68 @@
namespace Symfony\AI\Agent\Toolbox\ToolFactory;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException;
use Symfony\AI\Agent\Toolbox\Exception\ToolException;
use Symfony\AI\Agent\Toolbox\ToolFactoryInterface;
use Symfony\AI\Platform\Contract\JsonSchema\Factory;
use Symfony\AI\Platform\Tool\ExecutionReference;
use Symfony\AI\Platform\Tool\Tool;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class MemoryToolFactory extends AbstractToolFactory
final class MemoryToolFactory implements ToolFactoryInterface
{
/**
* @var array<string, AsTool[]>
* @var array<string, Tool[]>
*/
private array $tools = [];
public function __construct(
private readonly Factory $factory = new Factory(),
) {
}
public function addTool(string|object $class, string $name, string $description, string $method = '__invoke'): self
{
$className = \is_object($class) ? $class::class : $class;
$this->tools[$className][] = new AsTool($name, $description, $method);
$key = \is_object($class) ? (string) spl_object_id($class) : $className;
try {
$this->tools[$key][] = new Tool(
new ExecutionReference($className, $method),
$name,
$description,
$this->factory->buildParameters($className, $method),
);
} catch (\ReflectionException $e) {
throw ToolConfigurationException::invalidMethod($className, $method, $e);
}
return $this;
}
/**
* @param class-string $className
*/
public function getTool(string $className): iterable
public function getTool(object|string $reference): iterable
{
if (!isset($this->tools[$className])) {
throw ToolException::invalidReference($className);
if (\is_object($reference)) {
$key = (string) spl_object_id($reference);
if (isset($this->tools[$key])) {
yield from $this->tools[$key];
return;
}
// Fall back to class name for tools registered by class string
$key = $reference::class;
} else {
$key = $reference;
}
foreach ($this->tools[$className] as $tool) {
yield $this->convertAttribute($className, $tool);
if (!isset($this->tools[$key])) {
throw ToolException::invalidReference($key);
}
yield from $this->tools[$key];
}
}

View File

@@ -12,33 +12,53 @@
namespace Symfony\AI\Agent\Toolbox\ToolFactory;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException;
use Symfony\AI\Agent\Toolbox\Exception\ToolException;
use Symfony\AI\Agent\Toolbox\ToolFactoryInterface;
use Symfony\AI\Platform\Contract\JsonSchema\Factory;
use Symfony\AI\Platform\Tool\ExecutionReference;
use Symfony\AI\Platform\Tool\Tool;
/**
* Metadata factory that uses reflection in combination with `#[AsTool]` attribute to extract metadata from tools.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class ReflectionToolFactory extends AbstractToolFactory
final class ReflectionToolFactory implements ToolFactoryInterface
{
/**
* @param class-string $reference
*/
public function getTool(string $reference): iterable
public function __construct(
private readonly Factory $factory = new Factory(),
) {
}
public function getTool(object|string $reference): iterable
{
if (!class_exists($reference)) {
throw ToolException::invalidReference($reference);
$className = \is_object($reference) ? $reference::class : $reference;
if (!class_exists($className)) {
throw ToolException::invalidReference($className);
}
$reflectionClass = new \ReflectionClass($reference);
$reflectionClass = new \ReflectionClass($className);
$attributes = $reflectionClass->getAttributes(AsTool::class);
if ([] === $attributes) {
throw ToolException::missingAttribute($reference);
throw ToolException::missingAttribute($className);
}
foreach ($attributes as $attribute) {
yield $this->convertAttribute($reference, $attribute->newInstance());
$asTool = $attribute->newInstance();
try {
yield new Tool(
new ExecutionReference($className, $asTool->method),
$asTool->name,
$asTool->description,
$this->factory->buildParameters($className, $asTool->method),
);
} catch (\ReflectionException $e) {
throw ToolConfigurationException::invalidMethod($className, $asTool->method, $e);
}
}
}
}

View File

@@ -24,5 +24,5 @@ interface ToolFactoryInterface
*
* @throws ToolException if the metadata for the given reference is not found
*/
public function getTool(string $reference): iterable;
public function getTool(object|string $reference): iterable;
}

View File

@@ -39,6 +39,13 @@ final class Toolbox implements ToolboxInterface
*/
private array $toolsMetadata;
/**
* Maps tool name to the specific object instance that was registered for it.
*
* @var array<string, object>
*/
private array $instanceMap = [];
/**
* @param iterable<object> $tools
*/
@@ -59,7 +66,8 @@ final class Toolbox implements ToolboxInterface
$toolsMetadata = [];
foreach ($this->tools as $tool) {
foreach ($this->toolFactory->getTool($tool::class) as $metadata) {
foreach ($this->toolFactory->getTool($tool) as $metadata) {
$this->instanceMap[$metadata->getName()] = $tool;
$toolsMetadata[] = $metadata;
}
}
@@ -128,6 +136,10 @@ final class Toolbox implements ToolboxInterface
private function getExecutable(Tool $metadata): object
{
if (isset($this->instanceMap[$metadata->getName()])) {
return $this->instanceMap[$metadata->getName()];
}
$className = $metadata->getReference()->getClass();
foreach ($this->tools as $tool) {
if ($tool instanceof $className) {

View File

@@ -26,7 +26,7 @@ final class MemoryFactoryTest extends TestCase
$this->expectExceptionMessage('The reference "SomeClass" is not a valid tool.');
$factory = new MemoryToolFactory();
iterator_to_array($factory->getTool('SomeClass')); // @phpstan-ignore-line Yes, this class does not exist
iterator_to_array($factory->getTool('SomeClass'));
}
public function testGetMetadataWithDistinctToolPerClass()

View File

@@ -38,7 +38,7 @@ final class ReflectionFactoryTest extends TestCase
$this->expectException(ToolException::class);
$this->expectExceptionMessage('The reference "invalid" is not a valid tool.');
iterator_to_array($this->factory->getTool('invalid')); // @phpstan-ignore-line Yes, this class does not exist
iterator_to_array($this->factory->getTool('invalid'));
}
public function testWithoutAttribute()

View File

@@ -13,6 +13,7 @@ namespace Symfony\AI\Agent\Tests\Toolbox;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Agent\MockAgent;
use Symfony\AI\Agent\Tests\Fixtures\Tool\ToolCustomException;
use Symfony\AI\Agent\Tests\Fixtures\Tool\ToolDate;
use Symfony\AI\Agent\Tests\Fixtures\Tool\ToolException;
@@ -27,6 +28,7 @@ use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException;
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException;
use Symfony\AI\Agent\Toolbox\Source\Source;
use Symfony\AI\Agent\Toolbox\Tool\Subagent;
use Symfony\AI\Agent\Toolbox\Toolbox;
use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory;
use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory;
@@ -255,6 +257,51 @@ final class ToolboxTest extends TestCase
$this->assertSame('Happy Birthday, John! You are 30 years old.', $result->getResult());
}
public function testToolboxMapWithMultipleSubagents()
{
$mathAgent = new MockAgent(['2+2' => '4']);
$conversionAgent = new MockAgent(['100km' => '62 miles']);
$mathTool = new Subagent($mathAgent);
$conversionTool = new Subagent($conversionAgent);
$memoryFactory = (new MemoryToolFactory())
->addTool($mathTool, 'calculate', 'Performs calculations')
->addTool($conversionTool, 'convert', 'Converts units');
$toolbox = new Toolbox([$mathTool, $conversionTool], $memoryFactory);
$tools = $toolbox->getTools();
$this->assertCount(2, $tools);
$this->assertSame('calculate', $tools[0]->getName());
$this->assertSame('convert', $tools[1]->getName());
}
public function testToolboxExecutionWithMultipleSubagentsDispatchesToCorrectOne()
{
$mathAgent = new MockAgent(['2+2' => '4']);
$conversionAgent = new MockAgent(['100km' => '62 miles']);
$mathTool = new Subagent($mathAgent);
$conversionTool = new Subagent($conversionAgent);
$memoryFactory = (new MemoryToolFactory())
->addTool($mathTool, 'calculate', 'Performs calculations')
->addTool($conversionTool, 'convert', 'Converts units');
$toolbox = new Toolbox([$mathTool, $conversionTool], $memoryFactory);
$mathResult = $toolbox->execute(new ToolCall('call_math', 'calculate', ['message' => '2+2']));
$this->assertSame('4', $mathResult->getResult());
$conversionResult = $toolbox->execute(new ToolCall('call_convert', 'convert', ['message' => '100km']));
$this->assertSame('62 miles', $conversionResult->getResult());
$mathAgent->assertCallCount(1);
$conversionAgent->assertCallCount(1);
}
public function testToolboxMapWithOverrideViaChain()
{
$factory1 = (new MemoryToolFactory())

View File

@@ -14,7 +14,6 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor;
use Symfony\AI\Agent\Toolbox\Toolbox;
use Symfony\AI\Agent\Toolbox\ToolCallArgumentResolver;
use Symfony\AI\Agent\Toolbox\ToolFactory\AbstractToolFactory;
use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory;
use Symfony\AI\Agent\Toolbox\ToolResultConverter;
use Symfony\AI\AiBundle\Command\AgentCallCommand;
@@ -215,13 +214,10 @@ return static function (ContainerConfigurator $container): void {
service('logger')->ignoreOnInvalid(),
service('event_dispatcher')->nullOnInvalid(),
])
->set('ai.tool_factory.abstract', AbstractToolFactory::class)
->abstract()
->set('ai.tool_factory', ReflectionToolFactory::class)
->args([
service('ai.platform.json_schema_factory'),
])
->set('ai.tool_factory', ReflectionToolFactory::class)
->parent('ai.tool_factory.abstract')
->set('ai.tool_result_converter', ToolResultConverter::class)
->args([
service('serializer'),

View File

@@ -352,7 +352,6 @@ final class AiBundle extends AbstractBundle
if (!ContainerBuilder::willBeAvailable('symfony/ai-agent', Agent::class, ['symfony/ai-bundle'])) {
$builder->removeDefinition('ai.command.chat');
$builder->removeDefinition('ai.toolbox.abstract');
$builder->removeDefinition('ai.tool_factory.abstract');
$builder->removeDefinition('ai.tool_factory');
$builder->removeDefinition('ai.tool_result_converter');
$builder->removeDefinition('ai.tool_call_argument_resolver');
@@ -1077,8 +1076,9 @@ final class AiBundle extends AbstractBundle
// TOOLBOX
if ($config['tools']['enabled']) {
// Setup toolbox for agent
$memoryFactoryDefinition = new ChildDefinition('ai.tool_factory.abstract');
$memoryFactoryDefinition->setClass(MemoryToolFactory::class);
$memoryFactoryDefinition = new Definition(MemoryToolFactory::class, [
new Reference('ai.platform.json_schema_factory'),
]);
$container->setDefinition('ai.toolbox.'.$name.'.memory_factory', $memoryFactoryDefinition);
$chainFactoryDefinition = new Definition(ChainFactory::class, [
[new Reference('ai.toolbox.'.$name.'.memory_factory'), new Reference('ai.tool_factory')],