Skip to content

Symfony AI - Agent Component

Edit this page

The Agent component provides a framework for building AI agents that, sits on top of the Platform and Store components, allowing you to create agents that can interact with users, perform tasks, and manage workflows.

Installation

1
$ composer require symfony/ai-agent

Basic Usage

To instantiate an agent, you need to pass a PlatformInterface and a model name to the Agent class:

1
2
3
4
5
6
7
use Symfony\AI\Agent\Agent;
use Symfony\AI\Platform\Bridge\OpenAi\Factory;

$platform = Factory::createPlatform($apiKey);
$model = 'gpt-4o-mini';

$agent = new Agent($platform, $model);

You can then run the agent with a MessageBag instance as input and an optional array of options:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\AI\Agent\Agent;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

// Platform instantiation

$agent = new Agent($platform, $model);
$messages = new MessageBag(
    Message::forSystem('You are a helpful chatbot answering questions about LLM agent.'),
    Message::ofUser('Hello, how are you?'),
);
$result = $agent->call($messages);

echo $result->getContent(); // "I'm fine, thank you. How can I help you today?"

The structure of the input message bag is flexible, see Platform Component for more details on how to use it.

Options

As with the Platform component, you can pass options to the agent when running it. These options configure the agent's behavior, for example available tools to execute, or are forwarded to the underlying platform and model.

Tools

To integrate LLMs with your application, Symfony AI supports tool calling out of the box. Tools are services that can be called by the LLM to provide additional features or process data. Within a single agent call the LLM can request a chain of tool calls, where the result of one call may lead the model to request another.

To control token costs and prevent infinite loops, the AgentProcessor caps the number of tool-calling iterations per agent call. By default the limit is 50; once it is exceeded the processor throws a MaxIterationsExceededException. Adjust the cap, or disable it entirely by passing null (unbounded), via the maxToolCalls parameter:

1
2
3
4
use Symfony\AI\Agent\Toolbox\AgentProcessor;

$toolProcessor = new AgentProcessor($toolbox, maxToolCalls: 75); // raise the cap
$toolProcessor = new AgentProcessor($toolbox, maxToolCalls: null); // unbounded (no limit)

Tool calling can be enabled by registering the processors in the agent:

1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\Toolbox\AgentProcessor;
use Symfony\AI\Agent\Toolbox\Toolbox;

// Platform instantiation

$yourTool = new YourTool();

$toolbox = new Toolbox([$yourTool]);
$toolProcessor = new AgentProcessor($toolbox);

$agent = new Agent($platform, $model, inputProcessors: [$toolProcessor], outputProcessors: [$toolProcessor]);

Custom tools can basically be any class, but must configure by the AsTool attribute:

1
2
3
4
5
6
7
8
9
10
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;

#[AsTool('company_name', 'Provides the name of your company')]
final class CompanyName
{
    public function __invoke(): string
    {
        return 'ACME Corp.';
    }
}

Tool Return Value

In the end, the tool's result needs to be a string, but Symfony AI converts arrays and objects, that implement the JsonSerializable interface, to JSON strings for you. So you can return arrays or objects directly from your tool.

Tool Methods

You can configure the method to be called by the LLM with the AsTool attribute and have multiple tools per class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;

#[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
{
    public function current(float $latitude, float $longitude): array
    {
        // ...
    }

    public function forecast(float $latitude, float $longitude): array
    {
        // ...
    }
}

Tool Metadata

Tools can carry arbitrary, application-defined data via the metadata argument. It is not interpreted by Symfony AI itself - it is yours to act on:

1
2
3
4
5
6
7
8
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;

#[AsTool('search_products', 'Search the product catalog', metadata: ['needs_confirmation' => false])]
#[AsTool('delete_product', 'Delete a product', method: 'delete', metadata: ['needs_confirmation' => true])]
final class ProductTool
{
    // ...
}

The metadata is carried to the Tool definition, so you can read it with getMetadata() or getMetadataValue().

This enables two things a deny-listing listener cannot. First, you can shape the toolset before it is offered to the model, so a tool the model must not use is never advertised in the first place:

1
2
3
4
$readOnlyTools = array_filter(
    iterator_to_array($toolbox->getTools()),
    static fn (Tool $tool): bool => false === $tool->getMetadataValue('needs_confirmation', true),
);

Second, you can act on it just before a tool is invoked, by listening to ToolCallRequested and denying the call:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;

