diff --git a/demo/config/packages/ai.yaml b/demo/config/packages/ai.yaml index 19912c323..ed37f2a33 100644 --- a/demo/config/packages/ai.yaml +++ b/demo/config/packages/ai.yaml @@ -13,11 +13,8 @@ ai: method: 'now' stream: model: 'gpt-4o-mini' - prompt: | - You are an example chat application where messages from the LLM are streamed to the user using - Server-Sent Events via `symfony/ux-turbo` / Turbo Streams. This example does not use any custom - javascript and solely relies on the built-in `live` & `turbo_stream` Stimulus controllers. - Whatever the user asks, tell them about the application & used technologies. + prompt: + file: '%kernel.project_dir%/prompts/stream-chat.txt' tools: false youtube: model: 'gpt-4o-mini' diff --git a/demo/prompts/stream-chat.txt b/demo/prompts/stream-chat.txt new file mode 100644 index 000000000..e5fbcdc19 --- /dev/null +++ b/demo/prompts/stream-chat.txt @@ -0,0 +1,4 @@ +You are an example chat application where messages from the LLM are streamed to the user using +Server-Sent Events via `symfony/ux-turbo` / Turbo Streams. This example does not use any custom +javascript and solely relies on the built-in `live` & `turbo_stream` Stimulus controllers. +Whatever the user asks, tell them about the application & used technologies. diff --git a/examples/misc/prompt-json-file.php b/examples/misc/prompt-json-file.php new file mode 100644 index 000000000..f02c5f787 --- /dev/null +++ b/examples/misc/prompt-json-file.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY'], http_client()); + +// Load system prompt from a JSON file +$promptFile = File::fromFile(dirname(__DIR__, 2).'/fixtures/prompts/code-reviewer.json'); +$systemPromptProcessor = new SystemPromptInputProcessor($promptFile); + +$agent = new Agent($platform, 'gpt-4o-mini', [$systemPromptProcessor], logger: logger()); +$messages = new MessageBag(Message::ofUser('Review this code: function add($a, $b) { return $a + $b; }')); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/examples/misc/prompt-text-file.php b/examples/misc/prompt-text-file.php new file mode 100644 index 000000000..e30513ba3 --- /dev/null +++ b/examples/misc/prompt-text-file.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY'], http_client()); + +// Load system prompt from a plain text file (.txt) +$promptFile = File::fromFile(dirname(__DIR__, 2).'/fixtures/prompts/helpful-assistant.txt'); +$systemPromptProcessor = new SystemPromptInputProcessor($promptFile); + +$agent = new Agent($platform, 'gpt-4o-mini', [$systemPromptProcessor], logger: logger()); +$messages = new MessageBag(Message::ofUser('Can you explain what dependency injection is?')); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/fixtures/prompts/code-reviewer.json b/fixtures/prompts/code-reviewer.json new file mode 100644 index 000000000..01415f782 --- /dev/null +++ b/fixtures/prompts/code-reviewer.json @@ -0,0 +1,12 @@ +{ + "role": "You are an expert code reviewer with deep knowledge of software engineering best practices, design patterns, and code quality.", + "responsibilities": [ + "Review code for bugs and potential issues", + "Suggest improvements for code quality and maintainability", + "Identify security vulnerabilities", + "Recommend better design patterns when appropriate", + "Ensure code follows language-specific best practices" + ], + "tone": "constructive and educational", + "approach": "Provide thorough but concise feedback with specific suggestions and examples when helpful" +} diff --git a/fixtures/prompts/helpful-assistant.txt b/fixtures/prompts/helpful-assistant.txt new file mode 100644 index 000000000..a3f2eace7 --- /dev/null +++ b/fixtures/prompts/helpful-assistant.txt @@ -0,0 +1,8 @@ +You are a helpful and knowledgeable assistant. Your goal is to provide accurate, concise, and useful responses to user queries. + +Guidelines: +- Be clear and direct in your responses +- Provide examples when appropriate +- If you're unsure about something, say so +- Be respectful and professional at all times +- Break down complex topics into understandable explanations diff --git a/src/agent/src/InputProcessor/SystemPromptInputProcessor.php b/src/agent/src/InputProcessor/SystemPromptInputProcessor.php index ad7871abb..f96e87071 100644 --- a/src/agent/src/InputProcessor/SystemPromptInputProcessor.php +++ b/src/agent/src/InputProcessor/SystemPromptInputProcessor.php @@ -17,6 +17,7 @@ use Symfony\AI\Agent\Input; use Symfony\AI\Agent\InputProcessorInterface; use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Message\Content\File; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Tool\Tool; use Symfony\Contracts\Translation\TranslatableInterface; @@ -28,11 +29,11 @@ final readonly class SystemPromptInputProcessor implements InputProcessorInterface { /** - * @param \Stringable|TranslatableInterface|string $systemPrompt the system prompt to prepend to the input messages - * @param ToolboxInterface|null $toolbox the tool box to be used to append the tool definitions to the system prompt + * @param \Stringable|TranslatableInterface|string|File $systemPrompt the system prompt to prepend to the input messages, or a File object to read from + * @param ToolboxInterface|null $toolbox the tool box to be used to append the tool definitions to the system prompt */ public function __construct( - private \Stringable|TranslatableInterface|string $systemPrompt, + private \Stringable|TranslatableInterface|string|File $systemPrompt, private ?ToolboxInterface $toolbox = null, private ?TranslatorInterface $translator = null, private LoggerInterface $logger = new NullLogger(), @@ -52,9 +53,13 @@ public function processInput(Input $input): void return; } - $message = $this->systemPrompt instanceof TranslatableInterface - ? $this->systemPrompt->trans($this->translator) - : (string) $this->systemPrompt; + if ($this->systemPrompt instanceof File) { + $message = $this->systemPrompt->asBinary(); + } elseif ($this->systemPrompt instanceof TranslatableInterface) { + $message = $this->systemPrompt->trans($this->translator); + } else { + $message = (string) $this->systemPrompt; + } if ($this->toolbox instanceof ToolboxInterface && [] !== $this->toolbox->getTools() diff --git a/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php b/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php index 1b5bcfe05..8e0f6d628 100644 --- a/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php +++ b/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php @@ -18,6 +18,7 @@ use Symfony\AI\Fixtures\Tool\ToolNoParams; use Symfony\AI\Fixtures\Tool\ToolRequiredParams; use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Message\Content\File; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Message\SystemMessage; @@ -221,6 +222,49 @@ public function testWithMissingTranslator() ); } + public function testProcessInputWithFile() + { + $tempFile = tempnam(sys_get_temp_dir(), 'prompt_'); + file_put_contents($tempFile, 'This is a system prompt from a file'); + + try { + $file = File::fromFile($tempFile); + $processor = new SystemPromptInputProcessor($file); + + $input = new Input(new Gpt('gpt-4o'), new MessageBag(Message::ofUser('This is a user message'))); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + $this->assertCount(2, $messages); + $this->assertInstanceOf(SystemMessage::class, $messages[0]); + $this->assertInstanceOf(UserMessage::class, $messages[1]); + $this->assertSame('This is a system prompt from a file', $messages[0]->content); + } finally { + unlink($tempFile); + } + } + + public function testProcessInputWithMultilineFile() + { + $tempFile = tempnam(sys_get_temp_dir(), 'prompt_'); + file_put_contents($tempFile, "Line 1\nLine 2\nLine 3"); + + try { + $file = File::fromFile($tempFile); + $processor = new SystemPromptInputProcessor($file); + + $input = new Input(new Gpt('gpt-4o'), new MessageBag(Message::ofUser('This is a user message'))); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + $this->assertCount(2, $messages); + $this->assertInstanceOf(SystemMessage::class, $messages[0]); + $this->assertSame("Line 1\nLine 2\nLine 3", $messages[0]->content); + } finally { + unlink($tempFile); + } + } + private function getTranslator(): TranslatorInterface { return new class implements TranslatorInterface { diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 714a2836d..c1e3b57f4 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -306,22 +306,35 @@ return ['text' => $v]; }) ->end() - ->beforeNormalization() - ->ifArray() - ->then(function (array $v) { - if (!isset($v['text']) && !isset($v['include_tools'])) { - throw new \InvalidArgumentException('Either "text" or "include_tools" must be configured for prompt.'); + ->validate() + ->ifTrue(function ($v) { + if (!\is_array($v)) { + return false; } + $hasTextOrFile = isset($v['text']) || isset($v['file']); - return $v; + return !$hasTextOrFile; + }) + ->thenInvalid('Either "text" or "file" must be configured for prompt.') + ->end() + ->validate() + ->ifTrue(function ($v) { + return \is_array($v) && isset($v['text']) && isset($v['file']); }) + ->thenInvalid('Cannot use both "text" and "file" for prompt. Choose one.') ->end() ->validate() ->ifTrue(function ($v) { - return \is_array($v) && '' === trim($v['text'] ?? ''); + return \is_array($v) && isset($v['text']) && '' === trim($v['text']); }) ->thenInvalid('The "text" cannot be empty.') ->end() + ->validate() + ->ifTrue(function ($v) { + return \is_array($v) && isset($v['file']) && '' === trim($v['file']); + }) + ->thenInvalid('The "file" cannot be empty.') + ->end() ->validate() ->ifTrue(function ($v) { return \is_array($v) && ($v['enabled'] ?? false) && !interface_exists(TranslatorInterface::class); @@ -332,6 +345,9 @@ ->stringNode('text') ->info('The system prompt text') ->end() + ->stringNode('file') + ->info('Path to file containing the system prompt') + ->end() ->booleanNode('include_tools') ->info('Include tool definitions at the end of the system prompt') ->defaultFalse() diff --git a/src/ai-bundle/doc/index.rst b/src/ai-bundle/doc/index.rst index 8c7a8d728..085a46dbc 100644 --- a/src/ai-bundle/doc/index.rst +++ b/src/ai-bundle/doc/index.rst @@ -275,11 +275,62 @@ For more control, such as including tool definitions in the system prompt, use t The array format supports these options: -* ``text`` (string, required): The system prompt text that will be sent to the AI model +* ``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: + +.. code-block:: yaml + + 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. + +**Example Text File** (``prompts/assistant.txt``): + +.. code-block:: text + + 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``): + +.. code-block:: json + + { + "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: diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 5a4efb1e8..3c03bec5d 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -50,6 +50,7 @@ use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory as VertexAiPlatformFactory; use Symfony\AI\Platform\Bridge\Voyage\PlatformFactory as VoyagePlatformFactory; use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Message\Content\File; use Symfony\AI\Platform\ModelClientInterface; use Symfony\AI\Platform\Platform; use Symfony\AI\Platform\PlatformInterface; @@ -652,14 +653,28 @@ private function processAgentConfig(string $name, array $config, ContainerBuilde if (isset($config['prompt'])) { $includeTools = isset($config['prompt']['include_tools']) && $config['prompt']['include_tools']; - if ($config['prompt']['enable_translation']) { - if (!class_exists(TranslatableMessage::class)) { - throw new RuntimeException('For using prompt translataion, symfony/translation package is required. Try running "composer require symfony/translation".'); - } + // Create prompt from file if configured, otherwise use text + if (isset($config['prompt']['file'])) { + $filePath = $config['prompt']['file']; + // File::fromFile() handles validation, so no need to check here + // Use Definition with factory method because File objects cannot be serialized during container compilation + $prompt = (new Definition(File::class)) + ->setFactory([File::class, 'fromFile']) + ->setArguments([$filePath]); + } elseif (isset($config['prompt']['text'])) { + $promptText = $config['prompt']['text']; + + if ($config['prompt']['enable_translation']) { + if (!class_exists(TranslatableMessage::class)) { + throw new RuntimeException('For using prompt translataion, symfony/translation package is required. Try running "composer require symfony/translation".'); + } - $prompt = new TranslatableMessage($config['prompt']['text'], domain: $config['prompt']['translation_domain']); + $prompt = new TranslatableMessage($promptText, domain: $config['prompt']['translation_domain']); + } else { + $prompt = $promptText; + } } else { - $prompt = $config['prompt']['text']; + $prompt = ''; } $systemPromptInputProcessorDefinition = (new Definition(SystemPromptInputProcessor::class)) diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 015c376b8..eede239a1 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -835,11 +835,29 @@ public function testEmptyTextInArrayThrowsException() ]); } - #[TestDox('System prompt array without text key throws configuration exception')] + #[TestDox('System prompt array without text or file throws configuration exception')] public function testSystemPromptArrayWithoutTextKeyThrowsException() { $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('The "text" cannot be empty.'); + $this->expectExceptionMessage('Either "text" or "file" must be configured for prompt.'); + + $this->buildContainer([ + 'ai' => [ + 'agent' => [ + 'test_agent' => [ + 'model' => 'gpt-4', + 'prompt' => [], + ], + ], + ], + ]); + } + + #[TestDox('System prompt with only include_tools throws configuration exception')] + public function testSystemPromptWithOnlyIncludeToolsThrowsException() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Either "text" or "file" must be configured for prompt.'); $this->buildContainer([ 'ai' => [