Skip to content

AI Bundle

Edit this page

Symfony integration bundle for Symfony AI components.

Integrating:

Installation

1
$ composer require symfony/ai-bundle

Configuration

Basic Example with OpenAI

1
2
3
4
5
6
7
8
# config/packages/ai.yaml
ai:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'
    agent:
        default:
            model: 'gpt-4o-mini'

Advanced Example with Multiple Agents

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# config/packages/ai.yaml
ai:
    platform:
        anthropic:
            api_key: '%env(ANTHROPIC_API_KEY)%'
        azure:
            # multiple deployments possible
            gpt_deployment:
                base_url: '%env(AZURE_OPENAI_BASEURL)%'
                deployment: '%env(AZURE_OPENAI_GPT)%'
                api_key: '%env(AZURE_OPENAI_KEY)%'
                api_version: '%env(AZURE_GPT_VERSION)%'
        bedrock:
            # multiple instances possible - for example region or engine depending
            # "api" selects the inference engine, defaulting to the SDK-based InvokeModel API
            default: ~
            eu:
                bedrock_runtime_client: 'async_aws.client.bedrock_runtime_eu'
            # the OpenAI- and Anthropic-compatible Bedrock Mantle routes
            openweight:
                api: completions
                api_key: '%env(AWS_BEARER_TOKEN_BEDROCK)%'
                region: '%env(AWS_DEFAULT_REGION)%'
            gemma:
                # without an api_key, requests are signed with AWS SigV4
                api: responses
                region: '%env(AWS_DEFAULT_REGION)%'
            claude:
                api: messages
                api_key: '%env(AWS_BEARER_TOKEN_BEDROCK)%'
                region: '%env(AWS_DEFAULT_REGION)%'
                cache_retention: long
                # workspace: 'proj_example'
        deepgram:
            api_key: '%env(DEEPGRAM_API_KEY)%'
        elevenlabs:
            api_key: '%env(ELEVEN_LABS_API_KEY)%'
        gemini:
            api_key: '%env(GEMINI_API_KEY)%'
        # Higgsfield issues a key id and a key secret, both are required
        higgsfield:
            api_key: '%env(HIGGSFIELD_API_KEY)%'
            api_secret: '%env(HIGGSFIELD_API_SECRET)%'
        perplexity:
            api_key: '%env(PERPLEXITY_API_KEY)%'
        together:
            api_key: '%env(TOGETHER_API_KEY)%'
        venice:
            api_key: '%env(VENICE_API_KEY)%'
        # VertexAI with project-scoped endpoint (requires google/auth)
        vertexai:
            location: '%env(GOOGLE_CLOUD_LOCATION)%'
            project_id: '%env(GOOGLE_CLOUD_PROJECT)%'
            api_key: '%env(GOOGLE_CLOUD_VERTEX_API_KEY)%' # Optional: uses ADC by default

        # Or with global endpoint (API key only, no google/auth needed)
        # vertexai:
        #     api_key: '%env(GOOGLE_CLOUD_VERTEX_API_KEY)%'
        ollama:
            endpoint: '%env(OLLAMA_HOST_URL)%'
        transformersphp: ~
    agent:
        rag:
            platform: 'ai.platform.azure.gpt_deployment'
            model: 'gpt-4o-mini'
            memory: 'You have access to conversation history and user preferences' # Optional: static memory content
            prompt: # The system prompt configuration
                text: 'You are a helpful assistant that can answer questions.' # The prompt text
                include_tools: true # Include tool definitions at the end of the system prompt
            tools:
                # Referencing a service with #[AsTool] attribute
                - 'Symfony\AI\Agent\Bridge\SimilaritySearch\SimilaritySearch'

                # Referencing a service without #[AsTool] attribute
                - service: 'App\Agent\Tool\CompanyName'
                  name: 'company_name'
                  description: 'Provides the name of your company'
                  method: 'foo' # Optional with default value '__invoke'

                # Referencing an agent => agent in agent 🤯
                - agent: 'research'
                  name: 'wikipedia_research'
                  description: 'Can research on Wikipedia'
        research:
            platform: 'ai.platform.anthropic'
            model: 'claude-sonnet-4-5'
            tools: # Tools are opt-in: if undefined, the agent gets no tools; use "tools: true" to inject all tools.
                - 'Symfony\AI\Agent\Bridge\Wikipedia\Wikipedia'
                - { mcp_server: 'research.filesystem' } # Tools of a remote MCP server configured under "mcp.clients"
            fault_tolerant_toolbox: false # Disables fault tolerant toolbox, default is true
            max_tool_calls: 75 # Cap of tool-calling iterations per agent call (default 50); set to null to disable the limit
        search_agent:
            platform: 'ai.platform.perplexity'
            model: 'fast'
            tools: false
        audio:
            platform: 'ai.platform.elevenlabs'
            model: 'eleven_multilingual_v2'
            tools: false
        nova:
            platform: 'ai.platform.bedrock.default'
            model: 'nova-pro'
            tools: false
    store:
        chromadb:
            # multiple collections possible per type
            default:
                collection: 'my_collection'
        cache:
            research:
                service: 'cache.app'
                cache_key: 'research'
                strategy: 'chebyshev'
        memory:
            ollama:
                strategy: 'manhattan'
    vectorizer:
        # Reusable vectorizer configurations
        openai_embeddings:
            platform: 'ai.platform.openai'
            model:
                name: 'text-embedding-3-small'
                options:
                    dimensions: 512
        mistral_embeddings:
            platform: 'ai.platform.mistral'
            model: 'mistral-embed'
    indexer:
        # DocumentIndexer: no loader, accepts documents directly via index($document)
        default:
            vectorizer: 'ai.vectorizer.openai_embeddings'
            store: 'ai.store.chromadb.default'

        # SourceIndexer: loader without source, call index('/path/to/file') at runtime
        research:
            loader: 'Symfony\AI\Store\Document\Loader\TextFileLoader'
            vectorizer: 'ai.vectorizer.mistral_embeddings'
            store: 'ai.store.cache.research'

        # ConfiguredSourceIndexer: loader + pre-configured source, call index() with no arguments
        docs:
            loader: 'Symfony\AI\Store\Document\Loader\RstToctreeLoader'
            source: '/path/to/docs/index.rst'
            vectorizer: 'ai.vectorizer.openai_embeddings'
            store: 'ai.store.chromadb.default'
            # optional: apply transformers (e.g. chunking) and filters
            transformers:
                - 'Symfony\AI\Store\Document\Transformer\TextSplitTransformer'
            filters: []

