Skip to content

Commit d613e46

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 ------- 36f5bb44 [Agent][Platform] Add support for native union types and list of polymporphic* types by using `DiscriminatorMap`
2 parents aca0016 + ca8f9be commit d613e46

File tree

2 files changed

+166
-1
lines changed

2 files changed

+166
-1
lines changed

src/Contract/JsonSchema/Factory.php

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313

1414
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
1515
use Symfony\AI\Platform\Exception\InvalidArgumentException;
16+
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
1617
use Symfony\Component\TypeInfo\Type;
1718
use Symfony\Component\TypeInfo\Type\BackedEnumType;
1819
use Symfony\Component\TypeInfo\Type\BuiltinType;
1920
use Symfony\Component\TypeInfo\Type\CollectionType;
2021
use Symfony\Component\TypeInfo\Type\NullableType;
2122
use Symfony\Component\TypeInfo\Type\ObjectType;
23+
use Symfony\Component\TypeInfo\Type\UnionType;
2224
use Symfony\Component\TypeInfo\TypeIdentifier;
2325
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
2426

@@ -47,6 +49,7 @@
4749
* minProperties?: int,
4850
* maxProperties?: int,
4951
* dependentRequired?: bool,
52+
* anyOf?: list<mixed>,
5053
* }>,
5154
* required: list<string>,
5255
* additionalProperties: false,
@@ -110,7 +113,10 @@ private function convertTypes(array $elements): ?array
110113
$schema = $this->getTypeSchema($type);
111114

112115
if ($type->isNullable()) {
113-
$schema['type'] = [$schema['type'], 'null'];
116+
// anyOf already contains the null variant when applicable; do nothing
117+
if (!isset($schema['anyOf'])) {
118+
$schema['type'] = [$schema['type'], 'null'];
119+
}
114120
} elseif (!($element instanceof \ReflectionParameter && $element->isOptional())) {
115121
$result['required'][] = $name;
116122
}
@@ -151,6 +157,21 @@ private function getTypeSchema(Type $type): array
151157
}
152158
}
153159

160+
if ($type instanceof UnionType) {
161+
// Do not handle nullables as a union but directly return the wrapped type schema
162+
if (2 === \count($type->getTypes()) && $type->isNullable() && $type instanceof NullableType) {
163+
return $this->getTypeSchema($type->getWrappedType());
164+
}
165+
166+
$variants = [];
167+
168+
foreach ($type->getTypes() as $variant) {
169+
$variants[] = $this->getTypeSchema($variant);
170+
}
171+
172+
return ['anyOf' => $variants];
173+
}
174+
154175
switch (true) {
155176
case $type->isIdentifiedBy(TypeIdentifier::INT):
156177
return ['type' => 'integer'];
@@ -168,6 +189,22 @@ private function getTypeSchema(Type $type): array
168189
if ($collectionValueType->isIdentifiedBy(TypeIdentifier::OBJECT)) {
169190
\assert($collectionValueType instanceof ObjectType);
170191

192+
// Check for the DiscriminatorMap attribute to handle polymorphic arrays
193+
$discriminatorMapping = $this->findDiscriminatorMapping($collectionValueType->getClassName());
194+
if ($discriminatorMapping) {
195+
$discriminators = [];
196+
foreach ($discriminatorMapping as $_ => $discriminator) {
197+
$discriminators[] = $this->buildProperties($discriminator);
198+
}
199+
200+
return [
201+
'type' => 'array',
202+
'items' => [
203+
'anyOf' => $discriminators,
204+
],
205+
];
206+
}
207+
171208
return [
172209
'type' => 'array',
173210
'items' => $this->buildProperties($collectionValueType->getClassName()),
@@ -195,6 +232,8 @@ private function getTypeSchema(Type $type): array
195232
}
196233

197234
// no break
235+
case $type->isIdentifiedBy(TypeIdentifier::NULL):
236+
return ['type' => 'null'];
198237
case $type->isIdentifiedBy(TypeIdentifier::STRING):
199238
default:
200239
// Fallback to string for any unhandled types
@@ -233,4 +272,34 @@ private function buildEnumSchema(string $enumClassName): array
233272
'enum' => $values,
234273
];
235274
}
275+
276+
/**
277+
* @param class-string $className
278+
*
279+
* @return array<string, class-string>|null
280+
*
281+
* @throws \ReflectionException
282+
*/
283+
private function findDiscriminatorMapping(string $className): ?array
284+
{
285+
/** @var \ReflectionAttribute<DiscriminatorMap>[] $attributes */
286+
$attributes = (new \ReflectionClass($className))->getAttributes(DiscriminatorMap::class);
287+
$result = \count($attributes) > 0 ? $attributes[array_key_first($attributes)]->newInstance() : null;
288+
289+
if (!$result) {
290+
return null;
291+
}
292+
293+
/**
294+
* In the 8.* release of symfony/serializer DiscriminatorMap removes the getMapping() method in favor of property access.
295+
* This satisfies the project's pipeline that builds against both < and >= 8.* release.
296+
* This logic can be removed once the project builds against >= 8.* only.
297+
*
298+
* @see https://github.com/symfony/ai/pull/585#issuecomment-3303631346
299+
*/
300+
$reflectionProperty = new \ReflectionProperty($result, 'mapping');
301+
$reflectionProperty->setAccessible(true);
302+
303+
return $reflectionProperty->getValue($result);
304+
}
236305
}

