Docs menu
Exactly-Once Tools
Guarantees that side-effecting tools (payments, email) run EXACTLY ONCE when re-run with the same runId; crashes and retries never double-charge.
What it's for / when to use it#
If an agent crashes right after calling a tool (e.g. a credit-card charge) or the process restarts, calling runDurable again with the same runId would normally replay the model request from scratch. @gnldev/durable intercepts this replay at the tool level: every tool call is recorded in the journal under the toolCallId generated by the model. If a succeeded record already exists for that key during replay, the real function (execute) is NOT run again — the recorded output is returned directly.
Use case: any agent with side-effecting tools that are NOT idempotent — charging a payment, sending an email/SMS, writing to an external system. Read-only/idempotent tools don't strictly need this guarantee, but it doesn't hurt either.
Coming from the symptom rather than the mechanism? My AI SDK tool ran twice after a retry walks through it from the problem side.
Setup / import#
pnpm add @gnldev/durable aiFor a quick start, the in-memory InMemoryJournal can be used as the journal; for persistent storage, prefer the adapters exposed via sub-exports (see Storage Adapters):
import { runDurable, InMemoryJournal } from '@gnldev/durable';
// durable: import { SqliteStorage } from '@gnldev/durable/sqlite';
// durable: import { PostgresStorage } from '@gnldev/durable/postgres';Step-by-step usage#
runDurable is a drop-in replacement for the Vercel AI SDK's generateText function; it additionally takes journal and runId. In the example below, a charge tool is interrupted on the first run by a CRASH error produced by the model; when resumed with the same runId, the charge COUNTER does not increase (charges is still 1):
const journal = new InMemoryJournal();
const counter = { charges: 0 };
const tools = () => ({
chargeCard: tool({
description: 'charge',
inputSchema: z.object({ amount: z.number() }),
execute: async ({ amount }) => {
counter.charges++;
return { charged: amount };
},
}),
});
// 1) first run: the model calls the tool, then crashes on the next step
await expect(
runDurable({ runId: 'run-1', journal, model, tools: tools(), prompt: 'charge', stopWhen: stepCountIs(6) }),
).rejects.toThrow('CRASH');
expect(counter.charges).toBe(1);
// 2) resume with the same runId: chargeCard does NOT run again — the result comes from the journal
const res = await runDurable({ runId: 'run-1', journal, model, tools: tools(), prompt: 'charge', stopWhen: stepCountIs(6) });
expect(counter.charges).toBe(1); // exactly-once
expect(res.text).toContain('Charged');This guarantee runs automatically inside runDurable: the tools you provide are wrapped with durableTools(tools, ctx) — before each call, every tool's execute checks whether a succeeded/denied record already exists in the journal.
If you want to wrap a tool on its own (outside the agent loop), durableTool can be used directly; in that case you supply the DurableCtx (a context holding journal + runId) manually — runDurable sets this up for you automatically.
Deduping by arguments, not by call id#
By default a tool result is keyed on the model's toolCallId, so a retry of the same call reuses the same record. idempotency: 'args' keys it on the arguments instead: two different calls that ask for the same thing collapse onto one execution. That is what you want when a model re-asks for the same charge under a fresh call id, and it is the difference between "the retry is safe" and "the second identical request is also safe".
The key comes from argsHash, an order-independent hash — so { b: 2, a: 1 } and { a: 1, b: 2 } are the same call. Every value carries a type tag, which is what keeps values that stringify alike from sharing a key: a Date, a Map, a Set, a RegExp and a BigInt each hash distinctly. Without the tags they collapsed, and the second call read back the first one's result — a wrong answer produced by the mechanism that exists to prevent one.
import { durableTool } from '@gnldev/durable';
const chargeCard = durableTool({
name: 'chargeCard',
idempotency: 'args', // key on the arguments, not on the model's toolCallId
sideEffect: true,
execute: async ({ amount, orderId }) => paymentApi.charge(orderId, amount),
});
// Two calls, two toolCallIds, same arguments → ONE charge.
// { a: 1, b: 2 } and { b: 2, a: 1 } are the same key; a Date and a Map are not.A tool that takes no arguments can use the mode too: undefined hashes to a reserved token in the tag namespace, so "this tool takes no arguments" is an expressible key rather than a crash. A function or a symbol still throws, with a message naming argsHash and the type.
A tool that keys on a logical id with idempotencyKey must also say how far the key holds, with idempotencyWindow: 'run' — once in this request, a new request does it again; 'cross-run' — once for every request, until purged; 'thread' — once in this conversation. Without a window the tool is refused where it is declared (gnlTool) or wrapped (durableTool), and the error names the tool. Before 0.10 the window defaulted to 'run', so the same action asked for again in the next request ran again.
import { gnlTool } from '@gnldev/durable';
import { tool } from 'ai';
import { z } from 'zod';
const tools = {
// Once for every request, until purged: asking again in a later request does not charge again.
chargeOrder: gnlTool(
tool({ description: 'Charge an order', inputSchema: z.object({ orderId: z.string() }), execute: chargeCard }),
{ sideEffect: true, idempotencyKey: (input) => input.orderId, idempotencyWindow: 'cross-run' },
),
// Once in this request (the agent and every sub-agent under it); a new request sends it again.
sendReceipt: gnlTool(
tool({ description: 'Send a receipt', inputSchema: z.object({ orderId: z.string() }), execute: emailReceipt }),
{ sideEffect: true, idempotencyKey: (input) => input.orderId, idempotencyWindow: 'run' },
),
};
// Without idempotencyWindow, gnlTool throws a TypeError where the tool is declared:
// @gnldev/durable: gnlTool: the tool "Charge an order" has an idempotencyKey but no
// idempotencyWindow — say how far the key holds. ...The run window is the request's: a keyed call, or one with idempotency: 'args', is deduplicated across the agent, every sub-agent and every level under it. Build the key from stable identifiers (an id, a date), not from text the model writes.
API reference#
runDurableThe durable counterpart of generateText: runs the model + tools wrapped with exactly-once/replay logic; takes journal + runId.
durableToolWraps a single tool with exactly-once semantics; the key is the AI SDK's toolCallId. Applied automatically inside runDurable. Refuses a tool that has an idempotencyKey but no idempotencyWindow, naming the tool.
durableToolsWraps an entire ToolSet (Record<name, tool>) with durableTool.
argsHashAn order-independent, type-tagged hash of the tool arguments — the key for idempotency: 'args', and the value compared against a succeeded record during replay to detect drift.
claimAn atomic insert-only journal write (uses putIfAbsent if available, otherwise falls back to get+put) — prevents concurrent resumes from running the same tool twice.
SideEffectRetryBlockedErrorThrown when a failed record for a tool marked sideEffect:true / idempotent:false is about to be auto-retried without approvals[toolCallId]=true.
RetryLimitExceededErrorThrown when a tool's failed record reaches the maxRetries limit (default 3); the record stays permanently failed.
runId, the real execute of a successfully completed (succeeded) tool call never runs again — the key is the toolCallId generated by the model via the AI SDK; replay should produce the same output, so the same ID reappears.sideEffect: true (or idempotent: false), a crashed/failed call is NOT automatically retried — you must give explicit approval via approvals[toolCallId] = true, otherwise a SideEffectRetryBlockedError is thrown. This is a deliberate safeguard against risks like double-charging.