Skip to content

Aborted mid-stream LiteLLM tool-call finalizes partial arguments into an unhandled JSONDecodeError — truncation guard only covers finish_reason=='length' #6716

Description

@cyyeong

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

Metadata

Metadata

Labels

models[Component] This issue is related to model supportrequest clarification[Status] The maintainer need clarification or more information from the author

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions