Jinyuan Lu

Your agents can loop forever. Catch it before they run.

2026-07

Two agents called each other in a loop for eleven days and $47,000 before anyone noticed. Nothing stopped them before they ran. Here's how to make that loop impossible to start — with the actual workflow, the rejection, and the proof.

The bug

An agent workflow can call another workflow. Point two of them at each other and you get a loop:

analyzer:  run the model, then call → verifier
verifier:  run the model, then call → analyzer

analyzer calls verifier, which calls analyzer, which calls verifier — and nothing says when to stop. In November 2025 that exact shape — an Analyzer and a Verifier handing work back and forth, no limit, no budget cap — ran for eleven days and about $47,000. It was caught from the billing dashboard. The post-mortem line: “They had monitoring dashboards; they did not have pre-execution enforcement.”

Every popular tool runs this loop and hopes something kills it later. LangGraph throws an error after 25 steps — and its docs suggest improving your tool’s error messages so the model can “reason its way out.” AutoGen and CrewAI give you a max-turns setting. Temporal will faithfully replay it until you pull the plug. All of them fire after it’s running and spending, and after it’s left half-finished writes for someone to clean up.

The other half of the field just bans loops: Airflow, Dagster, and the newer graph tools require a DAG — no cycles at all. Safe, and useless, because “keep going until the answer is good enough” is exactly the shape you want and now can’t express.

The fix is boring

Allow the loop — but make it declare how many times it may go around, and check that before you run it. One rule:

every call that is part of a loop must carry a limit — a max depth or a timeout.

Our two workflows form a loop (analyzer → verifier → analyzer) and neither call has a limit, so the checker refuses to start it:

error: unbounded cycle  analyzer → verifier → analyzer
       analyzer: the call to "verifier" needs max_depth or timeout

The $47k workflow isn’t caught faster. It can’t exist. Add a limit —

analyzer:  run the model, then call → verifier   (max_depth: 5)

— and it’s accepted: the pair may bounce at most 5 times, then it has to stop. That’s the whole idea, and it’s the move a compiler makes every day: reject the broken program instead of running it and mopping up.

Why does a depth limit guarantee it stops? Because the number counts down on every call and can’t go below zero. Something that decreases and can’t go negative can only happen a finite number of times. No cleverness — that is the entire termination argument.

Checking the checker

This is where “we validate your graph before running it” usually stops, and where I didn’t want to. The checker is a program. Programs have bugs. If my loop-checker has a bug, an unbounded workflow slips through and you’re back to the $47k. So I checked the checker — twice, because two different things can be wrong.

Is the rule right? I wrote the rule down as a formal statement and had a model checker try to break it: generate every small workflow, look for one that passes my rule but still loops. In the tool’s own words, the rule is:

assert RecursiveCallsAreBounded {
    all w: WorkflowIR |
        selfRecursive[w] implies
            all n: w.irNodes & InvokeNode |
                n.workflowRef in callsTransitive[w] implies
                (some n.maxDepth or some n.invokeTimeout)
}

Read it as: for any workflow that can end up calling itself, every call it makes back into that loop must have a depth or a timeout. (Every, not some — two calls to the same target down different branches are two calls, and one unbounded one is enough to loop.) The checker found no counterexample. Its honest limit: it only tries small workflows, up to six nodes. Great for catching a design mistake in seconds; not a proof for all sizes. Which is why there’s a second check.

Does the code match the rule? A model checker checks my description of the rule, not the code that actually runs. So I rewrote the core in SPARK — a dialect of Ada where you can prove properties of the real code — and had the prover verify, for every possible input, two things.

The loop-runner can’t run forever:

procedure Execute_Invoke (Wf : Workflow_Id; Remaining : Depth_Type)
  with Subprogram_Variant => (Decreases => Remaining);

That one line tells the prover the remaining depth strictly drops on every call; the prover then guarantees the recursion bottoms out. Not “we’ll catch it” — it cannot loop, and that’s checked, not claimed.

And the checker gives the right verdict: for a workflow that calls only itself, I proved it answers “safe” exactly when the call is bounded — not usually, exactly.

The part I couldn’t finish

One self-call was easy. The general case — a loop through several workflows, A → B → C → A — is where it got hard, and I’d rather show you the wall than pretend it isn’t there.

The trick that made it tractable: don’t hunt for loops by walking the graph. A call A → B is part of a loop exactly when B can get back to A. So “is this call in a loop?” is really “can B reach A?” — plain reachability.

Of the 44 things the prover had to check, 42 went through: nothing crashes, the loop-runner always stops, and the checker’s verdict is correct given a table of who-can-reach-whom. The two that didn’t are one problem — proving that table actually captures every path:

pragma Loop_Invariant
  (for all I in WF => (for all J in WF => (for all P in WF =>
     (if P < K and then R (I, P) and then R (P, J) then R (I, J)))));
metaxon_core.adb:22: medium: loop invariant might not be preserved

This is a known-hard little proof — the solver needs a hand-written argument about paths that it can’t invent on its own. Rather than paper over it with a green checkmark, I left it as a named, open lemma. Anyone can run gnatprove and see the same two lines. “42 of 44, and here’s exactly what’s missing” is worth more than a “100%” you can’t audit.

What this is

Nothing here is new. Checking that loops are bounded is decades old, and I’m not the first to check agent graphs before running them — but the ones that do either don’t check termination or ban loops entirely. The open spot is doing both: allow bounded loops and prove they stop, checked at the rule level and the code level, on something that runs in production.

The point

Catch the loop before it runs. Termination, budget, recursion depth — you can decide these before execution, when rejecting a workflow costs nothing, instead of after, when it costs $47,000 and a cleanup. The tools are forty years old. The only new work is pointing them at agents, and being honest about the one proof that’s still open.

The rule, the proofs, and every rejected shape are open (including the two lines above that don't yet prove): github.com/jinyuanlu/metaxon-spec.