From 47a47bf71d0104226add2cbb67b4539a02b7298e Mon Sep 17 00:00:00 2001 From: nubs Date: Fri, 5 Jun 2026 18:57:36 +0000 Subject: [PATCH] fix(llm): guard against null arguments in streaming tool-call accumulator (#2923) --- src/llm_core.py | 7 ++++++- tests/test_llm_core_streaming.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/llm_core.py b/src/llm_core.py index f8664eb09..6a4c81e52 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -1690,7 +1690,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl if func.get("name"): _tc_acc[idx]["name"] = func["name"] if "arguments" in func: - _tc_acc[idx]["arguments"] += func["arguments"] + # Guard against a null arguments delta: `func` can be + # {"arguments": None} (JSON null), and a raw `+= None` + # raises TypeError that the broad except swallows, + # silently dropping the rest of the chunk. Matches the + # Anthropic accumulator (`partial = ... or ""`) above. + _tc_acc[idx]["arguments"] += func["arguments"] or "" # Stream tool arg deltas for doc tools if func["arguments"] and _tc_acc[idx].get("name") in ("create_document", "update_document", "edit_document"): yield f'data: {json.dumps({"type": "tool_call_delta", "index": idx, "name": _tc_acc[idx]["name"], "arg_delta": func["arguments"]})}\n\n' diff --git a/tests/test_llm_core_streaming.py b/tests/test_llm_core_streaming.py index 447628695..637b94b9d 100644 --- a/tests/test_llm_core_streaming.py +++ b/tests/test_llm_core_streaming.py @@ -149,3 +149,23 @@ def test_sparse_integer_indices_then_null_do_not_collide(monkeypatch): events = _drive(monkeypatch, lines) calls = next(e["calls"] for e in events if e.get("type") == "tool_calls") assert sorted(c["name"] for c in calls) == ["f0", "f2", "fn"], f"collision: {calls}" + + +def test_null_arguments_delta_does_not_drop_sibling_calls(monkeypatch): + # A gateway can emit a tool_call delta whose `arguments` is JSON null. The + # accumulator did `"" += None`, raising TypeError caught by the broad except + # that wraps the whole chunk — so it abandoned the rest of the tool_calls + # loop, silently dropping every LATER call in the same delta. Here the first + # call has arguments: null; the second (same delta) must still survive. + lines = [ + _sse({"tool_calls": [ + {"index": 0, "id": "a", "type": "function", + "function": {"name": "first", "arguments": None}}, + {"index": 1, "id": "b", "type": "function", + "function": {"name": "second", "arguments": "{}"}}, + ]}), + "data: [DONE]", + ] + events = _drive(monkeypatch, lines, model="gpt-4o-test") + calls = next(e["calls"] for e in events if e.get("type") == "tool_calls") + assert sorted(c["name"] for c in calls) == ["first", "second"], calls