AGENTS FROM FIRST PRINCIPLES · PART 8 OF 17

2026-07-04

Stopping the Runaway

An agent that never gives up retries a dead search forever and burns the budget. Multi-dimensional budgets, a loop detector, and a circuit breaker trip it to a graceful partial result.

What you’ll learn

Every part so far made the agent more capable. This one makes it safe to leave running. The loop from Part 1 ends in exactly two ways: the controller calls finish(), or it hits a single max_steps cap (the one guard RAG Part 19 ever had). The unspoken assumption underneath every part since then is that the loop eventually stops on its own. It does not always. A real controller can fail to converge in two different shapes, and they look nothing alike. A stuck agent gets jammed re-issuing the identical search forever, learning nothing, going nowhere. A wandering agent makes steady, plausible, never-ending progress, a slightly different query each step, that quietly runs up a large bill while never actually finishing. A single step cap cannot tell these two apart from a healthy run, and worse, it ignores the dimensions that actually cost money: tokens, dollars, and time. This part adds three guards that together turn “keep trying” into “keep trying, up to here, and then stop cleanly.” A multi-dimensional budget (a BudgetMeter over steps, tokens, cost, and simulated wall-clock) is checked before every step and stops the run the moment the first dimension crosses its ceiling. A loop detector counts identical (action, args) and flags the stuck signature at a threshold. And a circuit breaker ties them together: on budget-exceeded or a detected loop it trips, but tripping does not crash, it returns the best partial result the agent has plus the reason it stopped. The honest framing of the whole part: an agent that does not know when to give up is a liability.

Prerequisites

You need basic Python: functions, a class, a dictionary, a list, and a string check. That is all. Finishing Part 1 through Part 5 helps, especially Part 5, because this is the same recovery-and-limits theme carried to its conclusion: Part 5 bounded reflection (cap the trials, then escalate), and this part bounds the whole run. But the essay is self-contained: where it leans on an earlier idea, it restates it in a sentence, and it does not re-teach the ReAct loop, it wraps a few guards around it. The companion code, budget_circuit_breaker.py, runs offline with no API key, no network, and no dependencies, so you can read every line and reproduce every trace in this essay yourself. Wall-clock is simulated by a fixed per-step estimate and cost is an illustrative per-token estimate, so the demo is reproducible, with the real LLM controller (generate()) one environment flag away, exactly as in Parts 1 through 7.

What is new since RAG Part 19

I want to be precise about what is new, because the seed of all of it was already there. RAG Part 19’s agent had exactly one stopping control, a single max_steps cap, and a single informal aside in prose: “if the agent runs the same search twice, stop.” That note was never code; it was a warning. This part cashes both of those in and generalizes them. The single max_steps cap becomes a multi-dimensional budget, because one number cannot express “too many tokens” or “too expensive” or “running too long,” and a cap on steps alone happily lets a run burn its whole token and dollar budget inside the allowed step count. The informal “saw the same search twice” note becomes a real, reusable cycle detector that keys on the exact (action, args) and trips at a configurable threshold. And both feed a graceful circuit breaker that returns a partial result instead of crashing or spinning, which RAG Part 19 never had at all. I am not re-teaching the ReAct loop here; Part 1 owns it and I reference it. What is net-new is the budget that has more than one dimension, the detector that formalizes a note into a check, and the breaker that turns “stop” into “stop gracefully.”

The runaway

Start with what happens when nothing stops the loop. The first run uses a stuck_controller that re-issues the identical search every single step and never calls finish(), with the guards turned off so only a demo cap can end it. The ceilings are printed at the top of every run for reference:

Budget ceilings: 4 steps | 600 tokens | $0.02 | 12s (simulated). Loop threshold: 3 identical actions.

