Skip to content

Command Palette

Search for a command to run...

BLOG / AI AGENTSAdvanced

AI Agent Loops: Stopping Conditions and Guardrails for Production

Why agents loop forever and the guardrails that stop them: iteration limits, cost budgets, tool-call deduplication, progress checks and clear 'done' criteria.

Bhavin Mistry September 4, 2026 12 min readLast reviewed

Topics:AI agents in productionCost and production

An agent that searches, reads, reworded-searches and reads the same page again is not broken. It is doing exactly what the loop and the prompt allow, because nothing in the loop knows how long it has been running or what it has spent. The fix is not a smarter model; it is four guardrails that the code, not the model, enforces.

TL;DR

An agent is a loop: the model decides, a tool runs, the result goes back to the model, repeat. Nothing in that loop stops it. The model does not know it has been running for forty minutes, and it will happily call the same search with a slightly different phrasing for the twentieth time. Production agents need explicit stopping conditions that the loop enforces, not the model. The four that matter: a hard iteration limit, a cost budget, deduplication of repeated tool calls, and a progress check. Underneath all four, the prompt must define what "done" looks like, because "be thorough" is an instruction to never stop.

  1. Step 1: Iteration limit

    A hard cap, with a force-answer step when it is reached

  2. Step 2: Cost budget

    Count tokens on every call, convert to money and cap each run

  3. Step 3: Deduplication

    Hash normalised tool calls and refuse near-identical repeats

  4. Step 4: Progress check

    Every few iterations, ask whether the agent can answer now and what is still missing

The four guardrails

What a runaway agent looks like

The Agent That Wouldn't Stop challenge is built on a pattern that is common in first agents. In that illustrative scenario, a research agent is asked to answer a question. It searches, reads, searches again with a reworded query, reads the same page, searches again. Some forty minutes and fifteen dollars later a timeout kills it, and the user gets nothing.

Read the trace and you find three things missing and one thing wrong.

  • No iteration limit. The loop is while True.
  • No cost budget. Tokens are not counted anywhere.
  • No deduplication. The same tool is called with near-identical arguments repeatedly.
  • A prompt that says "be thorough and make sure you have covered everything." The model is doing exactly what it was told.

Each of these is a small fix. Together they are the difference between a demo and a system.

Why the model cannot stop itself

It is tempting to solve this in the prompt: "stop when you are done." It does not work reliably, for three reasons.

The model has no clock and no meter. It sees a transcript, not elapsed time or spend. Every iteration looks like the first from inside the context.

The model's judgement of "done" is exactly the thing that fails on hard tasks. An agent that could reliably tell it had enough information would not need guardrails. The guardrails exist for the cases where that judgement is wrong.

Context grows with every step. Late in a long run, early instructions are far from the model's attention and the accumulated tool output dominates. The stopping instruction is the first thing that gets lost.

So the loop, the code you control, must enforce the limits. The model can be asked to stop; the loop must be able to make it.

Guardrail one: a hard iteration limit

Set a maximum number of loop iterations. Ten is a sensible default for a research or retrieval agent; tune it by looking at how many steps successful runs actually take, then add headroom.

When the limit is reached, do not throw an exception and lose everything. Call a force-answer step: send the model the transcript so far with an instruction to answer now using only what it has, and to say what it could not verify. A partial answer with caveats beats a timeout every time.

The limit is a backstop, not a target. If most runs hit it, the agent has a different problem and the limit is hiding it.

Guardrail two: a cost budget

Count tokens on every model call and keep a running total in the agent's state. Convert to money using the model's pricing so the budget is in a unit the business understands. Set a per-run maximum. When it is exhausted, force-answer.

Add a per-user or per-tenant daily budget on top. A single run capped at one dollar is fine until a script calls it ten thousand times.

Log the final cost of every run alongside the outcome. Cost per successful task is the agent's most honest metric.

Guardrail three: tool-call deduplication

Keep a short history of recent tool calls as a hash of the tool name and normalised arguments. Before executing a call, check whether the same signature appeared in the last few steps. If it did, do not execute it. Return a synthetic tool result instead: "You already called this tool with these arguments. The result was above. Use it, or try a different approach."

Two refinements make this robust.

Normalise arguments before hashing. Trim whitespace, lower-case, sort keys. Otherwise a trailing space defeats the check.

Use a similarity check, not just equality, for free-text arguments. Searches for "RAG evaluation metrics" and "metrics for evaluating RAG" are the same call. An embedding similarity above a threshold, or even a token overlap ratio, catches most of these.

Count repeated refusals. If the model tries a duplicate three times in a row, it is stuck. Force-answer.

Guardrail four: a progress check

Some loops do not repeat exactly; they wander. Each call is new but none of them moves the task forward. A progress check asks, every few iterations, whether the agent is closer to done.

The simplest version is a reflection step. Every three iterations, insert a message: "Summarise what you have learned so far in relation to the task, and state whether you can answer now. If not, name the one specific thing still missing." If the model cannot name a specific missing item, force-answer. If it names the same item it named last time, force-answer.

A stronger version tracks a task-specific progress measure. For a research agent, the number of distinct relevant sources found. For a coding agent, the number of failing tests. If the measure has not improved in N iterations, stop.

Fix the prompt: define done

Every guardrail above is a backstop. The prompt is where most loops are prevented.

Replace open-ended instructions with concrete completion criteria.

  • If

    "Be thorough"

    Choose

    "Answer when you have three distinct sources that address the question. If after three searches you have fewer, answer with what you have and say so."

    Because

    A concrete criterion gives the model a place to stop

  • If

    "Make sure the code works"

    Choose

    "Run the tests. If they pass, stop and report. If the same test fails twice after a fix, stop and report the failure."

    Because

    It names the stop and the failure case

  • If

    "Keep going until the user is satisfied"

    Choose

    "Complete the task described. Do not start additional tasks the user did not ask for."

    Because

    It bounds the scope of the task

Rewrite open-ended instructions

Tell the model the limits exist. "You have at most ten steps. Plan accordingly." Models use budget information when they have it.

Give it permission to stop with a partial result. Many loops persist because the model has been told, implicitly, that an incomplete answer is unacceptable.

A reference implementation

The loop below is deliberately plain. It shows where each guardrail sits. A production version adds structured logging, retries on tool errors, and streaming, but the control flow is the same.

import hashlib
import json
from dataclasses import dataclass, field


@dataclass
class Config:
    max_iter: int = 10
    max_cost_usd: float = 1.00
    dedupe_window: int = 5
    reflect_every: int = 3


@dataclass
class State:
    messages: list
    iterations: int = 0
    cost_usd: float = 0.0
    recent_calls: list = field(default_factory=list)
    duplicate_strikes: int = 0
    last_missing: str | None = None


def call_signature(name: str, args: dict) -> str:
    normalised = json.dumps(args, sort_keys=True, separators=(",", ":")).lower().strip()
    return hashlib.sha256(f"{name}:{normalised}".encode()).hexdigest()


def force_answer(state: State, reason: str) -> str:
    state.messages.append({
        "role": "user",
        "content": (
            f"Stop searching. {reason} Answer the original task now using only "
            "the information above. State clearly anything you could not verify."
        ),
    })
    response = llm(state.messages)          # no tools offered on this call
    state.cost_usd += response.cost_usd
    return response.content


