Skip to content

Commit cb0cdb9

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix-thought-signature-pruning
2 parents 32a1160 + 932a9b5 commit cb0cdb9

4 files changed

Lines changed: 107 additions & 2 deletions

File tree

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ class _ReconnectSentinel(Event):
7171

7272
_ADK_AGENT_NAME_LABEL_KEY = 'adk_agent_name'
7373

74+
_NO_CONTENT_ERROR_CODE = 'MODEL_RETURNED_NO_CONTENT'
75+
_NO_CONTENT_ERROR_MESSAGE = (
76+
'The model returned no content (finish_reason=STOP with empty parts).'
77+
)
78+
7479
# Timing configuration
7580
DEFAULT_TRANSFER_AGENT_DELAY = 1.0
7681
DEFAULT_TASK_COMPLETION_DELAY = 1.0
@@ -1066,6 +1071,23 @@ async def _postprocess_async(
10661071
async for event in agen:
10671072
yield event
10681073

1074+
# A non-streaming turn that finishes with STOP but has no content parts would
1075+
# otherwise be skipped below and become a silent empty final response;
1076+
# surface it as an actionable error instead. Streaming is excluded
1077+
# because a terminal finish-only chunk legitimately follows content already
1078+
# streamed in earlier chunks.
1079+
if (
1080+
not llm_response.partial
1081+
and llm_response.error_code is None
1082+
and llm_response.finish_reason == types.FinishReason.STOP
1083+
and (not llm_response.content or not llm_response.content.parts)
1084+
and invocation_context.run_config.streaming_mode != StreamingMode.SSE
1085+
):
1086+
llm_response.error_code = _NO_CONTENT_ERROR_CODE
1087+
llm_response.error_message = (
1088+
llm_response.error_message or _NO_CONTENT_ERROR_MESSAGE
1089+
)
1090+
10691091
# Skip the model response event if there is no content and no error code.
10701092
# This is needed for the code executor to trigger another loop.
10711093
if (

tests/unittests/flows/llm_flows/test_base_llm_flow.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,6 +1644,58 @@ def _make_agent_tree():
16441644
return root, child1, child2
16451645

16461646

1647+
@pytest.mark.asyncio
1648+
async def test_empty_stop_after_tool_call_surfaces_error_event():
1649+
"""Regression test for empty Gemini turn after a successful tool call (#5631).
1650+
1651+
Turn 1 returns a function_call which executes successfully, then turn 2
1652+
returns Content(role='model', parts=[]) with finish_reason=STOP and no error.
1653+
In non-streaming mode the flow must surface that empty turn as an error event
1654+
instead of a silent empty final response.
1655+
"""
1656+
function_call_part = types.Part.from_function_call(
1657+
name='increase_by_one', args={'x': 1}
1658+
)
1659+
1660+
turn_1 = LlmResponse(
1661+
content=types.Content(role='model', parts=[function_call_part]),
1662+
finish_reason=types.FinishReason.STOP,
1663+
)
1664+
# An empty Gemini turn: STOP with no content parts and no error from the model.
1665+
turn_2 = LlmResponse(
1666+
content=types.Content(role='model', parts=[]),
1667+
finish_reason=types.FinishReason.STOP,
1668+
)
1669+
1670+
function_called = 0
1671+
1672+
def increase_by_one(x: int) -> int:
1673+
nonlocal function_called
1674+
function_called += 1
1675+
return x + 1
1676+
1677+
mock_model = testing_utils.MockModel.create(responses=[turn_1, turn_2])
1678+
agent = Agent(name='root_agent', model=mock_model, tools=[increase_by_one])
1679+
runner = testing_utils.InMemoryRunner(agent)
1680+
events = runner.run('test')
1681+
1682+
assert function_called == 1, 'Tool should still execute on turn 1'
1683+
1684+
function_call_events = [e for e in events if e.get_function_calls()]
1685+
function_response_events = [e for e in events if e.get_function_responses()]
1686+
assert len(function_call_events) == 1
1687+
assert len(function_response_events) == 1
1688+
1689+
# The empty turn 2 must surface as an error event, not an empty final.
1690+
error_events = [e for e in events if e.error_code]
1691+
assert len(error_events) == 1
1692+
err = error_events[0]
1693+
assert err.error_code == 'MODEL_RETURNED_NO_CONTENT'
1694+
assert err.error_message
1695+
# And it must be the run's final event (no silent empty event after it).
1696+
assert events[-1] is err
1697+
1698+
16471699
@pytest.mark.asyncio
16481700
async def test_transfer_to_sibling_disallowed_raises_value_error():
16491701
"""Transfer to sibling raises ValueError when disallow_transfer_to_peers is True."""

tests/unittests/models/test_llm_response.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,12 @@ def test_llm_response_create_error_case_with_citation_metadata():
345345

346346

347347
def test_llm_response_create_empty_content_with_stop_reason():
348-
"""Test LlmResponse.create() with empty content and stop finish reason."""
348+
"""Empty content + STOP stays a successful response at the model layer.
349+
350+
Surfacing the empty turn as an error is the flow's job (non-streaming only);
351+
the model/streaming layer must not classify a terminal finish-only chunk as
352+
an error or it breaks streaming consumers that batch parts across chunks.
353+
"""
349354
generate_content_response = types.GenerateContentResponse(
350355
candidates=[
351356
types.Candidate(
@@ -359,6 +364,26 @@ def test_llm_response_create_empty_content_with_stop_reason():
359364

360365
assert response.error_code is None
361366
assert response.content is not None
367+
assert response.finish_reason == types.FinishReason.STOP
368+
369+
370+
def test_llm_response_create_non_empty_parts_with_stop_is_success():
371+
"""Regression guard: real text + STOP must remain a successful response."""
372+
generate_content_response = types.GenerateContentResponse(
373+
candidates=[
374+
types.Candidate(
375+
content=types.Content(
376+
role='model', parts=[types.Part(text='ok')]
377+
),
378+
finish_reason=types.FinishReason.STOP,
379+
)
380+
]
381+
)
382+
383+
response = LlmResponse.create(generate_content_response)
384+
385+
assert response.error_code is None
386+
assert response.content is not None
362387

363388

364389
def test_llm_response_create_includes_model_version():

tests/unittests/utils/test_streaming_utils.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,12 @@ async def test_close_with_error(self):
188188
async def test_empty_content_produces_empty_final_frame(
189189
self, use_progressive_sse
190190
):
191-
"""A candidate with an empty parts list produces an empty final frame."""
191+
"""A candidate with empty parts + STOP passes through without an error.
192+
193+
A terminal empty STOP chunk must not be classified as an error at the
194+
streaming layer; consumers that batch parts across chunks rely on it
195+
passing through cleanly.
196+
"""
192197
with temporary_feature_override(
193198
FeatureName.PROGRESSIVE_SSE_STREAMING, use_progressive_sse
194199
):
@@ -208,6 +213,7 @@ async def test_empty_content_produces_empty_final_frame(
208213

209214
assert len(results) == 1
210215
assert results[0].content is not None
216+
assert results[0].error_code is None
211217
assert closed_response is not None
212218
assert closed_response.partial is False
213219
assert closed_response.content is None

0 commit comments

Comments
 (0)