Skip to content

Commit a4cbdc3

Browse files
committed
fix: retry with resume message when model returns empty response
Some models (notably Gemini 2.5) intermittently return empty content (parts: [], candidatesTokenCount: 0, finishReason: STOP) after processing tool results. This is especially common under concurrent load and with streaming + thinking enabled. ADK's is_final_response() treats this as a valid completed turn because it only checks for the absence of function calls, not the presence of actual content. The agent loop stops and the user sees nothing. This fix adds retry logic in BaseLlmFlow.run_async(): 1. _has_meaningful_content() helper detects empty/thought-only events 2. When an empty final response is detected from the current agent, a resume message ("Your previous response was empty. Please resume execution from where you left off.") is injected into the session as a user event before re-prompting the model 3. Maximum 2 retries to prevent infinite loops 4. Author check (last_event.author == agent.name) prevents false positives on legitimate empty events from agent transfers Unlike a silent re-prompt, the injected message gives the model context about why it is being called again, improving recovery rate. Fixes #3525
1 parent f434d25 commit a4cbdc3

File tree

2 files changed

+232
-1
lines changed

2 files changed

+232
-1
lines changed

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,39 @@
6565

6666
_ADK_AGENT_NAME_LABEL_KEY = 'adk_agent_name'
6767

68+
# Maximum number of retries when the model returns an empty response after
69+
# tool execution. Prevents infinite loops if the model keeps returning empty.
70+
_MAX_EMPTY_RESPONSE_RETRIES = 2
71+
72+
# Message injected into the conversation when the model returns an empty
73+
# response, nudging it to resume execution on the next attempt.
74+
_EMPTY_RESPONSE_RESUME_MESSAGE = (
75+
'Your previous response was empty. '
76+
'Please resume execution from where you left off.'
77+
)
78+
79+
80+
def _has_meaningful_content(event: Event) -> bool:
81+
"""Returns whether the event has content worth showing to the user.
82+
83+
An event with no content, empty parts, or only thought / whitespace parts
84+
is not meaningful. Used to detect empty model responses after tool
85+
execution so the loop can re-prompt instead of silently halting.
86+
"""
87+
if not event.content or not event.content.parts:
88+
return False
89+
for part in event.content.parts:
90+
if part.function_call or part.function_response:
91+
return True
92+
if part.thought:
93+
continue
94+
if part.text and part.text.strip():
95+
return True
96+
if part.inline_data or part.file_data:
97+
return True
98+
return False
99+
100+
68101
# Timing configuration
69102
DEFAULT_TRANSFER_AGENT_DELAY = 1.0
70103
DEFAULT_TASK_COMPLETION_DELAY = 1.0
@@ -748,16 +781,46 @@ async def run_async(
748781
self, invocation_context: InvocationContext
749782
) -> AsyncGenerator[Event, None]:
750783
"""Runs the flow."""
784+
empty_response_count = 0
751785
while True:
752786
last_event = None
753787
async with Aclosing(self._run_one_step_async(invocation_context)) as agen:
754788
async for event in agen:
755789
last_event = event
756790
yield event
757-
if not last_event or last_event.is_final_response() or last_event.partial:
791+
if not last_event or last_event.partial:
758792
if last_event and last_event.partial:
759793
logger.warning('The last event is partial, which is not expected.')
760794
break
795+
if last_event.is_final_response():
796+
if (
797+
not _has_meaningful_content(last_event)
798+
and last_event.author == invocation_context.agent.name
799+
and empty_response_count < _MAX_EMPTY_RESPONSE_RETRIES
800+
):
801+
empty_response_count += 1
802+
logger.warning(
803+
'Model returned an empty response (attempt %d/%d),'
804+
' injecting resume message and re-prompting.',
805+
empty_response_count,
806+
_MAX_EMPTY_RESPONSE_RETRIES,
807+
)
808+
# Inject a resume nudge into the session so the next LLM call
809+
# sees it in its context and is more likely to continue.
810+
resume_event = Event(
811+
invocation_id=invocation_context.invocation_id,
812+
author='user',
813+
branch=invocation_context.branch,
814+
content=types.Content(
815+
role='user',
816+
parts=[
817+
types.Part.from_text(text=_EMPTY_RESPONSE_RESUME_MESSAGE)
818+
],
819+
),
820+
)
821+
yield resume_event
822+
continue
823+
break
761824

