agent_signal

Guide

Duplicate tool calls after resume: making agent tool calls idempotent

By Agent Signal · Published 2026-10-16 · Updated 2026-10-16

Short answer

Within one resume the OpenAI Agents SDK runs an approved tool once, but the same saved RunState can be resumed twice (double-clicked approve button, webhook retry, worker retry after a crash), and each resume runs the tool again. Make the side effect idempotent: build a key from a stable run id plus the tool call id (ToolContext.tool_call_id), pass it as the provider's idempotency key or store it in a UNIQUE column, and return the stored result on a repeat. Also mark the stored approval as consumed atomically before resuming.

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

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)

  1. 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 own run_id in the run context. Don't use timestamps or random UUIDs generated inside the tool.
  2. 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.
  3. 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.
  4. 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 starter pack

The approval-flow starter pack

Tested human-in-the-loop approval starters for LangGraph, OpenAI Agents SDK, Vercel AI SDK and Google ADK, plus the weekly Agent Signal newsletter. Free.

Get tested agent templates weekly