diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index 1de75af1..cc949a9d 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -144,14 +144,9 @@ def _rearrange_events_for_async_function_responses_in_history( events: list[Event], ) -> list[Event]: """Rearrange the async function_response events in the history.""" - # `get_function_responses()` and `get_function_calls()` each rebuild a list - # by rescanning every part, and upstream calls them repeatedly per event. - # Compute both once per event and reuse them below. - responses_per_event = [event.get_function_responses() for event in events] - calls_per_event = [event.get_function_calls() for event in events] - function_call_id_to_response_events_index: dict[str | None, int] = {} - for i, function_responses in enumerate(responses_per_event): + for i, event in enumerate(events): + function_responses = event.get_function_responses() if function_responses: for function_response in function_responses: function_call_id = function_response.id @@ -161,14 +156,14 @@ def _rearrange_events_for_async_function_responses_in_history( return events result_events: list[Event] = [] - for i, event in enumerate(events): - if responses_per_event[i]: + for event in events: + if event.get_function_responses(): # function_response should be handled together with function_call below. continue - elif calls_per_event[i]: + elif event.get_function_calls(): function_response_events_indices = set() - for function_call in calls_per_event[i]: + for function_call in event.get_function_calls(): function_call_id = function_call.id if function_call_id in function_call_id_to_response_events_index: function_response_events_indices.add( @@ -351,13 +346,6 @@ def _rearrange_events_for_latest_function_response( return result_events -_INTERNAL_FUNCTION_NAMES = frozenset({ - 'adk_framework', - REQUEST_EUC_FUNCTION_CALL_NAME, - REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, -}) - - def _is_part_invisible( p: types.Part, *, include_thoughts: bool = False ) -> bool: @@ -409,6 +397,38 @@ def _is_part_invisible( ) +def _contains_empty_content( + event: Event, *, include_thoughts: bool = False +) -> 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, function response, server-side tool + call, server-side tool response, executable code, or code execution result. + Parts with only thoughts are also considered empty. + + Args: + event: The event to check. + + Returns: + True if the event should be skipped, False otherwise. + """ + if event.actions and event.actions.compaction: + return False + + return ( + not event.content + or not event.content.role + or not event.content.parts + or all( + _is_part_invisible(p, include_thoughts=include_thoughts) + for p in event.content.parts + ) + ) and (not event.output_transcription and not event.input_transcription) + + _SINGLE_TURN_NUDGE = ( 'Important: You will not receive any user replies or clarifications.' ' Complete the task using only the information provided above.' @@ -499,43 +519,12 @@ def _should_include_event_in_context( ev_iso = getattr(event, 'isolation_scope', None) if ev_iso != isolation_scope: return False - - # Single pass over the parts. The emptiness and internal-event checks - # previously each re-walked `event.content.parts`; this derives the same two - # facts once. The returned expression keeps upstream's operand order and - # short-circuit structure exactly. - content = event.content - all_parts_invisible = True - if content and content.parts: - is_internal_event = any( - (p.function_call and p.function_call.name in _INTERNAL_FUNCTION_NAMES) - or ( - p.function_response - and p.function_response.name in _INTERNAL_FUNCTION_NAMES - ) - for p in content.parts - ) - if is_internal_event: - return False - - all_parts_invisible = all( - _is_part_invisible(p, include_thoughts=include_thoughts) - for p in content.parts - ) - - if event.actions and event.actions.compaction: - contains_empty_content = False - else: - contains_empty_content = ( - not content - or not content.role - or not content.parts - or all_parts_invisible - ) and (not event.output_transcription and not event.input_transcription) - return not ( - contains_empty_content + _contains_empty_content(event, include_thoughts=include_thoughts) or not _is_event_belongs_to_branch(current_branch, event) + or _is_adk_framework_event(event) + or _is_auth_event(event) + or _is_request_confirmation_event(event) ) @@ -770,19 +759,16 @@ def _copy_content_for_request( if not parts: return new_content - if not strip_client_function_call_ids: - new_content.parts = [part.model_copy() for part in parts] - return new_content - new_parts = [] for part in parts: new_part = part.model_copy() - fc = new_part.function_call - if fc and fc.id and fc.id.startswith(AF_FUNCTION_CALL_ID_PREFIX): - new_part.function_call = fc.model_copy(update={'id': None}) - fr = new_part.function_response - if fr and fr.id and fr.id.startswith(AF_FUNCTION_CALL_ID_PREFIX): - new_part.function_response = fr.model_copy(update={'id': None}) + if strip_client_function_call_ids: + fc = new_part.function_call + if fc and fc.id and fc.id.startswith(AF_FUNCTION_CALL_ID_PREFIX): + new_part.function_call = fc.model_copy(update={'id': None}) + fr = new_part.function_response + if fr and fr.id and fr.id.startswith(AF_FUNCTION_CALL_ID_PREFIX): + new_part.function_response = fr.model_copy(update={'id': None}) new_parts.append(new_part) new_content.parts = new_parts return new_content @@ -1251,6 +1237,33 @@ def _is_event_belongs_to_branch( return inv_path == evt_path or inv_path.is_descendant_of(evt_path) +def _is_function_call_event(event: Event, function_name: str) -> bool: + """Checks if an event is a function call/response for a given function name.""" + if not event.content or not event.content.parts: + return False + for part in event.content.parts: + if part.function_call and part.function_call.name == function_name: + return True + if part.function_response and part.function_response.name == function_name: + return True + return False + + +def _is_auth_event(event: Event) -> bool: + """Checks if the event is an authentication event.""" + return _is_function_call_event(event, REQUEST_EUC_FUNCTION_CALL_NAME) + + +def _is_request_confirmation_event(event: Event) -> bool: + """Checks if the event is a request confirmation event.""" + return _is_function_call_event(event, REQUEST_CONFIRMATION_FUNCTION_CALL_NAME) + + +def _is_adk_framework_event(event: Event) -> bool: + """Checks if the event is an ADK framework event.""" + return _is_function_call_event(event, 'adk_framework') + + def _is_live_model_media_event_with_inline_data(event: Event) -> bool: """Check if the event is a live/bidi media event (audio, video, image) with inline data. diff --git a/tests/unittests/flows/llm_flows/test_contents.py b/tests/unittests/flows/llm_flows/test_contents.py index a0df5e62..a7d9f7d3 100644 --- a/tests/unittests/flows/llm_flows/test_contents.py +++ b/tests/unittests/flows/llm_flows/test_contents.py @@ -2322,118 +2322,3 @@ def test_task_input_user_content_preserves_non_ascii(): assert "שלום עולם" in text assert "北京" in text assert "\\u" not in text - - -def _internal_call_event(name: str, **kwargs) -> Event: - """An event whose only part is an internal ADK function call.""" - return Event( - invocation_id="inv_internal", - author="agent", - content=types.Content( - role="model", - parts=[ - types.Part( - function_call=types.FunctionCall( - id="fc-1", name=name, args={} - ) - ) - ], - ), - **kwargs, - ) - - -@pytest.mark.parametrize( - "internal_name", - [ - "adk_framework", - REQUEST_EUC_FUNCTION_CALL_NAME, - REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, - ], -) -def test_internal_events_excluded_even_with_transcription(internal_name): - """Internal ADK events stay excluded whatever else the event carries. - - The exclusion is independent of the emptiness check, so an internal event is - filtered out even when a transcription or a compaction action would otherwise - make it non-empty. - """ - compaction = EventCompaction( - start_timestamp=1.0, - end_timestamp=2.0, - compacted_content=types.Content( - role="model", parts=[types.Part(text="summary")] - ), - ) - - plain = _internal_call_event(internal_name) - with_transcription = _internal_call_event( - internal_name, - input_transcription=types.Transcription(text="hello"), - ) - with_compaction = _internal_call_event( - internal_name, actions=EventActions(compaction=compaction) - ) - - for event in (plain, with_transcription, with_compaction): - assert not contents._should_include_event_in_context(None, event) - - -def test_internal_event_with_empty_role_and_transcription_excluded(): - """The internal-event exclusion does not depend on the content role. - - A transcription keeps the event from being empty, and an empty role keeps the - parts from counting as visible content; the internal function call must still - exclude the event. - """ - event = Event( - invocation_id="inv_internal_empty_role", - author="agent", - content=types.Content( - role="", - parts=[ - types.Part( - function_call=types.FunctionCall( - id="fc-1", name="adk_framework", args={} - ) - ) - ], - ), - input_transcription=types.Transcription(text="hello"), - ) - - assert not contents._should_include_event_in_context(None, event) - - -def test_compaction_event_included_but_still_branch_filtered(): - """A compaction event is not empty, but the other filters still apply.""" - compaction = EventCompaction( - start_timestamp=1.0, - end_timestamp=2.0, - compacted_content=types.Content( - role="model", parts=[types.Part(text="summary")] - ), - ) - event = Event( - invocation_id="inv_compaction", - author="agent", - branch="root.other_agent", - content=types.Content(role="model", parts=[types.Part(text="hi")]), - actions=EventActions(compaction=compaction), - ) - - assert contents._should_include_event_in_context(None, event) - assert not contents._should_include_event_in_context( - "root.third_agent", event - ) - - -def test_content_with_parts_but_empty_role_is_empty(): - """Emptiness depends on the role as well as on the parts.""" - event = Event( - invocation_id="inv_empty_role", - author="agent", - content=types.Content(role="", parts=[types.Part(text="hi")]), - ) - - assert not contents._should_include_event_in_context(None, event)