refactor: rework LLM Chain into Symfony AI

This commit is contained in:
Christopher Hertel
2025-06-06 23:20:12 +02:00
commit fbc4f187c4
80 changed files with 4740 additions and 0 deletions

6
.gitattributes vendored Normal file
View File

@@ -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

8
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -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!

View File

@@ -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!

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
composer.lock
vendor
.phpunit.cache

19
LICENSE Normal file
View File

@@ -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.

72
composer.json Normal file
View File

@@ -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"}
]
}

10
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,10 @@
parameters:
level: 6
paths:
- src/
- tests/
ignoreErrors:
-
message: '#no value type specified in iterable type array#'
path: tests/*

24
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
colors="true"
executionOrder="depends,defects"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

121
src/Agent.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent;
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 <mail@christopher-hertel.de>
*/
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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface AgentAwareInterface
{
public function setAgent(AgentInterface $agent): void;
}

25
src/AgentAwareTrait.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
trait AgentAwareTrait
{
private AgentInterface $agent;
public function setAgent(AgentInterface $agent): void
{
$this->agent = $agent;
}
}

26
src/AgentInterface.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent;
use Symfony\AI\Platform\Message\MessageBagInterface;
use Symfony\AI\Platform\Response\ResponseInterface;
/**
* @author Denis Zunke <denis.zunke@gmail.com>
*/
interface AgentInterface
{
/**
* @param array<string, mixed> $options
*/
public function call(MessageBagInterface $messages, array $options = []): ResponseInterface;
}

View File

@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Exception;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Exception;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Exception;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
class LogicException extends \LogicException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Exception;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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');
}
}

View File

@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Exception;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
class RuntimeException extends \RuntimeException implements ExceptionInterface
{
}

47
src/Input.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent;
use Symfony\AI\Platform\Message\MessageBagInterface;
use Symfony\AI\Platform\Model;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class Input
{
/**
* @param array<string, mixed> $options
*/
public function __construct(
public Model $model,
public MessageBagInterface $messages,
private array $options,
) {
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
/**
* @param array<string, mixed> $options
*/
public function setOptions(array $options): void
{
$this->options = $options;
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\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 <mail@christopher-hertel.de>
*/
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'];
}
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\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 <mail@christopher-hertel.de>
*/
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) => <<<TOOL
## {$tool->name}
{$tool->description}
TOOL,
$this->toolbox->getTools()
));
$message = <<<PROMPT
{$this->systemPrompt}
# Available tools
{$tools}
PROMPT;
}
$input->messages = $messages->prepend(Message::forSystem($message));
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface InputProcessorInterface
{
public function processInput(Input $input): void;
}

33
src/Output.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent;
use Symfony\AI\Platform\Message\MessageBagInterface;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\Response\ResponseInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class Output
{
/**
* @param array<string, mixed> $options
*/
public function __construct(
public readonly Model $model,
public ResponseInterface $response,
public readonly MessageBagInterface $messages,
public readonly array $options,
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface OutputProcessorInterface
{
public function processOutput(Output $output): void;
}

View File

@@ -0,0 +1,94 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\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 <mail@christopher-hertel.de>
*/
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')
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\StructuredOutput;
use Symfony\AI\Platform\Contract\JsonSchema\Factory;
use function Symfony\Component\String\u;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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,
],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\StructuredOutput;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
interface ResponseFormatFactoryInterface
{
/**
* @param class-string $responseClass
*
* @return array{
* type: 'json_schema',
* json_schema: array{
* name: string,
* schema: array<string, mixed>,
* strict: true,
* }
* }
*/
public function create(string $responseClass): array;
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox;
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 <mail@christopher-hertel.de>
*/
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<mixed> $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;
};
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Attribute;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
final readonly class AsTool
{
public function __construct(
public string $name,
public string $description,
public string $method = '__invoke',
) {
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Event;
use Symfony\AI\Agent\Toolbox\ToolCallResult;
use Symfony\AI\Platform\Response\ResponseInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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);
}
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Exception;
use Symfony\AI\Agent\Exception\ExceptionInterface as BaseExceptionInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface ExceptionInterface extends BaseExceptionInterface
{
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Exception;
use Symfony\AI\Agent\Exception\InvalidArgumentException;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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);
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Exception;
use Symfony\AI\Agent\Exception\InvalidArgumentException;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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));
}
}

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Exception;
use Symfony\AI\Platform\Response\ToolCall;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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;
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Exception;
use Symfony\AI\Platform\Response\ToolCall;
use Symfony\AI\Platform\Tool\ExecutionReference;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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));
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox;
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 <mail@christopher-hertel.de>
*/
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));
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Response\BaseResponse;
use Symfony\AI\Platform\Response\ToolCallResponse;
/**
* @author Denis Zunke <denis.zunke@gmail.com>
*/
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;
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\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 <mail@christopher-hertel.de>
*/
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();
}
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Tool;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
#[AsTool('brave_search', 'Tool that searches the web using Brave Search')]
final readonly class Brave
{
/**
* @param array<string, mixed> $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<int, array{
* title: string,
* description: string,
* url: string,
* }>
*/
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'] ?? []);
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Tool;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\Component\Clock\Clock as SymfonyClock;
use Symfony\Component\Clock\ClockInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
#[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'),
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\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 <mail@christopher-hertel.de>
*/
#[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();
}
}