tests/Contract/JsonSchema/FactoryTest.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
use PHPUnit\Framework\TestCase;
1717
use Symfony\AI\Fixtures\StructuredOutput\ExampleDto;
1818
use Symfony\AI\Fixtures\StructuredOutput\MathReasoning;
19+
use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListOfPolymorphicTypesDto;
1920
use Symfony\AI\Fixtures\StructuredOutput\Step;
21+
use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnionTypeDto;
2022
use Symfony\AI\Fixtures\StructuredOutput\User;
2123
use Symfony\AI\Fixtures\Tool\ToolNoParams;
2224
use Symfony\AI\Fixtures\Tool\ToolOptionalParam;
@@ -226,6 +228,100 @@ public function testBuildPropertiesForMathReasoningClass()
226228
$this->assertSame($expected, $actual);
227229
}
228230

231+
public function testBuildPropertiesForListOfPolymorphicTypesDto()
232+
{
233+
$expected = [
234+
'type' => 'object',
235+
'properties' => [
236+
'items' => [
237+
'type' => 'array',
238+
'items' => [
239+
'anyOf' => [
240+
[
241+
'type' => 'object',
242+
'properties' => [
243+
'name' => ['type' => 'string'],
244+
'type' => [
245+
'type' => 'string',
246+
'pattern' => '^name$',
247+
],
248+
],
249+
'required' => [
250+
'name',
251+
'type',
252+
],
253+
'additionalProperties' => false,
254+
],
255+
[
256+
'type' => 'object',
257+
'properties' => [
258+
'age' => ['type' => 'integer'],
259+
'type' => [
260+
'type' => 'string',
261+
'pattern' => '^age$',
262+
],
263+
],
264+
'required' => [
265+
'age',
266+
'type',
267+
],
268+
'additionalProperties' => false,
269+
],
270+
],
271+
],
272+
],
273+
],
274+
'required' => ['items'],
275+
'additionalProperties' => false,
276+
];
277+
278+
$actual = $this->factory->buildProperties(ListOfPolymorphicTypesDto::class);
279+
280+
$this->assertSame($expected, $actual);
281+
$this->assertSame($expected['type'], $actual['type']);
282+
$this->assertSame($expected['required'], $actual['required']);
283+
}
284+
285+
public function testBuildPropertiesForUnionTypeDto()
286+
{
287+
$expected = [
288+
'type' => 'object',
289+
'properties' => [
290+
'time' => [
291+
'anyOf' => [
292+
[
293+
'type' => 'object',
294+
'properties' => [
295+
'readableTime' => ['type' => 'string'],
296+
],
297+
'required' => ['readableTime'],
298+
'additionalProperties' => false,
299+
],
300+
[
301+
'type' => 'object',
302+
'properties' => [
303+
'timestamp' => ['type' => 'integer'],
304+
],
305+
'required' => ['timestamp'],
306+
'additionalProperties' => false,
307+
],
308+
[
309+
'type' => 'null',
310+
],
311+
],
312+
],
313+
],
314+
'required' => [],
315+
'additionalProperties' => false,
316+
];
317+
318+
$actual = $this->factory->buildProperties(UnionTypeDto::class);
319+
320+
$this->assertSame($expected, $actual);
321+
$this->assertSame($expected['type'], $actual['type']);
322+
$this->assertSame($expected['required'], $actual['required']);
323+
}
324+
229325
public function testBuildPropertiesForStepClass()
230326
{
231327
$expected = [

0 commit comments

Comments
 (0)