final class HumanInTheLoopListener
{
    public function __invoke(ToolCallRequested $event): void
    {
        // default to requiring confirmation, so a tool added without metadata is not silently exempt
        if (false === $event->getDefinition()->getMetadataValue('needs_confirmation', true)) {
            return;
        }

        if (!$this->approvals->isApproved($event->getToolCall())) {
            $event->deny('Awaiting human confirmation.');
        }
    }
}

Note

Metadata only describes a tool, it does not enforce anything. Marking a tool as needs_confirmation does not prevent it from being called - a listener like the one above has to do that. Prefer failing closed, as shown, so tools added later without metadata are not silently exempt.

Tool Parameters

Symfony AI generates a JSON Schema representation for all tools in the Toolbox based on the AsTool attribute and method arguments and param comments in the doc block. Additionally, JSON Schema support validation rules, which are partially supported by LLMs like GPT.

Parameter Validation with #[Schema] Attribute

To leverage JSON Schema validation rules, configure the Schema attribute on the method arguments of your tool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\Schema;

#[AsTool('my_tool', 'Example tool with parameters requirements.')]
final class MyTool
{
    /**
     * @param string $name   The name of an object
     * @param int    $number The number of an object
     * @param array<string> $categories List of valid categories
     */
    public function __invoke(
        #[Schema(pattern: '/([a-z0-1]){5}/')]
        string $name,
        #[Schema(minimum: 0, maximum: 10)]
        int $number,
        #[Schema(enum: ['tech', 'business', 'science'])]
        array $categories,
    ): string {
        // ...
    }
}

See attribute class Schema for all available options.

Note

Please be aware, that this is only converted in a JSON Schema for the LLM to respect, but not validated by Symfony AI itself.

Defining Schema from File

If you already have a JSON Schema defined in a file, you can reference it using the ref argument:

1
2
3
4
5
6
7
8
9
10
11
12
13
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\Schema;

#[AsTool('my_tool', 'Example tool with external schema.')]
final class MyTool
{
    public function __invoke(
        #[Schema(ref: __DIR__.'/schema.json')]
        array $data,
    ): string {
        // ...
    }
}

Note

When using ref, other arguments on the #[Schema] attribute are not allowed as the entire schema is loaded from the file.

Runtime-driven Schema with #[Schema(provider: ...)]

When the allowed values come from runtime state (environment variables, database, injected services), #[Schema(enum: [...])] is not usable because PHP attributes only accept constant expressions. Set the provider argument on Schema to point at a service implementing SchemaProviderInterface, which contributes a JSON Schema fragment computed at runtime:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\Schema;
use Symfony\AI\Platform\Contract\JsonSchema\Provider\SchemaProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class PartStatusProvider implements SchemaProviderInterface
{
    public function __construct(
        #[Autowire('%env(csv:ACME_PART_STATUSES)%')]
        private readonly array $statuses,
    ) {
    }

    public function getSchemaFragment(array $context = []): array
    {
        return ['enum' => $this->statuses];
    }
}

#[AsTool('search_parts', 'Search parts by status')]
final class SearchPartsTool
{
    public function __invoke(
        #[Schema(provider: PartStatusProvider::class)]
        string $status,
    ): array {
        // ...
    }
}

The fragment returned by the provider is merged on top of the static schema built from reflection, #[Schema], PHPDoc and Validator constraints, and the attribute also works on properties of structured output DTOs.

See Runtime-driven Tool Parameters and Structured Output for composing with static constraints, structured output usage, and standalone wiring.

Automatic Enum Validation

For PHP backed enums, automatic validation without requiring any Schema attribute is supported:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
enum Priority: int
{
    case LOW = 1;
    case NORMAL = 5;
    case HIGH = 10;
}

enum ContentType: string
{
    case ARTICLE = 'article';
    case TUTORIAL = 'tutorial';
    case NEWS = 'news';
}

#[AsTool('content_search', 'Search for content with automatic enum validation.')]
final class ContentSearchTool
{
    /**
     * @param array<string> $keywords The search keywords
     * @param ContentType   $type     The content type to search for
     * @param Priority      $priority Minimum priority level
     * @param ContentType|null $fallback Optional fallback content type
     */
    public function __invoke(
        array $keywords,
        ContentType $type,
        Priority $priority,
        ?ContentType $fallback = null,
    ): array {
        // Enums are automatically validated - no #[Schema] attribute needed!
        // ...
    }
}

