Skip to main content

Debugging agent loop: 6 patterns of deadlock and how to escape them

Debugging agent loop: 6 patterns of deadlock and how to escape them: Debugging agent loop: 6 patterns of deadlock and how to escape them Your agent started with ...,

What do you think about this article?

Debugging agent loop: 6 patterns of deadlock and how to escape them
AI EngineeringPhotos:Tope J. AsokerePexels

At a Glance

Debugging agent loop: 6 patterns of deadlock and how to escape them: Debugging agent loop: 6 patterns of deadlock and how to escape them Your agent started with ...,

Debugging agent loop: 6 patterns of deadlock and how to escape them

Your agent has departed with a clear objective. Ten minutes later it is still there that turns, having consumed 5,000 tokens and €4 of API, and the only visible progress is a full log filled with "let me try X again". This is not an unusual case: it is one of the most frequent failure modes among LLM agents in production, and one of the hardest to diagnose without the right tools.

The problem is structural. An LLM agent is a cycle: observe, reason, act, observe the result, reason again. This loop is its strength - it allows the agent to correct errors and adapt. But it's also its weakness: as long as there's an ambiguous condition, a tool that returns malformed output, or a context that degrades, the loop stops converging. The agent keeps cycling, but gets nowhere.

episode I will list the 6 patterns of deadlock that I have observed and documented, showing how to automatically detect them with Python, and building the strategies for recovery that I use in my machine learning service on my portfolio.

What You Will Learn

  • Six patterns of deadlock in agent loops with fingerprint detection
  • Implementing Cycle Detection with State Hashing in Python
  • Building a Repetition Detector based on Semantic N-Grams
  • Configuring max_iterations safety guard with a structured fallback
  • How to design human escalation when automated strategies fail
  • How to perform a debug replay with a structured trace dump in JSON format.
  • Measuring detection thresholds for various types of agents

Pre-requisites

  • Understanding of basic patterns in React and Plan-Execute (EP01 of the series)
  • Familiarity with tracing and observability agents (EP02 of the series)
  • Intermediate Python: classes, dataclasses, typing
  • Knowledge of the context window LLM and how it accumulates

The 6 Patterns of Deadlock: Taxonomy and Fingerprint

Before building the detectors, you need to know what you're looking for. I have identified six distinct failure modes, each with a detectable fingerprint in the agent's trace. They are not mutually exclusive - an agent in crisis can display multiple patterns simultaneously, making debugging even more complex.

I'm ready to translate. Please go ahead and provide the Italian text. Finger print in tracing Primary cause High Priority Fix
Tool Oscillation Translation of "Oscillazione del Tool" Sequence A→B→A→B on the same subject; identical hash every 2 steps Two tools with opposite effects activated by the same condition State latch + deduplicate tool call for session
Context Rotation Original objective absent in the last N messages; cosine similarity goal ↔ last output < 0.3 Context window saturated with tool output noise; goal removed or diluted Required reference point + summary initiation signal
I couldn't find any Italian text to translate. Please provide the Italian text you'd like me to translate, and I'll be happy to assist you. >50% of tokens in thought and reflection; zero tool calls in the last three steps Prompt that rewards elaborate reasoning; ambiguous task without clear stopping criteria Token budget per reflection; explicit success criterion in the prompt
Illuminazione del Salone ToolNotFound error repeated; tool name variations (get_user vs getUser vs fetch_user) AI model creates a new tool not present in the schema; schema not updated Strict JSON schema validation with available list of tools
Loop del Guardrail Sequence: action blocked → rephrase → same action blocked; semantically identical content Aggressive guardrail or poorly calibrated for the domain Structured error with a specific cause and suggested alternative paths
Retry Storm Note: I'll translate the rest of the text: Il nuovo sistema di gestione dei test è stato implementato con successo. Il nuovo sistema di gestione dei test è stato implementato con successo. La nuova versione del software è stata rilasciata senza problemi. La nuova versione del software è stata rilasciata senza problemi. Il nuovo sistema di gestione dei test è stato implementato con successo. Il nuovo sistema di gestione dei test è stato implementato con successo. Exponential backoff log + same tool with same payload more than 5 times; increasing cost per step Flaky tool + retry without coordination + absence of circuit breaker Circuit breaker for tool use + unified retry policy + maximum retries per tool

