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 merged todos are 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

write_todos — full pipeline write_todos(input) WriteTodosInput { todos: TodoItem[] } Server Execution Pipeline ① deduplicate deduplicateTodos ② merge mergeTodos ③ promote autoPromote ④ enforce enforceStatusOrder ⑤ message buildMessage WriteTodosOutput { todos, message, corrections } + context.todos = merged Agent Stream Integration context.todos has pending items? yes → toolChoice: "required" all done → model replies freely Frontend Rendering Pipeline useAgentMessage grouping skip detection stabilizeRefs + render Server Agent Frontend I/O

Two different planning designs, one per task. This doc details write_todos, the classic mode — but the default is the plan + advance metronome (§7). The mode is chosen per task via planningMode ("classic" | "metronome", default metronome), and selectPlanningTool keeps 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 cover write_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 done item is silently restored in place — a completed stage is never lost to a forgetful resend
  • An omitted non-done item is taken as an intentional drop (re-plan / simplification)
  • A resent item is honored as-is, including reopening a done stage 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:

ScenarioHandling
done item omittedSilently restored in place — never drop a completed stage
done item resentHonored verbatim — the model may reopen or edit it
in_progress / pending resentHonored verbatim (including status rollback)
non-done item omittedDropped — taken as an intentional re-plan / simplification
New id appearsAppended 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:

  1. No in_progress items exist
  2. At least one pending item exists
  3. 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:

StateMessage 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, prepareStep sets the "todos" key to buildTodoDirective(ctx.todos) (§3.5); once every todo is done, it clears the "todos" key so the “deliver now” nudge stops.
  • metronome — the "plan" key holds buildStageDirective(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”) that prepareStep injects on the next step and then auto-clears, so it fires exactly once. A baseline reminder (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 outputexecute returns { 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 directivebuildTodoDirective is regenerated each step from ctx.todos (the merged list), so auto-promote / single-active repairs change the very “work on stage X” instruction the model receives.
  • stopOnComplete gate — it reads the merged list (todosIncomplete), so the repairs decide whether an early complete can 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

LevelConditionComplexityDescription
L0parts + isStreaming + finishReason all unchangedO(1)Return cache
L1!everHadChainO(n) scanNo plan/write_todos chain ever — pass through
L2Has a plan/write_todos chainO(n) fold + stabilizeFold 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

foldTimeline — flat parts folded into per-stage rows single pass · event-driven · no look-ahead — shared by write_todos (classic) and plan/advance (metronome) resolve authoritative list latest write_todos snapshot / PlanState → total = N rows for each part — single pass, no look-ahead isChainPart? YES seen = true · cur = min(cursorAt(p), total) consume the part — emit no row NO empty text / reasoning? YES skip NO seen yet? NO leading[].push YES subparts[cur].push Emit (in order) leading[] ++ one tool-plan stage row per authoritative item i ← subparts[i] ++ subparts[total] — orphan / trailing sink (re-planned-away work, post-completion)

Key concepts:

  • displayTodo: The “currently active” item — prefers in_progress, falls back to last done, then first item (getDisplayTodo in @zapvol/common)
  • stage: A run of content grouped under one todo state — write_todos and plan share the same StagePartInput shape and makePlanStagePart renderer
  • 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):

ConditionRender statusUI
Not terminal (still streaming)in_progressHeartbeat dot
finishReason === "error" while still in_progresserrorXCircle icon
finishReason === "abort" while still in_progressabortedSquare icon
Terminal otherwise (stop/tool-calls/length, or done)doneCheckCircle 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.

stabilizeRefs — reuse refs across recomputes runs on the folded output (text · tool · tool-plan stage rows) prevResult (cached) [0] text: "analyzing..." 0xA1 [1] tool: read_file 0xB2 [2] tool-plan stage 1/2 0xC3 result (new compute) [0] text: "analyzing..." 0xD4 [1] tool: read_file 0xE5 [2] tool-plan stage 1/2 0xF6 [3] tool-plan stage 2/2 (new!) 0x99 same → reuse 0xA1 same → reuse 0xB2 same → reuse 0xC3 stabilized output [0xA1, 0xB2, 0xC3, 0x99] 3 refs reused, only [3] new → minimal React re-render comparison rules text → .text tool → toolCallId + state tool-plan stage → stageId · status · evidence · isStreaming · hasNext · subparts refs


6. Key Design Decisions

DecisionRationale
Model owns the plan; server does only silent repairsTrusting 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 todosAn 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_progressModel often forgets to advance; auto-promotion saves a round-trip
Per-step directive via prepareStep, not tool outputKeeps “work on stage X now” fresh each step from live context.todos, outside the cached tool output
stopOnComplete gates complete on plan completionAn 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 finishReasonStream may end without the model marking the last stage done; finishReason resolves the dangling in_progress
write_todos and plan share StagePartInput renderingBoth 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 each status itself. Illegal states (duplicate ids, two in_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 < pointerdone, i === pointerin_progress, i > pointerpending) — so skipping, batching, reordering, or reopening are unrepresentable by construction, never repaired. advance moves 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

Aspectwrite_todos (checklist)plan + advance (metronome)
Model authors status?Yes — resends the full list with each statusNo — status is derived from the server pointer
Advancingmark one done + next in_progress in one calladvance ticks the pointer with evidence
Illegal movessilently repaired (dedupe, restore omitted done, one active)unrepresentable (no status field; pointer-only)
Re-planresend the list you wantplan again — completed frozen, tail replaced
Per-stage deliverableencouraged by the prompta required field
Was this page helpful?