Human approval before a refund
A tool that moves money should wait for a person. requires_approval ends the run with the pending call, and a second run carries the decision.
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, ModelResponse, TextPart, ToolCallPart, ToolDenied
from pydantic_ai.models.function import FunctionModel
def refunder(messages, info):
last = messages[-1].parts[-1]
if last.part_kind == "user-prompt":
return ModelResponse(parts=[ToolCallPart("refund_order", {"order_id": "A-1002", "amount": 120.0})])
return ModelResponse(parts=[TextPart(last.content)])
agent = Agent(FunctionModel(refunder), output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def refund_order(order_id: str, amount: float) -> str:
"""Refund an order."""
return f"Refunded {amount:.2f} euros on {order_id}."result = agent.run_sync("Refund my order A-1002")
print(type(result.output).__name__)
for call in result.output.approvals:
print(call.tool_name, call.args)requires_approval=True makes the tool deferred: when the model calls it, the function does not run. The run ends instead, and its output is a DeferredToolRequests listing the calls waiting for approval. That is why DeferredToolRequests is in output_type, next to str for normal answers.
Between the two runs, your app can take as long as it needs: show the call to a manager, store the messages, and come back tomorrow.
Approve
decision = DeferredToolResults(approvals={call.tool_call_id: True})
final = agent.run_sync(message_history=result.all_messages(), deferred_tool_results=decision)
print(final.output)The second run has no new prompt. It gets the first run's messages and the decisions, keyed by tool_call_id. Approved calls run now, and the model continues with their results.
Deny
decision = DeferredToolResults(approvals={call.tool_call_id: ToolDenied("A manager must approve refunds over 100 euros.")})
final = agent.run_sync(message_history=result.all_messages(), deferred_tool_results=decision)
print(final.output)ToolDenied sends your message to the model as the tool's result, so it can tell the customer why. The refund function never ran.
Approval only when it matters
The same agent, with ApprovalRequired and RunContext added to the imports, and a refund tool that decides for itself:
agent = Agent(FunctionModel(refunder), output_type=[str, DeferredToolRequests])
@agent.tool
def refund_order(ctx: RunContext, order_id: str, amount: float) -> str:
"""Refund an order."""
if amount > 50 and not ctx.tool_call_approved:
raise ApprovalRequired(metadata={"reason": "over 50 euros"})
return f"Refunded {amount:.2f} euros on {order_id}."result = agent.run_sync("Refund my order A-1002")
call = result.output.approvals[0]
print(result.output.metadata[call.tool_call_id])
decision = DeferredToolResults(approvals={call.tool_call_id: True})
print(agent.run_sync(message_history=result.all_messages(), deferred_tool_results=decision).output)Raising ApprovalRequired inside a tool asks for approval only when your rule says so. metadata travels with the request, keyed by call id, for the person deciding. On the approved second run the tool runs again with ctx.tool_call_approved set to True, so the same check lets it through.
- Approve one call and deny another in the same
DeferredToolResults, with a model that asks for two refunds. - Change the refund to 30 euros in
refunderand run the conditional agent. - Save
result.all_messages_json()between the two runs and load it back first, as in lesson 15.
You understood something today that you didn't yesterday.