A mid-stream-aborted LiteLLM tool-call finalizes partial arguments into an unhandled JSONDecodeError — the truncation guard only covers finish_reason == "length"
This issue has been reattributed since it was filed — the original title
blamed 2.5.0 and the version A/B below explains why that was wrong. What
remains is a robustness bug present, byte-identically, in 2.4.0 through
2.6.3.
The bug
_finalize_tool_call_response in models/lite_llm.py has a graceful branch
for truncated tool-call arguments — it returns an LlmResponse with
error_code=MAX_TOKENS and the message "Tool call arguments were truncated
while streaming and could not be parsed as valid JSON. Increase
max_output_tokens and retry." — but the validation is guarded:
for index, func_data in function_calls.items():
if func_data["id"]:
if finish_reason == "length": # <-- only this case is handled
try:
_parse_tool_call_arguments(func_data["args"])
except json.JSONDecodeError:
has_incomplete_tool_call_args = True
continue
tool_calls.append(...) # partial args pass through here
A stream that aborts mid-tool-call — provider incident, transport cut,
proxy timeout — ends with no finish_reason at all (and no usage chunk).
The guard is bypassed, the partial arguments reach
_parse_tool_call_arguments, and a bare JSONDecodeError propagates out of
generate_content_async and kills the whole invocation:
File ".../google/adk/models/lite_llm.py", line 2956, in generate_content_async
_finalize_tool_call_response(
File ".../google/adk/models/lite_llm.py", line 2184, in _message_to_generate_content_response
args=_parse_tool_call_arguments(tool_call.function.arguments),
File ".../google/adk/models/lite_llm.py", line 199, in _parse_tool_call_arguments
return json.loads(arguments)
json.decoder.JSONDecodeError: Expecting value: line 1 column 16 (char 15)
The buffer held exactly {"agent_name": — the head of ADK's own
transfer_to_agent arguments; the value never arrived.
Suggested fix: treat "stream ended while a tool call's arguments do not
parse" the same as the length case regardless of finish_reason — the graceful
MAX_TOKENS-style LlmResponse (or a dedicated aborted-stream error code)
instead of an uncaught parse exception. The discriminator is already in hand at
that point: tool-call deltas were received and the stream ended with no
terminal finish_reason. (The missing usage chunk — despite include_usage — corroborates the abnormal
end: 27/27 clean tee'd calls carried one, and every failure in the preserved
incident log is flagged by ADK's own Skipping missing token usage metadata
warning.)
How we hit it, and why the original version-attribution was wrong
During a provider-side incident window (2026-08-13, ~15:56–19:00 UTC, model
openrouter/z-ai/glm-5.2 through LiteLLM), a deployed multi-agent app died on
its first transfer_to_agent in 9 of 10 turns on 2.5.0 while an
interleaved 2.4.0 arm passed 10/10 — which looked exactly like a 2.5.0
regression, and was filed as one.
Re-measured twelve hours later with a wire tee on the same estate, same probe:
| Arm (deployed) |
Turns |
char 15 failures |
Transfer calls on the wire |
| 2.4.0 |
6 |
0 |
6/6 complete, finish=tool_calls, usage present |
| 2.5.0 |
10 |
0 |
10/10 complete |
| 2.6.3 |
10 |
0 |
10/10 complete |
Same requests (byte-identical messages; the versions' tools payloads differ by
two words of docstring), zero failures on any version. The failing window's
errors each correlate with ADK's own Skipping missing token usage metadata
warning — the streams ended abnormally. OpenRouter serves this model from a
32-provider pool and per-generation lookups show each window's calls
concentrating on one upstream (one arm: 10/10 CoreWeave; another: 8/11 Baidu),
so a window whose routing lands on a misbehaving upstream fails almost every
turn while an adjacent window passes — which is what masqueraded as a version
difference. A minimal standalone harness (coordinator + two sub-agents, same
model) reproduces on no version under either StreamingMode.
So: the 9/10-vs-0/10 A/B was real but confounded; 2.4.0 survived the incident
by routing luck, not by handling — its finalize path is byte-identical. Any ADK
version converts an aborted upstream stream mid-tool-call into a hard
invocation failure, and that is the actionable bug.
Environment
|
|
| google-adk |
2.4.0 / 2.5.0 / 2.6.3 (guard byte-identical in all three) |
| litellm |
1.89.4 (pinned across all measurements) |
| Model |
openrouter/z-ai/glm-5.2 (32-provider pool) |
| Streaming |
StreamingMode.SSE, stream_options={"include_usage": true} |
| Failing tool |
transfer_to_agent (ADK-generated) |
Possibly related
A mid-stream-aborted LiteLLM tool-call finalizes partial arguments into an unhandled JSONDecodeError — the truncation guard only covers
finish_reason == "length"The bug
_finalize_tool_call_responseinmodels/lite_llm.pyhas a graceful branchfor truncated tool-call arguments — it returns an
LlmResponsewitherror_code=MAX_TOKENSand the message "Tool call arguments were truncatedwhile streaming and could not be parsed as valid JSON. Increase
max_output_tokensand retry." — but the validation is guarded:A stream that aborts mid-tool-call — provider incident, transport cut,
proxy timeout — ends with no finish_reason at all (and no usage chunk).
The guard is bypassed, the partial arguments reach
_parse_tool_call_arguments, and a bareJSONDecodeErrorpropagates out ofgenerate_content_asyncand kills the whole invocation:The buffer held exactly
{"agent_name":— the head of ADK's owntransfer_to_agentarguments; the value never arrived.Suggested fix: treat "stream ended while a tool call's arguments do not
parse" the same as the
lengthcase regardless of finish_reason — the gracefulMAX_TOKENS-styleLlmResponse(or a dedicated aborted-stream error code)instead of an uncaught parse exception. The discriminator is already in hand at
that point: tool-call deltas were received and the stream ended with no
terminal finish_reason. (The missing usage chunk — despite
include_usage— corroborates the abnormalend: 27/27 clean tee'd calls carried one, and every failure in the preserved
incident log is flagged by ADK's own
Skipping missing token usage metadatawarning.)
How we hit it, and why the original version-attribution was wrong
During a provider-side incident window (2026-08-13, ~15:56–19:00 UTC, model
openrouter/z-ai/glm-5.2through LiteLLM), a deployed multi-agent app died onits first
transfer_to_agentin 9 of 10 turns on 2.5.0 while aninterleaved 2.4.0 arm passed 10/10 — which looked exactly like a 2.5.0
regression, and was filed as one.
Re-measured twelve hours later with a wire tee on the same estate, same probe:
char 15failuresfinish=tool_calls, usage presentSame requests (byte-identical messages; the versions' tools payloads differ by
two words of docstring), zero failures on any version. The failing window's
errors each correlate with ADK's own
Skipping missing token usage metadatawarning — the streams ended abnormally. OpenRouter serves this model from a
32-provider pool and per-generation lookups show each window's calls
concentrating on one upstream (one arm: 10/10 CoreWeave; another: 8/11 Baidu),
so a window whose routing lands on a misbehaving upstream fails almost every
turn while an adjacent window passes — which is what masqueraded as a version
difference. A minimal standalone harness (coordinator + two sub-agents, same
model) reproduces on no version under either
StreamingMode.So: the 9/10-vs-0/10 A/B was real but confounded; 2.4.0 survived the incident
by routing luck, not by handling — its finalize path is byte-identical. Any ADK
version converts an aborted upstream stream mid-tool-call into a hard
invocation failure, and that is the actionable bug.
Environment
openrouter/z-ai/glm-5.2(32-provider pool)StreamingMode.SSE,stream_options={"include_usage": true}transfer_to_agent(ADK-generated)Possibly related
PROGRESSIVE_SSE_STREAMING is not honored by the LiteLlm adapter[LiteLlm] JSONDecodeError on malformed tool_call.function.arguments