Generic Platform

Based on the generic bridge, you can configure any service, that complies with the original OpenAI API, like LiteLLM:

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
# config/packages/ai.yaml
ai:
    platform:
        generic:
            litellm:
                base_url: '%env(LITELLM_HOST_URL)%' # e.g. http://localhost:4000
                api_key: '%env(LITELLM_API_KEY)%' # e.g. sk-1234
                model_catalog: 'Symfony\AI\Platform\Bridge\Generic\ModelCatalog' # see below
    agent:
        test:
            platform: 'ai.platform.generic.litellm'
            model: 'mistral-small-latest'
            tools: false

services:
    Symfony\AI\Platform\Bridge\Generic\ModelCatalog:
        $models:
            mistral-small-latest:
                class: 'Symfony\AI\Platform\Bridge\Generic\CompletionsModel'
                capabilities:
                    - !php/const 'Symfony\AI\Platform\Capability::INPUT_MESSAGES'
                    - !php/const 'Symfony\AI\Platform\Capability::OUTPUT_TEXT'
                    - !php/const 'Symfony\AI\Platform\Capability::OUTPUT_STREAMING'
                    - !php/const 'Symfony\AI\Platform\Capability::OUTPUT_STRUCTURED'
                    - !php/const 'Symfony\AI\Platform\Capability::INPUT_IMAGE'
                    - !php/const 'Symfony\AI\Platform\Capability::TOOL_CALLING'

OpenResponses Platform

The openresponses platform exposes the OpenResponses bridge so any service that speaks the OpenAI Responses API (POST /v1/responses) can be configured directly through the bundle, including self-hosted gateways, internal model brokers, or vLLM deployments that implement the Responses shape:

1
2
3
4
5
6
7
8
9
10
11
12
13
# config/packages/ai.yaml
ai:
    platform:
        openresponses:
            local_observe:
                base_url: '%env(AI_TOOLS_BASE_URI)%'
                api_key: '%env(default::AI_TOOLS_API_KEY)%'
                responses_path: '/v1/responses'
                model_catalog: 'App\Ai\LocalObserveModelCatalog' # optional
    agent:
        observe:
            platform: 'ai.platform.openresponses.local_observe'
            model: 'auto'

Cached Platform

Thanks to Symfony's Cache component, platforms can be decorated and use any cache adapter, this platform allows to reduce network calls / resource consumption:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config/packages/ai.yaml
ai:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'
        cache:
            openai:
                platform: 'ai.platform.openai'
                service: 'cache.app'

    agent:
        openai:
            platform: 'ai.platform.cache.openai'
            model: 'gpt-4o-mini'

Store Dependency Injection

When using multiple stores in your application, the AI Bundle registers an alias for each store, named after the store, to inject it into your services. Store names must be unique across all store types, so each name points to exactly one store. Names like blog_store and blogStore count as the same, because both lead to the StoreInterface $blogStore alias.

1
2
3
4
5
6
7
8
ai:
    store:
        memory:
            main:
                strategy: 'cosine'
        chromadb:
            documents:
                collection: 'documents'

From the configuration above, the bundle registers StoreInterface $main for the memory store and StoreInterface $documents for the chromadb store:

1
2
3
4
5
6
7
8
9
10
use Symfony\AI\Store\StoreInterface;

final readonly class DocumentService
{
    public function __construct(
        private StoreInterface $main,
        private StoreInterface $documents,
    ) {
    }
}

The same applies to message stores: their names must be unique across all message store types, and each one gets a MessageStoreInterface alias named after it.

Model Configuration

Models can be configured in two different ways to specify model options and parameters. You can append query parameters directly to the model name using a URL-like syntax:

1
2
3
4
ai:
    agent:
        my_agent:
            model: 'gpt-4o-mini?temperature=0.7&max_output_tokens=2000&stream=true'

Alternatively, you can specify model options in a separate options section:

1
2
3
4
5
6
7
8
9
ai:
    agent:
        my_agent:
            model:
                name: 'gpt-4o-mini'
                options:
                    temperature: 0.7
                    max_output_tokens: 2000
                    stream: true

Note

You cannot use both query parameters in the model name and the options key simultaneously.

You can also define models for the vectorizer this way:

1
2
3
4
ai:
    vectorizer:
        embeddings:
            model: 'text-embedding-3-small?dimensions=512&encoding_format=float'

Configuring the Model Catalog

Each platform uses a model catalog that knows the available models and their capabilities. You can change this catalog with the model_catalog option of the platform.

Adding Models

