Planning
The agent's two planning tools — the write_todos checklist and the plan+advance metronome — their server logic, agent-streaming integration, and shared frontend rendering
1. The model owns the plan; the server only repairs
write_todos lets the model decompose a complex task into ordered stages and execute them step by step. It is a
full-stack collaborative system — but the model owns the plan; the server is a light touch, not an enforcer:
- Server: honors the full list verbatim and applies only minimal silent repairs (dedupe ids, restore an omitted
done item, keep one
in_progress, auto-advance). No corrections are surfaced — the mergedtodosare the sole output. - Agent streaming layer: forces continued tool calls until every stage is done, and injects a per-step directive telling the model which stage to work on next
- Frontend: transforms the flat tool-call stream into grouped, visually structured stage rows
Two different planning designs, one per task. This doc details
write_todos, theclassicmode — but the default is theplan+advancemetronome (§7). The mode is chosen per task viaplanningMode("classic" | "metronome", defaultmetronome), andselectPlanningToolkeeps only the chosen tool key so the model never sees both. They are not two flavors of the same thing — see §7 for how they differ at the design level. Sections 2–6 coverwrite_todos.
2. Data Model
2.1 TodoItem State Machine
The intended flow is pending → in_progress → done, one stage at a time. But the model owns the plan — it re-plans
freely by resending the full list (reopen, rewrite, drop, or reorder stages). The server does not enforce
forward-only status or immutability; it honors the input verbatim, with one safety net:
- An omitted
doneitem is silently restored in place — a completed stage is never lost to a forgetful resend - An omitted non-
doneitem is taken as an intentional drop (re-plan / simplification) - A resent item is honored as-is, including reopening a
donestage or editing its content
2.2 Core Types
interface TodoItem {
id: string; // Stable identifier across calls
content: string; // Task description
status: "pending" | "in_progress" | "done"; // Forward-only status
}
interface WriteTodosInput {
todos: TodoItem[]; // Full list submitted each call
}
type WriteTodosOutput = {
todos: TodoItem[]; // Authoritative merged list (may differ from input) — the SOLE output
};
// No `message` and no `corrections`. The per-step "work on stage X" directive is injected separately
// by prepareStep (buildTodoDirective), and the server's repairs are silent.
3. Server Execution Pipeline
Each write_todos call passes through a 5-step pipeline:
input.todos
↓
deduplicateTodos — drop duplicate ids (keep first)
↓
mergeTodos — honor the model's list; silently restore an omitted done item
↓
autoPromote — promote the first pending if none is active (and a done exists)
↓
enforceSingleActive — demote any extra in_progress to pending
↓
context.todos = merged // update agent context
↓
return { todos: merged } // the merged list is the sole output
buildTodoDirective is not part of this pipeline — prepareStep injects it per step from the live
context.todos (see 3.5). All repairs below are silent: nothing is surfaced to the model except the merged list.
3.1 Deduplication
Deduplicates by id, keeping the first occurrence. Silent — no signal is returned.
3.2 Merge
Merges the submitted list with context.todos (previous call result). The model owns the plan, so the input is honored
verbatim; the only intervention is restoring an omitted completed stage:
| Scenario | Handling |
|---|---|
done item omitted | Silently restored in place — never drop a completed stage |
done item resent | Honored verbatim — the model may reopen or edit it |
in_progress / pending resent | Honored verbatim (including status rollback) |
non-done item omitted | Dropped — taken as an intentional re-plan / simplification |
| New id appears | Appended to end |
Design intent: the model is trusted to re-plan; the one thing the server won’t let it lose is a completed stage it simply forgot to resend (downstream stages may depend on that output).
3.3 Auto-Promote
When all three conditions are met, auto-promotes the first pending item to in_progress:
- No in_progress items exist
- At least one pending item exists
- At least one done item exists (excludes initial plan creation)
This avoids wasting a round-trip when the model forgets to advance the next stage.
3.4 Single-Active Enforcement (enforceSingleActive)
Scans the list keeping at most one in_progress item. If multiple are found, keeps the first and demotes the rest to
pending — silently.
3.5 The Per-Step Directive (buildTodoDirective)
Not part of the execute pipeline: prepareStep injects this per step from the live context.todos as a keyed reminder
(outside the cache prefix). It is the sole model-facing signal besides the merged list — the tool output carries no
message. It guides the model’s next action:
| State | Message template |
|---|---|
| All done | "All stages complete. Deliver the final output to the user now." |
| Active + has next | "[Stage N/M] Execute "xxx" to completion first — only after output is verified, call write_todos once to mark "xxx" done and "yyy" in_progress." |
| Active + no next | "[Stage N/M] Execute "xxx" to completion first (final stage) — only after output is verified, mark done then deliver to user." |
| No active | "[Stage N/M] No active stage — mark the first pending item as in_progress to begin." |
4. Agent Streaming Integration
4.1 Context Maintenance & the Reminder Channel
After tool execution, the merged result is stored in RuntimeContext.todos. This persists throughout the agent
execution cycle for subsequent tool calls and streaming control.
The directive from §3.5 does not travel in the tool output — it rides the reminder channel, and understanding that channel is what makes “the directive stays fresh each step” concrete.
RuntimeContext.reminders is a keyed map of short strings, not a flat list. Each step, prepareStep serialises the
current reminders into a single <task_reminder>…</task_reminder> block and appends it to the last user message
(via getRemindersText()). Two properties matter:
- Keyed = upsert, not accumulate.
setReminder(key, text)replaces that key’s previous value; different producers (keys) coexist. So a per-step directive re-set under the same key stays single and current — it never piles up copies across steps.clearReminder(key)removes one producer’s line. - Ephemeral = outside the cache prefix. The block is appended after the prompt-cache breakpoint, so its ever-changing content never invalidates the cached system + tools + history prefix. This is precisely why the standing directive can be rewritten every step at no cache cost (see Prompt Caching).
<task_reminder> is an application-level protocol declared in the system prompts, so the model treats its contents as
runtime-authoritative — above ordinary conversation text.
Reminders are how planning steers the model. Both planners write their standing directive to this channel each step, keyed so it stays a single live line:
classic— when any todo is incomplete,prepareStepsets the"todos"key tobuildTodoDirective(ctx.todos)(§3.5); once every todo isdone, it clears the"todos"key so the “deliver now” nudge stops.metronome— the"plan"key holdsbuildStageDirective(ctx.plan), rebuilt from the live pointer each step and cleared once the plan is complete (§7.3).- One-shot nudges — a tool can set a
once:-keyed reminder (e.g.plan/advance’s “advance refused: nothing was produced since the last stage”) thatprepareStepinjects on the next step and then auto-clears, so it fires exactly once. Abaselinereminder (e.g. the no-emoji rule) is set at setup and rides the same channel.
Because the directive lives on this channel — regenerated from ctx.todos / ctx.plan, not baked into a cached tool
output — the silent server repairs (§3) reach the model the moment the next step’s directive is rebuilt.
4.2 complete is gated until the plan is done (stopOnComplete)
Premature exit is prevented at the stop condition, not with toolChoice. The loop’s stopOnComplete
(tools/stop-conditions.ts) lets a complete call end the turn only when the active planner is finished:
// stopOnComplete: a `complete` call stops the loop only if no stage remains
const todosIncomplete = ctx.todos?.some((t) => t.status !== "done") ?? false;
const planIncomplete = !isPlanComplete(ctx.plan);
return hasComplete && !(todosIncomplete || planIncomplete);
So a complete called with stages still open — classic (write_todos) todos not all done, or metronome
(plan/advance) pointer not at the end — does not stop the turn; the loop feeds the result back and the per-step
directive keeps pointing at the next stage. Only when every stage is complete does complete actually end the turn.
One condition covers both planners.
4.3 How the Silent Repairs Reach the Model
The server’s repairs (§3) are silent — there is no corrections/message channel telling the model “I restored X”
or “I demoted Y”. Yet they do affect the model, through three channels, all derived from the merged list:
- Tool output —
executereturns{ todos: merged }, so the merged (repaired) list is the tool result the model reads on its next step. A restored done item, an auto-promoted stage, or a demoted duplicate shows up there. - Per-step directive —
buildTodoDirectiveis regenerated each step fromctx.todos(the merged list), so auto-promote / single-active repairs change the very “work on stage X” instruction the model receives. stopOnCompletegate — it reads the merged list (todosIncomplete), so the repairs decide whether an earlycompletecan actually end the turn.
So the model isn’t told what changed — it self-corrects by observing the authoritative merged todos and
reconciling it against what it sent. This is why the input is detached before the repairs run (§3): the persisted
tool-call input stays exactly what the model submitted (an honest record of “what I sent”), while output.todos is
the repaired result (“what the server settled on”). If the two ever diverge, the divergence itself is the signal —
merging the mutation into the input would instead produce a self-contradictory trace the model can’t reason about.
5. Frontend Rendering Pipeline
The frontend transforms flat parts arrays into grouped structures via the useAgentMessage hook.
5.1 Three-Level Performance Strategy
| Level | Condition | Complexity | Description |
|---|---|---|---|
| L0 | parts + isStreaming + finishReason all unchanged | O(1) | Return cache |
| L1 | !everHadChain | O(n) scan | No plan/write_todos chain ever — pass through |
| L2 | Has a plan/write_todos chain | O(n) fold + stabilize | Fold into stage parts + reference stabilization |
finishReason comes from message.metadata.finishReason (assistant messages), written by the server when the stream
terminates (stop / tool-calls / length / abort / error). It participates in both the memo key and the status
decision tree as a terminal-state fallback signal.
5.2 Stage-Folding Algorithm
Key concepts:
- displayTodo: The “currently active” item — prefers
in_progress, falls back to lastdone, then first item (getDisplayTodoin@zapvol/common) - stage: A run of content grouped under one todo state —
write_todosandplanshare the sameStagePartInputshape andmakePlanStagePartrenderer - subparts: Non-write_todos content (text, reasoning, other tool calls) produced during a stage
Each stage is rendered from a shared StagePartInput:
interface StagePartInput {
// the stage's todo(s) + index/total for the "Stage N/M" label
subparts: AppMessagePart[]; // non-todo content produced during this stage
status: StageRenderStatus; // from stageRenderStatus(stage.status, terminal, finishReason)
isStreaming?: boolean; // controls the heartbeat animation
}
Merging reads output.todos (the server-merged authoritative list) when present, falling back to input.todos. Since
the server surfaces no message or corrections, output.todos is the whole story.
5.3 Terminal Convergence
When the stream ends with a stage still open, stageRenderStatus(status, terminal, finishReason) converges it by
finishReason (terminal = finishReason !== undefined || isStreaming === false):
| Condition | Render status | UI |
|---|---|---|
| Not terminal (still streaming) | in_progress | Heartbeat dot |
finishReason === "error" while still in_progress | error | XCircle icon |
finishReason === "abort" while still in_progress | aborted | Square icon |
Terminal otherwise (stop/tool-calls/length, or done) | done | CheckCircle icon |
There is no “dropped” state and no correction channel — the server trusts the model’s re-planning, so a stage the model drops from a later list simply stops appearing; the frontend does not flag it.
5.4 No Skip Synthesis — Every Authoritative Todo Gets a Row
Because the fold renders one row per todo in the authoritative list, there is no “skip detection” pass. A todo the
model marks done without ever making it the active item — work front-loaded into an earlier todo — simply renders as an
empty done row; nothing is synthesized. A todo the model re-plans away (drops from a later list) has no row at all,
and its work flows to the trailing sink (subparts[total]) rather than vanishing (computeTodosMergedParts in
use-agent-message.ts).
5.5 Reference Stabilization
During streaming, each new chunk produces a new parts array. stabilizeRefs compares items one by one, reusing old
references for unchanged content to enable efficient React shallow comparison.
Stage-part comparison fields (on the synthetic StagePartInput): stageId, status, evidence, isStreaming,
hasNext, and per-element subparts references.
6. Key Design Decisions
| Decision | Rationale |
|---|---|
| Model owns the plan; server does only silent repairs | Trusting the model’s full list keeps the trace honest; the server intervenes minimally instead of “correcting” it |
Repairs are silent — output is just the merged todos | An extra corrections/message channel proved unnecessary; the model self-corrects from the merged list + directive |
| Restore only an omitted done item (not a resent one) | Guards the one thing a forgetful resend can lose — a completed stage — without blocking legitimate re-planning |
| Auto-promote first pending to in_progress | Model often forgets to advance; auto-promotion saves a round-trip |
Per-step directive via prepareStep, not tool output | Keeps “work on stage X now” fresh each step from live context.todos, outside the cached tool output |
stopOnComplete gates complete on plan completion | An early complete with stages still open doesn’t end the turn — the loop keeps going |
Frontend reads output.todos (falls back to input.todos) | The server-merged list is authoritative — and it is the only thing the output carries |
stageRenderStatus converges terminal state by finishReason | Stream may end without the model marking the last stage done; finishReason resolves the dangling in_progress |
write_todos and plan share StagePartInput rendering | Both are staged checklists; one renderer keeps their timeline UI consistent |
7. The other planner: plan + advance (metronome)
write_todos and plan + advance are two fundamentally different planning designs, selected per task by
planningMode ("classic" | "metronome", default metronome). They are never both active — selectPlanningTool
keeps only the chosen tool key so the model only ever sees a single planning system. classic = write_todos;
metronome is the planning tool key, which expands to the two tools [plan, advance].
The difference is not cosmetic — it is repair-after vs. prevent-by-construction:
classic(write_todos) — the model owns a mutable checklist: it rewrites the whole list each call and authors eachstatusitself. Illegal states (duplicate ids, twoin_progress, a dropped done item) are repaired after the fact by the silent server merge (§3).metronome(plan+advance) — the server owns a pointer; the model can only declare stages and tick forward. A stage has no status field to write — status is derived from the pointer (i < pointer→done,i === pointer→in_progress,i > pointer→pending) — so skipping, batching, reordering, or reopening are unrepresentable by construction, never repaired.advancemoves the pointer one stage at a time, with evidence.
7.1 plan — declare the stages
interface PlanStage {
id: string; // stable, unique within the plan
content: string; // what this stage does — a single goal
deliverable: string; // the ONE verifiable output it will produce (there is no status field)
}
Called once at the start; the first stage is immediately in_progress. Re-planning calls plan again — the ratchet
(mergePlan) keeps the completed stages [0, pointer) frozen (with their recorded evidence) and replaces the
unfinished tail, dropping any incoming id that reuses a completed one. The pointer never moves backward.
7.2 advance — tick forward
advance({ evidence }) closes the current stage — recording evidence, the receipt that its deliverable was met — and
opens the next. N stages take N advances: one stage per tick, never batched. A no-empty-tick guard refuses an
advance when no work happened since the last one (planWorkTick <= workAtLastAdvance): it nudges once per stage via a
once:plan one-shot reminder, then trusts the model’s completion claim (executor-trust). Advancing with no active
stage is a no-op plus a reminder.
7.3 State, output, and directive
PlanState { stages: PlanStageState[], pointer, workAtLastAdvance, lastRefusedPointer? } lives on ctx.plan — a single
live object advance mutates in place. The tool output is { plan: snapshotPlan(ctx.plan) }: snapshotPlan freezes a
per-stage copy, because the AI SDK persists output by reference (the same hazard as write_todos’ input — see §3), so
returning the live object would collapse every plan/advance snapshot to the final pointer. As with write_todos, there
is no message — the per-step “work on stage X until its deliverable, then advance with evidence” directive is
buildStageDirective, injected each step by prepareStep from the live plan.
7.4 Rendering
Both planners render through the same frontend path: plan and write_todos share the StagePartInput
stage-folding described in §5, so the timeline UI is identical whichever mode an agent runs.
7.5 Checklist vs metronome
| Aspect | write_todos (checklist) | plan + advance (metronome) |
|---|---|---|
| Model authors status? | Yes — resends the full list with each status | No — status is derived from the server pointer |
| Advancing | mark one done + next in_progress in one call | advance ticks the pointer with evidence |
| Illegal moves | silently repaired (dedupe, restore omitted done, one active) | unrepresentable (no status field; pointer-only) |
| Re-plan | resend the list you want | plan again — completed frozen, tail replaced |
| Per-stage deliverable | encouraged by the prompt | a required field |