Docs menu
Safe-by-default duplicate guard
When a side-effecting tool is about to run AGAIN with arguments identical to an earlier successful call in the same run, the ladder (off → warn → reflect → block → suspend) intervenes — nudging the model to reconsider or stopping the duplicate outright.
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: it detects when a side-effect tool (charge, sendEmail, …) is about to execute again with args identical to an earlier successful call in the same runId, and applies the configured response. It is a per-run, in-run protection (it does NOT dedup across separate runs — for that use cross-run idempotency).
The ladder#
Set the response with limits.sideEffectDuplicates (default warn). The levels escalate in strictness:
'off'No detection — the duplicate executes.
'warn'Executes as before, but writes a named journaled incident on each occurrence (an adoption ramp — never a silent duplicate).
'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.
'block'Stops the duplicate via DuplicateSideEffectError — nothing is written for the blocked call.
'suspend'Writes a suspended record → the duplicate lands in the Approvals flow (a human decides).
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:
exact-hash / markerA 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.
XIDA 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.
semanticIdentityA 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.
open 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, DuplicateSideEffectError, type RunLimits } from '@gnldev/durable';Step-by-step usage#
Pass limits to gnl.run. An unmarked tool is treated as a side effect; a second identical call in the same runId throws DuplicateSideEffectError under block:
// 'charge' is unmarked → treated as a side effect (H7: sideEffect ?? idempotent !== true).
// A second identical charge in the SAME runId is caught by the ladder.
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' };Mark a genuinely idempotent tool with idempotent: true so the guard never fires for it (read-only lookups, status polls):
// Exempt a genuinely idempotent tool so the guard never fires for it:
const getStatus = { execute: async (a) => fetchStatus(a), idempotent: true };API reference#
RunLimits.sideEffectDuplicatesRunLimits field: 'off' | 'warn' | 'reflect' | 'block' | 'suspend'. Default 'warn'. Applies to a side-effect tool about to repeat with identical args in the same run.
DuplicateSideEffectErrorThrown under 'block' (and after an ignored 'reflect' nudge). detail: { toolName, argsHash, firstToolCallId, toolCallId }.
AnyTool.sideEffect / idempotentA tool is side-effectful by default (H7: sideEffect ?? idempotent !== true). Mark idempotent: true (or sideEffect: false) to exempt it from the guard.
reflect is the pragmatic default choice for real agents: the nudge lets a confused model fix itself without killing the run, and the escalation-to-block after an identical repeat keeps the safety guarantee. The nudge text is model-facing and brand-neutral.warn, not off: behavior does not change, but every duplicate is named as a journaled incident — so an operator can query it instead of it evaporating into a console line.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.