Now watch the unguarded run, from the artifact’s real output:

    step 1: search_policy('discount code for ORD-9999') -> (no discount code on file)    [steps 1 | tokens 80 | $0.0008 | ~1.2s]
    step 2: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x2)    [steps 2 | tokens 160 | $0.0016 | ~2.4s]
    step 3: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x3)    [steps 3 | tokens 240 | $0.0024 | ~3.6s]
    step 4: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x4)    [steps 4 | tokens 320 | $0.0032 | ~4.8s]
    step 5: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x5)    [steps 5 | tokens 400 | $0.0040 | ~6.0s]
    step 6: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x6)    [steps 6 | tokens 480 | $0.0048 | ~7.2s]
    step 7: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x7)    [steps 7 | tokens 560 | $0.0056 | ~8.4s]
    step 8: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x8)    [steps 8 | tokens 640 | $0.0064 | ~9.6s]
    [demo cap] cut off at 8 steps; an unguarded agent would not stop on its own.
    final meter: steps 8 | tokens 640 | $0.0064 | ~9.6s
    -> RUNAWAY (no guards): 8 steps and still not done.

Read the trace, because the failure is the whole point. The agent searches for the discount code, gets (no discount code on file), and instead of concluding “there is no code” it searches again. And again. The (x2), (x3), all the way to (x8) count the identical repeats, and the meter climbs in lockstep: tokens 80, 160, ... 640, cost $0.0008 to $0.0064, simulated time ~1.2s to ~9.6s. Nothing in the loop ever says stop. The run does not end because the agent decided it was done; it ends because the demo cuts it off at 8 steps, and the trace says so out loud: an unguarded agent would not stop on its own. That [demo cap] line is the safety net I put there for the essay, not a mechanism the agent has. Without it the meter would have kept climbing. This is not a contrived worry. There are documented incidents of unbounded agent loops burning a serious amount of money before anyone noticed, because nothing was watching the dimensions that cost money. I am not going to invent a specific dollar figure, but the shape is real: a loop with no off-switch is a bill with no ceiling. The rest of the part is three off-switches.

A budget with more than one dimension

The first guard is the BudgetMeter. The insight is that “too expensive” is not one number. A run can be cheap in steps but ruinous in tokens (a single step that re-reads a giant document), or fine on tokens but slow enough to blow a latency SLA, or under every other limit while quietly crossing a dollar ceiling. So the meter tracks four dimensions at once, steps, estimated tokens, estimated USD, and simulated wall-clock, each accumulating a fixed amount per step (80 tokens, $0.0008, ~1.2s), and each with its own ceiling. The crucial detail is when and how it is checked. Before every step the loop asks meter.exceeded(), which walks the dimensions in order and returns the first one that has crossed its limit, or None. One dimension over is enough to stop the run; you do not get to spend your whole token budget just because you are under the step cap. Two honesty notes that the code states plainly and I will repeat here: the wall-clock is simulated, a fixed per-step estimate rather than a measured duration, which is what makes the run reproducible, and the cost is an illustrative per-token estimate, not a real price sheet. Swapping in measured time and a real price table is a one-line change; the mechanism, check all four before every step and stop on the first to cross, is the part that carries forward. We will see exactly which dimension trips in the wandering run further down.

Catching the loop

A budget alone is not enough, because it cannot tell a stuck run from an expensive one until the bill is already large. The stuck run above would eventually trip the token or step budget, but only after wasting every step on the same useless search. The second guard catches the stuck shape directly and early. The LoopDetector keys each step on its exact (action, args), counts how many times that key has been seen, and flags a loop when the count reaches a threshold (here 3). This is the line of code that RAG Part 19 only ever wrote in prose: its “if the agent runs the same search twice, stop” note becomes a real, reusable check that any controller can drop in. Turn the guards on and run the same stuck controller again, from the artifact’s real output:

    step 1: search_policy('discount code for ORD-9999') -> (no discount code on file)    [steps 1 | tokens 80 | $0.0008 | ~1.2s]
    step 2: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x2)    [steps 2 | tokens 160 | $0.0016 | ~2.4s]
    step 3: search_policy('discount code for ORD-9999') -> (no discount code on file)  (x3)    [steps 3 | tokens 240 | $0.0024 | ~3.6s]
    BREAKER TRIPPED: loop detected: identical action repeated 3 times
    breaker: tripped (loop detected: identical action repeated 3 times)
    final meter: steps 3 | tokens 240 | $0.0024 | ~3.6s  (well under budget; the loop caught it first)
    -> Stopped gracefully after 3 steps (loop detected: identical action repeated 3 times). Partial result: could not complete 'Find a discount code for ORD-9999'; returning what was gathered instead of looping or crashing.

