Files
adk-python/tests/unittests/flows/llm_flows/test_functions_simple.py
T
Xiang (Sean) ZhouandCopybara-Service 6da6c2a44c fix: using async lock for accessing shared object in parallel executions and update tests for testing various type of functions
1. given we are running parallel functions in one event loop (one thread) , we should use async lock instead of thread lock
2. test three kind of functions:
  a. sync function
  b. async function that doesn't yield
  c. async function that yield

PiperOrigin-RevId: 791255012
2025-08-05 09:45:04 -07:00

1026 lines
32 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 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.
import asyncio
from typing import Any
from typing import Callable
from google.adk.agents.llm_agent import Agent
from google.adk.events.event import Event
from google.adk.flows.llm_flows.functions import find_matching_function_call
from google.adk.tools.function_tool import FunctionTool
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_respones_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_respones_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_respones_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."""
from google.adk.flows.llm_flows.functions import handle_function_calls_async
from google.adk.flows.llm_flows.functions import handle_function_calls_live
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."""
from google.adk.flows.llm_flows.functions import handle_function_calls_async
from google.adk.flows.llm_flows.functions import handle_function_calls_live
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."""
from google.adk.flows.llm_flows.functions import handle_function_calls_async
from google.adk.flows.llm_flows.functions import handle_function_calls_live
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."""
from google.adk.flows.llm_flows.functions import handle_function_calls_async
from google.adk.flows.llm_flows.functions import handle_function_calls_live
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."""
import time
execution_order = []
execution_times = {}
async def slow_function_1(delay: float = 0.1) -> dict:
start_time = time.time()
execution_order.append('start_1')
await asyncio.sleep(delay)
end_time = time.time()
execution_times['func_1'] = (start_time, end_time)
execution_order.append('end_1')
return {'result': 'function_1_result'}
async def slow_function_2(delay: float = 0.1) -> dict:
start_time = time.time()
execution_order.append('start_2')
await asyncio.sleep(delay)
end_time = time.time()
execution_times['func_2'] = (start_time, end_time)
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)
# Measure total execution time
start_time = time.time()
events = await runner.run_async_with_new_session('test')
total_time = time.time() - start_time
# Verify parallel execution by checking execution order
# In parallel execution, both functions should start before either finishes
assert 'start_1' in execution_order
assert 'start_2' in execution_order
assert 'end_1' in execution_order
assert 'end_2' in execution_order
# Verify both functions started within a reasonable time window
func_1_start, func_1_end = execution_times['func_1']
func_2_start, func_2_end = execution_times['func_2']
# Functions should start at approximately the same time (within 10ms)
start_time_diff = abs(func_1_start - func_2_start)
assert (
start_time_diff < 0.01
), f'Functions started too far apart: {start_time_diff}s'
# Total execution time should be closer to 0.1s (parallel) than 0.2s (sequential)
# Allow some overhead for task creation and synchronization
assert (
total_time < 0.15
), f'Execution took too long: {total_time}s, expected < 0.15s'
# 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']
@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',
]