<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Jinyuan Lu</title>
    <subtitle>Systems &amp; formal methods. Writing on verifiable AI infrastructure.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://1uke.io/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://1uke.io"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-07-06T00:00:00+00:00</updated>
    <id>https://1uke.io/atom.xml</id>
    <entry xml:lang="en">
        <title>Your agents can loop forever. Catch it before they run.</title>
        <published>2026-07-06T00:00:00+00:00</published>
        <updated>2026-07-06T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://1uke.io/writing/prove-your-agent-workflow-terminates/"/>
        <id>https://1uke.io/writing/prove-your-agent-workflow-terminates/</id>
        
        <content type="html" xml:base="https://1uke.io/writing/prove-your-agent-workflow-terminates/">&lt;p class=&quot;lede&quot;&gt;Two agents called each other in a loop for eleven days and $47,000 before
anyone noticed. Nothing stopped them before they ran. Here&#x27;s how to make that loop impossible
to &lt;em&gt;start&lt;&#x2F;em&gt; — with the actual workflow, the rejection, and the proof.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-bug&quot;&gt;The bug&lt;&#x2F;h2&gt;
&lt;p&gt;An agent workflow can call another workflow. Point two of them at each other and you get a
loop:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;analyzer:  run the model, then call → verifier
verifier:  run the model, then call → analyzer
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;analyzer&lt;&#x2F;code&gt; calls &lt;code&gt;verifier&lt;&#x2F;code&gt;, which calls &lt;code&gt;analyzer&lt;&#x2F;code&gt;, which calls &lt;code&gt;verifier&lt;&#x2F;code&gt; — 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 &lt;strong&gt;eleven days and about $47,000&lt;&#x2F;strong&gt;. It was
caught from the billing dashboard. The post-mortem line: &lt;em&gt;“They had monitoring dashboards;
they did not have pre-execution enforcement.”&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;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 &lt;em&gt;after&lt;&#x2F;em&gt; it’s running and
spending, and after it’s left half-finished writes for someone to clean up.&lt;&#x2F;p&gt;
&lt;p&gt;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.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix-is-boring&quot;&gt;The fix is boring&lt;&#x2F;h2&gt;
&lt;p&gt;Allow the loop — but make it declare how many times it may go around, and check that &lt;em&gt;before&lt;&#x2F;em&gt;
you run it. One rule:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;every call that is part of a loop must carry a limit — a max depth or a timeout.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Our two workflows form a loop (&lt;code&gt;analyzer → verifier → analyzer&lt;&#x2F;code&gt;) and neither call has a limit,
so the checker refuses to start it:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;error: unbounded cycle  analyzer → verifier → analyzer
       analyzer: the call to &amp;quot;verifier&amp;quot; needs max_depth or timeout
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The $47k workflow isn’t caught faster. It can’t exist. Add a limit —&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;analyzer:  run the model, then call → verifier   (max_depth: 5)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;— 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.&lt;&#x2F;p&gt;
&lt;p&gt;Why does a depth limit &lt;em&gt;guarantee&lt;&#x2F;em&gt; 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.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;checking-the-checker&quot;&gt;Checking the checker&lt;&#x2F;h2&gt;
&lt;p&gt;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 &lt;em&gt;my&lt;&#x2F;em&gt; 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.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Is the rule right?&lt;&#x2F;strong&gt; 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:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;alloy&quot;&gt;assert RecursiveCallsAreBounded {
    all w: WorkflowIR |
        selfRecursive[w] implies
            all n: w.irNodes &amp;amp; InvokeNode |
                n.workflowRef in callsTransitive[w] implies
                (some n.maxDepth or some n.invokeTimeout)
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Read it as: for any workflow that can end up calling itself, &lt;em&gt;every&lt;&#x2F;em&gt; 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.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Does the code match the rule?&lt;&#x2F;strong&gt; A model checker checks my &lt;em&gt;description&lt;&#x2F;em&gt; 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.&lt;&#x2F;p&gt;
&lt;p&gt;The loop-runner can’t run forever:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;ada&quot;&gt;procedure Execute_Invoke (Wf : Workflow_Id; Remaining : Depth_Type)
  with Subprogram_Variant =&amp;gt; (Decreases =&amp;gt; Remaining);
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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 &lt;em&gt;cannot&lt;&#x2F;em&gt; loop, and that’s
checked, not claimed.&lt;&#x2F;p&gt;
&lt;p&gt;And the checker gives the right verdict: for a workflow that calls only itself, I proved it
answers “safe” &lt;em&gt;exactly&lt;&#x2F;em&gt; when the call is bounded — not usually, exactly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-part-i-couldn-t-finish&quot;&gt;The part I couldn’t finish&lt;&#x2F;h2&gt;
&lt;p&gt;One self-call was easy. The general case — a loop through several workflows, &lt;code&gt;A → B → C → A&lt;&#x2F;code&gt; —
is where it got hard, and I’d rather show you the wall than pretend it isn’t there.&lt;&#x2F;p&gt;
&lt;p&gt;The trick that made it tractable: don’t hunt for loops by walking the graph. A call &lt;code&gt;A → B&lt;&#x2F;code&gt; is
part of a loop exactly when &lt;code&gt;B&lt;&#x2F;code&gt; can get back to &lt;code&gt;A&lt;&#x2F;code&gt;. So “is this call in a loop?” is really
“can B reach A?” — plain reachability.&lt;&#x2F;p&gt;
&lt;p&gt;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 &lt;em&gt;given&lt;&#x2F;em&gt; a table of who-can-reach-whom. The
two that didn’t are one problem — proving that table actually captures &lt;em&gt;every&lt;&#x2F;em&gt; path:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;ada&quot;&gt;pragma Loop_Invariant
  (for all I in WF =&amp;gt; (for all J in WF =&amp;gt; (for all P in WF =&amp;gt;
     (if P &amp;lt; K and then R (I, P) and then R (P, J) then R (I, J)))));
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;metaxon_core.adb:22: medium: loop invariant might not be preserved
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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 &lt;code&gt;gnatprove&lt;&#x2F;code&gt; 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.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-is&quot;&gt;What this is&lt;&#x2F;h2&gt;
&lt;p&gt;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 &lt;em&gt;and&lt;&#x2F;em&gt; prove they stop,
checked at the rule level and the code level, on something that runs in production.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-point&quot;&gt;The point&lt;&#x2F;h2&gt;
&lt;p&gt;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.&lt;&#x2F;p&gt;
&lt;p class=&quot;foot&quot;&gt;The rule, the proofs, and every rejected shape are open (including the two
lines above that don&#x27;t yet prove):
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;jinyuanlu&#x2F;metaxon-spec&quot;&gt;github.com&#x2F;jinyuanlu&#x2F;metaxon-spec&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
