From b378cef186ebcf17f6c2abc8d1d662e266af3566 Mon Sep 17 00:00:00 2001 From: Akash Bangad Date: Tue, 24 Mar 2026 15:36:53 +0100 Subject: [PATCH] fix: retry when model returns empty response after tool execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../adk/flows/llm_flows/base_llm_flow.py | 42 +++- .../llm_flows/test_empty_response_retry.py | 222 ++++++++++++++++++ 2 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 tests/unittests/flows/llm_flows/test_empty_response_retry.py diff --git a/src/google/adk/flows/llm_flows/base_llm_flow.py b/src/google/adk/flows/llm_flows/base_llm_flow.py index bd0037bdcb..3c25a7a746 100644 --- a/src/google/adk/flows/llm_flows/base_llm_flow.py +++ b/src/google/adk/flows/llm_flows/base_llm_flow.py @@ -65,6 +65,11 @@ _ADK_AGENT_NAME_LABEL_KEY = 'adk_agent_name' +# Maximum number of retries when the model returns an empty response. +# This prevents infinite loops when the model repeatedly returns empty content +# (e.g. after tool execution with some models like Claude). +_MAX_EMPTY_RESPONSE_RETRIES = 2 + # Timing configuration DEFAULT_TRANSFER_AGENT_DELAY = 1.0 DEFAULT_TASK_COMPLETION_DELAY = 1.0 @@ -73,6 +78,27 @@ DEFAULT_ENABLE_CACHE_STATISTICS = False +def _has_meaningful_content(event: Event) -> bool: + """Returns whether the event has content that is meaningful to the user. + + An event with no content, empty parts, or only empty/whitespace text parts + is not meaningful. This is used to detect cases where the model returns an + empty response after tool execution (observed with Claude and some Gemini + preview models), which should trigger a re-prompt instead of ending the + agent loop. + """ + if not event.content or not event.content.parts: + return False + for part in event.content.parts: + if part.function_call or part.function_response: + return True + if part.text and part.text.strip(): + return True + if part.inline_data: + return True + return False + + def _finalize_model_response_event( llm_request: LlmRequest, llm_response: LlmResponse, @@ -748,16 +774,30 @@ async def run_async( self, invocation_context: InvocationContext ) -> AsyncGenerator[Event, None]: """Runs the flow.""" + empty_response_count = 0 while True: last_event = None async with Aclosing(self._run_one_step_async(invocation_context)) as agen: async for event in agen: last_event = event yield event - if not last_event or last_event.is_final_response() or last_event.partial: + if not last_event or last_event.partial: if last_event and last_event.partial: logger.warning('The last event is partial, which is not expected.') break + if last_event.is_final_response(): + if ( + not _has_meaningful_content(last_event) + and empty_response_count < _MAX_EMPTY_RESPONSE_RETRIES + ): + empty_response_count += 1 + logger.warning( + 'Model returned an empty response (attempt %d/%d), re-prompting.', + empty_response_count, + _MAX_EMPTY_RESPONSE_RETRIES, + ) + continue + break async def _run_one_step_async( self, diff --git a/tests/unittests/flows/llm_flows/test_empty_response_retry.py b/tests/unittests/flows/llm_flows/test_empty_response_retry.py new file mode 100644 index 0000000000..a2ca4da051 --- /dev/null +++ b/tests/unittests/flows/llm_flows/test_empty_response_retry.py @@ -0,0 +1,222 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for empty model response retry logic in BaseLlmFlow.run_async.""" + +from google.adk.agents.llm_agent import Agent +from google.adk.events.event import Event +from google.adk.events.event_actions import EventActions +from google.adk.flows.llm_flows.base_llm_flow import _has_meaningful_content +from google.adk.flows.llm_flows.base_llm_flow import _MAX_EMPTY_RESPONSE_RETRIES +from google.adk.models.llm_response import LlmResponse +from google.genai import types +import pytest + +from ... import testing_utils + + +class TestHasMeaningfulContent: + """Tests for the _has_meaningful_content helper function.""" + + def test_no_content(self): + """Event with no content is not meaningful.""" + event = Event( + invocation_id="test", + author="model", + content=None, + ) + assert not _has_meaningful_content(event) + + def test_empty_parts(self): + """Event with empty parts list is not meaningful.""" + event = Event( + invocation_id="test", + author="model", + content=types.Content(role="model", parts=[]), + ) + assert not _has_meaningful_content(event) + + def test_only_empty_text_part(self): + """Event with only an empty text part is not meaningful.""" + event = Event( + invocation_id="test", + author="model", + content=types.Content( + role="model", parts=[types.Part.from_text(text="")] + ), + ) + assert not _has_meaningful_content(event) + + def test_only_whitespace_text_part(self): + """Event with only whitespace text is not meaningful.""" + event = Event( + invocation_id="test", + author="model", + content=types.Content( + role="model", parts=[types.Part.from_text(text=" \n ")] + ), + ) + assert not _has_meaningful_content(event) + + def test_non_empty_text(self): + """Event with actual text is meaningful.""" + event = Event( + invocation_id="test", + author="model", + content=types.Content( + role="model", + parts=[types.Part.from_text(text="Hello, world!")], + ), + ) + assert _has_meaningful_content(event) + + def test_function_call(self): + """Event with a function call is meaningful.""" + event = Event( + invocation_id="test", + author="model", + content=types.Content( + role="model", + parts=[ + types.Part.from_function_call( + name="test_tool", args={"key": "value"} + ) + ], + ), + ) + assert _has_meaningful_content(event) + + def test_function_response(self): + """Event with a function response is meaningful.""" + event = Event( + invocation_id="test", + author="model", + content=types.Content( + role="model", + parts=[ + types.Part.from_function_response( + name="test_tool", response={"result": "ok"} + ) + ], + ), + ) + assert _has_meaningful_content(event) + + +class TestEmptyResponseRetry: + """Tests for the agent loop retrying on empty model responses.""" + + @pytest.mark.asyncio + async def test_empty_response_retried_then_succeeds(self): + """Agent loop retries when model returns empty content, then succeeds.""" + empty_response = LlmResponse( + content=types.Content(role="model", parts=[]), + partial=False, + ) + good_response = LlmResponse( + content=types.Content( + role="model", + parts=[types.Part.from_text(text="Here are the results.")], + ), + partial=False, + ) + + mock_model = testing_utils.MockModel.create( + responses=[empty_response, good_response] + ) + agent = Agent( + name="test_agent", + model=mock_model, + instruction="You are a helpful assistant.", + ) + + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content="test" + ) + + events = [] + async for event in agent.run_async(invocation_context): + events.append(event) + + # Should have events from both LLM calls (empty + good) + non_partial_events = [e for e in events if not e.partial] + final_texts = [ + part.text + for e in non_partial_events + if e.content and e.content.parts + for part in e.content.parts + if part.text + ] + assert any( + "results" in t for t in final_texts + ), "Expected the good response text after retry" + + @pytest.mark.asyncio + async def test_empty_response_stops_after_max_retries(self): + """Agent loop stops after max retries of empty responses.""" + empty_responses = [ + LlmResponse( + content=types.Content(role="model", parts=[]), + partial=False, + ) + for _ in range(_MAX_EMPTY_RESPONSE_RETRIES + 1) + ] + + mock_model = testing_utils.MockModel.create(responses=empty_responses) + agent = Agent( + name="test_agent", + model=mock_model, + instruction="You are a helpful assistant.", + ) + + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content="test" + ) + + events = [] + async for event in agent.run_async(invocation_context): + events.append(event) + + # The model should have been called _MAX_EMPTY_RESPONSE_RETRIES + 1 times + # (1 initial + N retries) and then the loop should stop. + assert mock_model.response_index == _MAX_EMPTY_RESPONSE_RETRIES + + @pytest.mark.asyncio + async def test_non_empty_response_not_retried(self): + """A normal response with content is not retried.""" + good_response = LlmResponse( + content=types.Content( + role="model", + parts=[types.Part.from_text(text="All good.")], + ), + partial=False, + ) + + mock_model = testing_utils.MockModel.create(responses=[good_response]) + agent = Agent( + name="test_agent", + model=mock_model, + instruction="You are a helpful assistant.", + ) + + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content="test" + ) + + events = [] + async for event in agent.run_async(invocation_context): + events.append(event) + + # Model should only be called once + assert mock_model.response_index == 0