Say you are shipping a research agent that takes a question, calls a search tool, reads results, and loops until it has enough to answer. It works on your first twenty test prompts. Then a user submits something slightly ambiguous — “summarize the latest guidance on this policy” without naming the policy — and the agent enters a loop that runs for 47 iterations, hits your max-step budget, and returns a stack trace to the UI along with a half-formed answer. You restart it. Same prompt. Same loop. You eventually notice the agent is calling the same search tool with the same query eleven times, each time reading the result, then deciding to search again.

This is the single most common failure class in agent systems: the loop does not crash, it does not error, it simply fails to terminate because the agent cannot recognize that it already has what it needs. Debugging it means treating the agent like any other stateful system with an unbounded retry — instrument the state transitions, identify the invariant that is not being satisfied, patch it, and re-run against the same prompt.

This post walks through one concrete scenario end to end: a ReAct-style agent with a search tool and a calculator tool that stalls on ambiguous queries. The fixes generalize. The instrumentation pattern generalizes more.

The Scenario: A ReAct Agent That Will Not Stop Searching

The agent uses a standard loop: the model is given a system prompt describing the tools, a user message, and the conversation so far. It emits either a tool call or a final answer. If a tool call, the runtime executes the tool, appends the result to the conversation, and calls the model again. The loop terminates when the model emits a final answer or when a step counter exceeds a limit.

The stalling prompt is a follow-up: “What about the second point you mentioned?”

That phrasing is fine for a human in a conversation. It is catastrophic for a stateless agent loop because “the second point” refers to something that was never written into the agent’s context as a structured entity. The search tool does not understand the referent. The model, lacking the resolved referent, assumes it needs more information and searches again. Each resulting search returns tangentially related content that does not resolve the referent, so the model searches again. The pattern is stable: search, read, repeat, up to the step budget.

Instrumenting Before Patching

The instinct is to raise the step limit, add a “stop searching” instruction to the system prompt, or switch to a larger model. All three sometimes help. None of them tell you why the loop is occurring. Before touching the prompt, add a trace layer that logs every model call, every tool call, every tool result, and — critically — whether the state of the conversation has changed materially since the previous iteration.

The cheapest useful trace is a structured event log. Every iteration emits one JSON object. A minimal version looks like this:

{
  "iteration": 7,
  "tool_call": "search",
  "arguments": {"query": "second point policy guidance"},
  "result_hash": "9f2b1c",
  "result_length_chars": 412,
  "tool_called_previously_with_same_args": true,
  "consecutive_iterations_same_tool": 4,
  "agent_reasoning_snippet": "I need more context about the second point."
}

Two fields in that trace answer the diagnostic question directly. tool_called_previously_with_same_args tells you whether the agent is making no progress on the argument space. consecutive_iterations_same_tool tells you whether the agent is stuck on the same action class. When both are true, the loop is not converging and no amount of budget will save it.

You can compute those fields in a few lines. Here is a Python implementation you can drop into the loop’s tool-execution wrapper:

from dataclasses import dataclass, field
from hashlib import sha1
from typing import Any, Dict, List, Optional

@dataclass
class ToolCallRecord:
    tool: str
    args: Dict[str, Any]
    result_hash: str

@dataclass
class LoopTrace:
    history: List[ToolCallRecord] = field(default_factory=list)

    def _signature(self, tool: str, args: Dict[str, Any]) -> str:
        payload = f"{tool}|{sorted(args.items())}".encode("utf-8")
        return sha1(payload).hexdigest()

    def record(self, tool: str, args: Dict[str, Any], result: str) -> Dict[str, Any]:
        sig = self._signature(tool, args)
        result_hash = sha1(result.encode("utf-8")).hexdigest()[:6]

        same_args_seen = any(
            self._signature(r.tool, r.args) == sig for r in self.history
        )
        consecutive_same_tool = 0
        for r in reversed(self.history):
            if r.tool == tool:
                consecutive_same_tool += 1
            else:
                break

        self.history.append(ToolCallRecord(tool, args, result_hash))

        return {
            "tool": tool,
            "same_args_seen_before": same_args_seen,
            "consecutive_same_tool": consecutive_same_tool + 1,
            "result_hash": result_hash,
        }

