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.
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.
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.
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.