This eliminates the need for manual #[Schema(enum: [...])] attributes when using PHP's native backed enum types.

Using Symfony Validator

If you have `symfony/validator` installed, you can also use validation constraints for schema generation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\Component\Validator\Constraints as Assert;

class Person
{
    #[Assert\Length(max: 255)]
    public string $name;

    #[Assert\Range(min: 18)]
    public int $age;
}

#[AsTool('my_person_lookup_tool', 'Example tool to lookup a person.')]
final class MyTool
{
    public function __invoke(Person $person): string
    {
        // do the lookup ...
    }
}

This replaces the need to manually define the schema using #[Schema(...)], though it's possible to use both if needed.

To validate tool call arguments before invoking the actual tool, add the built-in ValidateToolCallArgumentsListener to the event dispatcher that is passed to Toolbox:

1
2
3
4
use Symfony\AI\Agent\Toolbox\Event\ToolCallArgumentsResolved;
use Symfony\AI\Agent\Toolbox\EventListener\ValidateToolCallArgumentsListener;

$eventDispatcher->addListener(ToolCallArgumentsResolved::class, new ValidateToolCallArgumentsListener());

This makes the toolbox throw InvalidToolCallArgumentsException before the tool is invoked if any of the arguments did not pass validation. When using the AI Bundle, the event listener is registered automatically and validation happens out-of-the-box.

See Tool Call Argument Validation for a complete example.

Polymorphic Parameters with DiscriminatorMap

For complex tool parameters that can be one of multiple types, use the DiscriminatorMap attribute from Symfony Serializer. This generates a JSON Schema with anyOf to properly describe all possible implementations. The typeProperty defines which field identifies the type, and the Symfony Serializer will automatically deserialize to the correct implementation class based on this discriminator field.

See the toolcall-polymorphic-interface.php example for a complete working implementation.

Third-Party Tools

In some cases you might want to use third-party tools, which are not part of your application. Adding the AsTool attribute to the class is not possible in those cases, but you can explicitly register the tool in the MemoryToolFactory:

1
2
3
4
5
6
7
use Symfony\AI\Agent\Toolbox\Toolbox;
use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory;
use Symfony\Component\Clock\Clock;

$metadataFactory = (new MemoryToolFactory())
    ->addTool(Clock::class, 'clock', 'Get the current date and time', 'now');
$toolbox = new Toolbox([new Clock()], $metadataFactory);

Note

Please be aware that not all return types are supported by the toolbox, so a decorator might still be needed.

This can be combined with the ChainFactory which enables you to use explicitly registered tools and AsTool tagged tools in the same chain - which even enables you to overwrite the pre-existing configuration of a tool:

1
2
3
4
5
6
7
8
9
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;

$reflectionFactory = new ReflectionToolFactory(); // Register tools with #[AsTool] attribute
$metadataFactory = (new MemoryToolFactory())      // Register or overwrite tools explicitly
    ->addTool(...);
$toolbox = new Toolbox([...], new ChainFactory($metadataFactory, $reflectionFactory));

Note

The order of the factories in the ChainFactory matters, as the first factory has the highest priority.

Subagent

Similar to third-party tools, an agent can also use an different agent as a tool. This can be useful to encapsulate complex logic or to reuse an agent in multiple places or hide sub-agents from the LLM:

1
2
3
4
5
6
7
8
9
10
use Symfony\AI\Agent\Toolbox\Tool\Subagent;
use Symfony\AI\Agent\Toolbox\Toolbox;
use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory;

// agent was initialized before

$subagent = new Subagent($agent);
$metadataFactory = (new MemoryToolFactory())
    ->addTool($subagent, 'research_agent', 'Meaningful description for sub-agent');
$toolbox = new Toolbox([$subagent], $metadataFactory);

Fault Tolerance

To gracefully handle errors that occur during tool calling, e.g. wrong tool names or runtime errors, you can use the FaultTolerantToolbox as a decorator for the Toolbox. It will catch the exceptions and return readable error messages to the LLM:

1
2
3
4
5
6
7
8
9
10
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\Toolbox\AgentProcessor;
use Symfony\AI\Agent\Toolbox\FaultTolerantToolbox;

// Platform, LLM & Toolbox instantiation

$toolbox = new FaultTolerantToolbox($innerToolbox);
$toolProcessor = new AgentProcessor($toolbox);

$agent = new Agent($platform, $model, inputProcessors: [$toolProcessor], outputProcessors: [$toolProcessor]);