Watch the difference. The first two steps are identical to the unguarded run, but the third repeat hits the threshold, the detector flags loop detected: identical action repeated 3 times, and the breaker trips. The final meter reads steps 3 | tokens 240 | $0.0024 | ~3.6s, and the trace makes the key observation explicit: well under budget; the loop caught it first. This is the loop detector earning its place. If the only guard were the multi-dimensional budget, this run would have kept going until a ceiling stopped it: just one more step to the step cap of 4, and far longer to the token cap of 600. The detector recognizes the stuck signature, the identical action repeating, and stops at the first sign of it instead of waiting for the bill to grow. A budget bounds the cost of a run; a loop detector bounds the cost of the specific failure of going nowhere. Diagram 1 lays the detector out: identical actions accumulating into a counter that trips at the threshold.

A diagram of a loop detector. On the left, three identical action cards are stacked, each reading search_policy query discount code for ORD-9999, labelled step 1, step 2 with a small x2 tag, and step 3 with a small x3 tag. An arrow from each card feeds a counter in the centre that shows the value climbing 1, 2, 3 against a dashed red threshold line labelled threshold 3 identical actions. When the counter reaches 3 the threshold line lights up and an arrow leads to a box reading loop detected: identical action repeated 3 times, which connects to a circuit breaker symbol marked tripped. Below, a small meter reads steps 3, tokens 240, 0.0024 dollars, approximately 3.6 simulated seconds, with a label well under budget, the loop caught it first. A caption strip reads: a stuck agent repeats the same action, the detector counts identical action and args and trips at the threshold.
Fig 1 The loop detector formalizing RAG Part 19's informal note into a reusable check. Each step is keyed on its exact action and arguments, here search_policy with the query discount code for ORD-9999. A counter increments every time the identical key is seen: 1, then 2 marked x2, then 3 marked x3. The threshold is 3 identical actions, so on the third repeat the detector flags loop detected: identical action repeated 3 times and trips the circuit breaker. The meter at that point reads steps 3, tokens 240, 0.0024 dollars, ~3.6 simulated seconds, well under every budget ceiling, because the loop caught it first. The point: a stuck agent has a signature, the same action over and over, and the detector recognizes it early instead of waiting for the budget to fill.

Tripping gracefully

The third guard is the part that decides what happens when a limit is hit. A naive implementation would raise an exception (the caller gets a stack trace and the partial work is lost) or, worse, just keep spinning (the failure the runaway showed). The CircuitBreaker does neither. It has three states, closed -> tripped -> graceful-finish: closed while the run is healthy, tripped the moment the budget is exceeded or a loop is detected, and on a trip it records why and the loop returns the best partial result the agent has gathered so far together with that reason. It never raises. We just saw the breaker trip on a loop; now watch it trip on the budget instead. The third run uses a wandering_controller that issues a different plausible search every step (policy clause 1, 2, 3, 4), so it never repeats and the loop detector has nothing to catch. Here the budget is the only thing that can stop it, from the artifact’s real output:

    step 1: search_policy('policy clause 1') -> clause text for 'policy clause 1'    [steps 1 | tokens 80 | $0.0008 | ~1.2s]
    step 2: search_policy('policy clause 2') -> clause text for 'policy clause 2'    [steps 2 | tokens 160 | $0.0016 | ~2.4s]
    step 3: search_policy('policy clause 3') -> clause text for 'policy clause 3'    [steps 3 | tokens 240 | $0.0024 | ~3.6s]
    step 4: search_policy('policy clause 4') -> clause text for 'policy clause 4'    [steps 4 | tokens 320 | $0.0032 | ~4.8s]
    BREAKER TRIPPED before step 5: step budget (4) reached
    breaker: tripped (step budget (4) reached)
    final meter: steps 4 | tokens 320 | $0.0032 | ~4.8s
    -> Stopped gracefully after 4 steps (step budget (4) reached). Partial result: could not complete 'Audit every policy clause'; returning what was gathered instead of looping or crashing.