Sometimes you need a model that is not part of the platform's bundled list (e.g. a local model served by LM Studio, or a model that has not been added to the bridge yet). Add it with the models option. For each model you provide the model class and the list of capabilities it supports:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# config/packages/ai.yaml
ai:
    platform:
        lmstudio:
            host_url: '%env(LMSTUDIO_HOST_URL)%'
            model_catalog:
                models:
                    qwen3-coder-next:
                        class: 'Symfony\AI\Platform\Bridge\Generic\CompletionsModel'
                        capabilities:
                            - !php/const Symfony\AI\Platform\Capability::INPUT_MESSAGES
                            - !php/const Symfony\AI\Platform\Capability::INPUT_IMAGE
                            - !php/const Symfony\AI\Platform\Capability::OUTPUT_TEXT
                            - !php/const Symfony\AI\Platform\Capability::OUTPUT_STREAMING
                            - !php/const Symfony\AI\Platform\Capability::TOOL_CALLING
                            - !php/const Symfony\AI\Platform\Capability::OUTPUT_STRUCTURED

    agent:
        coder:
            platform: 'ai.platform.lmstudio'
            model: 'qwen3-coder-next'

The configured models take precedence over models with the same name in the platform's catalog, so you can also use this option to change the capabilities of an existing model.

  • class (string): The fully qualified class name of the model, defaults to Model. It must extend Model. For platforms based on the generic bridge, use CompletionsModel for chat/completion models or EmbeddingsModel for embedding models.
  • capabilities (list): The Capability cases the model supports. At least one capability must be specified for each model.

The models option works with every platform and catalog: the bundled list, a custom service and a catalog that fetches its models from the provider API. The generic, openresponses and transformersphp platforms accept any model name, and the models option gives such a model its exact capabilities.

Using a Custom Catalog

To use your own catalog, pass the ID of a service that implements ModelCatalogInterface:

1
2
3
4
5
6
7
8
9
10
# config/packages/ai.yaml
ai:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'
            model_catalog:
                service: 'app.model_catalog'

            # a string is short for the "service" option
            # model_catalog: 'app.model_catalog'

Fetching Models from the Provider API

The amazeeai, deepgram, elevenlabs, fireworks, higgsfield, ollama, together and venice platforms always fetch their models from the provider API, so they have no api option.

The edenai, huggingface and openrouter platforms ship a bundled list of models and can also fetch the models from the API. They use the bundled list by default. Set the api option to use the API:

1
2
3
4
5
6
7
# config/packages/ai.yaml
ai:
    platform:
        openrouter:
            api_key: '%env(OPENROUTER_API_KEY)%'
            model_catalog:
                api: true

Caching the Model Catalog

A catalog that fetches its models from the provider API sends a request for each model lookup. Enable the cache option to store the lookups in a cache pool and reuse them across requests:

1
2
3
4
5
6
7
8
9
10
11
12
# config/packages/ai.yaml
ai:
    platform:
        ollama:
            endpoint: '%env(OLLAMA_HOST_URL)%'
            model_catalog:
                cache: true

                # "true" is short for:
                # cache:
                #     service: 'cache.app' # the cache pool that stores the lookups
                #     ttl: 3600            # lifetime of the cached lookups in seconds

Lookups of unknown models are not cached. The models of the models option are not cached either, so changes to them apply right away. You can use the cache option with a catalog that fetches its models from the API or with a custom service. A bundled list needs no cache, so the bundle throws an exception if you enable the cache for it.

HTTP Client Configuration

Each platform can be configured with a custom HTTP client service to handle API requests. This allows you to customize timeouts, proxy settings, SSL configurations, and other HTTP-specific options.

By default, all platforms use the standard Symfony HTTP client service (http_client):

1
2
3
4
5
ai:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'
            # http_client: 'http_client'  # This is the default

You can specify a custom HTTP client service for any platform:

1
2
3
4
5
ai:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'
            http_client: 'app.custom_http_client'

System Prompt Configuration

For basic usage, specify the system prompt as a simple string:

1
2
3
4
5
ai:
    agent:
        my_agent:
            model: 'gpt-4o-mini'
            prompt: 'You are a helpful assistant.'

Advanced Configuration

For more control, such as including tool definitions in the system prompt, use the array format:

1
2
3
4
5
6
7
8
ai:
    agent:
        my_agent:
            model: 'gpt-4o-mini'
            prompt:
                text: 'You are a helpful assistant that can answer questions.'
                include_tools: true # Include tool definitions at the end of the system prompt
            tools: true

The array format supports these options:

  • text (string): The system prompt text that will be sent to the AI model (either text or file is required)
  • file (string): Path to a file containing the system prompt (either text or file is required)
  • include_tools (boolean, optional): When set to true, tool definitions will be appended to the system prompt
  • enable_translation (boolean, optional): When set to true, enables translation for the system prompt text (requires symfony/translation)
  • translation_domain (string, optional): The translation domain to use for the system prompt translation

Note

You cannot use both text and file simultaneously. Choose one option based on your needs.

File-Based Prompts

For better organization and reusability, you can store system prompts in external files. This is particularly useful for:

  • Long, complex prompts with multiple sections
  • Prompts shared across multiple agents or projects
  • Version-controlled prompt templates
  • JSON-structured prompts with specific formatting

Configure the prompt with a file path:

1
2
3
4
5
6
ai:
    agent:
        my_agent:
            model: 'gpt-4o-mini'
            prompt:
                file: '%kernel.project_dir%/prompts/assistant.txt'

The file can be in any text format (.txt, .json, .md, etc.). The entire content of the file will be used as the system prompt text. The file may contain {placeholder} markers, which are replaced at call time when matching template_vars are provided in the agent input.

Example Text File

prompts/assistant.txt:

1
2
3
4
5
6
You are a helpful and knowledgeable assistant.

Guidelines:
- Be clear and direct in your responses
- Provide examples when appropriate
- Be respectful and professional at all times

Example JSON File

prompts/code-reviewer.json:

1
2
3
4
5
6
7
8
{
  "role": "You are an expert code reviewer",
  "responsibilities": [
    "Review code for bugs and potential issues",
    "Suggest improvements for code quality"
  ],
  "tone": "constructive and educational"
}

Translation Support

To use translated system prompts, you need to have the Symfony Translation component installed:

