Files
google--adk-python/tests/unittests/flows/llm_flows/test_functions_simple.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

1867 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
import asyncio
from typing import Any
from typing import Callable
from unittest import mock
from fastapi.openapi.models import HTTPBearer
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.adk.agents.llm_agent import Agent
from google.adk.auth.auth_tool import AuthConfig
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.events.ui_widget import UiWidget
from google.adk.flows.llm_flows.functions import find_matching_function_call
from google.adk.flows.llm_flows.functions import handle_function_calls_async
from google.adk.flows.llm_flows.functions import handle_function_calls_live
from google.adk.flows.llm_flows.functions import merge_parallel_function_response_events
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.computer_use.computer_use_tool import ComputerUseTool
from google.adk.tools.function_tool import FunctionTool
from google.adk.tools.tool_confirmation import ToolConfirmation
from google.adk.tools.tool_context import ToolContext
from google.genai import types
import pytest
from ... import testing_utils
def test_simple_function():
function_call_1 = types.Part.from_function_call(
name='increase_by_one', args={'x': 1}
)
function_responses_2 = types.Part.from_function_response(
name='increase_by_one', response={'result': 2}
)
responses: list[types.Content] = [
function_call_1,
'response1',
'response2',
'response3',
'response4',
]
function_called = 0
mock_model = testing_utils.MockModel.create(responses=responses)
def increase_by_one(x: int) -> int:
nonlocal function_called
function_called += 1
return x + 1
agent = Agent(name='root_agent', model=mock_model, tools=[increase_by_one])
runner = testing_utils.InMemoryRunner(agent)
assert testing_utils.simplify_events(runner.run('test')) == [
('root_agent', function_call_1),
('root_agent', function_responses_2),
('root_agent', 'response1'),
]
# Asserts the requests.
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
('user', 'test')
]
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
('user', 'test'),
('model', function_call_1),
('user', function_responses_2),
]
# Asserts the function calls.
assert function_called == 1
@pytest.mark.asyncio
async def test_async_function():
function_calls = [
types.Part.from_function_call(name='increase_by_one', args={'x': 1}),
types.Part.from_function_call(name='multiple_by_two', args={'x': 2}),
types.Part.from_function_call(name='multiple_by_two_sync', args={'x': 3}),
]
function_responses = [
types.Part.from_function_response(
name='increase_by_one', response={'result': 2}
),
types.Part.from_function_response(
name='multiple_by_two', response={'result': 4}
),
types.Part.from_function_response(
name='multiple_by_two_sync', response={'result': 6}
),
]
responses: list[types.Content] = [
function_calls,
'response1',
'response2',
'response3',
'response4',
]
function_called = 0
mock_model = testing_utils.MockModel.create(responses=responses)
async def increase_by_one(x: int) -> int:
nonlocal function_called
function_called += 1
return x + 1
async def multiple_by_two(x: int) -> int:
nonlocal function_called
function_called += 1
return x * 2
def multiple_by_two_sync(x: int) -> int:
nonlocal function_called
function_called += 1
return x * 2
agent = Agent(
name='root_agent',
model=mock_model,
tools=[increase_by_one, multiple_by_two, multiple_by_two_sync],
)
runner = testing_utils.TestInMemoryRunner(agent)
events = await runner.run_async_with_new_session('test')
assert testing_utils.simplify_events(events) == [
('root_agent', function_calls),
('root_agent', function_responses),
('root_agent', 'response1'),
]
# Asserts the requests.
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
('user', 'test')
]
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
('user', 'test'),
('model', function_calls),
('user', function_responses),
]
# Asserts the function calls.
assert function_called == 3
@pytest.mark.asyncio
async def test_function_tool():
function_calls = [
types.Part.from_function_call(name='increase_by_one', args={'x': 1}),
types.Part.from_function_call(name='multiple_by_two', args={'x': 2}),
types.Part.from_function_call(name='multiple_by_two_sync', args={'x': 3}),
]
function_responses = [
types.Part.from_function_response(
name='increase_by_one', response={'result': 2}
),
types.Part.from_function_response(
name='multiple_by_two', response={'result': 4}
),
types.Part.from_function_response(
name='multiple_by_two_sync', response={'result': 6}
),
]
responses: list[types.Content] = [
function_calls,
'response1',
'response2',
'response3',
'response4',
]
function_called = 0
mock_model = testing_utils.MockModel.create(responses=responses)
async def increase_by_one(x: int) -> int:
nonlocal function_called
function_called += 1
return x + 1
async def multiple_by_two(x: int) -> int:
nonlocal function_called
function_called += 1
return x * 2
def multiple_by_two_sync(x: int) -> int:
nonlocal function_called
function_called += 1
return x * 2
class TestTool(FunctionTool):
def __init__(self, func: Callable[..., Any]):
super().__init__(func=func)
wrapped_increase_by_one = TestTool(func=increase_by_one)
agent = Agent(
name='root_agent',
model=mock_model,
tools=[wrapped_increase_by_one, multiple_by_two, multiple_by_two_sync],
)
runner = testing_utils.TestInMemoryRunner(agent)
events = await runner.run_async_with_new_session('test')
assert testing_utils.simplify_events(events) == [
('root_agent', function_calls),
('root_agent', function_responses),
('root_agent', 'response1'),
]
# Asserts the requests.
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
('user', 'test')
]
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
('user', 'test'),
('model', function_calls),
('user', function_responses),
]
# Asserts the function calls.
assert function_called == 3
def test_update_state():
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(name='update_state', args={}),
'response1',
]
)
def update_state(tool_context: ToolContext):
tool_context.state['x'] = 1
agent = Agent(name='root_agent', model=mock_model, tools=[update_state])
runner = testing_utils.InMemoryRunner(agent)
runner.run('test')
assert runner.session.state['x'] == 1
def test_function_call_id():
responses = [
types.Part.from_function_call(name='increase_by_one', args={'x': 1}),
'response1',
]
mock_model = testing_utils.MockModel.create(responses=responses)
def increase_by_one(x: int) -> int:
return x + 1
agent = Agent(name='root_agent', model=mock_model, tools=[increase_by_one])
runner = testing_utils.InMemoryRunner(agent)
events = runner.run('test')
for request in mock_model.requests:
for content in request.contents:
for part in content.parts:
if part.function_call:
assert part.function_call.id is None
if part.function_response:
assert part.function_response.id is None
assert events[0].content.parts[0].function_call.id.startswith('adk-')
assert events[1].content.parts[0].function_response.id.startswith('adk-')
def test_find_function_call_event_no_function_response_in_last_event():
"""Test when last event has no function response."""
events = [
Event(
invocation_id='inv1',
author='user',
content=types.Content(role='user', parts=[types.Part(text='Hello')]),
)
]
result = find_matching_function_call(events)
assert result is None
def test_find_function_call_event_empty_session_events():
"""Test when session has no events."""
events = []
result = find_matching_function_call(events)
assert result is None
def test_find_function_call_event_function_response_but_no_matching_call():
"""Test when last event has function response but no matching call found."""
# Create a function response
function_response = types.FunctionResponse(
id='func_123', name='test_func', response={}
)
events = [
Event(
invocation_id='inv1',
author='agent1',
content=types.Content(
role='model',
parts=[types.Part(text='Some other response')],
),
),
Event(
invocation_id='inv2',
author='user',
content=types.Content(
role='user',
parts=[types.Part(function_response=function_response)],
),
),
]
result = find_matching_function_call(events)
assert result is None
def test_find_function_call_event_function_response_with_matching_call():
"""Test when last event has function response with matching function call."""
# Create a function call
function_call = types.FunctionCall(id='func_123', name='test_func', args={})
# Create a function response with matching ID
function_response = types.FunctionResponse(
id='func_123', name='test_func', response={}
)
call_event = Event(
invocation_id='inv1',
author='agent1',
content=types.Content(
role='model', parts=[types.Part(function_call=function_call)]
),
)
response_event = Event(
invocation_id='inv2',
author='user',
content=types.Content(
role='user', parts=[types.Part(function_response=function_response)]
),
)
events = [call_event, response_event]
result = find_matching_function_call(events)
assert result == call_event
def test_find_function_call_event_multiple_function_responses():
"""Test when last event has multiple function responses."""
# Create function calls
function_call1 = types.FunctionCall(id='func_123', name='test_func1', args={})
function_call2 = types.FunctionCall(id='func_456', name='test_func2', args={})
# Create function responses
function_response1 = types.FunctionResponse(
id='func_123', name='test_func1', response={}
)
function_response2 = types.FunctionResponse(
id='func_456', name='test_func2', response={}
)
call_event1 = Event(
invocation_id='inv1',
author='agent1',
content=types.Content(
role='model', parts=[types.Part(function_call=function_call1)]
),
)
call_event2 = Event(
invocation_id='inv2',
author='agent2',
content=types.Content(
role='model', parts=[types.Part(function_call=function_call2)]
),
)
response_event = Event(
invocation_id='inv3',
author='user',
content=types.Content(
role='user',
parts=[
types.Part(function_response=function_response1),
types.Part(function_response=function_response2),
],
),
)
events = [call_event1, call_event2, response_event]
# Should return the first matching function call event found
result = find_matching_function_call(events)
assert result == call_event1 # First match (func_123)
@pytest.mark.asyncio
async def test_function_call_args_not_modified():
"""Test that function_call.args is not modified when making a copy."""
def simple_fn(**kwargs) -> dict:
return {'result': 'test'}
tool = FunctionTool(simple_fn)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(
name='test_agent',
model=model,
tools=[tool],
)
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
# Create original args that we want to ensure are not modified
original_args = {'param1': 'value1', 'param2': 42}
function_call = types.FunctionCall(name=tool.name, args=original_args)
content = types.Content(parts=[types.Part(function_call=function_call)])
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=content,
)
tools_dict = {tool.name: tool}
# Test handle_function_calls_async
result_async = await handle_function_calls_async(
invocation_context,
event,
tools_dict,
)
# Verify original args are not modified
assert function_call.args == original_args
assert function_call.args is not original_args # Should be a copy
# Test handle_function_calls_live
result_live = await handle_function_calls_live(
invocation_context,
event,
tools_dict,
)
# Verify original args are still not modified
assert function_call.args == original_args
assert function_call.args is not original_args # Should be a copy
# Both should return valid results
assert result_async is not None
assert result_live is not None
@pytest.mark.asyncio
async def test_function_call_args_none_handling():
"""Test that function_call.args=None is handled correctly."""
def simple_fn(**kwargs) -> dict:
return {'result': 'test'}
tool = FunctionTool(simple_fn)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(
name='test_agent',
model=model,
tools=[tool],
)
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
# Create function call with None args
function_call = types.FunctionCall(name=tool.name, args=None)
content = types.Content(parts=[types.Part(function_call=function_call)])
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=content,
)
tools_dict = {tool.name: tool}
# Test handle_function_calls_async
result_async = await handle_function_calls_async(
invocation_context,
event,
tools_dict,
)
# Test handle_function_calls_live
result_live = await handle_function_calls_live(
invocation_context,
event,
tools_dict,
)
# Both should return valid results even with None args
assert result_async is not None
assert result_live is not None
@pytest.mark.asyncio
async def test_function_call_args_copy_behavior():
"""Test that modifying the copied args doesn't affect the original."""
def simple_fn(test_param: str, other_param: int) -> dict:
# Modify the args to test that the copy prevents affecting the original
return {
'result': 'test',
'received_args': {'test_param': test_param, 'other_param': other_param},
}
tool = FunctionTool(simple_fn)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(
name='test_agent',
model=model,
tools=[tool],
)
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
# Create original args
original_args = {'test_param': 'original_value', 'other_param': 123}
function_call = types.FunctionCall(name=tool.name, args=original_args)
content = types.Content(parts=[types.Part(function_call=function_call)])
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=content,
)
tools_dict = {tool.name: tool}
# Test handle_function_calls_async
result_async = await handle_function_calls_async(
invocation_context,
event,
tools_dict,
)
# Verify original args are unchanged
assert function_call.args == original_args
assert function_call.args['test_param'] == 'original_value'
# Verify the tool received the args correctly
assert result_async is not None
response = result_async.content.parts[0].function_response.response
# Check if the response has the expected structure
assert 'received_args' in response
received_args = response['received_args']
assert 'test_param' in received_args
assert received_args['test_param'] == 'original_value'
assert received_args['other_param'] == 123
assert (
function_call.args['test_param'] == 'original_value'
) # Original unchanged
@pytest.mark.asyncio
async def test_function_call_args_deep_copy_behavior():
"""Test that deep copy behavior works correctly with nested structures."""
def simple_fn(nested_dict: dict, list_param: list) -> dict:
# Modify the nested structures to test deep copy
nested_dict['inner']['value'] = 'modified'
list_param.append('new_item')
return {
'result': 'test',
'received_nested': nested_dict,
'received_list': list_param,
}
tool = FunctionTool(simple_fn)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(
name='test_agent',
model=model,
tools=[tool],
)
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
# Create original args with nested structures
original_nested_dict = {'inner': {'value': 'original'}}
original_list = ['item1', 'item2']
original_args = {
'nested_dict': original_nested_dict,
'list_param': original_list,
}
function_call = types.FunctionCall(name=tool.name, args=original_args)
content = types.Content(parts=[types.Part(function_call=function_call)])
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=content,
)
tools_dict = {tool.name: tool}
# Test handle_function_calls_async
result_async = await handle_function_calls_async(
invocation_context,
event,
tools_dict,
)
# Verify original args are completely unchanged
assert function_call.args == original_args
assert function_call.args['nested_dict']['inner']['value'] == 'original'
assert function_call.args['list_param'] == ['item1', 'item2']
# Verify the tool received the modified nested structures
assert result_async is not None
response = result_async.content.parts[0].function_response.response
# Check that the tool received modified versions
assert 'received_nested' in response
assert 'received_list' in response
assert response['received_nested']['inner']['value'] == 'modified'
assert 'new_item' in response['received_list']
# Verify original is still unchanged
assert function_call.args['nested_dict']['inner']['value'] == 'original'
assert function_call.args['list_param'] == ['item1', 'item2']
def test_shallow_vs_deep_copy_demonstration():
"""Demonstrate why deep copy is necessary vs shallow copy."""
import copy
# Original nested structure
original = {
'nested_dict': {'inner': {'value': 'original'}},
'list_param': ['item1', 'item2'],
}
# Shallow copy (what dict() does)
shallow_copy = dict(original)
# Deep copy (what copy.deepcopy() does)
deep_copy = copy.deepcopy(original)
# Modify the shallow copy
shallow_copy['nested_dict']['inner']['value'] = 'modified'
shallow_copy['list_param'].append('new_item')
# Check that shallow copy affects the original
assert (
original['nested_dict']['inner']['value'] == 'modified'
) # Original is affected!
assert 'new_item' in original['list_param'] # Original is affected!
# Reset original for deep copy test
original = {
'nested_dict': {'inner': {'value': 'original'}},
'list_param': ['item1', 'item2'],
}
# Modify the deep copy
deep_copy['nested_dict']['inner']['value'] = 'modified'
deep_copy['list_param'].append('new_item')
# Check that deep copy does NOT affect the original
assert (
original['nested_dict']['inner']['value'] == 'original'
) # Original unchanged
assert 'new_item' not in original['list_param'] # Original unchanged
assert (
deep_copy['nested_dict']['inner']['value'] == 'modified'
) # Copy is modified
assert 'new_item' in deep_copy['list_param'] # Copy is modified
@pytest.mark.asyncio
async def test_parallel_function_execution_timing():
"""Test that multiple function calls are executed in parallel, not sequentially."""
execution_order = []
async def slow_function_1(delay: float = 0.1) -> dict:
execution_order.append('start_1')
await asyncio.sleep(delay)
execution_order.append('end_1')
return {'result': 'function_1_result'}
async def slow_function_2(delay: float = 0.1) -> dict:
execution_order.append('start_2')
await asyncio.sleep(delay)
execution_order.append('end_2')
return {'result': 'function_2_result'}
# Create function calls
function_calls = [
types.Part.from_function_call(
name='slow_function_1', args={'delay': 0.1}
),
types.Part.from_function_call(
name='slow_function_2', args={'delay': 0.1}
),
]
function_responses = [
types.Part.from_function_response(
name='slow_function_1', response={'result': 'function_1_result'}
),
types.Part.from_function_response(
name='slow_function_2', response={'result': 'function_2_result'}
),
]
responses: list[types.Content] = [
function_calls,
'response1',
]
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='test_agent',
model=mock_model,
tools=[slow_function_1, slow_function_2],
)
runner = testing_utils.TestInMemoryRunner(agent)
events = await runner.run_async_with_new_session('test')
# Parallel execution means both functions start before either one finishes.
assert set(execution_order) == {'start_1', 'start_2', 'end_1', 'end_2'}
last_start = max(
execution_order.index('start_1'), execution_order.index('start_2')
)
first_end = min(
execution_order.index('end_1'), execution_order.index('end_2')
)
assert (
last_start < first_end
), f'Functions did not overlap; execution was sequential: {execution_order}'
# Verify the results are correct
assert testing_utils.simplify_events(events) == [
('test_agent', function_calls),
('test_agent', function_responses),
('test_agent', 'response1'),
]
@pytest.mark.asyncio
async def test_parallel_state_modifications_thread_safety():
"""Test that parallel function calls modifying state are thread-safe."""
state_modifications = []
def modify_state_1(tool_context: ToolContext) -> dict:
# Track when this function modifies state
current_state = dict(tool_context.state.to_dict())
state_modifications.append(('func_1_start', current_state))
tool_context.state['counter'] = tool_context.state.get('counter', 0) + 1
tool_context.state['func_1_executed'] = True
final_state = dict(tool_context.state.to_dict())
state_modifications.append(('func_1_end', final_state))
return {'result': 'modified_state_1'}
def modify_state_2(tool_context: ToolContext) -> dict:
# Track when this function modifies state
current_state = dict(tool_context.state.to_dict())
state_modifications.append(('func_2_start', current_state))
tool_context.state['counter'] = tool_context.state.get('counter', 0) + 1
tool_context.state['func_2_executed'] = True
final_state = dict(tool_context.state.to_dict())
state_modifications.append(('func_2_end', final_state))
return {'result': 'modified_state_2'}
# Create function calls
function_calls = [
types.Part.from_function_call(name='modify_state_1', args={}),
types.Part.from_function_call(name='modify_state_2', args={}),
]
responses: list[types.Content] = [
function_calls,
'response1',
]
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='test_agent',
model=mock_model,
tools=[modify_state_1, modify_state_2],
)
runner = testing_utils.TestInMemoryRunner(agent)
events = await runner.run_async_with_new_session('test')
# Verify the parallel execution worked correctly by checking the events
# The function response event should have the merged state_delta
function_response_event = events[
1
] # Second event should be the function response
assert function_response_event.actions.state_delta['counter'] == 2
assert function_response_event.actions.state_delta['func_1_executed'] is True
assert function_response_event.actions.state_delta['func_2_executed'] is True
# Verify both functions were called
assert len(state_modifications) == 4 # 2 functions × 2 events each
# Extract function names from modifications
func_names = [mod[0] for mod in state_modifications]
assert 'func_1_start' in func_names
assert 'func_1_end' in func_names
assert 'func_2_start' in func_names
assert 'func_2_end' in func_names
@pytest.mark.asyncio
async def test_sync_function_blocks_async_functions():
"""Test that sync functions block async functions from running concurrently."""
execution_order = []
def blocking_sync_function() -> dict:
execution_order.append('sync_A')
# Simulate CPU-intensive work that blocks the event loop
result = 0
for i in range(1000000): # This blocks the event loop
result += i
execution_order.append('sync_B')
return {'result': 'sync_done'}
async def yielding_async_function() -> dict:
execution_order.append('async_C')
await asyncio.sleep(
0.001
) # This should yield, but can't if event loop is blocked
execution_order.append('async_D')
return {'result': 'async_done'}
# Create function calls - these should run "in parallel"
function_calls = [
types.Part.from_function_call(name='blocking_sync_function', args={}),
types.Part.from_function_call(name='yielding_async_function', args={}),
]
responses: list[types.Content] = [function_calls, 'response1']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='test_agent',
model=mock_model,
tools=[blocking_sync_function, yielding_async_function],
)
runner = testing_utils.TestInMemoryRunner(agent)
events = await runner.run_async_with_new_session('test')
# With blocking sync function, execution should be sequential: A, B, C, D
# The sync function blocks, preventing the async function from yielding properly
assert execution_order == ['sync_A', 'sync_B', 'async_C', 'async_D']
@pytest.mark.asyncio
async def test_async_function_without_yield_blocks_others():
"""Test that async functions without yield statements block other functions."""
execution_order = []
async def non_yielding_async_function() -> dict:
execution_order.append('non_yield_A')
# CPU-intensive work without any await statements - blocks like sync function
result = 0
for i in range(1000000): # No await here, so this blocks the event loop
result += i
execution_order.append('non_yield_B')
return {'result': 'non_yielding_done'}
async def yielding_async_function() -> dict:
execution_order.append('yield_C')
await asyncio.sleep(
0.001
) # This should yield, but can't if event loop is blocked
execution_order.append('yield_D')
return {'result': 'yielding_done'}
# Create function calls
function_calls = [
types.Part.from_function_call(
name='non_yielding_async_function', args={}
),
types.Part.from_function_call(name='yielding_async_function', args={}),
]
responses: list[types.Content] = [function_calls, 'response1']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='test_agent',
model=mock_model,
tools=[non_yielding_async_function, yielding_async_function],
)
runner = testing_utils.TestInMemoryRunner(agent)
events = await runner.run_async_with_new_session('test')
# Non-yielding async function blocks, so execution is sequential: A, B, C, D
assert execution_order == ['non_yield_A', 'non_yield_B', 'yield_C', 'yield_D']
def test_merge_parallel_function_response_events_preserves_invocation_id():
"""Test that merge_parallel_function_response_events preserves the base event's invocation_id."""
# Create multiple function response events with different invocation IDs
invocation_id = 'base_invocation_123'
function_response1 = types.FunctionResponse(
id='func_123', name='test_function1', response={'result': 'success1'}
)
function_response2 = types.FunctionResponse(
id='func_456', name='test_function2', response={'result': 'success2'}
)
event1 = Event(
invocation_id=invocation_id,
author='test_agent',
content=types.Content(
role='user', parts=[types.Part(function_response=function_response1)]
),
)
event2 = Event(
invocation_id='different_invocation_456', # Different invocation ID
author='test_agent',
content=types.Content(
role='user', parts=[types.Part(function_response=function_response2)]
),
)
# Merge the events
merged_event = merge_parallel_function_response_events([event1, event2])
# Should preserve the base event's (first event's) invocation_id
assert merged_event.invocation_id == invocation_id
assert merged_event.invocation_id != 'different_invocation_456'
# Should contain both function responses
assert len(merged_event.content.parts) == 2
# Verify the responses are preserved
response_ids = {
part.function_response.id for part in merged_event.content.parts
}
assert 'func_123' in response_ids
assert 'func_456' in response_ids
def test_merge_parallel_function_response_events_single_event():
"""Test that merge_parallel_function_response_events returns single event unchanged."""
invocation_id = 'single_invocation_123'
function_response = types.FunctionResponse(
id='func_123', name='test_function', response={'result': 'success'}
)
event = Event(
invocation_id=invocation_id,
author='test_agent',
content=types.Content(
role='user', parts=[types.Part(function_response=function_response)]
),
)
# Merge single event
merged_event = merge_parallel_function_response_events([event])
# Should return the same event object
assert merged_event is event
assert merged_event.invocation_id == invocation_id
def test_merge_parallel_function_response_events_preserves_other_attributes():
"""Test that merge_parallel_function_response_events preserves other attributes from base event."""
invocation_id = 'base_invocation_123'
base_author = 'base_agent'
base_branch = 'main_branch'
function_response1 = types.FunctionResponse(
id='func_123', name='test_function1', response={'result': 'success1'}
)
function_response2 = types.FunctionResponse(
id='func_456', name='test_function2', response={'result': 'success2'}
)
event1 = Event(
invocation_id=invocation_id,
author=base_author,
branch=base_branch,
content=types.Content(
role='user', parts=[types.Part(function_response=function_response1)]
),
)
event2 = Event(
invocation_id='different_invocation_456',
author='different_agent', # Different author
branch='different_branch', # Different branch
content=types.Content(
role='user', parts=[types.Part(function_response=function_response2)]
),
)
# Merge the events
merged_event = merge_parallel_function_response_events([event1, event2])
# Should preserve base event's attributes
assert merged_event.invocation_id == invocation_id
assert merged_event.author == base_author
assert merged_event.branch == base_branch
# Should contain both function responses
assert len(merged_event.content.parts) == 2
@pytest.mark.asyncio
async def test_yielding_async_functions_run_concurrently():
"""Test that async functions with proper yields run concurrently."""
execution_order = []
async def yielding_async_function_1() -> dict:
execution_order.append('func1_A')
await asyncio.sleep(0.001) # Yield control
execution_order.append('func1_B')
return {'result': 'func1_done'}
async def yielding_async_function_2() -> dict:
execution_order.append('func2_C')
await asyncio.sleep(0.001) # Yield control
execution_order.append('func2_D')
return {'result': 'func2_done'}
# Create function calls
function_calls = [
types.Part.from_function_call(name='yielding_async_function_1', args={}),
types.Part.from_function_call(name='yielding_async_function_2', args={}),
]
responses: list[types.Content] = [function_calls, 'response1']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='test_agent',
model=mock_model,
tools=[yielding_async_function_1, yielding_async_function_2],
)
runner = testing_utils.TestInMemoryRunner(agent)
events = await runner.run_async_with_new_session('test')
# With proper yielding, execution should interleave: A, C, B, D
# Both functions start, yield, then complete
assert execution_order == ['func1_A', 'func2_C', 'func1_B', 'func2_D']
@pytest.mark.asyncio
async def test_mixed_function_types_execution_order():
"""Test execution order with all three types of functions."""
execution_order = []
def sync_function() -> dict:
execution_order.append('sync_A')
# Small amount of blocking work
result = sum(range(100000))
execution_order.append('sync_B')
return {'result': 'sync_done'}
async def non_yielding_async() -> dict:
execution_order.append('non_yield_C')
# CPU work without yield
result = sum(range(100000))
execution_order.append('non_yield_D')
return {'result': 'non_yield_done'}
async def yielding_async() -> dict:
execution_order.append('yield_E')
await asyncio.sleep(0.001) # Proper yield
execution_order.append('yield_F')
return {'result': 'yield_done'}
# Create function calls
function_calls = [
types.Part.from_function_call(name='sync_function', args={}),
types.Part.from_function_call(name='non_yielding_async', args={}),
types.Part.from_function_call(name='yielding_async', args={}),
]
responses: list[types.Content] = [function_calls, 'response1']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='test_agent',
model=mock_model,
tools=[sync_function, non_yielding_async, yielding_async],
)
runner = testing_utils.TestInMemoryRunner(agent)
events = await runner.run_async_with_new_session('test')
# All blocking functions run sequentially, then the yielding one
# Expected order: sync_A, sync_B, non_yield_C, non_yield_D, yield_E, yield_F
assert execution_order == [
'sync_A',
'sync_B',
'non_yield_C',
'non_yield_D',
'yield_E',
'yield_F',
]
def test_merge_parallel_function_response_events_merges_ui_widgets():
"""Test that merge_parallel_function_response_events merges render_ui_widgets."""
invocation_id = 'base_invocation_123'
widget1 = UiWidget(
id='widget_1', provider='mcp', payload={'resource_uri': 'ui://widget1'}
)
widget2 = UiWidget(
id='widget_2', provider='mcp', payload={'resource_uri': 'ui://widget2'}
)
widget3 = UiWidget(
id='widget_3', provider='mcp', payload={'resource_uri': 'ui://widget3'}
)
event1 = Event(
invocation_id=invocation_id,
author='test_agent',
actions=EventActions(render_ui_widgets=[widget1]),
)
event2 = Event(
invocation_id='different_invocation_456',
author='different_agent',
actions=EventActions(render_ui_widgets=[widget2, widget3]),
)
# Merge the events
merged_event = merge_parallel_function_response_events([event1, event2])
# Should contain all ui widgets
assert merged_event.actions.render_ui_widgets is not None
assert len(merged_event.actions.render_ui_widgets) == 3
widget_ids = {widget.id for widget in merged_event.actions.render_ui_widgets}
assert 'widget_1' in widget_ids
assert 'widget_2' in widget_ids
assert 'widget_3' in widget_ids
@pytest.mark.asyncio
@pytest.mark.parametrize(
'handle_function_calls',
[
(handle_function_calls_async),
(handle_function_calls_live),
],
)
async def test_computer_use_tool_decoding_behavior(handle_function_calls):
"""Tests that computer use tools automatically decode base64 images."""
valid_b64 = 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
# make the tool return a dictionary with the image
async def mock_run(*args, **kwargs):
return {
'image': {'data': valid_b64, 'mimetype': 'image/png'},
'url': 'https://example.com',
}
# create a ComputerUseTool
tool = ComputerUseTool(func=mock_run, screen_size=(1024, 768))
model = testing_utils.MockModel.create(responses=[])
agent = Agent(
name='test_agent',
model=model,
tools=[tool],
)
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
# Create function call
function_call = types.FunctionCall(name=tool.name, args={})
content = types.Content(parts=[types.Part(function_call=function_call)])
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=content,
)
tools_dict = {tool.name: tool}
result = await handle_function_calls(
invocation_context,
event,
tools_dict,
)
assert result is not None
response_part = result.content.parts[0].function_response
# Verify original image data is removed from the dict response
assert 'image' not in response_part.response
assert 'url' in response_part.response
# Verify the image was converted to a blob
assert len(response_part.parts) == 1
assert response_part.parts[0].inline_data is not None
@pytest.mark.asyncio
async def test_handle_function_calls_live_preserves_live_session_id():
"""Tests that handle_function_calls_live preserves live_session_id for single call."""
def simple_fn() -> dict[str, str]:
return {'result': 'test'}
tool1 = FunctionTool(simple_fn)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(
name='test_agent',
model=model,
tools=[tool1],
)
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call1 = types.FunctionCall(id='call_1', name=tool1.name, args={})
content1 = types.Content(parts=[types.Part(function_call=function_call1)])
event1 = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=content1,
live_session_id='test-live-session-id',
)
tools_dict = {tool1.name: tool1}
result_single = await handle_function_calls_live(
invocation_context,
event1,
tools_dict,
)
assert result_single is not None
assert result_single.live_session_id == 'test-live-session-id'
@pytest.mark.asyncio
async def test_handle_function_calls_live_parallel_preserves_live_session_id():
"""Tests that handle_function_calls_live preserves live_session_id for parallel calls."""
def simple_fn() -> dict[str, str]:
return {'result': 'test'}
tool1 = FunctionTool(simple_fn)
tool2 = FunctionTool(simple_fn)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(
name='test_agent',
model=model,
tools=[tool1, tool2],
)
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call1 = types.FunctionCall(id='call_1', name=tool1.name, args={})
function_call2 = types.FunctionCall(id='call_2', name=tool2.name, args={})
content2 = types.Content(
parts=[
types.Part(function_call=function_call1),
types.Part(function_call=function_call2),
]
)
event2 = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=content2,
live_session_id='test-live-session-id-parallel',
)
tools_dict = {tool1.name: tool1, tool2.name: tool2}
result_parallel = await handle_function_calls_live(
invocation_context,
event2,
tools_dict,
)
assert result_parallel is not None
assert result_parallel.live_session_id == 'test-live-session-id-parallel'
class _MockControlSignalTool(BaseTool):
"""A tool that simulates requesting confirmation or OAuth authentication."""
def __init__(self, name: str, behavior: str):
super().__init__(name=name, description='Simulated control tool')
self.behavior = behavior
async def run_async(self, *, args, tool_context):
if self.behavior == 'confirm':
tool_context.actions.requested_tool_confirmations = {
'fc_test_confirm': ToolConfirmation(hint='Authorize execution?')
}
return {'error': 'This tool requires user approval.'}
elif self.behavior == 'auth':
tool_context.actions.requested_auth_configs = {
'fc_test_auth': AuthConfig(auth_scheme=HTTPBearer())
}
return {'error': 'Please complete OAuth setup.'}
def _detect_error_in_response(self, response: Any) -> str | None:
if isinstance(response, dict) and 'error' in response:
return 'TOOL_ERROR'
return None
class _ErrorDetectingTool(BaseTool):
"""A test tool whose _detect_error_in_response raises an exception."""
async def run_async(self, *, args, tool_context):
return {'result': 'result'}
def _detect_error_in_response(self, response: Any) -> str | None:
raise RuntimeError('detection exploded')
@pytest.mark.asyncio
@pytest.mark.parametrize(
'handle_function_calls',
[
(handle_function_calls_async),
(handle_function_calls_live),
],
)
@pytest.mark.parametrize(
'mock_response,expected_error_type',
[
({'error': 'Internal component timeout'}, 'TOOL_ERROR'),
({'result': 'Execution succeeded'}, None),
],
ids=['dict_error_recorded', 'success_dict_ignored'],
)
async def test_e2e_telemetry_error_classification(
monkeypatch, handle_function_calls, mock_response, expected_error_type
):
"""E2E: asserts that tool outputs successfully translate to targeted OTel span error attributes."""
recorded_calls = []
# Intercept trace_tool_call to capture final telemetry state
monkeypatch.setattr(
'google.adk.telemetry._instrumentation.tracing.trace_tool_call',
lambda **kw: recorded_calls.append(kw),
)
tool = FunctionTool(func=lambda: mock_response)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_test')
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
await handle_function_calls(invocation_context, event, {tool.name: tool})
assert len(recorded_calls) == 1
assert recorded_calls[0]['error_type'] == expected_error_type
assert recorded_calls[0]['error'] is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'handle_function_calls',
[
(handle_function_calls_async),
(handle_function_calls_live),
],
)
async def test_exception_takes_precedence_over_dict_error(
monkeypatch, handle_function_calls
):
"""End-to-end integration: exception takes strict precedence over manual dict error_type."""
recorded_calls = []
monkeypatch.setattr(
'google.adk.telemetry._instrumentation.tracing.trace_tool_call',
lambda **kw: recorded_calls.append(kw),
)
def mock_crashing_func():
raise ValueError('Fatal arithmetic error')
tool = FunctionTool(func=mock_crashing_func)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call = types.FunctionCall(
name=tool.name, args={}, id='fc_test_exception'
)
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
with pytest.raises(ValueError, match='Fatal arithmetic error'):
await handle_function_calls(invocation_context, event, {tool.name: tool})
assert len(recorded_calls) == 1
assert isinstance(recorded_calls[0]['error'], ValueError)
assert recorded_calls[0]['error_type'] is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'handle_function_calls',
[
(handle_function_calls_async),
(handle_function_calls_live),
],
)
async def test_detection_skipped_when_confirmation_requested(
monkeypatch, handle_function_calls
):
"""E2E confirmation verification: control prompt avoids polluting telemetry with TOOL_ERROR."""
recorded_calls = []
monkeypatch.setattr(
'google.adk.telemetry._instrumentation.tracing.trace_tool_call',
lambda **kw: recorded_calls.append(kw),
)
tool = _MockControlSignalTool(name='confirm_tool', behavior='confirm')
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call = types.FunctionCall(
name=tool.name, args={}, id='fc_test_confirm'
)
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
await handle_function_calls(invocation_context, event, {tool.name: tool})
assert len(recorded_calls) == 1
assert recorded_calls[0]['error_type'] is None
assert recorded_calls[0]['error'] is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'handle_function_calls',
[
(handle_function_calls_async),
(handle_function_calls_live),
],
)
async def test_detection_skipped_when_auth_requested(
monkeypatch, handle_function_calls
):
"""E2E OAuth verification: authenticate control prompt avoids polluting telemetry with TOOL_ERROR."""
recorded_calls = []
monkeypatch.setattr(
'google.adk.telemetry._instrumentation.tracing.trace_tool_call',
lambda **kw: recorded_calls.append(kw),
)
tool = _MockControlSignalTool(name='auth_tool', behavior='auth')
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_test_auth')
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
await handle_function_calls(invocation_context, event, {tool.name: tool})
assert len(recorded_calls) == 1
assert recorded_calls[0]['error_type'] is None
assert recorded_calls[0]['error'] is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'handle_function_calls',
[
(handle_function_calls_async),
(handle_function_calls_live),
],
)
async def test_detection_exception_does_not_break_tool_call(
monkeypatch, handle_function_calls
):
"""Safety Verification: telemetry errors during error parsing are caught cleanly, not crashing tool calls."""
recorded_calls = []
monkeypatch.setattr(
'google.adk.telemetry._instrumentation.tracing.trace_tool_call',
lambda **kw: recorded_calls.append(kw),
)
tool = _ErrorDetectingTool(
name='buggy_telemetry_tool', description='raises on tel'
)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call = types.FunctionCall(
name=tool.name, args={}, id='fc_test_buggy'
)
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
result_event = await handle_function_calls(
invocation_context, event, {tool.name: tool}
)
assert result_event is not None
assert result_event.content.parts[0].function_response.response == {
'result': 'result'
}
assert len(recorded_calls) == 1
assert recorded_calls[0]['error_type'] is None
assert recorded_calls[0]['error'] is None
@pytest.mark.asyncio
async def test_response_scheduling_applied_to_function_response():
"""response_scheduling on a tool is stamped onto the FunctionResponse part."""
def simple_fn(**kwargs) -> dict:
return {'result': 'test'}
tool = FunctionTool(simple_fn)
tool.response_scheduling = types.FunctionResponseScheduling.SILENT
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_test')
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
result_event = await handle_function_calls_async(
invocation_context, event, {tool.name: tool}
)
assert result_event is not None
function_response = result_event.content.parts[0].function_response
assert function_response.scheduling is types.FunctionResponseScheduling.SILENT
@pytest.mark.asyncio
async def test_response_scheduling_unset_by_default():
"""Without response_scheduling, the FunctionResponse part leaves it unset."""
def simple_fn(**kwargs) -> dict:
return {'result': 'test'}
tool = FunctionTool(simple_fn)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_test')
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
result_event = await handle_function_calls_async(
invocation_context, event, {tool.name: tool}
)
assert result_event is not None
function_response = result_event.content.parts[0].function_response
assert function_response.scheduling is None
async def _drain_live_function_responses(
live_request_queue: LiveRequestQueue,
count: int,
) -> list[types.Content]:
"""Drains ``count`` contents from a live request queue, ignoring acks."""
contents = []
while len(contents) < count:
request = await asyncio.wait_for(live_request_queue._queue.get(), timeout=5)
if request.content is not None:
contents.append(request.content)
return contents
@pytest.mark.asyncio
async def test_streaming_tool_with_scheduling_emits_function_response():
"""A streaming tool with response_scheduling relays yields as FunctionResponses."""
async def streaming_fn(**kwargs):
yield 'first'
yield 'second'
tool = FunctionTool(streaming_fn)
tool.response_scheduling = types.FunctionResponseScheduling.SILENT
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
invocation_context.live_request_queue = LiveRequestQueue()
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_stream')
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
await handle_function_calls_live(invocation_context, event, {tool.name: tool})
contents = await _drain_live_function_responses(
invocation_context.live_request_queue, count=2
)
responses = [content.parts[0].function_response for content in contents]
assert [r.response for r in responses] == [
{'result': 'first'},
{'result': 'second'},
]
assert all(r.id == 'fc_stream' for r in responses)
assert all(
r.scheduling is types.FunctionResponseScheduling.SILENT for r in responses
)
@pytest.mark.asyncio
async def test_streaming_tool_without_scheduling_emits_function_response():
"""A streaming tool without response_scheduling still relays yields as
FunctionResponses, leaving scheduling unset."""
async def streaming_fn(**kwargs):
yield 'hello'
tool = FunctionTool(streaming_fn)
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
invocation_context.live_request_queue = LiveRequestQueue()
function_call = types.FunctionCall(name=tool.name, args={}, id='fc_stream')
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
await handle_function_calls_live(invocation_context, event, {tool.name: tool})
contents = await _drain_live_function_responses(
invocation_context.live_request_queue, count=1
)
function_response = contents[0].parts[0].function_response
assert function_response.response == {'result': 'hello'}
assert function_response.id == 'fc_stream'
assert function_response.scheduling is None
async def test_non_blocking_tool_handled_asynchronously():
"""Tests that a NON_BLOCKING tool returns None inline and pushes to live request queue."""
import asyncio
async def slow_fn() -> dict[str, str]:
await asyncio.sleep(0.1)
return {'result': 'done'}
tool = FunctionTool(slow_fn)
tool.response_scheduling = types.FunctionResponseScheduling.WHEN_IDLE
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
invocation_context.live_request_queue = LiveRequestQueue()
function_call = types.FunctionCall(
name=tool.name, args={}, id='fc_non_blocking'
)
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
# Inline call should return None, indicating it is handled asynchronously
result = await handle_function_calls_live(
invocation_context, event, {tool.name: tool}
)
assert result is None
# Verify strong reference is kept in active_non_blocking_tool_tasks while running
assert invocation_context.active_non_blocking_tool_tasks is not None
task_key = f'{tool.name}_fc_non_blocking'
assert task_key in invocation_context.active_non_blocking_tool_tasks
# Wait for the background task to complete and push to the queue
contents = await _drain_live_function_responses(
invocation_context.live_request_queue, count=1
)
function_response = contents[0].parts[0].function_response
assert function_response.response == {'result': 'done'}
assert function_response.id == 'fc_non_blocking'
assert (
function_response.scheduling == types.FunctionResponseScheduling.WHEN_IDLE
)
# Give event loop a tick for finally block to clean up the completed task
await asyncio.sleep(0)
assert task_key not in invocation_context.active_non_blocking_tool_tasks
async def test_non_blocking_tool_exception_handling_and_cleanup():
"""Tests that an exception in a NON_BLOCKING tool is logged and cleaned up."""
import asyncio
async def failing_fn() -> dict[str, str]:
await asyncio.sleep(0.05)
raise RuntimeError('Tool execution failed purposely')
tool = FunctionTool(failing_fn)
tool.response_scheduling = types.FunctionResponseScheduling.WHEN_IDLE
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
invocation_context.live_request_queue = LiveRequestQueue()
function_call = types.FunctionCall(
name=tool.name, args={}, id='fc_failing_non_blocking'
)
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(parts=[types.Part(function_call=function_call)]),
)
result = await handle_function_calls_live(
invocation_context, event, {tool.name: tool}
)
assert result is None
task_key = f'{tool.name}_fc_failing_non_blocking'
assert (
invocation_context.active_non_blocking_tool_tasks
and task_key in invocation_context.active_non_blocking_tool_tasks
)
# Allow background task to execute and raise its exception
await asyncio.sleep(0.1)
# Give event loop a tick for finally block to clean up the completed task
await asyncio.sleep(0)
assert task_key not in invocation_context.active_non_blocking_tool_tasks
async def test_parallel_non_blocking_tools():
"""Tests that multiple NON_BLOCKING tools execute in parallel and clean up independently."""
import asyncio
async def slow_fn_1() -> dict[str, str]:
await asyncio.sleep(0.05)
return {'result': 'done_1'}
async def slow_fn_2() -> dict[str, str]:
await asyncio.sleep(0.05)
return {'result': 'done_2'}
tool1 = FunctionTool(slow_fn_1)
tool1.response_scheduling = types.FunctionResponseScheduling.WHEN_IDLE
tool2 = FunctionTool(slow_fn_2)
tool2.response_scheduling = types.FunctionResponseScheduling.SILENT
model = testing_utils.MockModel.create(responses=[])
agent = Agent(name='test_agent', model=model, tools=[tool1, tool2])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, user_content=''
)
invocation_context.live_request_queue = LiveRequestQueue()
fc1 = types.FunctionCall(name=tool1.name, args={}, id='fc_1')
fc2 = types.FunctionCall(name=tool2.name, args={}, id='fc_2')
event = Event(
invocation_id=invocation_context.invocation_id,
author=agent.name,
content=types.Content(
parts=[
types.Part(function_call=fc1),
types.Part(function_call=fc2),
]
),
)
result = await handle_function_calls_live(
invocation_context, event, {tool1.name: tool1, tool2.name: tool2}
)
assert result is None
assert len(invocation_context.active_non_blocking_tool_tasks) == 2
key1 = f'{tool1.name}_fc_1'
key2 = f'{tool2.name}_fc_2'
assert key1 in invocation_context.active_non_blocking_tool_tasks
assert key2 in invocation_context.active_non_blocking_tool_tasks
contents = await _drain_live_function_responses(
invocation_context.live_request_queue, count=2
)
responses = {
c.parts[0].function_response.id: c.parts[0].function_response
for c in contents
}
assert responses['fc_1'].response == {'result': 'done_1'}
assert responses['fc_2'].response == {'result': 'done_2'}
await asyncio.sleep(0)
assert len(invocation_context.active_non_blocking_tool_tasks) == 0