n4nAI

How to prevent multi-agent systems from looping forever

Practical steps to detect and stop a multi-agent infinite loop in production orchestration, with code for timeouts, counters, and cycle detection.

n4n Team3 min read682 words

Audio narration

Coming soon — every post will get a voice note here.

A multi-agent infinite loop will burn tokens and freeze your pipeline faster than any single bad prompt. In orchestration code, agents calling agents without termination guards turn a trivial task into a runaway process that saturates your rate limits. This guide gives concrete steps to instrument, bound, and break those cycles before they reach production.

Step 1: Define explicit termination conditions

Every agent loop needs a declared stop. Too many frameworks treat “the model says STOP” as sufficient. It isn’t. Models hallucinate continuation or emit malformed terminators.

Write a state machine, not a while True. Enumerate the terminal states up front.

from enum import Enum, auto

class AgentState(Enum):
    IDLE = auto()
    PLANNING = auto()
    EXECUTING = auto()
    AWAITING_TOOL = auto()
    DONE = auto()
    FAILED = auto()

def transition(current: AgentState, event: str) -> AgentState:
    if current == AgentState.PLANNING and event == "plan_ready":
        return AgentState.EXECUTING
    if current == AgentState.EXECUTING and event == "task_complete":
        return AgentState.DONE
    if current == AgentState.EXECUTING and event == "error":
        return AgentState.FAILED
    # no transition back to PLANNING from EXECUTING without explicit reset
    raise ValueError(f"Illegal transition {current} -> {event}")

The key: forbid implicit re-entry to the planning state. If an agent finishes execution, it either terminates or fails. It does not silently spawn a new plan.

Step 2: Implement a global step counter and token budget

A multi-agent infinite loop often hides as slow growth: each iteration adds a small tool call. Cap total steps across the whole orchestration, not per agent.

class OrchestrationBudget:
    def __init__(self, max_steps: int, max_tokens: int):
        self.steps = 0
        self.tokens = 0
        self.max_steps = max_steps
        self.max_tokens = max_tokens

    def charge_step(self, n=1):
        self.steps += n
        if self.steps > self.max_steps:
            raise RuntimeError("Step budget exceeded")

    def charge_tokens(self, used: int):
        self.tokens += used
        if self.tokens > self.max_tokens:
            raise RuntimeError("Token budget exceeded")

Wire this into your agent runner. Every LLM call and every tool invocation calls charge_step. Every token returned from the API calls charge_tokens. When the budget throws, catch it at the top level and emit a structured failure.

budget = OrchestrationBudget(max_steps=50, max_tokens=200_000)

try:
    for task in queue:
        run_agent(task, budget)
except RuntimeError as e:
    logger.error("orchestration halted", reason=str(e))
    emit_alert("multi-agent loop bounded", str(e))

Set max_steps relative to expected depth. For a three-agent pipeline with two round trips, 50 is generous. If you see loops, lower it.

Step 3: Add cycle detection on the agent call graph

Agents calling each other create directed edges. A cycle in that graph is a guaranteed multi-agent infinite loop unless broken externally.

Maintain a call stack per root request. Before dispatching to a child agent, check if that agent is already in the active path.

from typing import Set, Dict, List

class CallGraph:
    def __init__(self):
        self.edges: Dict[str, List[str]] = {}
        self.active: Set[str] = set()

    def enter(self, agent_id: str, parent: str | None):
        if agent_id in self.active:
            raise RuntimeError(f"Cycle detected: {agent_id} already active")
        self.active.add(agent_id)
        if parent:
            self.edges.setdefault(parent, []).append(agent_id)

    def exit(self, agent_id: str):
        self.active.discard(agent_id)

Use it as a context manager around each agent invocation:

from contextlib import contextmanager

@contextmanager
def agent_span(graph: CallGraph, agent_id: str, parent: str | None):
    graph.enter(agent_id, parent)
    try:
        yield
    finally:
        graph.exit(agent_id)

