GNL
Docs menu
Core · Free@gnldev/durable

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#

npm / pnpm
pnpm add @gnldev/durable ai

For 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
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):

exactly-once — crash + resume
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.

same arguments, one execution
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#

fnrunDurable

The durable counterpart of generateText: runs the model + tools wrapped with exactly-once/replay logic; takes journal + runId.

fndurableTool

Wraps a single tool with exactly-once semantics; the key is the AI SDK's toolCallId. Applied automatically inside runDurable.

fndurableTools

Wraps an entire ToolSet (Record<name, tool>) with durableTool.

fnargsHash

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

fnclaim

An atomic insert-only journal write (uses putIfAbsent if available, otherwise falls back to get+put) — prevents concurrent resumes from running the same tool twice.

classSideEffectRetryBlockedError

Thrown when a failed record for a tool marked sideEffect:true / idempotent:false is about to be auto-retried without approvals[toolCallId]=true.

classRetryLimitExceededError

Thrown when a tool's failed record reaches the maxRetries limit (default 3); the record stays permanently failed.

Guarantee
No matter how many times you resume with the same 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.
Warning
If you mark a side-effecting (non-idempotent) tool with 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.