Skip to content

Commit d742c66

Browse files
committed
feature #585 [Agent][Platform] Add support for native union types and list of polymporphic* types by using DiscriminatorMap (HaKIMus)
This PR was squashed before being merged into the main branch. Discussion ---------- [Agent][Platform] Add support for native union types and list of polymporphic* types by using `DiscriminatorMap` | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Docs? | yes <!-- required for new features --> | Issues | Fix #559 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT It's a draft, because, primarily, I want to ask if the DiscriminatorMap approach is ok with you. **Why the discriminator map usage?** I've achieved the native `list<Foo|Bar>` union type support for agent json schema output, but Symfony Serializer wasn't working with it correctly (`list<TableDataVisualizationDto|ChartDataVisualizationDto>`), outputting an array in `\Symfony\Component\Serializer\SerializerInterface::deserialize::112`. If you know how to make the serializer work with the "nested" union types correctly, then let me know and we might achieve a native support without the DiscriminatorMap usage. **Example** This is an example from my project I want the feature to work on. Model I tried it with: `gpt-5-nano` with the `Symfony\AI\Platform\Bridge\OpenAi\Gpt`model class. Prompt: ``` { "message": "Provide me with a table representing my latest orders and a chart representing amount of orders for last 10 months." } ``` Schema produced: <details> <summary>Schema dump</summary> ``` array:2 [ "type" => "json_schema" "json_schema" => array:3 [ "name" => "ChatStructuredOutput" "schema" => array:4 [ "type" => "object" "properties" => array:4 [ "title" => array:1 [ "type" => "string" ] "finalAnswer" => array:1 [ "type" => "string" ] "parts" => array:2 [ "type" => "array" "items" => array:1 [ "anyOf" => array:3 [ 0 => array:4 [ "type" => "object" "properties" => array:6 [ "title" => array:1 [ "type" => "string" ] "chartType" => array:2 [ "type" => "string" "enum" => array:3 [ 0 => "bar" 1 => "line" 2 => "pie" ] ] "labels" => array:2 [ "type" => "array" "items" => array:1 [ "type" => "string" ] ] "datasets" => array:2 [ "type" => "array" "items" => array:4 [ "type" => "object" "properties" => array:3 [ "label" => array:1 [ "type" => "string" ] "data" => array:2 [ "type" => "array" "items" => array:1 [ "type" => "number" ] ] "bgColor" => array:2 [ "type" => "string" "description" => "Optional param, default to empty string." ] ] "required" => array:3 [ 0 => "label" 1 => "data" 2 => "bgColor" ] "additionalProperties" => false ] ] "options" => array:2 [ "type" => "array" "items" => array:1 [ "type" => "string" ] ] "type" => array:2 [ "type" => "string" "pattern" => "^chart$" ] ] "required" => array:6 [ 0 => "title" 1 => "chartType" 2 => "labels" 3 => "datasets" 4 => "options" 5 => "type" ] "additionalProperties" => false ] 1 => array:4 [ "type" => "object" "properties" => array:4 [ "columns" => array:2 [ "type" => "array" "items" => array:1 [ "type" => "string" ] ] "rows" => array:2 [ "type" => "array" "items" => array:2 [ "type" => "array" "items" => array:1 [ "type" => "string" ] ] ] "meta" => array:2 [ "type" => "array" "items" => array:1 [ "type" => "string" ] ] "type" => array:2 [ "type" => "string" "pattern" => "^table$" ] ] "required" => array:4 [ 0 => "columns" 1 => "rows" 2 => "meta" 3 => "type" ] "additionalProperties" => false ] 2 => array:4 [ "type" => "object" "properties" => array:2 [ "text" => array:1 [ "type" => "string" ] "type" => array:2 [ "type" => "string" "pattern" => "^text$" ] ] "required" => array:2 [ 0 => "text" 1 => "type" ] "additionalProperties" => false ] ] ] ] "reasoningStep" => array:2 [ "type" => "array" "items" => array:4 [ "type" => "object" "properties" => array:1 [ "reasoning" => array:1 [ "type" => "string" ] ] "required" => array:1 [ 0 => "reasoning" ] "additionalProperties" => false ] ] ] "required" => array:4 [ 0 => "title" 1 => "finalAnswer" 2 => "parts" 3 => "reasoningStep" ] "additionalProperties" => false ] "strict" => true ] ] ``` </details> The description is long enough, so I'd skip the PHP DTOs structure. They look almost identical to the ones I used in tests in the PR. Output (the data are fake): <details> <summary>Output dump</summary> ``` App\Agent\Application\StructuredOutput\ChatStructuredOutput {#2041 +title: "Latest orders and orders by month (last 10 months)" +finalAnswer: "Here is a table with your latest orders and a bar chart showing the number of orders per month for the last 10 months." +parts: array:3 [ 0 => App\Agent\Application\StructuredOutput \ TablePart {#1596 +type: "table" +columns: array:5 [ 0 => "Order #" 1 => "Date Created" 2 => "Status" 3 => "Total PLN" 4 => "Customer Email" ] +rows: array:10 [ 0 => array:5 [ 0 => "115" 1 => "2025-09-14 11:46:11" 2 => "failed" 3 => "24902.21" 4 => "N/A" ] 1 => array:5 [ 0 => "117" 1 => "2025-09-14 11:46:11" 2 => "completed" 3 => "28029.39" 4 => "[email protected]" ] 2 => array:5 [ 0 => "124" 1 => "2025-09-14 11:46:11" 2 => "completed" 3 => "28424.05" 4 => "[email protected]" ] 3 => array:5 [ 0 => "122" 1 => "2025-09-14 11:46:11" 2 => "completed" 3 => "8692.10" 4 => "[email protected]" ] 4 => array:5 [ 0 => "121" 1 => "2025-09-14 11:46:11" 2 => "completed" 3 => "10583.07" 4 => "[email protected]" ] 5 => array:5 [ 0 => "120" 1 => "2025-09-14 11:46:11" 2 => "completed" 3 => "14509.36" 4 => "[email protected]" ] 6 => array:5 [ 0 => "119" 1 => "2025-09-14 11:46:11" 2 => "completed" 3 => "36850.59" 4 => "[email protected]" ] 7 => array:5 [ 0 => "116" 1 => "2025-09-14 11:46:11" 2 => "completed" 3 => "19252.16" 4 => "[email protected]" ] 8 => array:5 [ 0 => "118" 1 => "2025-09-14 11:46:11" 2 => "on-hold" 3 => "33717.00" 4 => "N/A" ] 9 => array:5 [ 0 => "123" 1 => "2025-09-14 11:46:11" 2 => "completed" 3 => "33392.61" 4 => "[email protected]" ] ] +meta: [] } 1 => App\Agent\Application\StructuredOutput \ ChartPart {#2100 +type: "chart" +title: "Orders in the last 10 months" +chartType: App\Agent\Application\StructuredOutput \ ChartPartType {#1107 +name: "BAR" +value: "bar" } +labels: array:10 [ 0 => "Dec 2024" 1 => "Jan 2025" 2 => "Feb 2025" 3 => "Mar 2025" 4 => "Apr 2025" 5 => "May 2025" 6 => "Jun 2025" 7 => "Jul 2025" 8 => "Aug 2025" 9 => "Sep 2025" ] +datasets: array:1 [ 0 => App\Agent\Application\StructuredOutput \ DatasetPart {#2079 +label: "Number of orders" +data: array:10 [ 0 => 0 1 => 0 2 => 0 3 => 0 4 => 0 5 => 0 6 => 0 7 => 0 8 => 0 9 => 85 ] +bgColor: "#4e79a7" } ] +options: array:2 [ 0 => "responsive" 1 => "scales: {y: {beginAtZero: true}}" ] } 2 => App\Agent\Application\StructuredOutput \ TextPart {#2120 +type: "text" +text: "Note: The chart shows order counts per month for the last 10 months. In this dataset, only Sep 2025 contains orders (85 orders)." } ] +reasoningStep: array:2 [ 0 => App\Agent\Application\StructuredOutput \ ReasoningStep {#2069 +reasoning: "I retrieved the latest orders using the WooCommerce orders endpoint and prepared a compact table with key fields: Order #, Date Created, Status, Total PLN, and Customer Email. Since the dataset appears to be current (all entries dated 2025-09-14), I listed the ten most recent orders as they appear from the data source." } 1 => App\Agent\Application\StructuredOutput \ ReasoningStep {#2148 +reasoning: "For the chart, I computed a 10-month window ending in Sep 2025. Based on the provided data, all orders fall in Sep 2025, so Sep 2025 has 85 orders and the preceding months have 0 orders. This yields a bar chart with a single non-zero bar for Sep 2025. If you’d like a different window (e.g., moving 10 months from a different start date) I can adjust the chart accordingly." } ] } ``` </details> As you can see all data have been correctly mapped from schema to php objects. <!-- Replace this notice by a description of your feature/bugfix. This will help reviewers and should be a good start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - For new features, provide some code snippets to help understand usage. - Features and deprecations must be submitted against branch main. - Update/add documentation as required (we can help!) - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry - Never break backward compatibility (see https://symfony.com/bc). --> Commits ------- 36f5bb4 [Agent][Platform] Add support for native union types and list of polymporphic* types by using `DiscriminatorMap`
2 parents a2b2cf4 + 36f5bb4 commit d742c66

