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 31f998a588..a21eba2ae2 100644 --- a/src/google/adk/flows/llm_flows/base_llm_flow.py +++ b/src/google/adk/flows/llm_flows/base_llm_flow.py @@ -960,8 +960,14 @@ async def _postprocess_async( # Skip the model response event if there is no content and no error code. # This is needed for the code executor to trigger another loop. + # Treat a Content object with empty/missing parts as "no content" so it + # cannot pass through as a final response with empty text. Empty content + # carrying an error_code is still yielded so the caller sees the error. + content_is_empty = ( + not llm_response.content or not llm_response.content.parts + ) if ( - not llm_response.content + content_is_empty and not llm_response.error_code and not llm_response.interrupted ): diff --git a/src/google/adk/models/llm_response.py b/src/google/adk/models/llm_response.py index 10ab946455..38d1a72382 100644 --- a/src/google/adk/models/llm_response.py +++ b/src/google/adk/models/llm_response.py @@ -182,9 +182,7 @@ def create( usage_metadata = generate_content_response.usage_metadata if generate_content_response.candidates: candidate = generate_content_response.candidates[0] - if ( - candidate.content and candidate.content.parts - ) or candidate.finish_reason == types.FinishReason.STOP: + if candidate.content and candidate.content.parts: return LlmResponse( content=candidate.content, grounding_metadata=candidate.grounding_metadata, @@ -195,17 +193,29 @@ def create( logprobs_result=candidate.logprobs_result, model_version=generate_content_response.model_version, ) - else: - return LlmResponse( - error_code=candidate.finish_reason, - error_message=candidate.finish_message, - citation_metadata=candidate.citation_metadata, - usage_metadata=usage_metadata, - finish_reason=candidate.finish_reason, - avg_logprobs=candidate.avg_logprobs, - logprobs_result=candidate.logprobs_result, - model_version=generate_content_response.model_version, + # Empty/missing parts. Distinguish empty-with-STOP (e.g. some + # gemini-2.5-flash-lite turns after a tool call return zero output + # tokens with finish_reason=STOP) from other finish reasons so callers + # see an actionable error instead of a silent empty final output. + if candidate.finish_reason == types.FinishReason.STOP: + error_code = 'MODEL_RETURNED_NO_CONTENT' + error_message = ( + candidate.finish_message + or 'The model returned no content (finish_reason=STOP with empty parts).' ) + else: + error_code = candidate.finish_reason + error_message = candidate.finish_message + return LlmResponse( + error_code=error_code, + error_message=error_message, + citation_metadata=candidate.citation_metadata, + usage_metadata=usage_metadata, + finish_reason=candidate.finish_reason, + avg_logprobs=candidate.avg_logprobs, + logprobs_result=candidate.logprobs_result, + model_version=generate_content_response.model_version, + ) else: if generate_content_response.prompt_feedback: prompt_feedback = generate_content_response.prompt_feedback diff --git a/tests/unittests/flows/llm_flows/test_base_llm_flow.py b/tests/unittests/flows/llm_flows/test_base_llm_flow.py index 7e3d4d8339..ef97bb7a5c 100644 --- a/tests/unittests/flows/llm_flows/test_base_llm_flow.py +++ b/tests/unittests/flows/llm_flows/test_base_llm_flow.py @@ -981,3 +981,60 @@ async def mock_receive(): assert events[0].author == 'user' assert events[1].author == 'test_agent' assert events[2].author == 'user' + + +@pytest.mark.asyncio +async def test_empty_stop_after_tool_call_surfaces_error_event(): + """Regression test for empty Gemini turn after a successful tool call. + + Repro from a user bug report against gemini-2.5-flash-lite: + turn 1 returns a function_call which executes successfully, then turn 2 + returns Content(role='model', parts=[]) with finish_reason=STOP. The fix + in LlmResponse.create classifies that as MODEL_RETURNED_NO_CONTENT, and + the flow must surface it as an error-coded event instead of emitting an + empty final response. + """ + function_call_part = types.Part.from_function_call( + name='increase_by_one', args={'x': 1} + ) + + turn_1 = LlmResponse( + content=types.Content(role='model', parts=[function_call_part]), + finish_reason=types.FinishReason.STOP, + ) + # What LlmResponse.create now produces for an empty Gemini candidate: + turn_2 = LlmResponse( + error_code='MODEL_RETURNED_NO_CONTENT', + error_message=( + 'The model returned no content (finish_reason=STOP with empty parts).' + ), + finish_reason=types.FinishReason.STOP, + ) + + function_called = 0 + + def increase_by_one(x: int) -> int: + nonlocal function_called + function_called += 1 + return x + 1 + + mock_model = testing_utils.MockModel.create(responses=[turn_1, turn_2]) + agent = Agent(name='root_agent', model=mock_model, tools=[increase_by_one]) + runner = testing_utils.InMemoryRunner(agent) + events = runner.run('test') + + assert function_called == 1, 'Tool should still execute on turn 1' + + function_call_events = [e for e in events if e.get_function_calls()] + function_response_events = [e for e in events if e.get_function_responses()] + assert len(function_call_events) == 1 + assert len(function_response_events) == 1 + + # The empty turn 2 must surface as an error event, not an empty final. + error_events = [e for e in events if e.error_code] + assert len(error_events) == 1 + err = error_events[0] + assert err.error_code == 'MODEL_RETURNED_NO_CONTENT' + assert err.error_message + # And it must be the run's final event (no silent empty event after it). + assert events[-1] is err diff --git a/tests/unittests/models/test_llm_response.py b/tests/unittests/models/test_llm_response.py index e2cbe4c286..ad6f16fdbf 100644 --- a/tests/unittests/models/test_llm_response.py +++ b/tests/unittests/models/test_llm_response.py @@ -320,7 +320,12 @@ def test_llm_response_create_error_case_with_citation_metadata(): def test_llm_response_create_empty_content_with_stop_reason(): - """Test LlmResponse.create() with empty content and stop finish reason.""" + """Empty content + STOP must surface an explicit MODEL_RETURNED_NO_CONTENT error. + + Previously this returned a successful LlmResponse with empty content, + which let an empty model turn (e.g. gemini-2.5-flash-lite returning zero + output tokens after a tool call) silently become the final agent output. + """ generate_content_response = types.GenerateContentResponse( candidates=[ types.Candidate( @@ -332,8 +337,9 @@ def test_llm_response_create_empty_content_with_stop_reason(): response = LlmResponse.create(generate_content_response) - assert response.error_code is None - assert response.content is not None + assert response.error_code == 'MODEL_RETURNED_NO_CONTENT' + assert response.error_message + assert response.finish_reason == types.FinishReason.STOP def test_llm_response_create_includes_model_version(): @@ -397,3 +403,84 @@ def test_get_function_responses_empty_when_no_content(): def test_get_function_responses_empty_when_no_parts(): response = LlmResponse(content=types.Content(parts=None)) assert response.get_function_responses() == [] + + +def test_llm_response_create_empty_parts_with_stop_surfaces_error(): + """Empty parts + finish_reason=STOP must not pass through as success. + + Reproduces the gemini-2.5-flash-lite case where the second turn after a + tool call returns Content(role='model', parts=[]) with STOP, which used + to silently become an empty final agent output. + """ + generate_content_response = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=types.Content(role='model', parts=[]), + finish_reason=types.FinishReason.STOP, + ) + ] + ) + + response = LlmResponse.create(generate_content_response) + + assert response.error_code == 'MODEL_RETURNED_NO_CONTENT' + assert response.error_message + assert response.finish_reason == types.FinishReason.STOP + + +def test_llm_response_create_none_content_with_stop_surfaces_error(): + """content=None + finish_reason=STOP also routes to the error branch.""" + generate_content_response = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=None, + finish_reason=types.FinishReason.STOP, + ) + ] + ) + + response = LlmResponse.create(generate_content_response) + + assert response.error_code == 'MODEL_RETURNED_NO_CONTENT' + assert response.error_message + assert response.finish_reason == types.FinishReason.STOP + + +def test_llm_response_create_non_empty_parts_with_stop_is_success(): + """Regression guard: real text + STOP must remain a successful response.""" + generate_content_response = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=types.Content( + role='model', parts=[types.Part(text='ok')] + ), + finish_reason=types.FinishReason.STOP, + ) + ] + ) + + response = LlmResponse.create(generate_content_response) + + assert response.error_code is None + assert response.error_message is None + assert response.content.parts[0].text == 'ok' + assert response.finish_reason == types.FinishReason.STOP + + +def test_llm_response_create_empty_parts_with_max_tokens_preserves_finish_reason(): + """Regression guard: non-STOP empty responses still surface their finish_reason.""" + generate_content_response = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=types.Content(role='model', parts=[]), + finish_reason=types.FinishReason.MAX_TOKENS, + finish_message='token limit reached', + ) + ] + ) + + response = LlmResponse.create(generate_content_response) + + assert response.error_code == types.FinishReason.MAX_TOKENS + assert response.error_message == 'token limit reached' + assert response.finish_reason == types.FinishReason.MAX_TOKENS diff --git a/tests/unittests/utils/test_streaming_utils.py b/tests/unittests/utils/test_streaming_utils.py index 6b68789bf0..dd570eb56b 100644 --- a/tests/unittests/utils/test_streaming_utils.py +++ b/tests/unittests/utils/test_streaming_utils.py @@ -185,7 +185,13 @@ async def test_close_with_error(self): @pytest.mark.asyncio async def test_process_response_with_none_content(self): - """Test that StreamingResponseAggregator handles content=None.""" + """Empty parts + STOP must surface a MODEL_RETURNED_NO_CONTENT error. + + Previously the aggregator yielded a successful LlmResponse with empty + content here; that let an empty Gemini turn (e.g. gemini-2.5-flash-lite + returning zero output tokens after a tool call) silently become the + final agent output. + """ aggregator = streaming_utils.StreamingResponseAggregator() response = types.GenerateContentResponse( candidates=[ @@ -199,7 +205,9 @@ async def test_process_response_with_none_content(self): async for r in aggregator.process_response(response): results.append(r) assert len(results) == 1 - assert results[0].content is not None + assert results[0].error_code == "MODEL_RETURNED_NO_CONTENT" + assert results[0].error_message + assert results[0].finish_reason == types.FinishReason.STOP closed_response = aggregator.close() assert closed_response is None