diff --git a/UPGRADE.md b/UPGRADE.md index 7f6f4692..e496ded2 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -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 --------- diff --git a/examples/gemini/subagents.php b/examples/gemini/subagents.php new file mode 100644 index 00000000..7afc6242 --- /dev/null +++ b/examples/gemini/subagents.php @@ -0,0 +1,64 @@ + + * + * 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; diff --git a/examples/openai/agent-as-tool.php b/examples/openai/subagents.php similarity index 50% rename from examples/openai/agent-as-tool.php rename to examples/openai/subagents.php index 343cd5b6..798b4843 100644 --- a/examples/openai/agent-as-tool.php +++ b/examples/openai/subagents.php @@ -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; diff --git a/src/agent/CHANGELOG.md b/src/agent/CHANGELOG.md index 3422642e..cbf7ef7a 100644 --- a/src/agent/CHANGELOG.md +++ b/src/agent/CHANGELOG.md @@ -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 diff --git a/src/agent/src/Toolbox/ToolFactory/AbstractToolFactory.php b/src/agent/src/Toolbox/ToolFactory/AbstractToolFactory.php deleted file mode 100644 index 4a3a146b..00000000 --- a/src/agent/src/Toolbox/ToolFactory/AbstractToolFactory.php +++ /dev/null @@ -1,44 +0,0 @@ - - * - * 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 - */ -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); - } - } -} diff --git a/src/agent/src/Toolbox/ToolFactory/ChainFactory.php b/src/agent/src/Toolbox/ToolFactory/ChainFactory.php index ce2b40ec..13fc6d12 100644 --- a/src/agent/src/Toolbox/ToolFactory/ChainFactory.php +++ b/src/agent/src/Toolbox/ToolFactory/ChainFactory.php @@ -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) { diff --git a/src/agent/src/Toolbox/ToolFactory/MemoryToolFactory.php b/src/agent/src/Toolbox/ToolFactory/MemoryToolFactory.php index 45bab2d0..eb538350 100644 --- a/src/agent/src/Toolbox/ToolFactory/MemoryToolFactory.php +++ b/src/agent/src/Toolbox/ToolFactory/MemoryToolFactory.php @@ -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 */ -final class MemoryToolFactory extends AbstractToolFactory +final class MemoryToolFactory implements ToolFactoryInterface { /** - * @var array + * @var array */ 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]; } } diff --git a/src/agent/src/Toolbox/ToolFactory/ReflectionToolFactory.php b/src/agent/src/Toolbox/ToolFactory/ReflectionToolFactory.php index 5c4f2e94..62594b43 100644 --- a/src/agent/src/Toolbox/ToolFactory/ReflectionToolFactory.php +++ b/src/agent/src/Toolbox/ToolFactory/ReflectionToolFactory.php @@ -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 */ -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); + } } } } diff --git a/src/agent/src/Toolbox/ToolFactoryInterface.php b/src/agent/src/Toolbox/ToolFactoryInterface.php index aafdf65d..d7ecc9f1 100644 --- a/src/agent/src/Toolbox/ToolFactoryInterface.php +++ b/src/agent/src/Toolbox/ToolFactoryInterface.php @@ -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; } diff --git a/src/agent/src/Toolbox/Toolbox.php b/src/agent/src/Toolbox/Toolbox.php index 85ee26e2..15643898 100644 --- a/src/agent/src/Toolbox/Toolbox.php +++ b/src/agent/src/Toolbox/Toolbox.php @@ -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 + */ + private array $instanceMap = []; + /** * @param iterable $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) { diff --git a/src/agent/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php b/src/agent/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php index 70a5b30a..27209b54 100644 --- a/src/agent/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php +++ b/src/agent/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php @@ -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() diff --git a/src/agent/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php b/src/agent/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php index 29a3ff6c..fbfa9203 100644 --- a/src/agent/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php +++ b/src/agent/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php @@ -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() diff --git a/src/agent/tests/Toolbox/ToolboxTest.php b/src/agent/tests/Toolbox/ToolboxTest.php index d383ed79..6f557de2 100644 --- a/src/agent/tests/Toolbox/ToolboxTest.php +++ b/src/agent/tests/Toolbox/ToolboxTest.php @@ -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()) diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index cf951b48..1efc6e46 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -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'), diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 7044d937..a5cf3bac 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -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')],