AI Engineer Dojo Contents
AI Agent Engineer · Chapter 3

The Agent Loop & Stopping

The loop is easy to write and easy to get catastrophically wrong. Two failure modes bracket every agent: it stops too early (returns a half-done answer) or it never stops (burns your budget in circles). Engineering the stopping rule is engineering the agent.

The model signals "I'm done" by returning a response with no tool calls — just text. That's your natural stopping condition. But relying on it alone is how agents run away, because a confused model will keep calling tools forever. So a real loop has three ways to stop, in priority order:

  1. Natural stop — the model returned a final text answer (no tool_use). The good ending.
  2. Step budget — a hard max_steps cap. The safety net that guarantees termination.
  3. Stuck detection — the loop is making no progress (repeating actions). The early bail-out that saves budget.

The runaway loop, in numbers

Leave out the step cap and give an agent a task it can't complete — a tool that keeps erroring, or a goal with no solution — and it doesn't gracefully give up. It retries, rephrases, tries adjacent tools, and keeps going. An agent with no cap on an impossible task in one test ran 50+ steps before it was killed manually, at which point it had spent roughly $4 on a single request that should have failed in 3 steps for a fraction of a cent. Multiply by real traffic and a missing max_steps is a genuine outage-and-invoice event. Every production loop has a hard cap. No exceptions.

Stuck detection — cheaper than the cap

The step cap guarantees termination but is wasteful: you still pay for all N steps before it fires. Better to notice the agent is spinning and bail early. The simplest, most effective signal: the agent repeats an identical (tool, arguments) pair it already tried. If it just called get_weather("Paris") for the second time this task, it's looping. Detecting a repeated action and injecting a nudge ("You already tried that and got X; try a different approach or stop") recovers many stuck runs — and if the nudge doesn't help, you stop. In practice, repeat-action detection catches the majority of runaway trajectories before they hit the hard cap, cutting wasted spend on failing tasks substantially.

The stopping hierarchy

Return on natural stop. Bail on repeated actions with a nudge, then a hard stop. Always terminate at max_steps. Log why each run ended — "natural / stuck / capped" — because that distribution is a live health metric: a rising "capped" rate means your agent is failing silently.

Design the final answer, not just the stop

When the loop ends, what do you return? On a natural stop, the model's text. But on a capped or stuck stop, returning the model's last confused message is a bad user experience. Give those endings a defined output: "I couldn't complete this — here's what I found and where I got stuck." An agent that fails honestly is far more useful than one that returns a confident guess after 8 flailing steps.

Try it · ~25 min

Add a step cap and stuck-detection — and measure wasted steps saved

You'll take a loop pointed at an unsolvable task (a tool that always errors) and add the two guards. You'll measure how many steps the naive loop wastes vs. the guarded one, and confirm both terminate.

Setup: mock-only — no key needed; the "model" here is a simple stand-in that keeps retrying, which is exactly the runaway behavior you're defending against.

Step 1. Run the naive loop (cap only, set high) and count steps to termination.

Step 2. Add repeat-action detection: if the same (tool, args) recurs, nudge once, then stop.

Step 3. Print steps for naive vs. guarded, and the stop reason for each.

Your goal: two numbers and two stop reasons — e.g. "naive 20 (capped), guarded 3 (stuck)."

Starter code

# A stand-in "model" that stubbornly retries the same failing tool.
def fake_model(messages):
    # Always wants to call broken_tool with the same args => a loop.
    return {"tool":"broken_tool", "args":{"x":1}}

def broken_tool(args):
    return "ERROR: service unavailable"

def loop(max_steps=20, detect_stuck=False):
    messages, seen, steps = [], set(), 0
    for steps in range(1, max_steps+1):
        act = fake_model(messages)
        if act is None:
            return steps, "natural"
        key = (act["tool"], tuple(sorted(act["args"].items())))
        if detect_stuck and key in seen:
            # nudge once already given? here we just stop on 2nd repeat
            return steps, "stuck"
        seen.add(key)
        result = broken_tool(act["args"])
        messages.append(result)
    return steps, "capped"

# TODO: run loop() both ways and print steps + reason for each.
Worked solution
print(loop(max_steps=20, detect_stuck=False))   # (20, 'capped')
print(loop(max_steps=20, detect_stuck=True))    # (2,  'stuck')

What you should see: the naive loop runs the full 20 (capped) — every step a paid model call against a tool that will never succeed. The guarded loop stops at 2 (stuck) the instant the identical action repeats: a 90% reduction in wasted steps on this failing task, and it still terminates safely even if stuck-detection somehow missed (the cap is the backstop).

The engineering read. The cap protects you from catastrophe; stuck-detection protects you from waste. You want both, layered — cheap early bail-out backed by a guaranteed hard stop. And note the reason string: in production, watching the ratio of natural/stuck/capped endings tells you your agent's health without reading a single transcript.

Going further (optional): make fake_model alternate between two failing tools so the naive repeat-check misses it. You'll see you need to detect "no progress" more broadly (e.g. N steps with no new information), not just exact repeats — a preview of the reliability work in Ch. 7.

Case study

Vectorly: the loop that wouldn't quit

Vectorly's research agent occasionally ran 40+ steps and never finished — re-running the same search, re-reading the same page, circling. A few of these runaway runs racked up real cost, and one ran for twenty minutes before a human killed it. The root cause wasn't intelligence; it was the absence of a stopping rule. The agent had no crisp definition of "done" and couldn't tell it was repeating itself.

Two changes fixed it. A hard step-and-cost budget guaranteed no run could ever go unbounded — a safety floor, not a solution. Then the real fix: an explicit definition of done, tool results made legible so the model could see its last action's outcome, and detection of repeated no-progress states so it changed strategy instead of looping. Average steps dropped from 14 to 6, and the never-terminating runs disappeared. Raising the max-iterations cap, which they'd tried first, had only made the expensive runs more expensive.

Running case · Ridgeline

Ridgeline caps every ticket at a step and cost budget from day one, and defines "done" explicitly: the ticket is resolved or escalated. When the agent starts re-fetching the same order, a no-progress check nudges it to escalate rather than spin.

Quiz · Chapter 3 — reasoning, not recall

  1. How does the model normally signal that it's finished?
  2. Why is a hard max_steps cap non-negotiable in production?
  3. Stuck-detection via repeated (tool, args) is valuable in addition to the cap because:
  4. On a capped or stuck ending, the best thing to return is:
  5. Logging each run's stop reason (natural/stuck/capped) is useful because:

← Back Continue →

AI Agent Engineer — New-Grad Edition · AI Engineer Dojo · aiengineerdojo.com