1
$ composer require symfony/translation

Then configure the prompt with translation enabled:

1
2
3
4
5
6
7
8
ai:
    agent:
        my_agent:
            model: 'gpt-4o-mini'
            prompt:
                text: 'agent.system_prompt'  # Translation key
                enable_translation: true
                translation_domain: 'ai_prompts'  # Optional: specify translation domain

The system prompt text will be automatically translated using the configured translator service. If no translation domain is specified, the default domain will be used.

Message Template Support

The Platform's feature for using message templates is set up by the bundle, and conditionally also registers the expression language support if the `symfony/expression-language` package is installed.

More about message templates can be found in the Platform documentation.

Memory Provider Configuration

Memory providers allow agents to access and utilize conversation history and context from previous interactions. This enables agents to maintain context across conversations and provide more personalized responses.

Static Memory (Simple)

The simplest way to add memory is to provide a string that will be used as static context:

1
2
3
4
5
6
7
ai:
    agent:
        my_agent:
            model: 'gpt-4o-mini'
            memory: 'You have access to user preferences and conversation history'
            prompt:
                text: 'You are a helpful assistant.'

This static memory content is consistently available to the agent across all conversations.

Dynamic Memory (Advanced)

For more sophisticated scenarios, you can reference an existing service that implements dynamic memory. Use the array syntax with a service key to explicitly reference a service:

1
2
3
4
5
6
7
8
ai:
    agent:
        my_agent:
            model: 'gpt-4o-mini'
            memory:
                service: 'my_memory_service'  # Explicitly references an existing service
            prompt:
                text: 'You are a helpful assistant.'

Memory as System Prompt

Memory can work independently or alongside the system prompt:

  • Memory only: If no prompt is provided, memory becomes the system prompt
  • Memory + Prompt: If both are provided, memory is prepended to the prompt
1
2
3
4
5
6
7
8
9
10
11
12
13
ai:
    agent:
        # Agent with memory only (memory becomes system prompt)
        memory_only_agent:
            model: 'gpt-4o-mini'
            memory: 'You are a helpful assistant with conversation history'

        # Agent with both memory and prompt (memory prepended to prompt)
        memory_and_prompt_agent:
            model: 'gpt-4o-mini'
            memory: 'Previous conversation context...'
            prompt:
                text: 'You are a helpful assistant.'

Custom Memory Provider Requirements

When using a service reference, the memory service must implement the MemoryProviderInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use Symfony\AI\Agent\Input;
use Symfony\AI\Agent\Memory\Memory;
use Symfony\AI\Agent\Memory\MemoryProviderInterface;

final class MyMemoryProvider implements MemoryProviderInterface
{
    public function load(Input $input): array
    {
        // Return an array of Memory objects containing relevant conversation history
        return [
            new Memory('Username: OskarStark'),
            new Memory('Age: 40'),
            new Memory('User preferences: prefers concise answers'),
        ];
    }
}

Multi-Agent Orchestration

The AI Bundle provides a configuration system for creating multi-agent orchestrators that route requests to specialized agents based on defined handoff rules.

Multi-Agent vs Agent-as-Tool

The AI Bundle supports two different approaches for combining multiple agents:

  1. Agent-as-Tool: An agent can use another agent as a tool during its processing. The main agent decides when and how to call the secondary agent, similar to any other tool. This is useful when:

    • The main agent needs optional access to specialized capabilities
    • The decision to use the secondary agent is context-dependent
    • You want the main agent to control the entire conversation flow
    • The secondary agent provides supplementary information

    Example: A general assistant that can optionally query a research agent for detailed information.

  2. Multi-Agent Orchestration: A dedicated orchestrator analyzes each request and routes it to the most appropriate specialized agent. This is useful when:

    • You have distinct domains that require different expertise
    • You want clear separation of concerns between agents
    • The routing decision should be made upfront based on the request type
    • Each agent should handle the entire conversation for its domain

    Example: A customer service system that routes to technical support, billing, or general inquiries based on the user's question.

Key Differences
  • Control Flow: Agent-as-tool maintains control in the primary agent; Multi-Agent delegates full control to the selected agent
  • Decision Making: Agent-as-tool decides during processing; Multi-Agent decides before processing
  • Response Generation: Agent-as-tool integrates tool responses; Multi-Agent returns the selected agent's complete response
  • Use Case: Agent-as-tool for augmentation; Multi-Agent for specialization
Configuration

The orchestrator, handoff and fallback agents must be defined under ai.agent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# config/packages/ai.yaml
ai:
    multi_agent:
        # Define named multi-agent systems
        support:
            # The main orchestrator agent that analyzes requests
            orchestrator: 'orchestrator'

            # Handoff rules mapping agents to trigger keywords
            # At least 1 handoff required
            handoffs:
                technical: ['bug', 'problem', 'technical', 'error', 'code', 'debug']

            # Fallback agent for unmatched requests (required)
            fallback: 'general'

Each multi-agent configuration automatically registers a service with the ID pattern ai.multi_agent.{name}.

For the example above, the service ai.multi_agent.support is registered and can be injected:

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\AgentInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class SupportController
{
    public function __construct(
        #[Autowire(service: 'ai.multi_agent.support')]
        private AgentInterface $supportAgent,
    ) {
    }

    public function askSupport(string $question): string
    {
        $messages = new MessageBag(Message::ofUser($question));
        $response = $this->supportAgent->call($messages);

        return $response->getContent();
    }
}
Handoff Rules and Fallback

Handoff rules are defined as a key-value mapping where:

  • Key: The name of the target agent (automatically prefixed with ai.agent.)
  • Value: An array of keywords or phrases that trigger this handoff

Example of creating a Handoff in PHP:

1
2
3
4
5
6
use Symfony\AI\Agent\MultiAgent\Handoff;

