Files
Eduard van Valkenburg df29af611c Python: Add tool approval middleware (#6414)
* Add Python tool approval middleware

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix tool approval restored state handling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Gate hidden approvals on explicit approval responses

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Handle string inputs in approval replay scan

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Cover argument-scoped approval rules

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Refine tool approval state and budgets

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix tool approval PR CI failures

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Revert DevUI Aspire README link change

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-11 17:35:44 +00:00

192 lines
7.7 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Annotated
from agent_framework import (
Agent,
AgentResponse,
AgentSession,
Content,
Message,
ToolApprovalMiddleware,
create_always_approve_tool_response,
create_always_approve_tool_with_arguments_response,
tool,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
"""
This sample demonstrates how a host application can decide which approval
requests may run now, which must be rejected, and which can be remembered for
future runs.
The model may not request every tool on every run. The important part is the
approval mechanism:
1. Tools that are safe to run immediately use ``approval_mode="never_require"``.
2. Sensitive tools use ``approval_mode="always_require"``.
3. ``ToolApprovalMiddleware`` coordinates approval prompts and standing rules.
4. The host turns user policy into ``function_approval_response`` content:
- approve for this request only;
- reject for this request;
- approve and remember the tool for future requests;
- approve and remember the tool only when called again with the same arguments.
5. Heuristic auto-approval rules can approve low-risk function calls before
the user is prompted.
"""
# Load environment variables from .env file
load_dotenv()
@tool(approval_mode="never_require")
def lookup_ticket(ticket_id: Annotated[str, "Support ticket id, for example T-123"]) -> str:
"""Look up a support ticket. This read-only tool runs without approval."""
return f"Ticket {ticket_id}: customer confirmed the issue can be closed."
@tool(approval_mode="always_require")
def close_ticket(
ticket_id: Annotated[str, "Support ticket id, for example T-123"],
resolution: Annotated[str, "Short resolution text"],
) -> str:
"""Close a support ticket."""
return f"Ticket {ticket_id} closed with resolution: {resolution}"
@tool(approval_mode="always_require")
def notify_customer(
ticket_id: Annotated[str, "Support ticket id, for example T-123"],
message: Annotated[str, "Message to send to the customer"],
) -> str:
"""Notify the customer about a ticket update."""
return f"Customer notified for {ticket_id}: {message}"
@tool(approval_mode="always_require")
def add_internal_note(
ticket_id: Annotated[str, "Support ticket id, for example T-123"],
note: Annotated[str, "Internal note text"],
) -> str:
"""Add an internal note to a support ticket."""
return f"Internal note added to {ticket_id}: {note}"
@tool(approval_mode="always_require")
def delete_attachment(
ticket_id: Annotated[str, "Support ticket id, for example T-123"],
attachment_name: Annotated[str, "Attachment file name"],
) -> str:
"""Delete an attachment from a support ticket."""
return f"Deleted {attachment_name} from ticket {ticket_id}."
def auto_approve_low_risk_notes(function_call: Content) -> bool:
"""Heuristic rule: auto-approve short internal notes for the target ticket."""
if function_call.name != "add_internal_note":
return False
arguments = function_call.parse_arguments() or {}
note = str(arguments.get("note", ""))
return arguments.get("ticket_id") == "T-123" and len(note) <= 120
def approval_response_for_user_policy(request: Content) -> Content:
"""Convert user/host policy into an approval response for one tool request."""
function_call = request.function_call
if function_call is None or function_call.name is None:
return request.to_function_approval_response(approved=False)
tool_name = function_call.name
print(f"Approval requested: {tool_name}({function_call.arguments})")
if tool_name in {"close_ticket"}:
print(f"Decision: approve and remember future {tool_name} calls with these exact arguments")
return create_always_approve_tool_with_arguments_response(request)
if tool_name in {"notify_customer"}:
print(f"Decision: approve and remember all future {tool_name} calls")
return create_always_approve_tool_response(request)
if tool_name in {"delete_attachment"}:
print(f"Decision: reject {tool_name} for this run")
return request.to_function_approval_response(approved=False)
print(f"Decision: reject {tool_name}; no policy allowed it")
return request.to_function_approval_response(approved=False)
async def resolve_approval_requests(agent: Agent, response: AgentResponse, session: AgentSession) -> AgentResponse:
"""Resolve approval prompts until the agent returns a regular answer."""
result = response
while result.user_input_requests:
approval_responses = [approval_response_for_user_policy(request) for request in result.user_input_requests]
result = await agent.run(Message(role="user", contents=approval_responses), session=session)
return result
async def main() -> None:
"""Run the tool approval middleware sample."""
# 1. Create a regular chat client.
client = FoundryChatClient(credential=AzureCliCredential())
# 2. Create an agent with sensitive tools and opt-in ToolApprovalMiddleware.
agent = Agent(
client=client,
name="SupportAgent",
instructions=(
"You are a support agent. Use tools when useful. "
"Look up ticket T-123, close it if the customer confirmed, notify the customer, "
"add a short internal note, and do not delete attachments unless the tool is approved."
),
tools=[lookup_ticket, close_ticket, notify_customer, add_internal_note, delete_attachment],
middleware=[ToolApprovalMiddleware(auto_approval_rules=[auto_approve_low_risk_notes])],
)
session = agent.create_session()
# 3. Ask for work that may trigger a mixed batch of safe and sensitive tool calls.
query = (
"Please process ticket T-123: check the ticket, close it as resolved, "
"notify the customer, add a short internal note, and remove debug.log if it is attached."
)
print(f"User: {query}")
result = await agent.run(query, session=session)
# 4. Convert approval requests into approve/reject/always-approve responses.
result = await resolve_approval_requests(agent, result, session)
print(f"Agent: {result.text}")
# 5. Later runs can use remembered approval rules:
# - notify_customer: all future calls to the tool.
# - close_ticket: only future calls with the same arguments.
# - add_internal_note: low-risk matching calls are auto-approved by the heuristic callback.
follow_up = "Send the customer a short follow-up for ticket T-123."
print(f"\nUser: {follow_up}")
result = await agent.run(follow_up, session=session)
result = await resolve_approval_requests(agent, result, session)
print(f"Agent: {result.text}")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
User: Please process ticket T-123: check the ticket, close it as resolved,
notify the customer, add a short internal note, and remove debug.log if it is attached.
Approval requested: close_ticket({"ticket_id": "T-123", "resolution": "resolved"})
Decision: approve and remember future close_ticket calls with these exact arguments
Approval requested: notify_customer({"ticket_id": "T-123", "message": "Your ticket has been resolved."})
Decision: approve and remember all future notify_customer calls
Approval requested: delete_attachment({"ticket_id": "T-123", "attachment_name": "debug.log"})
Decision: reject delete_attachment for this run
Agent: Ticket T-123 was closed, the customer was notified, and a short internal note was added.
I did not delete debug.log.
User: Send the customer a short follow-up for ticket T-123.
Agent: The customer was sent a short follow-up for ticket T-123.
"""