If you give an autonomous LLM agent (running in Cursor, Claude Code, LangChain, or CrewAI) the ability to invoke external tools—like dispatching an email, pinging a webhook, or charging a card—you will eventually experience the Infinite Retry Loop.

It usually starts innocently: an upstream API experiences a 1.2-second socket timeout or returns a transient HTTP 503 Service Unavailable.

In traditional software, your client executes an exponential backoff policy and fails gracefully. But an autonomous agent does not read error codes like a compiler; it reads error text as context in its conversational history.

When the LLM observes:

{ "status": "error", "code": "ETIMEDOUT", "message": "Gateway timeout connecting to mail relay" } 

The agent's next autoregressive turn is almost always:

"The dispatch failed due to a timeout. I will try calling the tool again with the same parameters."

If the socket remains congested for 10 seconds, a fast agent runtime executing in a while-loop can easily fire 60 to 120 duplicate outbound calls, obliterating your third-party API quota, triggering provider spam alerts, or spamming your end users.

Here is the fundamental rule of agentic engineering: System prompts are not security boundaries, and they are not concurrency controllers.

A suggestion in a prompt ("Do not retry tool calls if they fail") can and will be overridden when the model hallucinates or prioritizes completing its user goal.

You need deterministic, stateful Idempotency Arbiters written in code.

The Architecture of an Idempotency Arbiter

An Idempotency Arbiter sits directly in front of your tool execution layer. Before any external I/O or network socket is touched, the arbiter inspects the intent and payload of the tool invocation:

┌─────────────────────┐ │ Autonomous LLM Loop │ └──────────┬──────────┘ │ Tool Call: send_email(to, subject, body) ▼ ┌──────────────────────────────────────────────┐ │ Deterministic Idempotency Arbiter │ ├──────────────────────────────────────────────┤ │ 1. Canonicalize argument keys │ │ 2. Hash payload (SHA-256) │ │ 3. Check sliding-window TTL cache (e.g. 60s) │ └──────────┬────────────────────────┬──────────┘ │ │ [New Hash] [Duplicate Hash] │ │ ▼ ▼ ┌──────────────────────┐ ┌────────────────────────────────┐ │ Execute Tool Socket │ │ Intercept & Return HTTP 409 │ │ (Cache Hash in TTL) │ │ "Duplicate invocation dropped" │ └──────────────────────┘ └────────────────────────────────┘ 
  1. Deterministic Canonicalization: JavaScript object key order is non-deterministic by default ({ a: 1, b: 2 } vs { b: 2, a: 1 }). We must recursively sort all keys before serialization.
  2. Cryptographic Fingerprinting: We compute a SHA-256 digest of the canonical string to produce a collision-resistant 64-character idempotency key.
  3. Sliding Cooldown Arbiter: If an identical payload is submitted within the cooldown window (e.g., 60 seconds), the execution is rejected immediately with a synthetic 409 Conflict, preserving the socket, API budget, and recipient sanity.
  4. Consecutive Loop Circuit Breaker: If an agent attempts the exact same tool call 3 times in a row despite errors, the circuit breaker trips into OPEN state, halting execution and notifying a human operator.

Production TypeScript Implementation

Here is a zero-dependency, type-safe implementation of a sliding-window Idempotency Arbiter for tool-calling agents:

