- name
- agentsop-llm-tool-idempotency
- version
- 0.1.0
- description
- Decision protocol for making side-effectful agent tools idempotent — so when an LLM tool call is retried (timeout, framework resume, user re-run, model duplicate emit), the second call is a no-op instead of a double-send. The load-bearing premise: the LM cannot promise it'll call exactly once; the tool must promise the second call is safe. Framework-agnostic — applies to LangGraph node bodies that re-run on resume, MCP tools, OpenAI tool-calling retries, CrewAI delegated tool invocations, and direct HTTP wrappers. Search keywords: duplicate email sent, charged twice, exactly-once, idempotency key, tool called twice, retry side effect, double-send, at-least-once delivery.
- domain
- coder-agent / tool-execution-safety
- audience
- engineers wiring LLM agents whose tools have side effects (email, payment, DB writes, message posting, file creation, API calls)
- trigger_keywords
- ["idempotency key","double send","tool retry","exactly-once","at-least-once","duplicate side effect","node body re-runs on resume","interrupt resumed twice"]
- when_to_use
- ["any agent tool that performs a side effect: send_email, create_record, charge_card, post_message, write_file, publish_event, transfer_funds","LangGraph node containing both an interrupt and a side effect","tool that wraps a non-idempotent third-party API (SendGrid, Twilio, S3 PUT of a generated id)","model-orchestrated workflow where the LM may emit the same tool_call_id twice due to streaming retries or compaction","MCP server exposing tools to a client that may re-invoke on transport failure"]
- when_not_to_use
- ["pure read tools (search, get, list) — already idempotent by HTTP semantics","the side effect is intrinsically commutative (incrementing a counter where duplicates are acceptable — but verify this; usually they aren't)","single-call manual scripts with no retry layer above"]
# LLM Tool Idempotency · SOP
> One-liner: **The LM is at-least-once; the tool must be at-most-once.**
> Every framework that promises "durable execution" still re-runs node bodies
> on resume. Every HTTP client retries on timeout. Every model occasionally
> emits the same tool_call twice. Idempotency belongs in the tool, not in a
> wish.
---
## 1. 何时激活 (Activation Rules)
Activate this skill when **any** of the following triggers fire:
- You're defining a tool whose name contains `send_`, `create_`, `charge_`,
`post_`, `write_`, `publish_`, `transfer_`, `delete_`, `update_`, or
`notify_`.
- The tool wraps a third-party API call (Stripe, SendGrid, Twilio, Slack,
Discord webhook, S3 PUT, payment gateway, internal write API).
- You're inside a LangGraph node that contains an `interrupt(...)` call AND a
side effect in the same function body — the resume re-runs the body from the
top `[langgraph/gotchas]`.
- The tool is invoked through MCP, OpenAI tool-calling, Anthropic tool use,
CrewAI delegation, or any layer where a transport timeout could be
interpreted as "retry" even though the operation succeeded server-side.
- The user reports "the agent sent it twice" / "charge appeared twice" /
"duplicate row" / "got two emails".
- Your test harness records the same `(tool_name, args_hash)` invoked more
than once within a single user turn.
**Do not activate** when the tool is read-only (GET-equivalent), or when the
side effect is genuinely commutative AND verified safe under duplication.
---
## 2. 核心心智模型 (Core Mental Model)
### 2.1 The fundamental asymmetry
```
LM tool-call semantics Tool side-effect semantics
───────────────────── ─────────────────────────
At-least-once delivery Must be at-most-once
(network retry, framework (one charge, one email,
resume, model dup-emit, one record)
user re-prompt)
│ │
└────── gap to bridge ───────────┘
↓
IDEMPOTENCY KEY
(a stable identifier the LM
commits to BEFORE the call,
which the tool dedupes on)
```
**The LM cannot promise it'll call exactly once.** Four independent retry
sources stack here:
1. **Transport retry**: HTTP client (or MCP transport) sees a timeout, the
server actually completed the operation, the client retries. Stripe's
docs call this out as the canonical case `[stripe/idempotency]`.
2. **Framework resume**: LangGraph re-runs the *entire node body* on resume
from an interrupt. Code before the interrupt re-executes on every resume
`[langgraph/gotchas]`. Same applies to Temporal-style workflows on replay.
3. **Model duplicate emission**: Streaming sometimes yields the same
`tool_call_id` twice (rare but documented in OpenAI tool-calling); model
may re-emit on context-compaction round-trips.
4. **User-level retry**: The user clicks "send" twice, or re-runs the agent
after timeout, with the same instructions.
Any one of these turns a single-intent action into multiple side effects
unless the tool itself dedupes.
### 2.2 The promise inversion
Naive design says: "I'll make the LM call the tool exactly once."
Mature design says: "I'll make the tool ignore the second call."
The inversion matters because the LM is in the *control* path; the tool is in
the *execution* path. Execution-path guarantees are the only ones that hold
under failure.
### 2.3 The idempotency key has to come from the agent, not the tool
A common bug: the tool generates a UUID *inside* itself, then dedupes on that
UUID. This breaks because retry creates a *new* UUID. The key has to:
- Be **chosen by the caller** (the agent / orchestrator).
- Be **deterministic for a single logical operation** — same input → same
key on retry.
- Be **persisted into agent state** before the tool call, so a resume picks
up the same key.
Stripe's pattern (the industry reference): client generates an idempotency key
(typically UUIDv4), sends it as `Idempotency-Key: <uuid>` HTTP header. Server
caches the response for 24 hours keyed by that header `[stripe/idempotency]`.
A retry with the same key returns the cached response — no second charge.
### 2.4 Three classes of side effect, three idempotency strategies
| Side-effect class | Examples | Idempotency mechanism |
|---|---|---|
| **Non-replayable external API** | Stripe charge, Twilio SMS, SendGrid email | Caller-supplied idempotency key passed to API (header or body field) |
| **Internal write to a store you own** | `INSERT` into your DB, `PUT` to your S3 bucket, append to your queue | Dedup table keyed on `(operation_id, args_hash)` with unique constraint, OR `INSERT … ON CONFLICT DO NOTHING`, OR content-addressed write |
| **Compensatable operation** (no native idempotency, must undo on duplicate) | Bank wire to a counterparty, physical device actuation | Saga pattern: record intent, perform, compensate-on-duplicate-detection; or a reservation token (two-phase) |
The SOP in §3 walks you through classifying first, then picking the mechanism.
---
## 3. SOP 工作流 (Agentic Protocol)
### Step 1 · Classify the side effect
Ask three questions, in order:
1. **Does the downstream API accept an idempotency key?**
- Yes (Stripe, Square, modern PayPal, AWS SDK with client tokens,
Anthropic Files API): use the native key. Stop here.
- No: continue.
2. **Do I own the store being written to?**
- Yes (my Postgres, my S3 bucket, my Kafka topic): build a dedup layer.
Go to Step 2.
- No: continue.
3. **Is the effect compensatable?**
- Yes (can issue a refund / send a correction): saga / compensation.
- No (irreversible physical action, sent SMS): the only safe option is
**block the second call** — fail-closed dedup table, no fallback.
### Step 2 · Choose the idempotency mechanism
Decision tree:
```
Side effect classified above.
│
├─ Native idempotency-key API (Stripe et al.)
│ → OP-1: pass key in HTTP header / SDK kwarg
│
├─ Internal write you own
│ ├─ Operation has natural content identity (file hash, message dedup id)
│ │ → OP-3: content-addressed write (PUT key = sha256(content))
│ ├─ Single-row insert
│ │ → OP-4: INSERT ... ON CONFLICT (idempotency_key) DO NOTHING RETURNING *
│ └─ Multi-step write
│ → OP-2: dedup table + transaction at the tool boundary
│
└─ Compensatable, non-idempotent external call
→ OP-5: saga (record intent → call → compensate on duplicate)
```
### Step 3 · Decide where the key is generated and how it's named
| Source of key | When to use | Pitfall |
|---|---|---|
| `uuid4()` in agent state, persisted before call | Default for one-shot operations | Lost if state isn't persisted before the side effect |
| `hash(user_id, intent, day)` | Idempotency per-user-per-intent-per-day (e.g., daily digest email) | Too coarse → blocks legitimate second sends |
| `hash(canonicalized_args)` | Content-determined (same email body to same address) | Blocks legitimate "send the same message again later" |
| `hash(thread_id, node_id, run_id)` | LangGraph node-level dedup | Doesn't help across thread resumes that re-enter the same node |
| **Composite**: `hash(thread_id, node_id, attempt_args)` | Recommended for LangGraph node bodies that re-run on resume | One more arg to wire |
**Naming convention**: store the key in agent state as
`{tool_name}_idempotency_key`, persist it **before** the tool call. On
resume, check if state already has a key — if yes, reuse; if no, generate.
### Step 4 · Pass the key through every layer
```
Agent state ──→ Tool wrapper ──→ HTTP client ──→ External API
(uuid) (header) (transport) (server)
```
Verify each layer:
- Agent state stores the key in a serialized, checkpointed field (not in-memory
only).
- Tool wrapper reads from state, not from a fresh `uuid4()` call.
- HTTP client uses the key as `Idempotency-Key` header *and* propagates it on
client-side retries (don't generate a new key per HTTP retry).
- Server caches the response keyed on that header for a documented TTL
(Stripe: 24 h; AWS SQS dedup: 5 min default; design your own to ≥ 1 h).
### Step 5 · Handle the conflict response
A successful idempotent retry returns the **original** response. A *conflict*
(same key, different args) usually returns 4xx — Stripe returns
`HTTP 409 Conflict` with code `idempotency_key_in_use` if the request payload
mismatches `[stripe/idempotency]`. Decision rules:
- **Same key, same args, cached response** → return to LM as if first call;
surface a `was_replay: true` flag for observability.
- **Same key, different args** → bug in the caller. Fail loudly, do not
fall back to "just send again". This catches "agent re-prompted with new
message content but reused old key" bugs.
- **Key in flight** (server still processing first call) → either block
(server-side wait) or return 409 / 425 Too Early. Caller must back off.
### Step 6 · Persist the dedup record on the *commit* side, atomically
The classic race: tool calls API, API succeeds, tool crashes before recording
the dedup row. Retry now duplicates because the dedup row is missing.
Two safe patterns:
- **Database transaction encloses both**: insert dedup row AND record of API
call in the same transaction; commit. If external API is the side effect,
the dedup row goes in *before* the external call, with a `status: pending`
flag, then updated to `status: completed` after. On retry, `pending` means
"wait or fail" — not "go ahead and call again".
- **Use the external API's idempotency** as the source of truth (OP-1) — skip
your own dedup table entirely; trust Stripe's. Recommended when available.
### Step 7 · Expose `was_replay` in the tool result
The LM should know it didn't actually re-send. Return:
```json
{
"result": { /* original response payload */ },
"was_replay": true,
"original_called_at": "2026-05-19T10:11:12Z"
}
```
Why: prevents the agent from thinking "send failed, let me try a different
phrasing" and producing a logical (not technical) duplicate.
---
## 4. 操作模型 (Operation Models)
Format: **Trigger → Action → Output → Evidence**.
### OP-1 · Idempotency-key HTTP header (Stripe pattern)
- **Trigger**: Wrapping a third-party API that supports idempotency keys
(Stripe, Square, modern PayPal, Adyen, Anthropic Files, AWS with client
tokens).
- **Action**: Before the call, read or generate
`key = state.get("charge_key") or uuid4()`; persist to state; pass as
`Idempotency-Key: <key>` header (or SDK kwarg, e.g.
`stripe.PaymentIntent.create(..., idempotency_key=key)`).
- **Output**: At most one server-side execution per key; retries return cached
response. TTL ~24 h for Stripe.
- **Evidence**: `[stripe/idempotency]` "Stripe's idempotency works by saving
the resulting status code and body of the first request made for any given
idempotency key, regardless of whether it succeeded or failed."
### OP-2 · Dedup table at the tool-call layer
- **Trigger**: Internal API with no native idempotency, multi-row write,
or aggregation that doesn't fit content-addressing.
- **Action**: Create table `tool_call_dedup(key TEXT PRIMARY KEY,
tool_name TEXT, args_hash TEXT, result JSONB, status TEXT,
created_at TIMESTAMPTZ)`. On call: `INSERT … ON CONFLICT(key) DO NOTHING
RETURNING …`. If insert succeeds, perform side effect, then
`UPDATE … SET result=…, status='completed'`. If conflict, read row, return
its `result`.
- **Output**: Single execution per key across all retries; replayable result.
- **Evidence**: This is the generalised form of Stripe's server-side
implementation `[stripe/idempotency]`; appears in PayPal, AWS SDK
whitepapers as the standard "dedup table" pattern
`[aws/idempotency-whitepaper]`.
### OP-3 · Content-addressed write
- **Trigger**: Writing a file, blob, or message whose identity *is* its
content (build artifact, immutable record, derived data).
- **Action**: Compute `key = sha256(canonical_content)`; write to
`PUT /bucket/{key}`. S3 conditional put (`If-None-Match: *`, available
since 2024) returns 412 on second write.
- **Output**: Repeated writes are no-ops (same key → same content). No dedup
table needed.
- **Evidence**: `[aws/s3-conditional]` S3 conditional writes documentation;
this is the underlying primitive in Git, IPFS, content-addressable storage
generally.
### OP-4 · Database `INSERT … ON CONFLICT … DO NOTHING`
- **Trigger**: The side effect is a single row insert into a table you own
(audit log, message record, user-generated record).
- **Action**: Add a unique constraint on
`(idempotency_key)` or `(user_id, intent, request_id)`. Use
`INSERT … ON CONFLICT(idempotency_key) DO NOTHING RETURNING id`.
- **Output**: Empty result set means "duplicate, no-op". Non-empty means
"first call, inserted".
- **Evidence**: PostgreSQL `ON CONFLICT` semantics `[pg/insert]`; MySQL
`INSERT IGNORE`; SQLite `INSERT OR IGNORE`. The database does the dedup;
no application race window.
### OP-5 · Saga / compensation for non-idempotent compensatable APIs
- **Trigger**: The downstream system has no idempotency API but the effect
can be undone (refund, retraction, correction email).
- **Action**:
1. Record intent in your DB with `status='pending', key=<id>` in a
transaction.
2. Call the external API.
3. Update to `status='completed', external_id=<their_id>`.
4. On retry detected (key already present, status='completed'): return the
stored `external_id`, do not call again.
5. On retry detected (status='pending', call in flight): wait + poll, do
not call again.
6. On reconciliation job finding a `pending` past TTL: investigate; possibly
compensate the now-known external_id.
- **Output**: At-most-once semantics with a recovery path.
- **Evidence**: Saga pattern `[microservices/saga]`; Temporal workflow
`[temporal/idempotency]` uses the same shape under the hood.
### OP-6 · LangGraph: pull key from state before `interrupt()`
- **Trigger**: A LangGraph node both calls `interrupt()` and performs a side
effect.
- **Action**:
```python
def send_email_node(state):
# Step 1: derive a stable key BEFORE interrupt
key = state.get("email_key") or str(uuid4())
state["email_key"] = key # persist via reducer
decision = interrupt({"to": state["to"], "body": state["body"]})
if decision != "approve":
return {"status": "rejected"}
# Step 2: side effect uses the key — safe under resume
result = send_email_api(to=state["to"], body=state["body"],
idempotency_key=key)
return {"email_sent": True, "email_key": key, "result": result}
```
- **Output**: Resume re-runs the node body but the key is stable, so the
external send is a no-op on the second pass.
- **Evidence**: `[langgraph/gotchas]` "Side effects after interrupt() will
re-run on resume — wrap them with an idempotency key drawn from state."
See LangGraph payment-double-charge Case Study 4 in
`output/langgraph-sop-skill/SKILL.md`.
### OP-7 · MCP / tool-boundary dedup window
- **Trigger**: MCP server exposing a side-effectful tool to a client that may
retry on transport timeout.
- **Action**: In the MCP tool implementation, accept an
`idempotency_key` argument as part of the tool schema; require it for
side-effectful tools. Maintain a server-side dedup window (e.g., 5-minute
in-memory LRU + persistent backing for cross-restart safety).
- **Output**: Transport-level retries (which MCP clients do) hit the dedup
cache; only one execution.
- **Evidence**: MCP spec does not mandate idempotency, so this is a
*server* responsibility — analogous to Stripe's server-side dedup. Surface
the requirement explicitly in the tool's input schema.
### OP-8 · Pre-call state checkpoint (works for any framework)
- **Trigger**: Working in a framework without first-class durable execution
(plain Python loop, CrewAI task, custom orchestrator).
- **Action**: Before calling a side-effectful tool, persist
`(intent_id, args_hash, key, status='in_progress')` to a local sqlite or
durable store. After call: update to `status='completed'` with result.
Wrap in a context manager so a crash leaves `in_progress`, and a retry
reads that state and either polls or fails-closed.
Auf GitHub ansehen