Read what the two failure shapes have in common and where they differ. The wandering run never repeats, so each step does real-looking work and the loop detector stays silent, but the pre-step budget check catches it: BREAKER TRIPPED before step 5: step budget (4) reached. Note the timing, the breaker trips before step 5, when meter.exceeded() is consulted at the top of the loop and finds steps already at the ceiling of 4. That is the first dimension to cross here; a different controller might have tripped the token or cost dimension first, and the meter returns whichever it is. And critically, the outcome has the exact shape of the loop case: the breaker is tripped, the run Stopped gracefully after 4 steps, and the agent returns a Partial result that names what it could not complete and says it is returning what was gathered instead of looping or crashing. That last clause is the entire contract of the breaker. Whether a loop or a budget tripped it, the caller never gets a crash and never gets an infinite spin; the caller gets the best the agent had plus a plain-English reason it stopped, which is exactly the thing an on-call human needs at 3am. Diagram 2 draws the three states, and the interactive figure below lets you drive both failure shapes and watch the four-dimensional meter fill until one ceiling trips the breaker.

A state-machine diagram of a circuit breaker with three states drawn as rounded boxes left to right. The first box, CLOSED, is labelled run healthy, every step allowed. Two inbound triggers point at the transition out of closed: one labelled BUDGET EXCEEDED with the example step budget 4 reached before step 5, and one labelled LOOP DETECTED with the example identical action repeated 3 times. An arrow leads to the second box, TRIPPED, labelled record the reason, stop the loop, do not raise. An arrow then leads to the third box, GRACEFUL FINISH, labelled return best partial result plus the reason, with a sample line reading Stopped gracefully after 4 steps, step budget 4 reached, returning what was gathered instead of looping or crashing. Two crossed-out alternatives are shown faded below the tripped box: a crash icon labelled no exception and a spinning-loop icon labelled no infinite spin. A caption strip reads: closed to tripped to graceful finish, a partial result and a reason, never a crash, never a spin.
Fig 2 The circuit breaker as a three-state machine that never crashes. It starts closed while the run is healthy. On either trigger it moves to tripped: the multi-dimensional budget exceeded (here the step budget of 4 reached before step 5) or the loop detector flagging an identical action repeated 3 times. Tripping records the reason and moves to graceful-finish, where the agent returns its best partial result plus that reason, for example Stopped gracefully after 4 steps (step budget (4) reached), returning what was gathered instead of looping or crashing. The point: a limit being hit ends the run with something useful and an explanation, never an exception and never an infinite spin.

Open figure ↗

Fig 3 The four-dimensional budget meter and circuit breaker, interactive. Step through either failure shape and watch the BudgetMeter fill. The stuck shape re-issues the identical search_policy query every step, and the loop detector trips the breaker at step 3 (identical action repeated 3 times) while the meter is still well under budget at steps 3, tokens 240, 0.0024 dollars, ~3.6 seconds. The wandering shape issues a different query each step so the detector stays silent, and the step dimension is the first to cross its ceiling, tripping the breaker before step 5 with step budget (4) reached at steps 4, tokens 320, 0.0032 dollars, ~4.8 seconds. Each of the four dimensions, steps, tokens, cost, and simulated time, has its own bar and ceiling, and the meter is checked before every step so the first to cross stops the run. The figure shows multi-dimensional budgeting, the loop detector, and the graceful breaker working as one system.

💡 From experience. The bill that taught me budgets came from a tool that had quietly been removed. An agent I had left running overnight was calling a search endpoint that now returned an empty result, and the controller, reading “no results,” decided the right move was to try the search again, slightly reworded each time so my naive “saw it twice” check never fired. By morning it had made thousands of calls, every one cheap on its own, and the total was a genuinely surprising number on the invoice. The fix was not cleverer prompting; it was two boring guards. A loop detector keyed on the action and its arguments would have caught the literal-repeat version, and a cost ceiling checked before every step would have caught the reworded-wandering version regardless, because the dollars crossed the line long before I woke up. The graceful half of the lesson came later, on a different system. A long agent run tripped its budget at 3am and paged me, and the only reason it was a clean page instead of a fire was that the breaker returned a partial result with the reason attached: it had finished four of six sub-tasks and stopped at the cost ceiling. I read one line, saw it had done the safe thing, and went back to sleep. A crash at 3am is an incident; a partial result with a reason is a note you read in the morning.