$technicalHandoff = new Handoff(
    to: $technicalAgent,
    when: ['code', 'debug', 'implementation', 'refactor', 'programming']
);

The fallback parameter (required) specifies an agent to handle requests that don't match any handoff rules. This ensures all requests have a proper handler.

How It Works
  1. The orchestrator agent receives the initial request
  2. It analyzes the request content and matches it against handoff rules
  3. If keywords match a handoff's conditions, the request is delegated to that agent
  4. If no specific conditions match, the request is delegated to the fallback agent
  5. The selected agent processes the request and returns the response
Example: Customer Service Bot
1
2
3
4
5
6
7
8
9
ai:
    multi_agent:
        customer_service:
            orchestrator: 'analyzer'
            handoffs:
                tech_support: ['error', 'bug', 'crash', 'not working', 'broken']
                billing: ['payment', 'invoice', 'billing', 'subscription', 'price']
                product_info: ['features', 'how to', 'tutorial', 'guide', 'documentation']
            fallback: 'general_support'  # Fallback for general inquiries

Commands

The AI Bundle provides several console commands for interacting with AI platforms, agents, and stores.

ai:platform:invoke

The ai:platform:invoke command allows you to directly invoke any configured AI platform with a message. This is useful for testing platform configurations and quick interactions with AI models.

1
2
3
4
5
6
7
$ php bin/console ai:platform:invoke <platform> <model> "<message>"

# Using OpenAI
$ php bin/console ai:platform:invoke openai gpt-4o-mini "Hello, world!"

# Using Anthropic
$ php bin/console ai:platform:invoke anthropic claude-3-5-sonnet-20241022 "Explain quantum physics"

ai:agent:call

The ai:agent:call command provides an interactive chat interface to communicate with configured agents. This is useful for testing agent configurations, tools, and conversational flows.

1
2
3
4
5
6
7
$ php bin/console ai:agent:call <agent>

# Chat with the default agent
$ php bin/console ai:agent:call default

# Chat with a specific agent
$ php bin/console ai:agent:call wikipedia

The command starts an interactive session where you can:

  • Type messages and press Enter to send them to the agent
  • See the agent's responses in real-time
  • View the system prompt that was configured for the agent
  • Type exit or quit to end the conversation

If no agent name is provided, you'll be prompted to select one from the available configured agents.

ai:store:setup

The ai:store:setup command prepares the required infrastructure for a store (e.g., creates database tables, indexes, collections).

1
2
3
4
5
6
7
$ php bin/console ai:store:setup <store>

# Setup the chromadb store
$ php bin/console ai:store:setup default

# Pass a runtime option supported by the store
$ php bin/console ai:store:setup default --option forceDatabaseCreation=true

The commands name a store by its configured name, e.g. default. If you leave out the store, indexer or retriever, or give an unknown one, the command asks you to select one of the configured services and lists the closest matches first.

The --option (or -o) option takes name=value and can be repeated. The value is decoded as JSON when possible (e.g. true or 100), and a name without value is set to true. The ai:store:drop command accepts the same option.

Note

This command only works with stores that implement ManagedStoreInterface. Not all store types support or require setup operations.

Tip

When using a store that shares a Doctrine DBAL connection (e.g. the Postgres store with dbal_connection), Doctrine's schema tools (doctrine:schema:update, doctrine:schema:validate) may fail because they don't understand store-specific column types like vector. To prevent this, configure a schema_filter in your Doctrine DBAL configuration to exclude the store tables:

1
2
3
4
# config/packages/doctrine.yaml
doctrine:
    dbal:
        schema_filter: '~^(?!ai_)~'

This regex excludes all tables prefixed with ai_ from Doctrine's schema management. Make sure your store's table_name uses the same prefix (e.g. ai_documents).

ai:store:drop

The ai:store:drop command drops the infrastructure for a store (e.g., removes database tables, indexes, collections).

1
2
3
4
$ php bin/console ai:store:drop <store> --force

# Drop the chromadb store
$ php bin/console ai:store:drop default --force

Warning

The --force (or -f) option is required to prevent accidental data loss. Without it, the command asks for confirmation in interactive mode. This command will permanently delete all data in the store.

Note

This command only works with stores that implement ManagedStoreInterface. Not all store types support drop operations.

ai:store:index

The ai:store:index command indexes documents into a store using a configured indexer.

1
2
3
4
5
6
7
8
9
10
$ php bin/console ai:store:index <indexer>

# Index using the default indexer
$ php bin/console ai:store:index default

# Override the configured source with a single file
$ php bin/console ai:store:index blog --source=/path/to/file.txt

# Override with multiple sources
$ php bin/console ai:store:index blog --source=/path/to/file1.txt --source=/path/to/file2.txt

The --source (or -s) option allows you to override the source(s) configured in your indexer. This is useful for ad-hoc indexing operations or testing different data sources.

Note

This command only works with indexers that have a loader configured. Document indexers (those without a loader) must be used programmatically in your code.

Usage

Agent Service

Use the Agent service to leverage models and tools:

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\AgentInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

final readonly class MyService
{
    public function __construct(
        private AgentInterface $agent,
    ) {
    }

    public function submit(string $message): string
    {
        $messages = new MessageBag(
            Message::forSystem('Speak like a pirate.'),
            Message::ofUser($message),
        );

        return $this->agent->call($messages)->getContent();
    }
}

Register Processors

By default, all services implementing the InputProcessorInterface or the OutputProcessorInterface interfaces are automatically applied to every Agent.

This behavior can be overridden/configured with the AsInputProcessor and the AsOutputProcessor attributes:

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\Attribute\AsInputProcessor;
use Symfony\AI\Agent\Attribute\AsOutputProcessor;
use Symfony\AI\Agent\Input;
use Symfony\AI\Agent\InputProcessorInterface;
use Symfony\AI\Agent\Output;
use Symfony\AI\Agent\OutputProcessorInterface;

