THE FULL HEIGHT FULL · 2026·08·16 FULL·11 x:1294 y:959 x:050 y:050 FACE·05

The Limit Said 10. The Loop Made 500 Calls.

Your limit counts one cycle. The one that runs away is another. Here is how to tell them apart.

Harry Floyd 14 min read THE FULL HEIGHT
Contents · 8 sections
  1. Ten. Five hundred.
  2. What decreases, on which cycle, in what units, and what tests it
  3. Your limit counts the outer cycle
  4. How often is “forever”?
  5. Parse, stop, spend
  6. Repetition is not futility
  7. The exit test and the counter on the same cycle
  8. This week

Your limit counts one cycle. The one that runs away is another. Here is how to tell them apart.


The counter reads 1 of 10, honoured, while the receipts pile up underneath it

This is the first pitch of THE FULL HEIGHT, a series that takes one primitive at a time and climbs its full height. Most writing about agents stops at the sentence everyone repeats: it is an LLM, a loop, and enough tokens. That sentence is true, and it stops at exactly the point where the engineering starts: whether the limit you set bounds what you think it does, which limit your framework already ships, what a run costs, what can switch it off from outside your process, how a run should end, and what the correct version actually looks like in code.

This ascent is that loop, climbed from the bottom. The first pitch is the one the rest stands on: what a bound actually is, and why the one you have already set may not be doing the job you think.

Ten. Five hundred.

A loop with max_iterations = 10 that made five hundred model calls, honouring the limit on every single pass.

Five hundred is not where it ran out. Five hundred is a hard cap I wrote into the bench so that it would stop, and the loop had not exhausted anything when it got there. Take the cap out and it runs until you close the terminal.

Every agent framework I pulled off the shelf ships a limit of that kind. LangGraph calls it recursion_limit, CrewAI max_iter, the OpenAI Agents SDK max_turns, LangChain max_iterations. AutoGen is the outlier worth knowing about: of its eleven termination conditions only one counts messages, while another counts tokens and another counts wall-clock seconds. Which limit yours ships, and what it actually covers, is worth knowing before you lean on it.

The idea underneath is old. The answer has been sitting in computer science since 1949, and the vocabulary for it has not made the trip across to how we write about agents.

What decreases, on which cycle, in what units, and what tests it

Ask these four questions about a loop and you will find either the measure that ends it or the hole where that measure should be. The first three are the loop variant, the termination method Turing was already using in 1949. The fourth is the engineering addition the proof never needed and your runtime does.

  • What decreases. Name a quantity that gets strictly smaller on every single pass through the cycle, without exception.
  • On which cycle. Find every path that hands control back to an earlier point. Each one you find needs its own answer.
  • In what units. Units with a floor. A counter falling to zero has one; a counter with nothing underneath it can fall forever, and n -= 1 will happily run all afternoon into the negatives.
  • What tests it. A quantity that decreases and is never compared against anything stops nothing.

The fourth is the one that turns the other three from trivia into an instrument, and it is the one I see dropped most often.

Three things it is not, and the first two matter more than they look.

It is not a decision procedure. Answer all four cleanly and you have shown that this cycle cannot spin forever, not that the program halts. Some terminating loops have no simple count at all: Cook, Podelski and Rybalchenko print one on page 91 of their CACM paper, a single while for which “no ranking function into the natural numbers exists that can prove the termination of this program”. Building richer measures for those is their whole subject. The four questions sort cycles into easy, hard, and none, and in agent code the third is the common one.

It only sees the loops in front of you. Two agents calling each other, or a graph with a path back to itself, are cycles with no while to point at.

And it is not a way to bound money. A call served from cache, or refused by a rate limiter before it bills anything, costs approximately nothing, so a spend ceiling can sit almost still while a loop spins. Count integers to bound the loop. Cap money to bound the damage. Different instruments, and only the first is in scope here.

Your limit counts the outer cycle

Take a loop with a limit on it and add the most ordinary thing in the world: retry on a parse failure. Everybody writes this. It looks like diligence.

