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
@@ -0,0 +1,102 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from random import randrange
from typing import TYPE_CHECKING, Annotated, Any
from agent_framework import ChatAgent, ai_function
from agent_framework.openai import OpenAIResponsesClient
if TYPE_CHECKING:
from agent_framework import AgentProtocol
"""
Demonstration of a tool with approvals.
This sample demonstrates using AI functions with user approval workflows.
It shows how to handle function call approvals without using threads.
"""
conditions = ["sunny", "cloudy", "raining", "snowing", "clear"]
@ai_function
def get_weather(location: Annotated[str, "The city and state, e.g. San Francisco, CA"]) -> str:
"""Get the current weather for a given location."""
# Simulate weather data
return f"The weather in {location} is {conditions[randrange(0, len(conditions))]} and {randrange(-10, 30)}°C."
# Define a simple weather tool that requires approval
@ai_function(approval_mode="always_require")
def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Francisco, CA"]) -> str:
"""Get the current weather for a given location."""
# Simulate weather data
return (
f"The weather in {location} is {conditions[randrange(0, len(conditions))]} and {randrange(-10, 30)}°C, "
"with a humidity of 88%. "
f"Tomorrow will be {conditions[randrange(0, len(conditions))]} with a high of {randrange(-10, 30)}°C."
)
async def handle_approvals(query: str, agent: "AgentProtocol"):
"""Handle function call approvals.
When we don't have a thread, we need to ensure we include the original query,
the approval request, and the approval response in each iteration.
"""
from agent_framework import ChatMessage
result = await agent.run(query)
while len(result.user_input_requests) > 0:
# Start with the original query
new_inputs: list[Any] = [query]
for user_input_needed in result.user_input_requests:
print(
f"\nUser Input Request for function from {agent.name}:"
f"\n Function: {user_input_needed.function_call.name}"
f"\n Arguments: {user_input_needed.function_call.arguments}"
)
# Add the assistant message with the approval request
new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed]))
# Get user approval
user_approval = input("\nApprove function call? (y/n): ")
# Add the user's approval response
new_inputs.append(
ChatMessage(role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")])
)
# Run again with all the context
result = await agent.run(new_inputs)
return result
async def run_weather_agent_with_approval() -> None:
"""Example showing AI function with approval requirement."""
print("\n=== Weather Agent WITH Approval Required ===\n")
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
name="WeatherAgent",
instructions=("You are a helpful weather assistant. Use the get_weather tool to provide weather information."),
tools=[get_weather, get_weather_detail],
) as agent:
query2 = "Can you give me an update of the weather in LA and Portland and detailed weather for Seattle?"
print(f"User: {query2}")
result2 = await handle_approvals(query2, agent)
print(f"\n{agent.name}: {result2}\n")
async def main() -> None:
print("=== Demonstration of a tool with approvals ===\n")
await run_weather_agent_with_approval()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,103 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Annotated
from agent_framework import FunctionCallContent, FunctionResultContent
from agent_framework.openai import OpenAIResponsesClient
"""
Tool exceptions handled by returning the error for the agent to recover from.
Shows how a tool that throws an exception creates gracefull recovery and can keep going.
The LLM decides whether to retry the call or to respond with something else, based on the exception.
"""
def greet(name: Annotated[str, "Name to greet"]) -> str:
"""Greet someone."""
return f"Hello, {name}!"
# we trick the AI into calling this function with 0 as denominator to trigger the exception
def safe_divide(
a: Annotated[int, "Numerator"],
b: Annotated[int, "Denominator"],
) -> str:
"""Divide two numbers can be used with 0 as denominator."""
try:
result = a / b # Will raise ZeroDivisionError
except ZeroDivisionError as exc:
print(f" Tool failed: with error: {exc}")
raise
return f"{a} / {b} = {result}"
async def main():
# tools = Tools()
agent = OpenAIResponsesClient().create_agent(
name="ToolAgent",
instructions="Use the provided tools.",
tools=[greet, safe_divide],
)
thread = agent.get_new_thread()
print("=" * 60)
print("Step 1: Call divide(10, 0) - tool raises exception")
response = await agent.run("Divide 10 by 0", thread=thread)
print(f"Response: {response.text}")
print("=" * 60)
print("Step 2: Call greet('Bob') - conversation can keep going.")
response = await agent.run("Greet Bob", thread=thread)
print(f"Response: {response.text}")
print("=" * 60)
print("Replay the conversation:")
assert thread.message_store
assert thread.message_store.list_messages
for idx, msg in enumerate(await thread.message_store.list_messages()):
if msg.text:
print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ")
for content in msg.contents:
if isinstance(content, FunctionCallContent):
print(
f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}"
)
if isinstance(content, FunctionResultContent):
print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}")
"""
Expected Output:
============================================================
Step 1: Call divide(10, 0) - tool raises exception
Tool failed: with error: division by zero
Response: Division by zero is undefined in standard arithmetic, so 10 ÷ 0 has no meaning.
If youre curious about limits: as x approaches 0 from the positive side, 10/x tends to +∞; from the negative side,
10/x tends to -∞.
If you want a finite result, try dividing by a nonzero number, e.g., 10 ÷ 2 = 5 or 10 ÷ 0.1 = 100. Want me to compute
something else?
============================================================
Step 2: Call greet('Bob') - conversation can keep going.
Response: Hello, Bob!
============================================================
Replay the conversation:
1 user: Divide 10 by 0
2 ToolAgent: calling function: safe_divide with arguments: {"a":10,"b":0}
3 tool: division by zero
4 ToolAgent: Division by zero is undefined in standard arithmetic, so 10 ÷ 0 has no meaning.
If youre curious about limits: as x approaches 0 from the positive side, 10/x tends to +∞; from the negative side,
10/x tends to -∞.
If you want a finite result, try dividing by a nonzero number, e.g., 10 ÷ 2 = 5 or 10 ÷ 0.1 = 100. Want me to compute
something else?
5 user: Greet Bob
6 ToolAgent: calling function: greet with arguments: {"name":"Bob"}
7 tool: Hello, Bob!
8 ToolAgent: Hello, Bob!
"""
if __name__ == "__main__":
asyncio.run(main())