HomingoLearn
AI agents · 9 min read

How to stop an AI agent infinite loop in Python (step caps, repeat checks)

An agent stuck in an infinite loop, calling the same tool again and again, is a common way agent runs fail. A few small guards turn it into a clean stop with a reason.

By Homingo Learn ·

Agent loops look alike: a search returns nothing useful, so the agent searches again with the same words; a test fails, the agent makes the same edit and runs it again; two steps undo each other. The model is not broken. It has no signal that it is repeating itself and no clear definition of finished. This guide adds both, in plain Python 3.10 or newer, around any agent loop that asks a model for the next action. Put the code blocks in one file, in order.

Frameworks have a step cap built in: LangChain’s legacy AgentExecutor has max_iterations (“Agent stopped due to iteration limit or time limit.”), LangGraph has recursion_limit (“Recursion limit of 25 reached without hitting a stop condition”), and the OpenAI Agents SDK has max_turns. A cap stops the bleeding, but it does not notice repeats or stalls, and by default it ends the run with an error or a fixed message instead of a usable result. The guards below add those.

Why LLM agents get stuck in loops

  • No memory of failure: the error is in the context, but nothing says “you already tried exactly this”.
  • No definition of done: “research this topic” never ends; “return three findings, each with a URL and a quote” does.
  • Errors that read like instructions: a tool that returns “try again later” gets tried again, immediately.
  • Nothing outside the model decides to stop. Stopping has to be enforced in code, not requested in the prompt.

1. Add a step cap, repeat detection and a no-progress check

One object, created fresh for every run and consulted on every turn, enforces a hard step cap, counts repeated tool calls and spots runs that stopped making progress:

import hashlib
import json
from collections import Counter, deque

class LoopStop(Exception):
    pass

TEXT_KEYS = {"query", "q", "question"}  # arguments where case and spacing do not matter

def normalize(args: dict) -> dict:
    """Treat "Python loops " and "python loops" as the same search; leave paths and code alone."""
    return {k: " ".join(v.lower().split()) if k in TEXT_KEYS and isinstance(v, str) else v
            for k, v in args.items()}

def call_key(name: str, args: dict, state: str = "") -> str:
    """state: a version of the world for tools whose answer depends on it (e.g. the git tree hash)."""
    return hashlib.sha256(json.dumps([name, normalize(args), state], sort_keys=True, default=str).encode()).hexdigest()

class LoopGuard:
    def __init__(self, max_steps: int = 25, max_repeats: int = 1, stall_steps: int = 5):
        self.max_steps, self.max_repeats, self.stall_steps = max_steps, max_repeats, stall_steps
        self.steps = 0
        self.calls: Counter[str] = Counter()
        self.recent: deque[str] = deque(maxlen=stall_steps + 1)

    def tick(self, fingerprint: str) -> None:
        """Call at the start of every model turn with a fingerprint of the useful state."""
        self.steps += 1
        if self.steps > self.max_steps:
            raise LoopStop(f"step cap of {self.max_steps} reached")
        self.recent.append(fingerprint)
        if len(self.recent) == self.recent.maxlen and len(set(self.recent)) == 1:
            raise LoopStop(f"no new useful result in the last {self.stall_steps} steps")

    def record_call(self, key: str) -> bool:
        """Records a tool call. Returns True if it repeats an earlier one; raises LoopStop
        when a call has been repeated more than max_repeats times."""
        self.calls[key] += 1
        if self.calls[key] > self.max_repeats + 1:
            raise LoopStop(f"the same call was made {self.calls[key]} times")
        return self.calls[key] > 1

The fingerprint is whatever represents real progress for your agent: the set of findings collected so far, the hash of the working tree for a coding agent, the list of files written. Errors and empty results do not count. If it has not changed in five steps, more steps rarely change it; tune stall_steps from your traces. Normalizing the search text catches near-identical searches, and passing a state (such as the working-tree hash) lets a coding agent rerun the tests after a real edit without it counting as a repeat.

2. Tell the agent it is repeating a tool call

The first repeat should not be fatal; it should be visible. Instead of running the tool again, return the earlier result with a note (the repeat branch of the full loop in step 4). That often breaks the loop, because the model finally sees that the same action gives the same answer. With max_repeats=1, the first repeat gets the note and a third identical call stops the run. Retry transient errors (“try again later”) with backoff inside each tool function (not shown), so the agent only ever sees a final result, never a decision about retrying.

3. Validate the finish step in code