View File

@@ -0,0 +1,132 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Tool;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
#[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;
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Tool;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
#[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<string, mixed> $results
*/
private function extractBestResponse(array $results): string
{
return implode('. ', array_map(fn ($story) => $story['title'], $results['organic_results']));
}
}

View File

@@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\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 <mail@christopher-hertel.de>
*/
#[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;
}
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Tool;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Tool integration of tavily.com.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
#[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<string, string|string[]|int|bool> $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();
}
}

View File

@@ -0,0 +1,99 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\Tool;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
#[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<string, mixed> $query
*
* @return array<string, mixed>
*/
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();
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\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 <mail@christopher-hertel.de>
*/
#[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);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox;
use Symfony\AI\Platform\Response\ToolCall;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final readonly class ToolCallResult
{
public function __construct(
public ToolCall $toolCall,
public mixed $result,
) {
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\ToolFactory;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException;
use Symfony\AI\Agent\Toolbox\ToolFactoryInterface;
use Symfony\AI\Platform\Contract\JsonSchema\Factory;
use Symfony\AI\Platform\Tool\ExecutionReference;
use Symfony\AI\Platform\Tool\Tool;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
abstract class AbstractToolFactory implements ToolFactoryInterface
{
public function __construct(
private readonly Factory $factory = new Factory(),
) {
}
protected function convertAttribute(string $className, AsTool $attribute): Tool
{
try {
return new Tool(
new ExecutionReference($className, $attribute->method),
$attribute->name,
$attribute->description,
$this->factory->buildParameters($className, $attribute->method)
);
} catch (\ReflectionException $e) {
throw ToolConfigurationException::invalidMethod($className, $attribute->method, $e);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\ToolFactory;
use Symfony\AI\Agent\Toolbox\Exception\ToolException;
use Symfony\AI\Agent\Toolbox\ToolFactoryInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final readonly class ChainFactory implements ToolFactoryInterface
{
/**
* @var list<ToolFactoryInterface>
*/
private array $factories;
/**
* @param iterable<ToolFactoryInterface> $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);
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\ToolFactory;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Agent\Toolbox\Exception\ToolException;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class MemoryToolFactory extends AbstractToolFactory
{
/**
* @var array<string, AsTool[]>
*/
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);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox\ToolFactory;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Agent\Toolbox\Exception\ToolException;
/**
* Metadata factory that uses reflection in combination with `#[AsTool]` attribute to extract metadata from tools.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class ReflectionToolFactory extends AbstractToolFactory
{
/**
* @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());
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox;
use Symfony\AI\Agent\Toolbox\Exception\ToolException;
use Symfony\AI\Platform\Tool\Tool;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface ToolFactoryInterface
{
/**
* @return iterable<Tool>
*
* @throws ToolException if the metadata for the given reference is not found
*/
public function getTool(string $reference): iterable;
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final readonly class ToolResultConverter
{
/**
* @param \JsonSerializable|\Stringable|array<int|string, mixed>|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;
}
}

110
src/Toolbox/Toolbox.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox;
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 <mail@christopher-hertel.de>
*/
final class Toolbox implements ToolboxInterface
{
/**
* List of executable tools.
*
* @var list<mixed>
*/
private readonly array $tools;
/**
* List of tool metadata objects.
*
* @var Tool[]
*/
private array $map;
/**
* @param iterable<mixed> $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);
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Toolbox;
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 <mail@christopher-hertel.de>
*/
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;
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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);
}
}

View File

@@ -0,0 +1,195 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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',
<<<DESCRIPTION
A tool with required parameters
or not
DESCRIPTION,
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(<<<PROMPT
This is a system prompt
# Available tools
## tool_no_params
A tool without parameters
## tool_required_params
A tool with required parameters
or not
PROMPT, $messages[0]->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(<<<PROMPT
My dynamic system prompt.
# Available tools
## tool_no_params
A tool without parameters
PROMPT, $messages[0]->content);
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\InputProcessor;
final class SystemPromptService implements \Stringable
{
public function __toString(): string
{
return 'My dynamic system prompt.';
}
}

View File

@@ -0,0 +1,173 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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(<<<JSON
{
"steps": [
{
"explanation": "We want to isolate the term with x. First, let's subtract 7 from both sides of the equation.",
"output": "8x + 7 - 7 = -23 - 7"
},
{
"explanation": "This simplifies to 8x = -30.",
"output": "8x = -30"
},
{
"explanation": "Next, to solve for x, we need to divide both sides of the equation by 8.",
"output": "x = -30 / 8"
},
{
"explanation": "Now we simplify -30 / 8 to its simplest form.",
"output": "x = -15 / 4"
},
{
"explanation": "Dividing both the numerator and the denominator by their greatest common divisor, we finalize our solution.",
"output": "x = -3.75"
}
],
"finalAnswer": "x = -3.75"
}
JSON);
$output = new Output($model, $response, new MessageBag(), $input->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);
}
}

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\StructuredOutput;
use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactoryInterface;
final readonly class ConfigurableResponseFormatFactory implements ResponseFormatFactoryInterface
{
/**
* @param array<mixed> $responseFormat
*/
public function __construct(
private array $responseFormat = [],
) {
}
public function create(string $responseClass): array
{
return $this->responseFormat;
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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));
}
}

View File

@@ -0,0 +1,156 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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);
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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);
}
}

