Normalizing a conversation makes it sendable to a provider
Stored history and provider history are not the same shape. Normalization is the one place that reconciles them, and it reports what it had to drop.
The rule
- A conversation-normalization node takes one message list and emits a normalized message list plus the number of entries dropped, applying four rules in order.
- (1) Each entry is coerced to the flat message shape a reasoner consumes; a non-list entry or one without a role is dropped, and content that is not a scalar is serialized to text rather than blanked, falling back to the empty string if it cannot be serialized.
- (2)
system-role messages move to the front, with the system group and the remaining group each keeping their own relative order; a system message is never dropped. - (3) Tool pairing is repaired by the same procedure a reasoner applies mid-loop: an unanswered call gets a synthetic interrupted result adjacent to it, an orphan or duplicate result is dropped, and a tool call id declared a second time is dropped from the later turn, so the first declaration owns the pairing.
- (4) Any message still leading the list with role
toolis stripped, and because stripping it can leave a call unanswered, rule (3) is applied again; repair is idempotent. - The reported drop count comes from the repair pass itself, never from a separate reimplementation of its rules.
What it means
The reported drop count has to come from the repair pass itself, never from
a second count kept alongside it — the two can disagree on a case a reader
would not think to try. A tool call id can be present in the buffer and
still be unusable: "0" is a legal id string but a falsy value, and once the
call carrying it is treated as unusable, the turn that declared it, the id
itself, and the result answering it all have to go — one unusable id costing
three drops, not one, from a buffer of two entries.
The other clause worth reading closely is what happens at the front of the list. A tool result placed before the assistant turn that declares its id is not caught by id-matching alone — the declaring call exists somewhere in the list, just not before its result — so a separate leading-orphan guard strips it. Stripping it un-answers the assistant turn it was answering, so tool pairing is applied again; the result is provider-sendable, not merely orphan-free.
Example
A call id that is technically declared but falsy:
[
{ "role": "assistant", "content": "", "tool_calls": [{ "tool_call_id": "0", "name": "search" }] },
{ "role": "tool", "content": "result", "tool_call_id": "0" }
][]A tool result appearing before the call that declares its id:
[
{ "role": "tool", "content": "out of order", "tool_call_id": "c1" },
{ "role": "assistant", "content": "", "tool_calls": [{ "tool_call_id": "c1", "name": "search" }] }
][
{ "role": "assistant", "content": "", "tool_calls": [{ "tool_call_id": "c1", "name": "search" }] },
{ "role": "tool", "content": "Tool call was interrupted; no result.", "tool_call_id": "c1" }
]