Skip to content

[Chat] MessageNormalizer cannot round-trip AssistantMessage with tool calls #1972

@sjahns

Description

@sjahns

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:

  1. 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.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugSomething isn't workingChatIssues & PRs about the AI Chat componentStatus: Needs Review

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions