GNL
Core · Free@gnldev/durable

Your AI SDK tool ran twice. Here's how to make it run once.

A retry, a redeploy or the model itself can run a side-effecting tool a second time. This page shows why, what fixing it by hand misses, and the change that stops it.

The symptom#

An agent sends a customer an email. It runs inside a queue worker, like most production agents do. The process is killed after the email went out but before the job was acknowledged — a deploy, an out-of-memory kill, a serverless timeout. The queue delivers the job again, and the customer gets the same email twice.

a plain AI SDK agent
import { generateText, tool, stepCountIs } from 'ai';
import { z } from 'zod';

const sendEmail = tool({
  description: 'Send the customer an email',
  inputSchema: z.object({ to: z.string(), subject: z.string() }),
  execute: async ({ to, subject }) => mailer.send({ to, subject }),
});

// A queue worker runs this. The process is killed after the email went out,
// before the job was acked.
await generateText({ model, tools: { sendEmail }, stopWhen: stepCountIs(10), prompt });
// The queue redelivers the job → generateText starts from zero →
// the model plans sendEmail again → the customer gets a second email.

Why it happens#

generateText keeps no record of what it already did. A retry of the job is a new call: the model reads the prompt from the start, plans sendEmail again, and the tool runs again. Nothing in the loop knows the first email exists.

It happens without a crash too. A model can plan the same work twice under a new toolCallId — the AI SDK issue tracker has reports of the same tool called five times in one turn. Anything that dedups by call id cannot see that case.

Fixing it by hand#

The usual fix is a key in your own database: check before sending, record after.

a hand-rolled guard
execute: async ({ to, subject }) => {
  const key = `${to}:${subject}`;
  if (await db.sent.has(key)) return { alreadySent: true };
  const res = await mailer.send({ to, subject });
  await db.sent.put(key, res.id); // a crash before this line and the retry sends again
  return res;
}

It works for the common case, and it misses three things:

  • The window. If the process dies after send and before put, the key was never written and the retry sends again.
  • The model's steps. The tool is guarded, the conversation is not: the retry calls the model again from the start, pays for it again, and may plan something different this time.
  • Every tool. Each side-effecting tool needs its own copy of this code, its own key design and its own table.

With GNL#

Wrap the tool and call runDurable where you called generateText — same arguments, plus the id of this job and a journal.

the same agent, durable
import { runDurable, gnlTool } from '@gnldev/durable';
import { SqliteStorage } from '@gnldev/durable/sqlite';

const tools = { sendEmail: gnlTool(sendEmail, { sideEffect: true, idempotency: 'args' }) };

await runDurable({
  runId: `welcome-email:${customerId}`,   // the id of THIS job — never a session id
  journal: new SqliteStorage('runs.db').runs,
  model, tools, stopWhen: stepCountIs(10), prompt,
});

When the queue delivers the job again, it runs with the same runId. Every step that already finished is read back from the journal — the model call and the tool result — and sendEmail is not executed again. The agent continues from the first step that never completed.

idempotency: 'args' covers the re-plan: if the model asks for the same email again under a new toolCallId, it collapses into the one that already ran.

To carry the guarantee past your process, execute(input, options) receives a stable options.idempotencyKey. If your email provider accepts an idempotency key, pass it on and the provider itself refuses the duplicate.

Note
One id is one job, never one session. Name the work (a welcome email for one customer), not the conversation — a session id as runId would make every later turn replay the first one.

When GNL does not solve it#

The crash inside the call. If the email went out and the process died before the result was written, GNL cannot know whether it was sent. It does not guess: runDurable throws SideEffectRetryBlockedError and a human decides — or your tool's recover() asks the provider. The safe direction: the email is never sent twice, but resuming is not always seamless.

A different argument is different work. If the model changes the subject line, the arguments hash differently and it is a second email. Key by what defines the action instead: idempotencyKey: (args) => args.to.

Storage that loses an acknowledged write. The guarantee rests on the journal keeping what it confirmed. On Postgres with failover, run synchronous replication; asynchronous replication can lose a write and let the tool run twice.

Sources#

Every claim on this page has a test or a runnable example in the public repository:

  • packages/durable/test/args-idempotency.test.ts — the same tool re-planned under new call ids, collapsed into one execution
  • packages/durable/test/crash-window.test.ts — the crash between the side effect and the journal write
  • packages/durable/test/idempotency-key.test.ts — the stable key handed to execute
  • examples/incident-proofs — the checkpoint resend and duplicate-call-id patterns, reproduced and blocked, no API key

The mechanism in full: exactly-once tool calls. github.com/Karaca7/gnldev