#[AsInputProcessor(priority: 99)] // This applies to every agent
#[AsOutputProcessor(agent: 'ai.agent.my_agent_name')] // The output processor will only be registered for 'ai.agent.my_agent_name'
final readonly class MyService implements InputProcessorInterface, OutputProcessorInterface
{
    public function processInput(Input $input): void
    {
        // ...
    }

    public function processOutput(Output $output): void
    {
        // ...
    }
}

Register Tools

The following tools can be installed as dedicated packages, no configuration is needed as these bridges come with flex recipes.

1
2
3
4
5
6
7
8
9
10
11
12
$ composer require symfony/ai-brave-tool
$ composer require symfony/ai-clock-tool
$ composer require symfony/ai-firecrawl-tool
$ composer require symfony/ai-mapbox-tool
$ composer require symfony/ai-mcp-tool
$ composer require symfony/ai-open-meteo-tool
$ composer require symfony/ai-scraper-tool
$ composer require symfony/ai-serp-api-tool
$ composer require symfony/ai-similarity-search-tool
$ composer require symfony/ai-tavily-tool
$ composer require symfony/ai-wikipedia-tool
$ composer require symfony/ai-youtube-tool

Some tools may require additional configuration even when installed as dedicated packages. For example, the SimilaritySearch tool requires a retriever:

1
2
3
4
5
6
7
services:
    _defaults:
        autowire: true
        autoconfigure: true

    Symfony\AI\Agent\Bridge\SimilaritySearch\SimilaritySearch:
        $retriever: '@ai.retriever.main'

The SimilaritySearch tool returns a SimilaritySearchResult, which the agent serializes with the serializer service. By default, the model gets a JSON list of the documents with their id and metadata. To change what the model gets, register a normalizer for this 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
25
26
27
use Symfony\AI\Agent\Bridge\SimilaritySearch\SimilaritySearchResult;
use Symfony\AI\Store\Document\VectorDocumentInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final class SimilaritySearchResultNormalizer implements NormalizerInterface
{
    public function normalize(mixed $data, ?string $format = null, array $context = []): array
    {
        return array_map(
            static fn (VectorDocumentInterface $document): array => [
                'id' => $document->getId(),
                'text' => $document->getMetadata()->getText(),
            ],
            $data->getDocuments(),
        );
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof SimilaritySearchResult;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [SimilaritySearchResult::class => true];
    }
}

Creating Custom Tools

Custom tools can be registered by using 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.';
    }
}

Tools are not registered with an agent automatically - they are opt-in. To inject all known tools, meaning every service tagged with ai.tool, e.g. via the #[AsTool] attribute, opt in explicitly by setting the tools option to true:

1
2
3
4
ai:
    agent:
        my_agent:
            tools: true

To inject only specific tools, list them in the configuration:

1
2
3
4
5
ai:
    agent:
        my_agent:
            tools:
                - 'Symfony\AI\Agent\Bridge\SimilaritySearch\SimilaritySearch'

Omitting the tools option, or setting it to false, null or an empty list, leaves the agent without any tools.

To restrict the access to a tool, you can use the IsGrantedTool attribute, which works similar to IsGranted attribute in `symfony/security-http`. For this to work, make sure you have `symfony/security-core` installed in your project.

1
2
3
4
5
6
7
8
9
10
11
use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool;

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

The attribute IsGrantedTool can be added on class- or method-level - even multiple times. If multiple attributes apply to one tool call, a logical AND is used and all access decisions have to grant access.

Tool Execution Strategy

By default, tool calls are executed sequentially, one after another. The bundle lets you switch the executor per agent through the execution_strategy option under tools. Built-in values are sequential (default) and fiber; any other value is treated as a service ID of a custom ToolExecutorInterface implementation.

Sequential (default)
Executes tool calls one at a time in the order they are received. Safe and predictable; no additional knowledge is required from tool authors.
Fiber
Runs all tool calls concurrently using PHP Fibers. All fibers are started before any result is collected, and a round-robin scheduler gives each suspended fiber a turn in every pass, so I/O-bound tools that yield wait for their responses at the same time instead of one after another. Results are still returned in the order of the tool calls.

Configure the strategy per agent under the tools option:

1
2
3
4
5
6
7
8
9
ai:
    agent:
        my_agent:
            model: 'gpt-4o-mini'
            tools:
                execution_strategy: fiber # "sequential" (default), "fiber", or a custom service ID
                services:
                    - 'App\Tool\WeatherTool'
                    - 'App\Tool\CalendarTool'

To use a custom executor, implement ToolExecutorInterface and register it as a service, then reference its service ID:

1
2
3
4
5
ai:
    agent:
        my_agent:
            tools:
                execution_strategy: 'app.my_custom_executor'

To write tools that cooperate with the Fiber strategy, see Writing Fiber-Compatible Tools in the Agent component documentation.

Tools of a Remote MCP Server

An agent can use the tools of a remote Model Context Protocol server. Configure the connection with the MCP bundle and reference it as <client>.<server> in an mcp_server tool entry, so both bundles share one connection:

1
$ composer require symfony/ai-mcp-tool symfony/mcp-bundle
1
2
3
4
5
6
7
8
# config/packages/mcp.yaml
mcp:
    clients:
        research:
            servers:
                filesystem:
                    transport: stdio
                    command: ['npx', '-y', '@modelcontextprotocol/server-filesystem', '%kernel.project_dir%/var']
1
2
3
4
5
6
# config/packages/ai.yaml
ai:
    agent:
        my_agent:
            tools:
                - { mcp_server: 'research.filesystem' } # tools reach the model as "filesystem_<name>", change it with "prefix"

