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
8 changes: 7 additions & 1 deletion src/google/adk/flows/llm_flows/base_llm_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
36 changes: 23 additions & 13 deletions src/google/adk/models/llm_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
57 changes: 57 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 @@ -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
93 changes: 90 additions & 3 deletions tests/unittests/models/test_llm_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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():
Expand Down Expand Up @@ -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
12 changes: 10 additions & 2 deletions tests/unittests/utils/test_streaming_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand All @@ -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
Expand Down
Loading