View File

@@ -0,0 +1,92 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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);
}
};
}
}

View File

@@ -0,0 +1,101 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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);
}
}

View File

@@ -0,0 +1,116 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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);
}
}

View File

@@ -0,0 +1,155 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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);
}
}

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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 <strong>Dallas</strong> <strong>Cowboys</strong> live scores, video highlights, and <strong>latest</strong> 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));
}
}

View File

@@ -0,0 +1,83 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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));
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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 = <<<EOT
Articles with the following titles were found on Wikipedia:
- Under-Secretary-General of the United Nations
- United Nations secretary-general selection
- List of current permanent representatives to the United Nations
- United Nations
- United Nations Secretariat
- Flag of the United Nations
- List of current members of the United States House of Representatives
- Member states of the United Nations
- Official languages of the United Nations
- United States Secretary of State
Use the title of the article with tool "wikipedia_article" to load the content.
EOT;
static::assertSame($expected, $actual);
}
#[Test]
public function searchWithoutResults(): void
{
$response = $this->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 = <<<EOT
This is the content of article "Secretary-General of the United Nations":
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.
EOT;
static::assertSame($expected, $actual);
}
#[Test]
public function articleWithRedirect(): void
{
$response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-article-redirect.json');
$httpClient = new MockHttpClient($response);
$wikipedia = new Wikipedia($httpClient);
$actual = $wikipedia->article('United Nations secretary-general');
$expected = <<<EOT
The article "United Nations secretary-general" redirects to article "Secretary-General of the United Nations".
This is the content of article "Secretary-General of the United Nations":
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.
EOT;
static::assertSame($expected, $actual);
}
#[Test]
public function articleMissing(): void
{
$response = $this->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));
}
}

View File

@@ -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 <strong>Dallas</strong> <strong>Cowboys</strong> live scores, video highlights, and <strong>latest</strong> 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&#x27;s Mick Shots, Mickey Spagnola looks at how depth options could play a part on who to pick at <strong>No. 12.</strong> 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 <strong>latest</strong> news and information for the <strong>Dallas</strong> <strong>Cowboys</strong>. 2024 season schedule, scores, stats, and highlights. Find out the <strong>latest</strong> 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 · <strong>7 - 10 - 0</strong> 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 <strong>Cowboys</strong> return to the playoffs. The good news is that <strong>Dallas</strong> has been one of the better drafting teams in the league, and they routinely find the right players to help win <strong>games</strong>.",
"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
}
}

View File

@@ -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
}
}

View File

@@ -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
]
}
}

