d97ee2b917
* fix(middleware): normalize blank tool_call_id before provider request Closes #345. Some streaming providers (notably Kimi and Zhipu on streamed tool calls) occasionally emit tool calls whose id is an empty string, whitespace, or None. Strict providers reject the next turn with `invalid tool_call_id` (HTTP 400, code 3), freezing the thread after the very first tool round. The existing repair logic skipped blank ids via a truthy check (`if tool_call_id := call.get("id")`), so an AIMessage carrying a blank id was passed through unchanged while its paired ToolMessage was dropped as an orphan -- the next model call then 400'd. This adds a pre-pass (`_normalize_blank_tool_call_ids`) before the main repair loop that: * assigns each blank-id tool call on an AIMessage a fresh `_repair_<uuid>` id * pairs subsequent blank-id ToolMessages in arrival order (FIFO) so existing exchanges stay paired * lets unmatched blank calls fall through to the main loop, which synthesizes an interrupted-result ToolMessage with the fresh id * drops `additional_kwargs["tool_calls"]` on touched messages so langchain-openai's serializer falls back to the now-valid parsed form instead of preferring the raw form (which still carries the blank id) * pre-adds the fresh ids to `warned` so repair stays silent on subsequent model calls (the fresh ids are non-deterministic across calls) Verified end-to-end: `_convert_message_to_dict` on the repaired history puts no blank id on the wire and preserves AIMessage<->ToolMessage pairing. * fix(middleware): scope blank-id FIFO per exchange; cover invalid_tool_calls Addresses CodeRabbit review on PR #399. (1) Critical -- pending_slots FIFO leak across exchanges -------------------------------------------------------- The FIFO queue of fresh ids for blank-id calls was never closed at non-ToolMessage boundaries, so a later exchange's blank ToolMessage could be paired with a stale id from an earlier, already-interrupted exchange. The main loop then dropped the real tool result as an orphan and synthesized a fake interrupted result for the real call: AIMessage1(blank A) -- interrupted HumanMessage AIMessage2(blank B) ToolMessage(blank) -> popped A_new from FIFO front, not B_new Fix: close pending_slots at every AIMessage / HumanMessage / SystemMessage boundary via _close_unclaimed, mirroring close_pending() in the main loop. Unclaimed slots are merged into `warned` so the main loop's synthesized interrupted-result for that id stays silent across model calls. (2) Major -- invalid_tool_calls with blank id were silently dropped ------------------------------------------------------------------- `any_changed` was set only inside the `tool_calls` loop, so a message with a blank id only in `invalid_tool_calls` never entered the model_copy update path and the blank id survived untouched. langchain-openai's serializer puts `tool_calls + invalid_tool_calls` on the wire when either parsed list is non-empty (it does NOT skip invalid calls), so a blank id on an invalid call reaches the provider just as readily as one on a valid call -- verified by direct inspection of `_convert_message_to_dict`. Fix: track `invalid_changed` separately and include it in the update-path condition; also push invalid fresh ids into pending_slots (valid-first ordering ensures a real ToolMessage for a valid blank call never accidentally claims an invalid call's id). Tests ----- * test_pending_slots_scoped_per_exchange_not_global_fifo: the exact cross-exchange leak scenario CodeRabbit described. * test_normalizes_blank_id_in_invalid_tool_calls_only: the invalid-only case that previously slipped through. All 26 tests in test_tool_history_repair_middleware.py pass; full suite 3044 passed, 13 skipped (Windows-compatible subset). * fix(middleware): deterministic repair ids; don't pair invalid calls with orphan results Addresses din0s review on PR #399. (1) IDs are now deterministic, not uuid4 ---------------------------------------- Each blank id is rewritten as `_repair_{msg_idx}_{v|i}{call_idx}` so the same blank call gets the same id on every model call. The middleware re-runs on every request but can only rewrite the outgoing request, not the thread state, so random uuids made the wire payload unstable and grew the `warned` set unboundedly. Deterministic ids let the main loop's existing `warned`-set dedup suppress the synthesized-result warning from the second call on -- no pre-add hack needed. The `warned` parameter is therefore dropped from `_normalize_blank_tool_call_ids` (and the `_close_unclaimed` helper removed in favor of plain `pending_slots.clear()`). (2) invalid_tool_calls fresh ids are no longer pushed to pending_slots ---------------------------------------------------------------------- Invalid calls are never executed by LangGraph (args can't be parsed), so no real ToolMessage can claim their slot. Pushing it let an orphan blank ToolMessage from some other call mis-pair with the invalid call, surfacing the orphan's content under the invalid call's name. Without the push the orphan keeps its blank id and the main loop drops it, which is what we want. Tests ----- * Updated `test_blank_id_repair_does_not_spam_warnings` to expect the one-time-warning-then-silent pattern (matches `test_warning_deduplicates_across_calls`) and asserts the deterministic id. * New `test_invalid_blank_id_not_pushed_to_pending_slots` reproduces the exact mis-pairing scenario (orphan blank result + invalid call) and asserts the orphan content never leaks into a repaired tool result. 27 tests in test_tool_history_repair_middleware.py pass; ruff clean.