Once same_args_seen_before flips to true, you know the agent has re-entered a state it already visited. That is the invariant violation. A loop that revisits the same tool-and-argument signature is provably not making progress, and any further iteration is wasted tokens and latency.

Patch One: Detect the Repeat and Break It Deterministically

The most reliable fix is not to ask the model to stop looping. It is to detect the repeated signature in the runtime and force the loop to terminate — or to inject a corrective message into the conversation before the next model call. Models reason about the conversation state they can see; they do not reliably reason about the runtime’s iteration counter.

The intervention looks like this:

MAX_STEPS = 12

def run_agent(user_message: str, tools: Dict[str, callable]) -> str:
    messages = build_initial_messages(user_message)
    trace = LoopTrace()

    for step in range(MAX_STEPS):
        response = model_call(messages, tools)

        if response.stop_reason == "final_answer":
            return response.content

        tool_name = response.tool_name
        tool_args = response.tool_args

        if trace.would_repeat(tool_name, tool_args):
            messages.append({
                "role": "system",
                "content": (
                    "You have already called this tool with these exact "
                    "arguments. The result did not answer your question. "
                    "Either answer with what you have, or call a different "
                    "tool with different arguments."
                ),
            })
            continue

        result = tools[tool_name](**tool_args)
        trace.record(tool_name, tool_args, result)

        messages.append({"role": "tool", "content": result})

    return "Agent exceeded step budget without resolving the task."

The continue skips re-executing the tool, which saves the API round trip for the duplicate call. The system message names the failure explicitly and gives the model two possible resolutions. In practice this converts a 47-iteration stall into a 3- or 4-iteration termination, because the model pattern-matches the injected message against its own behavior and short-circuits.

A trade-off worth understanding: injecting corrective system messages mid-loop costs you one extra model call in the common case. If your agent rarely loops, the extra call is pure overhead. If your agent loops on a meaningful fraction of prompts — 5% is a reasonable rule of thumb for tools with ambiguous natural-language inputs — it pays for itself immediately.

Patch Two: Resolve Ambiguity at the Boundary, Not in the Loop

The repeat-detection fix terminates the loop. It does not fix the underlying problem, which is that “the second point you mentioned” was allowed into the loop without a resolved referent. Every tool the agent calls with unresolved references will produce noisy results, which will produce further loops. The right place to handle this is at the message boundary, before the agent ever runs.

Add a pre-flight classification step: does the incoming user message contain a referent that cannot be resolved from the current session context? If yes, the agent’s first move should be a clarifying question, not a tool call.

REFERENT_CLARIFIER_PROMPT = """
You are a pre-flight check for an agent that has access to a search tool.
Given the user's latest message and the conversation history, decide:

1. Does the message contain a referent ("the second point", "that thing",
   "the previous one") that requires a specific prior item to interpret?
2. If yes, is that prior item present as a distinct, identifiable entity
   in the conversation history?

Respond with one of:
- RESOLVED: <the referent resolved to a specific entity>
- NEEDS_CLARIFICATION: <a short question to ask the user>
"""

def should_clarify(user_message: str, history: list) -> Optional[str]:
    resp = model_call([
        {"role": "system", "content": REFERENT_CLARIFIER_PROMPT},
        {"role": "user", "content": f"History:\n{history}\n\nMessage: {user_message}"},
    ])
    if resp.startswith("NEEDS_CLARIFICATION"):
        return resp.split(":", 1)[1].strip()
    return None

This pattern is sometimes called a “disambiguation gate” or “clarification router.” It adds latency — one extra model call per user message — and it is not always the right choice. For an agent that serves fast, low-stakes interactions (autocomplete, casual chat, search suggestion), the clarification step is friction the user will not tolerate. For an agent that makes tool calls with real cost (paid APIs, database writes, external actions), the gate is almost always worth it.

The decision rule: if a wrong tool call with an unresolved referent could produce a user-visible error or spend meaningful budget, gate at the boundary. If the only cost is a slightly less relevant search result, let the loop self-correct and rely on the runtime repeat-detector as a safety net.

