feat(agent): A4 compaction phase 1 — pure cut/walk/checkpoint/honesty primitives (#948) - #953
Conversation
… primitives (#948) Plan #948 (phase 1 of parent #947, source #552 — A4 compaction engine). - lib/sessionCloudCaps.ts: 3 NEW caps — COMPACTION_RESERVE_TOKENS (16384, Pi completion-reserve for the trigger line), COMPACTION_SUMMARY_MAX_CHARS (8000, code-point-safe bounded summary), COMPACTION_FILES_TOUCHED_MAX (256, keep-newest path list). No existing cap changed, no human gate. - lib/agent/modelMessages.ts: export rePairModelMessages (reuse unmodified; behavior unchanged - the locked #937 orphan/orphan-call invariant, now shared with the compaction cut walk). - lib/agent/compaction.ts (new pure module): findCompactionCut (newest-user- boundary walk-back under token/row/byte rails; re-pairs BOTH sides so a cut never splits an assistant toolCalls row from its tool result rows; null when no clean cut), buildCheckpoint (typed {summary, filesTouched, retainedTail} - parent shape lock; caps enforced with explicit markers), renderSummaryRow (labeled user-role row: 'Summary of earlier session (compacted, not live assistant prose):' - the parent Goal 4 honesty lock). - lib/agent/compactionBudget.ts (new pure module): shouldCompact - fires on the PRE-TRIM seeded projection when its estimated tokens exceed foldBudgetTokens - COMPACTION_RESERVE_TOKENS; reuses #944's estimateTokens (never a second estimator); fails open (never compacts on a lie). - Tests: 22 new unit rows (lib/agent/compaction.test.ts, lib/agent/compactionBudget.test.ts) covering plan Testing rows 1-5. Gates: npm run typecheck green; full vitest run green (180 files / 3435 tests, was 3413 -> +22). No Wasm/Zig changes. No Production mutate.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
Implementation summary (implement_plan #948): Lands the plan's phase-1 deliverables exactly as locked: 3 NEW caps ( Gates: Known environmental note: Out of scope (per plan): persist seam (#949), route trigger + summarizer step (#950), living docs (#952). Ready for adversarial review. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #953
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main ← plan/a4-compaction-p1 @ 3501d78 · 6 files · A4 phase 1 pure compaction primitives (plan #948 / parent #947 / source #552)
Lenses run: L1, L5, L6, L8 (skip L2: no secrets / workflows / API surface; skip L3: server-only helpers, no dual-chat / Wasm composer path; skip L4: no CI / harness artifact; skip L7: new caps live in sessionCloudCaps.ts, no host bind; skip L9: no UI)
AGENTS.md read: yes · docs/feature-divide.md read (durable seed is Vercel backend; these helpers stay on that side)
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1 | shouldCompact (lib/agent/compactionBudget.ts) subtracts COMPACTION_RESERVE_TOKENS (16 384) from the already-reserved #944 foldBudgetTokens result. Parent #947 Goal 1 and plan #948 Testing row 5 fire when the pre-trim estimate exceeds the fold budget. foldBudgetTokens already did window − max(16384, 15% × window). The extra subtraction zeros the trigger on any model whose fold budget is ≤ 16 384 — i.e. every ~32k-or-smaller window, including A3's documented openai/gpt-3.5-turbo 16385 → budget 1 case, and any 32k catalog row (budget = 32768 − 16384 = 16384 → triggerLine <= 0 → false). Those are the windows that overflow first; A4 becomes a no-op and the route will blind-trim. Tests lock the hole (budget === COMPACTION_RESERVE_TOKENS → false). |
Session on a 32k-window model, pre-trim seed ~20k tokens. Goal 1 says compact. shouldCompact(rows, 16384) returns false. Phase 3 falls through to trimModelMessagesToBudget and drops oldest rows with no checkpoint. A 200k-window session at 160k tokens (fits the 170k fold budget) does compact — history replaced by an 8k summary — because the extra 16k fires 16k tokens before overflow. |
Defender: “the new cap JSDoc says fold budget MINUS this extra reserve so completion is not crowded out.” Fold budget already reserved completion (CONTEXT_RESERVE_MIN_TOKENS is the same 16 384). Plan #948 Design: “exceed budgetTokens (the #944 foldBudgetTokens result).” Testing row 5: “true only when estimate > budget.” The cap can stay exported as the Pi name; it must not be subtracted again from a fold-budget argument. |
high |
| Major | L5+L1 | findCompactionCut (lib/agent/compaction.ts) walks user-boundaries newest→oldest and JSON.stringifys each suffix. Tail size is monotonic on all three rails (rows / tokens / UTF-8 bytes): if the newest (smallest) tail misses, no earlier tail can fit. The loop continues anyway and serializes every larger suffix. Same family as the #945 17s linear drop on POST /api/turns. Semantics already collapse to “inspect the newest user boundary”; the extra iterations cannot change the result. |
2048 user turns, last-turn tail already over budgetTokens or MODEL_MSG_SEED_MAX_BYTES. shouldCompact is true. findCompactionCut stringifies ~2048 growing suffixes, last one the full ~2 MiB array, before returning null. Phase 3 (#950) will call this at the route boundary before start(). Tests only use 2–3 turns, so they cannot catch it. |
Defender: “phase 1 is unwired; #950 can fix the walk.” This PR is the cut walk #950 will import. #945 already blocked this exact stringify-per-suffix pattern on the start path. Rails are monotonic — first miss must be return null, not continue. |
high |
| Minor | L8+L6 | Production estimator is forked, not reused. shouldCompact imports estimateTokens and never calls it (inlines ceil(chars / 4)). findCompactionCut hardcodes 4 instead of CONTEXT_CHARS_PER_TOKEN. Plan #948 forbidden: “never a second estimator.” A later ratio change in contextBudget.ts silently desyncs compaction from A3 trim. |
Operator raises CONTEXT_CHARS_PER_TOKEN (or estimateTokens grows a UTF-8 path). Trim and compaction disagree on occupancy; compact / trim fire on different seeds. |
Defender: “the formula is identical today.” The unused import is the tell — reuse was intended and not done. Tests already go through estimateTokens for expected values. |
high |
| Minor | L1 | boundFilesTouched (lib/agent/compaction.ts) dedupes first-seen, then slice-keeps newest unique. A path read early and again last is stuck at the front and is the first dropped when the list overflows. JSDoc says keep the newest “reads the model is most likely to edit.” |
257 unique paths plus a re-read of p0.ts at the end. p0.ts is dropped; the summarizer/checkpoint forgets the file the model just touched. |
Defender: “filesTouched is a set.” The keep-newest rationale is last occurrence, matching FRESHNESS_REMINDER_MAX_PATHS drop-oldest. |
high |
| Nit | L8 | renderSummaryRow docs claim the … (N earlier paths omitted) marker; the function has no omitted count. buildCheckpoint bakes that line into summary so renderSummaryRow(cp.summary, cp.filesTouched) can emit two Files read/modified: lines. Shape lock { summary, filesTouched, retainedTail } forces the marker into summary — document that contract, don’t claim the renderer emits it. |
Phase 3 wires renderSummaryRow(checkpoint.summary, checkpoint.filesTouched) and the model sees a duplicated files block. |
Defender: “honesty marker must survive without the renderer.” True for the checkpoint object; the renderer docs are still wrong. | medium |
Residual risk
Chars/4 remains the English-prose estimator (CJK/code under-trims; same A3 residual). The cut does not count the labeled summary row or the current ask in the tail budget — newest-only tails are small enough that this usually fits; a future “max tail that still fits” walk would let A3 trim drop the summary (oldest) row. COMPACTION_SPAN_MAX_BYTES (parent, phase 3) is what will force a larger retained tail; this walk’s first-fit-newest shape is compatible with that later constraint only if a miss already returns null. JSON.stringify on hostile BigInt / cyclic args can still throw despite “never throws” (pre-existing on the A3 trim path). No live summarizer / persist / route round-trip in this attack (out of phase).
Merge guidance
CONCERNS: do not merge until both Majors are fixed — (1) shouldCompact compares the reused estimateTokens result to budgetTokens (the #944 fold budget), not budgetTokens − 16384; a real 32k-class fold budget must still be able to compact; (2) findCompactionCut must not stringify older suffixes after the newest tail misses a monotonic rail (return null; cover with a many-row stringify-count test). The Minors should land in the same follow-up (estimator reuse, last-occurrence files bound, renderer docs). DI/cost: no new PGlite / live I/O in changed tests — not a gate.
Do not --approve from this skill.
What was not attacked
Phase 2 persist seam (meta.compactionPointer), phase 3 route trigger + compactionStep summarizer, phase 5 living docs, live Workflows canvas, occupancy meter (#556), tokenizer parity vs provider counts, a planted oversize Blob, Wasm status-bar paint.
shouldCompact compared estimate to foldBudgetTokens − 16384. That reserve is already inside foldBudgetTokens, so every ~32k-or-smaller window (triggerLine <= 0) could never compact — the models that overflow first. Compare to the fold budget (Goal 1 / plan #948 Testing row 5). Reuse estimateTokens. findCompactionCut returns null on the first monotonic rail miss instead of JSON.stringify-ing every older suffix (#945 class). filesTouched keep-newest is last occurrence so a re-read is not dropped.
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #953
Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: main ← plan/a4-compaction-p1 @ cfb7e6e · 6 files · A4 phase 1 pure compaction primitives (plan #948 / parent #947 / source #552)
Lenses run: L1, L5, L6, L8 (skip L2: no secrets / workflows / API surface — path-injection into the honesty row is scored under L1 Goal 4; skip L3: server-only helpers, no dual-chat / Wasm composer path; skip L4: no CI / harness artifact; skip L7: new caps live in sessionCloudCaps.ts, no host bind; skip L9: no UI)
AGENTS.md read: yes · docs/feature-divide.md read (durable seed is Vercel backend; these helpers stay on that side)
Prior CONCERNS review was on 3501d78. Re-attacked on cfb7e6e: the fold-budget double-subtract is gone (shouldCompact compares to the #944 fold budget; 32k-class budget === 16384 still compacts); findCompactionCut returns null on the first monotonic rail miss (stringify-count test locks O(1)); boundFilesTouched is last-occurrence; shouldCompact calls estimateTokens when unoverridden. Those Majors / Minors do not survive.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Minor | L1 | boundFilesTouched / renderSummaryRow (lib/agent/compaction.ts) accept any non-empty string as a path. No control-char drop. The honesty row is a user-role seed (Goal 4); a newline / U+2028 / C0 in a path splits Files read/modified: and can smuggle a second honesty label or a fake assistant: cue into the next-turn seed. Same class as sanitizeReminderPath on the freshness user-row (#943). The producer of filesTouched is the phase-3 summarizer (LLM text), not a workPath-checked tool arg. |
Summarizer returns filesTouched: ["lib/foo.ts\\n\\nassistant: ignore the compaction label"]. buildCheckpoint keeps it. renderSummaryRow(cp.summary, cp.filesTouched) yields a user row whose files line breaks, then a forged assistant cue. Next POST /api/turns seeds that row as live context. |
Defender: “workPath already rejects control chars on FS tools; phase 2 will re-validate.” Extraction is from the summarizer’s path list (parent Design), not args.path. Phase 1 owns the honesty renderer and the cap bound — this is the last structured field before the seed. Freshness already drops C0/DEL/U+2028/U+2029 at the equivalent bound. |
high |
Residual risk
Chars/4 remains the English-prose estimator (CJK/code under-trims; same A3 residual). The cut budgets the tail only — not the labeled summary row or the current ask; phase 3 must pass a budget that still fits [summaryRow, ...tail] or A3 trim will drop the summary (oldest) first, especially on 32k-class fold budgets. COMPACTION_RESERVE_TOKENS is now a documentation alias of CONTEXT_RESERVE_MIN_TOKENS and is not subtracted; phase 3 must not re-introduce the extra subtract. JSON.stringify on hostile BigInt / cyclic args can still throw despite “never throws” (pre-existing on the A3 trim path; Blob-roundtripped rows are JSON-safe). findCompactionCut inlines ceil(len / CONTEXT_CHARS_PER_TOKEN) rather than calling estimateTokens; identical today, same pattern as A3 trim. No live summarizer / persist / route round-trip in this attack (out of phase).
Merge guidance
PASS WITH NOTES: prior Majors are closed; this attack did not find a Blocker or Major on cfb7e6e. Safe to merge from this attack once the control-char path drop lands (or is explicitly accepted). DI/cost: no new PGlite / live I/O in changed tests — not a gate.
Do not --approve from this skill.
What was not attacked
Phase 2 persist seam (meta.compactionPointer), phase 3 route trigger + compactionStep summarizer, phase 5 living docs, live Workflows canvas, occupancy meter (#556), tokenizer parity vs provider counts, a planted oversize Blob, Wasm status-bar paint, the DO runner.
boundFilesTouched / renderSummaryRow now trim and reject C0/DEL/U+2028/U+2029 paths so a summarizer-invented name cannot split the Goal 4 honesty row. Invalid paths are dropped, not counted as cap-omitted.
|
Follow-up to the PASS WITH NOTES review: the Minor (control-char
|
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #953
Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: main ← plan/a4-compaction-p1 @ d8cd4b0 · 6 files · A4 phase 1 pure compaction primitives (plan #948 / parent #947 / source #552)
Lenses run: L1, L5, L6, L8 (skip L2: no secrets / workflows / API surface — path injection already scored and closed under L1 Goal 4; skip L3: server-only helpers, no dual-chat / Wasm composer path; skip L4: no CI / harness artifact; skip L7: new caps live in sessionCloudCaps.ts, no host bind; skip L9: no UI)
AGENTS.md read: yes · docs/feature-divide.md read (durable seed is Vercel backend; these helpers stay on that side)
Re-attacked on d8cd4b0 after two prior passes (3501d78 CONCERNS, cfb7e6e PASS WITH NOTES). Closed and not re-filed: fold-budget double-subtract; O(n) stringify on a monotonic miss; first-seen vs last-occurrence filesTouched; control-char path split of the Goal 4 row; shouldCompact estimator reuse.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Minor | L1+L6 | boundSummary (lib/agent/compaction.ts) gates overflow on UTF-16 .length but trims with [...summary].slice(0, COMPACTION_SUMMARY_MAX_CHARS). JSDoc and Testing row 4 lock a code-point bound. When UTF-16 length > 8000 and code-point count ≤ 8000, nothing is dropped and … [summary truncated] is still appended — a lying marker. The emoji unit row (🙂.repeat(8000)) asserts the marker and never asserts that any code point was removed, so it locks the lie. |
buildCheckpoint({ summary: '🙂'.repeat(8000), filesTouched: [] }, []) — 8000 code points, exactly the cap. Output keeps every rune and adds the truncation marker. Same for '🙂'.repeat(4001) (8002 UTF-16, 4001 code points) and 'a'.repeat(7990) + '🙂'.repeat(6) (8002 UTF-16, 7996 code points). Phase 3 will persist that marker into the Goal 4 user row; the model is told content was dropped when it was not. |
Defender: “truncateToolResult uses the same .length then spread-slice idiom; the emoji test only cares that surrogates do not split.” That idiom is wrong here too, but this PR claims a code-point cap and ships a test named for it. A UTF-16-only fast path (length <= 8000 → return) is valid; the overflow path must walk code points and append the marker only when a code point was actually dropped. |
high |
Residual risk
Chars/4 remains the English-prose estimator (CJK/code under-trims; same A3 residual). The cut budgets the tail only — not the labeled summary row or the current ask; phase 3 must pass a budget that still fits [summaryRow, ...tail] or A3 trim will drop the summary (oldest) first, especially on 32k-class fold budgets. COMPACTION_RESERVE_TOKENS is a second 16_384 literal next to CONTEXT_RESERVE_MIN_TOKENS rather than an alias — drift if one is edited. findCompactionCut still inlines ceil(len / CONTEXT_CHARS_PER_TOKEN) rather than calling estimateTokens (identical today; same pattern as A3 trimModelMessagesToBudget). JSON.stringify on hostile BigInt / cyclic args can still throw despite “never throws” (pre-existing on the A3 trim path; Blob-roundtripped rows are JSON-safe). Unbounded per-path string length in filesTouched is count-capped only; phase 2’s 1 MiB checkpoint blob is the byte backstop. No live summarizer / persist / route round-trip in this attack (out of phase).
Merge guidance
PASS WITH NOTES: prior Majors stay closed; this attack did not find a Blocker or Major on d8cd4b0. Safe to merge from this attack once the boundSummary code-point gate lands (or is explicitly accepted). DI/cost: no new PGlite / live I/O in changed tests — not a gate.
Do not --approve from this skill.
What was not attacked
Phase 2 persist seam (meta.compactionPointer), phase 3 route trigger + compactionStep summarizer, phase 5 living docs, live Workflows canvas, occupancy meter (#556), tokenizer parity vs provider counts, a planted oversize Blob, Wasm status-bar paint, the DO runner.
boundSummary used UTF-16 .length to decide overflow, then sliced by code points. An astral-heavy summary at or under the 8000 code-point cap (🙂.repeat(8000), 4001 emoji, mixed BMP+emoji) got a lying "… [summary truncated]" marker with nothing dropped. Walk code points on the overflow path; stamp the marker only when a rune is dropped.
|
Follow-up to the PASS WITH NOTES review on The overflow path now walks code points and appends |
Plan: A4 compaction phase 1 — pure compaction primitives
Implements plan issue #948 (phase 1 of parent #947, source #552 — the A4 compaction engine): the pure, server-side compaction primitives. No route wiring, no persist seam, no docs — those are phases 2/3/5 per the parent's phase map.
Closes #948 · Refs #947 · Refs #552
What lands
lib/sessionCloudCaps.tsCOMPACTION_RESERVE_TOKENS(16 384, Pi completion-reserve gating only the trigger line),COMPACTION_SUMMARY_MAX_CHARS(8 000),COMPACTION_FILES_TOUCHED_MAX(256). No existing cap value changed → no human gate (plan Caps table)lib/agent/modelMessages.tsrePairModelMessagesexported — reuse unmodified (the locked #937 orphan/orphan-call invariant), now shared with the compaction cut walk. Behavior byte-identicallib/agent/compaction.tsfindCompactionCut(walk back from the newest row to the newestuserboundary whose tail fits token/row/byte rails; re-pairs BOTH span and tail;nullwhen no clean cut — never splits a tool call from its result, parent Goal 3),buildCheckpoint(typed{summary, filesTouched, retainedTail}— the parent's locked shape; caps enforced with explicit truncation/omitted markers, keep-newest paths),renderSummaryRow(labeled user-role rowSummary of earlier session (compacted, not live assistant prose):— the parent Goal 4 honesty lock)lib/agent/compactionBudget.tsshouldCompact: true when the pre-trim seeded projection's estimated tokens exceedfoldBudgetTokens − COMPACTION_RESERVE_TOKENS. Reuses #944'sestimateTokens(never a second estimator); empty rows → false; degenerate/non-finite budget → false (fails open — never compacts on a lie)lib/agent/compaction.test.tsrePairModelMessages· single-turn →null· rails (maxRows/maxBytes) · newest-fitting-boundary semantics ·renderSummaryRowhonesty + caps enforcement (code-point-safe summary bound, keep-newest files bound, dedupe)lib/agent/compactionBudget.test.tscharsPerTokentest seamOut of scope (locked by the plan): persist seam (
meta.compactionPointer→ phase 2 #949), route trigger + summarizer step (phase 3 #950), living docs (phase 5 #952). No Wasm/Zig changes; the window source is consumed (#944), never re-implemented.Verification
npm run typechecknative/harness/**changenpm run buildlib/*change — typecheck + tests are the meaningful gate per AGENTS.mdnode scripts/di-gate.mjs)app/.well-known/workflow/v1/step/route.js(Next-generated compiled route left by a priornext build); it fails identically on a pristine tree (verified viagit stash) and is untouched by this PR. This PR adds no I/O construction — both new modules are pure, seam-freeCloud ops / living docs
N/A per the plan (no Production mutate; docs owned by phase 5 #952).
CI
Required checks will run on push; status reported honestly below when known.
Next step
This PR is merge-ready from review only after
adversarial_review— not merged here per the workflow (implement_plan stops at open PR).