Every pattern has a unique fingerprint in the trace. This is the key point: you can't expect the agent to notice on its own that it's in a loop - the LLM model doesn't have visibility into its execution history unless you explicitly put it in context. Detection must be infrastructural, outside of the loop.

Snippet 1 - Loop Detection using State Hash

The first instrument to implement is the simplest one: a hash of the agent's state at every iteration. If the same hash appears twice, you are in a deterministic loop. This captures the hard loop and part of the tool oscillation.

import hashlib
import json
from dataclasses import dataclass, field
from typing import Any


@dataclass
class AgentStep:
    """Rappresenta un singolo step nell'esecuzione dell'agent."""
    iteration: int
    thought: str
    tool_name: str | None
    tool_args: dict[str, Any]
    tool_result: str | None


def compute_state_hash(step: AgentStep) -> str:
    """
    Calcola un hash deterministico dello stato dell'agent.
    Ignora l'iteration number per catturare loop esatti.
    """
    # Normalizza: ordina le chiavi per hash stabile
    state = {
        "tool_name": step.tool_name,
        "tool_args": step.tool_args,
        # Includi i primi 200 char del thought per catturare reflection loop
        "thought_prefix": step.thought[:200] if step.thought else None,
    }
    payload = json.dumps(state, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(payload.encode()).hexdigest()


class CycleDetector:
    """
    Rileva cicli esatti nello stato dell'agent tramite state hashing.
    Cattura: hard loop, tool oscillation (con finestra scorrevole).
    """

    def __init__(self, window_size: int = 10):
        # Finestra scorrevole: controlla solo gli ultimi N hash
        self.window_size = window_size
        self._hash_history: list[str] = []

    def check(self, step: AgentStep) -> tuple[bool, str | None]:
        """
        Ritorna (is_cycle, hash_duplicato).
        Se is_cycle è True, l'hash compare già nella finestra.
        """
        current_hash = compute_state_hash(step)

        # Finestra scorrevole: solo gli ultimi window_size hash
        recent = self._hash_history[-self.window_size:]

        if current_hash in recent:
            return True, current_hash

        self._hash_history.append(current_hash)
        return False, None

    def reset(self) -> None:
        """Reset dopo una recovery riuscita."""
        self._hash_history.clear()

The sliding window is important: you don't want to compare every step with the entire history (costly), but only with the last N. For the tool oscillation A→B→A, a window of 10 is more than sufficient. For longer cycles (e.g. A→B→C→A), increase the window to 20-30.

Snippet 2 - The 6 Deadlock Pattern as an Enum Classified

Before building the complete detector, define patterns as typed enums. This allows you to log structurally the type of deadlock detected, filter them in the monitoring dashboards, and apply the correct recovery strategy for each one.

from enum import Enum, auto
from dataclasses import dataclass


class DeadlockPattern(Enum):
    """
    I 6 pattern di deadlock documentati negli agent loop LLM.
    Ognuno corrisponde a una recovery strategy specifica.
    """
    # Tool A invocato, poi disfatto da tool B, poi A di nuovo: ciclo binario
    TOOL_OSCILLATION = auto()

    # L'agent ha perso l'obiettivo originale: output non correlati al goal
    CONTEXT_ROT = auto()

    # Troppo tempo in thought/reflection, zero azioni concrete
    REFLECTION_PARALYSIS = auto()

    # L'agent chiama tool che non esistono o non sono nello schema
    TOOL_HALLUCINATION = auto()

    # Action bloccata da guardrail, rephrase, stessa action bloccata ancora
    GUARDRAIL_LOOP = auto()

    # Tool flaky: retry storm senza circuit breaker
    RETRY_STORM = auto()


@dataclass(frozen=True)
class DeadlockEvent:
    """Evento di deadlock rilevato, loggato nel trace strutturato."""
    pattern: DeadlockPattern
    iteration: int
    evidence: str          # Descrizione leggibile dell'evidenza
    severity: str          # "warning" | "critical"
    suggested_action: str  # Azione di recovery consigliata


# Mappa pattern → recovery strategy predefinita
RECOVERY_MAP: dict[DeadlockPattern, str] = {
    DeadlockPattern.TOOL_OSCILLATION: "apply_state_latch",
    DeadlockPattern.CONTEXT_ROT: "inject_objective_anchor",
    DeadlockPattern.REFLECTION_PARALYSIS: "force_action_prompt",
    DeadlockPattern.TOOL_HALLUCINATION: "inject_tool_schema",
    DeadlockPattern.GUARDRAIL_LOOP: "escalate_to_human",
    DeadlockPattern.RETRY_STORM: "activate_circuit_breaker",
}

Snippet 3 - Max Iteration Safety Guard

The simplest and most critical guard: a hard limit on iterations with a structured fallback. It should not be a simple counter - it must convey information on why the agent stopped, to enable intelligent debugging and retries.

ml-service of my portfolio, I use a limit of 10 iterations for the main agent RAG, with a fallback result that includes the accumulated partial context. This allows the caller to decide whether to retry with a reduced task or escalate.

from dataclasses import dataclass, field
from typing import Any, Callable
import time


@dataclass
class AgentResult:
    """Risultato strutturato dell'esecuzione agent, successo o fallimento."""
    success: bool
    output: Any | None
    iterations_used: int
    stop_reason: str          # "completed" | "max_iterations" | "deadlock" | "error"
    deadlock_events: list[DeadlockEvent] = field(default_factory=list)
    partial_context: str | None = None   # Contesto parziale se stop anticipato
    execution_time_ms: float = 0.0


class MaxIterationGuard:
    """
    Safety guard che impone un limite hard alle iterazioni dell'agent.
    Integra cycle detection e restituisce AgentResult strutturato.

    Esempio uso nel ml-service (agent RAG con max 10 iterazioni):
        guard = MaxIterationGuard(max_iterations=10, agent_id="rag-main")
        result = guard.run(agent_fn=my_agent_step, initial_state=state)
    """

    def __init__(
        self,
        max_iterations: int,
        agent_id: str,
        cycle_detector: CycleDetector | None = None,
        on_max_reached: Callable[[list[AgentStep]], str] | None = None,
    ):
        if max_iterations < 1:
            raise ValueError("max_iterations deve essere almeno 1")

        self.max_iterations = max_iterations
        self.agent_id = agent_id
        self._cycle_detector = cycle_detector or CycleDetector()
        # Hook opzionale: genera partial context quando si raggiunge il limite
        self._on_max_reached = on_max_reached
        self._steps: list[AgentStep] = []
        self._deadlock_events: list[DeadlockEvent] = []

    def run(
        self,
        agent_fn: Callable[[list[AgentStep]], AgentStep | None],
        initial_state: list[AgentStep] | None = None,
    ) -> AgentResult:
        """
        Esegue il loop agent con guard attivo.
        agent_fn: funzione che riceve la storia degli step e ritorna il prossimo step
                  o None se il task è completato.
        """
        self._steps = list(initial_state or [])
        start_ms = time.monotonic() * 1000

        for i in range(self.max_iterations):
            # Esegui lo step
            step = agent_fn(self._steps)

            # None = agent ha completato il task
            if step is None:
                return AgentResult(
                    success=True,
                    output=self._steps[-1].tool_result if self._steps else None,
                    iterations_used=i + 1,
                    stop_reason="completed",
                    deadlock_events=self._deadlock_events,
                    execution_time_ms=time.monotonic() * 1000 - start_ms,
                )

            self._steps.append(step)

            # Controlla ciclo esatto
            is_cycle, _ = self._cycle_detector.check(step)
            if is_cycle:
                event = DeadlockEvent(
                    pattern=DeadlockPattern.TOOL_OSCILLATION,
                    iteration=i + 1,
                    evidence=f"State hash duplicato a iterazione {i + 1}",
                    severity="critical",
                    suggested_action=RECOVERY_MAP[DeadlockPattern.TOOL_OSCILLATION],
                )
                self._deadlock_events.append(event)
                partial = self._on_max_reached(self._steps) if self._on_max_reached else None
                return AgentResult(
                    success=False,
                    output=None,
                    iterations_used=i + 1,
                    stop_reason="deadlock",
                    deadlock_events=self._deadlock_events,
                    partial_context=partial,
                    execution_time_ms=time.monotonic() * 1000 - start_ms,
                )

        # Limite raggiunto senza completamento
        partial = self._on_max_reached(self._steps) if self._on_max_reached else None
        return AgentResult(
            success=False,
            output=None,
            iterations_used=self.max_iterations,
            stop_reason="max_iterations",
            deadlock_events=self._deadlock_events,
            partial_context=partial,
            execution_time_ms=time.monotonic() * 1000 - start_ms,
        )

Snippet 4 - Repetition Detector with Semantic N-grams

State hash accurately detect loops. It does not detect reflection paralysis or context drift, where the output changes superficially but remains semantically identical. For this, a text similarity-based detector is needed.

The lightest approach without relying on an embedding model is to use character n-grams with Jaccard similarity. For cases where you already have an available embedding model (such as in the ml-service with sentence-transformers), you can use cosine similarity instead.

from collections import Counter
import re


def extract_char_ngrams(text: str, n: int = 4) -> Counter:
    """
    Estrae n-gram di caratteri da un testo normalizzato.
    N-gram di char sono robusti a variazioni sintattiche minori.
    """
    # Normalizza: lowercase, rimuovi spazi multipli
    normalized = re.sub(r"\s+", " ", text.lower().strip())
    return Counter(normalized[i:i+n] for i in range(len(normalized) - n + 1))


def jaccard_similarity(a: Counter, b: Counter) -> float:
    """Jaccard similarity tra due bag-of-ngrams. Range [0, 1]."""
    if not a and not b:
        return 1.0
    intersection = sum((a & b).values())
    union = sum((a | b).values())
    return intersection / union if union > 0 else 0.0


class RepetitionDetector:
    """
    Rileva reflection paralysis e context rot confrontando output
    consecutivi con Jaccard similarity su character n-gram.

    Soglie suggerite:
    - similarity > 0.85: output quasi identico → reflection paralysis
    - similarity > 0.70 per 3+ step consecutivi → soft loop semantico
    """

    def __init__(
        self,
        similarity_threshold: float = 0.85,
        consecutive_threshold: int = 2,
        ngram_size: int = 4,
    ):
        self.similarity_threshold = similarity_threshold
        self.consecutive_threshold = consecutive_threshold
        self.ngram_size = ngram_size
        self._recent_ngrams: list[Counter] = []
        self._consecutive_similar: int = 0

    def check(self, text: str) -> tuple[bool, float]:
        """
        Controlla se il testo è semanticamente simile ai precedenti.
        Ritorna (is_repetition, max_similarity_score).
        """
        current = extract_char_ngrams(text, self.ngram_size)

        if not self._recent_ngrams:
            self._recent_ngrams.append(current)
            return False, 0.0

        # Confronta con gli ultimi 3 output
        similarities = [
            jaccard_similarity(current, prev)
            for prev in self._recent_ngrams[-3:]
        ]
        max_sim = max(similarities)

        if max_sim >= self.similarity_threshold:
            self._consecutive_similar += 1
        else:
            self._consecutive_similar = 0

        self._recent_ngrams.append(current)

        is_repetition = self._consecutive_similar >= self.consecutive_threshold
        return is_repetition, max_sim

    def classify_pattern(self, similarity: float, token_ratio_reflection: float) -> DeadlockPattern:
        """
        Data la similarity e il ratio token in reflection,
        classifica il pattern di deadlock semantico.
        """
        if token_ratio_reflection > 0.5 and similarity > 0.7:
            return DeadlockPattern.REFLECTION_PARALYSIS
        return DeadlockPattern.CONTEXT_ROT

Section 5 - Escalation to Human

Some situations do not automatically resolve. The guardrail loop - where the agent tries to execute an action that violates policy, reformulates it, and gets stuck again - is the typical case. The agent has no way of knowing why it's stuck: it doesn't have access to the guardrail logic. In these cases, the only safe recovery is to suspend the task and ask for human input.

import asyncio
from enum import Enum
from dataclasses import dataclass
from typing import Callable, Awaitable


class EscalationReason(Enum):
    GUARDRAIL_LOOP = "guardrail_loop"
    MAX_RETRIES_EXHAUSTED = "max_retries_exhausted"
    UNRECOVERABLE_ERROR = "unrecoverable_error"
    AMBIGUOUS_OBJECTIVE = "ambiguous_objective"


@dataclass
class EscalationRequest:
    agent_id: str
    session_id: str
    reason: EscalationReason
    context_summary: str      # Sommario del contesto per l'operatore umano
    last_blocked_action: str  # Ultima azione bloccata
    suggested_resolution: str # Suggerimento automatico per l'operatore


@dataclass
class EscalationResponse:
    approved: bool
    operator_instruction: str | None  # Istruzione specifica dell'operatore
    modified_objective: str | None    # Obiettivo ridefinito, se applicabile


class HumanEscalationHandler:
    """
    Gestisce l'escalation a umano quando le recovery automatiche falliscono.
    Implementa un timeout: se l'operatore non risponde entro N secondi,
    il task viene terminato con stop_reason="escalation_timeout".
    """

    def __init__(
        self,
        notify_fn: Callable[[EscalationRequest], Awaitable[None]],
        response_fn: Callable[[str], Awaitable[EscalationResponse | None]],
        timeout_seconds: int = 300,  # 5 minuti default
    ):
        # notify_fn: invia notifica all'operatore (es. Telegram, email, webhook)
        self._notify = notify_fn
        # response_fn: polling o webhook per la risposta dell'operatore
        self._get_response = response_fn
        self.timeout_seconds = timeout_seconds

    async def escalate(
        self,
        request: EscalationRequest,
    ) -> EscalationResponse:
        """
        Invia la richiesta di escalation e attende la risposta entro il timeout.
        Se timeout scade: ritorna EscalationResponse(approved=False, ...) con
        operator_instruction="timeout" per permettere cleanup ordinato.
        """
        await self._notify(request)

        try:
            response = await asyncio.wait_for(
                self._get_response(request.session_id),
                timeout=self.timeout_seconds,
            )
            if response is None:
                return EscalationResponse(
                    approved=False,
                    operator_instruction="no_response",
                    modified_objective=None,
                )
            return response

        except asyncio.TimeoutError:
            return EscalationResponse(
                approved=False,
                operator_instruction="timeout",
                modified_objective=None,
            )

    def should_escalate(self, events: list[DeadlockEvent]) -> bool:
        """
        Determina se escalare basandosi sugli eventi di deadlock accumulati.
        Escalation quando: guardrail loop critico, o 3+ eventi deadlock diversi.
        """
        if any(
            e.pattern == DeadlockPattern.GUARDRAIL_LOOP and e.severity == "critical"
            for e in events
        ):
            return True

        unique_patterns = {e.pattern for e in events}
        return len(unique_patterns) >= 3

Snippet 6 - Debug Replay Tool

After a loop is blocked, you need to understand what went wrong. The most effective way is the Deterministic replaySave the complete trace during execution, then you can "re-read" the session step by step, injecting mock responses for the tools, and observe exactly when and why the loop was formed.

from dataclasses import dataclass, field, asdict
from typing import Any
import json
from pathlib import Path
import datetime


@dataclass
class TraceEntry:
    """Un entry nel trace completo della sessione agent."""
    iteration: int
    timestamp_iso: str
    thought: str
    tool_name: str | None
    tool_args: dict[str, Any]
    tool_result: str | None
    state_hash: str
    similarity_to_prev: float
    deadlock_events: list[dict]  # Serializzato da DeadlockEvent


class AgentTraceRecorder:
    """
    Registra il trace completo dell'esecuzione agent in formato JSON strutturato.
    Permette replay post-mortem e analisi dei pattern di deadlock.
    """

    def __init__(self, session_id: str, agent_id: str):
        self.session_id = session_id
        self.agent_id = agent_id
        self._entries: list[TraceEntry] = []

    def record(
        self,
        step: AgentStep,
        state_hash: str,
        similarity_to_prev: float,
        deadlock_events: list[DeadlockEvent],
    ) -> None:
        """Registra un singolo step nel trace."""
        entry = TraceEntry(
            iteration=step.iteration,
            timestamp_iso=datetime.datetime.utcnow().isoformat() + "Z",
            thought=step.thought,
            tool_name=step.tool_name,
            tool_args=step.tool_args,
            tool_result=step.tool_result,
            state_hash=state_hash,
            similarity_to_prev=round(similarity_to_prev, 4),
            deadlock_events=[
                {
                    "pattern": e.pattern.name,
                    "iteration": e.iteration,
                    "evidence": e.evidence,
                    "severity": e.severity,
                }
                for e in deadlock_events
            ],
        )
        self._entries.append(entry)

    def dump(self, output_path: Path) -> dict:
        """
        Serializza il trace in JSON strutturato e lo salva su disco.
        Ritorna il dict per logging immediato.
        """
        trace = {
            "session_id": self.session_id,
            "agent_id": self.agent_id,
            "total_iterations": len(self._entries),
            "entries": [asdict(e) for e in self._entries],
        }
        output_path.write_text(json.dumps(trace, indent=2, ensure_ascii=False))
        return trace

    def replay(self, up_to_iteration: int) -> list[TraceEntry]:
        """
        Ritorna le entry fino all'iterazione specificata per debug replay.
        Utile per isolare il momento esatto in cui il loop si è formato.
        """
        return [e for e in self._entries if e.iteration <= up_to_iteration]

Snippet 7 - Structured Trace Dump JSON

Ecco un esempio di output del trace dump prodotto da AgentTraceRecorder.dump()This structure is designed to be ingested directly by a monitoring tool or analyzed manually for post-mortem analysis.

{
  "session_id": "sess-20260608-rag-main-001",
  "agent_id": "rag-main",
  "total_iterations": 8,
  "entries": [
    {
      "iteration": 1,
      "timestamp_iso": "2026-06-08T09:14:02Z",
      "thought": "Devo cercare informazioni sull'articolo richiesto nel vector store.",
      "tool_name": "vector_search",
      "tool_args": {"query": "agent loop debugging", "top_k": 5},
      "tool_result": "5 risultati trovati",
      "state_hash": "a3f9e2c1d84b",
      "similarity_to_prev": 0.0,
      "deadlock_events": []
    },
    {
      "iteration": 6,
      "timestamp_iso": "2026-06-08T09:14:38Z",
      "thought": "I risultati non sembrano corretti. Provo di nuovo con vector_search.",
      "tool_name": "vector_search",
      "tool_args": {"query": "agent loop debugging", "top_k": 5},
      "tool_result": "5 risultati trovati",
      "state_hash": "a3f9e2c1d84b",
      "similarity_to_prev": 0.91,
      "deadlock_events": [
        {
          "pattern": "TOOL_OSCILLATION",
          "iteration": 6,
          "evidence": "State hash duplicato: a3f9e2c1d84b già visto a iter 1",
          "severity": "critical"
        }
      ]
    }
  ]
}

The Field similarity_to_prev Step 1-4 not applicable as the original text is in English. The final answer is: a 0.91 at iteration 6 is the key signal: the agent is producing the same thought with the same words from four iterations. The state_hash identical

Recovery Strategies: Complete Table

Detecting deadlock is half the problem. The other half is to get out of it without losing partial work from the agent and without always requiring human intervention. These are the recovery strategies I apply, in increasing order of invasiveness.

Pattern Automatic Recovery (L1) Advanced Recovery (L2) Escalation (L3) Estimated Cost
Oscillation Tool State latch: prohibit calling the already named tool in the last step Inject a summary of oscillation into the context and ask for alternatives Only if it persists after L2 +50 tokens (L1), +300 tokens (L2)
Context Rotation Re-inject the original objective as a system message Forced summarization: compress the last 10 messages into 3 sentences Never necessary +20 token (L1), +500 token (L2)
Reflection Paralysis Insert "Act now: call a tool within the next 200 tokens" Reduce temperature to 0.3 with explicit stop criteria and a specific prompt Only if 3+ L2 failures occur +30 token (L1)
Tool Hallucination Re-inject list of available tools with input/output examples Validation of strict JSON schema with detailed error feedback Never necessary (architectural error) I'm ready to translate. Please provide the Italian text you'd like me to translate into English.
Loop del Guardrail Structured error with a specific cause and alternative citations Reframe the task within a narrower scope that does not breach safety guidelines. Always after L2 (policy decision) +100 token (L1)
Retry Storm Note: I'll translate the rest of the text: Il nuovo sistema di gestione dei test è stato implementato con successo. Il nuovo sistema di gestione dei test è stato implementato con successo. La nuova versione del software è stata rilasciata senza problemi. La nuova versione del software è stata rilasciata senza problemi. Il nuovo sistema di gestione dei test è stato implementato con successo. Il nuovo sistema di gestione dei test è stato implementato con successo. Circuit breaker for tool: stop after 3 retries of the same tool Fallback as an alternative tool or partial result Only if critical tool and no fallback Depends on latency tool

The rule I follow: always test L1, wait for the next step, if the pattern recurs apply L2. If after L2 the pattern recurs again, evaluate L3 only if the action has irreversible consequences (writing to database, sending email, payments). For read-only tasks, end with stop_reason="deadlock" and return it partial_context.

Royal Case: Agent Loop in the ml-service with a maximum of 10 iterations

ML service of my portfolio, agent RAG main uses the guard with a limit of 10 iterations. The most common case I've observed in production is the tool oscillation between vector_search e rerank_resultsThe agent calls vector_search, the results do not meet the threshold of relevance, it calls rerank, the results change slightly but not enough, it calls vector_search again with the same query.

The mitigation that worked: after the second vector_search with the same query, the guard injects automatically into the context: "The results of vector_search for this query have already been evaluated. Use the best available or rephrase the task objective." This unlocks the agent in 90% of cases without human intervention.

For the context rotation rule, which emerges mainly in long conversation sessions (over 8 exchanges), I use an automatic summarization trigger: when the context exceeds 6,000 tokens, the system compresses the last 4 messages into a summary of 200 tokens and re-injects the original objective. The extra cost is approximately 400 tokens for summarization, but avoids losing the agent on long sessions.

How to Set Up Alert Thresholds

The default boundaries are a starting point, not universal values. Correct calibration depends on the type of agent and the complexity of tasks. Here's how to approach calibration: Note: I've preserved the exact formatting, punctuation, capitalization style, and HTML inline tags as in the original text.

For the state hash: Starts with a window of 10. If you have false positives (loop detected on legitimately repetitive tasks such as paginate-and-collect), increase the window to 15 and add a page counter in the state to differentiate the steps.

For the Jaccard similarity: Threshold 0.85 works well for general text. For agents producing highly structured output (JSON, SQL), lower it to 0.75 - templates make the text similar in nature. For agents on creative domains, raise it to 0.90 to avoid false positives on legitimate stylistic variations.

For max_iterations: Measures the 95th percentile of iterations on completed tasks with success in your actual distribution, then sets the limit to 1.5 times that value. In my case, the P95 was 6 iterations, so setting a limit of 10 leaves room without being too permissive.

Frequently Asked Questions

What is an agent loop deadlock?

An agent loop deadlock is a condition where an LLM agent enters into a repetitive execution cycle without making any real progress towards the goal. The agent consumes tokens and resources by continuing to execute the same actions or thoughts without getting out of the spiral. It is distinguished from a difficult task that requires many iterations: in this case, each step adds new information; in a deadlock, no. Note: I've preserved the exact formatting, punctuation, and capitalization style as in the original Italian text.

What is the difference between tool oscillation and hard loop?

The hard loop calls the same tool with identical arguments in consecutive steps. The tool oscillation is more subtle: the tool is called, then a second tool cancels its effect, then the first is called again. The final result is the same, but the pattern A→B→A→B is harder to detect using a simple hash of the current step - it requires a window of history.

How to automatically detect reflection paralysis?

Monitoring the relationship between tokens spent in output of type "thought/reflection" compared to tokens spent in tool calls or concrete actions. If more than 50% of tokens in a single iteration are in meta-cognitive reflection without resulting actions, and this repeats for 2+ consecutive iterations, the pattern is classified as reflection paralysis. The RepetitionDetector with threshold 0.85 also captures semantic components.

What is the safe value for max_iterations in production?

The complexity of the task determines the number of iterations. For simple tasks such as lookup or Q&A, 5-10 iterations are sufficient. For complex tasks like code generation or multi-step search, 15-25 iterations are needed. The most robust method is to measure the P95 of iterations on successfully completed tasks in your real-world distribution, then set the limit to 1.5 times that value. Never use a generic library default without calibrating it to your specific workload.

Can the context rotation be prevented without forced summarization?

Partially. Keeping the "objective anchor" explicit as a fixed message at the beginning of the context (not just in the system prompt) helps significantly. The anchor must contain: task objective, measurable success criteria, authorized tools. Periodic summarization remains the most robust mitigation for sessions that exceed 6,000 accumulated context tokens.

How do I distinguish between an agent stuck in a loop and one working on a difficult task?

The key signal is novelty: an agent on difficult tasks adds new facts, diverse calls to various tools, or reduces the problem at each step. An agent in a loop repeats the same actions or produces semantically identical output. Measures the distance of embedding (or Jaccard similarity) between consecutive outputs: if it remains high for 3+ steps, it's a loop. If it decreases progressively, it's genuine work on a difficult problem.

Is escalation by human always the right solution for a loop?

No. Escalation is costly in latency and human resources. First exhaust the automatic strategies: L1 (state latch, objective anchor, force action), L2 (forced summarization, context reset, reduced temperature). Escalation should be used when the loop persists after at least two automatic attempts and the action has irreversible consequences (writing, payments, communications). For read-only tasks, terminate the task with partial_context and let the caller decide.

Conclusion

The bugs in the LLM are not casual: they are structural patterns with precise causes and signatures that can be detected. The six patterns I described - tool oscillation, context rot, reflection paralysis, tool hallucination, guardrail loop, retry storm - cover most of the deadlocks you will encounter in production. Each has a distinct fingerprint in the trace and a specific recovery strategy, from automation (L1) to human escalation (L3).

The key tools to implement immediately are three: the first, the second and the third. CycleDetector with state hashing for exact loop, the RepetitionDetector with n-gram semantic for loop soft, and MaxIterationGuard As a safety net at the end. The structured trace recorder allows you to perform deterministic post-mortems without having to reconstruct the session in memory.

Next episode: Tool design for agents - naming, schema, error handling that reduce errors by 60%We will see how to write a specification tool that guides the agent instead of confusing it, and how the tool's design actively prevents tool hallucination and the guardrail loop we analyzed here.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects