ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
897 lines
26 KiB
Python
897 lines
26 KiB
Python
# Copyright 2026 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 Progressive SSE Streaming Stage 1 implementation."""
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
from typing import AsyncGenerator
|
|
|
|
from google.adk.agents.llm_agent import Agent
|
|
from google.adk.agents.run_config import RunConfig
|
|
from google.adk.agents.run_config import StreamingMode
|
|
from google.adk.models.base_llm import BaseLlm
|
|
from google.adk.models.llm_request import LlmRequest
|
|
from google.adk.models.llm_response import LlmResponse
|
|
from google.adk.runners import InMemoryRunner
|
|
from google.adk.utils.streaming_utils import StreamingResponseAggregator
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
|
|
def get_weather(location: str) -> dict[str, Any]:
|
|
"""Mock weather function for testing.
|
|
|
|
Args:
|
|
location: The location to get the weather for.
|
|
|
|
Returns:
|
|
A dictionary containing the weather information.
|
|
"""
|
|
return {
|
|
"temperature": 22,
|
|
"condition": "sunny",
|
|
"location": location,
|
|
}
|
|
|
|
|
|
class StreamingMockModel(BaseLlm):
|
|
"""A mock model that properly streams multiple chunks in a single call."""
|
|
|
|
model: str = "streaming-mock"
|
|
stream_chunks: list[LlmResponse] = []
|
|
call_count: int = 0
|
|
|
|
@classmethod
|
|
def supported_models(cls) -> list[str]:
|
|
return ["streaming-mock"]
|
|
|
|
async def generate_content_async(
|
|
self, llm_request: LlmRequest, stream: bool = False
|
|
) -> AsyncGenerator[LlmResponse, None]:
|
|
"""Yield all chunks in a single streaming call."""
|
|
self.call_count += 1
|
|
|
|
# Only stream on the first call
|
|
if self.call_count > 1:
|
|
# On subsequent calls, return a simple final response
|
|
yield LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[types.Part.from_text(text="Task completed.")],
|
|
),
|
|
partial=False,
|
|
)
|
|
return
|
|
|
|
aggregator = StreamingResponseAggregator()
|
|
|
|
# Process each chunk through the aggregator
|
|
for chunk in self.stream_chunks:
|
|
# Convert LlmResponse to types.GenerateContentResponse
|
|
# Since we don't have the full response object, we'll simulate it
|
|
async for processed_chunk in aggregator.process_response(
|
|
self._llm_response_to_generate_content_response(chunk)
|
|
):
|
|
yield processed_chunk
|
|
|
|
# Call close() to get the final aggregated response
|
|
if final_response := aggregator.close():
|
|
yield final_response
|
|
|
|
def _llm_response_to_generate_content_response(
|
|
self, llm_response: LlmResponse
|
|
) -> types.GenerateContentResponse:
|
|
"""Convert LlmResponse to GenerateContentResponse for aggregator."""
|
|
# Create a minimal GenerateContentResponse that the aggregator can process
|
|
candidates = []
|
|
if llm_response.content:
|
|
candidates.append(
|
|
types.Candidate(
|
|
content=llm_response.content,
|
|
finish_reason=llm_response.finish_reason,
|
|
finish_message=llm_response.error_message,
|
|
)
|
|
)
|
|
|
|
return types.GenerateContentResponse(
|
|
candidates=candidates,
|
|
usage_metadata=llm_response.usage_metadata,
|
|
)
|
|
|
|
|
|
def test_progressive_sse_streaming_function_calls():
|
|
"""Test that function calls are buffered and executed in parallel."""
|
|
|
|
# Setup: Create mock responses simulating streaming chunks
|
|
response1 = LlmResponse(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part.from_text(text="Checking weather...")]
|
|
),
|
|
)
|
|
|
|
response2 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[
|
|
types.Part.from_function_call(
|
|
name="get_weather", args={"location": "Tokyo"}
|
|
)
|
|
],
|
|
),
|
|
)
|
|
|
|
response3 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[
|
|
types.Part.from_function_call(
|
|
name="get_weather", args={"location": "New York"}
|
|
)
|
|
],
|
|
),
|
|
finish_reason=types.FinishReason.STOP,
|
|
)
|
|
|
|
# Create a streaming mock that yields all chunks in one call
|
|
mock_model = StreamingMockModel(
|
|
stream_chunks=[response1, response2, response3]
|
|
)
|
|
|
|
agent = Agent(
|
|
name="weather_agent",
|
|
model=mock_model,
|
|
tools=[get_weather],
|
|
)
|
|
|
|
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
|
|
|
# Use the real InMemoryRunner to get access to run_config parameter
|
|
runner = InMemoryRunner(agent=agent)
|
|
|
|
# Create session manually
|
|
session = runner.session_service.create_session_sync(
|
|
app_name=runner.app_name, user_id="test_user"
|
|
)
|
|
|
|
events = []
|
|
for event in runner.run(
|
|
user_id="test_user",
|
|
session_id=session.id,
|
|
new_message=types.Content(
|
|
role="user",
|
|
parts=[types.Part.from_text(text="What is the weather?")],
|
|
),
|
|
run_config=run_config,
|
|
):
|
|
events.append(event)
|
|
|
|
# Verify event structure (Stage 1 expectations)
|
|
# Expected events:
|
|
# 0-2: Partial events (text + 2 FCs) - not executed
|
|
# 3: Final aggregated model event (text + 2 FCs) - partial=False
|
|
# 4: Aggregated function response (both get_weather results executed in
|
|
# parallel)
|
|
# 5: Final model response after FCs
|
|
assert len(events) == 6
|
|
|
|
assert events[0].partial
|
|
assert events[0].content.parts[0].text == "Checking weather..."
|
|
|
|
assert events[1].partial
|
|
assert events[1].content.parts[0].function_call.name == "get_weather"
|
|
assert events[1].content.parts[0].function_call.args["location"] == "Tokyo"
|
|
|
|
assert events[2].partial
|
|
assert events[2].content.parts[0].function_call.name == "get_weather"
|
|
assert events[2].content.parts[0].function_call.args["location"] == "New York"
|
|
|
|
assert not events[3].partial
|
|
assert events[3].content.parts[0].text == "Checking weather..."
|
|
assert events[3].content.parts[1].function_call.name == "get_weather"
|
|
assert events[3].content.parts[1].function_call.args["location"] == "Tokyo"
|
|
assert events[3].content.parts[2].function_call.name == "get_weather"
|
|
assert events[3].content.parts[2].function_call.args["location"] == "New York"
|
|
|
|
assert not events[4].partial
|
|
assert events[4].content.parts[0].function_response.name == "get_weather"
|
|
assert (
|
|
events[4].content.parts[0].function_response.response["location"]
|
|
== "Tokyo"
|
|
)
|
|
assert events[4].content.parts[1].function_response.name == "get_weather"
|
|
assert (
|
|
events[4].content.parts[1].function_response.response["location"]
|
|
== "New York"
|
|
)
|
|
|
|
assert not events[5].partial
|
|
assert events[5].content.parts[0].text == "Task completed."
|
|
|
|
|
|
def test_progressive_sse_preserves_part_ordering():
|
|
"""Test that part ordering is preserved, especially for thought parts.
|
|
|
|
This test verifies that when the model outputs:
|
|
- chunk1(thought1_1)
|
|
- chunk2(thought1_2)
|
|
- chunk3(text1_1)
|
|
- chunk4(text1_2)
|
|
- chunk5(FC1)
|
|
- chunk6(thought2_1)
|
|
- chunk7(thought2_2)
|
|
- chunk8(FC2)
|
|
|
|
The final aggregated output should be:
|
|
- Part(thought1) # thought1_1 + thought1_2 merged
|
|
- Part(text1) # text1_1 + text1_2 merged
|
|
- Part(FC1)
|
|
- Part(thought2) # thought2_1 + thought2_2 merged
|
|
- Part(FC2)
|
|
"""
|
|
|
|
# Create streaming chunks that test the ordering requirement
|
|
chunk1 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[types.Part(text="Initial thought part 1. ", thought=True)],
|
|
)
|
|
)
|
|
|
|
chunk2 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[types.Part(text="Initial thought part 2.", thought=True)],
|
|
)
|
|
)
|
|
|
|
chunk3 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[types.Part.from_text(text="Let me check Tokyo. ")],
|
|
)
|
|
)
|
|
|
|
chunk4 = LlmResponse(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part.from_text(text="And New York too.")]
|
|
)
|
|
)
|
|
|
|
chunk5 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[
|
|
types.Part.from_function_call(
|
|
name="get_weather", args={"location": "Tokyo"}
|
|
)
|
|
],
|
|
)
|
|
)
|
|
|
|
chunk6 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[
|
|
types.Part(
|
|
text="Now processing second thought part 1. ", thought=True
|
|
)
|
|
],
|
|
)
|
|
)
|
|
|
|
chunk7 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[types.Part(text="Second thought part 2.", thought=True)],
|
|
)
|
|
)
|
|
|
|
chunk8 = LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[
|
|
types.Part.from_function_call(
|
|
name="get_weather", args={"location": "New York"}
|
|
)
|
|
],
|
|
),
|
|
finish_reason=types.FinishReason.STOP,
|
|
)
|
|
|
|
mock_model = StreamingMockModel(
|
|
stream_chunks=[
|
|
chunk1,
|
|
chunk2,
|
|
chunk3,
|
|
chunk4,
|
|
chunk5,
|
|
chunk6,
|
|
chunk7,
|
|
chunk8,
|
|
]
|
|
)
|
|
|
|
agent = Agent(
|
|
name="ordering_test_agent",
|
|
model=mock_model,
|
|
tools=[get_weather],
|
|
)
|
|
|
|
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
|
|
|
# Use the real InMemoryRunner to get access to run_config parameter
|
|
runner = InMemoryRunner(agent=agent)
|
|
|
|
# Create session manually
|
|
session = runner.session_service.create_session_sync(
|
|
app_name=runner.app_name, user_id="test_user"
|
|
)
|
|
|
|
events = []
|
|
for event in runner.run(
|
|
user_id="test_user",
|
|
session_id=session.id,
|
|
new_message=types.Content(
|
|
role="user",
|
|
parts=[types.Part.from_text(text="What is the weather?")],
|
|
),
|
|
run_config=run_config,
|
|
):
|
|
events.append(event)
|
|
|
|
# Find the final aggregated model event (partial=False, from model)
|
|
aggregated_event = None
|
|
for event in events:
|
|
if (
|
|
not event.partial
|
|
and event.author == "ordering_test_agent"
|
|
and event.content
|
|
and len(event.content.parts) > 2
|
|
):
|
|
aggregated_event = event
|
|
break
|
|
|
|
assert aggregated_event is not None, "Should find an aggregated model event"
|
|
|
|
# Verify the part ordering
|
|
parts = aggregated_event.content.parts
|
|
assert len(parts) == 5, f"Expected 5 parts, got {len(parts)}"
|
|
|
|
# Part 0: First thought (merged from chunk1 + chunk2)
|
|
assert parts[0].thought
|
|
assert parts[0].text == "Initial thought part 1. Initial thought part 2."
|
|
|
|
# Part 1: Regular text (merged from chunk3 + chunk4)
|
|
assert not parts[1].thought
|
|
assert parts[1].text == "Let me check Tokyo. And New York too."
|
|
|
|
# Part 2: First function call (from chunk5)
|
|
assert parts[2].function_call.name == "get_weather"
|
|
assert parts[2].function_call.args["location"] == "Tokyo"
|
|
|
|
# Part 3: Second thought (merged from chunk6 + chunk7)
|
|
assert parts[3].thought
|
|
assert (
|
|
parts[3].text
|
|
== "Now processing second thought part 1. Second thought part 2."
|
|
)
|
|
|
|
# Part 4: Second function call (from chunk8)
|
|
assert parts[4].function_call.name == "get_weather"
|
|
assert parts[4].function_call.args["location"] == "New York"
|
|
|
|
|
|
def test_progressive_sse_streaming_function_call_arguments():
|
|
"""Test streaming function call arguments feature.
|
|
|
|
This test simulates the streamFunctionCallArguments feature where a function
|
|
call's arguments are streamed incrementally across multiple chunks:
|
|
|
|
Chunk 1: FC name + partial location argument ("New ")
|
|
Chunk 2: Continue location argument ("York") -> concatenated to "New York"
|
|
Chunk 3: Add unit argument ("celsius"), willContinue=False -> FC complete
|
|
|
|
Expected result: FunctionCall(name="get_weather",
|
|
args={"location": "New York", "unit":
|
|
"celsius"},
|
|
id="fc_001")
|
|
"""
|
|
|
|
aggregator = StreamingResponseAggregator()
|
|
|
|
# Chunk 1: FC name + partial location argument
|
|
chunk1_fc = types.FunctionCall(
|
|
name="get_weather",
|
|
id="fc_001",
|
|
partial_args=[
|
|
types.PartialArg(json_path="$.location", string_value="New ")
|
|
],
|
|
will_continue=True,
|
|
)
|
|
chunk1 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=chunk1_fc)]
|
|
)
|
|
)
|
|
]
|
|
)
|
|
|
|
# Chunk 2: Continue streaming location argument
|
|
chunk2_fc = types.FunctionCall(
|
|
partial_args=[
|
|
types.PartialArg(json_path="$.location", string_value="York")
|
|
],
|
|
will_continue=True,
|
|
)
|
|
chunk2 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=chunk2_fc)]
|
|
)
|
|
)
|
|
]
|
|
)
|
|
|
|
# Chunk 3: Add unit argument, FC complete
|
|
chunk3_fc = types.FunctionCall(
|
|
partial_args=[
|
|
types.PartialArg(json_path="$.unit", string_value="celsius")
|
|
],
|
|
will_continue=False, # FC complete
|
|
)
|
|
chunk3 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=chunk3_fc)]
|
|
),
|
|
finish_reason=types.FinishReason.STOP,
|
|
)
|
|
]
|
|
)
|
|
|
|
# Process all chunks through aggregator
|
|
processed_chunks = []
|
|
for chunk in [chunk1, chunk2, chunk3]:
|
|
|
|
async def process():
|
|
results = []
|
|
async for response in aggregator.process_response(chunk):
|
|
results.append(response)
|
|
return results
|
|
|
|
import asyncio
|
|
|
|
chunk_results = asyncio.run(process())
|
|
processed_chunks.extend(chunk_results)
|
|
|
|
# Get final aggregated response
|
|
final_response = aggregator.close()
|
|
|
|
# Verify final aggregated response has complete FC
|
|
assert final_response is not None
|
|
assert len(final_response.content.parts) == 1
|
|
|
|
fc_part = final_response.content.parts[0]
|
|
assert fc_part.function_call is not None
|
|
assert fc_part.function_call.name == "get_weather"
|
|
assert fc_part.function_call.id == "fc_001"
|
|
|
|
# Verify arguments were correctly assembled from streaming chunks
|
|
args = fc_part.function_call.args
|
|
assert args["location"] == "New York" # "New " + "York" concatenated
|
|
assert args["unit"] == "celsius"
|
|
|
|
|
|
def test_progressive_sse_preserves_thought_signature():
|
|
"""Test that thought_signature is preserved when streaming FC arguments.
|
|
|
|
This test verifies that when a streaming function call has a thought_signature
|
|
in the Part, it is correctly preserved in the final aggregated FunctionCall.
|
|
"""
|
|
|
|
aggregator = StreamingResponseAggregator()
|
|
|
|
# Create a thought signature (simulating what Gemini returns)
|
|
# thought_signature is bytes (base64 encoded)
|
|
test_thought_signature = b"test_signature_abc123"
|
|
|
|
# Chunk with streaming FC args and thought_signature
|
|
chunk_fc = types.FunctionCall(
|
|
name="add_5_numbers",
|
|
id="fc_003",
|
|
partial_args=[
|
|
types.PartialArg(json_path="$.num1", number_value=10),
|
|
types.PartialArg(json_path="$.num2", number_value=20),
|
|
],
|
|
will_continue=False,
|
|
)
|
|
|
|
# Create Part with both function_call AND thought_signature
|
|
chunk_part = types.Part(
|
|
function_call=chunk_fc, thought_signature=test_thought_signature
|
|
)
|
|
|
|
chunk = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(role="model", parts=[chunk_part]),
|
|
finish_reason=types.FinishReason.STOP,
|
|
)
|
|
]
|
|
)
|
|
|
|
# Process chunk through aggregator
|
|
async def process():
|
|
results = []
|
|
async for response in aggregator.process_response(chunk):
|
|
results.append(response)
|
|
return results
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(process())
|
|
|
|
# Get final aggregated response
|
|
final_response = aggregator.close()
|
|
|
|
# Verify thought_signature was preserved in the Part
|
|
assert final_response is not None
|
|
assert len(final_response.content.parts) == 1
|
|
|
|
fc_part = final_response.content.parts[0]
|
|
assert fc_part.function_call is not None
|
|
assert fc_part.function_call.name == "add_5_numbers"
|
|
|
|
assert fc_part.thought_signature == test_thought_signature
|
|
|
|
|
|
def test_progressive_sse_handles_empty_function_call():
|
|
"""Test that empty function calls are skipped.
|
|
|
|
When using streamFunctionCallArguments, Gemini may send an empty
|
|
functionCall: {} as the final chunk to signal streaming completion.
|
|
This test verifies that such empty function calls are properly skipped
|
|
and don't cause errors.
|
|
"""
|
|
|
|
aggregator = StreamingResponseAggregator()
|
|
|
|
# Chunk 1: Streaming FC with partial args
|
|
chunk1_fc = types.FunctionCall(
|
|
name="concat_number_and_string",
|
|
id="fc_001",
|
|
partial_args=[
|
|
types.PartialArg(json_path="$.num", number_value=100),
|
|
types.PartialArg(json_path="$.s", string_value="ADK"),
|
|
],
|
|
will_continue=False,
|
|
)
|
|
chunk1 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=chunk1_fc)]
|
|
)
|
|
)
|
|
]
|
|
)
|
|
|
|
# Chunk 2: Empty function call (streaming end marker)
|
|
chunk2_fc = types.FunctionCall() # Empty function call
|
|
chunk2 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=chunk2_fc)]
|
|
),
|
|
finish_reason=types.FinishReason.STOP,
|
|
)
|
|
]
|
|
)
|
|
|
|
# Process all chunks through aggregator
|
|
async def process():
|
|
results = []
|
|
for chunk in [chunk1, chunk2]:
|
|
async for response in aggregator.process_response(chunk):
|
|
results.append(response)
|
|
return results
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(process())
|
|
|
|
# Get final aggregated response
|
|
final_response = aggregator.close()
|
|
|
|
# Verify final response only has the real FC, not the empty one
|
|
assert final_response is not None
|
|
assert len(final_response.content.parts) == 1
|
|
|
|
fc_part = final_response.content.parts[0]
|
|
assert fc_part.function_call is not None
|
|
assert fc_part.function_call.name == "concat_number_and_string"
|
|
assert fc_part.function_call.id == "fc_001"
|
|
|
|
# Verify arguments
|
|
args = fc_part.function_call.args
|
|
assert args["num"] == 100
|
|
assert args["s"] == "ADK"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"first_chunk_partial_args",
|
|
[
|
|
pytest.param(None, id="partial_args_none"),
|
|
pytest.param([], id="partial_args_empty_list"),
|
|
],
|
|
)
|
|
def test_streaming_fc_chunk_with_will_continue_but_no_partial_args(
|
|
first_chunk_partial_args,
|
|
):
|
|
"""Test streaming function call with will_continue=True but no partial_args."""
|
|
|
|
aggregator = StreamingResponseAggregator()
|
|
|
|
# Chunk 1: FC name + will_continue=True, but NO partial_args (or empty list)
|
|
# This is the first chunk that Gemini 3 sends for streaming FC
|
|
chunk1_fc = types.FunctionCall(
|
|
name="my_tool",
|
|
id="fc_gemini3",
|
|
will_continue=True,
|
|
partial_args=first_chunk_partial_args,
|
|
)
|
|
chunk1_part = types.Part(
|
|
function_call=chunk1_fc,
|
|
thought_signature=b"test_sig_123",
|
|
)
|
|
chunk1 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(role="model", parts=[chunk1_part])
|
|
)
|
|
]
|
|
)
|
|
|
|
# Chunk 2: Middle chunk with partial_args, name is None
|
|
chunk2_fc = types.FunctionCall(
|
|
partial_args=[
|
|
types.PartialArg(json_path="$.document", string_value="Once upon ")
|
|
],
|
|
will_continue=True,
|
|
)
|
|
chunk2 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=chunk2_fc)]
|
|
)
|
|
)
|
|
]
|
|
)
|
|
|
|
# Chunk 3: Another middle chunk continuing the string argument
|
|
chunk3_fc = types.FunctionCall(
|
|
partial_args=[
|
|
types.PartialArg(json_path="$.document", string_value="a time...")
|
|
],
|
|
will_continue=True,
|
|
)
|
|
chunk3 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=chunk3_fc)]
|
|
)
|
|
)
|
|
]
|
|
)
|
|
|
|
# Chunk 4: Final chunk - no name, no partial_args, will_continue=False
|
|
# This signals the end of the streaming function call
|
|
chunk4_fc = types.FunctionCall(
|
|
will_continue=False,
|
|
)
|
|
chunk4 = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=chunk4_fc)]
|
|
),
|
|
finish_reason=types.FinishReason.STOP,
|
|
)
|
|
]
|
|
)
|
|
|
|
# Process all chunks through aggregator
|
|
async def process():
|
|
results = []
|
|
for chunk in [chunk1, chunk2, chunk3, chunk4]:
|
|
async for response in aggregator.process_response(chunk):
|
|
results.append(response)
|
|
return results
|
|
|
|
processed_chunks = asyncio.run(process())
|
|
|
|
# All intermediate chunks should be marked as partial
|
|
assert all(chunk.partial for chunk in processed_chunks)
|
|
|
|
# Get final aggregated response
|
|
final_response = aggregator.close()
|
|
|
|
# Verify final aggregated response has the complete FC with accumulated args
|
|
assert final_response is not None
|
|
assert len(final_response.content.parts) == 1
|
|
|
|
fc_part = final_response.content.parts[0]
|
|
assert fc_part.function_call is not None
|
|
assert fc_part.function_call.name == "my_tool"
|
|
assert fc_part.function_call.id == "fc_gemini3"
|
|
|
|
# Verify the document argument was correctly accumulated
|
|
args = fc_part.function_call.args
|
|
assert "document" in args
|
|
assert (
|
|
args["document"] == "Once upon a time..."
|
|
) # Concatenated from chunks 2 + 3
|
|
|
|
# Verify thought_signature was preserved from the first chunk
|
|
assert fc_part.thought_signature == b"test_sig_123"
|
|
|
|
|
|
class PartialFunctionCallMockModel(BaseLlm):
|
|
"""A mock model that yields partial function call events followed by final."""
|
|
|
|
model: str = "partial-fc-mock"
|
|
tool_call_count: int = 0
|
|
|
|
@classmethod
|
|
def supported_models(cls) -> list[str]:
|
|
return ["partial-fc-mock"]
|
|
|
|
async def generate_content_async(
|
|
self, llm_request: LlmRequest, stream: bool = False
|
|
) -> AsyncGenerator[LlmResponse, None]:
|
|
"""Yield partial FC events then final, simulating streaming behavior."""
|
|
|
|
# Check if this is a follow-up call (after function response)
|
|
has_function_response = False
|
|
for content in llm_request.contents:
|
|
for part in content.parts or []:
|
|
if part.function_response:
|
|
has_function_response = True
|
|
break
|
|
|
|
if has_function_response:
|
|
# Final response after function execution
|
|
yield LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[types.Part.from_text(text="Function executed once.")],
|
|
),
|
|
partial=False,
|
|
)
|
|
return
|
|
|
|
# First call: yield partial FC events then final
|
|
# Partial event 1
|
|
yield LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[
|
|
types.Part.from_function_call(
|
|
name="track_execution", args={"call_id": "partial_1"}
|
|
)
|
|
],
|
|
),
|
|
partial=True,
|
|
)
|
|
|
|
# Partial event 2
|
|
yield LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[
|
|
types.Part.from_function_call(
|
|
name="track_execution", args={"call_id": "partial_2"}
|
|
)
|
|
],
|
|
),
|
|
partial=True,
|
|
)
|
|
|
|
# Final aggregated event (only this should trigger execution)
|
|
yield LlmResponse(
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[
|
|
types.Part.from_function_call(
|
|
name="track_execution", args={"call_id": "final"}
|
|
)
|
|
],
|
|
),
|
|
partial=False,
|
|
finish_reason=types.FinishReason.STOP,
|
|
)
|
|
|
|
|
|
def test_partial_function_calls_not_executed_in_none_streaming_mode():
|
|
"""Test that partial function call events are skipped regardless of mode."""
|
|
execution_log = []
|
|
|
|
def track_execution(call_id: str) -> str:
|
|
"""A tool that logs each execution to verify call count."""
|
|
execution_log.append(call_id)
|
|
return f"Executed: {call_id}"
|
|
|
|
mock_model = PartialFunctionCallMockModel()
|
|
|
|
agent = Agent(
|
|
name="partial_fc_test_agent",
|
|
model=mock_model,
|
|
tools=[track_execution],
|
|
)
|
|
|
|
# Use StreamingMode.NONE to verify partial FCs are still skipped
|
|
run_config = RunConfig(streaming_mode=StreamingMode.NONE)
|
|
|
|
runner = InMemoryRunner(agent=agent)
|
|
|
|
session = runner.session_service.create_session_sync(
|
|
app_name=runner.app_name, user_id="test_user"
|
|
)
|
|
|
|
events = []
|
|
for event in runner.run(
|
|
user_id="test_user",
|
|
session_id=session.id,
|
|
new_message=types.Content(
|
|
role="user",
|
|
parts=[types.Part.from_text(text="Test partial FC handling")],
|
|
),
|
|
run_config=run_config,
|
|
):
|
|
events.append(event)
|
|
|
|
# Verify the tool was only executed once (from the final event)
|
|
assert (
|
|
len(execution_log) == 1
|
|
), f"Expected 1 execution, got {len(execution_log)}: {execution_log}"
|
|
assert (
|
|
execution_log[0] == "final"
|
|
), f"Expected 'final' execution, got: {execution_log[0]}"
|
|
|
|
# Verify partial events were yielded but not executed
|
|
partial_events = [e for e in events if e.partial]
|
|
assert (
|
|
len(partial_events) == 2
|
|
), f"Expected 2 partial events, got {len(partial_events)}"
|
|
|
|
# Verify there's a function response event (from the final FC execution)
|
|
function_response_events = [
|
|
e
|
|
for e in events
|
|
if e.content
|
|
and e.content.parts
|
|
and any(p.function_response for p in e.content.parts)
|
|
]
|
|
assert (
|
|
len(function_response_events) == 1
|
|
), f"Expected 1 function response event, got {len(function_response_events)}"
|