Skip to content

Gemini 3.x streaming: empty STOP chunk after tool call terminates the loop, producing an empty response #1328

Description

@Cato-Wen

Environment

  • adk-java: 1.5.0 (latest)
  • Model: gemini-3-flash-preview / gemini-3.1-flash-lite (Vertex AI)
  • StreamingMode: SSE

Summary

With a Gemini 3.x model + SSE streaming + function calling, after a tool
executes, the agent produces an empty final response (no summary text ever
reaches the client). The exact same agent works correctly with Claude models
and also works when StreamingMode is NONE.

Root cause (traced through source, adk-java 1.5.0)

In SSE streaming, Gemini 3.x returns the functionCall and then a trailing
empty chunk
: content is present with a single part whose text is "",
finishReason=STOP, partial=false, and no functionCall/functionResponse.

  1. BaseLlmFlow.buildPostprocessingEvents only drops responses whose
    content() is fully empty. This chunk has content present (a part with an
    empty-string text), so it is NOT dropped and becomes an Event.
  2. Event.finalResponse() returns true for it:
    functionCalls().isEmpty() && functionResponses().isEmpty() && !partial().orElse(false) && !hasTrailingCodeExecutionResult() — all true.
  3. BaseLlmFlow.run() checks Iterables.getLast(eventList).finalResponse().
    Since this empty chunk is the last event produced by the step, the loop
    terminates and the 2nd LLM call to summarize the tool result is never
    issued
    .

Claude is unaffected because it returns text + functionCall in a single
response and does not emit a standalone empty chunk.

Which version introduced this (best-effort, from release notes)

  • The empty stop-chunk first became reachable as a surviving finalResponse
    event in v0.8.0 (commit a0cba25, "Fixed issue where events were marked
    empty if the first part had an empty text; now checks all parts for meaningful
    content"). That change stopped treating an event as empty based on the first
    part alone, so a "content-present-but-textless" Gemini 3.x chunk is no longer
    filtered out. The same release (acffdb9) added Gemini 3.1 event buffering.
  • A targeted fix, "Suppress empty-text-only chunks from streaming responses
    while preserving carried metadata"
    (commit b4791ef), was added in
    v1.4.0 but reverted in the same release (commit 69638df), and was
    not restored in v1.5.0 — so the bug remains reachable through 1.5.0.

(Note: I could not verify the exact introduction commit by source diff; the
above is inferred from release notes and matches the observed behavior.)

Steps to reproduce

  1. LlmAgent with a Gemini 3.x model + one FunctionTool, SSE streaming.
  2. Ask a question that triggers the tool.
  3. Observe: functionCall → tool executes → functionResponse → empty STOP chunk
    → loop ends. No summary text is produced.

Observed event sequence (from our tracing)

AFTER_MODEL #1: hasFunctionCall=true  hasText=false finishReason=-      (functionCall)
STREAM_EVENT:   functionCalls=1  finalResponse=false
[tool executes]
STREAM_EVENT:   functionResponses=1  finalResponse=false
AFTER_MODEL #2: hasFunctionCall=false hasText=false finishReason=STOP   (EMPTY chunk)
STREAM_EVENT:   finalResponse=true  textLen=0        <-- loop terminates here
STREAM_COMPLETE: accumulatedTextLen=0

Expected

The empty trailing chunk should not terminate the multi-step loop; the flow
should issue the follow-up LLM call to summarize the tool result (as it does
with Claude, and as SSE + Gemini does once the empty chunk is removed).

Workaround we are using

In afterModelCallback, detect this throwaway empty chunk — content present,
not partial, and EVERY part has blank text AND none of the payload-bearing
fields (functionCall / functionResponse / thoughtSignature / inlineData /
fileData / executableCode / codeExecutionResult / videoMetadata) — and return a
content-less LlmResponse, so it hits ADK's own drop path in
buildPostprocessingEvents. This keeps SSE streaming and lets the loop
continue.

Important: chunks carrying a thoughtSignature (even with empty text) must NOT
be dropped, since they carry reasoning-chain context for multi-turn function
calling.

Notes

  • Setting StreamingMode.NONE also avoids the issue (Gemini returns a single
    complete functionCall response, with no separate empty chunk).
  • Related history: b4791ef (v1.4.0) added the suppression fix, reverted by
    69638df in the same release.

Metadata

Metadata

Assignees

Labels

waiting on reporterWaiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.

Type

Fields

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions