mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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>
This commit is contained in:
committed by
GitHub
Unverified
parent
c79f886dc3
commit
df29af611c
@@ -22,6 +22,7 @@ injection, and dynamic (progressive) tool exposure.
|
||||
|------|--------------|
|
||||
| [`function_tool_with_approval.py`](function_tool_with_approval.py) | Requiring human approval before a tool runs. |
|
||||
| [`function_tool_with_approval_and_sessions.py`](function_tool_with_approval_and_sessions.py) | Tool approvals combined with sessions. |
|
||||
| [`tool_approval_middleware.py`](tool_approval_middleware.py) | Session-backed approval coordination, mixed-batch approvals, and "always approve" rules. |
|
||||
| [`function_invocation_configuration.py`](function_invocation_configuration.py) | Configuring function-invocation settings (e.g. max iterations). |
|
||||
| [`control_total_tool_executions.py`](control_total_tool_executions.py) | All the ways to cap how many times tools run. |
|
||||
| [`function_tool_with_max_invocations.py`](function_tool_with_max_invocations.py) | Limiting the number of invocations per tool. |
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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.
|
||||
"""
|
||||
Reference in New Issue
Block a user