Files
adk-python/src/google/adk/flows/llm_flows/contents.py
T
2025-04-10 14:38:28 +08:00

391 lines
13 KiB
Python

# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import copy
from typing import AsyncGenerator
from typing import Generator
from typing import Optional
from google.genai import types
from typing_extensions import override
from ...agents.invocation_context import InvocationContext
from ...events.event import Event
from ...models.llm_request import LlmRequest
from ._base_llm_processor import BaseLlmRequestProcessor
from .functions import remove_client_function_call_id
from .functions import REQUEST_EUC_FUNCTION_CALL_NAME
class _ContentLlmRequestProcessor(BaseLlmRequestProcessor):
"""Builds the contents for the LLM request."""
@override
async def run_async(
self, invocation_context: InvocationContext, llm_request: LlmRequest
) -> AsyncGenerator[Event, None]:
from ...agents.llm_agent import LlmAgent
agent = invocation_context.agent
if not isinstance(agent, LlmAgent):
return
if agent.include_contents != 'none':
llm_request.contents = _get_contents(
invocation_context.branch,
invocation_context.session.events,
agent.name,
)
# Maintain async generator behavior
if False: # Ensures it behaves as a generator
yield # This is a no-op but maintains generator structure
request_processor = _ContentLlmRequestProcessor()
def _rearrange_events_for_async_function_responses_in_history(
events: list[Event],
) -> list[Event]:
"""Rearrange the async function_response events in the history."""
function_call_id_to_response_events_index: dict[str, list[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
function_call_id_to_response_events_index[function_call_id] = i
result_events: list[Event] = []
for event in events:
if event.get_function_responses():
# function_response should be handled together with function_call below.
continue
elif event.get_function_calls():
function_response_events_indices = set()
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(
function_call_id_to_response_events_index[function_call_id]
)
result_events.append(event)
if not function_response_events_indices:
continue
if len(function_response_events_indices) == 1:
result_events.append(
events[next(iter(function_response_events_indices))]
)
else: # Merge all async function_response as one response event
result_events.append(
_merge_function_response_events(
[events[i] for i in sorted(function_response_events_indices)]
)
)
continue
else:
result_events.append(event)
return result_events
def _rearrange_events_for_latest_function_response(
events: list[Event],
) -> list[Event]:
"""Rearrange the events for the latest function_response.
If the latest function_response is for an async function_call, all events
bewteen the initial function_call and the latest function_response will be
removed.
Args:
events: A list of events.
Returns:
A list of events with the latest function_response rearranged.
"""
if not events:
return events
function_responses = events[-1].get_function_responses()
if not function_responses:
# No need to process, since the latest event is not fuction_response.
return events
function_responses_ids = set()
for function_response in function_responses:
function_responses_ids.add(function_response.id)
function_calls = events[-2].get_function_calls()
if function_calls:
for function_call in function_calls:
# The latest function_response is already matched
if function_call.id in function_responses_ids:
return events
function_call_event_idx = -1
# look for corresponding function call event reversely
for idx in range(len(events) - 2, -1, -1):
event = events[idx]
function_calls = event.get_function_calls()
if function_calls:
for function_call in function_calls:
if function_call.id in function_responses_ids:
function_call_event_idx = idx
break
if function_call_event_idx != -1:
# in case the last response event only have part of the responses
# for the function calls in the function call event
for function_call in function_calls:
function_responses_ids.add(function_call.id)
break
if function_call_event_idx == -1:
raise ValueError(
'No function call event found for function responses ids:'
f' {function_responses_ids}'
)
# collect all function response between last function response event
# and function call event
function_response_events: list[Event] = []
for idx in range(function_call_event_idx + 1, len(events) - 1):
event = events[idx]
function_responses = event.get_function_responses()
if (
function_responses
and function_responses[0].id in function_responses_ids
):
function_response_events.append(event)
function_response_events.append(events[-1])
result_events = events[: function_call_event_idx + 1]
result_events.append(
_merge_function_response_events(function_response_events)
)
return result_events
def _get_contents(
current_branch: Optional[str], events: list[Event], agent_name: str = ''
) -> list[types.Content]:
"""Get the contents for the LLM request.
Args:
current_branch: The current branch of the agent.
events: A list of events.
agent_name: The name of the agent.
Returns:
A list of contents.
"""
filtered_events = []
# Parse the events, leaving the contents and the function calls and
# responses from the current agent.
for event in events:
if not event.content or not event.content.role:
# Skip events without content, or generated neither by user nor by model.
# E.g. events purely for mutating session states.
continue
if not _is_event_belongs_to_branch(current_branch, event):
# Skip events not belong to current branch.
continue
if _is_auth_event(event):
# skip auth event
continue
filtered_events.append(
_convert_foreign_event(event)
if _is_other_agent_reply(agent_name, event)
else event
)
result_events = _rearrange_events_for_latest_function_response(
filtered_events
)
result_events = _rearrange_events_for_async_function_responses_in_history(
result_events
)
contents = []
for event in result_events:
content = copy.deepcopy(event.content)
remove_client_function_call_id(content)
contents.append(content)
return contents
def _is_other_agent_reply(current_agent_name: str, event: Event) -> bool:
"""Whether the event is a reply from another agent."""
return bool(
current_agent_name
and event.author != current_agent_name
and event.author != 'user'
)
def _convert_foreign_event(event: Event) -> Event:
"""Converts an event authored by another agent as a user-content event.
This is to provide another agent's output as context to the current agent, so
that current agent can continue to respond, such as summarizing previous
agent's reply, etc.
Args:
event: The event to convert.
Returns:
The converted event.
"""
if not event.content or not event.content.parts:
return event
content = types.Content()
content.role = 'user'
content.parts = [types.Part(text='For context:')]
for part in event.content.parts:
if part.text:
content.parts.append(
types.Part(text=f'[{event.author}] said: {part.text}')
)
elif part.function_call:
content.parts.append(
types.Part(
text=(
f'[{event.author}] called tool `{part.function_call.name}`'
f' with parameters: {part.function_call.args}'
)
)
)
elif part.function_response:
# Otherwise, create a new text part.
content.parts.append(
types.Part(
text=(
f'[{event.author}] `{part.function_response.name}` tool'
f' returned result: {part.function_response.response}'
)
)
)
# Fallback to the original part for non-text and non-functionCall parts.
else:
content.parts.append(part)
return Event(
timestamp=event.timestamp,
author='user',
content=content,
branch=event.branch,
)
def _merge_function_response_events(
function_response_events: list[Event],
) -> Event:
"""Merges a list of function_response events into one event.
The key goal is to ensure:
1. function_call and function_response are always of the same number.
2. The function_call and function_response are consecutively in the content.
Args:
function_response_events: A list of function_response events.
NOTE: function_response_events must fulfill these requirements: 1. The
list is in increasing order of timestamp; 2. the first event is the
initial function_response event; 3. all later events should contain at
least one function_response part that related to the function_call
event. (Note, 3. may not be true when aync function return some
intermediate response, there could also be some intermediate model
response event without any function_response and such event will be
ignored.)
Caveat: This implementation doesn't support when a parallel function_call
event contains async function_call of the same name.
Returns:
A merged event, that is
1. All later function_response will replace function_response part in
the initial function_response event.
2. All non-function_response parts will be appended to the part list of
the initial function_response event.
"""
if not function_response_events:
raise ValueError('At least one function_response event is required.')
merged_event = function_response_events[0].model_copy(deep=True)
parts_in_merged_event: list[types.Part] = merged_event.content.parts # type: ignore
if not parts_in_merged_event:
raise ValueError('There should be at least one function_response part.')
part_indices_in_merged_event: dict[str, int] = {}
for idx, part in enumerate(parts_in_merged_event):
if part.function_response:
function_call_id: str = part.function_response.id # type: ignore
part_indices_in_merged_event[function_call_id] = idx
for event in function_response_events[1:]:
if not event.content.parts:
raise ValueError('There should be at least one function_response part.')
for part in event.content.parts:
if part.function_response:
function_call_id: str = part.function_response.id # type: ignore
if function_call_id in part_indices_in_merged_event:
parts_in_merged_event[
part_indices_in_merged_event[function_call_id]
] = part
else:
parts_in_merged_event.append(part)
part_indices_in_merged_event[function_call_id] = (
len(parts_in_merged_event) - 1
)
else:
parts_in_merged_event.append(part)
return merged_event
def _is_event_belongs_to_branch(
invocation_branch: Optional[str], event: Event
) -> bool:
"""Event belongs to a branch, when event.branch is prefix of the invocation branch."""
if not invocation_branch or not event.branch:
return True
return invocation_branch.startswith(event.branch)
def _is_auth_event(event: Event) -> bool:
if not event.content.parts:
return False
for part in event.content.parts:
if (
part.function_call
and part.function_call.name == REQUEST_EUC_FUNCTION_CALL_NAME
):
return True
if (
part.function_response
and part.function_response.name == REQUEST_EUC_FUNCTION_CALL_NAME
):
return True
return False