If you want to expose the underlying error to the LLM, you can throw a custom exception that implements ToolExecutionExceptionInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;

class EntityNotFoundException extends \RuntimeException implements ToolExecutionExceptionInterface
{
    public function __construct(
        private string $entityName,
        private int $id,
    ){
    }

    public function getToolCallResult(): string
    {
        return \sprintf('No %s found with id %d', $this->entityName, $this->id);
    }
}

#[AsTool('get_user_age', 'Get age by user id')]
class GetUserAge
{
    public function __construct(
        private UserRepository $userRepository,
    ){
    }

    public function __invoke(int $id): int
    {
        $user = $this->userRepository->find($id)

        if (null === $user) {
            throw new EntityNotFoundException('user', $id);
        }

        return $user->getAge();
    }
}

Tool Sources

Some tools bring in data to the agent from external sources, like search engines or APIs. Those sources can be exposed by enabling `includeSources` as argument of the AgentProcessor:

1
2
3
4
5
use Symfony\AI\Agent\Toolbox\AgentProcessor;
use Symfony\AI\Agent\Toolbox\Toolbox;

$toolbox = new Toolbox([new MyTool()]);
$toolProcessor = new AgentProcessor($toolbox, includeSources: true);

In the tool implementation sources can be added by implementing the HasSourcesInterface in combination with the trait HasSourcesTrait:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Symfony\AI\Agent\Toolbox\Source\HasSourcesInterface;
use Symfony\AI\Agent\Toolbox\Source\HasSourcesTrait;

#[AsTool('my_tool', 'Example tool with sources.')]
final class MyTool implements HasSourcesInterface
{
    use HasSourcesTrait;

    public function __invoke(string $query): string
    {
        // Add sources relevant for the result

        $this->addSource(
            new Source('Example Source 1', 'https://example.com/source1', 'Relevant content from source 1'),
        );

        // return result
    }
}

The sources can be fetched from the metadata of the result after the agent execution:

1
2
3
4
5
$result = $agent->call($messages);

foreach ($result->getMetadata()->get('sources', []) as $source) {
    echo sprintf(' - %s (%s): %s', $source->getName(), $source->getReference(), $source->getContent());
}

See Anthropic Toolbox Example for a complete example using sources with Wikipedia tool.

Tool Filtering

To limit the tools provided to the LLM in a specific agent call to a subset of the configured tools, you can use the tools option with a list of tool names:

1
$this->agent->call($messages, ['tools' => ['tavily_search']]);

Tool Result Interception

To react to the result of a tool, you can implement an EventListener, that listens to the ToolCallsExecuted event. This event is dispatched after the Toolbox executed all current tool calls and enables you to skip the next LLM call by setting a result yourself:

1
2
3
4
5
6
7
$eventDispatcher->addListener(ToolCallsExecuted::class, function (ToolCallsExecuted $event): void {
    foreach ($event->getToolResults() as $toolResult) {
        if (str_starts_with($toolResult->getToolCall()->getName(), 'weather_')) {
            $event->setResult(new ObjectResult($toolResult->getResult()));
        }
    }
});

Tool Call Lifecycle Events

If you need to react more granularly to the lifecycle of individual tool calls, you can listen to the ToolCallRequested, ToolCallArgumentsResolved, ToolCallSucceeded and ToolCallFailed events. These are dispatched at different stages:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$eventDispatcher->addListener(ToolCallRequested::class, function (ToolCallRequested $event): void {
    // Intercept a tool call before execution, e.g. to deny it or set a custom result
});

$eventDispatcher->addListener(ToolCallArgumentsResolved::class, function (ToolCallArgumentsResolved $event): void {
    // Let the client know, that the tool $event->getMetadata()->getName() was executed
});

$eventDispatcher->addListener(ToolCallSucceeded::class, function (ToolCallSucceeded $event): void {
    // Let the client know, that the tool $event->getMetadata()->getName() successfully returned the result $event->getResult()
});

$eventDispatcher->addListener(ToolCallFailed::class, function (ToolCallFailed $event): void {
    // Let the client know, that the tool $event->getMetadata()->getName() failed with the exception: $event->getException()
});

The ToolCallRequested event is dispatched before a tool runs and lets you control whether it executes at all. Call $event->deny($reason) to block execution and return the reason to the LLM, or $event->setResult($result) with a ToolResult to skip execution and return a custom result instead. Doing nothing lets the call proceed:

