diff --git a/src/agent/CHANGELOG.md b/src/agent/CHANGELOG.md index 7ab3b901b..b82ee4f76 100644 --- a/src/agent/CHANGELOG.md +++ b/src/agent/CHANGELOG.md @@ -54,3 +54,4 @@ CHANGELOG * Add model capability detection before processing * Add comprehensive type safety with full PHP type hints * Add clear exception hierarchy for different error scenarios + * Add translation support for system prompts diff --git a/src/agent/composer.json b/src/agent/composer.json index 727b52e7b..40cb5b2b0 100644 --- a/src/agent/composer.json +++ b/src/agent/composer.json @@ -41,7 +41,8 @@ "symfony/css-selector": "^6.4 || ^7.1", "symfony/dom-crawler": "^6.4 || ^7.1", "symfony/event-dispatcher": "^6.4 || ^7.1", - "symfony/http-foundation": "^6.4 || ^7.1" + "symfony/http-foundation": "^6.4 || ^7.1", + "symfony/translation-contracts": "^3.6" }, "autoload": { "psr-4": { diff --git a/src/agent/src/InputProcessor/SystemPromptInputProcessor.php b/src/agent/src/InputProcessor/SystemPromptInputProcessor.php index e6a077d60..c6bd60173 100644 --- a/src/agent/src/InputProcessor/SystemPromptInputProcessor.php +++ b/src/agent/src/InputProcessor/SystemPromptInputProcessor.php @@ -13,11 +13,13 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\AI\Agent\Exception\RuntimeException; use Symfony\AI\Agent\Input; use Symfony\AI\Agent\InputProcessorInterface; use Symfony\AI\Agent\Toolbox\ToolboxInterface; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Tool\Tool; +use Symfony\Contracts\Translation\TranslatorInterface; /** * @author Christopher Hertel @@ -31,8 +33,14 @@ public function __construct( private \Stringable|string $systemPrompt, private ?ToolboxInterface $toolbox = null, + private ?TranslatorInterface $translator = null, + private bool $enableTranslation = false, + private ?string $translationDomain = null, private LoggerInterface $logger = new NullLogger(), ) { + if ($this->enableTranslation && !$this->translator) { + throw new RuntimeException('Prompt translation is enabled but no translator was provided.'); + } } public function processInput(Input $input): void @@ -45,7 +53,9 @@ public function processInput(Input $input): void return; } - $message = (string) $this->systemPrompt; + $message = $this->enableTranslation + ? $this->translator->trans((string) $this->systemPrompt, [], $this->translationDomain) + : (string) $this->systemPrompt; if ($this->toolbox instanceof ToolboxInterface && [] !== $this->toolbox->getTools() @@ -61,7 +71,7 @@ public function processInput(Input $input): void )); $message = <<systemPrompt} + {$message} # Tools diff --git a/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php b/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php index 1370748b4..e0177ff4d 100644 --- a/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php +++ b/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php @@ -29,6 +29,7 @@ use Symfony\AI\Platform\Result\ToolCall; use Symfony\AI\Platform\Tool\ExecutionReference; use Symfony\AI\Platform\Tool\Tool; +use Symfony\Contracts\Translation\TranslatorInterface; #[CoversClass(SystemPromptInputProcessor::class)] #[UsesClass(Gpt::class)] @@ -89,7 +90,7 @@ public function execute(ToolCall $toolCall): mixed { return null; } - } + }, ); $input = new Input(new Gpt(), new MessageBag(Message::ofUser('This is a user message'))); @@ -105,7 +106,7 @@ public function execute(ToolCall $toolCall): mixed public function testIncludeToolDefinitions() { $processor = new SystemPromptInputProcessor( - 'This is a system prompt', + 'This is a', new class implements ToolboxInterface { public function getTools(): array { @@ -127,7 +128,9 @@ public function execute(ToolCall $toolCall): mixed { return null; } - } + }, + $this->getTranslator(), + true, ); $input = new Input(new Gpt(), new MessageBag(Message::ofUser('This is a user message'))); @@ -138,7 +141,7 @@ public function execute(ToolCall $toolCall): mixed $this->assertInstanceOf(SystemMessage::class, $messages[0]); $this->assertInstanceOf(UserMessage::class, $messages[1]); $this->assertSame(<<content); } + + public function testWithTranslatedSystemPrompt() + { + $processor = new SystemPromptInputProcessor('This is a', null, $this->getTranslator(), true); + + $input = new Input(new Gpt(), 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 cool translated system prompt', $messages[0]->content); + } + + public function testWithTranslationDomainSystemPrompt() + { + $processor = new SystemPromptInputProcessor( + 'This is a', + null, + $this->getTranslator(), + true, + 'prompts' + ); + + $input = new Input(new Gpt(), new MessageBag(), []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + $this->assertCount(1, $messages); + $this->assertInstanceOf(SystemMessage::class, $messages[0]); + $this->assertSame('This is a cool translated system prompt with a translation domain', $messages[0]->content); + } + + public function testWithMissingTranslator() + { + $this->expectExceptionMessage('Prompt translation is enabled but no translator was provided'); + + new SystemPromptInputProcessor( + 'This is a', + null, + null, + true, + ); + } + + private function getTranslator(): TranslatorInterface + { + return new class implements TranslatorInterface { + public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + $translated = \sprintf('%s cool translated system prompt', $id); + + return $domain ? $translated.' with a translation domain' : $translated; + } + + public function getLocale(): string + { + return 'en'; + } + }; + } } diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 86ee44f31..8bfa9a662 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -18,6 +18,7 @@ use Symfony\AI\Platform\PlatformInterface; use Symfony\AI\Store\Document\VectorizerInterface; use Symfony\AI\Store\StoreInterface; +use Symfony\Contracts\Translation\TranslatorInterface; return static function (DefinitionConfigurator $configurator): void { $configurator->rootNode() @@ -154,6 +155,12 @@ }) ->thenInvalid('The "text" cannot be empty.') ->end() + ->validate() + ->ifTrue(function ($v) { + return \is_array($v) && $v['enabled'] && !interface_exists(TranslatorInterface::class); + }) + ->thenInvalid('System prompt translation is enabled, but no translator is present. Try running `composer require symfony/translation`.') + ->end() ->children() ->scalarNode('text') ->info('The system prompt text') @@ -162,6 +169,14 @@ ->info('Include tool definitions at the end of the system prompt') ->defaultFalse() ->end() + ->booleanNode('enable_translation') + ->info('Enable translation for the system prompt') + ->defaultFalse() + ->end() + ->scalarNode('translation_domain') + ->info('The translation domain for the system prompt') + ->defaultNull() + ->end() ->end() ->end() ->arrayNode('tools') diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index eb60a8341..ddd39c91b 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -611,6 +611,9 @@ private function processAgentConfig(string $name, array $config, ContainerBuilde ->setArguments([ $config['prompt']['text'], $includeTools ? new Reference('ai.toolbox.'.$name) : null, + new Reference('translator', ContainerInterface::NULL_ON_INVALID_REFERENCE), + $config['prompt']['enable_translation'], + $config['prompt']['translation_domain'], new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), ]) ->addTag('ai.agent.input_processor', ['agent' => $agentId, 'priority' => -30]); diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index f973ca9f9..ddc4bc925 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -471,11 +471,13 @@ public function testMultipleAgentsWithProcessors() $firstSystemPrompt = $container->getDefinition('ai.agent.first_agent.system_prompt_processor'); $firstSystemTags = $firstSystemPrompt->getTag('ai.agent.input_processor'); $this->assertSame($firstAgentId, $firstSystemTags[0]['agent']); + $this->assertCount(3, array_filter($firstSystemPrompt->getArguments())); // Second agent system prompt processor $secondSystemPrompt = $container->getDefinition('ai.agent.second_agent.system_prompt_processor'); $secondSystemTags = $secondSystemPrompt->getTag('ai.agent.input_processor'); $this->assertSame($secondAgentId, $secondSystemTags[0]['agent']); + $this->assertCount(3, array_filter($secondSystemPrompt->getArguments())); } #[TestDox('Processors work correctly when using the default toolbox')] @@ -658,6 +660,8 @@ public function testSystemPromptWithArrayStructure() 'model' => ['class' => 'Symfony\AI\Platform\Bridge\OpenAi\Gpt'], 'prompt' => [ 'text' => 'You are a helpful assistant.', + 'enable_translation' => true, + 'translation_domain' => 'prompts', ], 'tools' => [ ['service' => 'some_tool', 'description' => 'Test tool'], @@ -673,6 +677,8 @@ public function testSystemPromptWithArrayStructure() $this->assertSame('You are a helpful assistant.', $arguments[0]); $this->assertNull($arguments[1]); // include_tools is false, so null reference + $this->assertTrue($arguments[3]); + $this->assertSame('prompts', $arguments[4]); } #[TestDox('System prompt with include_tools enabled works correctly')]