Skip to content

Commit ce9af5d

Browse files
committed
[AI Bundle] Add support for loading agent prompts from files
This change allows users to configure agent prompts via file paths using the 'prompt.file' configuration option. The implementation uses Symfony's HttpFoundation File class for clean, idiomatic file handling. Configuration example: ```yaml ai: agent: my_agent: platform: ai.platform.openai model: gpt-4o-mini prompt: file: '%kernel.project_dir%/config/prompts/assistant.txt' ``` Programmatic example: ```php $file = new File('/path/to/prompt.txt'); $processor = new SystemPromptInputProcessor($file); $agent = new Agent($platform, 'gpt-4o-mini', [$processor]); ``` Key features: - prompt.text and prompt.file are mutually exclusive - File existence and readability validated at container build time - Works with any text-based file format (.txt, .json, .md, etc.) - Uses Symfony\Component\HttpFoundation\File\File for clean API - File content loaded lazily via $file->getContent() Changes: - Added symfony/http-foundation dependency to agent component - SystemPromptInputProcessor now accepts File objects - Added prompt.file configuration option with validation - AiBundle creates File service definition from file path - Comprehensive tests for single and multiline files Examples: - examples/misc/prompt-text-file.php - Text file example - examples/misc/prompt-json-file.php - JSON file example - fixtures/prompts/helpful-assistant.txt - Example text prompt - fixtures/prompts/code-reviewer.json - Example JSON prompt
1 parent b602bb9 commit ce9af5d

File tree

8 files changed

+187
-14
lines changed

8 files changed

+187
-14
lines changed

examples/misc/prompt-json-file.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor;
14+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
15+
use Symfony\AI\Platform\Message\Message;
16+
use Symfony\AI\Platform\Message\MessageBag;
17+
use Symfony\Component\HttpFoundation\File\File;
18+
19+
require_once dirname(__DIR__).'/bootstrap.php';
20+
21+
$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY'], http_client());
22+
23+
// Load system prompt from a JSON file
24+
$promptFile = new File(dirname(__DIR__, 2).'/fixtures/prompts/code-reviewer.json');
25+
$systemPromptProcessor = new SystemPromptInputProcessor($promptFile);
26+
27+
$agent = new Agent($platform, 'gpt-4o-mini', [$systemPromptProcessor], logger: logger());
28+
$messages = new MessageBag(Message::ofUser('Review this code: function add($a, $b) { return $a + $b; }'));
29+
$result = $agent->call($messages);
30+
31+
echo $result->getContent().\PHP_EOL;

examples/misc/prompt-text-file.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor;
14+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
15+
use Symfony\AI\Platform\Message\Message;
16+
use Symfony\AI\Platform\Message\MessageBag;
17+
use Symfony\Component\HttpFoundation\File\File;
18+
19+
require_once dirname(__DIR__).'/bootstrap.php';
20+
21+
$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY'], http_client());
22+
23+
// Load system prompt from a plain text file (.txt)
24+
$promptFile = new File(dirname(__DIR__, 2).'/fixtures/prompts/helpful-assistant.txt');
25+
$systemPromptProcessor = new SystemPromptInputProcessor($promptFile);
26+
27+
$agent = new Agent($platform, 'gpt-4o-mini', [$systemPromptProcessor], logger: logger());
28+
$messages = new MessageBag(Message::ofUser('Can you explain what dependency injection is?'));
29+
$result = $agent->call($messages);
30+
31+
echo $result->getContent().\PHP_EOL;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"role": "You are an expert code reviewer with deep knowledge of software engineering best practices, design patterns, and code quality.",
3+
"responsibilities": [
4+
"Review code for bugs and potential issues",
5+
"Suggest improvements for code quality and maintainability",
6+
"Identify security vulnerabilities",
7+
"Recommend better design patterns when appropriate",
8+
"Ensure code follows language-specific best practices"
9+
],
10+
"tone": "constructive and educational",
11+
"approach": "Provide thorough but concise feedback with specific suggestions and examples when helpful"
12+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
You are a helpful and knowledgeable assistant. Your goal is to provide accurate, concise, and useful responses to user queries.
2+
3+
Guidelines:
4+
- Be clear and direct in your responses
5+
- Provide examples when appropriate
6+
- If you're unsure about something, say so
7+
- Be respectful and professional at all times
8+
- Break down complex topics into understandable explanations

src/agent/src/InputProcessor/SystemPromptInputProcessor.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\AI\Agent\Toolbox\ToolboxInterface;
2020
use Symfony\AI\Platform\Message\Message;
2121
use Symfony\AI\Platform\Tool\Tool;
22+
use Symfony\Component\HttpFoundation\File\File;
2223
use Symfony\Contracts\Translation\TranslatableInterface;
2324
use Symfony\Contracts\Translation\TranslatorInterface;
2425

