Repository navigation
plan: A4 compaction — phase 3 — trigger + summarizer step + seeding (source #552) #950
Description
Plan header
| Field | Value |
|---|---|
| Status | IMPLEMENTED |
| 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— 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.- 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
- Maps to parent checklist: "Compaction triggers on the selected model's window − reserve (consumed from plan: context-window fold budget (A3, source #551) #944)" + "Wasm never runs the summarizer"
- Cloud ops: N/A — no Production mutate
- Living docs: N/A this phase (parent Phase 5 owns docs)
- No existing cap value changed; no human gate
- The summarizer round runs in a step server-side; Wasm untouched; no bridge/protocol change
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) |
IMPLEMENTED (2026-09-06) — PR #955: #955
All locked rows shipped: pre-trim trigger (review-note 1), fail-open summarizer + seed [summaryRow, ...retainedTail, user], checkpoint writer wired into derivePersistFold (review-note 2), COMPACTION_SPAN_MAX_BYTES (new cap, no human gate), no maxOutputTokens. Gates: typecheck clean, full vitest 181 files / 3483 tests green. Awaiting adversarial review.
Activity
btipling commentedon Sep 6, 2026
Ignore C1–C4 if you already pulled them. That was a mid-flight edit to this HANDOFF-READY plan; reverted. Original DoD / caps / tests stand. Do not implement
COMPACTION_FILES_TOUCHED_MAX_BYTESor the extra cut/persistStep items as part of this phase.fix(agent): adversarial #955 — fail-open seed, live checkpoint tail, …
fix(agent): adversarial #955 follow-up — fail-open pin, start() combi…
fix(agent): adversarial #955 follow-up 3 — persist tail by honesty la…
fix(agent): adversarial #955 follow-up 4 — display checkpoint is this…
fix(agent): adversarial #955 follow-up 5 — span walk continue + pin-m…
fix(agent): adversarial #955 follow-up 6 — clip over-cap span, reserv…
fix(agent): adversarial #955 follow-up 7 — clip fail-open seed + file…
fix(agent): adversarial #955 follow-up 8 — suffix clip + mm omit fail…
fix(agent): adversarial #955 follow-up 9 — live mm seed + pin honesty…
fix(agent): adversarial #955 follow-up 10 — prefix clip + empty-span …
fix(agent): adversarial #955 follow-up 11 — start() rail shrinks span…
fix(agent): adversarial #955 follow-up 12 — span rail mins with fold-…
fix(agent): adversarial #955 follow-up 13 — pin-only clip yields to #944
fix(agent): adversarial #955 follow-up 14 — reserve the current ask i…
fix(agent): adversarial #955 follow-up 15 — pin-only start-rail shrin…
fix(agent): adversarial #955 follow-up 16 — G22 Stop during summarize…
fix(agent): adversarial #955 follow-up 17 — G22 summarizer Stop termi…
fix(agent): adversarial #955 follow-up 18 — summarizer wall_clock wra…
Merge pull request #955 from btipling/plan/a4-compaction-p3