diff --git a/docs/bundles/ai-bundle.rst b/docs/bundles/ai-bundle.rst index 06495c138..336d7c2e4 100644 --- a/docs/bundles/ai-bundle.rst +++ b/docs/bundles/ai-bundle.rst @@ -67,7 +67,6 @@ Advanced Example with Multiple Agents agent: rag: platform: 'ai.platform.azure.gpt_deployment' - structured_output: false # Disables support for "output_structure" option, default is true track_token_usage: true # Enable tracking of token usage for the agent, default is true model: 'gpt-4o-mini' memory: 'You have access to conversation history and user preferences' # Optional: static memory content @@ -513,11 +512,6 @@ Configuration # Fallback agent for unmatched requests (required) fallback: 'general' -.. important:: - - The orchestrator agent MUST have ``structured_output: true`` (the default) to work correctly. - The multi-agent system uses structured output to reliably parse agent selection decisions. - 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:: diff --git a/docs/components/agent.rst b/docs/components/agent.rst index 5faa19555..57bc0282f 100644 --- a/docs/components/agent.rst +++ b/docs/components/agent.rst @@ -483,80 +483,6 @@ Code Examples * `RAG with MongoDB`_ * `RAG with Pinecone`_ -Structured Output ------------------ - -A typical use-case of LLMs is to classify and extract data from unstructured sources, which is supported by some models -by features like Structured Output or providing a Response Format. - -PHP Classes as Output -~~~~~~~~~~~~~~~~~~~~~ - -Symfony AI supports that use-case by abstracting the hustle of defining and providing schemas to the LLM and converting -the result back to PHP objects. - -To achieve this, a specific agent processor needs to be registered:: - - use Symfony\AI\Agent\Agent; - use Symfony\AI\Agent\StructuredOutput\AgentProcessor; - use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactory; - use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; - use Symfony\AI\Platform\Message\Message; - use Symfony\AI\Platform\Message\MessageBag; - use Symfony\Component\Serializer\Encoder\JsonEncoder; - use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; - use Symfony\Component\Serializer\Serializer; - - // Initialize Platform and LLM - - $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); - $processor = new AgentProcessor(new ResponseFormatFactory(), $serializer); - $agent = new Agent($platform, $model, [$processor], [$processor]); - - $messages = new MessageBag( - Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), - Message::ofUser('how can I solve 8x + 7 = -23'), - ); - $result = $agent->call($messages, ['output_structure' => MathReasoning::class]); - - dump($result->getContent()); // returns an instance of `MathReasoning` class - -Array Structures as Output -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Also PHP array structures as response_format are supported, which also requires the agent processor mentioned above:: - - use Symfony\AI\Platform\Message\Message; - use Symfony\AI\Platform\Message\MessageBag; - - // Initialize Platform, LLM and agent with processors and Clock tool - - $messages = new MessageBag(Message::ofUser('What date and time is it?')); - $result = $agent->call($messages, ['response_format' => [ - 'type' => 'json_schema', - 'json_schema' => [ - 'name' => 'clock', - 'strict' => true, - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'date' => ['type' => 'string', 'description' => 'The current date in the format YYYY-MM-DD.'], - 'time' => ['type' => 'string', 'description' => 'The current time in the format HH:MM:SS.'], - ], - 'required' => ['date', 'time'], - 'additionalProperties' => false, - ], - ], - ]]); - - dump($result->getContent()); // returns an array - -Code Examples -~~~~~~~~~~~~~ - -* `Structured Output with PHP class`_ -* `Structured Output with array`_ - Input & Output Processing ------------------------- @@ -825,7 +751,5 @@ Code Examples .. _`Store Component`: https://github.com/symfony/ai-store .. _`RAG with MongoDB`: https://github.com/symfony/ai/blob/main/examples/rag/mongodb.php .. _`RAG with Pinecone`: https://github.com/symfony/ai/blob/main/examples/rag/pinecone.php -.. _`Structured Output with PHP class`: https://github.com/symfony/ai/blob/main/examples/openai/structured-output-math.php -.. _`Structured Output with array`: https://github.com/symfony/ai/blob/main/examples/openai/structured-output-clock.php .. _`Chat with static memory`: https://github.com/symfony/ai/blob/main/examples/memory/static.php .. _`Chat with embedding search memory`: https://github.com/symfony/ai/blob/main/examples/memory/mariadb.php diff --git a/docs/components/platform.rst b/docs/components/platform.rst index 071e2c2b9..6bea67f72 100644 --- a/docs/components/platform.rst +++ b/docs/components/platform.rst @@ -278,6 +278,76 @@ Code Examples * `Embeddings with Voyage`_ * `Embeddings with Mistral`_ +Structured Output +----------------- + +A typical use-case of LLMs is to classify and extract data from unstructured sources, which is supported by some models +by features like Structured Output or providing a Response Format. + +PHP Classes as Output +~~~~~~~~~~~~~~~~~~~~~ + +Symfony AI supports that use-case by abstracting the hustle of defining and providing schemas to the LLM and converting +the result back to PHP objects. + +To achieve this, the ``Symfony\AI\Platform\StructuredOutput\PlatformSubscriber`` needs to be registered with the platform:: + + use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; + use Symfony\AI\Platform\Bridge\Mistral\PlatformFactory; + use Symfony\AI\Platform\Message\Message; + use Symfony\AI\Platform\Message\MessageBag; + use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; + use Symfony\Component\EventDispatcher\EventDispatcher; + + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new PlatformSubscriber()); + + $platform = PlatformFactory::create($apiKey, eventDispatcher: $dispatcher); + $messages = new MessageBag( + Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), + Message::ofUser('how can I solve 8x + 7 = -23'), + ); + $result = $platform->invoke('mistral-small-latest', $messages, ['output_structure' => MathReasoning::class]); + + dump($result->asObject()); // returns an instance of `MathReasoning` class + +Array Structures as Output +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Also PHP array structures as response_format are supported, which also requires the event subscriber mentioned above. On +top this example uses the feature through the agent to leverage tool calling:: + + use Symfony\AI\Platform\Message\Message; + use Symfony\AI\Platform\Message\MessageBag; + + // Initialize Platform, LLM and agent with processors and Clock tool + + $messages = new MessageBag(Message::ofUser('What date and time is it?')); + $result = $agent->call($messages, ['response_format' => [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'clock', + 'strict' => true, + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'date' => ['type' => 'string', 'description' => 'The current date in the format YYYY-MM-DD.'], + 'time' => ['type' => 'string', 'description' => 'The current time in the format HH:MM:SS.'], + ], + 'required' => ['date', 'time'], + 'additionalProperties' => false, + ], + ], + ]]); + + dump($result->getContent()); // returns an array + +Code Examples +~~~~~~~~~~~~~ + +* `Structured Output with PHP class`_ +* `Structured Output with array`_ + Server Tools ------------ @@ -426,6 +496,8 @@ Code Examples .. _`Embeddings with OpenAI`: https://github.com/symfony/ai/blob/main/examples/openai/embeddings.php .. _`Embeddings with Voyage`: https://github.com/symfony/ai/blob/main/examples/voyage/embeddings.php .. _`Embeddings with Mistral`: https://github.com/symfony/ai/blob/main/examples/mistral/embeddings.php +.. _`Structured Output with PHP class`: https://github.com/symfony/ai/blob/main/examples/openai/structured-output-math.php +.. _`Structured Output with array`: https://github.com/symfony/ai/blob/main/examples/openai/structured-output-clock.php .. _`Parallel GPT Calls`: https://github.com/symfony/ai/blob/main/examples/misc/parallel-chat-gpt.php .. _`Parallel Embeddings Calls`: https://github.com/symfony/ai/blob/main/examples/misc/parallel-embeddings.php .. _`LM Studio`: https://lmstudio.ai/ diff --git a/examples/deepseek/structured-output-clock.php b/examples/deepseek/structured-output-clock.php index 22c82f241..b80626a91 100644 --- a/examples/deepseek/structured-output-clock.php +++ b/examples/deepseek/structured-output-clock.php @@ -10,24 +10,27 @@ */ use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor as StructuredOutputProcessor; -use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; +use Symfony\AI\Agent\Toolbox\AgentProcessor; use Symfony\AI\Agent\Toolbox\Tool\Clock; use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; use Symfony\Component\Clock\Clock as SymfonyClock; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client(), eventDispatcher: $dispatcher); $clock = new Clock(new SymfonyClock()); -$toolbox = new Toolbox([$clock]); -$toolProcessor = new ToolProcessor($toolbox); -$structuredOutputProcessor = new StructuredOutputProcessor(); -$agent = new Agent($platform, 'deepseek-chat', [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]); +$toolbox = new Toolbox([$clock], logger: logger()); +$toolProcessor = new AgentProcessor($toolbox); +$agent = new Agent($platform, 'deepseek-chat', [$toolProcessor], [$toolProcessor]); $messages = new MessageBag( // for DeepSeek it is *mandatory* to mention JSON anywhere in the prompt when using structured output diff --git a/examples/gemini/structured-output-clock.php b/examples/gemini/structured-output-clock.php index a9d1430aa..443feca00 100644 --- a/examples/gemini/structured-output-clock.php +++ b/examples/gemini/structured-output-clock.php @@ -10,24 +10,27 @@ */ use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor as StructuredOutputProcessor; -use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; +use Symfony\AI\Agent\Toolbox\AgentProcessor; use Symfony\AI\Agent\Toolbox\Tool\Clock; use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Platform\Bridge\Gemini\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; use Symfony\Component\Clock\Clock as SymfonyClock; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('GEMINI_API_KEY'), http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('GEMINI_API_KEY'), http_client(), eventDispatcher: $dispatcher); $clock = new Clock(new SymfonyClock()); $toolbox = new Toolbox([$clock], logger: logger()); -$toolProcessor = new ToolProcessor($toolbox); -$structuredOutputProcessor = new StructuredOutputProcessor(); -$agent = new Agent($platform, 'gemini-2.5-flash', [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]); +$toolProcessor = new AgentProcessor($toolbox); +$agent = new Agent($platform, 'gemini-2.5-flash', [$toolProcessor], [$toolProcessor]); $messages = new MessageBag(Message::ofUser('What date and time is it?')); $result = $agent->call($messages, ['response_format' => [ diff --git a/examples/gemini/structured-output-math.php b/examples/gemini/structured-output-math.php index f3fabc130..92307b2be 100644 --- a/examples/gemini/structured-output-math.php +++ b/examples/gemini/structured-output-math.php @@ -9,23 +9,24 @@ * file that was distributed with this source code. */ -use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; use Symfony\AI\Platform\Bridge\Gemini\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('GEMINI_API_KEY'), http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); -$processor = new AgentProcessor(); -$agent = new Agent($platform, 'gemini-2.5-flash', [$processor], [$processor]); +$platform = PlatformFactory::create(env('GEMINI_API_KEY'), http_client(), eventDispatcher: $dispatcher); $messages = new MessageBag( Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), Message::ofUser('how can I solve 8x + 7 = -23'), ); -$result = $agent->call($messages, ['output_structure' => MathReasoning::class]); -dump($result->getContent()); +$result = $platform->invoke('gemini-2.5-flash', $messages, ['output_structure' => MathReasoning::class]); + +dump($result->asObject()); diff --git a/examples/mistral/structured-output-math.php b/examples/mistral/structured-output-math.php index dd5244bdc..447bff21e 100644 --- a/examples/mistral/structured-output-math.php +++ b/examples/mistral/structured-output-math.php @@ -9,29 +9,23 @@ * file that was distributed with this source code. */ -use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; -use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactory; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; use Symfony\AI\Platform\Bridge\Mistral\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; -use Symfony\Component\Serializer\Encoder\JsonEncoder; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; -use Symfony\Component\Serializer\Serializer; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('MISTRAL_API_KEY'), http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); -$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); - -$processor = new AgentProcessor(new ResponseFormatFactory(), $serializer); -$agent = new Agent($platform, 'mistral-small-latest', [$processor], [$processor]); +$platform = PlatformFactory::create(env('MISTRAL_API_KEY'), http_client(), eventDispatcher: $dispatcher); $messages = new MessageBag( Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), Message::ofUser('how can I solve 8x + 7 = -23'), ); -$result = $agent->call($messages, ['output_structure' => MathReasoning::class]); +$result = $platform->invoke('mistral-small-latest', $messages, ['output_structure' => MathReasoning::class]); -dump($result->getContent()); +dump($result->asObject()); diff --git a/examples/multi-agent/orchestrator.php b/examples/multi-agent/orchestrator.php index 3b63cd992..7e99308aa 100644 --- a/examples/multi-agent/orchestrator.php +++ b/examples/multi-agent/orchestrator.php @@ -13,24 +13,23 @@ use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; use Symfony\AI\Agent\MultiAgent\Handoff; use Symfony\AI\Agent\MultiAgent\MultiAgent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); - -// Create structured output processor for the orchestrator -$structuredOutputProcessor = new AgentProcessor(); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $dispatcher); // Create orchestrator agent for routing decisions $orchestrator = new Agent( $platform, 'gpt-4o-mini', - [new SystemPromptInputProcessor('You are an intelligent agent orchestrator that routes user questions to specialized agents.'), $structuredOutputProcessor], - [$structuredOutputProcessor], + [new SystemPromptInputProcessor('You are an intelligent agent orchestrator that routes user questions to specialized agents.')], ); // Create technical agent for handling technical issues diff --git a/examples/ollama/structured-output-math.php b/examples/ollama/structured-output-math.php index 93f6b4006..2435ea5b1 100644 --- a/examples/ollama/structured-output-math.php +++ b/examples/ollama/structured-output-math.php @@ -9,23 +9,23 @@ * file that was distributed with this source code. */ -use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); -$processor = new AgentProcessor(); -$agent = new Agent($platform, env('OLLAMA_LLM'), [$processor], [$processor]); +$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), http_client(), eventDispatcher: $dispatcher); $messages = new MessageBag( Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), Message::ofUser('how can I solve 8x + 7 = -23'), ); -$result = $agent->call($messages, ['output_structure' => MathReasoning::class]); +$result = $platform->invoke(env('OLLAMA_LLM'), $messages, ['output_structure' => MathReasoning::class]); -dump($result->getContent()); +dump($result->asObject()); diff --git a/examples/openai/structured-output-clock.php b/examples/openai/structured-output-clock.php index 023f1c50b..2da321377 100644 --- a/examples/openai/structured-output-clock.php +++ b/examples/openai/structured-output-clock.php @@ -10,24 +10,27 @@ */ use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor as StructuredOutputProcessor; -use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; +use Symfony\AI\Agent\Toolbox\AgentProcessor; use Symfony\AI\Agent\Toolbox\Tool\Clock; use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; use Symfony\Component\Clock\Clock as SymfonyClock; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $dispatcher); $clock = new Clock(new SymfonyClock()); $toolbox = new Toolbox([$clock], logger: logger()); -$toolProcessor = new ToolProcessor($toolbox); -$structuredOutputProcessor = new StructuredOutputProcessor(); -$agent = new Agent($platform, 'gpt-4o-mini', [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]); +$toolProcessor = new AgentProcessor($toolbox); +$agent = new Agent($platform, 'gpt-4o-mini', [$toolProcessor], [$toolProcessor]); $messages = new MessageBag(Message::ofUser('What date and time is it?')); $result = $agent->call($messages, ['response_format' => [ diff --git a/examples/openai/structured-output-list-of-polymorphic-items.php b/examples/openai/structured-output-list-of-polymorphic-items.php index 283ca1f14..97ad6028d 100644 --- a/examples/openai/structured-output-list-of-polymorphic-items.php +++ b/examples/openai/structured-output-list-of-polymorphic-items.php @@ -9,22 +9,23 @@ * file that was distributed with this source code. */ -use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListOfPolymorphicTypesDto; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); -$processor = new AgentProcessor(); -$agent = new Agent($platform, 'gpt-4o-mini', [$processor], [$processor]); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $dispatcher); $messages = new MessageBag( Message::forSystem('You are a persona data collector! Return all the data you can gather from the user input.'), Message::ofUser('Hi! My name is John Doe, I am 30 years old and I live in Paris.'), ); -$result = $agent->call($messages, ['output_structure' => ListOfPolymorphicTypesDto::class]); +$result = $platform->invoke('gpt-4o-mini', $messages, ['output_structure' => ListOfPolymorphicTypesDto::class]); -dump($result->getContent()); +dump($result->asObject()); diff --git a/examples/openai/structured-output-math.php b/examples/openai/structured-output-math.php index e6e20968a..a74ffa999 100644 --- a/examples/openai/structured-output-math.php +++ b/examples/openai/structured-output-math.php @@ -9,22 +9,24 @@ * file that was distributed with this source code. */ -use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); -$processor = new AgentProcessor(); -$agent = new Agent($platform, 'gpt-4o-mini', [$processor], [$processor]); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $dispatcher); $messages = new MessageBag( Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), Message::ofUser('how can I solve 8x + 7 = -23'), ); -$result = $agent->call($messages, ['output_structure' => MathReasoning::class]); -dump($result->getContent()); +$result = $platform->invoke('gpt-4o-mini', $messages, ['output_structure' => MathReasoning::class]); + +dump($result->asObject()); diff --git a/examples/openai/structured-output-union-types.php b/examples/openai/structured-output-union-types.php index 407cd79e0..60c7302a6 100644 --- a/examples/openai/structured-output-union-types.php +++ b/examples/openai/structured-output-union-types.php @@ -9,18 +9,19 @@ * file that was distributed with this source code. */ -use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnionTypeDto; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); -$processor = new AgentProcessor(); -$agent = new Agent($platform, 'gpt-4o-mini', [$processor], [$processor]); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $dispatcher); $messages = new MessageBag( Message::forSystem(<<call($messages, ['output_structure' => UnionTypeDto::class]); +$result = $platform->invoke('gpt-4o-mini', $messages, ['output_structure' => UnionTypeDto::class]); -dump($result->getContent()); +dump($result->asObject()); diff --git a/examples/scaleway/structured-output-math.php b/examples/scaleway/structured-output-math.php index 465c2c2b9..b9230959e 100644 --- a/examples/scaleway/structured-output-math.php +++ b/examples/scaleway/structured-output-math.php @@ -9,23 +9,24 @@ * file that was distributed with this source code. */ -use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once dirname(__DIR__).'/bootstrap.php'; -$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client(), eventDispatcher: $dispatcher); -$processor = new AgentProcessor(); -$agent = new Agent($platform, 'gpt-oss-120b', [$processor], [$processor]); $messages = new MessageBag( Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), Message::ofUser('how can I solve 8x + 7 = -23'), ); -$result = $agent->call($messages, ['output_structure' => MathReasoning::class]); +$result = $platform->invoke('gpt-oss-120b', $messages, ['output_structure' => MathReasoning::class]); -dump($result->getContent()); +dump($result->asObject()); diff --git a/examples/vertexai/structured-output-clock.php b/examples/vertexai/structured-output-clock.php index 222eebaa6..b832b4e57 100644 --- a/examples/vertexai/structured-output-clock.php +++ b/examples/vertexai/structured-output-clock.php @@ -10,24 +10,27 @@ */ use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor as StructuredOutputProcessor; -use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; +use Symfony\AI\Agent\Toolbox\AgentProcessor; use Symfony\AI\Agent\Toolbox\Tool\Clock; use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; use Symfony\Component\Clock\Clock as SymfonyClock; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once __DIR__.'/bootstrap.php'; -$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client(), eventDispatcher: $dispatcher); $clock = new Clock(new SymfonyClock()); -$toolbox = new Toolbox([$clock]); -$toolProcessor = new ToolProcessor($toolbox); -$structuredOutputProcessor = new StructuredOutputProcessor(); -$agent = new Agent($platform, 'gemini-2.5-pro', [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]); +$toolbox = new Toolbox([$clock], logger: logger()); +$toolProcessor = new AgentProcessor($toolbox); +$agent = new Agent($platform, 'gemini-2.5-pro', [$toolProcessor], [$toolProcessor]); $messages = new MessageBag(Message::ofUser('What date and time is it?')); $result = $agent->call($messages, ['response_format' => [ diff --git a/examples/vertexai/structured-output-math.php b/examples/vertexai/structured-output-math.php index bb3ce8c6d..30f29dd67 100644 --- a/examples/vertexai/structured-output-math.php +++ b/examples/vertexai/structured-output-math.php @@ -9,23 +9,24 @@ * file that was distributed with this source code. */ -use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\Component\EventDispatcher\EventDispatcher; require_once __DIR__.'/bootstrap.php'; -$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client()); +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); -$processor = new AgentProcessor(); -$agent = new Agent($platform, 'gemini-2.5-flash-lite', [$processor], [$processor]); +$platform = PlatformFactory::create(env('GOOGLE_CLOUD_LOCATION'), env('GOOGLE_CLOUD_PROJECT'), adc_aware_http_client(), eventDispatcher: $dispatcher); $messages = new MessageBag( Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), Message::ofUser('how can I solve 8x + 7 = -23'), ); -$result = $agent->call($messages, ['output_structure' => MathReasoning::class]); -dump($result->getContent()); +$result = $platform->invoke('gemini-2.5-flash-lite', $messages, ['output_structure' => MathReasoning::class]); + +dump($result->asObject()); diff --git a/src/agent/src/Agent.php b/src/agent/src/Agent.php index e50eb8677..3e267552a 100644 --- a/src/agent/src/Agent.php +++ b/src/agent/src/Agent.php @@ -12,7 +12,6 @@ namespace Symfony\AI\Agent; use Symfony\AI\Agent\Exception\InvalidArgumentException; -use Symfony\AI\Agent\Exception\MissingModelSupportException; use Symfony\AI\Agent\Exception\RuntimeException; use Symfony\AI\Platform\Exception\ExceptionInterface; use Symfony\AI\Platform\Message\MessageBag; @@ -63,10 +62,9 @@ public function getName(): string /** * @param array $options * - * @throws MissingModelSupportException When the model doesn't support audio or image inputs present in the messages - * @throws InvalidArgumentException When the platform returns a client error (4xx) indicating invalid request parameters - * @throws RuntimeException When the platform returns a server error (5xx) or network failure occurs - * @throws ExceptionInterface When the platform converter throws an exception + * @throws InvalidArgumentException When the platform returns a client error (4xx) indicating invalid request parameters + * @throws RuntimeException When the platform returns a server error (5xx) or network failure occurs + * @throws ExceptionInterface When the platform converter throws an exception */ public function call(MessageBag $messages, array $options = []): ResultInterface { diff --git a/src/agent/src/StructuredOutput/AgentProcessor.php b/src/agent/src/StructuredOutput/AgentProcessor.php deleted file mode 100644 index 7fb1dd01d..000000000 --- a/src/agent/src/StructuredOutput/AgentProcessor.php +++ /dev/null @@ -1,119 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Agent\StructuredOutput; - -use Symfony\AI\Agent\Exception\InvalidArgumentException; -use Symfony\AI\Agent\Exception\MissingModelSupportException; -use Symfony\AI\Agent\Input; -use Symfony\AI\Agent\InputProcessorInterface; -use Symfony\AI\Agent\Output; -use Symfony\AI\Agent\OutputProcessorInterface; -use Symfony\AI\Platform\Result\ObjectResult; -use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; -use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\Serializer\Encoder\JsonEncoder; -use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; -use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; -use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; -use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; -use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; -use Symfony\Component\Serializer\Serializer; -use Symfony\Component\Serializer\SerializerInterface; - -/** - * @author Christopher Hertel - */ -final class AgentProcessor implements InputProcessorInterface, OutputProcessorInterface -{ - private string $outputStructure; - - public function __construct( - private readonly ResponseFormatFactoryInterface $responseFormatFactory = new ResponseFormatFactory(), - private ?SerializerInterface $serializer = null, - ) { - if (null === $this->serializer) { - $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); - $discriminator = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); - $propertyInfo = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); - - $normalizers = [ - new BackedEnumNormalizer(), - new ObjectNormalizer( - classMetadataFactory: $classMetadataFactory, - propertyTypeExtractor: $propertyInfo, - classDiscriminatorResolver: $discriminator - ), - new ArrayDenormalizer(), - ]; - - $this->serializer = new Serializer($normalizers, [new JsonEncoder()]); - } - } - - /** - * @throws MissingModelSupportException When structured output is requested but the model doesn't support it - * @throws InvalidArgumentException When streaming is enabled with structured output (incompatible options) - */ - public function processInput(Input $input): void - { - $options = $input->getOptions(); - - if (!isset($options['output_structure'])) { - return; - } - - if (true === ($options['stream'] ?? false)) { - throw new InvalidArgumentException('Streamed responses are not supported for structured output.'); - } - - $options['response_format'] = $this->responseFormatFactory->create($options['output_structure']); - - $this->outputStructure = $options['output_structure']; - unset($options['output_structure']); - - $input->setOptions($options); - } - - public function processOutput(Output $output): void - { - $options = $output->getOptions(); - - if ($output->getResult() instanceof ObjectResult) { - return; - } - - if (!isset($options['response_format'])) { - return; - } - - if (!isset($this->outputStructure)) { - $output->setResult(new ObjectResult(json_decode($output->getResult()->getContent(), true))); - - return; - } - - $originalResult = $output->getResult(); - $output->setResult(new ObjectResult( - $this->serializer->deserialize($output->getResult()->getContent(), $this->outputStructure, 'json') - )); - - if ($originalResult->getMetadata()->count() > 0) { - $output->getResult()->getMetadata()->set($originalResult->getMetadata()->all()); - } - - if (null !== $originalResult->getRawResult()) { - $output->getResult()->setRawResult($originalResult->getRawResult()); - } - } -} diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 6a9ee82af..b20559e37 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -283,7 +283,6 @@ }) ->end() ->end() - ->booleanNode('structured_output')->defaultTrue()->end() ->variableNode('memory') ->info('Memory configuration: string for static memory, or array with "service" key for service reference') ->defaultNull() diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index bf2855732..1de2fad69 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -11,9 +11,6 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor as StructureOutputProcessor; -use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactory; -use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactoryInterface; use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Agent\Toolbox\ToolCallArgumentResolver; @@ -61,6 +58,9 @@ use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; use Symfony\AI\Platform\Contract\JsonSchema\Factory as SchemaFactory; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\AI\Platform\StructuredOutput\ResponseFormatFactory; +use Symfony\AI\Platform\StructuredOutput\ResponseFormatFactoryInterface; use Symfony\AI\Store\Command\DropStoreCommand; use Symfony\AI\Store\Command\IndexCommand; use Symfony\AI\Store\Command\SetupStoreCommand; @@ -113,12 +113,13 @@ service('ai.platform.json_schema.description_parser'), service('type_info.resolver')->nullOnInvalid(), ]) - ->alias(ResponseFormatFactoryInterface::class, 'ai.agent.response_format_factory') - ->set('ai.agent.structured_output_processor', StructureOutputProcessor::class) + ->alias(ResponseFormatFactoryInterface::class, 'ai.platform.response_format_factory') + ->set('ai.platform.structured_output_subscriber', PlatformSubscriber::class) ->args([ service('ai.agent.response_format_factory'), service('serializer'), ]) + ->tag('kernel.event_subscriber') // tools ->set('ai.toolbox.abstract', Toolbox::class) diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index ce8e9a69c..3b920a51c 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -256,6 +256,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.anthropic'), new Reference('ai.platform.contract.anthropic'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'anthropic']); @@ -279,6 +280,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference($config['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.openai'), new Reference('ai.platform.contract.openai'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'azure.'.$name]); @@ -299,6 +301,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['host'], new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.elevenlabs'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'eleven_labs']); @@ -318,6 +321,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.gemini'), new Reference('ai.platform.contract.gemini'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'gemini']); @@ -358,6 +362,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $httpClient, new Reference('ai.platform.model_catalog.vertexai.gemini'), new Reference('ai.platform.contract.vertexai.gemini'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'vertexai']); @@ -378,6 +383,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.openai'), new Reference('ai.platform.contract.openai'), $platform['region'] ?? null, + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'openai']); @@ -396,6 +402,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['api_key'], new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.openrouter'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'openrouter']); @@ -414,6 +421,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['api_key'], new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.mistral'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'mistral']); @@ -432,6 +440,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['host_url'], new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.lmstudio'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'lmstudio']); @@ -451,6 +460,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.ollama'), new Reference('ai.platform.contract.ollama'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'ollama']); @@ -469,6 +479,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['api_key'], new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.cerebras'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'cerebras']); @@ -487,6 +498,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['api_key'], new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.deepseek'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'deepseek']); @@ -505,6 +517,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['api_key'], new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.voyage'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform', ['name' => 'voyage']); @@ -524,6 +537,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.perplexity'), new Reference('ai.platform.contract.perplexity'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform'); @@ -542,6 +556,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['host_url'], new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.dockermodelrunner'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform'); @@ -560,6 +575,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $platform['api_key'], new Reference('http_client', ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ai.platform.model_catalog.scaleway'), + new Reference('event_dispatcher'), ]) ->addTag('ai.platform'); @@ -648,13 +664,6 @@ private function processAgentConfig(string $name, array $config, ContainerBuilde } } - // STRUCTURED OUTPUT - if ($config['structured_output']) { - $container->getDefinition('ai.agent.structured_output_processor') - ->addTag('ai.agent.input_processor', ['agent' => $agentId, 'priority' => -20]) - ->addTag('ai.agent.output_processor', ['agent' => $agentId, 'priority' => -20]); - } - // TOKEN USAGE TRACKING if ($config['track_token_usage'] ?? true) { $platformServiceId = $config['platform']; diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 5887234b6..4735923c5 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -526,7 +526,6 @@ public function testProcessorTagsUseFullAgentId() 'tools' => [ ['service' => 'some_tool', 'description' => 'Test tool'], ], - 'structured_output' => true, 'prompt' => 'You are a test assistant.', ], ], @@ -545,21 +544,6 @@ public function testProcessorTagsUseFullAgentId() $this->assertNotEmpty($outputTags, 'Tool processor should have output processor tags'); $this->assertSame($agentId, $outputTags[0]['agent'], 'Tool output processor tag should use full agent ID'); - // Test structured output processor tags - $structuredOutputTags = $container->getDefinition('ai.agent.structured_output_processor') - ->getTag('ai.agent.input_processor'); - $this->assertNotEmpty($structuredOutputTags, 'Structured output processor should have input processor tags'); - - // Find the tag for our specific agent - $foundAgentTag = false; - foreach ($structuredOutputTags as $tag) { - if (($tag['agent'] ?? '') === $agentId) { - $foundAgentTag = true; - break; - } - } - $this->assertTrue($foundAgentTag, 'Structured output processor should have tag with full agent ID'); - // Test system prompt processor tags $systemPromptDefinition = $container->getDefinition('ai.agent.test_agent.system_prompt_processor'); $systemPromptTags = $systemPromptDefinition->getTag('ai.agent.input_processor'); @@ -715,7 +699,7 @@ public function testOpenAiPlatformWithDefaultRegion() $definition = $container->getDefinition('ai.platform.openai'); $arguments = $definition->getArguments(); - $this->assertCount(5, $arguments); + $this->assertCount(6, $arguments); $this->assertSame('sk-test-key', $arguments[0]); $this->assertNull($arguments[4]); // region should be null by default } @@ -741,7 +725,7 @@ public function testOpenAiPlatformWithRegion(?string $region) $definition = $container->getDefinition('ai.platform.openai'); $arguments = $definition->getArguments(); - $this->assertCount(5, $arguments); + $this->assertCount(6, $arguments); $this->assertSame('sk-test-key', $arguments[0]); $this->assertSame($region, $arguments[4]); } @@ -780,7 +764,7 @@ public function testPerplexityPlatformConfiguration() $definition = $container->getDefinition('ai.platform.perplexity'); $arguments = $definition->getArguments(); - $this->assertCount(4, $arguments); + $this->assertCount(5, $arguments); $this->assertSame('pplx-test-key', $arguments[0]); $this->assertInstanceOf(Reference::class, $arguments[1]); $this->assertSame('http_client', (string) $arguments[1]); @@ -2826,7 +2810,6 @@ private function getFullConfig(): array 'nested' => ['options' => ['work' => 'too']], ], ], - 'structured_output' => false, 'track_token_usage' => true, 'prompt' => [ 'text' => 'You are a helpful assistant.', diff --git a/src/platform/src/Bridge/AiMlApi/PlatformFactory.php b/src/platform/src/Bridge/AiMlApi/PlatformFactory.php index 88b6e42a4..1592393c7 100644 --- a/src/platform/src/Bridge/AiMlApi/PlatformFactory.php +++ b/src/platform/src/Bridge/AiMlApi/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\AiMlApi; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\AiMlApi\Embeddings\ModelClient; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\Platform; @@ -26,6 +27,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ?Contract $contract = null, string $hostUrl = 'https://api.aimlapi.com', + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { return new Platform( [ @@ -38,6 +40,7 @@ public static function create( ], new ModelCatalog(), $contract, + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Albert/PlatformFactory.php b/src/platform/src/Bridge/Albert/PlatformFactory.php index 4ab0071f3..cde0ee4c5 100644 --- a/src/platform/src/Bridge/Albert/PlatformFactory.php +++ b/src/platform/src/Bridge/Albert/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Albert; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\OpenAi\Embeddings; use Symfony\AI\Platform\Bridge\OpenAi\Gpt; use Symfony\AI\Platform\Contract; @@ -28,6 +29,7 @@ public static function create( #[\SensitiveParameter] string $apiKey, string $baseUrl, ?HttpClientInterface $httpClient = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { if (!str_starts_with($baseUrl, 'https://')) { throw new InvalidArgumentException('The Albert URL must start with "https://".'); @@ -52,6 +54,7 @@ public static function create( [new Gpt\ResultConverter(), new Embeddings\ResultConverter()], new ModelCatalog(), Contract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Anthropic/PlatformFactory.php b/src/platform/src/Bridge/Anthropic/PlatformFactory.php index cea9c2c2e..a4fe8a300 100644 --- a/src/platform/src/Bridge/Anthropic/PlatformFactory.php +++ b/src/platform/src/Bridge/Anthropic/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Anthropic; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Anthropic\Contract\AnthropicContract; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; @@ -28,6 +29,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -36,6 +38,7 @@ public static function create( [new ResultConverter()], $modelCatalog, $contract ?? AnthropicContract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Azure/Meta/PlatformFactory.php b/src/platform/src/Bridge/Azure/Meta/PlatformFactory.php index 63547362f..4071a516a 100644 --- a/src/platform/src/Bridge/Azure/Meta/PlatformFactory.php +++ b/src/platform/src/Bridge/Azure/Meta/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Azure\Meta; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Meta\ModelCatalog; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; @@ -29,9 +30,10 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $modelClient = new LlamaModelClient($httpClient ?? HttpClient::create(), $baseUrl, $apiKey); - return new Platform([$modelClient], [new LlamaResultConverter()], $modelCatalog, $contract); + return new Platform([$modelClient], [new LlamaResultConverter()], $modelCatalog, $contract, $eventDispatcher); } } diff --git a/src/platform/src/Bridge/Azure/OpenAi/PlatformFactory.php b/src/platform/src/Bridge/Azure/OpenAi/PlatformFactory.php index 23595c759..7089dff97 100644 --- a/src/platform/src/Bridge/Azure/OpenAi/PlatformFactory.php +++ b/src/platform/src/Bridge/Azure/OpenAi/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Azure\OpenAi; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\OpenAi\Embeddings; use Symfony\AI\Platform\Bridge\OpenAi\Gpt; use Symfony\AI\Platform\Bridge\OpenAi\ModelCatalog; @@ -35,6 +36,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); $embeddingsModelClient = new EmbeddingsModelClient($httpClient, $baseUrl, $deployment, $apiVersion, $apiKey); @@ -46,6 +48,7 @@ public static function create( [new Gpt\ResultConverter(), new Embeddings\ResultConverter(), new Whisper\ResultConverter()], $modelCatalog, $contract ?? Contract::create(new AudioNormalizer()), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Bedrock/PlatformFactory.php b/src/platform/src/Bridge/Bedrock/PlatformFactory.php index 3427aaa02..c8d7f6e57 100644 --- a/src/platform/src/Bridge/Bedrock/PlatformFactory.php +++ b/src/platform/src/Bridge/Bedrock/PlatformFactory.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Platform\Bridge\Bedrock; use AsyncAws\BedrockRuntime\BedrockRuntimeClient; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Anthropic\Contract as AnthropicContract; use Symfony\AI\Platform\Bridge\Bedrock\Anthropic\ClaudeModelClient; use Symfony\AI\Platform\Bridge\Bedrock\Anthropic\ClaudeResultConverter; @@ -35,6 +36,7 @@ public static function create( BedrockRuntimeClient $bedrockRuntimeClient = new BedrockRuntimeClient(), ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { if (!class_exists(BedrockRuntimeClient::class)) { throw new RuntimeException('For using the Bedrock platform, the async-aws/bedrock-runtime package is required. Try running "composer require async-aws/bedrock-runtime".'); @@ -67,7 +69,8 @@ public static function create( new NovaContract\ToolCallMessageNormalizer(), new NovaContract\ToolNormalizer(), new NovaContract\UserMessageNormalizer(), - ) + ), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Cerebras/PlatformFactory.php b/src/platform/src/Bridge/Cerebras/PlatformFactory.php index 0063b0660..02cf0d7a2 100644 --- a/src/platform/src/Bridge/Cerebras/PlatformFactory.php +++ b/src/platform/src/Bridge/Cerebras/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Cerebras; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Platform; @@ -27,6 +28,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -35,6 +37,7 @@ public static function create( [new ResultConverter()], $modelCatalog, $contract, + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/DeepSeek/PlatformFactory.php b/src/platform/src/Bridge/DeepSeek/PlatformFactory.php index 40f4d2555..f3f2abd9d 100644 --- a/src/platform/src/Bridge/DeepSeek/PlatformFactory.php +++ b/src/platform/src/Bridge/DeepSeek/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\DeepSeek; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Platform; @@ -24,6 +25,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -32,6 +34,7 @@ public static function create( [new ResultConverter()], $modelCatalog, $contract ?? Contract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/DockerModelRunner/PlatformFactory.php b/src/platform/src/Bridge/DockerModelRunner/PlatformFactory.php index f95fc12b3..30fb2c86b 100644 --- a/src/platform/src/Bridge/DockerModelRunner/PlatformFactory.php +++ b/src/platform/src/Bridge/DockerModelRunner/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\DockerModelRunner; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Platform; @@ -27,6 +28,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -40,7 +42,8 @@ public static function create( new Completions\ResultConverter(), ], $modelCatalog, - $contract + $contract, + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/ElevenLabs/PlatformFactory.php b/src/platform/src/Bridge/ElevenLabs/PlatformFactory.php index 7cb29acce..bd4898681 100644 --- a/src/platform/src/Bridge/ElevenLabs/PlatformFactory.php +++ b/src/platform/src/Bridge/ElevenLabs/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\ElevenLabs; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\ElevenLabs\Contract\ElevenLabsContract; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; @@ -29,6 +30,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -37,6 +39,7 @@ public static function create( [new ElevenLabsResultConverter($httpClient)], $modelCatalog, $contract ?? ElevenLabsContract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Gemini/PlatformFactory.php b/src/platform/src/Bridge/Gemini/PlatformFactory.php index 947928156..dfa35112d 100644 --- a/src/platform/src/Bridge/Gemini/PlatformFactory.php +++ b/src/platform/src/Bridge/Gemini/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Gemini; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Gemini\Contract\GeminiContract; use Symfony\AI\Platform\Bridge\Gemini\Embeddings\ModelClient as EmbeddingsModelClient; use Symfony\AI\Platform\Bridge\Gemini\Embeddings\ResultConverter as EmbeddingsResultConverter; @@ -32,6 +33,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -40,6 +42,7 @@ public static function create( [new EmbeddingsResultConverter(), new GeminiResultConverter()], $modelCatalog, $contract ?? GeminiContract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/HuggingFace/PlatformFactory.php b/src/platform/src/Bridge/HuggingFace/PlatformFactory.php index 5bfafab48..63fb23f7b 100644 --- a/src/platform/src/Bridge/HuggingFace/PlatformFactory.php +++ b/src/platform/src/Bridge/HuggingFace/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\HuggingFace; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\HuggingFace\Contract\FileNormalizer; use Symfony\AI\Platform\Bridge\HuggingFace\Contract\MessageBagNormalizer; use Symfony\AI\Platform\Contract; @@ -28,6 +29,7 @@ public static function create( string $provider = Provider::HF_INFERENCE, ?HttpClientInterface $httpClient = null, ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -39,6 +41,7 @@ public static function create( new FileNormalizer(), new MessageBagNormalizer(), ), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/LmStudio/PlatformFactory.php b/src/platform/src/Bridge/LmStudio/PlatformFactory.php index afebb7990..411a88e46 100644 --- a/src/platform/src/Bridge/LmStudio/PlatformFactory.php +++ b/src/platform/src/Bridge/LmStudio/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\LmStudio; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\LmStudio\Embeddings\ModelClient; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; @@ -28,6 +29,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -41,7 +43,8 @@ public static function create( new Completions\ResultConverter(), ], $modelCatalog, - $contract + $contract, + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Mistral/PlatformFactory.php b/src/platform/src/Bridge/Mistral/PlatformFactory.php index 87bfd9d87..65ddeb5d4 100644 --- a/src/platform/src/Bridge/Mistral/PlatformFactory.php +++ b/src/platform/src/Bridge/Mistral/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Mistral; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Mistral\Contract\DocumentNormalizer; use Symfony\AI\Platform\Bridge\Mistral\Contract\DocumentUrlNormalizer; use Symfony\AI\Platform\Bridge\Mistral\Contract\ImageUrlNormalizer; @@ -31,6 +32,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -44,6 +46,7 @@ public static function create( new DocumentUrlNormalizer(), new ImageUrlNormalizer(), ), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Ollama/PlatformFactory.php b/src/platform/src/Bridge/Ollama/PlatformFactory.php index dd801f39c..bf1d16c06 100644 --- a/src/platform/src/Bridge/Ollama/PlatformFactory.php +++ b/src/platform/src/Bridge/Ollama/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Ollama; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Ollama\Contract\OllamaContract; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; @@ -28,6 +29,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -36,6 +38,7 @@ public static function create( [new OllamaResultConverter()], $modelCatalog, $contract ?? OllamaContract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/OpenAi/PlatformFactory.php b/src/platform/src/Bridge/OpenAi/PlatformFactory.php index f93743ed5..406f3eed1 100644 --- a/src/platform/src/Bridge/OpenAi/PlatformFactory.php +++ b/src/platform/src/Bridge/OpenAi/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\OpenAi; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\OpenAi\Contract\OpenAiContract; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; @@ -32,6 +33,7 @@ public static function create( ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, ?string $region = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -50,6 +52,7 @@ public static function create( ], $modelCatalog, $contract ?? OpenAiContract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/OpenRouter/PlatformFactory.php b/src/platform/src/Bridge/OpenRouter/PlatformFactory.php index 6825a0468..4cfdfbf13 100644 --- a/src/platform/src/Bridge/OpenRouter/PlatformFactory.php +++ b/src/platform/src/Bridge/OpenRouter/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\OpenRouter; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Gemini\Contract\AssistantMessageNormalizer; use Symfony\AI\Platform\Bridge\Gemini\Contract\MessageBagNormalizer; use Symfony\AI\Platform\Bridge\Gemini\Contract\UserMessageNormalizer; @@ -30,6 +31,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -42,6 +44,7 @@ public static function create( new MessageBagNormalizer(), new UserMessageNormalizer(), ), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Perplexity/PlatformFactory.php b/src/platform/src/Bridge/Perplexity/PlatformFactory.php index 83241386c..a30ced9e3 100644 --- a/src/platform/src/Bridge/Perplexity/PlatformFactory.php +++ b/src/platform/src/Bridge/Perplexity/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Perplexity; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Perplexity\Contract\PerplexityContract; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; @@ -28,6 +29,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -36,6 +38,7 @@ public static function create( [new ResultConverter()], $modelCatalog, $contract ?? PerplexityContract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Replicate/PlatformFactory.php b/src/platform/src/Bridge/Replicate/PlatformFactory.php index d1c764cec..9cddcfd9f 100644 --- a/src/platform/src/Bridge/Replicate/PlatformFactory.php +++ b/src/platform/src/Bridge/Replicate/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Replicate; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Replicate\Contract\LlamaMessageBagNormalizer; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; @@ -29,12 +30,14 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { return new Platform( [new LlamaModelClient(new Client($httpClient ?? HttpClient::create(), new Clock(), $apiKey))], [new LlamaResultConverter()], $modelCatalog, $contract ?? Contract::create(new LlamaMessageBagNormalizer()), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Scaleway/PlatformFactory.php b/src/platform/src/Bridge/Scaleway/PlatformFactory.php index 190490317..4acd5e31c 100644 --- a/src/platform/src/Bridge/Scaleway/PlatformFactory.php +++ b/src/platform/src/Bridge/Scaleway/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Scaleway; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\Scaleway\Embeddings\ModelClient as ScalewayEmbeddingsModelClient; use Symfony\AI\Platform\Bridge\Scaleway\Embeddings\ResultConverter as ScalewayEmbeddingsResponseConverter; use Symfony\AI\Platform\Bridge\Scaleway\Llm\ModelClient as ScalewayModelClient; @@ -31,6 +32,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -45,6 +47,7 @@ public static function create( ], $modelCatalog, $contract, + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/TransformersPhp/PlatformFactory.php b/src/platform/src/Bridge/TransformersPhp/PlatformFactory.php index dcb0ac782..448d3ac7e 100644 --- a/src/platform/src/Bridge/TransformersPhp/PlatformFactory.php +++ b/src/platform/src/Bridge/TransformersPhp/PlatformFactory.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Platform\Bridge\TransformersPhp; use Codewithkyrian\Transformers\Transformers; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Platform; @@ -21,12 +22,14 @@ */ final readonly class PlatformFactory { - public static function create(ModelCatalogInterface $modelCatalog = new ModelCatalog()): Platform - { + public static function create( + ModelCatalogInterface $modelCatalog = new ModelCatalog(), + ?EventDispatcherInterface $eventDispatcher = null, + ): Platform { if (!class_exists(Transformers::class)) { throw new RuntimeException('For using the TransformersPHP with FFI to run models in PHP, the codewithkyrian/transformers package is required. Try running "composer require codewithkyrian/transformers".'); } - return new Platform([new ModelClient()], [new ResultConverter()], $modelCatalog); + return new Platform([new ModelClient()], [new ResultConverter()], $modelCatalog, eventDispatcher: $eventDispatcher); } } diff --git a/src/platform/src/Bridge/VertexAi/PlatformFactory.php b/src/platform/src/Bridge/VertexAi/PlatformFactory.php index 657ccd7ab..51bad139d 100644 --- a/src/platform/src/Bridge/VertexAi/PlatformFactory.php +++ b/src/platform/src/Bridge/VertexAi/PlatformFactory.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Platform\Bridge\VertexAi; use Google\Auth\ApplicationDefaultCredentials; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Bridge\VertexAi\Contract\GeminiContract; use Symfony\AI\Platform\Bridge\VertexAi\Embeddings\ModelClient as EmbeddingsModelClient; use Symfony\AI\Platform\Bridge\VertexAi\Embeddings\ResultConverter as EmbeddingsResultConverter; @@ -35,6 +36,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { if (!class_exists(ApplicationDefaultCredentials::class)) { throw new RuntimeException('For using the Vertex AI platform, google/auth package is required for authentication via application default credentials. Try running "composer require google/auth".'); @@ -47,6 +49,7 @@ public static function create( [new GeminiResultConverter(), new EmbeddingsResultConverter()], $modelCatalog, $contract ?? GeminiContract::create(), + $eventDispatcher, ); } } diff --git a/src/platform/src/Bridge/Voyage/PlatformFactory.php b/src/platform/src/Bridge/Voyage/PlatformFactory.php index 9af30c15a..d26073560 100644 --- a/src/platform/src/Bridge/Voyage/PlatformFactory.php +++ b/src/platform/src/Bridge/Voyage/PlatformFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Voyage; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Platform; @@ -27,6 +28,7 @@ public static function create( ?HttpClientInterface $httpClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, + ?EventDispatcherInterface $eventDispatcher = null, ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); @@ -35,6 +37,7 @@ public static function create( [new ResultConverter()], $modelCatalog, $contract, + $eventDispatcher, ); } } diff --git a/src/platform/src/Event/ResultEvent.php b/src/platform/src/Event/ResultEvent.php new file mode 100644 index 000000000..60a95b7e0 --- /dev/null +++ b/src/platform/src/Event/ResultEvent.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Event; + +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\Contracts\EventDispatcher\Event; + +/** + * Event dispatched after platform created the deferred result object for input. + * + * @author Christopher Hertel + */ +final class ResultEvent extends Event +{ + /** + * @param array $options + */ + public function __construct( + private Model $model, + private DeferredResult $deferredResult, + private array $options = [], + ) { + } + + public function getModel(): Model + { + return $this->model; + } + + public function setModel(Model $model): void + { + $this->model = $model; + } + + public function getDeferredResult(): DeferredResult + { + return $this->deferredResult; + } + + public function setDeferredResult(DeferredResult $deferredResult): void + { + $this->deferredResult = $deferredResult; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @param array $options + */ + public function setOptions(array $options): void + { + $this->options = $options; + } +} diff --git a/src/agent/src/Exception/MissingModelSupportException.php b/src/platform/src/Exception/MissingModelSupportException.php similarity index 92% rename from src/agent/src/Exception/MissingModelSupportException.php rename to src/platform/src/Exception/MissingModelSupportException.php index eb43df1ec..55efe3fea 100644 --- a/src/agent/src/Exception/MissingModelSupportException.php +++ b/src/platform/src/Exception/MissingModelSupportException.php @@ -9,7 +9,9 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Exception; +namespace Symfony\AI\Platform\Exception; + +use Symfony\AI\Agent\Exception\RuntimeException; /** * @author Christopher Hertel diff --git a/src/platform/src/Platform.php b/src/platform/src/Platform.php index 293e5b721..16ff947c7 100644 --- a/src/platform/src/Platform.php +++ b/src/platform/src/Platform.php @@ -13,6 +13,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\AI\Platform\Event\InvocationEvent; +use Symfony\AI\Platform\Event\ResultEvent; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Result\DeferredResult; @@ -63,9 +64,12 @@ public function invoke(string $model, array|string|object $input, array $options $options['tools'] = $this->contract->createToolOption($options['tools'], $model); } - $result = $this->doInvoke($model, $payload, $options); + $result = $this->convertResult($model, $this->doInvoke($model, $payload, $options), $options); - return $this->convertResult($model, $result, $options); + $event = new ResultEvent($model, $result, $options); + $this->eventDispatcher?->dispatch($event); + + return $event->getDeferredResult(); } public function getModelCatalog(): ModelCatalogInterface diff --git a/src/platform/src/Result/DeferredResult.php b/src/platform/src/Result/DeferredResult.php index 85b23c5ba..2448446a8 100644 --- a/src/platform/src/Result/DeferredResult.php +++ b/src/platform/src/Result/DeferredResult.php @@ -53,6 +53,11 @@ public function getResult(): ResultInterface return $this->convertedResult; } + public function getResultConverter(): ResultConverterInterface + { + return $this->resultConverter; + } + public function getRawResult(): RawResultInterface { return $this->rawResult; diff --git a/src/platform/src/StructuredOutput/PlatformSubscriber.php b/src/platform/src/StructuredOutput/PlatformSubscriber.php new file mode 100644 index 000000000..e5bb389ee --- /dev/null +++ b/src/platform/src/StructuredOutput/PlatformSubscriber.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\StructuredOutput; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Event\InvocationEvent; +use Symfony\AI\Platform\Event\ResultEvent; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Exception\MissingModelSupportException; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Christopher Hertel + */ +final class PlatformSubscriber implements EventSubscriberInterface +{ + private string $outputStructure; + + public function __construct( + private readonly ResponseFormatFactoryInterface $responseFormatFactory = new ResponseFormatFactory(), + private ?SerializerInterface $serializer = null, + ) { + if (null !== $this->serializer) { + return; + } + + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $discriminator = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); + $propertyInfo = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + + $normalizers = [ + new BackedEnumNormalizer(), + new ObjectNormalizer( + classMetadataFactory: $classMetadataFactory, + propertyTypeExtractor: $propertyInfo, + classDiscriminatorResolver: $discriminator, + ), + new ArrayDenormalizer(), + ]; + + $this->serializer = new Serializer($normalizers, [new JsonEncoder()]); + } + + public static function getSubscribedEvents(): array + { + return [ + InvocationEvent::class => 'processInput', + ResultEvent::class => 'processResult', + ]; + } + + /** + * @throws MissingModelSupportException When structured output is requested but the model doesn't support it + * @throws InvalidArgumentException When streaming is enabled with structured output (incompatible options) + */ + public function processInput(InvocationEvent $event): void + { + $options = $event->getOptions(); + + if (!isset($options['output_structure'])) { + return; + } + + if (!$event->getModel()->supports(Capability::OUTPUT_STRUCTURED)) { + throw MissingModelSupportException::forStructuredOutput($event->getModel()::class); + } + + if (true === ($options['stream'] ?? false)) { + throw new InvalidArgumentException('Streamed responses are not supported for structured output.'); + } + + $options['response_format'] = $this->responseFormatFactory->create($options['output_structure']); + + $this->outputStructure = $options['output_structure']; + unset($options['output_structure']); + + $event->setOptions($options); + } + + public function processResult(ResultEvent $event): void + { + $options = $event->getOptions(); + + if (!isset($options['response_format'])) { + return; + } + + $deferred = $event->getDeferredResult(); + $converter = new ResultConverter($deferred->getResultConverter(), $this->serializer, $this->outputStructure ?? null); + + $event->setDeferredResult(new DeferredResult($converter, $deferred->getRawResult(), $options)); + } +} diff --git a/src/agent/src/StructuredOutput/ResponseFormatFactory.php b/src/platform/src/StructuredOutput/ResponseFormatFactory.php similarity index 95% rename from src/agent/src/StructuredOutput/ResponseFormatFactory.php rename to src/platform/src/StructuredOutput/ResponseFormatFactory.php index a81811925..a3b1b626f 100644 --- a/src/agent/src/StructuredOutput/ResponseFormatFactory.php +++ b/src/platform/src/StructuredOutput/ResponseFormatFactory.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\StructuredOutput; +namespace Symfony\AI\Platform\StructuredOutput; use Symfony\AI\Platform\Contract\JsonSchema\Factory; diff --git a/src/agent/src/StructuredOutput/ResponseFormatFactoryInterface.php b/src/platform/src/StructuredOutput/ResponseFormatFactoryInterface.php similarity index 93% rename from src/agent/src/StructuredOutput/ResponseFormatFactoryInterface.php rename to src/platform/src/StructuredOutput/ResponseFormatFactoryInterface.php index ab28b1091..7fe040eec 100644 --- a/src/agent/src/StructuredOutput/ResponseFormatFactoryInterface.php +++ b/src/platform/src/StructuredOutput/ResponseFormatFactoryInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\StructuredOutput; +namespace Symfony\AI\Platform\StructuredOutput; /** * @author Oskar Stark diff --git a/src/platform/src/StructuredOutput/ResultConverter.php b/src/platform/src/StructuredOutput/ResultConverter.php new file mode 100644 index 000000000..c6e8a07e3 --- /dev/null +++ b/src/platform/src/StructuredOutput/ResultConverter.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\StructuredOutput; + +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\ObjectResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\Component\Serializer\SerializerInterface; + +final readonly class ResultConverter implements ResultConverterInterface +{ + public function __construct( + private ResultConverterInterface $innerConverter, + private SerializerInterface $serializer, + private ?string $outputClass = null, + ) { + } + + public function supports(Model $model): bool + { + return true; + } + + public function convert(RawResultInterface $result, array $options = []): ResultInterface + { + $innerResult = $this->innerConverter->convert($result, $options); + + if (!$innerResult instanceof TextResult) { + return $innerResult; + } + + $structure = null === $this->outputClass ? json_decode($innerResult->getContent(), true) + : $this->serializer->deserialize($innerResult->getContent(), $this->outputClass, 'json'); + + $objectResult = new ObjectResult($structure); + $objectResult->setRawResult($result); + $objectResult->getMetadata()->set($innerResult->getMetadata()->all()); + + return $objectResult; + } +} diff --git a/src/agent/tests/StructuredOutput/ConfigurableResponseFormatFactory.php b/src/platform/tests/StructuredOutput/ConfigurableResponseFormatFactory.php similarity index 82% rename from src/agent/tests/StructuredOutput/ConfigurableResponseFormatFactory.php rename to src/platform/tests/StructuredOutput/ConfigurableResponseFormatFactory.php index 74a0bdbf8..3cd3c3bff 100644 --- a/src/agent/tests/StructuredOutput/ConfigurableResponseFormatFactory.php +++ b/src/platform/tests/StructuredOutput/ConfigurableResponseFormatFactory.php @@ -9,9 +9,9 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Tests\StructuredOutput; +namespace Symfony\AI\Platform\Tests\StructuredOutput; -use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactoryInterface; +use Symfony\AI\Platform\StructuredOutput\ResponseFormatFactoryInterface; final readonly class ConfigurableResponseFormatFactory implements ResponseFormatFactoryInterface { diff --git a/src/agent/tests/StructuredOutput/AgentProcessorTest.php b/src/platform/tests/StructuredOutput/PlatformSubscriberTest.php similarity index 51% rename from src/agent/tests/StructuredOutput/AgentProcessorTest.php rename to src/platform/tests/StructuredOutput/PlatformSubscriberTest.php index 74391e817..b22735e2e 100644 --- a/src/agent/tests/StructuredOutput/AgentProcessorTest.php +++ b/src/platform/tests/StructuredOutput/PlatformSubscriberTest.php @@ -9,13 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Tests\StructuredOutput; +namespace Symfony\AI\Platform\Tests\StructuredOutput; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Symfony\AI\Agent\Input; -use Symfony\AI\Agent\Output; -use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\SomeStructure; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListItemAge; @@ -25,64 +22,87 @@ use Symfony\AI\Fixtures\StructuredOutput\UnionType\HumanReadableTimeUnion; use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnionTypeDto; use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnixTimestampUnion; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Event\InvocationEvent; +use Symfony\AI\Platform\Event\ResultEvent; +use Symfony\AI\Platform\Exception\MissingModelSupportException; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Metadata\Metadata; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\AI\Platform\Result\InMemoryRawResult; use Symfony\AI\Platform\Result\ObjectResult; use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\AI\Platform\Test\PlainConverter; use Symfony\Component\Serializer\SerializerInterface; -final class AgentProcessorTest extends TestCase +final class PlatformSubscriberTest extends TestCase { public function testProcessInputWithOutputStructure() { - $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); - $input = new Input('gpt-4', new MessageBag(), ['output_structure' => 'SomeStructure']); + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $event = new InvocationEvent(new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]), new MessageBag(), ['output_structure' => 'SomeStructure']); - $processor->processInput($input); + $processor->processInput($event); - $this->assertSame(['response_format' => ['some' => 'format']], $input->getOptions()); + $this->assertSame(['response_format' => ['some' => 'format']], $event->getOptions()); } public function testProcessInputWithoutOutputStructure() { - $processor = new AgentProcessor(new ConfigurableResponseFormatFactory()); - $input = new Input('gpt-4', new MessageBag()); + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory()); + $event = new InvocationEvent(new Model('gpt-4'), new MessageBag()); - $processor->processInput($input); + $processor->processInput($event); - $this->assertSame([], $input->getOptions()); + $this->assertSame([], $event->getOptions()); + } + + public function testProcessInputThrowsExceptionWhenLlmDoesNotSupportStructuredOutput() + { + $this->expectException(MissingModelSupportException::class); + + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory()); + + $model = new Model('gpt-3'); + $event = new InvocationEvent($model, new MessageBag(), ['output_structure' => 'SomeStructure']); + + $processor->processInput($event); } public function testProcessOutputWithResponseFormat() { - $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); $options = ['output_structure' => SomeStructure::class]; - $input = new Input('gpt-4', new MessageBag(), $options); - $processor->processInput($input); - - $result = new TextResult('{"some": "data"}'); + $invocationEvent = new InvocationEvent($model, new MessageBag(), $options); + $processor->processInput($invocationEvent); - $output = new Output('gpt-4', $result, new MessageBag(), $input->getOptions()); + $converter = new PlainConverter(new TextResult('{"some": "data"}')); + $deferred = new DeferredResult($converter, new InMemoryRawResult()); + $resultEvent = new ResultEvent($model, $deferred, $invocationEvent->getOptions()); - $processor->processOutput($output); + $processor->processResult($resultEvent); - $this->assertInstanceOf(ObjectResult::class, $output->getResult()); - $this->assertInstanceOf(SomeStructure::class, $output->getResult()->getContent()); - $this->assertInstanceOf(Metadata::class, $output->getResult()->getMetadata()); - $this->assertNull($output->getResult()->getRawResult()); - $this->assertSame('data', $output->getResult()->getContent()->some); + $deferredResult = $resultEvent->getDeferredResult(); + $this->assertInstanceOf(ObjectResult::class, $deferredResult->getResult()); + $this->assertInstanceOf(SomeStructure::class, $deferredResult->asObject()); + $this->assertInstanceOf(Metadata::class, $deferredResult->getResult()->getMetadata()); + $this->assertSame('data', $deferredResult->asObject()->some); } public function testProcessOutputWithComplexResponseFormat() { - $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); $options = ['output_structure' => MathReasoning::class]; - $input = new Input('gpt-4', new MessageBag(), $options); - $processor->processInput($input); + $invocationEvent = new InvocationEvent($model, new MessageBag(), $options); + $processor->processInput($invocationEvent); - $result = new TextResult(<<getOptions()); + JSON)); + $deferred = new DeferredResult($converter, new InMemoryRawResult()); + $resultEvent = new ResultEvent($model, $deferred, $invocationEvent->getOptions()); - $processor->processOutput($output); + $processor->processResult($resultEvent); - $this->assertInstanceOf(ObjectResult::class, $output->getResult()); - $this->assertInstanceOf(MathReasoning::class, $structure = $output->getResult()->getContent()); - $this->assertInstanceOf(Metadata::class, $output->getResult()->getMetadata()); - $this->assertNull($output->getResult()->getRawResult()); + $deferredResult = $resultEvent->getDeferredResult(); + $this->assertInstanceOf(ObjectResult::class, $result = $deferredResult->getResult()); + $this->assertInstanceOf(MathReasoning::class, $structure = $deferredResult->asObject()); + $this->assertInstanceOf(Metadata::class, $result->getMetadata()); $this->assertCount(5, $structure->steps); $this->assertInstanceOf(Step::class, $structure->steps[0]); $this->assertInstanceOf(Step::class, $structure->steps[1]); @@ -135,18 +155,22 @@ public function testProcessOutputWithComplexResponseFormat() #[DataProvider('unionTimeTypeProvider')] public function testProcessOutputWithUnionTypeResponseFormat(TextResult $result, string $expectedTimeStructure) { - $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); $options = ['output_structure' => UnionTypeDto::class]; - $input = new Input('gpt-4', new MessageBag(), $options); - $processor->processInput($input); + $invocationEvent = new InvocationEvent($model, new MessageBag(), $options); + $processor->processInput($invocationEvent); - $output = new Output('gpt-4', $result, new MessageBag(), $input->getOptions()); - $processor->processOutput($output); + $converter = new PlainConverter($result); + $deferred = new DeferredResult($converter, new InMemoryRawResult()); + $resultEvent = new ResultEvent($model, $deferred, $invocationEvent->getOptions()); - $this->assertInstanceOf(ObjectResult::class, $output->getResult()); + $processor->processResult($resultEvent); + + $this->assertInstanceOf(ObjectResult::class, $resultEvent->getDeferredResult()->getResult()); /** @var UnionTypeDto $structure */ - $structure = $output->getResult()->getContent(); + $structure = $resultEvent->getDeferredResult()->asObject(); $this->assertInstanceOf(UnionTypeDto::class, $structure); $this->assertInstanceOf($expectedTimeStructure, $structure->time); @@ -178,13 +202,14 @@ public static function unionTimeTypeProvider(): array public function testProcessOutputWithCorrectPolymorphicTypesResponseFormat() { - $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); $options = ['output_structure' => ListOfPolymorphicTypesDto::class]; - $input = new Input('gpt-4', new MessageBag(), $options); - $processor->processInput($input); + $invocationEvent = new InvocationEvent($model, new MessageBag(), $options); + $processor->processInput($invocationEvent); - $result = new TextResult(<<getOptions()); + JSON)); + $deferred = new DeferredResult($converter, new InMemoryRawResult()); + $resultEvent = new ResultEvent($model, $deferred, $invocationEvent->getOptions()); - $processor->processOutput($output); + $processor->processResult($resultEvent); - $this->assertInstanceOf(ObjectResult::class, $output->getResult()); + $this->assertInstanceOf(ObjectResult::class, $resultEvent->getDeferredResult()->getResult()); /** @var ListOfPolymorphicTypesDto $structure */ - $structure = $output->getResult()->getContent(); + $structure = $resultEvent->getDeferredResult()->asObject(); $this->assertInstanceOf(ListOfPolymorphicTypesDto::class, $structure); $this->assertCount(2, $structure->items); @@ -228,13 +253,14 @@ public function testProcessOutputWithoutResponseFormat() { $resultFormatFactory = new ConfigurableResponseFormatFactory(); $serializer = self::createMock(SerializerInterface::class); - $processor = new AgentProcessor($resultFormatFactory, $serializer); + $processor = new PlatformSubscriber($resultFormatFactory, $serializer); - $result = new TextResult(''); - $output = new Output('gpt4', $result, new MessageBag()); + $converter = new PlainConverter($result = new TextResult('{"some": "data"}')); + $deferred = new DeferredResult($converter, new InMemoryRawResult()); + $event = new ResultEvent(new Model('gpt4', [Capability::OUTPUT_STRUCTURED]), $deferred); - $processor->processOutput($output); + $processor->processResult($event); - $this->assertSame($result, $output->getResult()); + $this->assertSame($result, $event->getDeferredResult()->getResult()); } } diff --git a/src/agent/tests/StructuredOutput/ResponseFormatFactoryTest.php b/src/platform/tests/StructuredOutput/ResponseFormatFactoryTest.php similarity index 92% rename from src/agent/tests/StructuredOutput/ResponseFormatFactoryTest.php rename to src/platform/tests/StructuredOutput/ResponseFormatFactoryTest.php index ff1a9202b..84ad106cd 100644 --- a/src/agent/tests/StructuredOutput/ResponseFormatFactoryTest.php +++ b/src/platform/tests/StructuredOutput/ResponseFormatFactoryTest.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Tests\StructuredOutput; +namespace Symfony\AI\Platform\Tests\StructuredOutput; use PHPUnit\Framework\TestCase; -use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactory; use Symfony\AI\Fixtures\StructuredOutput\User; +use Symfony\AI\Platform\StructuredOutput\ResponseFormatFactory; final class ResponseFormatFactoryTest extends TestCase {