{"slug": "why-autonomous-ai-agents-enter-infinite-retry-loops-and-how-to-build-sha-256-in", "title": "Why Autonomous AI Agents Enter Infinite Retry Loops (And How to Build SHA-256 Idempotency Arbiters in TypeScript)", "summary": "A developer published a zero-dependency TypeScript implementation of an \"Idempotency Arbiter\" that sits in front of an autonomous LLM agent's tool-execution layer to stop infinite retry loops. The arbiter canonicalizes tool-call arguments, hashes the payload with SHA-256, and checks a 60-second sliding-window TTL cache, returning HTTP 409 for duplicate invocations and tripping a circuit breaker after a configurable number of repeats. The author argues that system prompts are not security boundaries or concurrency controllers, and that a fast agent runtime can fire 60 to 120 duplicate outbound calls during a brief upstream timeout.", "body_md": "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**.\n\nIt usually starts innocently: an upstream API experiences a 1.2-second socket timeout or returns a transient HTTP 503 Service Unavailable.\n\nIn 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.\n\nWhen the LLM observes:\n\n```\n{\n  \"status\": \"error\",\n  \"code\": \"ETIMEDOUT\",\n  \"message\": \"Gateway timeout connecting to mail relay\"\n}\n```\n\nThe agent's next autoregressive turn is almost always:\n\n*\"The dispatch failed due to a timeout. I will try calling the tool again with the same parameters.\"*\n\nIf 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.\n\nHere is the fundamental rule of agentic engineering: **System prompts are not security boundaries, and they are not concurrency controllers.**\n\nA 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. \n\nYou need deterministic, stateful **Idempotency Arbiters** written in code.\n\nAn 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:\n\n```\n┌─────────────────────┐\n│ Autonomous LLM Loop │\n└──────────┬──────────┘\n           │ Tool Call: send_email(to, subject, body)\n           ▼\n┌──────────────────────────────────────────────┐\n│        Deterministic Idempotency Arbiter      │\n├──────────────────────────────────────────────┤\n│ 1. Canonicalize argument keys                │\n│ 2. Hash payload (SHA-256)                    │\n│ 3. Check sliding-window TTL cache (e.g. 60s) │\n└──────────┬────────────────────────┬──────────┘\n           │                        │\n     [New Hash]              [Duplicate Hash]\n           │                        │\n           ▼                        ▼\n┌──────────────────────┐   ┌────────────────────────────────┐\n│ Execute Tool Socket  │   │ Intercept & Return HTTP 409    │\n│ (Cache Hash in TTL)  │   │ \"Duplicate invocation dropped\" │\n└──────────────────────┘   └────────────────────────────────┘\n```\n\n`{ a: 1, b: 2 }` vs `{ b: 2, a: 1 }`). We must recursively sort all keys before serialization.` 409 Conflict`, preserving the socket, API budget, and recipient sanity.` OPEN` state, halting execution and notifying a human operator.\nHere is a zero-dependency, type-safe implementation of a sliding-window Idempotency Arbiter for tool-calling agents:\n\n``` js\nimport { createHash } from \"node:crypto\";\n\nexport interface ToolInvocation<T = Record<string, unknown>> {\n  toolName: string;\n  arguments: T;\n  sessionId: string;\n}\n\nexport interface ArbiterResult<R = unknown> {\n  allowed: boolean;\n  idempotencyKey: string;\n  cachedResponse?: R;\n  reason?: \"DUPLICATE_CALL_SUPPRESSED\" | \"CIRCUIT_BREAKER_TRIPPED\";\n  cooldownRemainingSeconds?: number;\n}\n\ninterface CacheEntry<R> {\n  idempotencyKey: string;\n  firstSeenAt: number;\n  lastAttemptAt: number;\n  attemptCount: number;\n  response?: R;\n}\n\nexport class IdempotencyArbiter {\n  private cache = new Map<string, CacheEntry<unknown>>();\n  private readonly windowMs: number;\n  private readonly maxDuplicatesBeforeTrip: number;\n\n  constructor(options?: { windowMs?: number; maxDuplicatesBeforeTrip?: number }) {\n    this.windowMs = options?.windowMs ?? 60_000; // 60-second sliding cooldown\n    this.maxDuplicatesBeforeTrip = options?.maxDuplicatesBeforeTrip ?? 3;\n\n    // Background cleanup of expired keys every 30 seconds\n    setInterval(() => this.sweepExpired(), 30_000).unref();\n  }\n\n  /**\n   * Deterministically canonicalizes any JSON-compatible object\n   * by deeply sorting keys, guaranteeing identical hashes for identical inputs.\n   */\n  public canonicalize(value: unknown): string {\n    if (value === null || typeof value !== \"object\") {\n      return JSON.stringify(value);\n    }\n    if (Array.isArray(value)) {\n      return \"[\" + value.map((item) => this.canonicalize(item)).join(\",\") + \"]\";\n    }\n    const record = value as Record<string, unknown>;\n    const sortedKeys = Object.keys(record).sort();\n    const pairs = sortedKeys.map(\n      (key) => `${JSON.stringify(key)}:${this.canonicalize(record[key])}`\n    );\n    return \"{\" + pairs.join(\",\") + \"}\";\n  }\n\n  /**\n   * Computes a SHA-256 fingerprint for the specific tool and payload.\n   */\n  public generateKey<T>(invocation: ToolInvocation<T>): string {\n    const canonicalPayload = this.canonicalize(invocation.arguments);\n    const raw = `${invocation.sessionId}:${invocation.toolName}:${canonicalPayload}`;\n    return createHash(\"sha256\").update(raw).digest(\"hex\");\n  }\n\n  /**\n   * Evaluates whether the tool invocation is safe to execute or should be suppressed.\n   */\n  public evaluate<T, R>(invocation: ToolInvocation<T>): ArbiterResult<R> {\n    const key = this.generateKey(invocation);\n    const now = Date.now();\n    const existing = this.cache.get(key);\n\n    if (!existing) {\n      // First time seeing this invocation: allow execution\n      this.cache.set(key, {\n        idempotencyKey: key,\n        firstSeenAt: now,\n        lastAttemptAt: now,\n        attemptCount: 1,\n      });\n      return { allowed: true, idempotencyKey: key };\n    }\n\n    // Check if the sliding window has expired\n    if (now - existing.firstSeenAt > this.windowMs) {\n      this.cache.set(key, {\n        idempotencyKey: key,\n        firstSeenAt: now,\n        lastAttemptAt: now,\n        attemptCount: 1,\n      });\n      return { allowed: true, idempotencyKey: key };\n    }\n\n    // Duplicate call detected inside active window!\n    existing.attemptCount += 1;\n    existing.lastAttemptAt = now;\n    const remainingSeconds = Math.ceil((this.windowMs - (now - existing.firstSeenAt)) / 1000);\n\n    if (existing.attemptCount > this.maxDuplicatesBeforeTrip) {\n      return {\n        allowed: false,\n        idempotencyKey: key,\n        reason: \"CIRCUIT_BREAKER_TRIPPED\",\n        cooldownRemainingSeconds: remainingSeconds,\n      };\n    }\n\n    return {\n      allowed: false,\n      idempotencyKey: key,\n      reason: \"DUPLICATE_CALL_SUPPRESSED\",\n      cachedResponse: existing.response as R | undefined,\n      cooldownRemainingSeconds: remainingSeconds,\n    };\n  }\n\n  /**\n   * Records the successful execution result to serve on subsequent duplicate requests.\n   */\n  public recordResult<R>(idempotencyKey: string, response: R): void {\n    const entry = this.cache.get(idempotencyKey);\n    if (entry) {\n      entry.response = response;\n    }\n  }\n\n  private sweepExpired(): void {\n    const now = Date.now();\n    for (const [key, entry] of this.cache.entries()) {\n      if (now - entry.firstSeenAt > this.windowMs) {\n        this.cache.delete(key);\n      }\n    }\n  }\n}\n```\n\nHere 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):\n\n``` js\nconst arbiter = new IdempotencyArbiter({\n  windowMs: 60_000, // 1 minute protection\n  maxDuplicatesBeforeTrip: 3,\n});\n\nasync function handleAgentToolCall(invocation: ToolInvocation) {\n  const check = arbiter.evaluate(invocation);\n\n  if (!check.allowed) {\n    if (check.reason === \"CIRCUIT_BREAKER_TRIPPED\") {\n      // Return a fatal error to force the LLM loop to halt\n      return {\n        isError: true,\n        content: [\n          {\n            type: \"text\",\n            text: `[CIRCUIT BREAKER] Fatal: Agent attempted identical invocation ${check.idempotencyKey} repeatedly. Action locked for ${check.cooldownRemainingSeconds}s. Requesting human review.`,\n          },\n        ],\n      };\n    }\n\n    // Return synthetic 409 Conflict with cached response or drop message\n    return {\n      isError: false,\n      content: [\n        {\n          type: \"text\",\n          text: `[IDEMPOTENT] Duplicate action detected. Prior dispatch acknowledged. Cooldown active for ${check.cooldownRemainingSeconds}s.`,\n        },\n      ],\n    };\n  }\n\n  try {\n    // Safe to execute real network socket!\n    const result = await realToolExecution(invocation.arguments);\n    arbiter.recordResult(check.idempotencyKey, result);\n    return { isError: false, content: [{ type: \"text\", text: JSON.stringify(result) }] };\n  } catch (error) {\n    // If the network call failed, do NOT clear the arbiter immediately.\n    // Let the cooldown prevent immediate identical retry storms.\n    return {\n      isError: true,\n      content: [{ type: \"text\", text: `Execution failed: ${(error as Error).message}` }],\n    };\n  }\n}\n```\n\n💡 **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](https://www.sadasend.com/?utm_source=devto&utm_medium=article&utm_campaign=dankestrel&utm_content=agent-idempotency-arbiters) for hosted MCP tools and transactional email guardrails with active Port 25 pre-send bounce shields.\n\n*(Entity Note: SadaSend is an independent developer MTA & MCP service, unrelated to Pakistani consumer mobile wallet sadapay.pk).*\n\nBefore deploying any autonomous agent with write or send permissions to production, verify:\n\nWhat strategies are you using to prevent agent tool retry storms in your production stacks? Let me know in the comments below!", "url": "https://wpnews.pro/news/why-autonomous-ai-agents-enter-infinite-retry-loops-and-how-to-build-sha-256-in", "canonical_source": "https://dev.to/dev_kestrel/why-autonomous-ai-agents-enter-infinite-retry-loops-and-how-to-build-sha-256-idempotency-arbiters-4e05", "published_at": "2026-11-14 06:12:11+00:00", "updated_at": "2026-11-14 06:21:18.751350+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Cursor", "Claude Code", "LangChain", "CrewAI", "TypeScript", "Node.js"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/why-autonomous-ai-agents-enter-infinite-retry-loops-and-how-to-build-sha-256-in", "markdown": "https://wpnews.pro/news/why-autonomous-ai-agents-enter-infinite-retry-loops-and-how-to-build-sha-256-in.md", "text": "https://wpnews.pro/news/why-autonomous-ai-agents-enter-infinite-retry-loops-and-how-to-build-sha-256-in.txt", "jsonld": "https://wpnews.pro/news/why-autonomous-ai-agents-enter-infinite-retry-loops-and-how-to-build-sha-256-in.jsonld"}}