Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions src/google/adk/flows/llm_flows/base_llm_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down
47 changes: 47 additions & 0 deletions tests/unittests/flows/llm_flows/test_base_llm_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down