Pydantic AI: crash-safe tools and deferred tool approvals
Pydantic AI continues an approved run as a new run with the saved message history. How to keep the tool from acting twice if the process stops in between.
How Pydantic AI continues after an approval
With requires_approval=True, a run ends with a DeferredToolRequests output. You build a
DeferredToolResults with the decisions and start a new run, passing it "alongside the
original run's message history" (Pydantic AI docs). Everything depends on which message history you pass: if
the process stopped after the tool ran but before you stored the new messages, the next run starts from the
older history, as with any resume from saved state, and the approved tool can be called again.
Once-only tools with Pydantic AI
The adapter uses the run's conversation_id as the run id. Pydantic AI keeps it when you continue
with the saved message history, or you can pass conversation_id= yourself. ctx is left out
of the key.
from pydantic_ai import Agent, RunContext
import call4agents as c4a
from call4agents.adapters import pydantic_ai as c4a_pai
c4a.init()
agent = Agent("openai:gpt-4o")
@agent.tool
@c4a_pai.effect # once per conversation, tool and exact arguments
def send_email(ctx: RunContext[None], to: str, body: str) -> str:
return mailer.send(to=to, body=body)
agent.run_sync("email bob", conversation_id="order-42")
agent.run_sync("email bob", conversation_id="order-42") # retry / resume: no second email
pip install call4agents pydantic-ai
python -m call4agents.examples.pydantic_ai_tool # prints {'emails_sent': 1}
Without a library
Store the new message history before doing anything else after the run, and pass an idempotency key that the provider enforces. Without one, treat an approved call that may have run as unknown, not as safe to replay: a check that finds no charge is not proof, because reads can lag and the first request can still settle (thanks to the discussion in openai-agents-python #5409).
A good key is already there: on resume the tool gets the same ctx.tool_call_id as the first time,
also when Pydantic AI generated the id itself. Passed as the idempotency key to an API that drops repeated keys,
it stopped the second payment in each of our 3 runs (pydantic-ai 2.59.0). The tool body itself still ran twice,
so anything in it that the key does not cover, such as an email, still happens twice. Thanks to
@sattyamj-attri, who pointed this out in
pydantic-ai issue #10926.
@agent.tool(requires_approval=True)
def pay(ctx: RunContext[None], amount: int, to: str) -> str:
return payments.charge(amount, to, idempotency_key=ctx.tool_call_id)