1
2
3
4
5
6
7
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;

$eventDispatcher->addListener(ToolCallRequested::class, function (ToolCallRequested $event): void {
    if ('delete_account' === $event->getToolCall()->getName()) {
        $event->deny('Account deletion requires manual approval.');
    }
});

See the Human-in-the-Loop Tool Confirmation cookbook article for a complete guide on building a human-in-the-loop confirmation system using the ToolCallRequested event.

Excluding Tool Messages from MessageBag

Sometimes you might wish to exclude the tool messages (AssistantMessage containing ToolCall parts and ToolCallMessage containing the result) in the context. Enable the excludeToolMessages flag of the toolbox' AgentProcessor to ensure those messages will be removed from your MessageBag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use Symfony\AI\Agent\Toolbox\AgentProcessor;
use Symfony\AI\Agent\Toolbox\Toolbox;

// Platform instantiation

$messages = new MessageBag(
    Message::forSystem(<<<PROMPT
        Please answer all user questions only using the my-tool tool.
        Do not add information and if you cannot find an answer, say so.
        PROMPT),
    Message::ofUser('...') // The user's question.
);

$tool = new MyTool();

$toolbox = new Toolbox([$tool]);
$toolProcessor = new AgentProcessor($toolbox, excludeToolMessages: true);

$agent = new Agent($platform, $model, inputProcessors: [$toolProcessor], outputProcessors: [$toolProcessor]);
$result = $agent->call($messages);
// $messages will now exclude the tool messages

Retrieval Augmented Generation (RAG)

In combination with the Store Component, the Agent component can be used to build agents that perform Retrieval Augmented Generation (RAG). This allows the agent to retrieve relevant documents from a store and use them to generate more accurate and context-aware results. Therefore, the component provides a built-in tool called SimilaritySearch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\Bridge\SimilaritySearch\SimilaritySearch;
use Symfony\AI\Agent\Toolbox\AgentProcessor;
use Symfony\AI\Agent\Toolbox\Toolbox;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Store\Retriever;

// Initialize Platform & Models

$retriever = new Retriever($store, $vectorizer);
$similaritySearch = new SimilaritySearch($retriever);
$toolbox = new Toolbox([$similaritySearch]);
$processor = new AgentProcessor($toolbox);
$agent = new Agent($platform, $model, [$processor], [$processor]);

$messages = new MessageBag(
    Message::forSystem(<<<PROMPT
        Please answer all user questions only using the similarity_search tool. Do not add information and if you cannot
        find an answer, say so.
        PROMPT),
    Message::ofUser('...') // The user's question.
);
$result = $agent->call($messages);

Input & Output Processing

The behavior of the agent is extendable with services that implement InputProcessor and/or OutputProcessor interface. They are provided while instantiating the agent instance:

1
2
3
4
5
use Symfony\AI\Agent\Agent;

// Initialize Platform, LLM and processors

$agent = new Agent($platform, $model, $inputProcessors, $outputProcessors);

InputProcessor

InputProcessorInterface instances are called in the agent before handing over the MessageBag and the $options array to the LLM and are able to mutate both on top of the Input instance provided:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use Symfony\AI\Agent\Input;
use Symfony\AI\Agent\InputProcessorInterface;
use Symfony\AI\Platform\Message\Message;

final class MyProcessor implements InputProcessorInterface
{
    public function processInput(Input $input): void
    {
        // mutate options
        $options = $input->getOptions();
        $options['foo'] = 'bar';
        $input->setOptions($options);

        // mutate MessageBag
        $input->getMessageBag()->append(Message::ofAssistant(sprintf('Please answer using the locale %s', $this->locale)));
    }
}

OutputProcessor

OutputProcessorInterface instances are called after the model provided a result and can - on top of options and messages - mutate or replace the given result:

1
2
3
4
5
6
7
8
9
10
11
12
13
use Symfony\AI\Agent\Output;
use Symfony\AI\Agent\OutputProcessorInterface;

final class MyProcessor implements OutputProcessorInterface
{
    public function processOutput(Output $output): void
    {
        // mutate result
        if (str_contains($output->getResult()->getContent(), self::STOP_WORD)) {
            $output->setResult(new TextResult('Sorry, we were unable to find relevant information.'));
        }
    }
}

Agent Awareness

