Chapter 80 · Agent loops and control
Termination, and the agent that never stops
Five ways a loop fails to stop, only one of which a step limit catches. Teaching a model to give up, and why stopping early with a partial answer beats stopping late with a complete one.
11 min read
A step limit is the obvious termination control and it catches the least interesting failure. This chapter is about the other four.
The framing: a loop that stops at the step limit has already failed. The limit is a backstop, and a run that hits it burned its whole budget producing nothing. Good termination is early, deliberate, and explains itself.
Five ways it fails to stop
| # | Failure | Looks like | Caught by |
|---|---|---|---|
| 1 | Genuine complexity | Many distinct useful steps | Step limit |
| 2 | Repetition | The same call, repeatedly | Loop detection |
| 3 | Oscillation | A → B → A → B | Cycle detection |
| 4 | Searching for the unreachable | Trying tool after tool | Exhaustion detection |
| 5 | Not recognising completion | Has the answer, keeps going | Sufficiency check |
Atlas's distribution of the 2% of runs that hit the step limit:
searching for the unreachable 58% oscillation 19% repetition 13% not recognising completion 7% genuine complexity 3% ⚠️ the only one a bigger limit helps
Raising the step limit helps 3% of the problem. The other 97% needs detection.
Repetition and oscillation
Both are cheap to detect and should end the run with an explanation rather than at the limit.
// src/agent/termination.ts
export function detectRepetition(trace: TraceEntry[]): Termination | null {
if (trace.length < 2) return null
const recent = trace.slice(-3)
// Identical call twice in a row: the model did not absorb the result.
const [a, b] = recent.slice(-2)
if (a.tool === b.tool && deepEqual(a.arguments, b.arguments)) {
return { kind: 'repetition', tool: a.tool,
message: 'The same call was made twice with identical arguments.' }
}
// ⚠️ Oscillation: A B A. Usually the model is trying to reconcile two
// results that disagree, and it will not converge by calling again.
if (recent.length === 3 && recent[0].tool === recent[2].tool &&
recent[0].tool !== recent[1].tool &&
deepEqual(recent[0].arguments, recent[2].arguments)) {
return { kind: 'oscillation', tools: [recent[0].tool, recent[1].tool],
message: 'Alternating between two calls without new information.' }
}
return null
}// Detected, and returned TO THE MODEL once before halting — a nudge often
// resolves it, and halting immediately loses a recoverable run.
const repeat = detectRepetition(run.trace)
if (repeat && !run.context.nudgedOn?.includes(repeat.kind)) {
run.context.nudgedOn = [...(run.context.nudgedOn ?? []), repeat.kind]
results.push(systemNote(
`${repeat.message} Answer from what you already have, or say what is missing.`))
} else if (repeat) {
return { kind: 'halted', reason: 'repetition', partial: run.messages }
}One nudge, then halt. Atlas's measurement: the nudge resolved 71% of repetitions into an answer, and the second occurrence resolved essentially none — so nudging twice is wasted budget.
Searching for the unreachable
The biggest category and the one that needs a different kind of detection: the model is trying tools because it has not found the answer, and the answer is not in any of them.
// Track distinct tools tried without progress. "Progress" is the useful
// definition here, and it is not "a call succeeded".
export function detectExhaustion(trace: TraceEntry[]): Termination | null {
const sinceProgress = trace.slice(lastProgressIndex(trace) + 1)
const distinct = new Set(sinceProgress.map((t) => t.tool))
if (distinct.size >= 3) {
return { kind: 'exhaustion', tried: [...distinct],
message: `Tried ${distinct.size} different tools without finding new ` +
`information. The answer may not be available.` }
}
return null
}
// ⚠️ Progress is a result that CHANGED what is known: non-empty, not an
// error, and not identical to something already seen. An empty result is
// a successful call and not progress.
function isProgress(entry: TraceEntry, seen: Set<string>): boolean {
if (!entry.result.ok) return false
const digest = resultDigest(entry.result)
if (seen.has(digest)) return false
if (isEmptyResult(entry.result)) return false
seen.add(digest)
return true
}// And the halt message says what was tried, which turns a dead end into
// a useful answer — chapter 63's known-gap routing applied to a loop.
return {
kind: 'halted', reason: 'exhaustion',
message: `I could not find this. I checked the account record, open invoices ` +
`and payment history, and none of them covers it. ${routeToHuman(question)}`,
}Not recognising completion
The model has what it needs and keeps going, usually to "check one more thing".
// A sufficiency check between steps, using the CHEAP model. One small
// call to avoid an expensive one is a good trade at step 3 or later.
export async function hasEnough(run: Run): Promise<boolean> {
if (run.step < 2) return false // not worth checking early
const verdict = await callModel({
model: models.routing, // the small, fast one
messages: [{ role: 'user', content: sufficiencyPrompt(run) }],
maxTokens: 10,
})
return verdict.text.trim().toLowerCase().startsWith('yes')
}<!-- src/generate/prompts/sufficiency.md -->
Can the question below be answered completely from the information gathered?
Answer only "yes" or "no".
Question: {{question}}
Gathered: {{summaries}}⚠️ This costs a small call per step from step 2. On Atlas it saved an average of 1.3 steps on runs that would have continued, which is a clear net win given the quadratic cost growth — but measure it, because at a one-step median it would not pay.
Teaching the model to stop
The prompt matters more than the detectors, because it prevents rather than catches.
<!-- Added to the loop's system prompt. Each line maps to one of the five
failure modes above. -->
Stopping rules:
- Answer as soon as you can, from what you have. Do not gather more than
the question needs.
- If a tool returns nothing useful, do not call it again with different
arguments more than once.
- If three different tools have not produced what you need, stop and say
what you checked and what is missing.
- A partial answer naming what is missing is better than no answer.
- Never call a tool to confirm something you have already established.The fourth line is the one that changes behaviour most. Without explicit permission to answer partially, models keep searching — and the resulting run both costs more and ends with less.
// Measured, same cases, prompt varied.
// no stopping rules: median 4.1 steps, 9% hit the limit
// with stopping rules: median 2.6 steps, 2% hit the limit
// ⚠️ and correctness ROSE from 0.91 to 0.94, because fewer runs ended
// with nothing.Partial answers are the point
// A halted run is not a failure. Generate from what it has, and say what
// is missing — the honest version of chapter 63's abstention, applied to
// a run that did some work.
export async function answerFromPartial(run: Run, reason: HaltReason) {
const gathered = run.trace.filter((t) => t.result.ok && !isEmptyResult(t.result))
if (gathered.length === 0) {
return { text: ABSTENTIONS.not_in_corpus, found: false, partial: true }
}
return generate(run.question, gathered, {
prompt: 'answer-partial',
// ⚠️ The model is told what was NOT checked, so the answer can name
// the gap rather than implying completeness.
missing: describeMissing(run, reason),
})
}The account is $23,100 past due [1], with one disputed invoice [2]. I could not check the payment history — the finance system did not respond — so there may be a rejected payment I have not accounted for.
That answer is more useful than an apology and more honest than a confident paragraph that omits the gap.
Measuring termination
select terminated,
count(*),
round(avg(steps), 1) as avg_steps,
round(avg(cost_usd)::numeric, 4) as avg_cost,
round(avg(correct::int), 2) as correct
from runs
where started_at > now() - interval '30 days'
group by 1 order by 2 desc;Atlas, after this chapter's changes:
| Terminated | Runs | Avg steps | Avg cost | Correct |
|---|---|---|---|---|
| answered | 2,891 | 2.4 | $0.019 | 0.95 |
| halted: exhaustion | 94 | 3.8 | $0.031 | n/a — reported honestly |
| halted: repetition | 11 | 3.1 | $0.026 | n/a |
| halted: step_limit | 7 | 8.0 | $0.084 | 0.14 |
| suspended | 203 | 2.1 | $0.016 | n/a |
The step-limit row is the one to drive toward zero. Seven runs, the most expensive per run by a factor of four, and 14% correct. Everything else in this chapter exists to move runs out of that row and into exhaustion, where the cost is lower and the answer is honest.
The failure mode: the limit that was raised
The step limit is 8. Runs hit it. Someone raises it to 15 on the reasonable grounds that complex investigations need more steps.
Two things happen.
Runs that would have hit the limit at 8 now hit it at 15, having cost roughly four times as much — the quadratic growth means steps 9 to 15 are the expensive ones.
And the 3% with genuine complexity now succeed, which is real and is used to justify the change. The remaining 97% are not helped at all; they are made more expensive.
// ⚠️ Before raising a limit, classify the runs that hit it. If most are
// not 'genuine complexity', a higher limit is a cost increase.
export async function analyseLimitHits(days = 30) {
const hits = await db.query(
`select run_id, trace from runs
where terminated = 'step_limit' and started_at > now() - interval '${days} days'`)
return countBy(hits.rows, (r) => classifyNonTermination(r.trace))
// repetition / oscillation / exhaustion / no_completion / genuine
}// And the better fix when complexity IS genuine: a higher limit for the
// classified-hard route only, not globally.
const limits = route.multiHop && route.complexity === 'high'
? { ...LIMITS, maxSteps: 12 }
: LIMITSA step limit that is being hit is telling you something about the runs, not about the limit. Classify before you raise it — in 97% of cases the limit is not the constraint.
Key points
- A loop that stops at the step limit has already failed. The limit is a backstop.
- Five non-termination modes. A step limit only helps the 3% with genuine complexity.
- Detect repetition and oscillation cheaply, nudge once, then halt. A second nudge resolves nothing.
- Define progress as a result that changed what is known. An empty result is a successful call and not progress.
- Exhaustion is the largest category. Halt naming what was checked, which turns a dead end into a useful answer.
- A sufficiency check with the cheap model from step 2 saved 1.3 steps per continuing run.
- Give the model explicit permission to answer partially. Stopping rules cut median steps from 4.1 to 2.6 and raised correctness.
- A halted run usually has useful work. Generate from it and name what is missing.
- Drive the step-limit row toward zero: it is the most expensive outcome and the least correct.
- Classify the runs hitting a limit before raising it.
Next: step, time and spend limits — three bounds that fail differently.