Chapter 6 of 8 · 9 min read
A repeated state with changed inputs is progress
On this page
The planner in run_9402 has entered its verify_totals node four times in nine minutes. An engineer looking at the trace has to decide whether to add a visit cap of two to that node, which would have stopped this run at the four-minute mark and saved about $1.40, or leave it alone. Both readings of the trace are available. The planner may be circling — re-asking a question it has already failed to answer, with nothing new to work from. It may equally be doing exactly what a reconciliation is supposed to do, checking totals, finding a mismatch, going out for another vendor statement, and coming back to check again with more in hand.
The trace as recorded does not say which.
run_9402 agent=planner-1
09:41:07 enter verify_totals -> mismatch $4,102.50 -> dispatch fetch_statement(v2)
09:43:19 enter verify_totals -> mismatch $4,102.50 -> dispatch fetch_statement(v3)
09:46:02 enter verify_totals -> mismatch $4,102.50 -> dispatch fetch_statement(v3)
09:49:55 enter verify_totals -> mismatch $4,102.50 -> dispatch fetch_statement(v3)The mismatch figure is identical at all four visits, which looks damning until you notice that it would also be identical on a run making genuine progress toward explaining the mismatch. What separates the two is not visible in the node name or the outcome. It is in what the planner knew at each visit, and nothing in this trace records that.
What "the same state" has to be a hash of
The obvious implementation of repeat detection hashes the agent's context and compares. That fails immediately in both directions. Hash the full transcript and no two steps are ever equal, because the transcript grows monotonically — every visit carries the previous visits inside it, so a livelocked agent produces four distinct hashes and the detector reports nothing forever. Hash only the node name and every well-formed plan looks like a livelock, because revisiting a verification step is what verification is.
What the detector needs instead is a state signature: a short hash standing for everything about the agent's situation that determines what it does next, computed fresh at every step and compared against the ones already recorded for that run. The signature has to be over the decision-relevant projection of the state, meaning the things that, if they are all identical, guarantee the agent will make the same decision again.
def state_signature(agent_state) -> str:
canonical = {
"goal": agent_state.goal_id,
"node": agent_state.plan_node,
"action": normalize_call(agent_state.pending_action), # sorted args,
# lowercased, no ids
"evidence": sorted(agent_state.evidence_ids), # what it may use
"open_obligations": sorted(agent_state.open_obligation_ids),
"blocked_on": agent_state.blocked_on_id, # from chapter 2
}
return blake3(json.dumps(canonical, sort_keys=True).encode()).hexdigest()[:16]Four fields are deliberately excluded. Wall-clock time and step index are excluded because including them makes every signature unique. Raw token counts are excluded for the same reason. The model's sampled phrasing is excluded because two calls that differ only in wording are the same decision; normalize_call is what collapses search_clauses(q="indemnity cap") and search_clauses(q="Indemnity Cap") into one action, and getting that normaliser wrong is the most common source of a detector that never fires.
The signature is cheap. The canonical form — the same fields serialised in the same order every time, so that two equal states cannot produce two different hashes — for a typical planner state here is about 1.2 kB, and hashing it takes roughly 40 microseconds, against model calls measured in seconds. Storing sixteen bytes of signature plus a short evidence digest per step, for a run of 19 agents averaging 60 steps each, is under 40 kB — small enough that it goes in the step record unconditionally rather than behind a debug flag.
The delta is the field that decides it
A repeated signature is the question, not the answer. The second field each step carries is the delta since the agent's previous visit to that same signature: what evidence was admitted, what tool results came back, how much budget was consumed. Here are the two runs the engineer was choosing between, with the delta filled in.
| Visit | Time | Signature | Evidence set | New facts since last visit | Budget left |
|---|---|---|---|---|---|
run_9402, progressing | |||||
| 1 | 09:41:07 | a71c… | 4 items | — | 218k tok |
| 2 | 09:43:19 | 9f30… | 5 items (+stmt v2) | vendor v2 shows $1,900 credit | 191k |
| 3 | 09:46:02 | c40b… | 6 items (+stmt v3) | v3 shows $2,202.50 rebate | 164k |
| 4 | 09:49:55 | 55e8… | 7 items (+tax memo) | memo explains residual | 138k |
run_9417, livelocked | |||||
| 1 | 10:02:11 | a71c… | 4 items | — | 221k |
| 2 | 10:04:40 | a71c… | 4 items | none — fetch returned 0 rows | 194k |
| 3 | 10:07:02 | a71c… | 4 items | none — fetch returned 0 rows | 167k |
| 4 | 10:09:31 | a71c… | 4 items | none — fetch returned 0 rows | 140k |
The node name, the visit count and the mismatch figure are the same in both. The signature column separates them without a human reading anything: in the progressing run the evidence set grows, so the projection changes, so the signature changes and there is no repeat to detect at all. The livelocked run repeats a71c… three times with an empty delta, and three empty deltas at the same signature is the definition worth acting on.
The budget column is why "a decremented budget counts as change" needs care. Budget always decreases, so if the remaining budget went into the signature, nothing would ever repeat. It belongs in the delta as a cost measure rather than in the signature as an identity: run_9417 spent 81,000 tokens across three visits and admitted zero new facts, which is the ratio that makes it worth stopping. A run that spends 81,000 tokens and admits four new facts is buying something.
def classify_repeat(history, sig, k=3):
visits = [h for h in history if h.signature == sig]
if len(visits) < k:
return "watch"
deltas = [v.new_evidence_count for v in visits[-k:]]
if sum(deltas) == 0:
spent = visits[-k].budget_left - visits[-1].budget_left
return ("livelock", {"visits": len(visits), "tokens_burned": spent})
return "progress"Three is a threshold chosen from data rather than taste. Across 30 days of this pipeline's traces, legitimate plans revisit a node with a genuinely empty delta at most twice — the common case being a retry after a transient tool error, which returns nothing and then succeeds. Setting k=2 flagged 4.1 percent of successful runs; k=3 flags 0.2 percent, and every run that reached three empty deltas in the sample went on to exhaust its budget.
Two fields, four classifications
With blocked_on from chapter 2 and the signature delta from this one, every stuck-looking run falls into one of a small number of buckets, and a dashboard can assign the bucket without anyone opening a trace.
| Agent activity | blocked_on resolves to | Evidence delta | Classification |
|---|---|---|---|
| making calls | nothing (not blocked) | growing | working |
| making calls | nothing | zero across ≥3 visits | livelock |
| idle | a cycle in the wait-for graph | n/a | deadlock |
| idle | a sink outside the run | n/a | legitimate waiting |
| idle | an agent that stopped heartbeating | n/a | orphaned lease |
| idle | nothing, no obligations open | n/a | quiescent — check completion |
Each row implies a different action, and the reason the two fields are worth the plumbing is that four of the six rows are indistinguishable without them. A run that is deadlocked, a run that is waiting on a slow OCR call and a run that has quietly finished all present the same external picture — no tool calls, no token spend, a process answering its health check. Chapters 1 through 4 established that separately for each pair; the table is what those chapters buy when their two fields are recorded on every step of every agent.
A livelock can be spread across two agents
The signature as defined is per-agent, and there is a failure it cannot see because no single agent repeats anything. The planner asks extractor-2 for a clause table, extractor-2 finds nothing and returns an empty table with a note suggesting the pages be re-OCR'd, the planner dispatches a re-OCR and asks again, and the second extraction finds nothing for the same reason. From the planner's side each lap has a new obligation id and a new evidence item — the empty table counts as evidence admitted — so its signature changes every time. From the extractor's side each invocation is a fresh agent instance with an empty history, so it has no repeats to detect by construction.
The pair is going around a loop that neither participant can observe alone. The fix is to compute a second signature at run scope, over the projection of the shared state rather than any agent's private state: the set of artifact ids and versions in the blackboard, the set of open ledger items, and the run's current plan node. That signature is derived from the shared-artifact store of 11.12 rather than from any agent, and it repeats when the blackboard is unchanged even though every agent's private context has grown.
def run_signature(ledger, store, run_id) -> str:
canonical = {
"artifacts": sorted((a.id, a.version) for a in store.list(run_id)),
"open_items": sorted(i.id for i in ledger.open_items(run_id)),
"plan_node": ledger.current_plan_node(run_id),
}
return blake3(json.dumps(canonical, sort_keys=True).encode()).hexdigest()[:16]Re-OCR that produces byte-identical output does not create a new artifact version, so the run signature is unchanged across all three laps while roughly 47,000 tokens and eleven OCR pages are consumed. Evaluating it once per obligation discharge rather than once per step keeps the cost negligible and catches the class of loop that lives in the coordination rather than in any one agent's head.
The delta test is cheatable, and by whom
The honest limitation is that "did anything change" is not the same question as "did anything useful change", and the gap is exploitable by the very behaviour you are trying to catch. An agent that widens its search query slightly on each lap — indemnity cap, then indemnity cap OR ceiling, then indemnity cap OR ceiling OR maximum — produces a different normalized action every time, a different signature, and a delta that is technically non-empty because each search returns a different empty-ish result set. It circles for as long as its budget lasts and the classifier calls it working throughout. The same thing happens accidentally with any tool whose result embeds a timestamp or a request id, since every result then looks new; stripping volatile fields before digesting evidence is a per-tool chore that has to be done and is easy to forget on the tool added last week.
The stronger version of the check adds a monotone measure — a quantity that must move in one direction for the run to be making progress, such as the count of unresolved subgoals, or the size of the remaining mismatch. run_9402's mismatch stayed at $4,102.50 across all four visits, which under a monotone-measure rule is a warning even though the evidence set grew, and it is the right warning: the run was accumulating facts without closing the gap. Measures like that are domain-specific, they have to be defined per plan node, and most teams never define them, which is why the delta test carries most of the weight in practice and the budget from chapter 5 remains the backstop for everything the delta test misses.
None of this decides what the system should do when the classifier says livelock. Stopping the agent is one option; forcing a different action, escalating to a human, or letting it run and recording the classification are others. That choice interacts with something this chapter has quietly assumed — that the classification gets recorded at all. A team that installs a cap without recording why it fired ends up with a dashboard that looks better than the system it measures.
Termination and convergence — read the current version at https://daviddominguez.dev/learn/en/termination-and-convergence/a-repeated-state-with-changed-inputs-is-progress