View File

@@ -0,0 +1,16 @@
{
"batchcomplete": "",
"query": {
"pages": {
"-1": {
"ns": 0,
"title": "Blah blah blah",
"missing": "",
"contentmodel": "wikitext",
"pagelanguage": "en",
"pagelanguagehtmlcode": "en",
"pagelanguagedir": "ltr"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"batchcomplete": "",
"query": {
"searchinfo": {
"totalhits": 0
},
"search": [
]
}
}

View File

@@ -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-<span class=\"searchmatch\">secretary</span>-general <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> (USG) is a senior official within <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> System, normally appointed by <span class=\"searchmatch\">the</span> General Assembly",
"timestamp": "2024-11-28T08:11:08Z"
},
{
"ns": 0,
"title": "United Nations secretary-general selection",
"pageid": 52735558,
"size": 36343,
"wordcount": 4335,
"snippet": "<span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> <span class=\"searchmatch\">secretary</span>-general selection is <span class=\"searchmatch\">the</span> process <span class=\"searchmatch\">of</span> selecting <span class=\"searchmatch\">the</span> next <span class=\"searchmatch\">secretary</span>-general <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span>. To be selected as <span class=\"searchmatch\">secretary</span>-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 <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">current</span> permanent representatives to <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> at <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> Headquarters, New York City. <span class=\"searchmatch\">The</span> list includes <span class=\"searchmatch\">the</span> country that",
"timestamp": "2024-11-11T03:04:58Z"
},
{
"ns": 0,
"title": "United Nations",
"pageid": 31769,
"size": 173187,
"wordcount": 15417,
"snippet": "<span class=\"searchmatch\">The</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> (UN) is a diplomatic and political international organization with <span class=\"searchmatch\">the</span> intended purpose <span class=\"searchmatch\">of</span> maintaining international peace and security",
"timestamp": "2024-11-30T10:50:21Z"
},
{
"ns": 0,
"title": "United Nations Secretariat",
"pageid": 162410,
"size": 23340,
"wordcount": 2389,
"snippet": "<span class=\"searchmatch\">The</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> Secretariat is one <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> six principal organs <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> (UN), <span class=\"searchmatch\">The</span> secretariat is <span class=\"searchmatch\">the</span> UN's executive arm. <span class=\"searchmatch\">The</span> secretariat",
"timestamp": "2024-10-07T15:57:54Z"
},
{
"ns": 0,
"title": "Flag of the United Nations",
"pageid": 565612,
"size": 18615,
"wordcount": 1219,
"snippet": "<span class=\"searchmatch\">The</span> flag <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> is a sky blue banner containing <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span>' emblem in <span class=\"searchmatch\">the</span> centre. <span class=\"searchmatch\">The</span> emblem on <span class=\"searchmatch\">the</span> 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 <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> States House <span class=\"searchmatch\">of</span> Representatives List <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">current</span> <span class=\"searchmatch\">United</span> States senators List <span class=\"searchmatch\">of</span> members <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> States Congress by longevity <span class=\"searchmatch\">of</span> service",
"timestamp": "2024-12-06T14:58:13Z"
},
{
"ns": 0,
"title": "Member states of the United Nations",
"pageid": 31969,
"size": 107893,
"wordcount": 8436,
"snippet": "<span class=\"searchmatch\">The</span> member states <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> comprise 193 sovereign states. <span class=\"searchmatch\">The</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> (UN) is <span class=\"searchmatch\">the</span> 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": "<span class=\"searchmatch\">The</span> official languages <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> are <span class=\"searchmatch\">the</span> six languages used in <span class=\"searchmatch\">United</span> <span class=\"searchmatch\">Nations</span> (UN) meetings and in which <span class=\"searchmatch\">the</span> 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": "<span class=\"searchmatch\">The</span> <span class=\"searchmatch\">United</span> States <span class=\"searchmatch\">secretary</span> <span class=\"searchmatch\">of</span> state (SecState) is a member <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> executive branch <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> federal government and <span class=\"searchmatch\">the</span> head <span class=\"searchmatch\">of</span> <span class=\"searchmatch\">the</span> Department <span class=\"searchmatch\">of</span> State",
"timestamp": "2024-12-01T17:58:13Z"
}
]
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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"}',
];
}
}

View File

@@ -0,0 +1,265 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Agent\Tests\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<array{0: non-empty-string, 1: non-empty-string, 2?: array}>
*/
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());
}
}