From 6bb1f0ba227607d72c0496e2e05fb72022534453 Mon Sep 17 00:00:00 2001 From: Alpinist <199138677+Epsilon006@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:35:51 +0800 Subject: [PATCH] fix(events): exclude state-only events from final responses A callback state mutation is persisted as a content-less framework event. Exclude that state-only event from final-response detection while preserving legitimate content-less agent responses. Fixes #7078 --- src/google/adk/events/event.py | 9 +++++ tests/unittests/agents/test_base_agent.py | 48 +++++++++++++++++++++++ tests/unittests/events/test_event.py | 10 +++++ 3 files changed, 67 insertions(+) diff --git a/src/google/adk/events/event.py b/src/google/adk/events/event.py index 5833b756526..c61435dd410 100644 --- a/src/google/adk/events/event.py +++ b/src/google/adk/events/event.py @@ -302,6 +302,15 @@ def is_final_response(self) -> bool: and not self.get_function_calls() ): return True + # A state-only event persists framework state; it is not an agent response. + # Keep empty Content events final because a model may legitimately complete + # a turn without producing any parts. + if ( + self.content is None + and self.output is None + and self.actions.state_delta + ): + return False return ( not self.get_function_calls() and not self.get_function_responses() diff --git a/tests/unittests/agents/test_base_agent.py b/tests/unittests/agents/test_base_agent.py index 698520cd7ae..1d28cab3696 100644 --- a/tests/unittests/agents/test_base_agent.py +++ b/tests/unittests/agents/test_base_agent.py @@ -329,6 +329,30 @@ async def test_run_async_before_agent_callback_bypass_agent( assert events[0].content.parts[0].text == 'agent run is bypassed.' +@pytest.mark.asyncio +async def test_run_async_before_agent_callback_state_delta_is_not_final( + request: pytest.FixtureRequest, +): + def update_state(callback_context: CallbackContext) -> None: + callback_context.state['callback_state'] = 'before' + + agent = _TestingAgent( + name=f'{request.function.__name__}_test_agent', + before_agent_callback=update_state, + ) + parent_ctx = await _create_parent_invocation_context( + request.function.__name__, agent + ) + + events = [event async for event in agent.run_async(parent_ctx)] + + assert len(events) == 2 + assert events[0].content is None + assert events[0].actions.state_delta == {'callback_state': 'before'} + assert events[0].is_final_response() is False + assert events[1].is_final_response() is True + + @pytest.mark.asyncio async def test_run_async_with_async_before_agent_callback_bypass_agent( request: pytest.FixtureRequest, @@ -694,6 +718,30 @@ async def test_run_async_after_agent_callback_append_reply( ) +@pytest.mark.asyncio +async def test_run_async_after_agent_callback_state_delta_is_not_final( + request: pytest.FixtureRequest, +): + def update_state(callback_context: CallbackContext) -> None: + callback_context.state['callback_state'] = 'after' + + agent = _TestingAgent( + name=f'{request.function.__name__}_test_agent', + after_agent_callback=update_state, + ) + parent_ctx = await _create_parent_invocation_context( + request.function.__name__, agent + ) + + events = [event async for event in agent.run_async(parent_ctx)] + + assert len(events) == 2 + assert events[0].is_final_response() is True + assert events[1].content is None + assert events[1].actions.state_delta == {'callback_state': 'after'} + assert events[1].is_final_response() is False + + @pytest.mark.asyncio async def test_run_async_with_async_after_agent_callback_append_reply( request: pytest.FixtureRequest, diff --git a/tests/unittests/events/test_event.py b/tests/unittests/events/test_event.py index 4a5b56f70e5..b907274dd2b 100644 --- a/tests/unittests/events/test_event.py +++ b/tests/unittests/events/test_event.py @@ -69,6 +69,16 @@ def test_is_final_response_empty_event_is_final(): assert event.is_final_response() is True +def test_is_final_response_state_delta_only_event_is_not_final(): + event = _event(actions=EventActions(state_delta={'key': 'value'})) + assert event.is_final_response() is False + + +def test_is_final_response_empty_content_with_state_delta_is_final(): + event = _event(parts=[], actions=EventActions(state_delta={'key': 'value'})) + assert event.is_final_response() is True + + def test_is_final_response_with_function_call_is_not_final(): event = _event(parts=[_text_part(), _function_call_part()]) assert event.is_final_response() is False