Skip to content

plan: A4 compaction — phase 3 — trigger + summarizer step + seeding (source #552) #950

Description

@btipling

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_BYTESunchanged 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

  1. app/api/turns/route.ts — compute shouldCompact + findCompactionCut; pass compact?: { cutIndex } into start(); prefer compaction over blind trim when a clean cut exists.
  2. lib/workflows/compactionStep.ts — new 'use step' summarizer round.
  3. lib/workflows/turnWorkflow.ts — add serializable compact to TurnWorkflowArgs; invoke compactionStep pre-loop.
  4. lib/workflows/turnLoop.ts — seed [summaryRow, ...retainedTail, user]; route compaction result into derivePersistFold.
  5. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions