cd /news/ai-agents/why-autonomous-ai-agents-enter-infin… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-169444] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=↑ positive

Why Autonomous AI Agents Enter Infinite Retry Loops (And How to Build SHA-256 Idempotency Arbiters in TypeScript)

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.

by read6 min views2 publishedNov 14, 2026

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.

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" β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

{ 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. 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);
      }
    }
  }
}

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).

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

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

── more in #ai-agents 4 stories Β· sorted by recency
── more on @cursor 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/why-autonomous-ai-ag…] indexed:0 read:6min 2026-11-14 Β· β€”