Both, Input and Output instances, provide access to the LLM used by the agent, but the agent itself is only provided, in case the processor implemented the AgentAwareInterface interface, which can be combined with using the AgentAwareTrait:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use Symfony\AI\Agent\AgentAwareInterface;
use Symfony\AI\Agent\AgentAwareTrait;
use Symfony\AI\Agent\Output;
use Symfony\AI\Agent\OutputProcessorInterface;

final class MyProcessor implements OutputProcessorInterface, AgentAwareInterface
{
    use AgentAwareTrait;

    public function processOutput(Output $out): void
    {
        // additional agent interaction
        $result = $this->agent->call(...);
    }
}

Agent Memory Management

Symfony AI supports adding contextual memory to agent conversations, allowing the model to recall past interactions or relevant information from different sources. Memory providers inject information into the system prompt, providing the model with context without changing your application logic.

Using Memory

Memory integration is handled through the MemoryInputProcessor and one or more MemoryProviderInterface implementations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\Memory\MemoryInputProcessor;
use Symfony\AI\Agent\Memory\StaticMemoryProvider;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

// Platform instantiation

$personalFacts = new StaticMemoryProvider(
    'My name is Wilhelm Tell',
    'I wish to be a swiss national hero',
    'I am struggling with hitting apples but want to be professional with the bow and arrow',
);
$memoryProcessor = new MemoryInputProcessor([$personalFacts]);

$agent = new Agent($platform, $model, [$memoryProcessor]);
$messages = new MessageBag(Message::ofUser('What do we do today?'));
$result = $agent->call($messages);
Memory Providers

The library includes several memory provider implementations that are ready to use out of the box.

Static Memory

Static memory provides fixed information to the agent, such as user preferences, application context, or any other information that should be consistently available without being directly added to the system prompt:

1
2
3
4
5
6
use Symfony\AI\Agent\Memory\StaticMemoryProvider;

$staticMemory = new StaticMemoryProvider(
    'The user is allergic to nuts',
    'The user prefers brief explanations',
);

Embedding Provider

This provider leverages vector storage to inject relevant knowledge based on the user's current message. It can be used for retrieving general knowledge from a store or recalling past conversation pieces that might be relevant:

1
2
3
4
5
6
7
use Symfony\AI\Agent\Memory\EmbeddingProvider;

$embeddingsMemory = new EmbeddingProvider(
    $platform,
    $embeddings, // Your embeddings model for vectorizing user messages
    $store       // Your vector store to query for relevant context
);
Dynamic Memory Control

Memory is globally configured for the agent, but you can selectively disable it for specific calls when needed. This is useful when certain interactions shouldn't be influenced by the memory context:

1
2
3
$result = $agent->call($messages, [
    'use_memory' => false, // Disable memory for this specific call
]);

Testing

MockAgent

For testing purposes, the Agent component provides a MockAgent class that behaves like Symfony's MockHttpClient. It provides predictable responses without making external API calls and includes assertion methods for verifying interactions:

1
2
3
4
5
6
7
8
9
10
11
12
13
use Symfony\AI\Agent\MockAgent;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

$agent = new MockAgent([
    'What is Symfony?' => 'Symfony is a PHP web framework',
    'Tell me about caching' => 'Symfony provides powerful caching',
]);

$messages = new MessageBag(Message::ofUser('What is Symfony?'));
$result = $agent->call($messages);

echo $result->getContent(); // "Symfony is a PHP web framework"

Call Tracking and Assertions:

1
2
3
4
5
6
7
8
9
10
// Verify agent interactions
$agent->assertCallCount(1);
$agent->assertCalledWith('What is Symfony?');

// Get detailed call information
$calls = $agent->getCalls();
$lastCall = $agent->getLastCall();

// Reset call tracking
$agent->reset();
MockResponse Objects

Similar to MockHttpClient, you can use MockResponse objects for more complex scenarios:

1
2
3
4
5
6
7
use Symfony\AI\Agent\MockResponse;

$complexResponse = new MockResponse('Detailed response content');
$agent = new MockAgent([
    'complex query' => $complexResponse,
    'simple query' => 'Simple string response',
]);
Callable Responses

Like MockHttpClient, MockAgent supports callable responses for dynamic behavior:

1
2
3
4
5
6
7
8
9
10
11
12
$agent = new MockAgent();

// Dynamic response based on input and context
$agent->addResponse('weather', function ($messages, $options, $input) {
    $messageCount = count($messages->getMessages());
    return "Weather info (context: {$messageCount} messages)";
});

