Skip to content

Conversation

HaKIMus
Copy link
Contributor

@HaKIMus HaKIMus commented Sep 14, 2025

Q A
Bug fix? no
New feature? yes
Docs? yes
Issues Fix #559
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\Gptmodel 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:

Schema dump
```
 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
  ]
]
```

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):

Output dump
 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 => "ellis.freya@example.com"
        ]
        2 => array:5 [
          0 => "124"
          1 => "2025-09-14 11:46:11"
          2 => "completed"
          3 => "28424.05"
          4 => "mckenzie.britney@example.com"
        ]
        3 => array:5 [
          0 => "122"
          1 => "2025-09-14 11:46:11"
          2 => "completed"
          3 => "8692.10"
          4 => "maxie.wiegand@wiegand.com"
        ]
        4 => array:5 [
          0 => "121"
          1 => "2025-09-14 11:46:11"
          2 => "completed"
          3 => "10583.07"
          4 => "frami.fabiola@example.com"
        ]
        5 => array:5 [
          0 => "120"
          1 => "2025-09-14 11:46:11"
          2 => "completed"
          3 => "14509.36"
          4 => "user@example.com"
        ]
        6 => array:5 [
          0 => "119"
          1 => "2025-09-14 11:46:11"
          2 => "completed"
          3 => "36850.59"
          4 => "user@example.com"
        ]
        7 => array:5 [
          0 => "116"
          1 => "2025-09-14 11:46:11"
          2 => "completed"
          3 => "19252.16"
          4 => "ellis.freya@example.com"
        ]
        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 => "bechtelar.davonte@example.com"
        ]
      ]
      +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."
    }
  ]
}
As you can see all data have been correctly mapped from schema to php objects.

@carsonbot carsonbot changed the title DRAFT Added support for polymporphic* types by using DiscriminatorMap DRAFT Added support for polymporphic* types by using DiscriminatorMap Sep 14, 2025
@OskarStark OskarStark added Platform Issues & PRs about the AI Platform component Agent Issues & PRs about the AI Agent component labels Sep 15, 2025
@carsonbot carsonbot changed the title DRAFT Added support for polymporphic* types by using DiscriminatorMap [Agent][Platform] DRAFT Added support for polymporphic* types by using DiscriminatorMap Sep 15, 2025
@OskarStark OskarStark marked this pull request as draft September 15, 2025 05:42
@OskarStark OskarStark changed the title [Agent][Platform] DRAFT Added support for polymporphic* types by using DiscriminatorMap [Agent][Platform] Add support for polymporphic* types by using DiscriminatorMap Sep 15, 2025
@OskarStark
Copy link
Contributor

Maybe I miss something, but why do we need the DiscriminatorMap?

public function __invoke(
    Foo|Bar $myVar,
) {}

Doesn't this contain enough information to resolve it?

@HaKIMus
Copy link
Contributor Author

HaKIMus commented Sep 15, 2025

Maybe I miss something, but why do we need the DiscriminatorMap?

public function __invoke(
    Foo|Bar $myVar,
) {}

Doesn't this contain enough information to resolve it?

It might be, but the problematic case is a List<Foo|Bar>. The discriminator map is here primarily for the Agent Processor to know how to deserialize data produced by llm according to our json schema correctly.

If Symfony Serializer would understand how to work with annotated list<Foo|Bar> then we could get rid of the DiscriminatorMap. Because it lacks the strategy of deserializing a list of union types we'd get during the deserialization an array structurally reflecting our desired DTO, and not the DTO itself.

@HaKIMus HaKIMus changed the title [Agent][Platform] Add support for polymporphic* types by using DiscriminatorMap [Agent][Platform] Add support for native union types and list of polymporphic* types by using DiscriminatorMap Sep 16, 2025
@OskarStark
Copy link
Contributor

OskarStark commented Sep 16, 2025

I have one question, can we simplify the fixture classes to a bare minimum and use simple terminology? I now this is from your real world use case, but for tests, maintenance too much complexity for me. if we need the enum inside the union type, fine, if not, lets remove it or lets only keep 2 values etc.

Thanks

Copy link
Member

@chr-hertel chr-hertel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementation looks good to me as well, agree on the complexity issue with the fixture, and would love to see an example here - basically as to proof also for its functionality

@HaKIMus
Copy link
Contributor Author

HaKIMus commented Sep 17, 2025

@OskarStark I'll simplify and try to make the new DTOs examples more comprehensible max 'till Tomorrow.

@chr-hertel when you ask for examples - you mean like the ones in PR description or something like examples/openai/structured-output-math.php:24? I think the second one could be more useful long term.

@OskarStark
Copy link
Contributor

lets add examples/openai/structured-output-union-types.php

{
public function __construct(
public string $name,
#[With(pattern: '^name$')] public string $type = 'name',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[With(pattern: '^name$')] public string $type = 'name',
#[With(pattern: '^name$')]
public string $type = 'name',

@HaKIMus
Copy link
Contributor Author

HaKIMus commented Sep 17, 2025

I've simplified the examples to simpler objects and organized them in separated namespaces.

I'm not sure why the pipeline fails with:

1) Symfony\AI\Platform\Tests\Contract\JsonSchema\FactoryTest::testBuildPropertiesForListOfPolymorphicTypesDto
Error: Call to undefined method Symfony\Component\Serializer\Attribute\DiscriminatorMap::getMapping()

Locally the test runs correctly, and I'm on version 8.4.

php -v
PHP 8.4.12 (cli) (built: Aug 26 2025 13:36:28) (NTS)

None of us wants to introduce a ghost error the project, it'd be good to know why it has happened, and I, at the moment, have no clue.


UPDATE
I know why the error is occurring. The pipeline installs the version 8 for symfony serializer, where the DiscriminatorMap doesn't include the method. I'll think of a workaround.
https://github.com/symfony/serializer/blob/8.0/Attribute/DiscriminatorMap.php

@HaKIMus
Copy link
Contributor Author

HaKIMus commented Sep 17, 2025

@OskarStark @chr-hertel in the release 8.0 of the symfony/serializer package DiscriminatorMap doesn't implement getMapping() in favor of public properties. How do you handle such cases in the project? The pipeline builds against 7.* and 8.* version of the serializer.

I'm thinking of the following solutions:

  • Reflectively change the property access
  • Detect the PHP version and access the property based on it (phpstan will not be happy about calling a private property)
  • Do not build against 8.* of symfony serializer
  • Asking nicely to Symfony/Serializer maintainers to support for both getters and access properties

Wdyt?

7.3: https://github.com/symfony/serializer/blob/7.3/Attribute/DiscriminatorMap.php
8.0: https://github.com/symfony/serializer/blob/8.0/Attribute/DiscriminatorMap.php

EDIT
I've chosen the reflective approach.

@HaKIMus HaKIMus marked this pull request as ready for review September 19, 2025 08:04
Comment on lines 7 to 8
* Add support for polymorphic types via DiscriminatorMap
* Add support for union types
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Add support for polymorphic types via DiscriminatorMap
* Add support for union types
* Add support for union types and polymorphic types via DiscriminatorMap

@OskarStark
Copy link
Contributor

One minor and good to merge

@OskarStark OskarStark force-pushed the feature/union-type-support branch from 0ed5448 to 36f5bb4 Compare September 19, 2025 08:45
@OskarStark
Copy link
Contributor

Thanks for your work on this new feature!

@OskarStark OskarStark merged commit d742c66 into symfony:main Sep 19, 2025
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Agent Issues & PRs about the AI Agent component Feature New feature Platform Issues & PRs about the AI Platform component Status: Needs Review
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Union type support for structured outputs
4 participants