* 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
* fix(middleware): drop orphan ToolMessages with no matching AIMessage tool_call
The rebuild loop only skipped ToolMessages whose tool_call_id matched a
known AIMessage tool_call (to be re-emitted after it). An orphan ToolMessage
whose tool_call_id has no matching AIMessage tool_calls fell through and was
kept, leaving a dangling tool result that strict providers reject. Drop
orphan ToolMessages as well, logging at debug.
* fix(dangling): demote orphan-drop logs, add tool_call_id=None test
- Update module/class docstrings to mention orphan ToolMessage handling
- Accumulate orphan drop_count and emit a single logger.warning
instead of per-message logger.debug calls
- Simplify early-return logic: return None only when no patching
AND no orphans were dropped
- Add test_tool_call_id_none_orphan_is_dropped — a ToolMessage
with tool_call_id=None is always an orphan and must be dropped
Closes#4080
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(test): use model_construct for None tool_call_id test to bypass pydantic validation
ToolMessage content='ghost' tool_call_id=None fails pydantic validation at
construction. Use model_construct to simulate a corrupt/edge-case payload
without tripping the string-only guard.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* test: add unit tests for DanglingToolCallMiddleware
Cover message patching logic for dangling tool calls:
- No-op when all tool calls have responses
- Synthetic ToolMessage insertion at correct positions
- Mixed responded/dangling scenarios
- wrap_model_call and awrap_model_call integration
* test: fix async tests and strengthen override assertions
- Use @pytest.mark.anyio + async def instead of deprecated
asyncio.get_event_loop().run_until_complete() (fixes Py3.12 CI failure)
- Assert that override() receives the correct patched messages kwarg
in both wrap_model_call and awrap_model_call tests
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>