perf: remove redundant event scans in prompt assembly
Merge https://github.com/google/adk-python/pull/6540 PiperOrigin-RevId: 964496754
This commit is contained in:
committed by
Copybara-Service
parent
b381f6c234
commit
d7adc1ce3e
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user