for _ in range(max_iterations):        # the bound
    parsed = None
    while parsed is None:              # the cycle it does not constrain
        try:
            parsed = parse(model(messages))
        except ParseError:
            continue                   # swallow and retry
    ...

Now run the four questions over it. The outer for answers cleanly: iterations remaining decreases, in whole numbers with a floor at zero, and range tests it. The inner while is easy to name and then the other three come back empty. Nothing decreases, so there are no units, and nothing is compared to anything. The limit is still honoured; it is counting passes through a cycle that is not the one repeating.

Here is that, whole, in twenty-five lines. Save it as loop.py and run it. No key, no installs, no network.

calls = 0

def model(_messages):                  # a model that never returns parseable output
    global calls
    calls += 1
    return {"type": "unparseable"}

def parse(response):
    if response["type"] == "unparseable":
        raise ValueError("cannot parse")
    return response

def run(max_iterations=10, hard_cap=500):
    for _ in range(max_iterations):    # the bound everyone points at
        parsed = None
        while parsed is None:          # the cycle it does not constrain
            if calls >= hard_cap:
                return f"runaway: {calls} calls under max_iterations={max_iterations}"
            try:
                parsed = parse(model([]))
            except ValueError:
                continue               # swallow and retry, unbounded
    return "finished"

print(run())
runaway: 500 calls under max_iterations=10

The shape is in the wild. Hou and colleagues scanned 6,549 open-source agent repositories and confirmed 68 infinite-loop failures across 47 projects, and all 68 shared one root cause, which their paper states without hedging: “All 68 failures share the same root issue: the repeated path is not covered by a strong bound.” That is their thesis rather than my reading of their data, and it is a July 2026 preprint, so treat it as reported rather than settled. One of the 68 is the listing above with a live model attached: in LiteRAG, a planner nests two while not success loops around self.llm.invoke(...) and swallows the parse failure with a bare except OutputParserException: pass.

Read the arithmetic carefully. They report precision and never report recall, so 47 projects in 6,549 gives a floor of 0.72% and no ceiling at all. It tells you the shape and one real instance of it. It tells you nothing about your odds, and I am not going to pretend otherwise in either direction.

How often is “forever”?

Here is the objection I would raise, and it is a good one. That fake model fails to parse 100% of the time. Mine parses about 98% of the time, so my uncovered while runs 1.02 times on average and I have never seen it misbehave. The 500 is a property of a model you wrote to fail, not of my code.

That is right, and it deserves a number rather than a dismissal. Assume for a moment that each retry is an independent coin-flip at a fixed failure rate p. Then getting ten parses past the model takes 10/(1-p) calls on average, the spread around it is negative binomial, and both columns below are computed exactly rather than sampled.

How often is "forever"? Median and 95th-percentile model calls against a limit of ten, as the parse-failure rate climbs

The exact figures: at a 2% parse-failure rate the median run costs 10 calls and the p95 is 11; at 99% the median is 967 and the p95 is 1,568

At a two per cent failure rate the median run overruns by nothing at all: ten calls, exactly what the limit says, and nineteen runs in twenty finish inside eleven. If that is your steady state, this has probably never cost you anything worth noticing.

That table is the friendly case, because independence is the friendly assumption. Real parse failures can cluster: a schema change, a model deprecation, a rate-limit error mapped onto the parse branch, a prompt regression. None of those flips a fresh coin each retry. They break something and hold it broken, so the failures arrive in a run rather than scattered, and the loop sits in the high-p rows for as long as the cause lasts. The neat distribution is the good afternoon. The danger is the window where the rate jumps and stays there, and that is the window your limit was supposed to cover. What you have is a loop with no ceiling on how badly that window can go.

Parse, stop, spend

Step back from the retry for a moment and look at the loop it sits inside: ask a model, run what it asks for, feed the result back, go round. Three holes sit in that shape, and the same three are in every version of it I have written.

The three holes: parse, stop and spend

