Skip to content

Commit b378cef

Browse files
committed
fix: retry when model returns empty response after tool execution
Some models (notably Claude, and some Gemini preview models) occasionally return an empty content array (parts: []) after processing tool results. 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 adds a retry mechanism in BaseLlmFlow.run_async() that detects empty/meaningless final responses and re-prompts the model, up to a configurable maximum (default 2 retries) to prevent infinite loops. Closes #3754 Related: #3467, #4090, #3034
1 parent 4b677e7 commit b378cef

2 files changed

Lines changed: 263 additions & 1 deletion

File tree

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

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

6666
_ADK_AGENT_NAME_LABEL_KEY = 'adk_agent_name'
6767

68+
# Maximum number of retries when the model returns an empty response.
69+
# This prevents infinite loops when the model repeatedly returns empty content
70+
# (e.g. after tool execution with some models like Claude).
71+
_MAX_EMPTY_RESPONSE_RETRIES = 2
72+
6873
# Timing configuration
6974
DEFAULT_TRANSFER_AGENT_DELAY = 1.0
7075
DEFAULT_TASK_COMPLETION_DELAY = 1.0
@@ -73,6 +78,27 @@
7378
DEFAULT_ENABLE_CACHE_STATISTICS = False
7479

7580

81+
def _has_meaningful_content(event: Event) -> bool:
82+
"""Returns whether the event has content that is meaningful to the user.
83+
84+
An event with no content, empty parts, or only empty/whitespace text parts
85+
is not meaningful. This is used to detect cases where the model returns an
86+
empty response after tool execution (observed with Claude and some Gemini
87+
preview models), which should trigger a re-prompt instead of ending the
88+
agent loop.
89+
"""
90+
if not event.content or not event.content.parts:
91+
return False
92+
for part in event.content.parts:
93+
if part.function_call or part.function_response:
94+
return True
95+
if part.text and part.text.strip():
96+
return True
97+
if part.inline_data:
98+
return True
99+
return False
100+
101+
76102
def _finalize_model_response_event(
77103
llm_request: LlmRequest,
78104
llm_response: LlmResponse,
@@ -748,16 +774,30 @@ async def run_async(
748774
self, invocation_context: InvocationContext
749775
) -> AsyncGenerator[Event, None]:
750776
"""Runs the flow."""
777+
empty_response_count = 0
751778
while True:
752779
last_event = None
753780
async with Aclosing(self._run_one_step_async(invocation_context)) as agen:
754781
async for event in agen:
755782
last_event = event
756783
yield event
757-
if not last_event or last_event.is_final_response() or last_event.partial:
784+
if not last_event or last_event.partial:
758785
if last_event and last_event.partial:
759786
logger.warning('The last event is partial, which is not expected.')
760787
break
788+
if last_event.is_final_response():
789+
if (
790+
not _has_meaningful_content(last_event)
791+
and empty_response_count < _MAX_EMPTY_RESPONSE_RETRIES
792+
):
793+
empty_response_count += 1
794+
logger.warning(
795+
'Model returned an empty response (attempt %d/%d), re-prompting.',
796+
empty_response_count,
797+
_MAX_EMPTY_RESPONSE_RETRIES,
798+
)
799+
continue
800+
break
761801

762802
async def _run_one_step_async(
763803
self,
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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.agents.llm_agent import Agent
18+
from google.adk.events.event import Event
19+
from google.adk.events.event_actions import EventActions
20+
from google.adk.flows.llm_flows.base_llm_flow import _has_meaningful_content
21+
from google.adk.flows.llm_flows.base_llm_flow import _MAX_EMPTY_RESPONSE_RETRIES
22+
from google.adk.models.llm_response import LlmResponse
23+
from google.genai import types
24+
import pytest
25+
26+
from ... import testing_utils
27+
28+
29+
class TestHasMeaningfulContent:
30+
"""Tests for the _has_meaningful_content helper function."""
31+
32+
def test_no_content(self):
33+
"""Event with no content is not meaningful."""
34+
event = Event(
35+
invocation_id="test",
36+
author="model",
37+
content=None,
38+
)
39+
assert not _has_meaningful_content(event)
40+
41+
def test_empty_parts(self):
42+
"""Event with empty parts list is not meaningful."""
43+
event = Event(
44+
invocation_id="test",
45+
author="model",
46+
content=types.Content(role="model", parts=[]),
47+
)
48+
assert not _has_meaningful_content(event)
49+
50+
def test_only_empty_text_part(self):
51+
"""Event with only an empty text part is not meaningful."""
52+
event = Event(
53+
invocation_id="test",
54+
author="model",
55+
content=types.Content(
56+
role="model", parts=[types.Part.from_text(text="")]
57+
),
58+
)
59+
assert not _has_meaningful_content(event)
60+
61+
def test_only_whitespace_text_part(self):
62+
"""Event with only whitespace text is not meaningful."""
63+
event = Event(
64+
invocation_id="test",
65+
author="model",
66+
content=types.Content(
67+
role="model", parts=[types.Part.from_text(text=" \n ")]
68+
),
69+
)
70+
assert not _has_meaningful_content(event)
71+
72+
def test_non_empty_text(self):
73+
"""Event with actual text is meaningful."""
74+
event = Event(
75+
invocation_id="test",
76+
author="model",
77+
content=types.Content(
78+
role="model",
79+
parts=[types.Part.from_text(text="Hello, world!")],
80+
),
81+
)
82+
assert _has_meaningful_content(event)
83+
84+
def test_function_call(self):
85+
"""Event with a function call is meaningful."""
86+
event = Event(
87+
invocation_id="test",
88+
author="model",
89+
content=types.Content(
90+
role="model",
91+
parts=[
92+
types.Part.from_function_call(
93+
name="test_tool", args={"key": "value"}
94+
)
95+
],
96+
),
97+
)
98+
assert _has_meaningful_content(event)
99+
100+
def test_function_response(self):
101+
"""Event with a function response is meaningful."""
102+
event = Event(
103+
invocation_id="test",
104+
author="model",
105+
content=types.Content(
106+
role="model",
107+
parts=[
108+
types.Part.from_function_response(
109+
name="test_tool", response={"result": "ok"}
110+
)
111+
],
112+
),
113+
)
114+
assert _has_meaningful_content(event)
115+
116+
117+
class TestEmptyResponseRetry:
118+
"""Tests for the agent loop retrying on empty model responses."""
119+
120+
@pytest.mark.asyncio
121+
async def test_empty_response_retried_then_succeeds(self):
122+
"""Agent loop retries when model returns empty content, then succeeds."""
123+
empty_response = LlmResponse(
124+
content=types.Content(role="model", parts=[]),
125+
partial=False,
126+
)
127+
good_response = LlmResponse(
128+
content=types.Content(
129+
role="model",
130+
parts=[types.Part.from_text(text="Here are the results.")],
131+
),
132+
partial=False,
133+
)
134+
135+
mock_model = testing_utils.MockModel.create(
136+
responses=[empty_response, good_response]
137+
)
138+
agent = Agent(
139+
name="test_agent",
140+
model=mock_model,
141+
instruction="You are a helpful assistant.",
142+
)
143+
144+
invocation_context = await testing_utils.create_invocation_context(
145+
agent=agent, user_content="test"
146+
)
147+
148+
events = []
149+
async for event in agent.run_async(invocation_context):
150+
events.append(event)
151+
152+
# Should have events from both LLM calls (empty + good)
153+
non_partial_events = [e for e in events if not e.partial]
154+
final_texts = [
155+
part.text
156+
for e in non_partial_events
157+
if e.content and e.content.parts
158+
for part in e.content.parts
159+
if part.text
160+
]
161+
assert any(
162+
"results" in t for t in final_texts
163+
), "Expected the good response text after retry"
164+
165+
@pytest.mark.asyncio
166+
async def test_empty_response_stops_after_max_retries(self):
167+
"""Agent loop stops after max retries of empty responses."""
168+
empty_responses = [
169+
LlmResponse(
170+
content=types.Content(role="model", parts=[]),
171+
partial=False,
172+
)
173+
for _ in range(_MAX_EMPTY_RESPONSE_RETRIES + 1)
174+
]
175+
176+
mock_model = testing_utils.MockModel.create(responses=empty_responses)
177+
agent = Agent(
178+
name="test_agent",
179+
model=mock_model,
180+
instruction="You are a helpful assistant.",
181+
)
182+
183+
invocation_context = await testing_utils.create_invocation_context(
184+
agent=agent, user_content="test"
185+
)
186+
187+
events = []
188+
async for event in agent.run_async(invocation_context):
189+
events.append(event)
190+
191+
# The model should have been called _MAX_EMPTY_RESPONSE_RETRIES + 1 times
192+
# (1 initial + N retries) and then the loop should stop.
193+
assert mock_model.response_index == _MAX_EMPTY_RESPONSE_RETRIES
194+
195+
@pytest.mark.asyncio
196+
async def test_non_empty_response_not_retried(self):
197+
"""A normal response with content is not retried."""
198+
good_response = LlmResponse(
199+
content=types.Content(
200+
role="model",
201+
parts=[types.Part.from_text(text="All good.")],
202+
),
203+
partial=False,
204+
)
205+
206+
mock_model = testing_utils.MockModel.create(responses=[good_response])
207+
agent = Agent(
208+
name="test_agent",
209+
model=mock_model,
210+
instruction="You are a helpful assistant.",
211+
)
212+
213+
invocation_context = await testing_utils.create_invocation_context(
214+
agent=agent, user_content="test"
215+
)
216+
217+
events = []
218+
async for event in agent.run_async(invocation_context):
219+
events.append(event)
220+
221+
# Model should only be called once
222+
assert mock_model.response_index == 0

0 commit comments

Comments
 (0)