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 975f0e528c..f3810e8405 100644 --- a/src/google/adk/flows/llm_flows/base_llm_flow.py +++ b/src/google/adk/flows/llm_flows/base_llm_flow.py @@ -807,13 +807,6 @@ async def _postprocess_async( A generator of events. """ - # Runs processors. - async with Aclosing( - self._postprocess_run_processors_async(invocation_context, llm_response) - ) as agen: - async for event in agen: - yield event - # A non-streaming turn that finishes with STOP but has no content parts would # otherwise be skipped below and become a silent empty final response; # surface it as an actionable error instead. Streaming is excluded @@ -832,6 +825,16 @@ async def _postprocess_async( llm_response.error_message or _NO_CONTENT_ERROR_MESSAGE ) + # Runs processors. This must happen after genuine empty responses are + # classified above: processors may intentionally clear content as a + # continuation sentinel, as the code-execution processor does after + # emitting a sandbox result. + async with Aclosing( + self._postprocess_run_processors_async(invocation_context, llm_response) + ) as agen: + async for event in agen: + yield event + # 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. if ( 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 37149bd11e..986a562432 100644 --- a/tests/unittests/flows/llm_flows/test_base_llm_flow.py +++ b/tests/unittests/flows/llm_flows/test_base_llm_flow.py @@ -28,6 +28,8 @@ from google.adk.agents.run_config import RunConfig from google.adk.agents.run_config import StreamingMode from google.adk.apps.app import ResumabilityConfig +from google.adk.code_executors.base_code_executor import BaseCodeExecutor +from google.adk.code_executors.code_execution_utils import CodeExecutionResult from google.adk.events.event import Event from google.adk.features import FeatureName from google.adk.features._feature_registry import temporary_feature_override @@ -2509,6 +2511,51 @@ def _make_agent_tree(): return root, child1, child2 +@pytest.mark.asyncio +async def test_code_execution_stop_continues_to_final_response(): + """A code response ending in STOP continues after sandbox execution.""" + code_response = LlmResponse( + content=types.Content( + role='model', + parts=[types.Part(text='```python\nprint(6 * 7)\n```')], + ), + finish_reason=types.FinishReason.STOP, + ) + final_response = LlmResponse( + content=types.Content( + role='model', + parts=[types.Part(text='The answer is 42.')], + ), + finish_reason=types.FinishReason.STOP, + ) + code_executor = mock.MagicMock(spec=BaseCodeExecutor) + code_executor.optimize_data_file = False + code_executor.code_block_delimiters = [('```python\n', '\n```')] + code_executor.execution_result_delimiters = ( + '```tool_output\n', + '\n```', + ) + code_executor.error_retry_attempts = 2 + code_executor.stateful = False + code_executor.execute_code.return_value = CodeExecutionResult(stdout='42\n') + mock_model = testing_utils.MockModel.create( + responses=[code_response, final_response] + ) + agent = Agent( + name='root_agent', + model=mock_model, + code_executor=code_executor, + ) + + events = testing_utils.InMemoryRunner(agent).run('What is 6 * 7?') + + code_executor.execute_code.assert_called_once() + assert len(mock_model.requests) == 2 + assert not any(event.error_code for event in events) + assert events[-1].content + assert events[-1].content.parts[0].text == 'The answer is 42.' + + @pytest.mark.asyncio async def test_empty_stop_after_tool_call_surfaces_error_event(): """Regression test for an empty Gemini turn after a successful tool call.