File tree

14 files changed

+524
-3
lines changed

14 files changed

+524
-3
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Agent\StructuredOutput\AgentProcessor;
14+
use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListOfPolymorphicTypesDto;
15+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
16+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
17+
use Symfony\AI\Platform\Message\Message;
18+
use Symfony\AI\Platform\Message\MessageBag;
19+
20+
require_once dirname(__DIR__).'/bootstrap.php';
21+
22+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
23+
$model = new Gpt(Gpt::GPT_4O_MINI);
24+
25+
$processor = new AgentProcessor();
26+
$agent = new Agent($platform, $model, [$processor], [$processor], logger: logger());
27+
$messages = new MessageBag(
28+
Message::forSystem('You are a persona data collector! Return all the data you can gather from the user input.'),
29+
Message::ofUser('Hi! My name is John Doe, I am 30 years old and I live in Paris.'),
30+
);
31+
$result = $agent->call($messages, ['output_structure' => ListOfPolymorphicTypesDto::class]);
32+
33+
dump($result->getContent());
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Agent\StructuredOutput\AgentProcessor;
14+
use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnionTypeDto;
15+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
16+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
17+
use Symfony\AI\Platform\Message\Message;
18+
use Symfony\AI\Platform\Message\MessageBag;
19+
20+
require_once dirname(__DIR__).'/bootstrap.php';
21+
22+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
23+
$model = new Gpt(Gpt::GPT_4O_MINI);
24+
25+
$processor = new AgentProcessor();
26+
$agent = new Agent($platform, $model, [$processor], [$processor], logger: logger());
27+
$messages = new MessageBag(
28+
Message::forSystem(<<<PROMPT
29+
You are a time assistant! You can provide time either as a unix timestamp or as a human readable time format.
30+
If you don't know the time, return null.
31+
PROMPT),
32+
Message::ofUser('What is the current time?'),
33+
);
34+
$result = $agent->call($messages, ['output_structure' => UnionTypeDto::class]);
35+
36+
dump($result->getContent());
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\StructuredOutput\PolymorphicType;
13+
14+
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
15+
16+
final class ListItemAge implements ListItemDiscriminator
17+
{
18+
public function __construct(
19+
public int $age,
20+
#[With(pattern: '^age$')]
21+
public string $type = 'age',
22+
) {
23+
}
24+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\StructuredOutput\PolymorphicType;
13+
14+
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
15+
16+
#[DiscriminatorMap(
17+
typeProperty: 'type',
18+
mapping: [
19+
'name' => ListItemName::class,
20+
'age' => ListItemAge::class,
21+
]
22+
)]
23+
/**
24+
* @property string $type
25+
*
26+
* With the PHP 8.4^ you can replace the property annotation with a property hook:
27+
* public string $type {
28+
* get;
29+
* }
30+
*/
31+
interface ListItemDiscriminator
32+
{
33+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\StructuredOutput\PolymorphicType;
13+
14+
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
15+
16+
class ListItemName implements ListItemDiscriminator
17+
{
18+
public function __construct(
19+
public string $name,
20+
#[With(pattern: '^name$')]
21+
public string $type = 'name',
22+
) {
23+
}
24+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\StructuredOutput\PolymorphicType;
13+
14+
/**
15+
* Useful when you need to tell an agent that any of the items are acceptable types.
16+
* Real life example could be a list of possible analytical data visualization like charts or tables.
17+
*/
18+
final class ListOfPolymorphicTypesDto
19+
{
20+
/**
21+
* @param list<ListItemDiscriminator> $items
22+
*/
23+
public function __construct(public array $items)
24+
{
25+
}
26+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\StructuredOutput\UnionType;
13+
14+
final class HumanReadableTimeUnion
15+
{
16+
public function __construct(public string $readableTime)
17+
{
18+
}
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\StructuredOutput\UnionType;
13+
14+
final class UnionTypeDto
15+
{
16+
public function __construct(
17+
public UnixTimestampUnion|HumanReadableTimeUnion|null $time,
18+
) {
19+
}
20+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\StructuredOutput\UnionType;
13+
14+
final class UnixTimestampUnion
15+
{
16+
public function __construct(public int $timestamp)
17+
{
18+
}
19+
}

src/agent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
0.1
55
---
66

7+
* Add support for union types and polymorphic types via DiscriminatorMap
78
* Add Agent class as central orchestrator for AI interactions through the Platform component
89
* Add input/output processing pipeline:
910
- `InputProcessorInterface` for pre-processing messages and options

0 commit comments

Comments
 (0)