Summary
Symfony\AI\Chat\MessageNormalizer writes the toolsCalls payload of an AssistantMessage / ToolCallMessage in a flat shape ({id, name, arguments}) but its denormalize() method reads it expecting the OpenAI-style function-wrapped shape ({id, function: {name, arguments}}). Any conversation that contains a ToolCall cannot be restored — MessageNormalizer::denormalize() crashes with:
Warning: Undefined array key "function"
in vendor/symfony/ai-chat/src/MessageNormalizer.php on line 60
Actual behavior
After storing an AssistantMessage with tool calls via Serializer::normalize() (e.g. for a persistent chat store) and loading it back via Serializer::denormalize(), the denormalizer accesses a key that the normalizer never produced.
Concrete DB row I recorded (JSON from a real, freshly-stored conversation):
{
"type": "Symfony\\AI\\Platform\\Message\\AssistantMessage",
"content": "...",
"toolsCalls": [
{
"id": "fc_00cf31962a87d2830069e5d0679540819081763cbb56e88ba6",
"name": "search_contract_templates",
"arguments": {"searchTerm": "Promoter-Vertrag", "status": null}
}
]
}
Note: no function key. The next denormalize() on this data throws.
Expected behavior
Round-tripping a message through normalize() → denormalize() should produce an equivalent AssistantMessage without errors — i.e. the two halves of MessageNormalizer must agree on the on-wire shape.
Root cause
The two sides of MessageNormalizer speak different dialects.
1. normalize() delegates to the outer Serializer
https://github.com/symfony/ai/blob/v0.7.0/src/chat/src/MessageNormalizer.php#L112-L118
if ($data instanceof AssistantMessage && $data->hasToolCalls()) {
$toolsCalls = $this->normalizer->normalize($data->getToolCalls(), $format, $context);
}
$this->normalizer comes from NormalizerAwareTrait — it is the outer Symfony Serializer that the MessageNormalizer is attached to. The bundle registers only MessageNormalizer itself as a serializer.normalizer:
https://github.com/symfony/ai/blob/v0.7.0/src/ai-bundle/config/services.php#L263-L264
->set('ai.chat.message_bag.normalizer', MessageNormalizer::class)
->tag('serializer.normalizer')
Symfony\AI\Platform\Contract\Normalizer\Result\ToolCallNormalizer is not registered as a DI service anywhere in symfony/ai-bundle. It only ever lives inside a private Serializer instance built by Symfony\AI\Platform\Contract:
https://github.com/symfony/ai/blob/v0.7.0/src/platform/src/Contract.php
So when MessageNormalizer::normalize() asks the outer serializer to normalize a ToolCall, the outer serializer falls back to ObjectNormalizer and writes it flat:
['id' => '…', 'name' => '…', 'arguments' => [...]]
2. denormalize() hardcodes the OpenAI/function-wrapped shape
https://github.com/symfony/ai/blob/v0.7.0/src/chat/src/MessageNormalizer.php#L57-L78
AssistantMessage::class => new AssistantMessage($content, array_map(
static fn (array $toolsCall): ToolCall => new ToolCall(
$toolsCall['id'],
$toolsCall['function']['name'], // <-- crashes
json_decode($toolsCall['function']['arguments'], true)
),
$data['toolsCalls'],
)),
// …
ToolCallMessage::class => new ToolCallMessage(
new ToolCall(
$data['toolsCalls']['id'],
$data['toolsCalls']['function']['name'],
json_decode($data['toolsCalls']['function']['arguments'], true)
),
$content
),
Neither of the two reconstruction paths uses the outer serializer to denormalize a ToolCall — they read hardcoded keys that normalize() never emits.
The same code is present on main: https://github.com/symfony/ai/blob/main/src/chat/src/MessageNormalizer.php — so the bug is not specific to the v0.7.0 tag.
Minimal reproducer
<?php
use Symfony\AI\Chat\MessageNormalizer;
use Symfony\AI\Platform\Message\AssistantMessage;
use Symfony\AI\Platform\Message\MessageInterface;
use Symfony\AI\Platform\Result\ToolCall;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\UidNormalizer;
use Symfony\Component\Serializer\Serializer;
$messageNormalizer = new MessageNormalizer();
$serializer = new Serializer([$messageNormalizer, new UidNormalizer(), new ObjectNormalizer()]);
$message = new AssistantMessage('ok', [
new ToolCall('tc-1', 'search', ['q' => 'foo']),
]);
$data = $serializer->normalize($message);
// toolsCalls => [['id' => 'tc-1', 'name' => 'search', 'arguments' => ['q' => 'foo']]]
// ^^^^^^^^^^^^^^^^^ no 'function' wrapper
$serializer->denormalize($data, MessageInterface::class);
// PHP Warning: Undefined array key "function"
// at vendor/symfony/ai-chat/src/MessageNormalizer.php line 60
Output
Warning: Undefined array key "function" at src/chat/src/MessageNormalizer.php line 60
Warning: Undefined array key "function" at src/chat/src/MessageNormalizer.php line 61
TypeError: Symfony\AI\Platform\Result\ToolCall::__construct():
Argument #2 ($name) must be of type string, null given,
called in src/chat/src/MessageNormalizer.php on line 58
Impact
Any application that persists chat history through MessageNormalizer and later resumes a conversation whose assistant messages contain tool calls will crash on the very next turn. This affects the whole symfony/ai-chat resume path as soon as tools are involved.
Possible fix directions
Both halves need to agree. Off the top of my head:
- Normalize using an explicit
ToolCallNormalizer — either register Symfony\AI\Platform\Contract\Normalizer\Result\ToolCallNormalizer as a serializer.normalizer service in symfony/ai-bundle, or instantiate it inside MessageNormalizer::normalize() and use it to produce the wrapped shape. Then denormalize() reads what normalize() wrote.
- Symmetric flat shape — keep the outer-serializer delegation (which produces the flat form) and rewrite
denormalize() to read id/name/arguments directly.
Option 1 matches the OpenAI-style wire format that the denormalize() code was clearly written for; option 2 is smaller but throws away that intent.
Either way, a regression test that round-trips an AssistantMessage (and a ToolCallMessage) with ToolCalls through MessageNormalizer would prevent this from coming back.
Environment
symfony/ai-chat: v0.7.0 (also reproducible on main — same code path)
symfony/ai-platform: v0.7.0
symfony/ai-bundle: v0.7.0
- Symfony: 7.4.8
- PHP: 8.4
Summary
Symfony\AI\Chat\MessageNormalizerwrites thetoolsCallspayload of anAssistantMessage/ToolCallMessagein a flat shape ({id, name, arguments}) but itsdenormalize()method reads it expecting the OpenAI-style function-wrapped shape ({id, function: {name, arguments}}). Any conversation that contains aToolCallcannot be restored —MessageNormalizer::denormalize()crashes with:Actual behavior
After storing an
AssistantMessagewith tool calls viaSerializer::normalize()(e.g. for a persistent chat store) and loading it back viaSerializer::denormalize(), the denormalizer accesses a key that the normalizer never produced.Concrete DB row I recorded (JSON from a real, freshly-stored conversation):
{ "type": "Symfony\\AI\\Platform\\Message\\AssistantMessage", "content": "...", "toolsCalls": [ { "id": "fc_00cf31962a87d2830069e5d0679540819081763cbb56e88ba6", "name": "search_contract_templates", "arguments": {"searchTerm": "Promoter-Vertrag", "status": null} } ] }Note: no
functionkey. The nextdenormalize()on this data throws.Expected behavior
Round-tripping a message through
normalize()→denormalize()should produce an equivalentAssistantMessagewithout errors — i.e. the two halves ofMessageNormalizermust agree on the on-wire shape.Root cause
The two sides of
MessageNormalizerspeak different dialects.1.
normalize()delegates to the outer Serializerhttps://github.com/symfony/ai/blob/v0.7.0/src/chat/src/MessageNormalizer.php#L112-L118
$this->normalizercomes fromNormalizerAwareTrait— it is the outer Symfony Serializer that theMessageNormalizeris attached to. The bundle registers onlyMessageNormalizeritself as aserializer.normalizer:https://github.com/symfony/ai/blob/v0.7.0/src/ai-bundle/config/services.php#L263-L264
Symfony\AI\Platform\Contract\Normalizer\Result\ToolCallNormalizeris not registered as a DI service anywhere insymfony/ai-bundle. It only ever lives inside a privateSerializerinstance built bySymfony\AI\Platform\Contract:https://github.com/symfony/ai/blob/v0.7.0/src/platform/src/Contract.php
So when
MessageNormalizer::normalize()asks the outer serializer to normalize aToolCall, the outer serializer falls back toObjectNormalizerand writes it flat:2.
denormalize()hardcodes the OpenAI/function-wrapped shapehttps://github.com/symfony/ai/blob/v0.7.0/src/chat/src/MessageNormalizer.php#L57-L78
Neither of the two reconstruction paths uses the outer serializer to denormalize a
ToolCall— they read hardcoded keys thatnormalize()never emits.The same code is present on
main: https://github.com/symfony/ai/blob/main/src/chat/src/MessageNormalizer.php — so the bug is not specific to the v0.7.0 tag.Minimal reproducer
Output
Impact
Any application that persists chat history through
MessageNormalizerand later resumes a conversation whose assistant messages contain tool calls will crash on the very next turn. This affects the wholesymfony/ai-chatresume path as soon as tools are involved.Possible fix directions
Both halves need to agree. Off the top of my head:
ToolCallNormalizer— either registerSymfony\AI\Platform\Contract\Normalizer\Result\ToolCallNormalizeras aserializer.normalizerservice insymfony/ai-bundle, or instantiate it insideMessageNormalizer::normalize()and use it to produce the wrapped shape. Thendenormalize()reads whatnormalize()wrote.denormalize()to readid/name/argumentsdirectly.Option 1 matches the OpenAI-style wire format that the
denormalize()code was clearly written for; option 2 is smaller but throws away that intent.Either way, a regression test that round-trips an
AssistantMessage(and aToolCallMessage) withToolCalls throughMessageNormalizerwould prevent this from coming back.Environment
symfony/ai-chat: v0.7.0 (also reproducible onmain— same code path)symfony/ai-platform: v0.7.0symfony/ai-bundle: v0.7.0