GNL 0.17: early and moving fast — APIs can change between releasesJoin us

GNL
Docs menu
Core · Free@gnldev/durable

Safe-by-default duplicate guard

When a side-effecting tool with no key is about to run AGAIN with arguments identical to an earlier call in the same request, the ladder (off → warn → reflect → block → suspend) intervenes. The default, suspend, asks a person before the repeat runs.

What it's for / when to use it#

A confused model can call the same side-effecting tool twice with the SAME arguments — a double charge, a double email, a duplicate shipment. The duplicate guard is on by default, and by default it asks a person: when a side-effect tool (charge, sendEmail, …) is about to execute again with args identical to an earlier call in the same request — the agent's own calls and every sub-agent's under it — the repeat waits in the Approvals flow instead of running. Without a key, nothing can tell "the same work again" from "two of the same, on purpose"; a person can. A tool that declares its identity (idempotencyKey, idempotency: 'args', a window) never reaches this guard: its key resolves the repeat. The guard does NOT dedup across separate requests — for that use cross-run idempotency.

The ladder#

Set the response with limits.sideEffectDuplicates (default suspend; before 0.10 it was warn). The levels escalate in strictness:

const'off'

No detection — the duplicate executes.

const'warn'

Executes the repeat, and writes a named journaled incident on each occurrence. This was the default before 0.10; set it to keep that behaviour.

const'reflect'

Does NOT stop the run: delivers ONE journaled 'reconsider' nudge to the model (record status 'reflected'); an identical repeat AFTER the nudge escalates to a block.

const'block'

Stops the duplicate via DuplicateSideEffectError — nothing is written for the blocked call.

const'suspend'

The default. Writes a suspended record → the repeat lands in the Approvals flow and a person decides. Identical calls issued in parallel in one step are asked about the same way. Once a person refuses a repeat, a further identical call in the same request does not run and is not asked about again.

Which repeat hits which layer#

A repeat is not one thing, and no single layer sees all of them. Two axes decide: WHO produced the repeat (a confused model, or a user asking for the same job again) and whether it arrived in the SAME conversation. GNL closes three of the four cells today; the fourth is a deliberately open gap, and it is written down as one:

constexact-hash / marker

A model repeat with IDENTICAL arguments, in the same run or the same conversation → the ladder on this page. Fully deterministic: no score, no embedder, the argument hash is enough.

constXID

A user repeat whose identity fields are EQUAL after normalization — across conversations, and across channels too. XID is deterministic and resource-scoped (the person owns the identity): the same business identity is visible in one O(1) read whether it arrived from chat, a batch or a cron, and the question carries that — this job was completed 1 minute ago, via chat. It comes for free when the tool declares semanticIdentity and the call carries a resourceId; no embedder involved.

constsemanticIdentity

A different spelling or a paraphrase, same conversation → the hash is blind here. The only layer that can step in is semantic identity, and it only finds a CANDIDATE: the normalized identity fields and a human decide.

constopen gap (v2)

Paraphrase + a different conversation → an OPEN GAP. Semantic recall is thread-scoped, and XID cannot see it either unless the identity is equal after normalization. This is a deliberate boundary: cross-thread recall and an external vector index are the data-gated v2.

The third row in detail — the double opt-in, the decision hierarchy, the rule ladder and the judge's certificate — has its own page: semantic-duplicate-gate.

Setup / import#

The guard lives in @gnldev/durable and is configured per run via RunLimits:

import { createGnl, gnlTool, DuplicateSideEffectError, type RunLimits } from '@gnldev/durable';

Step-by-step usage#

Pass limits to gnl.run. A tool that declares nothing is treated as a side effect; a second identical call in the same request throws DuplicateSideEffectError under block:

// 'charge' is unmarked → treated as a side effect (H7: sideEffect ?? idempotent !== true).
// A second identical charge in the SAME request (sub-agents included) is caught by the ladder.
// The default is 'suspend' (a person decides); 'block' throws instead.
const limits: RunLimits = { sideEffectDuplicates: 'block' };

try {
  await gnl.run('pay', { runId: 'order-42', prompt: 'charge the customer', limits });
} catch (e) {
  if (e instanceof DuplicateSideEffectError) {
    // e.detail.firstToolCallId → the original successful call
    console.log('duplicate blocked:', e.detail.toolName, e.detail.firstToolCallId);
  }
}

Prefer reflect when you'd rather let the model recover: it receives one nudge and can proceed correctly; only a stubborn identical repeat is then blocked:

// 'reflect' does NOT stop the run: the model gets one journaled "reconsider" nudge
// (record status 'reflected'). An identical repeat AFTER the nudge escalates to a block.
const limits: RunLimits = { sideEffectDuplicates: 'reflect' };

Declare a read tool with sideEffect: false (or idempotent: true) so the guard never fires for it (lookups, status polls). Otherwise an undeclared read tool asked twice waits for a person too; the question names this fix:

// Declare a read tool so the guard never fires for it — the suspend question names this fix:
const getWeather = gnlTool(weatherTool, { sideEffect: false });

// A plain tool object can say the same:
const getStatus = { execute: async (a) => fetchStatus(a), idempotent: true };

// The behaviour before 0.10 — the repeat runs and is journaled as an incident:
const permissive: RunLimits = { sideEffectDuplicates: 'warn' };

API reference#

typeRunLimits.sideEffectDuplicates

RunLimits field: 'off' | 'warn' | 'reflect' | 'block' | 'suspend', or { action, scope?, ttlMs? }. Default 'suspend' (before 0.10 it was 'warn', which ran the repeat; set 'warn' to keep that). Applies to a side-effect tool with no key about to repeat with identical args in the same request.

classDuplicateSideEffectError

Thrown under 'block' (and after an ignored 'reflect' nudge). detail: { toolName, argsHash, firstToolCallId, toolCallId }.

typeAnyTool.sideEffect / idempotent

A tool is side-effectful by default (H7: sideEffect ?? idempotent !== true) — a tool that declares nothing counts as a side effect. Mark sideEffect: false (or idempotent: true) to exempt it from the guard.

Reflect vs block
Choose reflect over the default suspend when no person is there to answer: the nudge lets a confused model fix itself without ending the run, and the escalation-to-block after an identical repeat keeps the repeat from running. The nudge text is model-facing and brand-neutral.
Note
The default is suspend: a repeated side effect with no key waits for a person instead of running. A tool that declares nothing counts as a side effect, so an undeclared read tool asked twice is asked about too. Set sideEffectDuplicates: 'warn' for the behaviour before 0.10 — the repeat runs and is named as a journaled incident.
Related
See exactly-once-tools for the per-run exactly-once guarantee this builds on, taint-guard for the sibling ladder that fires on untrusted content, human-in-loop-approvals for the Approvals flow that suspend routes into, and semantic-duplicate-gate for the differently-worded case where the hash stays blind.