From f35d129b4c59d381e95418725d6eaa072ca7720a Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Wed, 17 Dec 2025 16:02:42 -0800 Subject: [PATCH] feat(fix): Check all content parts for emptiness in _contains_empty_content PiperOrigin-RevId: 845954226 --- src/google/adk/flows/llm_flows/contents.py | 24 +++--- .../flows/llm_flows/test_contents.py | 74 +++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index 39902e85..ce0df37e 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -219,13 +219,26 @@ def _rearrange_events_for_latest_function_response( return result_events +def _is_part_invisible(p: types.Part) -> bool: + """A part is considered invisble if it's a thought, or has no visible content.""" + return getattr(p, 'thought', False) or not ( + p.text + or p.inline_data + or p.file_data + or p.function_call + or p.function_response + ) + + def _contains_empty_content(event: Event) -> bool: """Check if an event should be skipped due to missing or empty content. This can happen to the events that only changed session state. When both content and transcriptions are empty, the event will be considered as empty. The content is considered empty if none of its parts contain text, - inline data, file data, function call, or function response. + inline data, file data, function call, or function response. Parts with + only thoughts are also considered empty. + Args: event: The event to check. @@ -240,14 +253,7 @@ def _contains_empty_content(event: Event) -> bool: not event.content or not event.content.role or not event.content.parts - or all( - not p.text - and not p.inline_data - and not p.file_data - and not p.function_call - and not p.function_response - for p in [event.content.parts[0]] - ) + or all(_is_part_invisible(p) for p in event.content.parts) ) and (not event.output_transcription and not event.input_transcription) diff --git a/tests/unittests/flows/llm_flows/test_contents.py b/tests/unittests/flows/llm_flows/test_contents.py index b2aa91db..bafaebed 100644 --- a/tests/unittests/flows/llm_flows/test_contents.py +++ b/tests/unittests/flows/llm_flows/test_contents.py @@ -16,6 +16,7 @@ from google.adk.agents.llm_agent import Agent from google.adk.events.event import Event from google.adk.events.event_actions import EventActions from google.adk.flows.llm_flows import contents +from google.adk.flows.llm_flows.contents import request_processor from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME from google.adk.flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME from google.adk.models.llm_request import LlmRequest @@ -433,6 +434,58 @@ async def test_rewind_events_are_filtered_out(): ] +@pytest.mark.asyncio +async def test_other_agent_empty_content(): + """Test that other agent messages with only thoughts or empty content are filtered out.""" + agent = Agent(model="gemini-2.5-flash", name="current_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + # Add events: user message, other agents with empty content, user message + events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Hello"), + ), + # Other agent with only thoughts + Event( + invocation_id="inv2", + author="other_agent1", + content=types.ModelContent([ + types.Part(text="This is a private thought", thought=True), + types.Part(text="Another private thought", thought=True), + ]), + ), + # Other agent with empty text and thoughts + Event( + invocation_id="inv3", + author="other_agent2", + content=types.ModelContent([ + types.Part(text="", thought=False), + types.Part(text="Secret thought", thought=True), + ]), + ), + Event( + invocation_id="inv4", + author="user", + content=types.UserContent("World"), + ), + ] + invocation_context.session.events = events + + # Process the request + async for _ in request_processor.run_async(invocation_context, llm_request): + pass + + # Verify empty content events are completely filtered out + assert llm_request.contents == [ + types.UserContent("Hello"), + types.UserContent("World"), + ] + + @pytest.mark.asyncio async def test_events_with_empty_content_are_skipped(): """Test that events with empty content (state-only changes) are skipped.""" @@ -471,6 +524,14 @@ async def test_events_with_empty_content_are_skipped(): author="user", content=types.Content(parts=[types.Part(text="")], role="model"), ), + # Event with content that has multiple empty text parts + Event( + invocation_id="inv6_2", + author="user", + content=types.Content( + parts=[types.Part(text=""), types.Part(text="")], role="model" + ), + ), # Event with content that has only inline data part Event( invocation_id="inv7", @@ -502,6 +563,15 @@ async def test_events_with_empty_content_are_skipped(): role="user", ), ), + # Event with mixed empty and non-empty text parts + Event( + invocation_id="inv9", + author="user", + content=types.Content( + parts=[types.Part(text=""), types.Part(text="Mixed content")], + role="user", + ), + ), ] invocation_context.session.events = events @@ -534,4 +604,8 @@ async def test_events_with_empty_content_are_skipped(): ], role="user", ), + types.Content( + parts=[types.Part(text=""), types.Part(text="Mixed content")], + role="user", + ), ]