Files
EvoScientist-Multi/tests
houren Antony d97ee2b917 fix(middleware): normalize blank tool_call_id before provider request (#399)
* 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.
2026-08-03 15:02:15 +01:00
..
2026-01-29 17:39:50 +00:00
2026-06-07 00:52:59 +01:00
2026-06-16 09:14:29 +02:00
2026-03-24 18:13:42 +00:00