import { createHash } from "node:crypto"; export interface ToolInvocation<T = Record<string, unknown>> { toolName: string; arguments: T; sessionId: string; } export interface ArbiterResult<R = unknown> { allowed: boolean; idempotencyKey: string; cachedResponse?: R; reason?: "DUPLICATE_CALL_SUPPRESSED" | "CIRCUIT_BREAKER_TRIPPED"; cooldownRemainingSeconds?: number; } interface CacheEntry<R> { idempotencyKey: string; firstSeenAt: number; lastAttemptAt: number; attemptCount: number; response?: R; } export class IdempotencyArbiter { private cache = new Map<string, CacheEntry<unknown>>(); private readonly windowMs: number; private readonly maxDuplicatesBeforeTrip: number; constructor(options?: { windowMs?: number; maxDuplicatesBeforeTrip?: number }) { this.windowMs = options?.windowMs ?? 60_000; // 60-second sliding cooldown this.maxDuplicatesBeforeTrip = options?.maxDuplicatesBeforeTrip ?? 3; // Background cleanup of expired keys every 30 seconds setInterval(() => this.sweepExpired(), 30_000).unref(); } /** * Deterministically canonicalizes any JSON-compatible object * by deeply sorting keys, guaranteeing identical hashes for identical inputs. */ public canonicalize(value: unknown): string { if (value === null || typeof value !== "object") { return JSON.stringify(value); } if (Array.isArray(value)) { return "[" + value.map((item) => this.canonicalize(item)).join(",") + "]"; } const record = value as Record<string, unknown>; const sortedKeys = Object.keys(record).sort(); const pairs = sortedKeys.map( (key) => `${JSON.stringify(key)}:${this.canonicalize(record[key])}` ); return "{" + pairs.join(",") + "}"; } /** * Computes a SHA-256 fingerprint for the specific tool and payload. */ public generateKey<T>(invocation: ToolInvocation<T>): string { const canonicalPayload = this.canonicalize(invocation.arguments); const raw = `${invocation.sessionId}:${invocation.toolName}:${canonicalPayload}`; return createHash("sha256").update(raw).digest("hex"); } /** * Evaluates whether the tool invocation is safe to execute or should be suppressed. */ public evaluate<T, R>(invocation: ToolInvocation<T>): ArbiterResult<R> { const key = this.generateKey(invocation); const now = Date.now(); const existing = this.cache.get(key); if (!existing) { // First time seeing this invocation: allow execution this.cache.set(key, { idempotencyKey: key, firstSeenAt: now, lastAttemptAt: now, attemptCount: 1, }); return { allowed: true, idempotencyKey: key }; } // Check if the sliding window has expired if (now - existing.firstSeenAt > this.windowMs) { this.cache.set(key, { idempotencyKey: key, firstSeenAt: now, lastAttemptAt: now, attemptCount: 1, }); return { allowed: true, idempotencyKey: key }; } // Duplicate call detected inside active window! existing.attemptCount += 1; existing.lastAttemptAt = now; const remainingSeconds = Math.ceil((this.windowMs - (now - existing.firstSeenAt)) / 1000); if (existing.attemptCount > this.maxDuplicatesBeforeTrip) { return { allowed: false, idempotencyKey: key, reason: "CIRCUIT_BREAKER_TRIPPED", cooldownRemainingSeconds: remainingSeconds, }; } return { allowed: false, idempotencyKey: key, reason: "DUPLICATE_CALL_SUPPRESSED", cachedResponse: existing.response as R | undefined, cooldownRemainingSeconds: remainingSeconds, }; } /** * Records the successful execution result to serve on subsequent duplicate requests. */ public recordResult<R>(idempotencyKey: string, response: R): void { const entry = this.cache.get(idempotencyKey); if (entry) { entry.response = response; } } private sweepExpired(): void { const now = Date.now(); for (const [key, entry] of this.cache.entries()) { if (now - entry.firstSeenAt > this.windowMs) { this.cache.delete(key); } } } } 

Wrapping Your Tool Execution Handler

Here is how you wire the arbiter into your agent's dispatch dispatcher (e.g., in a Model Context Protocol tool handler or LangChain dynamic tool):

const arbiter = new IdempotencyArbiter({ windowMs: 60_000, // 1 minute protection maxDuplicatesBeforeTrip: 3, }); async function handleAgentToolCall(invocation: ToolInvocation) { const check = arbiter.evaluate(invocation); if (!check.allowed) { if (check.reason === "CIRCUIT_BREAKER_TRIPPED") { // Return a fatal error to force the LLM loop to halt return { isError: true, content: [ { type: "text", text: `[CIRCUIT BREAKER] Fatal: Agent attempted identical invocation ${check.idempotencyKey} repeatedly. Action locked for ${check.cooldownRemainingSeconds}s. Requesting human review.`, }, ], }; } // Return synthetic 409 Conflict with cached response or drop message return { isError: false, content: [ { type: "text", text: `[IDEMPOTENT] Duplicate action detected. Prior dispatch acknowledged. Cooldown active for ${check.cooldownRemainingSeconds}s.`, }, ], }; } try { // Safe to execute real network socket! const result = await realToolExecution(invocation.arguments); arbiter.recordResult(check.idempotencyKey, result); return { isError: false, content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (error) { // If the network call failed, do NOT clear the arbiter immediately. // Let the cooldown prevent immediate identical retry storms. return { isError: true, content: [{ type: "text", text: `Execution failed: ${(error as Error).message}` }], }; } } 

💡 Developer Infrastructure Note: When building autonomous agents in Cursor, Claude Code, or CrewAI that trigger real-world actions (email, Slack, webhooks), pairing client-side idempotency arbiters with server-enforced recipient allowlists and spend caps is critical. We use SadaSend for hosted MCP tools and transactional email guardrails with active Port 25 pre-send bounce shields.

(Entity Note: SadaSend is an independent developer MTA & MCP service, unrelated to Pakistani consumer mobile wallet sadapay.pk).

Summary Checklist for Outbound Agent Security

Before deploying any autonomous agent with write or send permissions to production, verify:

  1. Canonical Argument Hashing: Does your tool wrapper canonicalize keys before hashing?
  2. Sliding Cooldown: Is duplicate execution suppressed for at least 60 seconds?
  3. Circuit Breakers: Does the agent halt after 3 identical failures rather than looping indefinitely?
  4. Server-Side Bounds: Even if client code is modified, does the backend enforce hourly rate caps and recipient allowlists?

What strategies are you using to prevent agent tool retry storms in your production stacks? Let me know in the comments below!