Give the agent an explicit finish action with required fields, state that contract in its system prompt, and validate it in code. A finish that does not meet the contract is sent back with the exact reason, and that turn still counts against the step cap:

def check_finish(args: dict) -> str | None:
    """None if the result meets the contract, else the reason it does not."""
    findings = args.get("findings")
    if not isinstance(findings, list) or len(findings) < 3:
        return "finish needs at least 3 findings"
    for i, f in enumerate(findings, 1):
        if not (isinstance(f, dict) and str(f.get("url", "")).startswith(("http://", "https://"))
                and f.get("quote")):
            return f"finding {i} needs a url and a quote"
    return None

This checks the shape, not the truth: whether a quote really appears on its page needs a separate checker that fetches the source; see the source checker in how to build a multi-agent research system in Python.

4. The full agent loop, with partial results

def useful(entry: dict) -> bool:
    obs = entry["observation"]
    return bool(obs) and not (isinstance(obs, dict) and "error" in obs)

def fingerprint(results: dict) -> str:
    return hashlib.sha256(json.dumps(sorted(k for k, e in results.items() if useful(e))).encode()).hexdigest()

def run_agent(task: str, call_model, tools: dict, guard: LoopGuard | None = None) -> dict:
    """call_model(messages) returns {"tool": name, "args": {...}}; "finish" ends the run."""
    guard = guard or LoopGuard()  # one guard per run: it holds the run's counts
    messages = [{"role": "user", "content": task}]
    results: dict[str, dict] = {}  # per distinct call: tool, args, observation
    try:
        while True:
            guard.tick(fingerprint(results))
            action = call_model(messages)
            if not isinstance(action, dict):
                messages.append({"role": "user", "content": 'Reply with JSON: {"tool": ..., "args": {...}}'})
                continue
            name, args = action.get("tool"), action.get("args") or {}
            messages.append({"role": "assistant", "content": json.dumps(action)})
            if name == "finish":
                problem = check_finish(args)
                if problem is None:
                    return {"status": "done", "result": args, "steps": guard.steps}
                messages.append({"role": "user", "content": f"Not finished: {problem}"})
                continue
            if name not in tools:
                options = sorted([*tools, "finish"])
                messages.append({"role": "user", "content": f"Unknown tool {name}. Use one of {options}."})
                continue
            key = call_key(name, args)
            if guard.record_call(key):
                observation = {"note": "You already made this call. Same result as before; "
                                       "try a different approach or finish.",
                               "result": results[key]["observation"]}
            else:
                try:
                    observation = tools[name](**args)
                except Exception as e:  # errors become observations, not crashes
                    observation = {"error": f"{name} failed: {e}"}
                results[key] = {"tool": name, "args": args, "observation": observation}
            messages.append({"role": "user", "content": json.dumps(observation, default=str)})
    except LoopStop as e:
        return {"status": "stopped", "reason": str(e), "steps": guard.steps,
                "partial": [x for x in results.values() if useful(x)]}

Three details matter. Every model turn counts, including malformed replies, rejected finishes and unknown tools, so the step cap is a true upper bound, and the progress check sees those empty turns too. A repeated call is not executed again, so a loop costs no extra tool calls. And a stop returns what was gathered (which tool, which arguments, what came back) plus the reason, which the caller can log, show or pass to a person, instead of an exception that loses the work. With a native tool-use API, the action is a tool-use block and the observation a tool result; the guards do not change.

A quick check with a fake model that keeps repeating the same search:

def search(query: str) -> list:
    return []  # nothing found, every time

def stuck_model(messages):
    return {"tool": "search", "args": {"query": "agent loops"}}

print(run_agent("Find three sources on agent loops", stuck_model, {"search": search}))
# {'status': 'stopped', 'reason': 'the same call was made 3 times', 'steps': 3, 'partial': []}

Tune the numbers from your traces, not by feel: look at how many steps successful runs take and set the cap a comfortable margin above that. Step caps do not bound a tool that hangs, so also give each tool call a timeout and the whole run a wall-clock deadline. A token budget checked before each call is the next line of defence; see the budgets section of our guide to agent traces, budgets and evals.

Prompt changes that help (but never replace the guards)

  • State the finish contract in the system prompt, in the same words the checker uses.
  • Make tool errors specific and actionable: “no results for this query; try fewer or different words”, not “failed”.
  • Ask for a one-line plan field in each action ({"plan": "...", "tool": ..., "args": ...}); the loop ignores it, but repeats become obvious to the model as well as to you.

For the bigger picture, with several agents sharing a planner, a tool layer and a checker, see how to build a multi-agent research system in Python.