Stop and Spend get conflated, and that is why “add a step limit” gets offered as the answer to both. A loop that will not stop can be cheap: an agent waiting on a tool that never returns burns almost nothing and hangs everything downstream. An expensive loop can be perfectly well behaved: a conversation that converges as designed can still cost more than the task was worth.

Stop is the interesting one, because termination in that shape is model-controlled. The run ends when the model volunteers that it is finished, which makes your limit a backstop for when that fails rather than a termination condition. A backstop only fires after the steps it was supposed to save you have already been spent.

Repetition is not futility

The instrument earns its keep here, because it lets you derive the next failure instead of waiting to be bitten by it.

If a loop is stuck, the obvious signature is the same action repeating. So hash the tool name with its arguments, count what you have seen, and stop on a repeat. Alan West published exactly this in May 2026 with working code, and his two steps, a hard iteration cap and a deduplicated tool call, are the two guards in the listing at the end of this piece.

Run the four questions over it before you write it. What decreases? The tempting answer is signatures not yet seen, and that one runs backwards: a fresh signature uses one up, while a repeat leaves the count exactly where it was. It measures novelty, and the guard fires on staleness. What the guard actually decrements is narrower. For a single signature, the allowance left on that key, repeat_limit minus the count it has reached, falls by one each time that same key comes round again.

On which cycle, and what tests it? Here is the crack. That allowance belongs to a signature rather than to the loop, and every fresh signature arrives with its own. The test reads one key’s counter and never reads any quantity covering the run, so nothing with a floor governs the cycle at all. An agent that varies its arguments mints allowances faster than it can spend them, and West names the same defeat himself, search("python async") followed by search("async in python").

You can get there from the four questions without running anything, which is the point of having them. Here is the measurement anyway. Save this as guard.py, separately from loop.py:

import hashlib, json

def guarded(model, max_iterations=30, repeat_limit=2):
    seen = {}
    for _ in range(max_iterations):
        call = model()                                  # {"tool": ..., "args": ...}
        key = hashlib.sha1(json.dumps(call, sort_keys=True).encode()).hexdigest()[:8]
        seen[key] = seen.get(key, 0) + 1
        if seen[key] > repeat_limit:
            return {"stopped": "no-progress", "calls": sum(seen.values()), "repeated": key}
    return {"stopped": "ceiling", "calls": sum(seen.values())}

n = [0]
def repeater():        return {"tool": "search", "args": {"q": "widget"}}
def never_finishes():  n[0] += 1; return {"tool": "think", "args": {"n": n[0]}}

print("repeater        ->", guarded(repeater))
print("never_finishes  ->", guarded(never_finishes))

Against a model that repeats itself, the guard ends it on the third call:

repeater        -> {'stopped': 'no-progress', 'calls': 3, 'repeated': 'c740a98e'}

Against a model that never repeats itself and never finishes:

never_finishes  -> {'stopped': 'ceiling', 'calls': 30}

Thirty is max_iterations in the guard’s own signature. What stopped that run was the ceiling. The detector saw nothing, because there was nothing for it to see: thirty calls, all different, all useless, every one of them looking like work.

The exit test and the counter on the same cycle

So what does a loop that carries its own variant look like? One cycle, one counter, and the thing that tests the counter is the thing that ends the loop. None of this is a property of the model. All of it is a property of the code you wrapped around the model, which is where the engineering actually lives.

import json

def bounded_run(model, tools, task, max_calls=20, repeat_limit=2):
    messages, seen, calls = [{"role": "user", "content": task}], {}, 0
    while calls < max_calls:                    # the exit test IS the counter test
        calls += 1                              # and it advances before anything fails
        parsed = model(messages)
        if parsed is None:                      # hole 1, parse: retry anyway --
            messages.append({"role": "user",    # the counter has already moved
                             "content": "That did not parse. Reply again."})
            continue
        if parsed["done"]:                      # hole 2, stop: the model proposes
            return {"exit": "success", "calls": calls}
        key = (parsed["tool"], json.dumps(parsed["args"], sort_keys=True))
        seen[key] = seen.get(key, 0) + 1        # the runtime's own way to end it,
        if seen[key] > repeat_limit:            # not just the model's
            return {"exit": "stall", "on": parsed["tool"], "calls": calls}
        result = tools[parsed["tool"]](**parsed["args"])
        messages.append({"role": "tool", "content": str(result)})
    return {"exit": "budget", "calls": calls}

