n4nAI

Why ReAct agents get stuck in loops (and how to stop it)

Analyzes why a ReAct agent stuck in loop occurs—from ambiguous observations to model repetition—and gives engineers concrete guardrails to break cycles.

n4n Team4 min read893 words

Audio narration

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

A ReAct agent stuck in loop is the most common failure mode in production agent systems, and it is rarely the model’s fault alone. The ReAct pattern couples reasoning traces with action calls, but without explicit cycle breaks the agent will happily repeat the same thought-action-observation triplet until the context window bleeds out.

The anatomy of a ReAct loop

ReAct interleaves natural language reasoning with tool invocations. A typical prompt asks the model to emit:

Thought: I need the weather in Berlin.
Action: get_weather
Action Input: {"city": "Berlin"}
Observation: {"temp": 12, "unit": "C"}
Thought: I have the answer.
Action: Finish
Action Input: {"answer": "12C"}

The agent loop parses Action and Action Input, calls the tool, appends the Observation, and repeats. The simplicity is the trap. If the observation does not change the agent’s belief state, the next sampled thought will likely echo the previous one.

A minimal implementation looks like this:

def react_step(llm, tools, query, history):
    prompt = format_prompt(query, history)
    raw = llm(prompt)
    thought, action = parse_output(raw)
    if action.name == "Finish":
        return thought, None
    obs = tools[action.name](**action.args)
    return thought, obs

That function has no memory of prior steps beyond the growing context. The model is left to self-regulate.

Why a ReAct agent stuck in loop happens

Three root causes show up in every postmortem I have reviewed.

Ambiguous observations

Tools that return free-text errors (“Something went wrong”) give the model no signal to branch. The agent sees a failure, reasons “I should retry”, and calls the same tool. The observation is identical, so the loop is stable. A search tool that returns “No results” without indicating whether the query was malformed produces the same behavior.

Missing termination signal

ReAct prompts often define Finish as the only exit. But Finish requires the model to be confident it has answered. Under uncertainty, models avoid committing and instead take another action that feels productive. A ReAct agent stuck in loop is frequently just a model that fears being wrong more than it fears repeating itself. If the reward signal during training penalized premature answers, the prior pushes toward more actions.

Probability reinforcement

Autoregressive sampling from a context that already contains “Thought: maybe I should search again” makes that continuation the highest-probability token. Each loop iteration appends more weight to the same pattern. Without an external break, the prior compounds. Temperature helps but does not eliminate it; nucleus sampling can still pick the repeated token when the distribution is sharp.

Minimal guard: iteration cap

Every agent needs a hard step limit. This is non-negotiable.

MAX_STEPS = 12

def run_agent(query):
    history = []
    for step in range(MAX_STEPS):
        thought, obs = react_step(llm, tools, query, history)
        history.append((thought, obs))
        if obs is None:  # Finish
            return thought
    return "Agent exceeded step budget"

A cap stops infinite hangs but does not stop a ReAct agent stuck in loop within the budget. Twelve repeated calls still waste tokens and return garbage. Set MAX_STEPS from the task distribution: a SQL generator needs 3–5; a multi-hop research agent may need 25. Measure and tune.

Cycle detection with state hashing

Track the (action, args, observation) tuple. If it repeats, break or force a different path.

seen = set()
for step in range(MAX_STEPS):
    thought, action, obs = react_step_full(llm, tools, query, history)
    if action.name == "Finish":
        return thought
    state_key = (action.name, json.dumps(action.args), obs[:64])
    if state_key in seen:
        # cycle detected
        history.append(("forced-reflection: repeated state", None))
        # switch strategy: try alternative tool or abort
        if len([h for h in history if "forced-reflection" in h[0]]) > 2:
            return "Agent trapped in cycle; escalate"
        continue
    seen.add(state_key)
    history.append((thought, action, obs))

Exact hashing catches strict repetitions. It will miss semantic loops (“search for ‘foo’”, then “search for ’foo ’”) but those are rarer. For high-stakes agents, add a cosine-similarity check on thought embeddings; the extra latency is worth it.

Tradeoff: false positives

Legitimate polling repeats the same action with different observations (e.g., get_job_status(job_id) returns “running” then “done”). Truncate the observation to a stable prefix or exclude status fields from the hash. Design the state key with intent: hash the query and a normalized status, not the raw response.

Structured tool outputs

Convert tool results to typed schemas so the model can reason about them:

{
  "status": "error",
  "code": "RATE_LIMITED",
  "retry_after": 30,
  "detail": "Provider returned 429"
}

Now the prompt can include a rule: “If code is RATE_LIMITED, wait retry_after seconds or call alternative provider; do not repeat immediately.” The ReAct agent stuck in loop on rate limits becomes a handled branch. Enforce the schema in code:

from pydantic import BaseModel

class ToolResult(BaseModel):
    status: str
    code: str | None = None
    retry_after: int | None = None
    detail: str | None = None

Parse tool output into ToolResult before feeding to the model. Malformed output becomes a known PARSE_ERROR rather than silent garbage.

Forced reflection and routing fallbacks

When a backend provider is degraded, the agent may receive timeouts that look like empty observations, prompting blind retries. Routing through an OpenAI-compatible endpoint such as n4n.ai, which automatically falls back across 240+ models when a provider is rate-limited, removes one external trigger but does not address the agent’s internal loop logic. You still need cycle guards.

Implement a reflection injection after N repeated states:

if len([h for h in history if "forced-reflection" in h[0]]) > 1:
    prompt += "\nYou have repeated an action. Choose a different tool or Finish."

This bounds the cost and surfaces the failure instead of masking it. Pair with exponential backoff inside the tool layer so the model sees “error resolved after backoff” not “error, error, error”.

Tradeoffs and when to relax

Strict loop prevention adds latency and code complexity. For agents that legitimately explore (e.g., tree search), disable exact hashing and rely on max steps plus a diversity metric. For customer-facing bots, prefer aggressive cycle breaks; a wrong answer beats a hung session.

Prompt-level fixes help but are not sufficient. Few-shot examples showing a loop being broken train the model to emit Reflection: but they depend on the model noticing. Deterministic guards do not depend on model mood.

Monitoring is part of the solution. Log each (action, obs_hash) and alert when a single hash appears more than twice per session. That telemetry turns random complaints into actionable bugs.

Decisive takeaway

A ReAct agent stuck in loop is a systems bug, not a model quirk. Ship these controls as baseline:

  • Hard step cap sized from real task traces.
  • State-hash cycle detector with semantic fallback for critical paths.
  • Structured tool outputs with explicit error codes and pydantic validation.
  • Forced reflection after two cycle hits, then escalation.
  • Model calls routed through a gateway with fallback to remove provider noise.

Treat any repeated triplet as a signal to break, not to continue. Do that and loop incidents drop to near zero, while token spend becomes predictable.

Tagsreact-patternai-agentsdebuggingreliability

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 react & reasoning-action loops posts →