Aari 5a5c661e9f
fix(middleware): recover malformed tool-call ids in dangling repair (#4246)
* fix(middleware): recover malformed tool-call ids in dangling repair

DanglingToolCallMiddleware normalizes malformed tool-call names (#4008) and
arguments (#4193) so strict OpenAI-compatible providers do not reject the next
request. The id is the third field of that same recovery contract and was left
alone.

A provider that emits an empty id -- or omits it -- parses into a well-formed
tool_calls entry, so it reaches the middleware through the normal path. The
empty id never enters the pairing set, so the orphan pass drops the call's
already-produced ToolMessage and the placeholder pass skips the call. The
request then goes out carrying an empty id and with the real tool result gone.

Normalize ids up front and re-point each already-paired ToolMessage at its
call's new id, so the existing pairing/orphan/placeholder logic no longer sees
a malformed id. Only the view that is actually read and serialized is
relabelled; a valid id is left byte-for-byte alone, since it is matched
verbatim against ToolMessage.tool_call_id.

* fix(middleware): scope malformed-id result pairing to its own turn

Malformed tool-call ids are all equally empty, so pairing recovered results by
the original id alone was a global FIFO over the whole transcript. An earlier
dangling call then consumed a later turn's result: the real result was served
to the wrong call while the call that actually ran got the interrupted
placeholder. An orphan result whose originating AIMessage was already gone
could likewise be adopted by a later malformed call, resurrecting it instead of
being dropped as the orphan pass intends.

Walk the messages once in document order so only the most recent AIMessage's
unanswered calls are claimable, which keeps a result answering the turn that
issued it, and rule out the wrong parallel sibling within a turn by tool name.
A result whose name matches no open call is left malformed for the existing
orphan pass to drop rather than repurposed as some other call's answer.

* fix(middleware): keep the shadowed raw view out of id recovery

* fix(middleware): only claim a malformed result when the pairing is forced

* docs(middleware): cite ToolNode's ordering guarantee for positional pairing
2026-07-17 19:16:42 +08:00
..