Key takeaways

  • The Part 1 loop stops only on finish() or a single max_steps cap (RAG Part 19’s one guard), but a real controller fails to converge in two shapes a step cap cannot tell apart: a stuck agent re-issuing the identical action forever, and a wandering agent making plausible never-ending progress that runs up the bill.
  • The runaway is real: with guards off, the stuck controller repeated search_policy('discount code for ORD-9999') for 8 steps (the meter climbing to steps 8 | tokens 640 | $0.0064 | ~9.6s) and only the demo cap stopped it, because an unguarded agent would not stop on its own. Documented incidents of unbounded loops have burned serious money this way.
  • A multi-dimensional budget (BudgetMeter over steps, tokens, cost, and simulated wall-clock) is checked before every step and stops on the first dimension to cross its ceiling, because one number cannot express “too expensive.” Wall-clock is simulated and cost is an illustrative per-token estimate.
  • A loop detector keys on the exact (action, args) and trips at a threshold (here 3). On the stuck run it fired at steps 3 | tokens 240 | $0.0024 | ~3.6s, well under budget; the loop caught it first. This formalizes RAG Part 19’s informal “saw the same search twice” note into a reusable check.
  • A circuit breaker ties them together with states closed -> tripped -> graceful-finish. On the wandering run the budget tripped it before step 5: step budget (4) reached, and the agent Stopped gracefully after 4 steps, returning a partial result instead of looping or crashing. It never raises and never spins.
  • The two failure shapes need different catchers: the loop detector recognizes the stuck signature early, the budget bounds the cost of any run including a wandering one, and the breaker makes either outcome a partial result plus a reason. Owned here, reused later: the BudgetMeter in Part 13 and the breaker in Part 14.

Glossary

  • Multi-dimensional budget: a stopping control that tracks several cost axes at once rather than a single step count, because “too expensive” cannot be captured by one number; checked before every step and triggered the instant any one axis crosses its ceiling.
  • BudgetMeter: the object that accumulates the four dimensions (a fixed 80 tokens, $0.0008, and ~1.2s per step, plus a step count) and exposes exceeded(), which returns the first dimension over its limit or None. Reused in Part 13 to cap a code-execution sandbox.
  • The four dimensions: steps, estimated tokens, estimated USD, and simulated wall-clock, with ceilings 4 steps | 600 tokens | $0.02 | 12s. Wall-clock is simulated (a fixed per-step estimate, not measured) and cost is an illustrative per-token estimate (not a real price sheet); both are reproducible by design.
  • Loop detector: a check that keys each step on its exact (action, args), counts repeats, and flags a loop when the count reaches a threshold (here 3 identical actions); it recognizes the stuck signature early, before the budget fills. It formalizes RAG Part 19’s informal “stop if you see the same search twice” note into reusable code.
  • Circuit breaker: a three-state machine, closed -> tripped -> graceful-finish, that trips on either a budget breach or a detected loop. It records the reason and ends the run without raising an exception and without spinning. Reused in Part 14 to stop a runaway supervisor.
  • Graceful partial result: what the breaker returns on a trip, the best output the agent gathered plus a plain-English reason it stopped (for example Stopped gracefully after 4 steps (step budget (4) reached)), so the caller gets something useful and an explanation rather than a crash or an infinite loop.

The rule from this part: an agent you can leave running needs to know when to give up, and one number cannot tell it. Bound the run on every dimension that costs money (a BudgetMeter checked before each step, the first to cross stops it), catch the stuck signature directly (a loop detector on the exact action and arguments, formalizing RAG Part 19’s note), and make hitting a limit graceful (a circuit breaker that trips to a partial result plus a reason, never a crash and never a spin). But notice the assumption that survived even this part: the agent lives in a single, fragile, in-memory process. The meter, the detector, the breaker, the partial result, all of it exists only in RAM. Trip a breaker cleanly, return a tidy partial result, and then have the process get killed mid-flight, and the half-done work vanishes. Part 9, The Durable Agent, is about surviving that: an event journal that records every step before it happens, replay that reconstructs state after a crash, and effectively-once execution so a refund that was halfway issued when the process died is neither lost nor double-paid.

AgentsReliabilityBudgetsLoop DetectionCircuit BreakerAI