# 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

- **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 calls `Runner.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](/guides/langgraph-interrupt-resume-twice/)).

The [OpenAI Agents SDK docs](https://openai.github.io/openai-agents-python/human_in_the_loop/) 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.

```python
# 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
```

```python
# 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:

```text
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: [OpenAI Agents SDK approval + resume template](/templates/openai-agents-approval-resume/).
- Pro: [Idempotent approved side effects (OpenAI Agents SDK)](/templates/openai-agents-idempotent-approval/) and [Idempotent approval: no double charges on resume (LangGraph)](/templates/langgraph-idempotent-approval/), covering duplicate resume and whole-run retry, in [Signal Pro](/pricing/).
- Done for you: the [$79 Agent Approval-Flow Fix](/fix/). We repair approval and resume in your agent and deliver a tested patch.
