Exactly-once or at-most-once? What an AI agent's tool call can actually promise
GNL teamside-effectsidempotencydurabilityexactly-once
Exactly-once tool calls sound like the goal. For an effect outside your process, no library can promise them alone. Here is what can be promised, and when it adds up to exactly-once.
The short answer#
An AI agent cannot promise exactly-once tool calls on its own when the tool changes something outside its process. What it can promise is at-most-once: a side effect is never silently run twice, and when the outcome is unknown, the agent stops and asks a person.
It becomes exactly-once end to end when the outside system takes part: it accepts an idempotency key, or it can be asked what happened. Then the guarantee is a joint one — the agent's and the provider's — and it should be stated as one.
Why exactly-once is impossible for a side effect#
A tool call that leaves the process has two parts: the effect, and the record that it happened. An email goes out, then the result is written down. A deploy starts, then the result is written down. Between the two there is a moment, and a crash can land in it.
After that crash, the process cannot know whether the email was sent. Running the tool again may send it twice. Skipping it may never send it. No client-side library can close this window, because the answer lives in the other system, not in yours.
Why an AI agent's tool runs twice#
The window is only one cause. In practice there are three:
- The retry starts from the top. A queue redelivers the job after a deploy or a timeout. The model plans again, and a ticket that was already opened is opened again. Resume an agent after a crash walks through this case.
- The model re-plans the same work. The same call comes back under a new
toolCallId, with no crash at all. Anything that dedups by call id cannot see it. My AI SDK tool ran twice after a retry shows the pattern. - The crash inside the call. The effect happened and the record did not. This is the one case no one can see through from inside the process.
What a tool call can actually promise#
Four things are achievable. Each one closes a different cause:
- Never re-run a recorded side effect. Each tool result is written to a journal as the run goes. On resume with the same
runId, a finished call is read back, not run. This covers the retry from the top. - Dedupe a re-planned call by its arguments. With
idempotency: 'args', the same arguments mean the same work, so a call re-planned under a newtoolCallIdgets the first call's result. This covers the re-plan. - Carry the guarantee to the provider. The tool receives a stable
idempotencyKeyto pass downstream, so the provider itself refuses the duplicate. Or the tool has arecover()that asks the provider whether the call went through. This is how the window gets an answer — when the provider supports it. - When the outcome is genuinely unknown, stop and ask. With no key and no
recover(), the resume throwsSideEffectRetryBlockedError. It does not re-run the tool and it does not skip it. A person decides.
import { runDurable, gnlTool } from '@gnldev/durable';
import { SqliteStorage } from '@gnldev/durable/sqlite';
import { openai } from '@ai-sdk/openai';
import { tool } from 'ai';
import { z } from 'zod';
const chargeCard = gnlTool(
tool({
description: "Charge the customer's card",
inputSchema: z.object({ amount: z.number() }),
execute: async ({ amount }) => payments.charge(amount),
}),
{
sideEffect: true, // money moves — never replayed from the journal
idempotency: 'args', // and never re-run when the model re-plans it under a new toolCallId
},
);
const res = await runDurable({
runId: 'order-123', // the id of THIS WORK (an orderId) — never a sessionId
journal: new SqliteStorage('runs.db').runs,
model: openai('gpt-4o'),
tools: { chargeCard },
prompt: 'Cancel the order, suggest a similar product',
});In the code, two options are all it takes for a payment to happen once at most. The same two options work for an email, a ticket or a deploy: mark the tool as a side effect, and say that the same arguments are the same work.
What "exactly-once effect" means in GNL#
In GNL, "exactly-once effect" means call-scoped dedup with a safe default. The same tool call never runs again, and with idempotency: 'args' the same arguments never run again either. The exact name of that guarantee is at-most-once for side effects.
It becomes end-to-end exactly-once when your tool carries the idempotencyKey to a provider that accepts one, or answers recover(). Without that, the safe direction wins: never twice, even if a resume sometimes needs a person.
Where the promise ends#
- Different arguments are different work. If the model adds a note to the same refund, the arguments hash differently and it runs twice. Key the call by what defines the action instead —
idempotencyKey: (args) => args.orderId— and give the key anidempotencyWindow:'run','thread'or'cross-run'. - Same arguments, no key. When a side-effect tool has neither
idempotency: 'args'nor a key, a repeat with identical arguments under a new call id is stopped by default and a person is asked. Without a key, nothing can tell "the same work again" from "two of the same, on purpose". - A tool that says nothing. A tool that declares no
sideEffectis treated as one. DeclaresideEffect: falseon a read tool, so it can simply be run again.
Read next#
The mechanism in full, with the API: exactly-once tool calls. The problem from the symptom side: my AI SDK tool ran twice after a retry. And what a crash does to the rest of the run: what happens when your agent crashes.
npm i @gnldev/durable, or start a new project with npm create gnl@latest my-agent.