Skip to content

Commit 40d72a4

Browse files
committed
fix: strip empty text parts in streaming responses to prevent skipped tool execution
Some Gemini models (e.g. gemini-3.1-pro-preview) return function_call + text="" in the same streaming response. The empty text part was being treated as a final answer by the flow layer, preventing the second LLM call that should happen after tool execution. This fix strips empty text="" parts (where text is an empty string, not None) in both progressive and non-progressive SSE streaming paths of StreamingResponseAggregator.process_response(). Progressive path: added explicit `continue` to skip parts with text="" before they reach the else branch (which would add them to _parts_sequence as "other" non-text parts). Non-progressive path: filter out empty text parts from llm_response.content.parts before the existing text-accumulation logic runs, so they are never yielded as standalone non-partial responses.
1 parent 51c19cb commit 40d72a4

File tree

2 files changed

+118
-0
lines changed

2 files changed

+118
-0
lines changed

src/google/adk/utils/streaming_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,11 @@ async def process_response(
268268
# Only merge consecutive text parts of the same type (thought or regular)
269269
if llm_response.content and llm_response.content.parts:
270270
for part in llm_response.content.parts:
271+
# Skip empty text parts (text="") that some models return
272+
# alongside function_call parts. These carry no content and
273+
# can cause the flow layer to treat them as a final response.
274+
if part.text == '' and not part.thought:
275+
continue
271276
if part.text:
272277
# Check if we need to flush the current buffer first
273278
# (when text type changes from thought to regular or vice versa)
@@ -297,6 +302,19 @@ async def process_response(
297302
return
298303

299304
# ========== Non-Progressive SSE Streaming (old behavior) ==========
305+
306+
# Strip empty text parts (text="") that some models return alongside
307+
# function_call parts in the same streaming response. Without this,
308+
# the empty-text part is yielded as a non-partial response, which
309+
# downstream (base_llm_flow) treats as a final answer — preventing
310+
# the tool-result continuation call from ever being made.
311+
if llm_response.content and llm_response.content.parts:
312+
llm_response.content.parts = [
313+
p
314+
for p in llm_response.content.parts
315+
if not (p.text == '' and not p.thought)
316+
]
317+
300318
if (
301319
llm_response.content
302320
and llm_response.content.parts

tests/unittests/utils/test_streaming_utils.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,3 +304,103 @@ async def run_test():
304304
await run_test()
305305
else:
306306
await run_test()
307+
308+
@pytest.mark.asyncio
309+
async def test_empty_text_with_function_call_non_progressive(self):
310+
"""Empty text="" parts should be stripped so they don't become false final responses.
311+
312+
Some Gemini models return function_call + text="" in a single streaming
313+
response. Without stripping, the empty-text chunk is yielded as a
314+
non-partial LlmResponse, which base_llm_flow interprets as a final answer
315+
and never makes the second LLM call after tool execution.
316+
"""
317+
with temporary_feature_override(
318+
FeatureName.PROGRESSIVE_SSE_STREAMING, False
319+
):
320+
aggregator = streaming_utils.StreamingResponseAggregator()
321+
322+
# Chunk 1: function_call
323+
response_fc = types.GenerateContentResponse(
324+
candidates=[
325+
types.Candidate(
326+
content=types.Content(
327+
parts=[
328+
types.Part.from_function_call(
329+
name="list_directory",
330+
args={"path": "/tmp"},
331+
)
332+
]
333+
)
334+
)
335+
]
336+
)
337+
# Chunk 2: empty text (the problematic part)
338+
response_empty = types.GenerateContentResponse(
339+
candidates=[
340+
types.Candidate(
341+
content=types.Content(parts=[types.Part(text="")]),
342+
finish_reason=types.FinishReason.STOP,
343+
)
344+
]
345+
)
346+
347+
results_fc = []
348+
async for r in aggregator.process_response(response_fc):
349+
results_fc.append(r)
350+
351+
results_empty = []
352+
async for r in aggregator.process_response(response_empty):
353+
results_empty.append(r)
354+
355+
# The function_call chunk should be yielded (not partial — it has no text)
356+
assert len(results_fc) == 1
357+
fc_parts = results_fc[0].content.parts
358+
assert any(p.function_call for p in fc_parts)
359+
360+
# The empty-text chunk should have its empty text part stripped.
361+
# It must NOT contain a text="" part that could be mistaken for a
362+
# final answer.
363+
assert len(results_empty) == 1
364+
for p in results_empty[0].content.parts:
365+
if p.text is not None:
366+
assert p.text != '', (
367+
"Empty text part was not stripped — this causes the flow layer "
368+
"to treat it as a final response and skip tool execution"
369+
)
370+
371+
@pytest.mark.asyncio
372+
async def test_empty_text_with_function_call_progressive(self):
373+
"""Progressive mode should also ignore empty text="" parts."""
374+
with temporary_feature_override(
375+
FeatureName.PROGRESSIVE_SSE_STREAMING, True
376+
):
377+
aggregator = streaming_utils.StreamingResponseAggregator()
378+
379+
# Single response with function_call + empty text
380+
response = types.GenerateContentResponse(
381+
candidates=[
382+
types.Candidate(
383+
content=types.Content(
384+
parts=[
385+
types.Part.from_function_call(
386+
name="run_shell",
387+
args={"command": "ls"},
388+
),
389+
types.Part(text=""),
390+
]
391+
),
392+
finish_reason=types.FinishReason.STOP,
393+
)
394+
]
395+
)
396+
397+
async for _ in aggregator.process_response(response):
398+
pass
399+
400+
closed = aggregator.close()
401+
assert closed is not None
402+
# Should have the function_call but NOT the empty text
403+
assert any(p.function_call for p in closed.content.parts)
404+
for p in closed.content.parts:
405+
if p.text is not None:
406+
assert p.text != '', "Empty text part leaked into progressive aggregation"

0 commit comments

Comments
 (0)