762825
async def _run_one_step_async(
763826
self,
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for empty model response retry logic in BaseLlmFlow.run_async."""
16+
17+
from google.adk.events.event import Event
18+
from google.adk.events.event_actions import EventActions
19+
from google.adk.flows.llm_flows.base_llm_flow import _has_meaningful_content
20+
from google.adk.flows.llm_flows.base_llm_flow import _MAX_EMPTY_RESPONSE_RETRIES
21+
from google.genai import types
22+
import pytest
23+
24+
25+
class TestHasMeaningfulContent:
26+
"""Tests for the _has_meaningful_content helper function."""
27+
28+
def test_no_content(self):
29+
"""Event with no content is not meaningful."""
30+
event = Event(
31+
invocation_id='test',
32+
author='model',
33+
content=None,
34+
)
35+
assert not _has_meaningful_content(event)
36+
37+
def test_empty_parts(self):
38+
"""Event with empty parts list is not meaningful."""
39+
event = Event(
40+
invocation_id='test',
41+
author='model',
42+
content=types.Content(role='model', parts=[]),
43+
)
44+
assert not _has_meaningful_content(event)
45+
46+
def test_only_empty_text_part(self):
47+
"""Event with only an empty text part is not meaningful."""
48+
event = Event(
49+
invocation_id='test',
50+
author='model',
51+
content=types.Content(
52+
role='model', parts=[types.Part.from_text(text='')]
53+
),
54+
)
55+
assert not _has_meaningful_content(event)
56+
57+
def test_only_whitespace_text_part(self):
58+
"""Event with only whitespace text is not meaningful."""
59+
event = Event(
60+
invocation_id='test',
61+
author='model',
62+
content=types.Content(
63+
role='model', parts=[types.Part.from_text(text=' \n ')]
64+
),
65+
)
66+
assert not _has_meaningful_content(event)
67+
68+
def test_thought_only_parts(self):
69+
"""Event with only thought parts is not meaningful."""
70+
event = Event(
71+
invocation_id='test',
72+
author='model',
73+
content=types.Content(
74+
role='model',
75+
parts=[types.Part(text='Let me think...', thought=True)],
76+
),
77+
)
78+
assert not _has_meaningful_content(event)
79+
80+
def test_text_content_is_meaningful(self):
81+
"""Event with non-empty text is meaningful."""
82+
event = Event(
83+
invocation_id='test',
84+
author='model',
85+
content=types.Content(
86+
role='model',
87+
parts=[types.Part.from_text(text='Here is the answer.')],
88+
),
89+
)
90+
assert _has_meaningful_content(event)
91+
92+
def test_function_call_is_meaningful(self):
93+
"""Event with a function call is meaningful."""
94+
event = Event(
95+
invocation_id='test',
96+
author='model',
97+
content=types.Content(
98+
role='model',
99+
parts=[
100+
types.Part(
101+
function_call=types.FunctionCall(name='my_tool', args={})
102+
)
103+
],
104+
),
105+
)
106+
assert _has_meaningful_content(event)
107+
108+
def test_function_response_is_meaningful(self):
109+
"""Event with a function response is meaningful."""
110+
event = Event(
111+
invocation_id='test',
112+
author='model',
113+
content=types.Content(
114+
role='model',
115+
parts=[
116+
types.Part(
117+
function_response=types.FunctionResponse(
118+
name='my_tool', response={'result': 'ok'}
119+
)
120+
)
121+
],
122+
),
123+
)
124+
assert _has_meaningful_content(event)
125+
126+
def test_thought_plus_text_is_meaningful(self):
127+
"""Event with thought AND real text is meaningful."""
128+
event = Event(
129+
invocation_id='test',
130+
author='model',
131+
content=types.Content(
132+
role='model',
133+
parts=[
134+
types.Part(text='Thinking...', thought=True),
135+
types.Part.from_text(text='The answer is 42.'),
136+
],
137+
),
138+
)
139+
assert _has_meaningful_content(event)
140+
141+
def test_inline_data_is_meaningful(self):
142+
"""Event with inline data is meaningful."""
143+
event = Event(
144+
invocation_id='test',
145+
author='model',
146+
content=types.Content(
147+
role='model',
148+
parts=[
149+
types.Part(
150+
inline_data=types.Blob(
151+
mime_type='image/png', data=b'\x89PNG'
152+
)
153+
)
154+
],
155+
),
156+
)
157+
assert _has_meaningful_content(event)
158+
159+
160+
class TestMaxEmptyResponseRetries:
161+
"""Verify the retry constant is sensible."""
162+
163+
def test_retry_limit_is_positive(self):
164+
assert _MAX_EMPTY_RESPONSE_RETRIES > 0
165+
166+
def test_retry_limit_is_small(self):
167+
"""Retry limit should be small to avoid excessive re-prompts."""
168+
assert _MAX_EMPTY_RESPONSE_RETRIES <= 5

0 commit comments

Comments
 (0)