// Callable can return string or MockResponse
$agent->addResponse('complex', function ($messages, $options, $input) {
    return new MockResponse("Complex response for: {$input}");
});
Service Testing Example

Testing a service that uses an agent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ChatServiceTest extends TestCase
{
    public function testChatResponse(): void
    {
        $agent = new MockAgent([
            'Hello' => 'Hi there! How can I help?',
        ]);

        $chatService = new ChatService($agent);
        $response = $chatService->processMessage('Hello');

        $this->assertSame('Hi there! How can I help?', $response);
        $agent->assertCallCount(1);
        $agent->assertCalledWith('Hello');
    }
}

The MockAgent provides all the benefits of traditional mocks while offering a more intuitive API for AI agent testing, making your tests more reliable and easier to maintain.

Speech support

The SpeechAgent decorator adds speech capabilities (STT/TTS) to any agent. It wraps an existing AgentInterface and handles audio-to-text conversion on input and text-to-audio conversion on output.

When TTS is configured, the decorator returns the speech result (a BinaryResult) directly. The original text from the LLM is available via the result's metadata (under the text key).

Here is a `text-to-speech` example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\SpeechAgent;
use Symfony\AI\Agent\Speech\SpeechConfiguration;
use Symfony\AI\Platform\Bridge\ElevenLabs\Factory as ElevenLabsFactory;
use Symfony\AI\Platform\Bridge\OpenAi\Factory as OpenAiFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

$openAIPlatform = OpenAiFactory::createPlatform('key');
$agent = new Agent($openAIPlatform, 'gpt-4o');

$elevenLabsPlatform = ElevenLabsFactory::createPlatform(apiKey: 'key');

$speechAgent = new SpeechAgent($agent, new SpeechConfiguration(
    ttsModel: 'eleven_multilingual_v2',
    ttsOptions: ['voice' => 'Dslrhjl3ZpzrctukrQSN'],
), textToSpeechPlatform: $elevenLabsPlatform);

$answer = $speechAgent->call(new MessageBag(
    Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
));

echo $answer->getMetadata()->get('text');  // text from the LLM
echo $answer->getContent();                // raw audio bytes
$answer->asFile('/tmp/speech.mp3');        // save to file

When handling `speech-to-text`, the decorator transcribes the audio input before delegating to the inner agent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\SpeechAgent;
use Symfony\AI\Agent\Speech\SpeechConfiguration;
use Symfony\AI\Platform\Bridge\OpenAi\Factory as OpenAiFactory;
use Symfony\AI\Platform\Message\Content\Audio;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

$platform = OpenAiFactory::createPlatform('key');
$agent = new Agent($platform, 'gpt-4o');

$speechAgent = new SpeechAgent($agent, configuration: new SpeechConfiguration(
    sttModel: 'whisper-1',
), speechToTextPlatform: $platform);

$answer = $speechAgent->call(new MessageBag(
    Message::ofUser(Audio::fromFile('audio.mp3'))
));

echo $answer->getContent(); // transcribed text was sent to the LLM

A full speech-to-speech pipeline (STT + TTS) can be created by configuring both models:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\SpeechAgent;
use Symfony\AI\Agent\Speech\SpeechConfiguration;
use Symfony\AI\Platform\Bridge\ElevenLabs\Factory as ElevenLabsFactory;
use Symfony\AI\Platform\Bridge\OpenAi\Factory as OpenAiFactory;
use Symfony\AI\Platform\Message\Content\Audio;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

$openAIPlatform = OpenAiFactory::createPlatform('key');
$agent = new Agent($openAIPlatform, 'gpt-4o');

$elevenLabsPlatform = ElevenLabsFactory::createPlatform(apiKey: 'key');

$speechAgent = new SpeechAgent($agent, new SpeechConfiguration(
    ttsModel: 'eleven_multilingual_v2',
    ttsOptions: ['voice' => 'Dslrhjl3ZpzrctukrQSN'],
    sttModel: 'whisper-1',
), $openAIPlatform, $elevenLabsPlatform);

$answer = $speechAgent->call(new MessageBag(
    Message::ofUser(Audio::fromFile('audio.mp3'))
));

echo $answer->getMetadata()->get('text');  // text from the LLM
$answer->asFile('/tmp/speech.mp3');        // save audio

Note

Handling both `text-to-speech` and `speech-to-text` introduces latency as most of the process is synchronous.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version