From b378cef186ebcf17f6c2abc8d1d662e266af3566 Mon Sep 17 00:00:00 2001 From: Akash Bangad Date: Tue, 24 Mar 2026 15:36:53 +0100 Subject: [PATCH 1/2] 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 From fe2ce67b25fb793eb453a68ea8a5531d44e27012 Mon Sep 17 00:00:00 2001 From: ERICPANDERSON Date: Tue, 26 May 2026 12:18:13 -0500 Subject: [PATCH 2/2] Fix unittest failures for https://github.com/google/adk-python/issues/3754 --- tests/unittests/tools/test_agent_tool.py | 25 ++++++++----------- .../unittests/workflow/test_agent_transfer.py | 6 +++-- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/tests/unittests/tools/test_agent_tool.py b/tests/unittests/tools/test_agent_tool.py index 6ddaa8df50..b0a9b0fdc6 100644 --- a/tests/unittests/tools/test_agent_tool.py +++ b/tests/unittests/tools/test_agent_tool.py @@ -949,22 +949,17 @@ class CustomOutput(BaseModel): async def test_run_async_handles_none_parts_in_response(): """Verify run_async handles None parts in response without raising TypeError.""" - # Mock model for the tool_agent that returns content with parts=None - # This simulates the condition causing the TypeError - tool_agent_model = testing_utils.MockModel.create( - responses=[ - LlmResponse( - content=types.Content(parts=None), - ) - ] - ) + class _StaticAgentWithNoneParts(BaseAgent): - tool_agent = Agent( - name='tool_agent', - model=tool_agent_model, - ) + async def _run_async_impl(self, ctx): + yield Event( + invocation_id=ctx.invocation_id, + author=self.name, + content=types.Content(role='model', parts=None), + ) - agent_tool = AgentTool(agent=tool_agent) + inner = _StaticAgentWithNoneParts(name='inner_agent', description='static') + agent_tool = AgentTool(agent=inner) session_service = InMemorySessionService() session = await session_service.create_session( @@ -973,7 +968,7 @@ async def test_run_async_handles_none_parts_in_response(): invocation_context = InvocationContext( invocation_id='invocation_id', - agent=tool_agent, + agent=inner, session=session, session_service=session_service, ) diff --git a/tests/unittests/workflow/test_agent_transfer.py b/tests/unittests/workflow/test_agent_transfer.py index 6832168b68..eda08e584d 100644 --- a/tests/unittests/workflow/test_agent_transfer.py +++ b/tests/unittests/workflow/test_agent_transfer.py @@ -308,6 +308,7 @@ def test_auto_to_sequential_to_auto(is_resumable: bool): 'response2', 'response3', 'response4', + 'response5', ] mock_model = testing_utils.MockModel.create(responses=response) # root (auto) - sub_agent_1 (seq) - sub_agent_1_1 (single) @@ -387,6 +388,7 @@ def test_auto_to_sequential_to_auto(is_resumable: bool): ('sub_agent_1_2', TRANSFER_RESPONSE_PART), ('sub_agent_1_2_1', 'response2'), ('sub_agent_1_2_1', END_OF_AGENT), + ('sub_agent_1_2', 'response3'), ('sub_agent_1_2', END_OF_AGENT), ( 'sub_agent_1', @@ -394,13 +396,13 @@ def test_auto_to_sequential_to_auto(is_resumable: bool): mode='json' ), ), - ('sub_agent_1_3', 'response3'), + ('sub_agent_1_3', 'response4'), ('sub_agent_1_3', END_OF_AGENT), ('sub_agent_1', END_OF_AGENT), ] # Same session, different invocation. assert testing_utils.simplify_resumable_app_events(runner.run('test2')) == [ - ('root_agent', 'response4'), + ('root_agent', 'response5'), ('root_agent', END_OF_AGENT), ]