Plan header
| Field |
Value |
| Status |
HANDOFF-READY |
| Date |
2026-09-05 |
| Type |
phase 3 |
| Parent |
#947 — #947 |
| Source issue |
#552 — #552 (A4 compaction engine) |
| Layers |
Vercel backend (route trigger + workflow step + loop seeding) |
| Reusability impact |
none |
| Production mutate? |
no |
| Cloud ops path |
N/A |
| Living docs |
N/A this phase (parent Phase 5 owns docs) |
Intent lock
In scope: the compaction trigger — the route computes the fold budget (consuming #944's foldBudgetTokens) + decides to compact; passes a serializable compact: { cutIndex } into start(); the workflow runs a new 'use step' summarizer before the first model round; the loop seeds [summaryRow, ...retainedTail, user].
Out of scope: persist seam (Phase 2 landed it), docs (Phase 5).
Forbidden: mid-turn compaction · Wasm-running summarizer · dual DOM chat · a second window-source fork off #944 · adding a maxOutputTokens knob.
Depends on
Phase 2 merged (needs compactionPointer carrier + checkpoint persist + route checkpoint-preference read).
Current baseline (live code @ main)
| Claim |
Path / symbol |
Notes |
| Route already resolves the window map + fold budget |
app/api/turns/route.ts (getJoinedWindowMap, foldBudgetTokens(windowMap, byok.modelId)) |
verified @ main (#944) |
trimModelMessagesToBudget trims seed at route boundary |
app/api/turns/route.ts, lib/agent/modelMessages.ts |
verified @ main (#944) |
Workflow args serializable-only; priorMessages forwarded |
lib/workflows/turnWorkflow.ts (TurnWorkflowArgs) |
verified @ main |
Loop seeds [...priorMessages, {role:'user'}] |
lib/workflows/turnLoop.ts (TurnLoopInput.priorMessages) |
verified @ main |
One model round = one generateOneRound; BYOK re-resolved in-step |
lib/workflows/modelGenerateStep.ts, lib/agent/generateOneRound.ts |
verified @ main |
Design
Trigger (route, pre-start). After the route rebuilds the seeded projection and computes foldBudgetTokens(windowMap, byok.modelId) (#944), it decides to compact when shouldCompact (Phase 1 compactionBudget.ts) is true — i.e. the seed that would otherwise be trimmed by trimModelMessagesToBudget still carries a meaningful pre-checkpoint span worth summarizing instead of blind-truncating. The route runs findCompactionCut (Phase 1) over the built rows. On a clean cut, it passes compact: { cutIndex } into start() as a serializable arg. On no clean cut (single-turn span) → fall back to today's trimModelMessagesToBudget behavior; never compact on a lie, never block the turn.
Summarize (first step). lib/workflows/compactionStep.ts — new 'use step'. When TurnWorkflowArgs.compact is set, the workflow invokes compactionStep before the first model round. The step feeds the compaction span (rows.slice(0, cutIndex), each tool result already truncated to MODEL_MSG_TOOL_RESULT_MAX_CHARS) to one generateOneRound on the session's modelId (BYOK re-resolved in-step, same 1h wall-clock discipline, same step maxDuration) with a locked summarizer system prompt. It returns { summary, filesTouched }. Failure → fail-open: seed from the full (trimmed) projection, never a doomed turn.
Seed (loop). lib/workflows/turnWorkflow.ts + lib/workflows/turnLoop.ts — accept the serializable compact arg; the loop seeds [summaryRow, ...retainedTail, user] where summaryRow = renderSummaryRow(summary, filesTouched) (Phase 1) and retainedTail = rows.slice(cutIndex) re-paired. The persona snapshot + working notes + skills catalog still ride the system prefix independently — the checkpoint only replaces the message history.
Persist linkage. The loop's terminal persist passes the compaction result to derivePersistFold; the Phase-2 seam writes the checkpoint Blob + patch.compactionPointer.
Caps table
| Cap / ceiling |
Value |
Rationale |
Code location |
NEW COMPACTION_SPAN_MAX_BYTES |
2 MiB |
Bounds the span fed to the summarizer so a single compact never sends an unbounded prompt in one model round; if the span exceeds this, the walk keeps a larger retained tail (shorter span) |
lib/sessionCloudCaps.ts |
MODEL_MSG_SEED_MAX_BYTES — unchanged |
2 MiB |
The seeded priorMessages run-arg carrier keeps its ceiling; compaction never raises it |
lib/sessionCloudCaps.ts (existing) |
No existing cap changed → no human gate.
Implementation order
app/api/turns/route.ts — compute shouldCompact + findCompactionCut; pass compact?: { cutIndex } into start(); prefer compaction over blind trim when a clean cut exists.
lib/workflows/compactionStep.ts — new 'use step' summarizer round.
lib/workflows/turnWorkflow.ts — add serializable compact to TurnWorkflowArgs; invoke compactionStep pre-loop.
lib/workflows/turnLoop.ts — seed [summaryRow, ...retainedTail, user]; route compaction result into derivePersistFold.
- Unit tests.
Testing
| # |
Case |
Layer |
Type |
Command / method |
| 1 |
Route decides to compact when seed > budget and a clean cut exists; passes compact: { cutIndex } into start() |
backend |
unit |
vitest run app/api/turns |
| 2 |
No clean cut → falls back to trimModelMessagesToBudget; never blocks the turn |
backend |
unit |
vitest run app/api/turns |
| 3 |
Workflow invokes compactionStep before the first model round when compact set; seeds [summaryRow, ...tail, user] |
backend |
unit |
vitest run lib/workflows/turnWorkflow + lib/workflows/turnLoop |
| 4 |
compactionStep returns { summary, filesTouched }; span-tool results already truncated; span bounded by COMPACTION_SPAN_MAX_BYTES |
backend |
unit |
vitest run lib/workflows/compactionStep.test.ts |
| 5 |
Summarizer step failure → fail-open to full (trimmed) projection, turn still starts |
backend |
unit |
vitest run lib/workflows/compactionStep.test.ts |
| 6 |
Loop terminal persist routes the compaction result into derivePersistFold → checkpoint write |
backend |
unit |
vitest run lib/workflows/turnLoop |
| 7 |
Gates |
all |
gate |
npm run typecheck, npm test |
Minimum locked for DoD: 1, 2, 3, 5, 7.
Definition of done
Corrections / refinements vs parent
| Topic |
Parent said |
This phase locks |
| Trigger vs #944 trim |
Parent: "trigger on window − reserve" |
Compaction is preferred over trimModelMessagesToBudget only when a clean user-boundary cut exists; else today's trim behavior is preserved (turn never blocked) |
No maxOutputTokens |
— |
Locked: do not add a maxOutputTokens knob (consistent with #944 and the repo rule) |
Plan header
Intent lock
In scope: the compaction trigger — the route computes the fold budget (consuming #944's
foldBudgetTokens) + decides to compact; passes a serializablecompact: { cutIndex }intostart(); the workflow runs a new'use step'summarizer before the first model round; the loop seeds[summaryRow, ...retainedTail, user].Out of scope: persist seam (Phase 2 landed it), docs (Phase 5).
Forbidden: mid-turn compaction · Wasm-running summarizer · dual DOM chat · a second window-source fork off #944 · adding a
maxOutputTokensknob.Depends on
Phase 2 merged (needs
compactionPointercarrier + checkpoint persist + route checkpoint-preference read).Current baseline (live code @ main)
app/api/turns/route.ts(getJoinedWindowMap,foldBudgetTokens(windowMap, byok.modelId))trimModelMessagesToBudgettrims seed at route boundaryapp/api/turns/route.ts,lib/agent/modelMessages.tspriorMessagesforwardedlib/workflows/turnWorkflow.ts(TurnWorkflowArgs)[...priorMessages, {role:'user'}]lib/workflows/turnLoop.ts(TurnLoopInput.priorMessages)generateOneRound; BYOK re-resolved in-steplib/workflows/modelGenerateStep.ts,lib/agent/generateOneRound.tsDesign
Trigger (route, pre-start). After the route rebuilds the seeded projection and computes
foldBudgetTokens(windowMap, byok.modelId)(#944), it decides to compact whenshouldCompact(Phase 1compactionBudget.ts) is true — i.e. the seed that would otherwise be trimmed bytrimModelMessagesToBudgetstill carries a meaningful pre-checkpoint span worth summarizing instead of blind-truncating. The route runsfindCompactionCut(Phase 1) over the built rows. On a clean cut, it passescompact: { cutIndex }intostart()as a serializable arg. On no clean cut (single-turn span) → fall back to today'strimModelMessagesToBudgetbehavior; never compact on a lie, never block the turn.Summarize (first step).
lib/workflows/compactionStep.ts— new'use step'. WhenTurnWorkflowArgs.compactis set, the workflow invokescompactionStepbefore the first model round. The step feeds the compaction span (rows.slice(0, cutIndex), eachtoolresult already truncated toMODEL_MSG_TOOL_RESULT_MAX_CHARS) to onegenerateOneRoundon the session'smodelId(BYOK re-resolved in-step, same 1h wall-clock discipline, same stepmaxDuration) with a locked summarizer system prompt. It returns{ summary, filesTouched }. Failure → fail-open: seed from the full (trimmed) projection, never a doomed turn.Seed (loop).
lib/workflows/turnWorkflow.ts+lib/workflows/turnLoop.ts— accept the serializablecompactarg; the loop seeds[summaryRow, ...retainedTail, user]wheresummaryRow = renderSummaryRow(summary, filesTouched)(Phase 1) andretainedTail = rows.slice(cutIndex)re-paired. The persona snapshot + working notes + skills catalog still ride the system prefix independently — the checkpoint only replaces the message history.Persist linkage. The loop's terminal persist passes the compaction result to
derivePersistFold; the Phase-2 seam writes the checkpoint Blob +patch.compactionPointer.Caps table
COMPACTION_SPAN_MAX_BYTESlib/sessionCloudCaps.tsMODEL_MSG_SEED_MAX_BYTES— unchangedpriorMessagesrun-arg carrier keeps its ceiling; compaction never raises itlib/sessionCloudCaps.ts(existing)No existing cap changed → no human gate.
Implementation order
app/api/turns/route.ts— computeshouldCompact+findCompactionCut; passcompact?: { cutIndex }intostart(); prefer compaction over blind trim when a clean cut exists.lib/workflows/compactionStep.ts— new'use step'summarizer round.lib/workflows/turnWorkflow.ts— add serializablecompacttoTurnWorkflowArgs; invokecompactionSteppre-loop.lib/workflows/turnLoop.ts— seed[summaryRow, ...retainedTail, user]; route compaction result intoderivePersistFold.Testing
compact: { cutIndex }intostart()vitest run app/api/turnstrimModelMessagesToBudget; never blocks the turnvitest run app/api/turnscompactionStepbefore the first model round whencompactset; seeds[summaryRow, ...tail, user]vitest run lib/workflows/turnWorkflow+lib/workflows/turnLoopcompactionStepreturns{ summary, filesTouched }; span-tool results already truncated; span bounded byCOMPACTION_SPAN_MAX_BYTESvitest run lib/workflows/compactionStep.test.tsvitest run lib/workflows/compactionStep.test.tsderivePersistFold→ checkpoint writevitest run lib/workflows/turnLoopnpm run typecheck,npm testMinimum locked for DoD: 1, 2, 3, 5, 7.
Definition of done
Corrections / refinements vs parent
trimModelMessagesToBudgetonly when a clean user-boundary cut exists; else today's trim behavior is preserved (turn never blocked)maxOutputTokensmaxOutputTokensknob (consistent with #944 and the repo rule)