@@ -28,18 +29,22 @@
2829
final readonly class SystemPromptInputProcessor implements InputProcessorInterface
2930
{
3031
/**
31-
* @param \Stringable|TranslatableInterface|string $systemPrompt the system prompt to prepend to the input messages
32-
* @param ToolboxInterface|null $toolbox the tool box to be used to append the tool definitions to the system prompt
32+
* @param \Stringable|TranslatableInterface|string|File $systemPrompt the system prompt to prepend to the input messages, or a File object to read from
33+
* @param ToolboxInterface|null $toolbox the tool box to be used to append the tool definitions to the system prompt
3334
*/
3435
public function __construct(
35-
private \Stringable|TranslatableInterface|string $systemPrompt,
36+
private \Stringable|TranslatableInterface|string|File $systemPrompt,
3637
private ?ToolboxInterface $toolbox = null,
3738
private ?TranslatorInterface $translator = null,
3839
private LoggerInterface $logger = new NullLogger(),
3940
) {
4041
if ($this->systemPrompt instanceof TranslatableInterface && !$this->translator) {
4142
throw new RuntimeException('Translatable system prompt is not supported when no translator is provided.');
4243
}
44+
45+
if ($this->systemPrompt instanceof File && !class_exists(File::class)) {
46+
throw new RuntimeException('File-based system prompt requires symfony/http-foundation. Try running "composer require symfony/http-foundation".');
47+
}
4348
}
4449

4550
public function processInput(Input $input): void
@@ -52,9 +57,13 @@ public function processInput(Input $input): void
5257
return;
5358
}
5459

55-
$message = $this->systemPrompt instanceof TranslatableInterface
56-
? $this->systemPrompt->trans($this->translator)
57-
: (string) $this->systemPrompt;
60+
if ($this->systemPrompt instanceof File) {
61+
$message = $this->systemPrompt->getContent();
62+
} elseif ($this->systemPrompt instanceof TranslatableInterface) {
63+
$message = $this->systemPrompt->trans($this->translator);
64+
} else {
65+
$message = (string) $this->systemPrompt;
66+
}
5867

5968
if ($this->toolbox instanceof ToolboxInterface
6069
&& [] !== $this->toolbox->getTools()

src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Symfony\AI\Platform\Result\ToolCall;
2626
use Symfony\AI\Platform\Tool\ExecutionReference;
2727
use Symfony\AI\Platform\Tool\Tool;
28+
use Symfony\Component\HttpFoundation\File\File;
2829
use Symfony\Component\Translation\TranslatableMessage;
2930
use Symfony\Contracts\Translation\TranslatorInterface;
3031

@@ -221,6 +222,49 @@ public function testWithMissingTranslator()
221222
);
222223
}
223224