If agent A calls B, B calls C, and C calls A, the third enter raises. You get a stack trace, not a hang.

Step 4: Use hard timeouts and deadlock breakers

Even with counters, a single agent can block on a network call. Set a per-agent timeout and a wall-clock deadline for the whole tree.

import signal

class TimeoutError(Exception):
    pass

def timeout_handler(signum, frame):
    raise TimeoutError("agent exceeded wall clock")

signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(30)  # 30 seconds max for this agent

try:
    result = blocking_llm_call()
finally:
    signal.alarm(0)

For distributed systems, use an external deadline propagated via headers. If a child misses the deadline, the parent cancels via asyncio.Task.cancel() or a message queue TTL.

A deadlock breaker: if no agent has made progress (zero state transitions) for N seconds, kill the root. Progress means a step charge or token charge. Track last_progress_ts.

import time

class ProgressWatcher:
    def __init__(self, stale_sec: int):
        self.last = time.monotonic()
        self.stale_sec = stale_sec

    def tick(self):
        self.last = time.monotonic()

    def check(self):
        if time.monotonic() - self.last > self.stale_sec:
            raise RuntimeError("No progress: possible deadlock")

Step 5: Route through a resilient gateway to avoid retry storms

A frequent cause of looping is a provider returning 429 or 500, and the agent code catching the error and retrying with the same parameters forever. Instead of hand-rolling retries, send traffic through a gateway that fails fast or falls back.

n4n.ai provides automatic fallback when a provider is rate-limited or degraded, so your orchestration sees a single successful response or a clean error instead of a retry spiral. Honor its cache-control hints to skip redundant generations.

import openai

client = openai.OpenAI(
    base_url="https://api.n4n.ai/v1",
    api_key="YOUR_KEY"
)

# single call, gateway handles fallback
resp = client.chat.completions.create(
    model="anthropic/claude-3.5-sonnet",
    messages=[{"role": "user", "content": "summarize"}],
    extra_headers={"x-n4n-cache": "read"}  # forward cache hint
)

If you must self-host retries, use exponential backoff with jitter and a max attempt count wired to the budget from Step 2.

import random, time

def backed_off_call(fn, max_attempts=3):
    for i in range(max_attempts):
        try:
            return fn()
        except TransientError:
            time.sleep((2 ** i) + random.random())
    raise RuntimeError("retries exhausted")

Step 6: Emit observability signals and alert on loop patterns

You cannot fix a multi-agent infinite loop you cannot see. Log every state transition with a correlation id and the current step count.

{
  "ts": "2025-04-12T10:22:01Z",
  "root_id": "req_123",
  "agent": "planner",
  "state": "EXECUTING",
  "step": 14,
  "tokens": 4200
}

Ship these to a log store and build a simple detector: if step count increments without a DONE state for more than K entries, page on-call.

from collections import defaultdict

step_counts = defaultdict(int)

def on_log(entry):
    if entry["state"] not in ("DONE", "FAILED"):
        step_counts[entry["root_id"]] = entry["step"]
        if entry["step"] > 40:
            alert(f"possible loop on {entry['root_id']}")

A dashboard showing steps-per-root over time makes loops obvious: a flat line at the cap means your breaker works; a climbing line means you missed a path.

Verify success

Deploy the budget and cycle detector in a staging run with a deliberately broken agent that loops. Confirm the process raises RuntimeError within the step limit and the call graph logs a cycle. Then run a normal workload for 24 hours and check that no root_id exceeds 80% of max_steps. If alerts fire on real traffic, tune the constants—do not remove the guards.

That is the whole defense: explicit terminals, global budgets, cycle detection, timeouts, resilient routing, and telemetry. Implement them as a library and wrap every agent you ship.

Tagsmulti-agent-orchestrationdebuggingreliability

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All multi-agent orchestration patterns posts →