def run_agent(task: str, tools: list, cfg: Config = Config()) -> str:
    state = State(messages=[{"role": "user", "content": task}])

    while True:
        if state.iterations >= cfg.max_iter:
            return force_answer(state, "The step limit has been reached.")
        if state.cost_usd >= cfg.max_cost_usd:
            return force_answer(state, "The cost budget has been reached.")
        if state.duplicate_strikes >= 3:
            return force_answer(state, "You are repeating the same actions.")

        if state.iterations > 0 and state.iterations % cfg.reflect_every == 0:
            state.messages.append({
                "role": "user",
                "content": (
                    "Before continuing: can you answer the task now? Reply DONE if yes. "
                    "If not, name the single specific piece of information still missing."
                ),
            })
            reflection = llm(state.messages)
            state.cost_usd += reflection.cost_usd
            text = reflection.content.strip()
            if text.upper().startswith("DONE") or text == state.last_missing:
                return force_answer(state, "No further progress is being made.")
            state.last_missing = text

        response = llm(state.messages, tools=tools)
        state.cost_usd += response.cost_usd
        state.iterations += 1

        if not response.tool_calls:
            return response.content         # the model chose to answer

        state.messages.append(response.as_message())

        for tc in response.tool_calls:
            sig = call_signature(tc.name, tc.args)
            if sig in state.recent_calls[-cfg.dedupe_window:]:
                state.duplicate_strikes += 1
                result = (
                    "You already called this tool with these arguments; "
                    "the result is above. Use it or try a different approach."
                )
            else:
                state.duplicate_strikes = 0
                state.recent_calls.append(sig)
                result = execute_tool(tc)
            state.messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})

Three details worth noticing. Cost is accumulated on every model call, including reflections and the forced answer, so the budget is real. Duplicate strikes reset when a novel call is made, so a single accidental repeat does not end the run. The forced answer offers no tools, so the model cannot loop again inside the exit.

What to log

For every run, record the outcome, the number of iterations, the total cost, the number of duplicate calls refused, and which guardrail ended the run if any did. Over a week that log tells you whether your limits are right. If a third of runs end on the iteration limit, the limit is too low or the task is too hard for the tool set. If runs rarely exceed four steps, a limit of ten is fine and the budget can drop.

Frequently asked questions

What should the iteration limit be? Look at successful runs. Take the number of steps the slowest successful run needed and add a small margin. Ten is a reasonable default for retrieval and research agents. Coding agents with test loops often need more.

Should I let the user raise the limits? For internal tools, a per-request override with logging is fine. For anything customer-facing, no. The limits protect the customer's bill and your own.

Does deduplication break legitimate retries? A tool that legitimately needs the same call twice, such as polling a job status, should be exempted by name or should include a changing argument like an attempt number. Exempt explicitly rather than loosening the check globally.

Is the reflection step worth its cost? It costs one model call every few iterations. A run that would otherwise burn ten more steps pays for many reflections. Measure it in your own logs; if reflections never trigger a stop, lengthen the interval.

Do frameworks handle this for me? Most agent frameworks expose an iteration limit and little else. Cost budgets, deduplication and progress checks are usually yours to add. Check what your framework enforces before assuming.

Why can't I just tell the model to stop when it is done? The model has no clock or meter, it sees a transcript rather than elapsed time or spend, and its judgement of "done" is exactly what fails on hard tasks. Late in a long run the stopping instruction is also the first thing lost from attention.

What is a force-answer step? When a limit is reached, send the model the transcript so far with an instruction to answer now using only what it has and to say what it could not verify, with no tools offered. A partial answer with caveats beats a timeout.

What should I log for every agent run? The outcome, iteration count, total cost, number of duplicate calls refused and which guardrail ended the run. Over a week the log shows whether your limits are right.

Cost and reliability

Your next step

Reading is the start. Turn it into skill: read the chapter, practise on something real, check the numbers, then follow a path.

  1. Read · ChapterCost & Reliability
  2. Practise · Debug challengeCost Blowup After Launch
  3. Use a free toolAI Cost Calculator
  4. Follow a roadmapAI Platform Engineer roadmap
All articles on AI agents in production
#agents#guardrails#ReAct#cost control#production#stopping conditions

Discuss this article

Got a question, a counter-example or a correction? Bring it to the community.

Join the discussion

Email updates

Enjoyed this? Get the next guide by email.

One email when something substantial is published. No digests, no daily drip. Unsubscribe in one click.

We will email you a confirmation link first, then send an occasional email when a substantial guide is published. Unsubscribe in one click. Stored on our own servers, never sold or shared. See the privacy policy.

Prefer a feed reader? Follow by RSS.