Chapter 82 · Agent loops and control
Planning: explicit plans against implicit ones
A plan you can read before anything executes, against a sequence that emerges one step at a time. What each buys, the hybrid that is usually right, and the plan that was followed off a cliff.
11 min read
The loop so far is implicitly planned: the model decides the next call from what it has, with no commitment to a sequence. This chapter is about making the plan explicit — producing it, showing it, and checking it — and about when that is worth doing.
The two shapes
| Implicit | Explicit | |
|---|---|---|
| Adapts to results | ✅ Naturally | ⚠️ Needs replanning |
| Reviewable before acting | ❌ | ✅ |
| Parallelisable | ❌ One at a time | ✅ Independent steps |
| Cost | One model call per step | One upfront, then cheap |
| Confirmable as a whole | ❌ Per action | ✅ Approve the plan |
The last row is the strongest argument for explicit planning in a system with write tools: a person can approve "check these three things, then draft a quote" once, rather than being asked four times.
Producing a plan
// src/agent/plan.ts
export const PlanSchema = z.object({
goal: z.string().max(200),
steps: z.array(z.object({
n: z.number().int().positive(),
tool: z.string(),
arguments: z.record(z.unknown()),
why: z.string().max(140),
// ⚠️ Declared dependencies. This is what makes parallelism safe and
// what distinguishes a plan from a list.
dependsOn: z.array(z.number().int().positive()).default([]),
})).min(1).max(6),
// The model's own statement of what it cannot determine upfront.
unknowns: z.array(z.string()).default([]),
})<!-- src/generate/prompts/plan.md -->
Produce a plan to answer the question using the available tools.
Rules:
- At most six steps. Fewer is better.
- Each step names one tool and its arguments.
- If a step needs a value you do not have yet, list the step it depends on
and leave the argument as the placeholder {{step_N.field}}.
- If something cannot be determined from the tools available, list it under
unknowns rather than inventing a step for it.
- Do not plan write actions unless the user asked for an action.
Question: {{question}}That last rule is chapter 76's intent boundary, applied at plan time — which is better than at execution time, because the plan can be rejected before anything runs.
Validating a plan before executing it
The main benefit of explicitness: a plan is data, and data can be checked.
export function validatePlan(plan: Plan, scope: Scope, intent: Intent): Issue[] {
const issues: Issue[] = []
for (const step of plan.steps) {
const tool = TOOLS[step.tool]
if (!tool) { issues.push({ kind: 'unknown_tool', step: step.n }); continue }
// Permission, up front, for every step.
if (!tool.permitted(scope)) issues.push({ kind: 'not_permitted', step: step.n })
// Intent, up front. Chapter 76.
if (!ALLOWED_BY_INTENT[intent].includes(toolKind(tool))) {
issues.push({ kind: 'outside_intent', step: step.n, tool: step.tool })
}
// ⚠️ Arguments that are not placeholders must validate now. An
// invalid argument at step 4 is better found before step 1 runs.
const concrete = stripPlaceholders(step.arguments)
const parsed = tool.schema.partial().safeParse(concrete)
if (!parsed.success) issues.push({ kind: 'invalid_arguments', step: step.n })
// Dependencies must be earlier steps, and acyclic.
if (step.dependsOn.some((d) => d >= step.n)) {
issues.push({ kind: 'forward_dependency', step: step.n })
}
}
// Preconditions, checked against the plan rather than against history.
for (const step of plan.steps) {
for (const pre of TOOLS[step.tool]?.preconditions ?? []) {
const satisfied = plan.steps.some((s) => s.n < step.n && s.tool === pre.tool)
if (!satisfied) issues.push({ kind: 'precondition_missing', step: step.n, needs: pre.tool })
}
}
return issues
}Catching a permission failure at plan time rather than at step 4 saves three steps of work and produces a much better message: "I cannot do this because I would need to send a quote, which needs senior approval" rather than three successful steps and then a refusal.
Executing a plan, with dependencies
export async function executePlan(plan: Plan, run: Run): Promise<ExecutionResult> {
const results = new Map<number, ToolResult>()
// Steps with no unmet dependencies run together — chapter 76's
// parallel rule, now derived from declared dependencies rather than
// guessed from a single turn.
for (const wave of topologicalWaves(plan.steps)) {
const runnable = wave.filter((s) => s.dependsOn.every((d) => results.get(d)?.ok))
const parallel = canRunInParallel(runnable.map((s) => ({ tool: s.tool, arguments: s.arguments })))
const executed = parallel
? await Promise.all(runnable.map((s) => executeStep(s, results, run)))
: await sequentially(runnable, (s) => executeStep(s, results, run))
for (const [i, result] of executed.entries()) results.set(runnable[i].n, result)
// ⚠️ A failed step may invalidate the rest of the plan. Replan rather
// than continuing with steps whose inputs never arrived.
if (executed.some((r) => !r.ok)) {
return { kind: 'needs_replan', completed: results, failedAt: wave[0].n }
}
}
return { kind: 'complete', results }
}
// Placeholders resolved from earlier results at execution time.
function resolveArguments(step: PlanStep, results: Map<number, ToolResult>) {
return mapValues(step.arguments, (v) =>
typeof v === 'string' && v.startsWith('{{')
? get(results.get(stepOf(v))?.data, fieldOf(v))
: v)
}Replanning
A plan that cannot adapt is worse than no plan. The rule is to replan on surprise, not on every step.
export function needsReplan(step: PlanStep, result: ToolResult, plan: Plan): boolean {
if (!result.ok) return true // a failed step
if (isEmptyResult(result) && dependentsOf(step, plan).length > 0) return true
// ⚠️ A result that contradicts the plan's assumption. The plan said
// "get the discount then compare orders"; there is no discount term.
if (result.contradicts?.(plan.assumptions)) return true
return false
}// Replanning is a new plan with the completed work as context, and it
// counts against the same budget — chapter 81's bounds do not reset.
if (replans >= MAX_REPLANS) {
return { kind: 'halted', reason: 'replan_limit',
message: 'The plan kept needing revision. Here is what I established.' }
}Two replans is Atlas's limit. Beyond that the model is not adapting, it is guessing, and chapter 80's exhaustion detection is the better exit.
What it costs and buys
Atlas, the 6% of traffic that uses the loop:
| Implicit | Explicit | |
|---|---|---|
| Model calls per run | 3.4 | 2.1 |
| p50 latency | 4.8 s | 2.9 s |
| Cost per run | $0.023 | $0.017 |
| Correct | 0.93 | 0.94 |
| Permission/intent failure found before acting | 0% | 100% |
| Confirmable as a whole | No | Yes |
Explicit planning is faster and cheaper here, which is not obvious: the upfront plan costs one call, and it pays for itself by enabling parallel execution of independent steps.
⚠️ That result depends on Atlas's plans averaging 2.8 steps with roughly half independent. A task whose steps are strictly sequential gains nothing from parallelism and the upfront call is pure overhead.
Showing the plan
I'll check three things: 1. Henderson's contract terms — to find the agreed discount 2. Their current open orders — to see what was actually applied 3. Their payment history, last 90 days — to check for rejected payments I can't tell from the documents whether a credit note was raised manually. [ Go ahead ] [ Change this ]
For a read-only plan this is shown and executed without waiting. For a plan containing a write, it is a single confirmation covering the whole sequence — which is a better experience than three separate dialogs and a stronger control, because the approver sees the shape rather than one action at a time.
The unknowns line is worth keeping visible. It sets expectations before the answer arrives, and it is the model saying what it cannot do, which is chapter 63's abstention moved earlier.
The failure mode: the plan that was followed off a cliff
A plan is produced for a past-due investigation. Step 1 returns an account summary showing the account is not past due — it was settled yesterday.
The plan continues. Step 2 fetches open invoices (none). Step 3 fetches payment history (fine). The answer explains at length that the account appears to be in good standing, having spent three tool calls establishing something step 1 already said.
Implicit planning would have stopped after step 1. The plan's rigidity is the cost of its reviewability.
// The fix is not to abandon plans. It is that a plan is a hypothesis,
// and a step that invalidates it ends it.
export function invalidatesPlan(result: ToolResult, plan: Plan): string | null {
for (const premise of plan.premises ?? []) {
if (premise.check(result) === false) return premise.description
}
return null
}<!-- So the plan prompt asks for premises, not only steps. -->
List the premises this plan depends on — facts you are assuming are true.
If a step shows a premise is false, the plan should stop.{
"goal": "Explain why the Henderson account is past due",
"premises": ["The account is past due"],
"steps": [ ... ]
}// And the check, after every step.
const broken = invalidatesPlan(result, plan)
if (broken) {
return { kind: 'premise_failed', premise: broken,
message: `The account is not past due — it was settled on 2 November. ` +
`Stopping here.` }
}A plan is a hypothesis about what will be true. Ask for its premises, check them as results arrive, and stop when one is false — otherwise an explicit plan trades adaptivity for reviewability at full price.
Key points
- Explicit plans are reviewable, parallelisable and confirmable as a whole; implicit ones adapt for free.
- Declare dependencies between steps. That is what separates a plan from a list and what makes parallelism safe.
- Validate the whole plan before executing: permissions, intent, arguments, dependency order and preconditions.
- Catching a permission failure at plan time saves the steps before it and produces a far better message.
- Resolve placeholder arguments from earlier results at execution time.
- Replan on surprise, not on every step, and cap it at two — beyond that, exhaustion detection is the better exit.
- Explicit planning was faster and cheaper on Atlas because independent steps ran in parallel. A strictly sequential task gains nothing.
- Show the plan, including what the model says it cannot determine.
- Ask for premises as well as steps, and stop when one is falsified. Otherwise a plan is followed past the point it stopped making sense.