Patch Three: Distinguish “No Progress” From “Slow Progress”

The two fixes above catch the common case: the agent re-calls the same tool with the same arguments. A subtler variant is the agent cycling through a small set of actions in a deterministic pattern — search A, search B, search A, search B. Each individual call has different arguments, so the simple repeat detector misses it. The result hashes, however, will be identical when the tool returns cached or stable content.

The refinement is to track not just the arguments but the pair of (arguments, result_hash). If a (tool, args) signature is seen again, check whether the result is also identical to the previous occurrence. If both match, the agent is in a provably closed cycle and the runtime should break it regardless of whether the arguments differ from the immediately previous call.

def detect_cycle(trace_history: List[ToolCallRecord], new_sig: str, new_hash: str) -> bool:
    for record in trace_history:
        if record.result_hash == new_hash:
            # Same output as a previous call, regardless of arguments, means
            # the tool cannot contribute new information to the loop.
            return True
    return False

This check is stricter than arguments-only comparison and can produce false positives when two distinct queries happen to return identical content — for instance, queries against a small static document. In that case, breaking the loop is still correct: if the tool cannot distinguish two queries, running them again will not help.

When the Loop Failure Is Not About Stalling

The patterns above handle the stall-by-repetition class. Two other loop failures deserve a mention because they look similar in the logs and require different fixes.

Silent truncation loops. The agent emits a tool call, the tool returns a result longer than the model’s context can absorb alongside the existing conversation, and the model — with the tail of the result cut off — re-issues the same call thinking it never received an answer. The trace will show identical arguments and a result length that exceeds the previous iteration’s usable window. The fix is not a loop-level change; it is truncation handling. Either chunk the tool result and stream it back in pieces, or summarize it with a secondary model call before appending. Raising the step budget makes this worse because each iteration pushes more tokens out of the window.

Constraint drift in long sessions. Turn 3 sets a constraint (“only use sources from the last 12 months”). By turn 14, the constraint has fallen out of the model’s effective attention window, and the agent has silently discarded it. This is not a loop in the code sense — the agent is still terminating — but it produces a loop-shaped symptom in user reports (“it kept giving me old sources”). The fix is a periodic constraint re-injection: at every fifth iteration, re-append the constraints the agent is expected to honor. If you use a system prompt, re-state the constraint in a system message mid-loop rather than relying on the initial system prompt, whose effect decays with distance.

The Debugging Checklist

When an agent loop misbehaves, the order of investigation that resolves it fastest is:

First, check whether the loop is repeating signatures. If a (tool, arguments) pair or a result hash has reappeared, you have a stall. Patch the runtime to detect and break it before touching the prompt.

Second, check whether the incoming message had an unresolvable referent. If it did, the correct fix is at the boundary — a disambiguation gate — and any loop-level patch is a bandage over a root cause that will recur.

Third, check whether any tool result exceeds the model’s usable context window. If results are being silently truncated, no amount of loop tuning helps; truncate or summarize at the tool boundary.

Fourth, check the step budget against the trace’s actual iteration count on successful runs. If successful runs typically complete in 4 or 5 steps and your budget is 25, you have headroom. If successful runs use 15 of 25, your budget is masking real stalls and the repeat detector should be tightened.

Fifth, only then consider model or prompt changes. Larger models loop less on average, but they loop for the same reasons and the runtime fixes above work regardless of the underlying model. The prompt is the last lever, not the first.

None of the patches here require a specific framework. They are runtime concerns — state tracking, cycle detection, boundary validation — and they belong next to the tool execution code, not in the system prompt. Prompts steer behavior probabilistically. Runtimes enforce it deterministically. For loop termination, deterministic enforcement is what you want.

One closing note on where these fixes do not apply: if your agent is a single-shot classifier with no tool calls, none of this is relevant. If your agent calls exactly one tool and always terminates after it returns, the repeat detector and cycle check are dead weight. Agent loop debugging becomes worth the instrumentation investment when your agent executes three or more tool calls per task on a meaningful fraction of inputs, because that is where the state space grows large enough to hide a stall. Below that threshold, a step budget and a clear system prompt are usually sufficient.