tools = {"search": lambda q: f"no results for {q}"}
n = [0]
def never_parses(_m):   return None
def always_repeats(_m): return {"done": False, "tool": "search", "args": {"q": "widget"}}
def never_finishes(_m):
    n[0] += 1
    return {"done": False, "tool": "search", "args": {"q": f"widget {n[0]}"}}

for name, m in [("never_parses", never_parses), ("always_repeats", always_repeats),
                ("never_finishes", never_finishes)]:
    print(f"{name:16} -> {bounded_run(m, tools, 'find the widget')}")

Drive it with three models that break everything above and all three stop:

never_parses     -> {'exit': 'budget', 'calls': 20}
always_repeats   -> {'exit': 'stall', 'on': 'search', 'calls': 3}
never_finishes   -> {'exit': 'budget', 'calls': 20}

The model that never parses ran five hundred calls in the earlier bench. Here it runs twenty. What does the work is narrower than the shape: every path that can reach another model call spends from the same budget before it gets there, and the test that reads that budget is the one that ends the loop. calls advances before anything that can fail.

Flattening the nest is not what bounds it. Keep both loops and put if calls >= max_calls: return inside the inner one, and it stops at twenty just the same; I ran it. One cycle is the version that is harder to get wrong later, because there is a single counter and a single test rather than two of each to keep in agreement. Those are different claims, and only the first is about termination. What does break it is dropping the property: put the counter first on a cycle that never tests it and you have a runaway with an increment in it.

Two honest limits on that listing. never_finishes was not detected, it was contained, and a ceiling reached is a bill paid in full. And the counter counts calls, not seconds, so a tool that blocks forever or a model call that never returns will hang it: I ran it against a tool that never returns and it was still going when I killed it. Wall-clock is a different quantity and it needs its own answer to the same four questions.

This week

Open your own loop. Between the limit you set and the model call, find every cycle: every while, every retry decorator, every graph edge or delegation that can send control round again. Then answer the four questions for each one, all four: what decreases, on which cycle, in what units, and what tests it. It is the same move as auditing an agent layer by layer, narrowed to the one layer that decides whether anything stops.

Expect it to argue back. Point it at a task-queue agent that pops a task and pushes the subtasks it finds: the queue grows before it shrinks, and no single number falls on every pass. That is not proof it runs forever. It means the four questions came back empty, and the reason this stops, if it stops, is an argument you have to make and write down rather than a counter you can read off the loop. The cycle to fix tonight is the one where you can point to neither a measure that falls nor any other reason control must eventually stop coming back.

One thing it will not give you. Every bound in this piece lives inside your own process, so it dies with your process and can be switched off by the code it is meant to constrain.

In May an autonomous agent was handed AWS credentials and reapplied its CloudFormation template again, and again: five m8g.12xlarge instances, load balancers and Lambdas, $6,531.30 in about twenty-four hours, for a workload the blog’s author reckons a small VPS would have carried. AWS later agreed to reduce the bill to $1,894. That cycle kept redeploying the same CloudFormation template rather than spinning a model loop, so no counter in this piece would have caught it, and the control that could have was not set: AWS Budgets can attach a policy that refuses further provisioning, and it runs on data that refreshes about three times a day, so even attached it would have fired late rather than never. What noticed first was the operator’s credit card.

That was not my bill, but I have shipped most of the bugs in this piece, some of them more than once. The four questions are how I catch them now. If a loop of yours has a cycle you cannot put a number on, I would like to hear about it.


AI is everywhere. The interesting stuff is underneath. The Durability Curve.

New to The Durability Curve? It is a standing argument about what survives when the tools get powerful and the surface gets cheap. Subscribe for the rest of the ascent, or start with the seven-layer audit.