The list stays explicit: an agent listing only mcp_server entries gets only the tools of those servers. To offer local tools next to them, list those tools as well. A server that cannot be reached contributes no tools; lower its init_timeout and max_retries to notice that sooner.

The reason is only logged as an error on the application's default logger. In a long-running process, e.g. a Messenger worker, the server is then not asked again for the next 60 seconds; with PHP-FPM every request asks it again. To find out why an agent has fewer tools than expected, check the log and run php bin/console debug:mcp --client=research --server=filesystem, which connects to the server and prints what it advertises.

Token Usage Tracking

Token usage tracking is a feature provided by some of the Platform's bridges, for monitoring and analyzing the consumption of tokens by your agents. This feature is particularly useful for understanding costs and performance.

In case a Platform bridge supports token usage tracking, the Platform will automatically track token usage information and add it to the result metadata. The tracked information includes:

  • Prompt tokens: Number of tokens used in the input/prompt
  • Completion tokens: Number of tokens generated in the response
  • Total tokens: Total number of tokens used (prompt + completion)
  • Remaining tokens: Number of remaining tokens in rate limits (when available)
  • Cached tokens: Number of cached tokens used (when available)
  • Thinking tokens: Number of reasoning tokens used (for models that support reasoning)

The token usage information can be accessed from the result metadata:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\TokenUsage\TokenUsage;

final readonly class MyService
{
    public function __construct(
        private AgentInterface $agent,
    ) {
    }

    public function getTokenUsage(string $message): ?TokenUsage
    {
        $messages = new MessageBag(Message::ofUser($message));
        $result = $this->agent->call($messages);

        return $result->getMetadata()->get('token_usage');
    }
}

Vectorizers

Vectorizers are components that convert text documents into vector embeddings for storage and retrieval. They can be configured once and reused across multiple indexers, providing better maintainability and consistency.

Configuring Vectorizers

Vectorizers are defined in the vectorizer section of your configuration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ai:
    vectorizer:
        openai_small:
            platform: 'ai.platform.openai'
            model:
                name: 'text-embedding-3-small'
                options:
                    dimensions: 512

        openai_large:
            platform: 'ai.platform.openai'
            model: 'text-embedding-3-large'

        mistral_embed:
            platform: 'ai.platform.mistral'
            model: 'mistral-embed'

Using Vectorizers in Indexers

Once configured, vectorizers can be referenced by name in indexer configurations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ai:
    indexer:
        documents:
            loader: 'Symfony\AI\Store\Document\Loader\TextFileLoader'
            vectorizer: 'ai.vectorizer.openai_small'
            store: 'ai.store.chromadb.documents'

        research:
            loader: 'Symfony\AI\Store\Document\Loader\TextFileLoader'
            vectorizer: 'ai.vectorizer.openai_large'
            store: 'ai.store.chromadb.research'

        knowledge_base:
            loader: 'Symfony\AI\Store\Document\Loader\InMemoryLoader'
            vectorizer: 'ai.vectorizer.mistral_embed'
            store: 'ai.store.memory.kb'

Document Indexers

If you omit the loader option, a DocumentIndexer is created instead of a SourceIndexer. This is useful when you want to index documents directly in your code without loading them from external sources:

1
2
3
4
5
6
ai:
    indexer:
        my_indexer:
            # No loader - creates a DocumentIndexer
            vectorizer: 'ai.vectorizer.openai_small'
            store: 'ai.store.chromadb.documents'

The resulting service accepts documents directly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use Symfony\AI\Store\Document\TextDocument;
use Symfony\AI\Store\IndexerInterface;

class MyService
{
    public function __construct(
        private IndexerInterface $myIndexer,
    ) {
    }

    public function indexContent(string $id, string $content): void
    {
        $this->myIndexer->index(new TextDocument($id, $content));
        // Or multiple documents
        $this->myIndexer->index([$document1, $document2]);
    }
}

Note

Document indexers cannot be used with the ai:store:index command, as that command requires a loader to fetch documents from sources.

Benefits of Configured Vectorizers

  • Reusability: Define once, use in multiple indexers
  • Consistency: Ensure all indexers using the same vectorizer have identical embedding configuration
  • Maintainability: Change vectorizer settings in one place

Retrievers

Retrievers are the opposite of indexers. While indexers populate a vector store with documents, retrievers allow you to search for documents in a store based on a query string. They vectorize the query and retrieve similar documents from the store.

Configuring Retrievers

Retrievers are defined in the retriever section of your configuration:

1
2
3
4
5
6
7
8
9
ai:
    retriever:
        default:
            vectorizer: 'ai.vectorizer.openai_small'
            store: 'ai.store.chromadb.default'

        research:
            vectorizer: 'ai.vectorizer.mistral_embed'
            store: 'ai.store.memory.research'

Using Retrievers

The retriever can be injected into your services using the RetrieverInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Symfony\AI\Store\RetrieverInterface;

final readonly class MyService
{
    public function __construct(
        private RetrieverInterface $retriever,
    ) {
    }

    public function search(string $query): array
    {
        $documents = [];
        foreach ($this->retriever->retrieve($query) as $document) {
            $documents[] = $document;
        }

        return $documents;
    }
}

When you have multiple retrievers configured, you can use the #[Autowire] attribute to inject a specific one:

1
2
3
4
5
6
7
8
9
10
11
use Symfony\AI\Store\RetrieverInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final readonly class ResearchService
{
    public function __construct(
        #[Autowire(service: 'ai.retriever.research')]
        private RetrieverInterface $retriever,
    ) {
    }
}

Profiler

The profiler panel provides insights into the agent's execution:

Profiler Panel

Agent calls, platform invocations and tool executions also show up in the performance timeline of the profiler, in the ai category. A platform invocation spans until its result is converted, or until its stream is fully consumed, so it includes the time waiting for the model's response. An agent call spans the consumption of its execution, since the agent only runs while it is consumed.

