Anyone who has built or deployed an autonomous coding agent has watched the exact same failure sequence unfold.
The agent starts out sharp. In turns 1 through 4, it parses the prompt, finds the relevant source files, and outlines a clean approach. Then, around turn 6, a shell command throws an unexpected error. Maybe a compiler fails with a missing type definition, or an integration test fails because a mock database port was not exposed.
Instead of taking a step back and inspecting the environment, the agent enters a death spiral. It runs the exact same test command with a trivial flag variation. That fails. It adds an unnecessary comment or changes a variable name in an unrelated file, runs the command again, and fails again. By turn 18, it has burned 90,000 tokens, corrupted three files that had nothing to do with the original bug, and is now arguing with its own prior reasoning traces.
Why does an LLM with billions of parameters, capable of writing complex algorithms from scratch, become remarkably stubborn when caught in a loop?
The short answer: agent loops are not random hallucinations. They are the predictable mathematical consequence of the ReAct execution pattern, autoregressive token sampling, and context window pollution.
Here is what is actually happening underneath your agent framework when an agent gets stuck, and the deterministic mechanisms required to break the cycle.
The Anatomy of the ReAct Loop
Most modern agent frameworks build on the ReAct framework, first formalized by Shunyu Yao et al. in 2022.
The core idea of ReAct is interleaving reasoning traces (Thought) and domain-specific actions (Action):
+-------------------------------------------------------------+ | User Task / Prompt | +-------------------------------------------------------------+ | v +-------------------------------------------------------------+ | [Turn 1] LLM Generation | | Thought: I need to locate where auth tokens are validated. | | Action: file_search(pattern="*auth*") | +-------------------------------------------------------------+ | v +-------------------------------------------------------------+ | External Environment Execution (Bash, Tools, API) | | Observation: ["src/auth/jwt.py", "src/auth/middleware.py"] | +-------------------------------------------------------------+ | v +-------------------------------------------------------------+ | [Turn 2] LLM Context = Prompt + Turn 1 + Observation | | Thought: I should check middleware.py for token expiration. | | Action: read_file(path="src/auth/middleware.py") | +-------------------------------------------------------------+ Here is the critical architectural detail that gets overlooked: an LLM is completely stateless.
The model has no persistent working memory between turns. The entire state of the agent at turn N is literally the raw array of text tokens accumulated across turns 1 through N-1.
When turn 8 runs, the prompt sent over the wire looks like this:
[System Prompt] [User Instruction] [Turn 1: Thought -> Action -> Observation] [Turn 2: Thought -> Action -> Observation] ... [Turn 7: Thought -> Action -> Observation] Every single character of past output, every bash traceback, and every failed parameter edit is fed directly into the model attention heads on every subsequent step.
The Three Mechanics That Cause the Loop
Once you understand that the context window is the sole medium for agent memory, the breakdown of long-horizon reasoning becomes easy to trace. Three distinct mechanisms drive the failure spiral.
1. In-Context Reinforcement and Autoregressive Priors
Autoregressive models predict tokens sequentially by sampling from a conditional probability distribution:
P(w_t | w_1, w_2, ..., w_{t-1})
Transformers use attention mechanisms to decide which parts of the prior sequence inform the next token. Modern models are explicitly fine-tuned on few-shot demonstrations to recognize and repeat patterns found in their context.
When an agent executes an action that fails, that failure enters the prompt history. If the agent makes a second similar attempt that also fails, the context window now contains two consecutive demonstrations of the agent executing a near-identical tool call in response to an error.
Turn 4: Action: run_command("pytest tests/test_auth.py") Observation: FAIL (AssertionError: expected 200, got 401) Turn 5: Thought: Let me retry with verbose mode. Action: run_command("pytest -v tests/test_auth.py") Observation: FAIL (AssertionError: expected 200, got 401) At Turn 6, the model attention heads attend to the repeated n-grams in the recent history. Rather than considering an orthogonal approach (such as inspecting the mock authentication fixture), the model assigns highest probability to another variation of the pytest command. The presence of repetitive actions in the context acts as an autoregressive prior that forces the model to keep repeating them.
The model is not trying to be stubborn. It is doing exactly what it was trained to do: completing the in-context pattern.
2. Context Window Poisoning
A simple tool error is manageable. Context poisoning is far more dangerous.
Context poisoning occurs when an incorrect premise or a hallucinated assumption enters the reasoning trace and is subsequently treated as ground truth by future turns.
Consider this sequence:
- At Turn 5, an agent runs a regex search to find a database schema file and receives empty results because its regex was slightly malformed.
- The agent outputs:
Thought: The users table does not have an email column yet. I need to create a migration. - In reality, the column already exists in
migrations/0012_add_users.sql. - At Turn 6, the agent writes a new migration adding the column.
- At Turn 7, the migration fails with
duplicate column name: email. - Instead of questioning its deduction from Turn 5, the agent assumes the database runner is corrupted. It attempts to rewrite the database initialization script or write custom SQL workarounds.
Because the model treats all prior tokens as factual context, it cannot easily invalidate a premise it established three turns ago. It rationalizes the conflict by inventing increasingly complex explanations for why the reality does not match its hallucinated premise.
3. The Oscillation Trap (Thrashing)
Another frequent manifestation of the agent loop is oscillation: State A -> State B -> State A -> State B.
This happens when a task has two mutually coupled constraints.
For example, a TypeScript project has a strict linter and a strict type checker.
- The agent modifies a function to fix a linting error (such as removing an explicit
any). - Running
tscnow produces a type mismatch error. - The agent updates the function signature to fix the type mismatch, re-introducing the pattern flagged by the linter.
- Running the linter throws the original lint error.
Because the ReAct loop evaluates progress only against the immediate prior observation, the agent has no memory of the global oscillation. Each local step looks like progress: "I am fixing the error that was just shown to me." Without a global diff tracker, the agent can cycle between two conflicting files until its token quota is exhausted.
The Error Recovery Paradox
Recent empirical studies on autonomous agent failure modes show how widespread this issue is.
In a benchmark analysis of over 42,000 production agent trajectories across multiple industry harnesses (such as the Wink study and MAST benchmark evaluations), researchers found that:
- Repetitive loops and unbounded retries account for roughly 15% to 30% of all agent failures.
- Unconstrained error recovery logic was the leading cause of runaway token consumption.
This reveals what can be termed the Error Recovery Paradox:
The code you write to handle failure is often the code that causes the agent to never stop.
When engineers build an agent harness, the instinct is to write defensive retry loops:
# The naive approach that causes loops while not task_completed and turns < max_turns: thought, action = agent.predict(history) obs, success = execute_tool(action) history.append((thought, action, obs)) # If it failed, we just feed the error right back into history If an error is non-retryable (such as a missing system library or an invalid API token), feeding the error back to the model twenty times will produce twenty failed variations of the same command. The harness is paying API bills to watch an algorithm bang its head against a wall.
Architectural Defenses: How to Break the Failure Spiral
Breaking agent loops requires deterministic software boundaries outside the LLM. You cannot prompt your way out of context poisoning; by the time the model has entered a loop, the corrupted context makes it incapable of following prompt instructions to stop.
Here are the four deterministic defenses that robust agent runtimes use.
| Defense Layer | Mechanism | Action on Trigger |
|---|---|---|
| Tool Signature Hashing | SHA-256 hash of normalized (tool_name, args) | Reject identical repeated calls; force alternative |
| N-gram Cycle Detection | Sliding window over recent action signatures | Detect A -> B -> A -> B oscillation patterns |
| Context Surgery | Prune large raw stdout dumps from the message list | Keep error signatures compact; avoid attention rot |
| State Progress Hashing | Hash external environment state (git diff, test status) | Abort if state remains unchanged across 3 turns |
Let us look at how these work in practice.
1. Tool Call Signature Hashing
The simplest and most effective safeguard is an external tool call ledger. Before executing any tool call, compute a normalized hash of the action name and its arguments.
import hashlib import json def get_action_signature(tool_name: str, arguments: dict) -> str: # Sort keys to ensure deterministic representation normalized = json.dumps({"tool": tool_name, "args": arguments}, sort_keys=True) return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12] If an agent attempts to invoke the exact same tool with the exact same arguments twice in a row, the harness must intercept the call before execution:
- Do not execute the tool.
- Return an explicit system warning:
"Error: You have already executed this exact command with identical arguments and received result X. You are forbidden from repeating it. Formulate a different approach."
2. Detecting Multi-Step Cycles (N-gram Oscillation)
Simple equality checks only catch immediate consecutive repeats (A -> A). They miss oscillation cycles (A -> B -> A -> B or A -> B -> C -> A -> B -> C).
To catch these, maintain a rolling history of action signatures and check for repeating subsequences:
def detect_action_cycle(history: list[str], max_cycle_len: int = 3) -> bool: n = len(history) for k in range(1, max_cycle_len + 1): if n >= 2 * k: pattern = history[-k:] previous = history[-2*k : -k] if pattern == previous: return True return False If history contains ["edit_file_a", "run_linter", "edit_file_a", "run_linter"], the detector flags a cycle of length 2 immediately on the fourth turn.
3. Context Surgery over Naive Compaction
When context windows fill up, many developers trigger an LLM summarization step. This often makes things worse. Summarization models tend to preserve high-level narrative sentences while silently discarding negative constraints (such as "never edit package.json" or specific compiler version flags).
Instead of summarizing the whole conversation with another LLM, perform deterministic context surgery:
- Quarantine stdout: If a terminal command returns a 4,000-line stack trace, never inject all 4,000 lines into the prompt. Write the full trace to a disk log, extract the last 15 lines and the primary error name, and pass only that excerpt to the agent.
- Prune failed intermediate branches: If an agent made three attempts to edit a file that resulted in syntax errors before finding a working syntax, strip the failed intermediate attempts from the history. Keep only the final valid edit. This cleanses the prompt of poisoned tokens.
Building a Lightweight Context Circuit Breaker
Here is a minimal, standalone Python implementation of a context circuit breaker that you can drop into any custom agent execution loop:
import hashlib import json from collections import deque from typing import Any, Optional class AgentCircuitBreaker: def __init__(self, repeat_threshold: int = 2, max_cycle_len: int = 3): self.repeat_threshold = repeat_threshold self.max_cycle_len = max_cycle_len self.signature_history: list[str] = [] self.recent_calls: deque[str] = deque(maxlen=20) self.consecutive_repeats: int = 0 self.last_signature: Optional[str] = None def _normalize(self, tool_name: str, arguments: dict[str, Any]) -> str: payload = {"name": tool_name.strip(), "args": arguments} serialized = json.dumps(payload, sort_keys=True, default=str) return hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:16] def record_and_evaluate(self, tool_name: str, arguments: dict[str, Any]) -> tuple[bool, str]: sig = self._normalize(tool_name, arguments) # Check consecutive identical tool call if sig == self.last_signature: self.consecutive_repeats += 1 if self.consecutive_repeats >= self.repeat_threshold: return False, f"Circuit Breaker Tripped: Tool '{tool_name}' invoked {self.consecutive_repeats} times with identical inputs." else: self.consecutive_repeats = 1 self.last_signature = sig self.signature_history.append(sig) self.recent_calls.append(sig) # Check for oscillating n-gram cycles (e.g., A -> B -> A -> B) n = len(self.signature_history) for k in range(2, self.max_cycle_len + 1): if n >= 2 * k: recent_k = self.signature_history[-k:] prior_k = self.signature_history[-2 * k : -k] if recent_k == prior_k: return False, f"Circuit Breaker Tripped: Detected repetitive cycle of length {k} in agent actions." return True, "OK" When record_and_evaluate returns False, your harness should not let the agent continue blind execution.
Instead, perform an out-of-band pivot:
- Roll back the workspace (using
git checkoutor file snapshots) to the last known healthy state before the loop started. - Strip the repeating cycle from the message history.
- Inject an out-of-band supervisor prompt:
"System intervention: You were caught in a repetitive loop attempting action X. The environment has been rolled back. Action X is now locked. Identify a completely different diagnostic path."
Practical Takeaways for Agent Builders
- The context window is memory, not a scratchpad. Everything you feed into it shapes the model autoregressive sampling distribution. Treat context hygiene with the same discipline as database integrity.
- Never allow an agent to grade its own completion. Autonomous agents will cheerfully report success when tests are red if they convince themselves the test was wrong. Verification must be enforced by deterministic external tools (exit code 0, all tests pass).
- Prune verbosity before it reaches the model. Raw build logs and multi-megabyte API responses do not give the model more intelligence. They dilute attention heads and accelerate context poisoning.
- Enforce hard stopping criteria. A maximum turn limit is a fallback of last resort. Use tool signature hashing, cycle detection, and progress checks to halt runaway agents in 3 turns rather than 30.
