Symfony Agent Integration¶
Overview¶
This guide shows how to configure and use AI agents in your Symfony application. Unlike the standalone examples which manually create the platform, Symfony applications configure agents through YAML and inject them as services.
Agent Configuration¶
Agents are configured through the ai.agent configuration in config/packages/ai.yaml, not as manual service definitions.
Example configuration:
ai:
agent:
# Default agent using sulu.ai platform
default:
platform: 'sulu_ai_platform.platform'
model: 'default'
# Weather agent with tools
weather_agent:
platform: 'sulu_ai_platform.platform'
model: 'gpt-4o'
prompt:
text: 'You are a helpful weather assistant.'
include_tools: true
tools:
- 'App\AI\Tool\WeatherService'
Using Agents in Services¶
Services inject the AgentInterface to use configured agents:
<?php
namespace App\Service;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
final readonly class WeatherService
{
public function __construct(
private AgentInterface $agent,
) {
}
public function getWeather(string $city): string
{
$messages = new MessageBag(Message::ofUser("What's the weather in {$city}?"));
$response = $this->agent->call($messages);
return $response->getContent();
}
}
Injecting Specific Named Agents¶
To use a specific agent instead of the default:
<?php
namespace App\Service;
use Symfony\AI\Agent\AgentInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class WeatherService
{
public function __construct(
#[Autowire(service: 'ai.agent.weather_agent')]
private AgentInterface $weatherAgent,
) {
}
public function getWeather(string $city): string
{
$messages = new MessageBag(Message::ofUser("What's the weather in {$city}?"));
$response = $this->weatherAgent->call($messages);
return $response->getContent();
}
}
Using Agents in Controllers¶
Inject the configured agent into controllers:
<?php
namespace App\Controller;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class WeatherController extends AbstractController
{
public function __construct(
#[Autowire(service: 'ai.agent.weather_agent')]
private readonly AgentInterface $weatherAgent
) {
}
public function getWeather(Request $request): Response
{
$city = $request->get('city', 'Berlin');
$messages = new MessageBag(Message::ofUser("What's the weather in {$city}?"));
$result = $this->weatherAgent->call($messages);
return new JsonResponse(['weather' => $result->getContent()]);
}
}
Streaming Responses¶
The default model supports streaming. Pass stream: true in the call options and consume the result as a sequence of deltas instead of waiting for the full answer:
<?php
namespace App\Service;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Result\Stream\Delta\TextDelta;
final readonly class WeatherService
{
public function __construct(
private AgentInterface $agent,
) {
}
public function streamWeather(string $city): \Generator
{
$messages = new MessageBag(Message::ofUser("What's the weather in {$city}?"));
$result = $this->agent->call($messages, ['stream' => true]);
foreach ($result->getContent() as $delta) {
if ($delta instanceof TextDelta) {
yield $delta->getText();
}
}
}
}
In a controller, forward the deltas to the browser as Server-Sent Events:
<?php
namespace App\Controller;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Result\Stream\Delta\TextDelta;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class WeatherController extends AbstractController
{
public function __construct(
#[Autowire(service: 'ai.agent.weather_agent')]
private readonly AgentInterface $weatherAgent,
) {
}
public function streamWeather(Request $request): StreamedResponse
{
$city = $request->get('city', 'Berlin');
$messages = new MessageBag(Message::ofUser("What's the weather in {$city}?"));
$result = $this->weatherAgent->call($messages, ['stream' => true]);
$response = new StreamedResponse(function () use ($result): void {
foreach ($result->getContent() as $delta) {
if ($delta instanceof TextDelta) {
echo 'data: ' . \json_encode(['content' => $delta->getText()]) . "\n\n";
\ob_flush();
\flush();
}
}
echo "data: [DONE]\n\n";
\ob_flush();
\flush();
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('X-Accel-Buffering', 'no');
return $response;
}
}
Notes:
- Tool calling still works while streaming: a
ToolCallCompletedelta carries a complete call as soon as it is ready, andSymfony\AI\Agent\Toolbox\StreamListener(insymfony/ai-agent) executes it and continues the stream automatically. Nothing changes on the calling side. - A
MetadataDelta('uuid', ...)is emitted early so the conversation id is available before the answer completes. Streamed calls don't carry token usage the way a non-streaming call does. - Not every model in the catalog supports streaming — check
Symfony\AI\Platform\Capability::OUTPUT_STREAMINGon the model, or simply trystream: trueagainstdefault, which does.
Registering Tools with Agents¶
Tools are registered and configured through the agent configuration:
# config/packages/ai.yaml
ai:
agent:
weather_agent:
platform: 'sulu_ai_platform.platform'
model: 'gpt-4o'
prompt:
text: 'You are a helpful weather assistant.'
include_tools: true
tools:
- 'App\AI\Tool\WeatherService'
Tools must be registered as services with the #[AsTool] attribute or tagged with ai.tool:
<?php
namespace App\AI\Tool;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
#[AsTool]
class WeatherService
{
public function getCurrentWeather(string $city): array
{
// In a real implementation, this would call a weather API
return [
'temperature' => 22,
'conditions' => 'sunny',
'city' => $city,
'humidity' => 65,
'wind_speed' => 12,
];
}
}
Using Intelligent Search as a Tool¶
Agents can use Intelligent Search as a tool — the agent decides when to look something up in your Sulu content and grounds its answers in the search results, citations included.
Wrap the search client in a tool class:
<?php
namespace App\AI\Tool;
use Sulu\Bundle\AiPlatformBundle\Application\IntelligentSearch\IntelligentSearchClientInterface;
use Sulu\Bundle\AiPlatformBundle\Application\IntelligentSearch\IntelligentSearchQuery;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
#[AsTool(
name: 'content_search',
description: 'Searches the website content and returns an answer with source citations. Use this to answer questions about the website, products, or documentation.',
)]
final readonly class ContentSearchTool
{
public function __construct(
private IntelligentSearchClientInterface $intelligentSearch,
) {
}
/**
* @param string $question The natural-language question to search for
* @param string $locale The locale to search in (e.g. "en")
*/
public function __invoke(string $question, string $locale = 'en'): string
{
$query = IntelligentSearchQuery::create($question, $locale);
$result = $this->intelligentSearch->search($query);
$sources = '';
foreach ($result->sources as $source) {
$sources .= \sprintf("\n[%d] %s (%s)", $source->citationNumber, $source->title, $source->url);
}
return $result->answer . "\n\nSources:" . $sources;
}
}
Then register the tool on your agent:
# config/packages/ai.yaml
ai:
agent:
support_agent:
platform: 'sulu_ai_platform.platform'
model: 'default'
prompt:
text: 'You are a support assistant. Answer questions using the content_search tool and always mention your sources.'
include_tools: true
tools:
- 'App\AI\Tool\ContentSearchTool'
Notes:
- Intelligent search must be enabled and ingested — the tool searches the same index as the public endpoint and CLI.
- The query supports the same refinements as everywhere else (
withBuckets(),withWebspaceKey(),withPreferRecent(),withSince()) — see Searching → Query Builder. Fix buckets or the webspace in the tool when the agent should only see a slice of the content. - Returning the citations as part of the tool result lets the agent quote its sources in the final answer.
- Every search made through the client is persisted for the Feedback screens, this one included, and no extra call is needed. The client assigns an id per search and puts it on the result, so a tool can hand it to the frontend and let visitors rate the answer through
POST /api/intelligent-search/feedback:
$result = $this->intelligentSearch->search(IntelligentSearchQuery::create($question, $locale));
// $result->uuid is the id of the persisted row and of the feedback endpoint
return \json_encode(['uuid' => $result->uuid, 'answer' => $result->answer]);
- To keep a search out of those screens — a health check, a warm-up, a test — build the query with
withoutPersistence().
Further Reading¶
- See examples directory for standalone agent examples demonstrating core concepts
- Check the bundle's test suite for MockPlatform testing patterns
- Review the Symfony AI Component documentation for complete reference