225+
public function testProcessInputWithFile()
226+
{
227+
$tempFile = tempnam(sys_get_temp_dir(), 'prompt_');
228+
file_put_contents($tempFile, 'This is a system prompt from a file');
229+
230+
try {
231+
$file = new File($tempFile);
232+
$processor = new SystemPromptInputProcessor($file);
233+
234+
$input = new Input(new Gpt('gpt-4o'), new MessageBag(Message::ofUser('This is a user message')));
235+
$processor->processInput($input);
236+
237+
$messages = $input->messages->getMessages();
238+
$this->assertCount(2, $messages);
239+
$this->assertInstanceOf(SystemMessage::class, $messages[0]);
240+
$this->assertInstanceOf(UserMessage::class, $messages[1]);
241+
$this->assertSame('This is a system prompt from a file', $messages[0]->content);
242+
} finally {
243+
unlink($tempFile);
244+
}
245+
}
246+
247+
public function testProcessInputWithMultilineFile()
248+
{
249+
$tempFile = tempnam(sys_get_temp_dir(), 'prompt_');
250+
file_put_contents($tempFile, "Line 1\nLine 2\nLine 3");
251+
252+
try {
253+
$file = new File($tempFile);
254+
$processor = new SystemPromptInputProcessor($file);
255+
256+
$input = new Input(new Gpt('gpt-4o'), new MessageBag(Message::ofUser('This is a user message')));
257+
$processor->processInput($input);
258+
259+
$messages = $input->messages->getMessages();
260+
$this->assertCount(2, $messages);
261+
$this->assertInstanceOf(SystemMessage::class, $messages[0]);
262+
$this->assertSame("Line 1\nLine 2\nLine 3", $messages[0]->content);
263+
} finally {
264+
unlink($tempFile);
265+
}
266+
}
267+
224268
private function getTranslator(): TranslatorInterface
225269
{
226270
return new class implements TranslatorInterface {

src/ai-bundle/config/options.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,19 +309,37 @@
309309
->beforeNormalization()
310310
->ifArray()
311311
->then(function (array $v) {
312-
if (!isset($v['text']) && !isset($v['include_tools'])) {
313-
throw new \InvalidArgumentException('Either "text" or "include_tools" must be configured for prompt.');
312+
if (!isset($v['text']) && !isset($v['file']) && !isset($v['include_tools'])) {
313+
throw new \InvalidArgumentException('Either "text", "file" or "include_tools" must be configured for prompt.');
314314
}
315315

316316
return $v;
317317
})
318318
->end()
319319
->validate()
320320
->ifTrue(function ($v) {
321-
return \is_array($v) && '' === trim($v['text'] ?? '');
321+
return \is_array($v) && isset($v['text']) && isset($v['file']);
322+
})
323+
->thenInvalid('Cannot use both "text" and "file" for prompt. Choose one.')
324+
->end()
325+
->validate()
326+
->ifTrue(function ($v) {
327+
return \is_array($v) && isset($v['text']) && '' === trim($v['text']);
322328
})
323329
->thenInvalid('The "text" cannot be empty.')
324330
->end()
331+
->validate()
332+
->ifTrue(function ($v) {
333+
return \is_array($v) && isset($v['file']) && '' === trim($v['file']);
334+
})
335+
->thenInvalid('The "file" cannot be empty.')
336+
->end()
337+
->validate()
338+
->ifTrue(function ($v) {
339+
return \is_array($v) && isset($v['file']) && !class_exists(\Symfony\Component\HttpFoundation\File\File::class);
340+
})
341+
->thenInvalid('File-based system prompt requires symfony/http-foundation. Try running `composer require symfony/http-foundation`.')
342+
->end()
325343
->validate()
326344
->ifTrue(function ($v) {
327345
return \is_array($v) && ($v['enabled'] ?? false) && !interface_exists(TranslatorInterface::class);
@@ -332,6 +350,9 @@
332350
->stringNode('text')
333351
->info('The system prompt text')
334352
->end()
353+
->stringNode('file')
354+
->info('Path to file containing the system prompt (JSON format supported)')
355+
->end()
335356
->booleanNode('include_tools')
336357
->info('Include tool definitions at the end of the system prompt')
337358
->defaultFalse()

src/ai-bundle/src/AiBundle.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
8585
use Symfony\Component\DependencyInjection\Reference;
8686
use Symfony\Component\HttpClient\HttpClient;
87+
use Symfony\Component\HttpFoundation\File\File;
8788
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
8889
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
8990
use Symfony\Component\Translation\TranslatableMessage;
@@ -652,14 +653,30 @@ private function processAgentConfig(string $name, array $config, ContainerBuilde
652653
if (isset($config['prompt'])) {
653654
$includeTools = isset($config['prompt']['include_tools']) && $config['prompt']['include_tools'];
654655

655-
if ($config['prompt']['enable_translation']) {
656-
if (!class_exists(TranslatableMessage::class)) {
657-
throw new RuntimeException('For using prompt translataion, symfony/translation package is required. Try running "composer require symfony/translation".');
656+
// Create prompt from file if configured, otherwise use text
657+
if (isset($config['prompt']['file'])) {
658+
$filePath = $config['prompt']['file'];
659+
if (!file_exists($filePath)) {
660+
throw new InvalidArgumentException(\sprintf('Prompt file "%s" does not exist.', $filePath));
658661
}
662+
if (!is_readable($filePath)) {
663+
throw new InvalidArgumentException(\sprintf('Prompt file "%s" is not readable.', $filePath));
664+
}
665+
$prompt = new Definition(File::class, [$filePath]);
666+
} elseif (isset($config['prompt']['text'])) {
667+
$promptText = $config['prompt']['text'];
668+
669+
if ($config['prompt']['enable_translation']) {
670+
if (!class_exists(TranslatableMessage::class)) {
671+
throw new RuntimeException('For using prompt translataion, symfony/translation package is required. Try running "composer require symfony/translation".');
672+
}
659673

660-
$prompt = new TranslatableMessage($config['prompt']['text'], domain: $config['prompt']['translation_domain']);
674+
$prompt = new TranslatableMessage($promptText, domain: $config['prompt']['translation_domain']);
675+
} else {
676+
$prompt = $promptText;
677+
}
661678
} else {
662-
$prompt = $config['prompt']['text'];
679+
$prompt = '';
663680
}
664681

665682
$systemPromptInputProcessorDefinition = (new Definition(SystemPromptInputProcessor::class))

0 commit comments

Comments
 (0)