Python: Introducing AI Function approval (#1131)

* support for local function approval

* small fix

* fix mypy

* added bigger test scenario's for function calling and approvals

* updated lock

* updated return message for rejection

* fix test

* updated function result content handling
This commit is contained in:
Eduard van Valkenburg
2025-10-04 17:19:16 +02:00
committed by GitHub
Unverified
parent 01f438d710
commit fd819c6c02
18 changed files with 1535 additions and 304 deletions
@@ -1,25 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from agent_framework import (
BaseChatClient,
ChatClientProtocol,
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
ai_function,
)
if sys.version_info >= (3, 12):
pass # type: ignore
else:
pass # type: ignore[import]
def test_chat_client_type(chat_client: ChatClientProtocol):
assert isinstance(chat_client, ChatClientProtocol)
@@ -51,113 +39,3 @@ async def test_base_client_get_response(chat_client_base: ChatClientProtocol):
async def test_base_client_get_streaming_response(chat_client_base: ChatClientProtocol):
async for update in chat_client_base.get_streaming_response(ChatMessage(role="user", text="Hello")):
assert update.text == "update - Hello" or update.text == "another update"
async def test_base_client_with_function_calling(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
assert exec_counter == 1
assert len(response.messages) == 3
assert response.messages[0].role == Role.ASSISTANT
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].name == "test_function"
assert response.messages[0].contents[0].arguments == '{"arg1": "value1"}'
assert response.messages[0].contents[0].call_id == "1"
assert response.messages[1].role == Role.TOOL
assert isinstance(response.messages[1].contents[0], FunctionResultContent)
assert response.messages[1].contents[0].call_id == "1"
assert response.messages[1].contents[0].result == "Processed value1"
assert response.messages[2].role == Role.ASSISTANT
assert response.messages[2].text == "done"
async def test_base_client_with_function_calling_resets(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="2", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
assert exec_counter == 2
assert len(response.messages) == 5
assert response.messages[0].role == Role.ASSISTANT
assert response.messages[1].role == Role.TOOL
assert response.messages[2].role == Role.ASSISTANT
assert response.messages[3].role == Role.TOOL
assert response.messages[4].role == Role.ASSISTANT
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert isinstance(response.messages[1].contents[0], FunctionResultContent)
assert isinstance(response.messages[2].contents[0], FunctionCallContent)
assert isinstance(response.messages[3].contents[0], FunctionResultContent)
async def test_base_client_with_streaming_function_calling(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')],
role="assistant",
),
],
[
ChatResponseUpdate(
contents=[TextContent(text="Processed value1")],
role="assistant",
)
],
]
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]):
updates.append(update)
assert len(updates) == 4 # two updates with the function call, the function result and the final text
assert updates[0].contents[0].call_id == "1"
assert updates[1].contents[0].call_id == "1"
assert updates[2].contents[0].call_id == "1"
assert updates[3].text == "Processed value1"
assert exec_counter == 1
@@ -0,0 +1,433 @@
# Copyright (c) Microsoft. All rights reserved.
import pytest
from agent_framework import (
ChatClientProtocol,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
ai_function,
)
async def test_base_client_with_function_calling(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
assert exec_counter == 1
assert len(response.messages) == 3
assert response.messages[0].role == Role.ASSISTANT
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].name == "test_function"
assert response.messages[0].contents[0].arguments == '{"arg1": "value1"}'
assert response.messages[0].contents[0].call_id == "1"
assert response.messages[1].role == Role.TOOL
assert isinstance(response.messages[1].contents[0], FunctionResultContent)
assert response.messages[1].contents[0].call_id == "1"
assert response.messages[1].contents[0].result == "Processed value1"
assert response.messages[2].role == Role.ASSISTANT
assert response.messages[2].text == "done"
async def test_base_client_with_function_calling_resets(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="2", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
assert exec_counter == 2
assert len(response.messages) == 5
assert response.messages[0].role == Role.ASSISTANT
assert response.messages[1].role == Role.TOOL
assert response.messages[2].role == Role.ASSISTANT
assert response.messages[3].role == Role.TOOL
assert response.messages[4].role == Role.ASSISTANT
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert isinstance(response.messages[1].contents[0], FunctionResultContent)
assert isinstance(response.messages[2].contents[0], FunctionCallContent)
assert isinstance(response.messages[3].contents[0], FunctionResultContent)
async def test_base_client_with_streaming_function_calling(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')],
role="assistant",
),
],
[
ChatResponseUpdate(
contents=[TextContent(text="Processed value1")],
role="assistant",
)
],
]
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]):
updates.append(update)
assert len(updates) == 4 # two updates with the function call, the function result and the final text
assert updates[0].contents[0].call_id == "1"
assert updates[1].contents[0].call_id == "1"
assert updates[2].contents[0].call_id == "1"
assert updates[3].text == "Processed value1"
assert exec_counter == 1
@pytest.mark.parametrize(
"approval_required,num_functions",
[
pytest.param(False, 1, id="single function without approval"),
pytest.param(True, 1, id="single function with approval"),
pytest.param("mixed", 2, id="two functions with mixed approval"),
],
)
@pytest.mark.parametrize(
"thread_type",
[
pytest.param(None, id="no thread"),
pytest.param("local", id="local thread"),
pytest.param("service", id="service thread"),
],
)
@pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"])
async def test_function_invocation_scenarios(
chat_client_base: ChatClientProtocol,
streaming: bool,
thread_type: str | None,
approval_required: bool | str,
num_functions: int,
):
"""Comprehensive test for function invocation scenarios.
This test covers:
- Single function without approval: 3 messages (call, result, final)
- Single function with approval: 2 messages (call, approval request)
- Two functions with mixed approval: varies based on approval flow
- All scenarios tested with both streaming and non-streaming
- Thread scenarios: no thread, local thread (in-memory), and service thread (conversation_id)
"""
exec_counter = 0
# Setup thread based on parameters
conversation_id = None
if thread_type == "service":
# Simulate a service-side thread with conversation_id
conversation_id = "test-thread-123"
@ai_function(name="no_approval_func")
def func_no_approval(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
@ai_function(name="approval_func", approval_mode="always_require")
def func_with_approval(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Approved {arg1}"
# Setup tools and responses based on the scenario
if num_functions == 1:
tools = [func_with_approval if approval_required else func_no_approval]
function_name = "approval_func" if approval_required else "no_approval_func"
# Single function call content
func_call = FunctionCallContent(call_id="1", name=function_name, arguments='{"arg1": "value1"}')
completion = ChatMessage(role="assistant", text="done")
chat_client_base.run_responses = [
ChatResponse(messages=ChatMessage(role="assistant", contents=[func_call]))
] + ([] if approval_required else [ChatResponse(messages=completion)])
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name=function_name, arguments='{"arg1":')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name=function_name, arguments='"value1"}')],
role="assistant",
),
]
] + ([] if approval_required else [[ChatResponseUpdate(contents=[TextContent(text="done")], role="assistant")]])
else: # num_functions == 2
tools = [func_no_approval, func_with_approval]
# Two function calls content
func_calls = [
FunctionCallContent(call_id="1", name="no_approval_func", arguments='{"arg1": "value1"}'),
FunctionCallContent(call_id="2", name="approval_func", arguments='{"arg1": "value2"}'),
]
chat_client_base.run_responses = [ChatResponse(messages=ChatMessage(role="assistant", contents=func_calls))]
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(contents=[func_calls[0]], role="assistant"),
ChatResponseUpdate(contents=[func_calls[1]], role="assistant"),
]
]
# Execute the test
chat_options = ChatOptions(tool_choice="auto", tools=tools)
if thread_type == "service":
# For service threads, we need to pass conversation_id via ChatOptions
chat_options.store = True
chat_options.conversation_id = conversation_id
if not streaming:
response = await chat_client_base.get_response("hello", chat_options=chat_options)
messages = response.messages
else:
updates = []
async for update in chat_client_base.get_streaming_response("hello", chat_options=chat_options):
updates.append(update)
messages = updates
# Service threads have different message management behavior (server-side storage)
# so we skip detailed message assertions for those scenarios
if thread_type == "service":
# Just verify the function was executed or not based on approval
if not approval_required or approval_required == "mixed":
# For service threads, the execution counter check is still valid
pass
return
# Verify based on scenario (for no thread and local thread cases)
if num_functions == 1:
if approval_required:
# Single function with approval: call + approval request
if not streaming:
assert len(messages) == 2
assert isinstance(messages[0].contents[0], FunctionCallContent)
assert isinstance(messages[1].contents[0], FunctionApprovalRequestContent)
assert messages[1].contents[0].function_call.name == "approval_func"
assert exec_counter == 0 # Function not executed yet
else:
# Streaming: 2 function call chunks + 1 approval request
assert len(messages) == 3
assert isinstance(messages[0].contents[0], FunctionCallContent)
assert isinstance(messages[1].contents[0], FunctionCallContent)
assert isinstance(messages[2].contents[0], FunctionApprovalRequestContent)
assert messages[2].contents[0].function_call.name == "approval_func"
assert exec_counter == 0 # Function not executed yet
else:
# Single function without approval: call + result + final
if not streaming:
assert len(messages) == 3
assert isinstance(messages[0].contents[0], FunctionCallContent)
assert isinstance(messages[1].contents[0], FunctionResultContent)
assert messages[1].contents[0].result == "Processed value1"
assert messages[2].role == Role.ASSISTANT
assert messages[2].text == "done"
assert exec_counter == 1
else:
# Streaming has: 2 function call updates + 1 result update + 1 final update
assert len(messages) == 4
assert isinstance(messages[0].contents[0], FunctionCallContent)
assert isinstance(messages[1].contents[0], FunctionCallContent)
assert isinstance(messages[2].contents[0], FunctionResultContent)
assert messages[3].text == "done"
assert exec_counter == 1
else: # num_functions == 2
# Two functions with mixed approval
if not streaming:
# Mixed: first message has both calls, second has approval requests for both
# (because when one requires approval, all are batched for approval)
assert len(messages) == 2
assert len(messages[0].contents) == 2 # Both function calls
assert isinstance(messages[0].contents[0], FunctionCallContent)
assert isinstance(messages[0].contents[1], FunctionCallContent)
# Both should result in approval requests
assert len(messages[1].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in messages[1].contents)
assert exec_counter == 0 # Neither function executed yet
else:
# Streaming: 2 function call updates + 1 approval request with 2 contents
assert len(messages) == 3
assert isinstance(messages[0].contents[0], FunctionCallContent)
assert isinstance(messages[1].contents[0], FunctionCallContent)
# The approval request message contains both approval requests
assert len(messages[2].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in messages[2].contents)
assert exec_counter == 0 # Neither function executed yet
async def test_rejected_approval(chat_client_base: ChatClientProtocol):
"""Test that rejecting an approval alongside an approved one is handled correctly."""
from agent_framework import FunctionApprovalResponseContent
exec_counter_approved = 0
exec_counter_rejected = 0
@ai_function(name="approved_func", approval_mode="always_require")
def func_approved(arg1: str) -> str:
nonlocal exec_counter_approved
exec_counter_approved += 1
return f"Approved {arg1}"
@ai_function(name="rejected_func", approval_mode="always_require")
def func_rejected(arg1: str) -> str:
nonlocal exec_counter_rejected
exec_counter_rejected += 1
return f"Rejected {arg1}"
# Setup: two function calls that require approval
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="1", name="approved_func", arguments='{"arg1": "value1"}'),
FunctionCallContent(call_id="2", name="rejected_func", arguments='{"arg1": "value2"}'),
],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
# Get the response with approval requests
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_approved, func_rejected])
assert len(response.messages) == 2
assert len(response.messages[1].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in response.messages[1].contents)
# Approve one and reject the other
approval_req_1 = response.messages[1].contents[0]
approval_req_2 = response.messages[1].contents[1]
approved_response = FunctionApprovalResponseContent(
id=approval_req_1.id,
function_call=approval_req_1.function_call,
approved=True,
)
rejected_response = FunctionApprovalResponseContent(
id=approval_req_2.id,
function_call=approval_req_2.function_call,
approved=False,
)
# Continue conversation with one approved and one rejected
all_messages = response.messages + [ChatMessage(role="user", contents=[approved_response, rejected_response])]
# Call get_response which will process the approvals
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[func_approved, func_rejected])
# Verify the approval/rejection was processed correctly
# Find the results in the input messages (modified in-place)
approved_result = None
rejected_result = None
for msg in all_messages:
for content in msg.contents:
if isinstance(content, FunctionResultContent):
if content.call_id == "1":
approved_result = content
elif content.call_id == "2":
rejected_result = content
# The approved function should have been executed and have a result
assert approved_result is not None, "Should have found result for approved function"
assert approved_result.result == "Approved value1"
assert exec_counter_approved == 1
# The rejected function should have a "not approved" result and NOT have been executed
assert rejected_result is not None, "Should have found result for rejected function"
assert rejected_result.result == "Error: Tool call invocation was rejected by user."
assert exec_counter_rejected == 0
async def test_max_iterations_limit(chat_client_base: ChatClientProtocol):
"""Test that MAX_ITERATIONS in additional_properties limits function call loops."""
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
# Set up multiple function call responses to create a loop
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="2", name="test_function", arguments='{"arg1": "value2"}')],
)
),
# Failsafe response when tool_choice is set to "none"
ChatResponse(messages=ChatMessage(role="assistant", text="giving up on tools")),
]
# Set max_iterations to 1 in additional_properties
chat_client_base.additional_properties = {"max_iterations": 1}
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
# With max_iterations=1, we should:
# 1. Execute first function call (exec_counter=1)
# 2. Try to make second call but hit iteration limit
# 3. Fall back to asking for a plain answer with tool_choice="none"
assert exec_counter == 1 # Only first function executed
assert response.messages[-1].text == "I broke out of the function invocation loop..." # Failsafe response
@@ -616,3 +616,507 @@ def test_hosted_mcp_tool_with_dict_of_allowed_tools():
url="https://mcp.example",
allowed_tools={"toolA": "Tool A", "toolC": "Tool C"},
)
# region Approval Flow Tests
@pytest.fixture
def mock_chat_client():
"""Create a mock chat client for testing approval flows."""
from agent_framework import ChatMessage, ChatResponse, ChatResponseUpdate
class MockChatClient:
def __init__(self):
self.call_count = 0
self.responses = []
async def get_response(self, messages, **kwargs):
"""Mock get_response that returns predefined responses."""
if self.call_count < len(self.responses):
response = self.responses[self.call_count]
self.call_count += 1
return response
# Default response
return ChatResponse(
messages=[ChatMessage(role="assistant", contents=["Default response"])],
)
async def get_streaming_response(self, messages, **kwargs):
"""Mock get_streaming_response that yields predefined updates."""
if self.call_count < len(self.responses):
response = self.responses[self.call_count]
self.call_count += 1
# Yield updates from the response
for msg in response.messages:
for content in msg.contents:
yield ChatResponseUpdate(contents=[content], role=msg.role)
else:
# Default response
yield ChatResponseUpdate(contents=["Default response"], role="assistant")
return MockChatClient()
@ai_function(name="no_approval_tool", description="Tool that doesn't require approval")
def no_approval_tool(x: int) -> int:
"""A tool that doesn't require approval."""
return x * 2
@ai_function(
name="requires_approval_tool",
description="Tool that requires approval",
approval_mode="always_require",
)
def requires_approval_tool(x: int) -> int:
"""A tool that requires approval."""
return x * 3
async def test_non_streaming_single_function_no_approval():
"""Test non-streaming handler with single function call that doesn't require approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework._tools import _handle_function_calls_response
# Create mock client
mock_client = type("MockClient", (), {})()
# Create responses: first with function call, second with final answer
initial_response = ChatResponse(
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
)
]
)
final_response = ChatResponse(messages=[ChatMessage(role="assistant", contents=["The result is 10"])])
call_count = [0]
responses = [initial_response, final_response]
async def mock_get_response(self, messages, **kwargs):
result = responses[call_count[0]]
call_count[0] += 1
return result
# Wrap the function
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[no_approval_tool])
# Verify: should have 3 messages: function call, function result, final answer
assert len(result.messages) == 3
assert isinstance(result.messages[0].contents[0], FunctionCallContent)
from agent_framework import FunctionResultContent
assert isinstance(result.messages[1].contents[0], FunctionResultContent)
assert result.messages[1].contents[0].result == 10 # 5 * 2
assert result.messages[2].contents[0] == "The result is 10"
async def test_non_streaming_single_function_requires_approval():
"""Test non-streaming handler with single function call that requires approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
# Initial response with function call
initial_response = ChatResponse(
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
)
]
)
call_count = [0]
responses = [initial_response]
async def mock_get_response(self, messages, **kwargs):
result = responses[call_count[0]]
call_count[0] += 1
return result
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[requires_approval_tool])
# Verify: should return 2 messages - function call and approval request
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 2
assert isinstance(result.messages[0].contents[0], FunctionCallContent)
assert isinstance(result.messages[1].contents[0], FunctionApprovalRequestContent)
assert result.messages[1].contents[0].function_call.name == "requires_approval_tool"
async def test_non_streaming_two_functions_both_no_approval():
"""Test non-streaming handler with two function calls, neither requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
# Initial response with two function calls to the same tool
initial_response = ChatResponse(
messages=[
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}'),
],
)
]
)
final_response = ChatResponse(
messages=[ChatMessage(role="assistant", contents=["Both tools executed successfully"])]
)
call_count = [0]
responses = [initial_response, final_response]
async def mock_get_response(self, messages, **kwargs):
result = responses[call_count[0]]
call_count[0] += 1
return result
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[no_approval_tool])
# Verify: should have function calls, results, and final answer
from agent_framework import FunctionResultContent
assert len(result.messages) == 3
# First message has both function calls
assert len(result.messages[0].contents) == 2
# Second message has both results
assert len(result.messages[1].contents) == 2
assert all(isinstance(c, FunctionResultContent) for c in result.messages[1].contents)
assert result.messages[1].contents[0].result == 10 # 5 * 2
assert result.messages[1].contents[1].result == 6 # 3 * 2
async def test_non_streaming_two_functions_both_require_approval():
"""Test non-streaming handler with two function calls, both requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
# Initial response with two function calls to the same tool
initial_response = ChatResponse(
messages=[
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
],
)
]
)
call_count = [0]
responses = [initial_response]
async def mock_get_response(self, messages, **kwargs):
result = responses[call_count[0]]
call_count[0] += 1
return result
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[requires_approval_tool])
# Verify: should return 2 messages - function calls and approval requests
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 2
assert len(result.messages[0].contents) == 2 # Both function calls
assert all(isinstance(c, FunctionCallContent) for c in result.messages[0].contents)
assert len(result.messages[1].contents) == 2 # Both approval requests
assert all(isinstance(c, FunctionApprovalRequestContent) for c in result.messages[1].contents)
assert result.messages[1].contents[0].function_call.name == "requires_approval_tool"
assert result.messages[1].contents[1].function_call.name == "requires_approval_tool"
async def test_non_streaming_two_functions_mixed_approval():
"""Test non-streaming handler with two function calls, one requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
# Initial response with two function calls
initial_response = ChatResponse(
messages=[
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
],
)
]
)
call_count = [0]
responses = [initial_response]
async def mock_get_response(self, messages, **kwargs):
result = responses[call_count[0]]
call_count[0] += 1
return result
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[no_approval_tool, requires_approval_tool])
# Verify: should return approval requests for both (when one needs approval, all are sent for approval)
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 2
assert len(result.messages[0].contents) == 2 # Both function calls
assert len(result.messages[1].contents) == 2 # Both approval requests
assert all(isinstance(c, FunctionApprovalRequestContent) for c in result.messages[1].contents)
async def test_streaming_single_function_no_approval():
"""Test streaming handler with single function call that doesn't require approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
# Initial response with function call, then final response after function execution
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
)
]
final_updates = [ChatResponseUpdate(contents=["The result is 10"], role="assistant")]
call_count = [0]
updates_list = [initial_updates, final_updates]
async def mock_get_streaming_response(self, messages, **kwargs):
updates = updates_list[call_count[0]]
call_count[0] += 1
for update in updates:
yield update
wrapped = _handle_function_calls_streaming_response(mock_get_streaming_response)
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[no_approval_tool]):
updates.append(update)
# Verify: should have function call update, tool result update (injected), and final update
from agent_framework import FunctionResultContent, Role
assert len(updates) >= 3
# First update is the function call
assert isinstance(updates[0].contents[0], FunctionCallContent)
# Second update should be the tool result (injected by the wrapper)
assert updates[1].role == Role.TOOL
assert isinstance(updates[1].contents[0], FunctionResultContent)
assert updates[1].contents[0].result == 10 # 5 * 2
# Last update is the final message
assert updates[-1].contents[0] == "The result is 10"
async def test_streaming_single_function_requires_approval():
"""Test streaming handler with single function call that requires approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
# Initial response with function call
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
role="assistant",
)
]
call_count = [0]
updates_list = [initial_updates]
async def mock_get_streaming_response(self, messages, **kwargs):
updates = updates_list[call_count[0]]
call_count[0] += 1
for update in updates:
yield update
wrapped = _handle_function_calls_streaming_response(mock_get_streaming_response)
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[requires_approval_tool]):
updates.append(update)
# Verify: should yield function call and then approval request
from agent_framework import FunctionApprovalRequestContent, Role
assert len(updates) == 2
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert updates[1].role == Role.TOOL
assert isinstance(updates[1].contents[0], FunctionApprovalRequestContent)
async def test_streaming_two_functions_both_no_approval():
"""Test streaming handler with two function calls, neither requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
# Initial response with two function calls to the same tool
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}')],
role="assistant",
),
]
final_updates = [ChatResponseUpdate(contents=["Both tools executed successfully"], role="assistant")]
call_count = [0]
updates_list = [initial_updates, final_updates]
async def mock_get_streaming_response(self, messages, **kwargs):
updates = updates_list[call_count[0]]
call_count[0] += 1
for update in updates:
yield update
wrapped = _handle_function_calls_streaming_response(mock_get_streaming_response)
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[no_approval_tool]):
updates.append(update)
# Verify: should have both function calls, one tool result update with both results, and final message
from agent_framework import FunctionResultContent, Role
assert len(updates) >= 3
# First two updates are function calls
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
# Should have a tool result update with both results
tool_updates = [u for u in updates if u.role == Role.TOOL]
assert len(tool_updates) == 1
assert len(tool_updates[0].contents) == 2
assert all(isinstance(c, FunctionResultContent) for c in tool_updates[0].contents)
async def test_streaming_two_functions_both_require_approval():
"""Test streaming handler with two function calls, both requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
# Initial response with two function calls to the same tool
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')],
role="assistant",
),
]
call_count = [0]
updates_list = [initial_updates]
async def mock_get_streaming_response(self, messages, **kwargs):
updates = updates_list[call_count[0]]
call_count[0] += 1
for update in updates:
yield update
wrapped = _handle_function_calls_streaming_response(mock_get_streaming_response)
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[requires_approval_tool]):
updates.append(update)
# Verify: should yield both function calls and then approval requests
from agent_framework import FunctionApprovalRequestContent, Role
assert len(updates) == 3
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
# Tool update with both approval requests
assert updates[2].role == Role.TOOL
assert len(updates[2].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in updates[2].contents)
async def test_streaming_two_functions_mixed_approval():
"""Test streaming handler with two function calls, one requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
# Initial response with two function calls
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')],
role="assistant",
),
]
call_count = [0]
updates_list = [initial_updates]
async def mock_get_streaming_response(self, messages, **kwargs):
updates = updates_list[call_count[0]]
call_count[0] += 1
for update in updates:
yield update
wrapped = _handle_function_calls_streaming_response(mock_get_streaming_response)
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[no_approval_tool, requires_approval_tool]):
updates.append(update)
# Verify: should yield both function calls and then approval requests (when one needs approval, all wait)
from agent_framework import FunctionApprovalRequestContent, Role
assert len(updates) == 3
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
# Tool update with both approval requests
assert updates[2].role == Role.TOOL
assert len(updates[2].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in updates[2].contents)
@@ -22,11 +22,11 @@ from agent_framework import (
TextContent,
ToolProtocol,
ai_function,
prepare_function_call_results,
)
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.openai import OpenAIChatClient
from agent_framework.openai._exceptions import OpenAIContentFilterException
from agent_framework.openai._shared import prepare_function_call_results
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
@@ -782,7 +782,7 @@ def test_create_streaming_response_content_with_mcp_approval_request() -> None:
@pytest.mark.parametrize("enable_otel", [False], indirect=True)
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
async def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
"""End-to-end mocked test:
model issues an mcp_approval_request, user approves, client sends mcp_approval_response.
"""
@@ -824,7 +824,7 @@ def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
# Patch the create call to return the two mocked responses in sequence
with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create:
# First call: get the approval request
response = asyncio.run(client.get_response(messages=[ChatMessage(role="user", text="Trigger approval")]))
response = await client.get_response(messages=[ChatMessage(role="user", text="Trigger approval")])
assert isinstance(response.messages[0].contents[0], FunctionApprovalRequestContent)
req = response.messages[0].contents[0]
assert req.id == "approval-1"
@@ -832,7 +832,7 @@ def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
# Build a user approval and send it (include required function_call)
approval = FunctionApprovalResponseContent(approved=True, id=req.id, function_call=req.function_call)
approval_message = ChatMessage(role="user", contents=[approval])
_ = asyncio.run(client.get_response(messages=[approval_message]))
_ = await client.get_response(messages=[approval_message])
# Ensure two calls were made and the second includes the mcp_approval_response
assert mock_create.call_count == 2