Testing agents

Answers of a model are not deterministic, which makes them a poor thing to assert on. What an agent did is deterministic enough: that it called the model, that it reached for a tool, that it did not call the platform twice when once is expected.

The profiler collects exactly that, and AiAssertionsTrait turns it into assertions for functional tests. Enable the profiler on the client before the request, and assert afterwards:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Symfony\AI\AiBundle\Test\AiAssertionsTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class AgentTest extends WebTestCase
{
    use AiAssertionsTrait;

    public function testTheAgentSearchesTheBlog()
    {
        $client = static::createClient();
        $client->enableProfiler();

        $client->request('POST', '/chat', ['message' => 'What happened last week?']);

        // Once to decide on the tool, once with its result.
        self::assertPlatformCallCount(2);
        self::assertModelCalled('gpt-4.1');
        self::assertToolCalled('similarity_search');
    }
}

The trait provides:

assertPlatformCallCount(int $expectedCount)
The number of calls made to a platform while handling the request, across all agents.
assertModelCalled(string $model)
That the given model was called at least once.
assertToolCalled(string $tool) and assertToolCallCount(int $expectedCount)
The tools the model actually invoked.
assertToolRegistered(string $tool)
That a tool is configured and therefore available to be called. Careful: the tools are collected from every toolbox of the application, not only from the agent that handled the request - so this says that a tool exists, not that this request used it. assertToolCalled() is what answers the latter.

Reading the collected data

For anything the assertions do not cover, getAiDataCollector() hands back the collector itself. Its data is collected as objects, so the input of a platform call is the MessageBag that was sent, and the metadata is a Metadata instance carrying the token usage the platform reported - there is nothing to parse:

1
2
3
4
$call = self::getAiDataCollector()->getPlatformCalls()[0];

$this->assertNotNull($call['input']->getSystemMessage());
$this->assertGreaterThan(0, $call['metadata']->get('token_usage')->getPromptTokens());

Next to getPlatformCalls(), getToolCalls() and getTools(), the collector exposes the agents, message stores and stores that took part in the request.

Note

The data collector only exists when the profiler is enabled, so the profiler needs to be available in the test environment. Browser-based tests that drive the application in a separate web server - Panther, for instance - cannot reach the collector through the container, and are not covered by the trait.

Message stores

Message stores keep the conversations of your agents across requests. The agent loads the conversation with the ID of the message bag and saves it with each new message. See Storing Conversations for how this works.

Configuring message stores

Message stores are defined in the message_store section of your configuration:

1
2
3
4
5
6
7
8
ai:
    # ...
    message_store:
        cache:
            youtube:
                service: 'cache.app'
                # prefix of the cache keys, defaults to the name of the message store
                key: 'youtube'

Use the message_store option of an agent to continue and save its conversations in the store:

1
2
3
4
5
6
ai:
    # ...
    agent:
        youtube:
            model: 'gpt-5-mini'
            message_store: 'ai.message_store.cache.youtube'

The calling code then sends only the new message, with the ID of the conversation:

1
2
3
4
5
6
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

$messages = (new MessageBag(Message::ofUser('And what about the second video?')))->withId($conversationId);

$answer = $agent->call($messages)->asText();

The cache store needs no setup. Other message stores need a table, an index or a collection (e.g. Doctrine DBAL). Create it before the first conversation, and remove it with all its conversations when you no longer need it:

1
2
$ php bin/console ai:message-store:setup ai.message_store.doctrine.dbal.main
$ php bin/console ai:message-store:drop ai.message_store.doctrine.dbal.main --force

Speech

The bundle can automatically decorate any agent with SpeechAgent to add speech capabilities (speech-to-text and/or text-to-speech). This enables STT, TTS, or full STS (speech-to-speech) pipelines, leading to a more "human-like" conversation flow.

Configuring speech

Add a speech key to any agent configuration to enable speech capabilities:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# config/packages/ai.yaml
ai:
    platform:
        elevenlabs:
            api_key: '%env(ELEVEN_LABS_API_KEY)%'
        openai:
            api_key: '%env(OPENAI_API_KEY)%'

    agent:
        my_agent:
            platform: ai.platform.openai
            model: gpt-4o
            speech:
                text_to_speech_platform: 'ai.platform.elevenlabs'
                tts_model: eleven_multilingual_v2
                tts_options:
                    voice: Dslrhjl3ZpzrctukrQSN
                speech_to_text_platform: 'ai.platform.openai'
                stt_model: whisper-1

The bundle automatically decorates the agent with SpeechAgent. At least one of text_to_speech_platform or speech_to_text_platform must be configured. Both can be enabled independently:

  • TTS only: configure text_to_speech_platform along with tts_model (and optionally tts_options) to convert the agent's text response to audio
  • STT only: configure speech_to_text_platform along with stt_model (and optionally stt_options) to transcribe audio input before sending it to the agent
  • STS: configure both for a full speech-to-speech pipeline

Speech is disabled by default and can be disabled when needed with speech: false:

1
2
3
4
5
ai:
    agent:
        my_agent:
            model: gpt-4o
            speech: false

Using the result

When TTS is configured, the decorated agent's execution resolves to the speech result (a BinaryResult). The original text from the LLM is available via the result's metadata (under the text key):

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

$result->getMetadata()->get('text');    // text from the LLM
$result->getContent();                  // raw audio bytes
$result->asFile('/tmp/speech.mp3');     // save audio to file
$result->toDataUri('audio/mpeg');       // data URI for embedding in HTML

When only STT is configured (no TTS), the agent returns the same result type as the inner agent (typically a TextResult) without the text metadata.

Note

Handling both speech-to-text and text-to-speech 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