The human clicked approve, the charge went through, and then it went through again. In agent frameworks with pause and resume (OpenAI Agents SDK, LangGraph, ADK, Vercel AI SDK), this usually isn't the model calling the tool twice. It's your app resuming the same paused run twice. The code below was run on openai-agents 0.24.0 (Python 3.13), Oct 16, 2026.
Where the duplicate comes from
- Duplicate approval delivery: a double-click, a retried HTTP request or webhook, or two workers picking up the same job. Each one loads the same saved
RunState, approves, and callsRunner.run(agent, state). - Retry after a crash: the tool ran, the process died before the new state was saved, and the retry runs it again.
- Framework replay: in LangGraph, code before
interrupt()re-runs on resume (our LangGraph guide).
The OpenAI Agents SDK docs say to coordinate consumption of each pending request "so concurrent or replayed submissions cannot resume the same snapshot twice."
Reproduce it, then fix it
Offline: the scripted model replaces OpenAI, and provider_charge stands in for a payment API that honors idempotency keys.
# scripted.py
# Offline stand-in for the OpenAI model. Everything else is real SDK code.
import json
from agents import Model, ModelResponse, Usage
from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText
class ScriptedModel(Model):
"""Turn 1: emit the given tool calls. After tool outputs arrive: reply with text."""
def __init__(self, calls):
self.calls = calls
async def get_response(self, system_instructions, input, model_settings, tools,
output_schema, handoffs, tracing, **kw):
items = input if isinstance(input, list) else []
outs = [i for i in items if isinstance(i, dict) and i.get("type") == "function_call_output"]
if not outs:
out = [ResponseFunctionToolCall(type="function_call", id=f"fc_{n}", call_id=f"call_{n}",
name=name, arguments=json.dumps(args))
for n, (name, args) in enumerate(self.calls)]
else:
text = " | ".join(f"{o['call_id']}={o['output']}" for o in outs)
out = [ResponseOutputMessage(type="message", id="msg_1", role="assistant", status="completed",
content=[ResponseOutputText(type="output_text", text=text, annotations=[])])]
return ModelResponse(output=out, usage=Usage(), response_id=None)
def stream_response(self, *a, **kw):
raise NotImplementedError
# guide_b.py
import asyncio
from agents import Agent, Runner, RunConfig, RunState, function_tool
from agents.tool_context import ToolContext
from scripted import ScriptedModel
CHARGES = [] # stands in for a payment provider
LEDGER = {} # stands in for a table with a UNIQUE idempotency_key column
def provider_charge(amount, idempotency_key=None):
if idempotency_key and idempotency_key in LEDGER:
return LEDGER[idempotency_key] # replay: return the original result
CHARGES.append(amount)
cid = f"ch_{len(CHARGES)}"
if idempotency_key:
LEDGER[idempotency_key] = cid
return cid
@function_tool(needs_approval=True)
def charge_naive(amount: int) -> str:
"""Charge the customer."""
return provider_charge(amount)
@function_tool(needs_approval=True)
def charge_safe(ctx: ToolContext, amount: int) -> str:
"""Charge the customer."""
key = f"{ctx.context['run_id']}:{ctx.tool_call_id}"
return provider_charge(amount, idempotency_key=key)
def make_agent(tool):
return Agent(name="Billing", instructions="bill",
model=ScriptedModel([(tool.name, {"amount": 79})]), tools=[tool])
async def approve_and_resume(tool, saved):
agent = make_agent(tool)
state = await RunState.from_string(agent, saved)
for item in state.get_interruptions():
state.approve(item)
r = await Runner.run(agent, state, run_config=RunConfig(tracing_disabled=True))
return r.final_output
async def scenario(tool):
CHARGES.clear(); LEDGER.clear()
r = await Runner.run(make_agent(tool), "charge", context={"run_id": "run_42"},
run_config=RunConfig(tracing_disabled=True))
saved = r.to_state().to_string()
# The same approval is delivered twice (double-click, webhook retry, worker retry).
print(" resume 1:", await approve_and_resume(tool, saved))
print(" resume 2:", await approve_and_resume(tool, saved))
print(" CHARGES:", CHARGES)
async def main():
print("naive:"); await scenario(charge_naive)
print("idempotent:"); await scenario(charge_safe)
asyncio.run(main())
Actual output:
naive:
resume 1: call_0=ch_1
resume 2: call_0=ch_2
CHARGES: [79, 79]
idempotent:
resume 1: call_0=ch_1
resume 2: call_0=ch_1
CHARGES: [79]
The naive tool charged twice for one approval. The idempotent tool returned the original ch_1 on the second resume and charged once.
The general pattern (any framework)
- Stable key:
run_id + tool_call_id. The tool call id is fixed inside a saved state, so every replay of that state gets the same key. Put your ownrun_idin the run context. Don't use timestamps or random UUIDs generated inside the tool. - Enforce it where the side effect happens: pass it as the provider's idempotency key, or insert it into a table with a UNIQUE constraint before acting and return the stored result on conflict.
- Consume the approval atomically: for example
UPDATE approvals SET status='consumed' WHERE id=$1 AND status='pending', and resume only if one row changed. This stops most duplicates before the agent runs; the idempotency key catches the rest, such as crash retries. - Return the original result on a repeat, so the model sees the same tool output both times.
What we did not test
No real OpenAI model, payment provider or database was used; the ledger is a Python dict. The crash-retry case relies on the same key logic but wasn't simulated separately.
Get the templates
- Free: OpenAI Agents SDK approval + resume template.
- Pro: Idempotent approved side effects (OpenAI Agents SDK) and Idempotent approval: no double charges on resume (LangGraph), covering duplicate resume and whole-run retry, in Signal Pro.
- Done for you: the $79 Agent Approval-Flow Fix. We repair approval and resume in your agent and deliver a tested patch.