commit fbc4f187c4cabe55e6bd49cba2ead725fcaec11c Author: Christopher Hertel Date: Fri Jun 6 23:20:12 2025 +0200 refactor: rework LLM Chain into Symfony AI diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ec8c018 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/.github export-ignore +/tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpstan.dist.neon export-ignore +phpunit.xml.dist export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fcb8722 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..207153f --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f43db63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +composer.lock +vendor +.phpunit.cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc38d71 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..44d7c23 --- /dev/null +++ b/composer.json @@ -0,0 +1,72 @@ +{ + "name": "symfony/ai-agent", + "type": "library", + "description": "PHP library for building agentic applications.", + "keywords": [ + "ai", + "llm", + "agent" + ], + "license": "MIT", + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "require": { + "php": ">=8.2", + "ext-fileinfo": "*", + "phpdocumentor/reflection-docblock": "^5.4", + "phpstan/phpdoc-parser": "^2.1", + "psr/log": "^3.0", + "symfony/ai-platform": "@dev", + "symfony/clock": "^6.4 || ^7.1", + "symfony/http-client": "^6.4 || ^7.1", + "symfony/property-access": "^6.4 || ^7.1", + "symfony/property-info": "^6.4 || ^7.1", + "symfony/serializer": "^6.4 || ^7.1", + "symfony/type-info": "^7.2.3" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.13", + "symfony/ai-store": "@dev", + "symfony/css-selector": "^6.4 || ^7.1", + "symfony/dom-crawler": "^6.4 || ^7.1", + "symfony/event-dispatcher": "^6.4 || ^7.1" + }, + "suggest": { + "symfony/ai-store": "For using Similarity Search with a vector store.", + "symfony/css-selector": "For using the YouTube transcription tool.", + "symfony/dom-crawler": "For using the YouTube transcription tool." + }, + "config": { + "sort-packages": true + }, + "extra": { + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Agent\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Agent\\Tests\\": "tests/", + "Symfony\\AI\\Fixtures\\": "../../fixtures" + } + }, + "repositories": [ + {"type": "path", "url": "../platform"}, + {"type": "path", "url": "../store"} + ] +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..5f15237 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,10 @@ +parameters: + level: 6 + paths: + - src/ + - tests/ + ignoreErrors: + - + message: '#no value type specified in iterable type array#' + path: tests/* + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..7c04fa4 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Agent.php b/src/Agent.php new file mode 100644 index 0000000..1e9eb15 --- /dev/null +++ b/src/Agent.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Response\AsyncResponse; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; + +/** + * @author Christopher Hertel + */ +final readonly class Agent implements AgentInterface +{ + /** + * @var InputProcessorInterface[] + */ + private array $inputProcessors; + + /** + * @var OutputProcessorInterface[] + */ + private array $outputProcessors; + + /** + * @param InputProcessorInterface[] $inputProcessors + * @param OutputProcessorInterface[] $outputProcessors + */ + public function __construct( + private PlatformInterface $platform, + private Model $model, + iterable $inputProcessors = [], + iterable $outputProcessors = [], + private LoggerInterface $logger = new NullLogger(), + ) { + $this->inputProcessors = $this->initializeProcessors($inputProcessors, InputProcessorInterface::class); + $this->outputProcessors = $this->initializeProcessors($outputProcessors, OutputProcessorInterface::class); + } + + /** + * @param array $options + */ + public function call(MessageBagInterface $messages, array $options = []): ResponseInterface + { + $input = new Input($this->model, $messages, $options); + array_map(fn (InputProcessorInterface $processor) => $processor->processInput($input), $this->inputProcessors); + + $model = $input->model; + $messages = $input->messages; + $options = $input->getOptions(); + + if ($messages->containsAudio() && !$model->supports(Capability::INPUT_AUDIO)) { + throw MissingModelSupportException::forAudioInput($model::class); + } + + if ($messages->containsImage() && !$model->supports(Capability::INPUT_IMAGE)) { + throw MissingModelSupportException::forImageInput($model::class); + } + + try { + $response = $this->platform->request($model, $messages, $options); + + if ($response instanceof AsyncResponse) { + $response = $response->unwrap(); + } + } catch (ClientExceptionInterface $e) { + $message = $e->getMessage(); + $content = $e->getResponse()->toArray(false); + + $this->logger->debug($message, $content); + + throw new InvalidArgumentException('' === $message ? 'Invalid request to model or platform' : $message, previous: $e); + } catch (HttpExceptionInterface $e) { + throw new RuntimeException('Failed to request model', previous: $e); + } + + $output = new Output($model, $response, $messages, $options); + array_map(fn (OutputProcessorInterface $processor) => $processor->processOutput($output), $this->outputProcessors); + + return $output->response; + } + + /** + * @param InputProcessorInterface[]|OutputProcessorInterface[] $processors + * @param class-string $interface + * + * @return InputProcessorInterface[]|OutputProcessorInterface[] + */ + private function initializeProcessors(iterable $processors, string $interface): array + { + foreach ($processors as $processor) { + if (!$processor instanceof $interface) { + throw new InvalidArgumentException(\sprintf('Processor %s must implement %s interface.', $processor::class, $interface)); + } + + if ($processor instanceof AgentAwareInterface) { + $processor->setAgent($this); + } + } + + return $processors instanceof \Traversable ? iterator_to_array($processors) : $processors; + } +} diff --git a/src/AgentAwareInterface.php b/src/AgentAwareInterface.php new file mode 100644 index 0000000..843da2c --- /dev/null +++ b/src/AgentAwareInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +/** + * @author Christopher Hertel + */ +interface AgentAwareInterface +{ + public function setAgent(AgentInterface $agent): void; +} diff --git a/src/AgentAwareTrait.php b/src/AgentAwareTrait.php new file mode 100644 index 0000000..56b8268 --- /dev/null +++ b/src/AgentAwareTrait.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +/** + * @author Christopher Hertel + */ +trait AgentAwareTrait +{ + private AgentInterface $agent; + + public function setAgent(AgentInterface $agent): void + { + $this->agent = $agent; + } +} diff --git a/src/AgentInterface.php b/src/AgentInterface.php new file mode 100644 index 0000000..d205836 --- /dev/null +++ b/src/AgentInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @author Denis Zunke + */ +interface AgentInterface +{ + /** + * @param array $options + */ + public function call(MessageBagInterface $messages, array $options = []): ResponseInterface; +} diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php new file mode 100644 index 0000000..606960f --- /dev/null +++ b/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Oskar Stark + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..71e1590 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Oskar Stark + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Exception/LogicException.php b/src/Exception/LogicException.php new file mode 100644 index 0000000..3eff060 --- /dev/null +++ b/src/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Oskar Stark + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Exception/MissingModelSupportException.php b/src/Exception/MissingModelSupportException.php new file mode 100644 index 0000000..eb43df1 --- /dev/null +++ b/src/Exception/MissingModelSupportException.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Christopher Hertel + */ +final class MissingModelSupportException extends RuntimeException +{ + private function __construct(string $model, string $support) + { + parent::__construct(\sprintf('Model "%s" does not support "%s".', $model, $support)); + } + + public static function forToolCalling(string $model): self + { + return new self($model, 'tool calling'); + } + + public static function forAudioInput(string $model): self + { + return new self($model, 'audio input'); + } + + public static function forImageInput(string $model): self + { + return new self($model, 'image input'); + } + + public static function forStructuredOutput(string $model): self + { + return new self($model, 'structured output'); + } +} diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 0000000..c0a2ee8 --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Oskar Stark + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Input.php b/src/Input.php new file mode 100644 index 0000000..7b0bd90 --- /dev/null +++ b/src/Input.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class Input +{ + /** + * @param array $options + */ + public function __construct( + public Model $model, + public MessageBagInterface $messages, + private array $options, + ) { + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @param array $options + */ + public function setOptions(array $options): void + { + $this->options = $options; + } +} diff --git a/src/InputProcessor/ModelOverrideInputProcessor.php b/src/InputProcessor/ModelOverrideInputProcessor.php new file mode 100644 index 0000000..7dafea1 --- /dev/null +++ b/src/InputProcessor/ModelOverrideInputProcessor.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\InputProcessor; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class ModelOverrideInputProcessor implements InputProcessorInterface +{ + public function processInput(Input $input): void + { + $options = $input->getOptions(); + + if (!\array_key_exists('model', $options)) { + return; + } + + if (!$options['model'] instanceof Model) { + throw new InvalidArgumentException(\sprintf('Option "model" must be an instance of %s.', Model::class)); + } + + $input->model = $options['model']; + } +} diff --git a/src/InputProcessor/SystemPromptInputProcessor.php b/src/InputProcessor/SystemPromptInputProcessor.php new file mode 100644 index 0000000..9e9c9c3 --- /dev/null +++ b/src/InputProcessor/SystemPromptInputProcessor.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\InputProcessor; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +final readonly class SystemPromptInputProcessor implements InputProcessorInterface +{ + /** + * @param \Stringable|string $systemPrompt the system prompt to prepend to the input messages + * @param ToolboxInterface|null $toolbox the tool box to be used to append the tool definitions to the system prompt + */ + public function __construct( + private \Stringable|string $systemPrompt, + private ?ToolboxInterface $toolbox = null, + private LoggerInterface $logger = new NullLogger(), + ) { + } + + public function processInput(Input $input): void + { + $messages = $input->messages; + + if (null !== $messages->getSystemMessage()) { + $this->logger->debug('Skipping system prompt injection since MessageBag already contains a system message.'); + + return; + } + + $message = (string) $this->systemPrompt; + + if ($this->toolbox instanceof ToolboxInterface + && [] !== $this->toolbox->getTools() + ) { + $this->logger->debug('Append tool definitions to system prompt.'); + + $tools = implode(\PHP_EOL.\PHP_EOL, array_map( + fn (Tool $tool) => <<name} + {$tool->description} + TOOL, + $this->toolbox->getTools() + )); + + $message = <<systemPrompt} + + # Available tools + + {$tools} + PROMPT; + } + + $input->messages = $messages->prepend(Message::forSystem($message)); + } +} diff --git a/src/InputProcessorInterface.php b/src/InputProcessorInterface.php new file mode 100644 index 0000000..fc0868b --- /dev/null +++ b/src/InputProcessorInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +/** + * @author Christopher Hertel + */ +interface InputProcessorInterface +{ + public function processInput(Input $input): void; +} diff --git a/src/Output.php b/src/Output.php new file mode 100644 index 0000000..dffd738 --- /dev/null +++ b/src/Output.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final class Output +{ + /** + * @param array $options + */ + public function __construct( + public readonly Model $model, + public ResponseInterface $response, + public readonly MessageBagInterface $messages, + public readonly array $options, + ) { + } +} diff --git a/src/OutputProcessorInterface.php b/src/OutputProcessorInterface.php new file mode 100644 index 0000000..a6ad499 --- /dev/null +++ b/src/OutputProcessorInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +/** + * @author Christopher Hertel + */ +interface OutputProcessorInterface +{ + public function processOutput(Output $output): void; +} diff --git a/src/StructuredOutput/AgentProcessor.php b/src/StructuredOutput/AgentProcessor.php new file mode 100644 index 0000000..50a95a2 --- /dev/null +++ b/src/StructuredOutput/AgentProcessor.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Response\ObjectResponse; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Christopher Hertel + */ +final class AgentProcessor implements InputProcessorInterface, OutputProcessorInterface +{ + private string $outputStructure; + + public function __construct( + private readonly ResponseFormatFactoryInterface $responseFormatFactory = new ResponseFormatFactory(), + private ?SerializerInterface $serializer = null, + ) { + if (null === $this->serializer) { + $propertyInfo = new PropertyInfoExtractor([], [new PhpDocExtractor()]); + $normalizers = [new ObjectNormalizer(propertyTypeExtractor: $propertyInfo), new ArrayDenormalizer()]; + $this->serializer = new Serializer($normalizers, [new JsonEncoder()]); + } + } + + public function processInput(Input $input): void + { + $options = $input->getOptions(); + + if (!isset($options['output_structure'])) { + return; + } + + if (!$input->model->supports(Capability::OUTPUT_STRUCTURED)) { + throw MissingModelSupportException::forStructuredOutput($input->model::class); + } + + if (true === ($options['stream'] ?? false)) { + throw new InvalidArgumentException('Streamed responses are not supported for structured output'); + } + + $options['response_format'] = $this->responseFormatFactory->create($options['output_structure']); + + $this->outputStructure = $options['output_structure']; + unset($options['output_structure']); + + $input->setOptions($options); + } + + public function processOutput(Output $output): void + { + $options = $output->options; + + if ($output->response instanceof ObjectResponse) { + return; + } + + if (!isset($options['response_format'])) { + return; + } + + if (!isset($this->outputStructure)) { + $output->response = new ObjectResponse(json_decode($output->response->getContent(), true)); + + return; + } + + $output->response = new ObjectResponse( + $this->serializer->deserialize($output->response->getContent(), $this->outputStructure, 'json') + ); + } +} diff --git a/src/StructuredOutput/ResponseFormatFactory.php b/src/StructuredOutput/ResponseFormatFactory.php new file mode 100644 index 0000000..a818119 --- /dev/null +++ b/src/StructuredOutput/ResponseFormatFactory.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput; + +use Symfony\AI\Platform\Contract\JsonSchema\Factory; + +use function Symfony\Component\String\u; + +/** + * @author Christopher Hertel + */ +final readonly class ResponseFormatFactory implements ResponseFormatFactoryInterface +{ + public function __construct( + private Factory $schemaFactory = new Factory(), + ) { + } + + public function create(string $responseClass): array + { + return [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => u($responseClass)->afterLast('\\')->toString(), + 'schema' => $this->schemaFactory->buildProperties($responseClass), + 'strict' => true, + ], + ]; + } +} diff --git a/src/StructuredOutput/ResponseFormatFactoryInterface.php b/src/StructuredOutput/ResponseFormatFactoryInterface.php new file mode 100644 index 0000000..ab28b10 --- /dev/null +++ b/src/StructuredOutput/ResponseFormatFactoryInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput; + +/** + * @author Oskar Stark + */ +interface ResponseFormatFactoryInterface +{ + /** + * @param class-string $responseClass + * + * @return array{ + * type: 'json_schema', + * json_schema: array{ + * name: string, + * schema: array, + * strict: true, + * } + * } + */ + public function create(string $responseClass): array; +} diff --git a/src/Toolbox/AgentProcessor.php b/src/Toolbox/AgentProcessor.php new file mode 100644 index 0000000..9f7c068 --- /dev/null +++ b/src/Toolbox/AgentProcessor.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Agent\AgentAwareInterface; +use Symfony\AI\Agent\AgentAwareTrait; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Agent\Toolbox\Event\ToolCallsExecuted; +use Symfony\AI\Agent\Toolbox\StreamResponse as ToolboxStreamResponse; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\AI\Platform\Response\StreamResponse as GenericStreamResponse; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\Tool\Tool; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @author Christopher Hertel + */ +final class AgentProcessor implements InputProcessorInterface, OutputProcessorInterface, AgentAwareInterface +{ + use AgentAwareTrait; + + public function __construct( + private readonly ToolboxInterface $toolbox, + private readonly ToolResultConverter $resultConverter = new ToolResultConverter(), + private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly bool $keepToolMessages = false, + ) { + } + + public function processInput(Input $input): void + { + if (!$input->model->supports(Capability::TOOL_CALLING)) { + throw MissingModelSupportException::forToolCalling($input->model::class); + } + + $toolMap = $this->toolbox->getTools(); + if ([] === $toolMap) { + return; + } + + $options = $input->getOptions(); + // only filter tool map if list of strings is provided as option + if (isset($options['tools']) && $this->isFlatStringArray($options['tools'])) { + $toolMap = array_values(array_filter($toolMap, fn (Tool $tool) => \in_array($tool->name, $options['tools'], true))); + } + + $options['tools'] = $toolMap; + $input->setOptions($options); + } + + public function processOutput(Output $output): void + { + if ($output->response instanceof GenericStreamResponse) { + $output->response = new ToolboxStreamResponse( + $output->response->getContent(), + $this->handleToolCallsCallback($output), + ); + + return; + } + + if (!$output->response instanceof ToolCallResponse) { + return; + } + + $output->response = $this->handleToolCallsCallback($output)($output->response); + } + + /** + * @param array $tools + */ + private function isFlatStringArray(array $tools): bool + { + return array_reduce($tools, fn (bool $carry, mixed $item) => $carry && \is_string($item), true); + } + + private function handleToolCallsCallback(Output $output): \Closure + { + return function (ToolCallResponse $response, ?AssistantMessage $streamedAssistantResponse = null) use ($output): ResponseInterface { + $messages = $this->keepToolMessages ? $output->messages : clone $output->messages; + + if (null !== $streamedAssistantResponse && '' !== $streamedAssistantResponse->content) { + $messages->add($streamedAssistantResponse); + } + + do { + $toolCalls = $response->getContent(); + $messages->add(Message::ofAssistant(toolCalls: $toolCalls)); + + $results = []; + foreach ($toolCalls as $toolCall) { + $result = $this->toolbox->execute($toolCall); + $results[] = new ToolCallResult($toolCall, $result); + $messages->add(Message::ofToolCall($toolCall, $this->resultConverter->convert($result))); + } + + $event = new ToolCallsExecuted(...$results); + $this->eventDispatcher?->dispatch($event); + + $response = $event->hasResponse() ? $event->response : $this->agent->call($messages, $output->options); + } while ($response instanceof ToolCallResponse); + + return $response; + }; + } +} diff --git a/src/Toolbox/Attribute/AsTool.php b/src/Toolbox/Attribute/AsTool.php new file mode 100644 index 0000000..04811ac --- /dev/null +++ b/src/Toolbox/Attribute/AsTool.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Attribute; + +/** + * @author Christopher Hertel + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final readonly class AsTool +{ + public function __construct( + public string $name, + public string $description, + public string $method = '__invoke', + ) { + } +} diff --git a/src/Toolbox/Event/ToolCallsExecuted.php b/src/Toolbox/Event/ToolCallsExecuted.php new file mode 100644 index 0000000..4101b8d --- /dev/null +++ b/src/Toolbox/Event/ToolCallsExecuted.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Event; + +use Symfony\AI\Agent\Toolbox\ToolCallResult; +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final class ToolCallsExecuted +{ + /** + * @var ToolCallResult[] + */ + public readonly array $toolCallResults; + public ResponseInterface $response; + + public function __construct(ToolCallResult ...$toolCallResults) + { + $this->toolCallResults = $toolCallResults; + } + + public function hasResponse(): bool + { + return isset($this->response); + } +} diff --git a/src/Toolbox/Exception/ExceptionInterface.php b/src/Toolbox/Exception/ExceptionInterface.php new file mode 100644 index 0000000..bbb5900 --- /dev/null +++ b/src/Toolbox/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Agent\Exception\ExceptionInterface as BaseExceptionInterface; + +/** + * @author Christopher Hertel + */ +interface ExceptionInterface extends BaseExceptionInterface +{ +} diff --git a/src/Toolbox/Exception/ToolConfigurationException.php b/src/Toolbox/Exception/ToolConfigurationException.php new file mode 100644 index 0000000..0e39c7f --- /dev/null +++ b/src/Toolbox/Exception/ToolConfigurationException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; + +/** + * @author Christopher Hertel + */ +final class ToolConfigurationException extends InvalidArgumentException implements ExceptionInterface +{ + public static function invalidMethod(string $toolClass, string $methodName, \ReflectionException $previous): self + { + return new self(\sprintf('Method "%s" not found in tool "%s".', $methodName, $toolClass), previous: $previous); + } +} diff --git a/src/Toolbox/Exception/ToolException.php b/src/Toolbox/Exception/ToolException.php new file mode 100644 index 0000000..08e496f --- /dev/null +++ b/src/Toolbox/Exception/ToolException.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +/** + * @author Christopher Hertel + */ +final class ToolException extends InvalidArgumentException implements ExceptionInterface +{ + public static function invalidReference(mixed $reference): self + { + return new self(\sprintf('The reference "%s" is not a valid tool.', $reference)); + } + + public static function missingAttribute(string $className): self + { + return new self(\sprintf('The class "%s" is not a tool, please add %s attribute.', $className, AsTool::class)); + } +} diff --git a/src/Toolbox/Exception/ToolExecutionException.php b/src/Toolbox/Exception/ToolExecutionException.php new file mode 100644 index 0000000..3577020 --- /dev/null +++ b/src/Toolbox/Exception/ToolExecutionException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @author Christopher Hertel + */ +final class ToolExecutionException extends \RuntimeException implements ExceptionInterface +{ + public ?ToolCall $toolCall = null; + + public static function executionFailed(ToolCall $toolCall, \Throwable $previous): self + { + $exception = new self(\sprintf('Execution of tool "%s" failed with error: %s', $toolCall->name, $previous->getMessage()), previous: $previous); + $exception->toolCall = $toolCall; + + return $exception; + } +} diff --git a/src/Toolbox/Exception/ToolNotFoundException.php b/src/Toolbox/Exception/ToolNotFoundException.php new file mode 100644 index 0000000..8e1fb90 --- /dev/null +++ b/src/Toolbox/Exception/ToolNotFoundException.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; + +/** + * @author Christopher Hertel + */ +final class ToolNotFoundException extends \RuntimeException implements ExceptionInterface +{ + public ?ToolCall $toolCall = null; + + public static function notFoundForToolCall(ToolCall $toolCall): self + { + $exception = new self(\sprintf('Tool not found for call: %s.', $toolCall->name)); + $exception->toolCall = $toolCall; + + return $exception; + } + + public static function notFoundForReference(ExecutionReference $reference): self + { + return new self(\sprintf('Tool not found for reference: %s::%s.', $reference->class, $reference->method)); + } +} diff --git a/src/Toolbox/FaultTolerantToolbox.php b/src/Toolbox/FaultTolerantToolbox.php new file mode 100644 index 0000000..f372ab1 --- /dev/null +++ b/src/Toolbox/FaultTolerantToolbox.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\Toolbox; + +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\Tool; + +/** + * Catches exceptions thrown by the inner tool box and returns error messages for the LLM instead. + * + * @author Christopher Hertel + */ +final readonly class FaultTolerantToolbox implements ToolboxInterface +{ + public function __construct( + private ToolboxInterface $innerToolbox, + ) { + } + + public function getTools(): array + { + return $this->innerToolbox->getTools(); + } + + public function execute(ToolCall $toolCall): mixed + { + try { + return $this->innerToolbox->execute($toolCall); + } catch (ToolExecutionException $e) { + return \sprintf('An error occurred while executing tool "%s".', $e->toolCall->name); + } catch (ToolNotFoundException) { + $names = array_map(fn (Tool $metadata) => $metadata->name, $this->getTools()); + + return \sprintf('Tool "%s" was not found, please use one of these: %s', $toolCall->name, implode(', ', $names)); + } + } +} diff --git a/src/Toolbox/StreamResponse.php b/src/Toolbox/StreamResponse.php new file mode 100644 index 0000000..475dbb6 --- /dev/null +++ b/src/Toolbox/StreamResponse.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Response\BaseResponse; +use Symfony\AI\Platform\Response\ToolCallResponse; + +/** + * @author Denis Zunke + */ +final class StreamResponse extends BaseResponse +{ + public function __construct( + private readonly \Generator $generator, + private readonly \Closure $handleToolCallsCallback, + ) { + } + + public function getContent(): \Generator + { + $streamedResponse = ''; + foreach ($this->generator as $value) { + if ($value instanceof ToolCallResponse) { + yield from ($this->handleToolCallsCallback)($value, Message::ofAssistant($streamedResponse))->getContent(); + + break; + } + + $streamedResponse .= $value; + yield $value; + } + } +} diff --git a/src/Toolbox/Tool/Agent.php b/src/Toolbox/Tool/Agent.php new file mode 100644 index 0000000..1e3ca7a --- /dev/null +++ b/src/Toolbox/Tool/Agent.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Response\TextResponse; + +/** + * @author Christopher Hertel + */ +final readonly class Agent +{ + public function __construct( + private AgentInterface $agent, + ) { + } + + /** + * @param string $message the message to pass to the chain + */ + public function __invoke(string $message): string + { + $response = $this->agent->call(new MessageBag(Message::ofUser($message))); + + \assert($response instanceof TextResponse); + + return $response->getContent(); + } +} diff --git a/src/Toolbox/Tool/Brave.php b/src/Toolbox/Tool/Brave.php new file mode 100644 index 0000000..4a25d30 --- /dev/null +++ b/src/Toolbox/Tool/Brave.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('brave_search', 'Tool that searches the web using Brave Search')] +final readonly class Brave +{ + /** + * @param array $options See https://api-dashboard.search.brave.com/app/documentation/web-search/query#WebSearchAPIQueryParameters + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + private array $options = [], + ) { + } + + /** + * @param string $query the search query term + * @param int $count The number of search results returned in response. + * Combine this parameter with offset to paginate search results. + * @param int $offset The number of search results to skip before returning results. + * In order to paginate results use this parameter together with count. + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $count = 20, + #[With(minimum: 0, maximum: 9)] + int $offset = 0, + ): array { + $response = $this->httpClient->request('GET', 'https://api.search.brave.com/res/v1/web/search', [ + 'headers' => ['X-Subscription-Token' => $this->apiKey], + 'query' => array_merge($this->options, [ + 'q' => $query, + 'count' => $count, + 'offset' => $offset, + ]), + ]); + + $data = $response->toArray(); + + return array_map(static function (array $result) { + return ['title' => $result['title'], 'description' => $result['description'], 'url' => $result['url']]; + }, $data['web']['results'] ?? []); + } +} diff --git a/src/Toolbox/Tool/Clock.php b/src/Toolbox/Tool/Clock.php new file mode 100644 index 0000000..562b3cc --- /dev/null +++ b/src/Toolbox/Tool/Clock.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Component\Clock\Clock as SymfonyClock; +use Symfony\Component\Clock\ClockInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('clock', description: 'Provides the current date and time.')] +final readonly class Clock +{ + public function __construct( + private ClockInterface $clock = new SymfonyClock(), + ) { + } + + public function __invoke(): string + { + return \sprintf( + 'Current date is %s (YYYY-MM-DD) and the time is %s (HH:MM:SS).', + $this->clock->now()->format('Y-m-d'), + $this->clock->now()->format('H:i:s'), + ); + } +} diff --git a/src/Toolbox/Tool/Crawler.php b/src/Toolbox/Tool/Crawler.php new file mode 100644 index 0000000..96e809e --- /dev/null +++ b/src/Toolbox/Tool/Crawler.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Component\DomCrawler\Crawler as DomCrawler; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('crawler', 'A tool that crawls one page of a website and returns the visible text of it.')] +final readonly class Crawler +{ + public function __construct( + private HttpClientInterface $httpClient, + ) { + if (!class_exists(DomCrawler::class)) { + throw new RuntimeException('The DomCrawler component is not installed. Please install it using "composer require symfony/dom-crawler".'); + } + } + + /** + * @param string $url the URL of the page to crawl + */ + public function __invoke(string $url): string + { + $response = $this->httpClient->request('GET', $url); + + return (new DomCrawler($response->getContent()))->filter('body')->text(); + } +} diff --git a/src/Toolbox/Tool/OpenMeteo.php b/src/Toolbox/Tool/OpenMeteo.php new file mode 100644 index 0000000..7e0adef --- /dev/null +++ b/src/Toolbox/Tool/OpenMeteo.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool(name: 'weather_current', description: 'get current weather for a location', method: 'current')] +#[AsTool(name: 'weather_forecast', description: 'get weather forecast for a location', method: 'forecast')] +final readonly class OpenMeteo +{ + private const WMO_CODES = [ + 0 => 'Clear', + 1 => 'Mostly Clear', + 2 => 'Partly Cloudy', + 3 => 'Overcast', + 45 => 'Fog', + 48 => 'Icy Fog', + 51 => 'Light Drizzle', + 53 => 'Drizzle', + 55 => 'Heavy Drizzle', + 56 => 'Light Freezing Drizzle', + 57 => 'Freezing Drizzle', + 61 => 'Light Rain', + 63 => 'Rain', + 65 => 'Heavy Rain', + 66 => 'Light Freezing Rain', + 67 => 'Freezing Rain', + 71 => 'Light Snow', + 73 => 'Snow', + 75 => 'Heavy Snow', + 77 => 'Snow Grains', + 80 => 'Light Showers', + 81 => 'Showers', + 82 => 'Heavy Showers', + 85 => 'Light Snow Showers', + 86 => 'Snow Showers', + 95 => 'Thunderstorm', + 96 => 'Light Thunderstorm with Hail', + 99 => 'Thunderstorm with Hail', + ]; + + public function __construct( + private HttpClientInterface $httpClient, + ) { + } + + /** + * @param float $latitude the latitude of the location + * @param float $longitude the longitude of the location + * + * @return array{ + * weather: string, + * time: string, + * temperature: string, + * wind_speed: string, + * } + */ + public function current(float $latitude, float $longitude): array + { + $response = $this->httpClient->request('GET', 'https://api.open-meteo.com/v1/forecast', [ + 'query' => [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'current' => 'weather_code,temperature_2m,wind_speed_10m', + ], + ]); + + $data = $response->toArray(); + + return [ + 'weather' => self::WMO_CODES[$data['current']['weather_code']] ?? 'Unknown', + 'time' => $data['current']['time'], + 'temperature' => $data['current']['temperature_2m'].$data['current_units']['temperature_2m'], + 'wind_speed' => $data['current']['wind_speed_10m'].$data['current_units']['wind_speed_10m'], + ]; + } + + /** + * @param float $latitude the latitude of the location + * @param float $longitude the longitude of the location + * @param int $days the number of days to forecast + * + * @return array{ + * weather: string, + * time: string, + * temperature_min: string, + * temperature_max: string, + * }[] + */ + public function forecast( + float $latitude, + float $longitude, + #[With(minimum: 1, maximum: 16)] + int $days = 7, + ): array { + $response = $this->httpClient->request('GET', 'https://api.open-meteo.com/v1/forecast', [ + 'query' => [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'daily' => 'weather_code,temperature_2m_max,temperature_2m_min', + 'forecast_days' => $days, + ], + ]); + + $data = $response->toArray(); + $forecast = []; + for ($i = 0; $i < $days; ++$i) { + $forecast[] = [ + 'weather' => self::WMO_CODES[$data['daily']['weather_code'][$i]] ?? 'Unknown', + 'time' => $data['daily']['time'][$i], + 'temperature_min' => $data['daily']['temperature_2m_min'][$i].$data['daily_units']['temperature_2m_min'], + 'temperature_max' => $data['daily']['temperature_2m_max'][$i].$data['daily_units']['temperature_2m_max'], + ]; + } + + return $forecast; + } +} diff --git a/src/Toolbox/Tool/SerpApi.php b/src/Toolbox/Tool/SerpApi.php new file mode 100644 index 0000000..782cd97 --- /dev/null +++ b/src/Toolbox/Tool/SerpApi.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool(name: 'serpapi', description: 'search for information on the internet')] +final readonly class SerpApi +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + ) { + } + + /** + * @param string $query The search query to use + */ + public function __invoke(string $query): string + { + $response = $this->httpClient->request('GET', 'https://serpapi.com/search', [ + 'query' => [ + 'q' => $query, + 'api_key' => $this->apiKey, + ], + ]); + + return \sprintf('Results for "%s" are "%s".', $query, $this->extractBestResponse($response->toArray())); + } + + /** + * @param array $results + */ + private function extractBestResponse(array $results): string + { + return implode('. ', array_map(fn ($story) => $story['title'], $results['organic_results'])); + } +} diff --git a/src/Toolbox/Tool/SimilaritySearch.php b/src/Toolbox/Tool/SimilaritySearch.php new file mode 100644 index 0000000..05b0f2d --- /dev/null +++ b/src/Toolbox/Tool/SimilaritySearch.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\VectorStoreInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('similarity_search', description: 'Searches for documents similar to a query or sentence.')] +final class SimilaritySearch +{ + /** + * @var VectorDocument[] + */ + public array $usedDocuments = []; + + public function __construct( + private readonly PlatformInterface $platform, + private readonly Model $model, + private readonly VectorStoreInterface $vectorStore, + ) { + } + + /** + * @param string $searchTerm string used for similarity search + */ + public function __invoke(string $searchTerm): string + { + /** @var Vector[] $vectors */ + $vectors = $this->platform->request($this->model, $searchTerm)->getContent(); + $this->usedDocuments = $this->vectorStore->query($vectors[0]); + + if (0 === \count($this->usedDocuments)) { + return 'No results found'; + } + + $result = 'Found documents with following information:'.\PHP_EOL; + foreach ($this->usedDocuments as $document) { + $result .= json_encode($document->metadata); + } + + return $result; + } +} diff --git a/src/Toolbox/Tool/Tavily.php b/src/Toolbox/Tool/Tavily.php new file mode 100644 index 0000000..f84848d --- /dev/null +++ b/src/Toolbox/Tool/Tavily.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Tool integration of tavily.com. + * + * @author Christopher Hertel + */ +#[AsTool('tavily_search', description: 'search for information on the internet', method: 'search')] +#[AsTool('tavily_extract', description: 'fetch content from websites', method: 'extract')] +final readonly class Tavily +{ + /** + * @param array $options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private array $options = ['include_images' => false], + ) { + } + + /** + * @param string $query The search query to use + */ + public function search(string $query): string + { + $response = $this->httpClient->request('POST', 'https://api.tavily.com/search', [ + 'json' => array_merge($this->options, [ + 'query' => $query, + 'api_key' => $this->apiKey, + ]), + ]); + + return $response->getContent(); + } + + /** + * @param string[] $urls URLs to fetch information from + */ + public function extract(array $urls): string + { + $response = $this->httpClient->request('POST', 'https://api.tavily.com/extract', [ + 'json' => [ + 'urls' => $urls, + 'api_key' => $this->apiKey, + ], + ]); + + return $response->getContent(); + } +} diff --git a/src/Toolbox/Tool/Wikipedia.php b/src/Toolbox/Tool/Wikipedia.php new file mode 100644 index 0000000..9aa5273 --- /dev/null +++ b/src/Toolbox/Tool/Wikipedia.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('wikipedia_search', description: 'Searches Wikipedia for a given query', method: 'search')] +#[AsTool('wikipedia_article', description: 'Retrieves a Wikipedia article by its title', method: 'article')] +final readonly class Wikipedia +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $locale = 'en', + ) { + } + + /** + * @param string $query The query to search for on Wikipedia + */ + public function search(string $query): string + { + $result = $this->execute([ + 'action' => 'query', + 'format' => 'json', + 'list' => 'search', + 'srsearch' => $query, + ], $this->locale); + + $titles = array_map(fn (array $item) => $item['title'], $result['query']['search']); + + if (empty($titles)) { + return 'No articles were found on Wikipedia.'; + } + + $response = 'Articles with the following titles were found on Wikipedia:'.\PHP_EOL; + foreach ($titles as $title) { + $response .= ' - '.$title.\PHP_EOL; + } + + return $response.\PHP_EOL.'Use the title of the article with tool "wikipedia_article" to load the content.'; + } + + /** + * @param string $title The title of the article to load from Wikipedia + */ + public function article(string $title): string + { + $result = $this->execute([ + 'action' => 'query', + 'format' => 'json', + 'prop' => 'extracts|info|pageimages', + 'titles' => $title, + 'explaintext' => true, + 'redirects' => true, + ], $this->locale); + + $article = current($result['query']['pages']); + + if (\array_key_exists('missing', $article)) { + return \sprintf('No article with title "%s" was found on Wikipedia.', $title); + } + + $response = ''; + if (\array_key_exists('redirects', $result['query'])) { + foreach ($result['query']['redirects'] as $redirect) { + $response .= \sprintf('The article "%s" redirects to article "%s".', $redirect['from'], $redirect['to']).\PHP_EOL; + } + $response .= \PHP_EOL; + } + + return $response.'This is the content of article "'.$article['title'].'":'.\PHP_EOL.$article['extract']; + } + + /** + * @param array $query + * + * @return array + */ + private function execute(array $query, ?string $locale = null): array + { + $url = \sprintf('https://%s.wikipedia.org/w/api.php', $locale ?? $this->locale); + $response = $this->httpClient->request('GET', $url, ['query' => $query]); + + return $response->toArray(); + } +} diff --git a/src/Toolbox/Tool/YouTubeTranscriber.php b/src/Toolbox/Tool/YouTubeTranscriber.php new file mode 100644 index 0000000..cdfef3b --- /dev/null +++ b/src/Toolbox/Tool/YouTubeTranscriber.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Exception\LogicException; +use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Component\CssSelector\CssSelectorConverter; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('youtube_transcript', 'Fetches the transcript of a YouTube video')] +final readonly class YouTubeTranscriber +{ + public function __construct( + private HttpClientInterface $client, + ) { + if (!class_exists(Crawler::class)) { + throw new LogicException('The Symfony DomCrawler component is required to use this tool. Try running "composer require symfony/dom-crawler".'); + } + if (!class_exists(CssSelectorConverter::class)) { + throw new LogicException('The Symfony CSS Selector component is required to use this tool. Try running "composer require symfony/css-selector".'); + } + } + + /** + * @param string $videoId The ID of the YouTube video + */ + public function __invoke(string $videoId): string + { + // Fetch the HTML content of the YouTube video page + $htmlResponse = $this->client->request('GET', 'https://youtube.com/watch?v='.$videoId); + $html = $htmlResponse->getContent(); + + // Use DomCrawler to parse the HTML + $crawler = new Crawler($html); + + // Extract the script containing the ytInitialPlayerResponse + $scriptContent = $crawler->filter('script')->reduce(function (Crawler $node) { + return str_contains($node->text(), 'var ytInitialPlayerResponse = {'); + })->text(); + + // Extract and parse the JSON data from the script + $start = strpos($scriptContent, 'var ytInitialPlayerResponse = ') + \strlen('var ytInitialPlayerResponse = '); + $dataString = substr($scriptContent, $start); + $dataString = substr($dataString, 0, strrpos($dataString, ';') ?: null); + $data = json_decode(trim($dataString), true); + + // Extract the URL for the captions + if (!isset($data['captions']['playerCaptionsTracklistRenderer']['captionTracks'][0]['baseUrl'])) { + throw new RuntimeException('Captions are not available for this video.'); + } + $captionsUrl = $data['captions']['playerCaptionsTracklistRenderer']['captionTracks'][0]['baseUrl']; + + // Fetch and parse the captions XML + $xmlResponse = $this->client->request('GET', $captionsUrl); + $xmlContent = $xmlResponse->getContent(); + $xmlCrawler = new Crawler($xmlContent); + + // Collect all text elements from the captions + $transcript = $xmlCrawler->filter('text')->each(function (Crawler $node) { + return $node->text().' YouTubeTranscriber.php'; + }); + + return implode(\PHP_EOL, $transcript); + } +} diff --git a/src/Toolbox/ToolCallResult.php b/src/Toolbox/ToolCallResult.php new file mode 100644 index 0000000..153631c --- /dev/null +++ b/src/Toolbox/ToolCallResult.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @author Christopher Hertel + */ +final readonly class ToolCallResult +{ + public function __construct( + public ToolCall $toolCall, + public mixed $result, + ) { + } +} diff --git a/src/Toolbox/ToolFactory/AbstractToolFactory.php b/src/Toolbox/ToolFactory/AbstractToolFactory.php new file mode 100644 index 0000000..4a3a146 --- /dev/null +++ b/src/Toolbox/ToolFactory/AbstractToolFactory.php @@ -0,0 +1,44 @@ + + * + * 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/Toolbox/ToolFactory/ChainFactory.php b/src/Toolbox/ToolFactory/ChainFactory.php new file mode 100644 index 0000000..5fca2bc --- /dev/null +++ b/src/Toolbox/ToolFactory/ChainFactory.php @@ -0,0 +1,54 @@ + + * + * 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\Exception\ToolException; +use Symfony\AI\Agent\Toolbox\ToolFactoryInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ChainFactory implements ToolFactoryInterface +{ + /** + * @var list + */ + private array $factories; + + /** + * @param iterable $factories + */ + public function __construct(iterable $factories) + { + $this->factories = $factories instanceof \Traversable ? iterator_to_array($factories) : $factories; + } + + public function getTool(string $reference): iterable + { + $invalid = 0; + foreach ($this->factories as $factory) { + try { + yield from $factory->getTool($reference); + } catch (ToolException) { + ++$invalid; + continue; + } + + // If the factory does not throw an exception, we don't need to check the others + return; + } + + if ($invalid === \count($this->factories)) { + throw ToolException::invalidReference($reference); + } + } +} diff --git a/src/Toolbox/ToolFactory/MemoryToolFactory.php b/src/Toolbox/ToolFactory/MemoryToolFactory.php new file mode 100644 index 0000000..80846d9 --- /dev/null +++ b/src/Toolbox/ToolFactory/MemoryToolFactory.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\Toolbox\ToolFactory; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolException; + +/** + * @author Christopher Hertel + */ +final class MemoryToolFactory extends AbstractToolFactory +{ + /** + * @var array + */ + private array $tools = []; + + 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); + + return $this; + } + + /** + * @param class-string $reference + */ + public function getTool(string $reference): iterable + { + if (!isset($this->tools[$reference])) { + throw ToolException::invalidReference($reference); + } + + foreach ($this->tools[$reference] as $tool) { + yield $this->convertAttribute($reference, $tool); + } + } +} diff --git a/src/Toolbox/ToolFactory/ReflectionToolFactory.php b/src/Toolbox/ToolFactory/ReflectionToolFactory.php new file mode 100644 index 0000000..8e76634 --- /dev/null +++ b/src/Toolbox/ToolFactory/ReflectionToolFactory.php @@ -0,0 +1,44 @@ + + * + * 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\ToolException; + +/** + * Metadata factory that uses reflection in combination with `#[AsTool]` attribute to extract metadata from tools. + * + * @author Christopher Hertel + */ +final class ReflectionToolFactory extends AbstractToolFactory +{ + /** + * @param class-string $reference + */ + public function getTool(string $reference): iterable + { + if (!class_exists($reference)) { + throw ToolException::invalidReference($reference); + } + + $reflectionClass = new \ReflectionClass($reference); + $attributes = $reflectionClass->getAttributes(AsTool::class); + + if (0 === \count($attributes)) { + throw ToolException::missingAttribute($reference); + } + + foreach ($attributes as $attribute) { + yield $this->convertAttribute($reference, $attribute->newInstance()); + } + } +} diff --git a/src/Toolbox/ToolFactoryInterface.php b/src/Toolbox/ToolFactoryInterface.php new file mode 100644 index 0000000..aafdf65 --- /dev/null +++ b/src/Toolbox/ToolFactoryInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Agent\Toolbox\Exception\ToolException; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +interface ToolFactoryInterface +{ + /** + * @return iterable + * + * @throws ToolException if the metadata for the given reference is not found + */ + public function getTool(string $reference): iterable; +} diff --git a/src/Toolbox/ToolResultConverter.php b/src/Toolbox/ToolResultConverter.php new file mode 100644 index 0000000..a5c8b5b --- /dev/null +++ b/src/Toolbox/ToolResultConverter.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +/** + * @author Christopher Hertel + */ +final readonly class ToolResultConverter +{ + /** + * @param \JsonSerializable|\Stringable|array|float|string|\DateTimeInterface|null $result + */ + public function convert(\JsonSerializable|\Stringable|array|float|string|\DateTimeInterface|null $result): ?string + { + if (null === $result) { + return null; + } + + if ($result instanceof \JsonSerializable || \is_array($result)) { + return json_encode($result, flags: \JSON_THROW_ON_ERROR); + } + + if (\is_float($result) || $result instanceof \Stringable) { + return (string) $result; + } + + if ($result instanceof \DateTimeInterface) { + return $result->format(\DATE_ATOM); + } + + return $result; + } +} diff --git a/src/Toolbox/Toolbox.php b/src/Toolbox/Toolbox.php new file mode 100644 index 0000000..bcab2c0 --- /dev/null +++ b/src/Toolbox/Toolbox.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +final class Toolbox implements ToolboxInterface +{ + /** + * List of executable tools. + * + * @var list + */ + private readonly array $tools; + + /** + * List of tool metadata objects. + * + * @var Tool[] + */ + private array $map; + + /** + * @param iterable $tools + */ + public function __construct( + private readonly ToolFactoryInterface $toolFactory, + iterable $tools, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + $this->tools = $tools instanceof \Traversable ? iterator_to_array($tools) : $tools; + } + + public static function create(object ...$tools): self + { + return new self(new ReflectionToolFactory(), $tools); + } + + public function getTools(): array + { + if (isset($this->map)) { + return $this->map; + } + + $map = []; + foreach ($this->tools as $tool) { + foreach ($this->toolFactory->getTool($tool::class) as $metadata) { + $map[] = $metadata; + } + } + + return $this->map = $map; + } + + public function execute(ToolCall $toolCall): mixed + { + $metadata = $this->getMetadata($toolCall); + $tool = $this->getExecutable($metadata); + + try { + $this->logger->debug(\sprintf('Executing tool "%s".', $toolCall->name), $toolCall->arguments); + $result = $tool->{$metadata->reference->method}(...$toolCall->arguments); + } catch (\Throwable $e) { + $this->logger->warning(\sprintf('Failed to execute tool "%s".', $toolCall->name), ['exception' => $e]); + throw ToolExecutionException::executionFailed($toolCall, $e); + } + + return $result; + } + + private function getMetadata(ToolCall $toolCall): Tool + { + foreach ($this->getTools() as $metadata) { + if ($metadata->name === $toolCall->name) { + return $metadata; + } + } + + throw ToolNotFoundException::notFoundForToolCall($toolCall); + } + + private function getExecutable(Tool $metadata): object + { + foreach ($this->tools as $tool) { + if ($tool instanceof $metadata->reference->class) { + return $tool; + } + } + + throw ToolNotFoundException::notFoundForReference($metadata->reference); + } +} diff --git a/src/Toolbox/ToolboxInterface.php b/src/Toolbox/ToolboxInterface.php new file mode 100644 index 0000000..1547864 --- /dev/null +++ b/src/Toolbox/ToolboxInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +interface ToolboxInterface +{ + /** + * @return Tool[] + */ + public function getTools(): array; + + /** + * @throws ToolExecutionException if the tool execution fails + * @throws ToolNotFoundException if the tool is not found + */ + public function execute(ToolCall $toolCall): mixed; +} diff --git a/tests/InputProcessor/ModelOverrideInputProcessorTest.php b/tests/InputProcessor/ModelOverrideInputProcessorTest.php new file mode 100644 index 0000000..5acaddf --- /dev/null +++ b/tests/InputProcessor/ModelOverrideInputProcessorTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\InputProcessor; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessor\ModelOverrideInputProcessor; +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Message\MessageBag; + +#[CoversClass(ModelOverrideInputProcessor::class)] +#[UsesClass(GPT::class)] +#[UsesClass(Claude::class)] +#[UsesClass(Input::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Embeddings::class)] +#[Small] +final class ModelOverrideInputProcessorTest extends TestCase +{ + #[Test] + public function processInputWithValidModelOption(): void + { + $gpt = new GPT(); + $claude = new Claude(); + $input = new Input($gpt, new MessageBag(), ['model' => $claude]); + + $processor = new ModelOverrideInputProcessor(); + $processor->processInput($input); + + self::assertSame($claude, $input->model); + } + + #[Test] + public function processInputWithoutModelOption(): void + { + $gpt = new GPT(); + $input = new Input($gpt, new MessageBag(), []); + + $processor = new ModelOverrideInputProcessor(); + $processor->processInput($input); + + self::assertSame($gpt, $input->model); + } + + #[Test] + public function processInputWithInvalidModelOption(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Option "model" must be an instance of Symfony\AI\Platform\Model.'); + + $gpt = new GPT(); + $model = new MessageBag(); + $input = new Input($gpt, new MessageBag(), ['model' => $model]); + + $processor = new ModelOverrideInputProcessor(); + $processor->processInput($input); + } +} diff --git a/tests/InputProcessor/SystemPromptInputProcessorTest.php b/tests/InputProcessor/SystemPromptInputProcessorTest.php new file mode 100644 index 0000000..c0ab486 --- /dev/null +++ b/tests/InputProcessor/SystemPromptInputProcessorTest.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\InputProcessor; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Fixtures\Tool\ToolNoParams; +use Symfony\AI\Fixtures\Tool\ToolRequiredParams; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +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\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(SystemPromptInputProcessor::class)] +#[UsesClass(GPT::class)] +#[UsesClass(Message::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Input::class)] +#[UsesClass(SystemMessage::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(Text::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[Small] +final class SystemPromptInputProcessorTest extends TestCase +{ + #[Test] + public function processInputAddsSystemMessageWhenNoneExists(): void + { + $processor = new SystemPromptInputProcessor('This is a system prompt'); + + $input = new Input(new GPT(), new MessageBag(Message::ofUser('This is a user message')), []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame('This is a system prompt', $messages[0]->content); + } + + #[Test] + public function processInputDoesNotAddSystemMessageWhenOneExists(): void + { + $processor = new SystemPromptInputProcessor('This is a system prompt'); + + $messages = new MessageBag( + Message::forSystem('This is already a system prompt'), + Message::ofUser('This is a user message'), + ); + $input = new Input(new GPT(), $messages, []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame('This is already a system prompt', $messages[0]->content); + } + + #[Test] + public function doesNotIncludeToolsIfToolboxIsEmpty(): void + { + $processor = new SystemPromptInputProcessor( + 'This is a system prompt', + new class implements ToolboxInterface { + public function getTools(): array + { + return []; + } + + public function execute(ToolCall $toolCall): mixed + { + return null; + } + } + ); + + $input = new Input(new GPT(), new MessageBag(Message::ofUser('This is a user message')), []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame('This is a system prompt', $messages[0]->content); + } + + #[Test] + public function includeToolDefinitions(): void + { + $processor = new SystemPromptInputProcessor( + 'This is a system prompt', + new class implements ToolboxInterface { + public function getTools(): array + { + return [ + new Tool(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), + new Tool( + new ExecutionReference(ToolRequiredParams::class, 'bar'), + 'tool_required_params', + <<processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame(<<content); + } + + #[Test] + public function withStringableSystemPrompt(): void + { + $processor = new SystemPromptInputProcessor( + new SystemPromptService(), + new class implements ToolboxInterface { + public function getTools(): array + { + return [ + new Tool(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), + ]; + } + + public function execute(ToolCall $toolCall): mixed + { + return null; + } + } + ); + + $input = new Input(new GPT(), new MessageBag(Message::ofUser('This is a user message')), []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame(<<content); + } +} diff --git a/tests/InputProcessor/SystemPromptService.php b/tests/InputProcessor/SystemPromptService.php new file mode 100644 index 0000000..1fc0e49 --- /dev/null +++ b/tests/InputProcessor/SystemPromptService.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\InputProcessor; + +final class SystemPromptService implements \Stringable +{ + public function __toString(): string + { + return 'My dynamic system prompt.'; + } +} diff --git a/tests/StructuredOutput/AgentProcessorTest.php b/tests/StructuredOutput/AgentProcessorTest.php new file mode 100644 index 0000000..85039f9 --- /dev/null +++ b/tests/StructuredOutput/AgentProcessorTest.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\StructuredOutput; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\StructuredOutput\AgentProcessor; +use Symfony\AI\Fixtures\SomeStructure; +use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; +use Symfony\AI\Fixtures\StructuredOutput\Step; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\Choice; +use Symfony\AI\Platform\Response\ObjectResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\Component\Serializer\SerializerInterface; + +#[CoversClass(AgentProcessor::class)] +#[UsesClass(Input::class)] +#[UsesClass(Output::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Choice::class)] +#[UsesClass(MissingModelSupportException::class)] +#[UsesClass(TextResponse::class)] +#[UsesClass(ObjectResponse::class)] +#[UsesClass(Model::class)] +final class AgentProcessorTest extends TestCase +{ + #[Test] + public function processInputWithOutputStructure(): void + { + $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $input = new Input($model, new MessageBag(), ['output_structure' => 'SomeStructure']); + + $processor->processInput($input); + + self::assertSame(['response_format' => ['some' => 'format']], $input->getOptions()); + } + + #[Test] + public function processInputWithoutOutputStructure(): void + { + $processor = new AgentProcessor(new ConfigurableResponseFormatFactory()); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $input = new Input($model, new MessageBag(), []); + + $processor->processInput($input); + + self::assertSame([], $input->getOptions()); + } + + #[Test] + public function processInputThrowsExceptionWhenLlmDoesNotSupportStructuredOutput(): void + { + self::expectException(MissingModelSupportException::class); + + $processor = new AgentProcessor(new ConfigurableResponseFormatFactory()); + + $model = new Model('gpt-3'); + $input = new Input($model, new MessageBag(), ['output_structure' => 'SomeStructure']); + + $processor->processInput($input); + } + + #[Test] + public function processOutputWithResponseFormat(): void + { + $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $options = ['output_structure' => SomeStructure::class]; + $input = new Input($model, new MessageBag(), $options); + $processor->processInput($input); + + $response = new TextResponse('{"some": "data"}'); + + $output = new Output($model, $response, new MessageBag(), $input->getOptions()); + + $processor->processOutput($output); + + self::assertInstanceOf(ObjectResponse::class, $output->response); + self::assertInstanceOf(SomeStructure::class, $output->response->getContent()); + self::assertSame('data', $output->response->getContent()->some); + } + + #[Test] + public function processOutputWithComplexResponseFormat(): void + { + $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $options = ['output_structure' => MathReasoning::class]; + $input = new Input($model, new MessageBag(), $options); + $processor->processInput($input); + + $response = new TextResponse(<<getOptions()); + + $processor->processOutput($output); + + self::assertInstanceOf(ObjectResponse::class, $output->response); + self::assertInstanceOf(MathReasoning::class, $structure = $output->response->getContent()); + self::assertCount(5, $structure->steps); + self::assertInstanceOf(Step::class, $structure->steps[0]); + self::assertInstanceOf(Step::class, $structure->steps[1]); + self::assertInstanceOf(Step::class, $structure->steps[2]); + self::assertInstanceOf(Step::class, $structure->steps[3]); + self::assertInstanceOf(Step::class, $structure->steps[4]); + self::assertSame('x = -3.75', $structure->finalAnswer); + } + + #[Test] + public function processOutputWithoutResponseFormat(): void + { + $responseFormatFactory = new ConfigurableResponseFormatFactory(); + $serializer = self::createMock(SerializerInterface::class); + $processor = new AgentProcessor($responseFormatFactory, $serializer); + + $model = self::createMock(Model::class); + $response = new TextResponse(''); + + $output = new Output($model, $response, new MessageBag(), []); + + $processor->processOutput($output); + + self::assertSame($response, $output->response); + } +} diff --git a/tests/StructuredOutput/ConfigurableResponseFormatFactory.php b/tests/StructuredOutput/ConfigurableResponseFormatFactory.php new file mode 100644 index 0000000..74a0bdb --- /dev/null +++ b/tests/StructuredOutput/ConfigurableResponseFormatFactory.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\StructuredOutput; + +use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactoryInterface; + +final readonly class ConfigurableResponseFormatFactory implements ResponseFormatFactoryInterface +{ + /** + * @param array $responseFormat + */ + public function __construct( + private array $responseFormat = [], + ) { + } + + public function create(string $responseClass): array + { + return $this->responseFormat; + } +} diff --git a/tests/StructuredOutput/ResponseFormatFactoryTest.php b/tests/StructuredOutput/ResponseFormatFactoryTest.php new file mode 100644 index 0000000..a687fc9 --- /dev/null +++ b/tests/StructuredOutput/ResponseFormatFactoryTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\StructuredOutput; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactory; +use Symfony\AI\Fixtures\StructuredOutput\User; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; + +#[CoversClass(ResponseFormatFactory::class)] +#[UsesClass(DescriptionParser::class)] +#[UsesClass(Factory::class)] +final class ResponseFormatFactoryTest extends TestCase +{ + #[Test] + public function create(): void + { + self::assertSame([ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'User', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user in lowercase', + ], + 'createdAt' => [ + 'type' => 'string', + 'format' => 'date-time', + ], + 'isActive' => ['type' => 'boolean'], + 'age' => ['type' => ['integer', 'null']], + ], + 'required' => ['id', 'name', 'createdAt', 'isActive'], + 'additionalProperties' => false, + ], + 'strict' => true, + ], + ], (new ResponseFormatFactory())->create(User::class)); + } +} diff --git a/tests/Toolbox/AgentProcessorTest.php b/tests/Toolbox/AgentProcessorTest.php new file mode 100644 index 0000000..7869906 --- /dev/null +++ b/tests/Toolbox/AgentProcessorTest.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(AgentProcessor::class)] +#[UsesClass(Input::class)] +#[UsesClass(Output::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(ToolCallResponse::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(MissingModelSupportException::class)] +#[UsesClass(Model::class)] +class AgentProcessorTest extends TestCase +{ + #[Test] + public function processInputWithoutRegisteredToolsWillResultInNoOptionChange(): void + { + $toolbox = $this->createStub(ToolboxInterface::class); + $toolbox->method('getTools')->willReturn([]); + + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); + $processor = new AgentProcessor($toolbox); + $input = new Input($model, new MessageBag(), []); + + $processor->processInput($input); + + self::assertSame([], $input->getOptions()); + } + + #[Test] + public function processInputWithRegisteredToolsWillResultInOptionChange(): void + { + $toolbox = $this->createStub(ToolboxInterface::class); + $tool1 = new Tool(new ExecutionReference('ClassTool1', 'method1'), 'tool1', 'description1', null); + $tool2 = new Tool(new ExecutionReference('ClassTool2', 'method1'), 'tool2', 'description2', null); + $toolbox->method('getTools')->willReturn([$tool1, $tool2]); + + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); + $processor = new AgentProcessor($toolbox); + $input = new Input($model, new MessageBag(), []); + + $processor->processInput($input); + + self::assertSame(['tools' => [$tool1, $tool2]], $input->getOptions()); + } + + #[Test] + public function processInputWithRegisteredToolsButToolOverride(): void + { + $toolbox = $this->createStub(ToolboxInterface::class); + $tool1 = new Tool(new ExecutionReference('ClassTool1', 'method1'), 'tool1', 'description1', null); + $tool2 = new Tool(new ExecutionReference('ClassTool2', 'method1'), 'tool2', 'description2', null); + $toolbox->method('getTools')->willReturn([$tool1, $tool2]); + + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); + $processor = new AgentProcessor($toolbox); + $input = new Input($model, new MessageBag(), ['tools' => ['tool2']]); + + $processor->processInput($input); + + self::assertSame(['tools' => [$tool2]], $input->getOptions()); + } + + #[Test] + public function processInputWithUnsupportedToolCallingWillThrowException(): void + { + self::expectException(MissingModelSupportException::class); + + $model = new Model('gpt-3'); + $processor = new AgentProcessor($this->createStub(ToolboxInterface::class)); + $input = new Input($model, new MessageBag(), []); + + $processor->processInput($input); + } + + #[Test] + public function processOutputWithToolCallResponseKeepingMessages(): void + { + $toolbox = $this->createMock(ToolboxInterface::class); + $toolbox->expects($this->once())->method('execute')->willReturn('Test response'); + + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); + + $messageBag = new MessageBag(); + + $response = new ToolCallResponse(new ToolCall('id1', 'tool1', ['arg1' => 'value1'])); + + $agent = $this->createStub(AgentInterface::class); + + $processor = new AgentProcessor($toolbox, keepToolMessages: true); + $processor->setAgent($agent); + + $output = new Output($model, $response, $messageBag, []); + + $processor->processOutput($output); + + self::assertCount(2, $messageBag); + self::assertInstanceOf(AssistantMessage::class, $messageBag->getMessages()[0]); + self::assertInstanceOf(ToolCallMessage::class, $messageBag->getMessages()[1]); + } + + #[Test] + public function processOutputWithToolCallResponseForgettingMessages(): void + { + $toolbox = $this->createMock(ToolboxInterface::class); + $toolbox->expects($this->once())->method('execute')->willReturn('Test response'); + + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); + + $messageBag = new MessageBag(); + + $response = new ToolCallResponse(new ToolCall('id1', 'tool1', ['arg1' => 'value1'])); + + $agent = $this->createStub(AgentInterface::class); + + $processor = new AgentProcessor($toolbox, keepToolMessages: false); + $processor->setAgent($agent); + + $output = new Output($model, $response, $messageBag, []); + + $processor->processOutput($output); + + self::assertCount(0, $messageBag); + } +} diff --git a/tests/Toolbox/Attribute/AsToolTest.php b/tests/Toolbox/Attribute/AsToolTest.php new file mode 100644 index 0000000..8d54a18 --- /dev/null +++ b/tests/Toolbox/Attribute/AsToolTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Attribute; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[CoversClass(AsTool::class)] +final class AsToolTest extends TestCase +{ + #[Test] + public function canBeConstructed(): void + { + $attribute = new AsTool( + name: 'name', + description: 'description', + ); + + self::assertSame('name', $attribute->name); + self::assertSame('description', $attribute->description); + } +} diff --git a/tests/Toolbox/FaultTolerantToolboxTest.php b/tests/Toolbox/FaultTolerantToolboxTest.php new file mode 100644 index 0000000..6df4e74 --- /dev/null +++ b/tests/Toolbox/FaultTolerantToolboxTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Agent\Toolbox\FaultTolerantToolbox; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Fixtures\Tool\ToolNoParams; +use Symfony\AI\Fixtures\Tool\ToolRequiredParams; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(FaultTolerantToolbox::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(ToolNotFoundException::class)] +#[UsesClass(ToolExecutionException::class)] +final class FaultTolerantToolboxTest extends TestCase +{ + #[Test] + public function faultyToolExecution(): void + { + $faultyToolbox = $this->createFaultyToolbox( + fn (ToolCall $toolCall) => ToolExecutionException::executionFailed($toolCall, new \Exception('error')) + ); + + $faultTolerantToolbox = new FaultTolerantToolbox($faultyToolbox); + $expected = 'An error occurred while executing tool "tool_foo".'; + + $toolCall = new ToolCall('987654321', 'tool_foo'); + $actual = $faultTolerantToolbox->execute($toolCall); + + self::assertSame($expected, $actual); + } + + #[Test] + public function faultyToolCall(): void + { + $faultyToolbox = $this->createFaultyToolbox( + fn (ToolCall $toolCall) => ToolNotFoundException::notFoundForToolCall($toolCall) + ); + + $faultTolerantToolbox = new FaultTolerantToolbox($faultyToolbox); + $expected = 'Tool "tool_xyz" was not found, please use one of these: tool_no_params, tool_required_params'; + + $toolCall = new ToolCall('123456789', 'tool_xyz'); + $actual = $faultTolerantToolbox->execute($toolCall); + + self::assertSame($expected, $actual); + } + + private function createFaultyToolbox(\Closure $exceptionFactory): ToolboxInterface + { + return new class($exceptionFactory) implements ToolboxInterface { + public function __construct(private readonly \Closure $exceptionFactory) + { + } + + /** + * @return Tool[] + */ + public function getTools(): array + { + return [ + new Tool(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), + new Tool(new ExecutionReference(ToolRequiredParams::class, 'bar'), 'tool_required_params', 'A tool with required parameters', null), + ]; + } + + public function execute(ToolCall $toolCall): mixed + { + throw ($this->exceptionFactory)($toolCall); + } + }; + } +} diff --git a/tests/Toolbox/MetadataFactory/ChainFactoryTest.php b/tests/Toolbox/MetadataFactory/ChainFactoryTest.php new file mode 100644 index 0000000..df59ce0 --- /dev/null +++ b/tests/Toolbox/MetadataFactory/ChainFactoryTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\MetadataFactory; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Medium; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException; +use Symfony\AI\Agent\Toolbox\Exception\ToolException; +use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory; +use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; +use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; +use Symfony\AI\Fixtures\Tool\ToolMisconfigured; +use Symfony\AI\Fixtures\Tool\ToolMultiple; +use Symfony\AI\Fixtures\Tool\ToolNoAttribute1; +use Symfony\AI\Fixtures\Tool\ToolOptionalParam; +use Symfony\AI\Fixtures\Tool\ToolRequiredParams; + +#[CoversClass(ChainFactory::class)] +#[Medium] +#[UsesClass(MemoryToolFactory::class)] +#[UsesClass(ReflectionToolFactory::class)] +#[UsesClass(ToolException::class)] +final class ChainFactoryTest extends TestCase +{ + private ChainFactory $factory; + + protected function setUp(): void + { + $factory1 = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute1::class, 'reference', 'A reference tool') + ->addTool(ToolOptionalParam::class, 'optional_param', 'Tool with optional param', 'bar'); + $factory2 = new ReflectionToolFactory(); + + $this->factory = new ChainFactory([$factory1, $factory2]); + } + + #[Test] + public function testGetMetadataNotExistingClass(): void + { + self::expectException(ToolException::class); + self::expectExceptionMessage('The reference "NoClass" is not a valid tool.'); + + iterator_to_array($this->factory->getTool('NoClass')); + } + + #[Test] + public function testGetMetadataNotConfiguredClass(): void + { + self::expectException(ToolConfigurationException::class); + self::expectExceptionMessage(\sprintf('Method "foo" not found in tool "%s".', ToolMisconfigured::class)); + + iterator_to_array($this->factory->getTool(ToolMisconfigured::class)); + } + + #[Test] + public function testGetMetadataWithAttributeSingleHit(): void + { + $metadata = iterator_to_array($this->factory->getTool(ToolRequiredParams::class)); + + self::assertCount(1, $metadata); + } + + #[Test] + public function testGetMetadataOverwrite(): void + { + $metadata = iterator_to_array($this->factory->getTool(ToolOptionalParam::class)); + + self::assertCount(1, $metadata); + self::assertSame('optional_param', $metadata[0]->name); + self::assertSame('Tool with optional param', $metadata[0]->description); + self::assertSame('bar', $metadata[0]->reference->method); + } + + #[Test] + public function testGetMetadataWithAttributeDoubleHit(): void + { + $metadata = iterator_to_array($this->factory->getTool(ToolMultiple::class)); + + self::assertCount(2, $metadata); + } + + #[Test] + public function testGetMetadataWithMemorySingleHit(): void + { + $metadata = iterator_to_array($this->factory->getTool(ToolNoAttribute1::class)); + + self::assertCount(1, $metadata); + } +} diff --git a/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php b/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php new file mode 100644 index 0000000..a765915 --- /dev/null +++ b/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\MetadataFactory; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolException; +use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; +use Symfony\AI\Fixtures\Tool\ToolNoAttribute1; +use Symfony\AI\Fixtures\Tool\ToolNoAttribute2; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(MemoryToolFactory::class)] +#[UsesClass(AsTool::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(ToolException::class)] +#[UsesClass(Factory::class)] +#[UsesClass(DescriptionParser::class)] +final class MemoryFactoryTest extends TestCase +{ + #[Test] + public function getMetadataWithoutTools(): void + { + self::expectException(ToolException::class); + self::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 + } + + #[Test] + public function getMetadataWithDistinctToolPerClass(): void + { + $factory = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute1::class, 'happy_birthday', 'Generates birthday message') + ->addTool(new ToolNoAttribute2(), 'checkout', 'Buys a number of items per product', 'buy'); + + $metadata = iterator_to_array($factory->getTool(ToolNoAttribute1::class)); + + self::assertCount(1, $metadata); + self::assertInstanceOf(Tool::class, $metadata[0]); + self::assertSame('happy_birthday', $metadata[0]->name); + self::assertSame('Generates birthday message', $metadata[0]->description); + self::assertSame('__invoke', $metadata[0]->reference->method); + + $expectedParams = [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'the name of the person'], + 'years' => ['type' => 'integer', 'description' => 'the age of the person'], + ], + 'required' => ['name', 'years'], + 'additionalProperties' => false, + ]; + + self::assertSame($expectedParams, $metadata[0]->parameters); + } + + #[Test] + public function getMetadataWithMultipleToolsInClass(): void + { + $factory = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute2::class, 'checkout', 'Buys a number of items per product', 'buy') + ->addTool(ToolNoAttribute2::class, 'cancel', 'Cancels an order', 'cancel'); + + $metadata = iterator_to_array($factory->getTool(ToolNoAttribute2::class)); + + self::assertCount(2, $metadata); + self::assertInstanceOf(Tool::class, $metadata[0]); + self::assertSame('checkout', $metadata[0]->name); + self::assertSame('Buys a number of items per product', $metadata[0]->description); + self::assertSame('buy', $metadata[0]->reference->method); + + $expectedParams = [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'the ID of the product'], + 'amount' => ['type' => 'integer', 'description' => 'the number of products'], + ], + 'required' => ['id', 'amount'], + 'additionalProperties' => false, + ]; + self::assertSame($expectedParams, $metadata[0]->parameters); + + self::assertInstanceOf(Tool::class, $metadata[1]); + self::assertSame('cancel', $metadata[1]->name); + self::assertSame('Cancels an order', $metadata[1]->description); + self::assertSame('cancel', $metadata[1]->reference->method); + + $expectedParams = [ + 'type' => 'object', + 'properties' => [ + 'orderId' => ['type' => 'string', 'description' => 'the ID of the order'], + ], + 'required' => ['orderId'], + 'additionalProperties' => false, + ]; + self::assertSame($expectedParams, $metadata[1]->parameters); + } +} diff --git a/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php b/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php new file mode 100644 index 0000000..26005ff --- /dev/null +++ b/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\MetadataFactory; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +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\ToolFactory\ReflectionToolFactory; +use Symfony\AI\Fixtures\Tool\ToolMultiple; +use Symfony\AI\Fixtures\Tool\ToolRequiredParams; +use Symfony\AI\Fixtures\Tool\ToolWrong; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(ReflectionToolFactory::class)] +#[UsesClass(AsTool::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(Factory::class)] +#[UsesClass(DescriptionParser::class)] +#[UsesClass(ToolConfigurationException::class)] +#[UsesClass(ToolException::class)] +final class ReflectionFactoryTest extends TestCase +{ + private ReflectionToolFactory $factory; + + protected function setUp(): void + { + $this->factory = new ReflectionToolFactory(); + } + + #[Test] + public function invalidReferenceNonExistingClass(): void + { + self::expectException(ToolException::class); + self::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 + } + + #[Test] + public function withoutAttribute(): void + { + self::expectException(ToolException::class); + self::expectExceptionMessage(\sprintf('The class "%s" is not a tool, please add %s attribute.', ToolWrong::class, AsTool::class)); + + iterator_to_array($this->factory->getTool(ToolWrong::class)); + } + + #[Test] + public function getDefinition(): void + { + /** @var Tool[] $metadatas */ + $metadatas = iterator_to_array($this->factory->getTool(ToolRequiredParams::class)); + + self::assertToolConfiguration( + metadata: $metadatas[0], + className: ToolRequiredParams::class, + name: 'tool_required_params', + description: 'A tool with required parameters', + method: 'bar', + parameters: [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ); + } + + #[Test] + public function getDefinitionWithMultiple(): void + { + $metadatas = iterator_to_array($this->factory->getTool(ToolMultiple::class)); + + self::assertCount(2, $metadatas); + + [$first, $second] = $metadatas; + + self::assertToolConfiguration( + metadata: $first, + className: ToolMultiple::class, + name: 'tool_hello_world', + description: 'Function to say hello', + method: 'hello', + parameters: [ + 'type' => 'object', + 'properties' => [ + 'world' => [ + 'type' => 'string', + 'description' => 'The world to say hello to', + ], + ], + 'required' => ['world'], + 'additionalProperties' => false, + ], + ); + + self::assertToolConfiguration( + metadata: $second, + className: ToolMultiple::class, + name: 'tool_required_params', + description: 'Function to say a number', + method: 'bar', + parameters: [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ); + } + + private function assertToolConfiguration(Tool $metadata, string $className, string $name, string $description, string $method, array $parameters): void + { + self::assertSame($className, $metadata->reference->class); + self::assertSame($method, $metadata->reference->method); + self::assertSame($name, $metadata->name); + self::assertSame($description, $metadata->description); + self::assertSame($parameters, $metadata->parameters); + } +} diff --git a/tests/Toolbox/Tool/BraveTest.php b/tests/Toolbox/Tool/BraveTest.php new file mode 100644 index 0000000..730fbe2 --- /dev/null +++ b/tests/Toolbox/Tool/BraveTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Tool; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Tool\Brave; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(Brave::class)] +final class BraveTest extends TestCase +{ + #[Test] + public function returnsSearchResults(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/brave.json'); + $httpClient = new MockHttpClient($response); + $brave = new Brave($httpClient, 'test-api-key'); + + $results = $brave('latest Dallas Cowboys game result'); + + self::assertCount(5, $results); + self::assertArrayHasKey('title', $results[0]); + self::assertSame('Dallas Cowboys Scores, Stats and Highlights - ESPN', $results[0]['title']); + self::assertArrayHasKey('description', $results[0]); + self::assertSame('Visit ESPN for Dallas Cowboys live scores, video highlights, and latest news. Find standings and the full 2024 season schedule.', $results[0]['description']); + self::assertArrayHasKey('url', $results[0]); + self::assertSame('https://www.espn.com/nfl/team/_/name/dal/dallas-cowboys', $results[0]['url']); + } + + #[Test] + public function passesCorrectParametersToApi(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/brave.json'); + $httpClient = new MockHttpClient($response); + $brave = new Brave($httpClient, 'test-api-key', ['extra' => 'option']); + + $brave('test query', 10, 5); + + $request = $response->getRequestUrl(); + self::assertStringContainsString('q=test%20query', $request); + self::assertStringContainsString('count=10', $request); + self::assertStringContainsString('offset=5', $request); + self::assertStringContainsString('extra=option', $request); + + $requestOptions = $response->getRequestOptions(); + self::assertArrayHasKey('headers', $requestOptions); + self::assertContains('X-Subscription-Token: test-api-key', $requestOptions['headers']); + } + + #[Test] + public function handlesEmptyResults(): void + { + $response = new MockResponse(json_encode(['web' => ['results' => []]])); + $httpClient = new MockHttpClient($response); + $brave = new Brave($httpClient, 'test-api-key'); + + $results = $brave('this should return nothing'); + + self::assertEmpty($results); + } + + /** + * This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4. + */ + private function jsonMockResponseFromFile(string $file): JsonMockResponse + { + return new JsonMockResponse(json_decode(file_get_contents($file), true)); + } +} diff --git a/tests/Toolbox/Tool/OpenMeteoTest.php b/tests/Toolbox/Tool/OpenMeteoTest.php new file mode 100644 index 0000000..6a9050b --- /dev/null +++ b/tests/Toolbox/Tool/OpenMeteoTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Tool; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Tool\OpenMeteo; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(OpenMeteo::class)] +final class OpenMeteoTest extends TestCase +{ + #[Test] + public function current(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/openmeteo-current.json'); + $httpClient = new MockHttpClient($response); + + $openMeteo = new OpenMeteo($httpClient); + + $actual = $openMeteo->current(52.52, 13.42); + $expected = [ + 'weather' => 'Overcast', + 'time' => '2024-12-21T01:15', + 'temperature' => '2.6°C', + 'wind_speed' => '10.7km/h', + ]; + + static::assertSame($expected, $actual); + } + + #[Test] + public function forecast(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/openmeteo-forecast.json'); + $httpClient = new MockHttpClient($response); + + $openMeteo = new OpenMeteo($httpClient); + + $actual = $openMeteo->forecast(52.52, 13.42, 3); + $expected = [ + [ + 'weather' => 'Light Rain', + 'time' => '2024-12-21', + 'temperature_min' => '2°C', + 'temperature_max' => '6°C', + ], + [ + 'weather' => 'Light Showers', + 'time' => '2024-12-22', + 'temperature_min' => '1.3°C', + 'temperature_max' => '6.4°C', + ], + [ + 'weather' => 'Light Snow Showers', + 'time' => '2024-12-23', + 'temperature_min' => '1.5°C', + 'temperature_max' => '4.1°C', + ], + ]; + + static::assertSame($expected, $actual); + } + + /** + * This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4. + */ + private function jsonMockResponseFromFile(string $file): JsonMockResponse + { + return new JsonMockResponse(json_decode(file_get_contents($file), true)); + } +} diff --git a/tests/Toolbox/Tool/WikipediaTest.php b/tests/Toolbox/Tool/WikipediaTest.php new file mode 100644 index 0000000..1547eb9 --- /dev/null +++ b/tests/Toolbox/Tool/WikipediaTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Tool; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Tool\Wikipedia; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(Wikipedia::class)] +final class WikipediaTest extends TestCase +{ + #[Test] + public function searchWithResults(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-search-result.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->search('current secretary of the united nations'); + $expected = <<jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-search-empty.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->search('weird questions without results'); + $expected = 'No articles were found on Wikipedia.'; + + static::assertSame($expected, $actual); + } + + #[Test] + public function articleWithResult(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-article.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->article('Secretary-General of the United Nations'); + $expected = <<jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-article-redirect.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->article('United Nations secretary-general'); + $expected = <<jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-article-missing.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->article('Blah blah blah'); + $expected = 'No article with title "Blah blah blah" was found on Wikipedia.'; + + static::assertSame($expected, $actual); + } + + /** + * This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4. + */ + private function jsonMockResponseFromFile(string $file): JsonMockResponse + { + return new JsonMockResponse(json_decode(file_get_contents($file), true)); + } +} diff --git a/tests/Toolbox/Tool/fixtures/brave.json b/tests/Toolbox/Tool/fixtures/brave.json new file mode 100644 index 0000000..d879338 --- /dev/null +++ b/tests/Toolbox/Tool/fixtures/brave.json @@ -0,0 +1,276 @@ +{ + "query": { + "original": "latest Dallas Cowboys game result", + "show_strict_warning": false, + "is_navigational": false, + "is_news_breaking": false, + "spellcheck_off": true, + "country": "us", + "bad_results": false, + "should_fallback": false, + "postal_code": "", + "city": "", + "header_country": "", + "more_results_available": true, + "state": "" + }, + "mixed": { + "type": "mixed", + "main": [ + { + "type": "web", + "index": 0, + "all": false + }, + { + "type": "web", + "index": 1, + "all": false + }, + { + "type": "web", + "index": 2, + "all": false + }, + { + "type": "web", + "index": 3, + "all": false + }, + { + "type": "web", + "index": 4, + "all": false + }, + { + "type": "web", + "index": 5, + "all": false + }, + { + "type": "web", + "index": 6, + "all": false + }, + { + "type": "web", + "index": 7, + "all": false + }, + { + "type": "web", + "index": 8, + "all": false + }, + { + "type": "web", + "index": 9, + "all": false + }, + { + "type": "web", + "index": 10, + "all": false + }, + { + "type": "web", + "index": 11, + "all": false + }, + { + "type": "web", + "index": 12, + "all": false + }, + { + "type": "web", + "index": 13, + "all": false + }, + { + "type": "web", + "index": 14, + "all": false + }, + { + "type": "web", + "index": 15, + "all": false + }, + { + "type": "web", + "index": 16, + "all": false + }, + { + "type": "web", + "index": 17, + "all": false + }, + { + "type": "web", + "index": 18, + "all": false + } + ], + "top": [], + "side": [] + }, + "type": "search", + "web": { + "type": "search", + "results": [ + { + "title": "Dallas Cowboys Scores, Stats and Highlights - ESPN", + "url": "https://www.espn.com/nfl/team/_/name/dal/dallas-cowboys", + "is_source_local": false, + "is_source_both": false, + "description": "Visit ESPN for Dallas Cowboys live scores, video highlights, and latest news. Find standings and the full 2024 season schedule.", + "profile": { + "name": "ESPN", + "url": "https://www.espn.com/nfl/team/_/name/dal/dallas-cowboys", + "long_name": "Entertainment and Sports Programming Network", + "img": "https://imgs.search.brave.com/Kz1hWnjcBXLBXExGU0hCyCn2-pB94hTqPkqNv2qL9Ds/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvM2MzMjQzYzM4/MGZiMjZlNDJlY2Iy/ZjM1N2RjZjMxYzhk/YWNiNmVlMGViMDRl/ZGVhYzJjMzI4OTkz/NDY0MGI4MS93d3cu/ZXNwbi5jb20v" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "espn.com", + "hostname": "www.espn.com", + "favicon": "https://imgs.search.brave.com/Kz1hWnjcBXLBXExGU0hCyCn2-pB94hTqPkqNv2qL9Ds/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvM2MzMjQzYzM4/MGZiMjZlNDJlY2Iy/ZjM1N2RjZjMxYzhk/YWNiNmVlMGViMDRl/ZGVhYzJjMzI4OTkz/NDY0MGI4MS93d3cu/ZXNwbi5jb20v", + "path": "› nfl › team › _ › name › dal › dallas-cowboys" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/yoylqmAf8Idap3k8AvVgIN8VAnBC3qYLTIPUhr-dngk/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9hLmVz/cG5jZG4uY29tL2Nv/bWJpbmVyL2k_aW1n/PS9pL3RlYW1sb2dv/cy9uZmwvNTAwL2Rh/bC5wbmc", + "original": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/nfl/500/dal.png", + "logo": true + } + }, + { + "title": "Dallas Cowboys | Official Site of the Dallas Cowboys", + "url": "https://www.dallascowboys.com/", + "is_source_local": false, + "is_source_both": false, + "description": "As Mickey Spagnola writes in his Friday column, the Cowboys have to be ready for anything in this upcoming draft as the best-laid plans can often go awry. In this week's Mick Shots, Mickey Spagnola looks at how depth options could play a part on who to pick at No. 12. Plus, a closer look at ...", + "profile": { + "name": "Dallascowboys", + "url": "https://www.dallascowboys.com/", + "long_name": "dallascowboys.com", + "img": "https://imgs.search.brave.com/jlBcXKzEJ8ZVEM7kjduly5zdZdDd3ZKJa3KtSH06rxk/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvODk0YjBmYmE0/N2E2ZmM4NjgxYzI2/ZmZmMWMxODE3YTMz/MmM5YmQ4MDBkZmM3/NjFiOWNlYzczMGUz/OTg3NWRhZi93d3cu/ZGFsbGFzY293Ym95/cy5jb20v" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "dallascowboys.com", + "hostname": "www.dallascowboys.com", + "favicon": "https://imgs.search.brave.com/jlBcXKzEJ8ZVEM7kjduly5zdZdDd3ZKJa3KtSH06rxk/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvODk0YjBmYmE0/N2E2ZmM4NjgxYzI2/ZmZmMWMxODE3YTMz/MmM5YmQ4MDBkZmM3/NjFiOWNlYzczMGUz/OTg3NWRhZi93d3cu/ZGFsbGFzY293Ym95/cy5jb20v", + "path": "" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/IfNsWGGP4OLU1pxkWNTYIeZtTxjykyoRTWTaYkKDPU0/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly93d3cu/ZGFsbGFzY293Ym95/cy5jb20v", + "original": "https://www.dallascowboys.com/", + "logo": false + } + }, + { + "title": "Dallas Cowboys News, Scores, Status, Schedule - NFL - CBSSports.com", + "url": "https://www.cbssports.com/nfl/teams/DAL/dallas-cowboys/", + "is_source_local": false, + "is_source_both": false, + "description": "Get the latest news and information for the Dallas Cowboys. 2024 season schedule, scores, stats, and highlights. Find out the latest on your favorite NFL teams on CBSSports.com.", + "profile": { + "name": "Cbssports", + "url": "https://www.cbssports.com/nfl/teams/DAL/dallas-cowboys/", + "long_name": "cbssports.com", + "img": "https://imgs.search.brave.com/G8DEk0_A87RxEMyNA8Uhu5GaN1usv62iX_74SwwTHSk/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvM2FlZjEzYmM3/NzkwMzQ5ZWYwMWQ3/YjJiZGM5MGMxMWFl/ZDBlNmQxMTk2N2Fm/MjljMzU2OGIzMTUz/M2Q4ZjcxNS93d3cu/Y2Jzc3BvcnRzLmNv/bS8" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "cbssports.com", + "hostname": "www.cbssports.com", + "favicon": "https://imgs.search.brave.com/G8DEk0_A87RxEMyNA8Uhu5GaN1usv62iX_74SwwTHSk/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvM2FlZjEzYmM3/NzkwMzQ5ZWYwMWQ3/YjJiZGM5MGMxMWFl/ZDBlNmQxMTk2N2Fm/MjljMzU2OGIzMTUz/M2Q4ZjcxNS93d3cu/Y2Jzc3BvcnRzLmNv/bS8", + "path": "› nfl › teams › DAL › dallas-cowboys" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/9YB61Wfb-DCqHUR_XH_Tq7Iwy9KW9qCjCaO0bKpQ_bU/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9zcG9y/dHNmbHkuY2JzaXN0/YXRpYy5jb20vZmx5/LTA5NDQvYnVuZGxl/cy9zcG9ydHNtZWRp/YWNzcy9pbWFnZXMv/ZmFudGFzeS9kZWZh/dWx0LWFydGljbGUt/aW1hZ2UtbGFyZ2Uu/cG5n", + "original": "https://sportsfly.cbsistatic.com/fly-0944/bundles/sportsmediacss/images/fantasy/default-article-image-large.png", + "logo": false + } + }, + { + "title": "Dallas Cowboys News, Scores, Stats, Schedule | NFL.com", + "url": "https://www.nfl.com/teams/dallas-cowboys/", + "is_source_local": false, + "is_source_both": false, + "description": "Dallas Cowboys · 3rd NFC East · 7 - 10 - 0 Buy Gear · Official Website · @DallasCowboys · @DallasCowboys · @dallascowboys · @DallasCowboys · Info · Roster · Stats · Advertising · news · Apr 21, 2025 · news · Apr 18, 2025 · news · Apr 18, 2025 ·", + "profile": { + "name": "Nfl", + "url": "https://www.nfl.com/teams/dallas-cowboys/", + "long_name": "nfl.com", + "img": "https://imgs.search.brave.com/L4B2SCyb0Ao1-76nGZVpWlnyS8TkQBAEFnf4Lpb_KRY/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZmQxNWFiOGQ3/Mjc0MWRjYjI0OTQx/N2E5NTNhODVkMGQ2/OTI3NzcyODQ4MzU2/Nzg3YTJmMjJiOGMz/OTM1NjVmYy93d3cu/bmZsLmNvbS8" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "nfl.com", + "hostname": "www.nfl.com", + "favicon": "https://imgs.search.brave.com/L4B2SCyb0Ao1-76nGZVpWlnyS8TkQBAEFnf4Lpb_KRY/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZmQxNWFiOGQ3/Mjc0MWRjYjI0OTQx/N2E5NTNhODVkMGQ2/OTI3NzcyODQ4MzU2/Nzg3YTJmMjJiOGMz/OTM1NjVmYy93d3cu/bmZsLmNvbS8", + "path": "› teams › dallas-cowboys" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/0EETrH0-svPjjtTCtvV7kIQ5DuPcD03NtVFgNG8A2KM/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9zdGF0/aWMud3d3Lm5mbC5j/b20vdF9oZWFkc2hv/dF9kZXNrdG9wL2xl/YWd1ZS9hcGkvY2x1/YnMvbG9nb3MvREFM", + "original": "https://static.www.nfl.com/t_headshot_desktop/league/api/clubs/logos/DAL", + "logo": true + } + }, + { + "title": "Dallas Cowboys News, Videos, Schedule, Roster, Stats - Yahoo Sports", + "url": "https://sports.yahoo.com/nfl/teams/dallas/", + "is_source_local": false, + "is_source_both": false, + "description": "The team has 10 selections to get right in hopes of turning a 7-10 season in 2024 into an afterthought, and helping the Cowboys return to the playoffs. The good news is that Dallas has been one of the better drafting teams in the league, and they routinely find the right players to help win games.", + "profile": { + "name": "Yahoo!", + "url": "https://sports.yahoo.com/nfl/teams/dallas/", + "long_name": "sports.yahoo.com", + "img": "https://imgs.search.brave.com/pqn4FSmuomyVnDi_JumaeN-Milit-_D15P8bquK1CAc/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTUxYWU5NWYz/ZWIxOGI3M2Q5MzFh/MjlmZDczOWEyMzY5/M2FhZTZiOGIzOTQ0/YzlkMGI3YTI2MmM2/ZmJmMWE2Zi9zcG9y/dHMueWFob28uY29t/Lw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "sports.yahoo.com", + "hostname": "sports.yahoo.com", + "favicon": "https://imgs.search.brave.com/pqn4FSmuomyVnDi_JumaeN-Milit-_D15P8bquK1CAc/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTUxYWU5NWYz/ZWIxOGI3M2Q5MzFh/MjlmZDczOWEyMzY5/M2FhZTZiOGIzOTQ0/YzlkMGI3YTI2MmM2/ZmJmMWE2Zi9zcG9y/dHMueWFob28uY29t/Lw", + "path": "› nfl › teams › dallas" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/okMqy3l4NCCt72TSaYUZSdJcmgHc9Q1i63ttIe-rIQ0/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9zLnlp/bWcuY29tL2l0L2Fw/aS9yZXMvMS4yL3ZX/bjVTVDlRQl95NmFi/dWl2cWdpQkEtLX5B/L1lYQndhV1E5ZVc1/bGQzTTdkejB4TWpB/d08yZzlOak13TzNF/OU1UQXcvaHR0cHM6/Ly9zLnlpbWcuY29t/L2N2L2FwaXYyL2Rl/ZmF1bHQvbmZsLzIw/MTkwNzI0LzUwMHg1/MDAvMjAxOV9EQUxf/d2JnLnBuZw", + "original": "https://s.yimg.com/it/api/res/1.2/vWn5ST9QB_y6abuivqgiBA--~A/YXBwaWQ9eW5ld3M7dz0xMjAwO2g9NjMwO3E9MTAw/https://s.yimg.com/cv/apiv2/default/nfl/20190724/500x500/2019_DAL_wbg.png", + "logo": false + } + } + ], + "family_friendly": true + } +} diff --git a/tests/Toolbox/Tool/fixtures/openmeteo-current.json b/tests/Toolbox/Tool/fixtures/openmeteo-current.json new file mode 100644 index 0000000..16d6cb2 --- /dev/null +++ b/tests/Toolbox/Tool/fixtures/openmeteo-current.json @@ -0,0 +1,23 @@ +{ + "latitude": 52.52, + "longitude": 13.419998, + "generationtime_ms": 0.06508827209472656, + "utc_offset_seconds": 0, + "timezone": "GMT", + "timezone_abbreviation": "GMT", + "elevation": 40.0, + "current_units": { + "time": "iso8601", + "interval": "seconds", + "weather_code": "wmo code", + "temperature_2m": "°C", + "wind_speed_10m": "km/h" + }, + "current": { + "time": "2024-12-21T01:15", + "interval": 900, + "weather_code": 3, + "temperature_2m": 2.6, + "wind_speed_10m": 10.7 + } +} diff --git a/tests/Toolbox/Tool/fixtures/openmeteo-forecast.json b/tests/Toolbox/Tool/fixtures/openmeteo-forecast.json new file mode 100644 index 0000000..beb4e14 --- /dev/null +++ b/tests/Toolbox/Tool/fixtures/openmeteo-forecast.json @@ -0,0 +1,37 @@ +{ + "latitude": 52.52, + "longitude": 13.419998, + "generationtime_ms": 0.0629425048828125, + "utc_offset_seconds": 0, + "timezone": "GMT", + "timezone_abbreviation": "GMT", + "elevation": 38.0, + "daily_units": { + "time": "iso8601", + "weather_code": "wmo code", + "temperature_2m_max": "°C", + "temperature_2m_min": "°C" + }, + "daily": { + "time": [ + "2024-12-21", + "2024-12-22", + "2024-12-23" + ], + "weather_code": [ + 61, + 80, + 85 + ], + "temperature_2m_max": [ + 6.0, + 6.4, + 4.1 + ], + "temperature_2m_min": [ + 2.0, + 1.3, + 1.5 + ] + } +} diff --git a/tests/Toolbox/Tool/fixtures/wikipedia-article-missing.json b/tests/Toolbox/Tool/fixtures/wikipedia-article-missing.json new file mode 100644 index 0000000..2bea603 --- /dev/null +++ b/tests/Toolbox/Tool/fixtures/wikipedia-article-missing.json @@ -0,0 +1,16 @@ +{ + "batchcomplete": "", + "query": { + "pages": { + "-1": { + "ns": 0, + "title": "Blah blah blah", + "missing": "", + "contentmodel": "wikitext", + "pagelanguage": "en", + "pagelanguagehtmlcode": "en", + "pagelanguagedir": "ltr" + } + } + } +} diff --git a/tests/Toolbox/Tool/fixtures/wikipedia-article-redirect.json b/tests/Toolbox/Tool/fixtures/wikipedia-article-redirect.json new file mode 100644 index 0000000..01175cd --- /dev/null +++ b/tests/Toolbox/Tool/fixtures/wikipedia-article-redirect.json @@ -0,0 +1,32 @@ +{ + "batchcomplete": "", + "query": { + "redirects": [ + { + "from": "United Nations secretary-general", + "to": "Secretary-General of the United Nations" + } + ], + "pages": { + "162415": { + "pageid": 162415, + "ns": 0, + "title": "Secretary-General of the United Nations", + "extract": "The secretary-general of the United Nations (UNSG or UNSECGEN) is the chief administrative officer of the United Nations and head of the United Nations Secretariat, one of the six principal organs of the United Nations. And so on.", + "contentmodel": "wikitext", + "pagelanguage": "en", + "pagelanguagehtmlcode": "en", + "pagelanguagedir": "ltr", + "touched": "2024-12-07T14:43:16Z", + "lastrevid": 1259468323, + "length": 35508, + "thumbnail": { + "source": "https:\/\/upload.wikimedia.org\/wikipedia\/commons\/thumb\/5\/52\/Emblem_of_the_United_Nations.svg\/50px-Emblem_of_the_United_Nations.svg.png", + "width": 50, + "height": 43 + }, + "pageimage": "Emblem_of_the_United_Nations.svg" + } + } + } +} diff --git a/tests/Toolbox/Tool/fixtures/wikipedia-article.json b/tests/Toolbox/Tool/fixtures/wikipedia-article.json new file mode 100644 index 0000000..6275e92 --- /dev/null +++ b/tests/Toolbox/Tool/fixtures/wikipedia-article.json @@ -0,0 +1,26 @@ +{ + "batchcomplete": "", + "query": { + "pages": { + "162415": { + "pageid": 162415, + "ns": 0, + "title": "Secretary-General of the United Nations", + "extract": "The secretary-general of the United Nations (UNSG or UNSECGEN) is the chief administrative officer of the United Nations and head of the United Nations Secretariat, one of the six principal organs of the United Nations. And so on.", + "contentmodel": "wikitext", + "pagelanguage": "en", + "pagelanguagehtmlcode": "en", + "pagelanguagedir": "ltr", + "touched": "2024-12-07T14:43:16Z", + "lastrevid": 1259468323, + "length": 35508, + "thumbnail": { + "source": "https:\/\/upload.wikimedia.org\/wikipedia\/commons\/thumb\/5\/52\/Emblem_of_the_United_Nations.svg\/50px-Emblem_of_the_United_Nations.svg.png", + "width": 50, + "height": 43 + }, + "pageimage": "Emblem_of_the_United_Nations.svg" + } + } + } +} diff --git a/tests/Toolbox/Tool/fixtures/wikipedia-search-empty.json b/tests/Toolbox/Tool/fixtures/wikipedia-search-empty.json new file mode 100644 index 0000000..3a547ec --- /dev/null +++ b/tests/Toolbox/Tool/fixtures/wikipedia-search-empty.json @@ -0,0 +1,11 @@ +{ + "batchcomplete": "", + "query": { + "searchinfo": { + "totalhits": 0 + }, + "search": [ + + ] + } +} diff --git a/tests/Toolbox/Tool/fixtures/wikipedia-search-result.json b/tests/Toolbox/Tool/fixtures/wikipedia-search-result.json new file mode 100644 index 0000000..20c7254 --- /dev/null +++ b/tests/Toolbox/Tool/fixtures/wikipedia-search-result.json @@ -0,0 +1,104 @@ +{ + "batchcomplete": "", + "continue": { + "sroffset": 10, + "continue": "-||" + }, + "query": { + "searchinfo": { + "totalhits": 27227 + }, + "search": [ + { + "ns": 0, + "title": "Under-Secretary-General of the United Nations", + "pageid": 3223434, + "size": 15971, + "wordcount": 1569, + "snippet": "An under-secretary-general of the United Nations (USG) is a senior official within the United Nations System, normally appointed by the General Assembly", + "timestamp": "2024-11-28T08:11:08Z" + }, + { + "ns": 0, + "title": "United Nations secretary-general selection", + "pageid": 52735558, + "size": 36343, + "wordcount": 4335, + "snippet": "United Nations secretary-general selection is the process of selecting the next secretary-general of the United Nations. To be selected as secretary-general", + "timestamp": "2024-08-23T07:56:28Z" + }, + { + "ns": 0, + "title": "List of current permanent representatives to the United Nations", + "pageid": 4409476, + "size": 47048, + "wordcount": 1524, + "snippet": "is a list of the current permanent representatives to the United Nations at United Nations Headquarters, New York City. The list includes the country that", + "timestamp": "2024-11-11T03:04:58Z" + }, + { + "ns": 0, + "title": "United Nations", + "pageid": 31769, + "size": 173187, + "wordcount": 15417, + "snippet": "The United Nations (UN) is a diplomatic and political international organization with the intended purpose of maintaining international peace and security", + "timestamp": "2024-11-30T10:50:21Z" + }, + { + "ns": 0, + "title": "United Nations Secretariat", + "pageid": 162410, + "size": 23340, + "wordcount": 2389, + "snippet": "The United Nations Secretariat is one of the six principal organs of the United Nations (UN), The secretariat is the UN's executive arm. The secretariat", + "timestamp": "2024-10-07T15:57:54Z" + }, + { + "ns": 0, + "title": "Flag of the United Nations", + "pageid": 565612, + "size": 18615, + "wordcount": 1219, + "snippet": "The flag of the United Nations is a sky blue banner containing the United Nations' emblem in the centre. The emblem on the flag is coloured white; it is", + "timestamp": "2024-09-26T02:16:57Z" + }, + { + "ns": 0, + "title": "List of current members of the United States House of Representatives", + "pageid": 12498224, + "size": 262590, + "wordcount": 1704, + "snippet": "in the United States House of Representatives List of current United States senators List of members of the United States Congress by longevity of service", + "timestamp": "2024-12-06T14:58:13Z" + }, + { + "ns": 0, + "title": "Member states of the United Nations", + "pageid": 31969, + "size": 107893, + "wordcount": 8436, + "snippet": "The member states of the United Nations comprise 193 sovereign states. The United Nations (UN) is the world's largest intergovernmental organization.", + "timestamp": "2024-12-08T15:19:12Z" + }, + { + "ns": 0, + "title": "Official languages of the United Nations", + "pageid": 25948712, + "size": 57104, + "wordcount": 4976, + "snippet": "The official languages of the United Nations are the six languages used in United Nations (UN) meetings and in which the UN writes all its official documents", + "timestamp": "2024-11-11T13:41:37Z" + }, + { + "ns": 0, + "title": "United States Secretary of State", + "pageid": 32293, + "size": 18112, + "wordcount": 1513, + "snippet": "The United States secretary of state (SecState) is a member of the executive branch of the federal government and the head of the Department of State", + "timestamp": "2024-12-01T17:58:13Z" + } + ] + } +} diff --git a/tests/Toolbox/ToolResultConverterTest.php b/tests/Toolbox/ToolResultConverterTest.php new file mode 100644 index 0000000..4a4c415 --- /dev/null +++ b/tests/Toolbox/ToolResultConverterTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\ToolResultConverter; + +#[CoversClass(ToolResultConverter::class)] +final class ToolResultConverterTest extends TestCase +{ + #[Test] + #[DataProvider('provideResults')] + public function testConvert(mixed $result, ?string $expected): void + { + $converter = new ToolResultConverter(); + + self::assertSame($expected, $converter->convert($result)); + } + + public static function provideResults(): \Generator + { + yield 'null' => [null, null]; + + yield 'integer' => [42, '42']; + + yield 'float' => [42.42, '42.42']; + + yield 'array' => [['key' => 'value'], '{"key":"value"}']; + + yield 'string' => ['plain string', 'plain string']; + + yield 'datetime' => [new \DateTimeImmutable('2021-07-31 12:34:56'), '2021-07-31T12:34:56+00:00']; + + yield 'stringable' => [ + new class implements \Stringable { + public function __toString(): string + { + return 'stringable'; + } + }, + 'stringable', + ]; + + yield 'json_serializable' => [ + new class implements \JsonSerializable { + public function jsonSerialize(): array + { + return ['key' => 'value']; + } + }, + '{"key":"value"}', + ]; + } +} diff --git a/tests/Toolbox/ToolboxTest.php b/tests/Toolbox/ToolboxTest.php new file mode 100644 index 0000000..72e1b54 --- /dev/null +++ b/tests/Toolbox/ToolboxTest.php @@ -0,0 +1,265 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException; +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +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\Fixtures\Tool\ToolException; +use Symfony\AI\Fixtures\Tool\ToolMisconfigured; +use Symfony\AI\Fixtures\Tool\ToolNoAttribute1; +use Symfony\AI\Fixtures\Tool\ToolNoParams; +use Symfony\AI\Fixtures\Tool\ToolOptionalParam; +use Symfony\AI\Fixtures\Tool\ToolRequiredParams; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(Toolbox::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(AsTool::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(ReflectionToolFactory::class)] +#[UsesClass(MemoryToolFactory::class)] +#[UsesClass(ChainFactory::class)] +#[UsesClass(Factory::class)] +#[UsesClass(DescriptionParser::class)] +#[UsesClass(ToolConfigurationException::class)] +#[UsesClass(ToolNotFoundException::class)] +#[UsesClass(ToolExecutionException::class)] +final class ToolboxTest extends TestCase +{ + private Toolbox $toolbox; + + protected function setUp(): void + { + $this->toolbox = new Toolbox(new ReflectionToolFactory(), [ + new ToolRequiredParams(), + new ToolOptionalParam(), + new ToolNoParams(), + new ToolException(), + ]); + } + + #[Test] + public function getTools(): void + { + $actual = $this->toolbox->getTools(); + + $toolRequiredParams = new Tool( + new ExecutionReference(ToolRequiredParams::class, 'bar'), + 'tool_required_params', + 'A tool with required parameters', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ); + + $toolOptionalParam = new Tool( + new ExecutionReference(ToolOptionalParam::class, 'bar'), + 'tool_optional_param', + 'A tool with one optional parameter', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text'], + 'additionalProperties' => false, + ], + ); + + $toolNoParams = new Tool( + new ExecutionReference(ToolNoParams::class), + 'tool_no_params', + 'A tool without parameters', + ); + + $toolException = new Tool( + new ExecutionReference(ToolException::class, 'bar'), + 'tool_exception', + 'This tool is broken', + ); + + $expected = [ + $toolRequiredParams, + $toolOptionalParam, + $toolNoParams, + $toolException, + ]; + + self::assertEquals($expected, $actual); + } + + #[Test] + public function executeWithUnknownTool(): void + { + self::expectException(ToolNotFoundException::class); + self::expectExceptionMessage('Tool not found for call: foo_bar_baz'); + + $this->toolbox->execute(new ToolCall('call_1234', 'foo_bar_baz')); + } + + #[Test] + public function executeWithMisconfiguredTool(): void + { + self::expectException(ToolConfigurationException::class); + self::expectExceptionMessage('Method "foo" not found in tool "Symfony\AI\Fixtures\Tool\ToolMisconfigured".'); + + $toolbox = new Toolbox(new ReflectionToolFactory(), [new ToolMisconfigured()]); + + $toolbox->execute(new ToolCall('call_1234', 'tool_misconfigured')); + } + + #[Test] + public function executeWithException(): void + { + self::expectException(ToolExecutionException::class); + self::expectExceptionMessage('Execution of tool "tool_exception" failed with error: Tool error.'); + + $this->toolbox->execute(new ToolCall('call_1234', 'tool_exception')); + } + + #[Test] + #[DataProvider('executeProvider')] + public function execute(string $expected, string $toolName, array $toolPayload = []): void + { + self::assertSame( + $expected, + $this->toolbox->execute(new ToolCall('call_1234', $toolName, $toolPayload)), + ); + } + + /** + * @return iterable + */ + public static function executeProvider(): iterable + { + yield 'tool_required_params' => [ + 'Hello says "3".', + 'tool_required_params', + ['text' => 'Hello', 'number' => 3], + ]; + } + + #[Test] + public function toolboxMapWithMemoryFactory(): void + { + $memoryFactory = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute1::class, 'happy_birthday', 'Generates birthday message'); + + $toolbox = new Toolbox($memoryFactory, [new ToolNoAttribute1()]); + $expected = [ + new Tool( + new ExecutionReference(ToolNoAttribute1::class, '__invoke'), + 'happy_birthday', + 'Generates birthday message', + [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'the name of the person', + ], + 'years' => [ + 'type' => 'integer', + 'description' => 'the age of the person', + ], + ], + 'required' => ['name', 'years'], + 'additionalProperties' => false, + ], + ), + ]; + + self::assertEquals($expected, $toolbox->getTools()); + } + + #[Test] + public function toolboxExecutionWithMemoryFactory(): void + { + $memoryFactory = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute1::class, 'happy_birthday', 'Generates birthday message'); + + $toolbox = new Toolbox($memoryFactory, [new ToolNoAttribute1()]); + $response = $toolbox->execute(new ToolCall('call_1234', 'happy_birthday', ['name' => 'John', 'years' => 30])); + + self::assertSame('Happy Birthday, John! You are 30 years old.', $response); + } + + #[Test] + public function toolboxMapWithOverrideViaChain(): void + { + $factory1 = (new MemoryToolFactory()) + ->addTool(ToolOptionalParam::class, 'optional_param', 'Tool with optional param', 'bar'); + $factory2 = new ReflectionToolFactory(); + + $toolbox = new Toolbox(new ChainFactory([$factory1, $factory2]), [new ToolOptionalParam()]); + + $expected = [ + new Tool( + new ExecutionReference(ToolOptionalParam::class, 'bar'), + 'optional_param', + 'Tool with optional param', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text'], + 'additionalProperties' => false, + ], + ), + ]; + + self::assertEquals($expected, $toolbox->getTools()); + } +}