diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index 9274cd462d..79028e4064 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -125,6 +125,7 @@ def _rearrange_events_for_async_function_responses_in_history( def _rearrange_events_for_latest_function_response( events: list[Event], + all_events: list[Event] | None = None, ) -> list[Event]: """Rearrange the events for the latest function_response. @@ -134,6 +135,7 @@ def _rearrange_events_for_latest_function_response( Args: events: A list of events. + all_events: Full session history to search for function_call. Returns: A list of events with the latest function_response rearranged. @@ -159,15 +161,18 @@ def _rearrange_events_for_latest_function_response( if function_call.id in function_responses_ids: return events + # Search in full session history if available + search_events = all_events if all_events else events + function_call_event = None function_call_event_idx = -1 # look for corresponding function call event reversely - for idx in range(len(events) - 2, -1, -1): - event = events[idx] + for idx in range(len(search_events) - 2, -1, -1): + event = search_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 + function_call_event = event function_call_ids = { function_call.id for function_call in function_calls } @@ -184,6 +189,18 @@ def _rearrange_events_for_latest_function_response( # the last response event function_responses_ids = function_call_ids break + if function_call_event: + break + + # Find the index of the function_call_event in the events list + if function_call_event: + for idx, event in enumerate(events): + if ( + event.invocation_id == function_call_event.invocation_id + and event.timestamp == function_call_event.timestamp + ): + function_call_event_idx = idx + break if function_call_event_idx == -1: logger.debug( @@ -424,7 +441,7 @@ def _get_contents( # Rearrange events for proper function call/response pairing result_events = _rearrange_events_for_latest_function_response( - filtered_events + filtered_events, rewind_filtered_events ) result_events = _rearrange_events_for_async_function_responses_in_history( result_events diff --git a/tests/unittests/flows/llm_flows/test_contents_function_response_separate_request.py b/tests/unittests/flows/llm_flows/test_contents_function_response_separate_request.py new file mode 100644 index 0000000000..b30b87aac5 --- /dev/null +++ b/tests/unittests/flows/llm_flows/test_contents_function_response_separate_request.py @@ -0,0 +1,89 @@ +# 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. + +"""Tests for function_response sent in separate request (async tools).""" + +from google.adk.agents.llm_agent import Agent +from google.adk.events.event import Event +from google.adk.flows.llm_flows import contents +from google.adk.models.llm_request import LlmRequest +from google.genai import types +import pytest + +from ... import testing_utils + + +@pytest.mark.asyncio +async def test_function_response_in_separate_request(): + """Test function_response sent separately finds function_call in history.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + function_call = types.FunctionCall( + id="async_call_123", name="async_tool", args={"job": "start"} + ) + function_response = types.FunctionResponse( + id="async_call_123", + name="async_tool", + response={"status": "completed"}, + ) + + # Simulate async job: function_call in early session history, + # function_response arrives much later in separate request + events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Start async job"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent([types.Part(function_call=function_call)]), + ), + Event( + invocation_id="inv3", + author="test_agent", + content=types.ModelContent("Job started, waiting for completion..."), + ), + # Much later: function_response arrives (separate SSE request) + Event( + invocation_id="inv4", + author="user", + content=types.UserContent( + [types.Part(function_response=function_response)] + ), + ), + ] + # Simulate event cloning that happens during processing + events = [e.model_copy(deep=True) for e in events] + invocation_context.session.events = events + + # Should not raise ValueError + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + # Verify function_response is processed + assert any( + hasattr(part, "function_response") + and part.function_response + and part.function_response.id == "async_call_123" + for content in llm_request.contents + for part in content.parts + )