From 9e20da7ba54e88d8c74bef69be91319cfdf737d9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 10:13:34 -0700 Subject: [PATCH 1/3] test(session): cover budgeted tail pipeline for compaction folds Red state for the shared summary-plus-small-tail pipeline: tail budget with shortened tool outputs, pair-safe cuts, whole newest user messages, repeat folds updating the prior summary, and archive retrievability. These fail against the last-N-verbatim keep window. --- src/session/compaction-archive.test.ts | 100 ++++++++++++++ src/session/compaction-handoff.test.ts | 156 ++++++++++++++++++++++ src/session/compaction-verify.test.ts | 174 +++++++++++++++++++++++++ 3 files changed, 430 insertions(+) diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index 472d194d8..b7ccd27a8 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -1035,6 +1035,9 @@ describe("wrapCompactorWithCompletenessGate", () => { keepRecentTurns: 2, maxAnchorTurns: 0, summaryMaxChars: 500, + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, }); const wrapped = wrapCompactorWithCompletenessGate(inner, archive); const turns: import("@intx/types/runtime").ConversationTurn[] = [ @@ -1108,6 +1111,9 @@ describe("wrapCompactorWithCompletenessGate", () => { keepRecentTurns: 2, maxAnchorTurns: 0, summaryMaxChars: 500, + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, }); const wrapped = wrapCompactorWithCompletenessGate(inner, archive); const turns: import("@intx/types/runtime").ConversationTurn[] = [ @@ -1144,4 +1150,98 @@ describe("wrapCompactorWithCompletenessGate", () => { if (spine?.type !== "text") throw new Error("unreachable"); expect(spine.text.startsWith(COMPACTED_PREFIX)).toBe(true); }); + + test("CL-9007 tail excerpts stay certified while full bytes stay retrievable", async () => { + const { wrapCompactorWithCompletenessGate, recordAdoptedHandoff } = + await import("./compaction-archive.js"); + const { createPruningCompactor } = await import("./compactor.js"); + const { COMPACTED_PREFIX } = await import("./compaction-handoff.js"); + const { archive } = memoryArchive(); + const FULL = `decisive-fact-99:${"y".repeat(20_000)}`; + const FILLER = `f1:${"x".repeat(4000)}`; + await archive.recordAuthorizedPayload({ + kind: "tool_args", + payload: { name: "read_file", arguments: { path: "src/f1.ts" } }, + callId: "f1", + }); + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: FILLER, + callId: "f1", + }); + const bigOcc = await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: FULL, + callId: "big-1", + }); + const inner = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 4000, + compactionShape: { tailBudgetTokens: 1000 }, + summarize: async () => + "Re-read src/f1.ts leftovers. Next: keep newest ask whole.", + }); + const wrapped = wrapCompactorWithCompletenessGate(inner, archive); + type Turn = import("@intx/types/runtime").ConversationTurn; + const pair = ( + id: string, + name: string, + args: Record, + resultText: string, + ): Turn[] => [ + { + role: "assistant", + content: [{ type: "tool_call", id, name, arguments: args }], + timestamp: Date.now(), + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: id, + content: [{ type: "text", text: resultText }], + }, + ], + timestamp: Date.now(), + }, + ]; + const text = (role: Turn["role"], content: string): Turn => ({ + role, + content: [{ type: "text", text: content }], + timestamp: Date.now(), + }); + const turns: Turn[] = [ + text("user", "Migrate the auth module to opaque tokens in src/auth.ts"), + ...pair("f1", "read_file", { path: "src/f1.ts" }, FILLER), + ...pair("big-1", "run_shell", { command: "bun run test auth" }, FULL), + text("user", "newest ask: keep this newest user message whole verbatim"), + text("assistant", "newest reply"), + ]; + const result = await wrapped.apply(turns, ctx); + expect(result.record.reason).not.toBe("incomplete-evidence-archive"); + expect(result.record.reason.startsWith("compacted")).toBe(true); + // Tail excerpts live inside tool_result bodies, not top-level text blocks. + const live = result.output + .flatMap((t) => + t.content.flatMap((b) => { + if (b.type === "text") return [b.text]; + if (b.type === "tool_result") + return b.content.map((c) => (c.type === "text" ? c.text : "")); + return []; + }), + ) + .join("\n"); + expect(live).not.toContain(FULL); + expect(live).toContain("decisive-fact-99:"); + expect(live).toContain("[tail-shortened"); + expect(await archive.readAuthorizedPayload(bigOcc.occurrenceId)).toBe(FULL); + const adopted = await recordAdoptedHandoff( + archive, + `${COMPACTED_PREFIX} Goal: carry me retrievable`, + ); + expect(await archive.readAuthorizedPayload(adopted.occurrenceId)).toContain( + "carry me retrievable", + ); + }); }); diff --git a/src/session/compaction-handoff.test.ts b/src/session/compaction-handoff.test.ts index 7c55bc0fc..d77fb7522 100644 --- a/src/session/compaction-handoff.test.ts +++ b/src/session/compaction-handoff.test.ts @@ -890,6 +890,9 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => { const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500, + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, }); const turns: ConversationTurn[] = [ userTurn("Ship the widget. Never rename src/widget.ts."), @@ -953,6 +956,9 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => { const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500, + // CL-9007: pin a tiny tail budget so each fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, readPriorHandoff: async () => latest, }); const firstTurns: ConversationTurn[] = [ @@ -989,3 +995,153 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => { expect(filesSection).toContain("diagnostic.log"); }); }); + +describe("CL-9007 tail attachments stay whole", () => { + const PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + test("a recent image attachment and its user text survive the fold whole", async () => { + const userText = + "screenshot ask: keep this newest user message whole verbatim"; + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 4000, + compactionShape: { tailBudgetTokens: 1000 }, + summarize: async () => "Re-read src/a.ts. Next: review the screenshot.", + }); + const turns: ConversationTurn[] = [ + userTurn("Migrate the auth module to opaque tokens in src/auth.ts"), + ...fileReadTurns("a", "src/a.ts", `a-result:${"a".repeat(4000)}`), + ...fileReadTurns("b", "src/b.ts", `b-result:${"b".repeat(4000)}`), + makeTurn({ + role: "user", + content: [ + { type: "text", text: userText }, + { + type: "image", + source: { kind: "base64", mimeType: "image/png", data: PNG_B64 }, + }, + ], + }), + makeTurn({ + role: "assistant", + content: [{ type: "text", text: "newest reply" }], + }), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(result.record.reason.startsWith("compacted")).toBe(true); + const live = result.output + .flatMap((t) => t.content) + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n"); + expect(live).toContain(userText); + const image = result.output + .flatMap((t) => t.content) + .find((b) => b.type === "image"); + expect(image).toEqual({ + type: "image", + source: { kind: "base64", mimeType: "image/png", data: PNG_B64 }, + }); + }); +}); + +describe("CL-9007 repeated compactions update the summary", () => { + const TAIL_MARK = "newest ask: carry this tail string forward"; + + function firstSession(): ConversationTurn[] { + return [ + userTurn("Migrate the auth module to opaque tokens in src/auth.ts"), + ...fileReadTurns("a", "src/a.ts", `a-result:${"a".repeat(4000)}`), + ...fileReadTurns("b", "src/b.ts", `b-result:${"b".repeat(4000)}`), + userTurn(TAIL_MARK), + makeTurn({ + role: "assistant", + content: [{ type: "text", text: "newest reply" }], + }), + ]; + } + + function countSpines(turns: ConversationTurn[]): number { + return turns + .flatMap((t) => t.content) + .filter((b) => b.type === "text" && b.text.includes(COMPACTED_PREFIX)) + .length; + } + + function countMarkers(turns: ConversationTurn[]): number { + // Markers live inside tool_result bodies, not top-level text blocks. + return turns + .flatMap((t) => + t.content.flatMap((b) => { + if (b.type === "text") return [b.text]; + if (b.type === "tool_result") + return b.content.map((c) => (c.type === "text" ? c.text : "")); + return []; + }), + ) + .reduce((sum, text) => sum + text.split("[tail-shortened").length - 1, 0); + } + + function liveText(turns: ConversationTurn[]): string { + return turns + .flatMap((t) => t.content) + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n"); + } + + test("a second fold updates the prior summary and carries the tail forward", async () => { + let priorFile: string | undefined; + let seenPrior: string | undefined; + let calls = 0; + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 4000, + compactionShape: { tailBudgetTokens: 1000 }, + readPriorHandoff: async () => priorFile, + summarize: async (_turns, ctx) => { + calls += 1; + if (calls === 2) seenPrior = ctx?.priorSummary; + return calls === 1 + ? "Re-read src/a.ts. Next: keep newest ask whole." + : "Migrating auth to opaque tokens via src/a.ts. Next: keep newest ask whole."; + }, + }); + + const first = await compactor.apply(firstSession(), mockStrategyCtx); + expect(first.record.reason.startsWith("compacted")).toBe(true); + expect(countSpines(first.output)).toBe(1); + priorFile = new TextDecoder().decode( + defined(defined(first.blobs)[0]).bytes, + ); + expect(priorFile).toContain("src/a.ts"); + + const second = await compactor.apply( + [ + ...first.output, + // New tool activity after the first fold: the second tail holds this + // fresh excerpt while the first fold's excerpt is summarized from its + // shortened text — never re-summarized raw, never duplicated live. + ...fileReadTurns("c", "src/c.ts", `c-result:${"c".repeat(4000)}`), + userTurn("follow-up ask"), + makeTurn({ + role: "assistant", + content: [{ type: "text", text: "follow-up reply" }], + }), + ], + mockStrategyCtx, + ); + expect(second.record.reason.startsWith("compacted")).toBe(true); + expect(countSpines(second.output)).toBe(1); + expect(seenPrior).toContain(COMPACTED_PREFIX); + expect(seenPrior).toContain("src/a.ts"); + expect(liveText(second.output)).toContain(TAIL_MARK); + expect(countMarkers(second.output)).toBe(countMarkers(first.output)); + const secondFile = new TextDecoder().decode( + defined(defined(second.blobs)[0]).bytes, + ); + expect(secondFile).toContain("src/a.ts"); + expect(secondFile).not.toBe(priorFile); + }); +}); diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 454f9a5f2..e1f0c598f 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -624,6 +624,9 @@ describe("pruning compactor verify pass", () => { const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 2000, + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, summarize: async () => "Work continues. Next: fix tests.", }); const turns: ConversationTurn[] = [ @@ -641,6 +644,9 @@ describe("pruning compactor verify pass", () => { const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 2000, + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, summarize: async () => "Auth migration done. No errors remain.", }); const turns: ConversationTurn[] = [ @@ -658,6 +664,9 @@ describe("pruning compactor verify pass", () => { const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 2000, + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, summarize: async () => "Migrating auth to opaque tokens. Read src/auth.ts, ran bun run " + "test auth; the token refresh assertion failed. Fix the token " + @@ -674,12 +683,174 @@ describe("pruning compactor verify pass", () => { }); }); +describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { + const BIG_HEAD = "BIG-OUTPUT-HEAD:"; + const BIG_TAIL = ":BIG-OUTPUT-TAIL"; + const BIG_OUTPUT = `${BIG_HEAD}${"x".repeat(59_970)}${BIG_TAIL}`; + + function pairTurns( + id: string, + name: string, + args: Record, + resultText: string, + ): ConversationTurn[] { + return [ + { + role: "assistant", + content: [{ type: "tool_call", id, name, arguments: args }], + timestamp: Date.now(), + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: id, + content: [{ type: "text", text: resultText }], + }, + ], + timestamp: Date.now(), + }, + ]; + } + + function tailSession(): ConversationTurn[] { + return [ + textTurn( + "user", + "Migrate the auth module to opaque tokens in src/auth.ts", + ), + ...pairTurns( + "a", + "read_file", + { path: "src/a.ts" }, + `a-result:${"a".repeat(4000)}`, + ), + ...pairTurns( + "b", + "read_file", + { path: "src/b.ts" }, + `b-result:${"b".repeat(4000)}`, + ), + ...pairTurns( + "big", + "run_shell", + { command: "bun run test auth" }, + BIG_OUTPUT, + ), + textTurn( + "user", + "newest ask: keep this newest user message whole verbatim", + ), + textTurn("assistant", "newest reply"), + ]; + } + + function tailCompactor() { + return createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 4000, + compactionShape: { tailBudgetTokens: 1000 }, + summarize: async () => + "Re-read src/a.ts and src/b.ts leftovers. Next: keep newest ask whole.", + }); + } + + function toolPairIds(turns: ConversationTurn[]): { + calls: string[]; + results: string[]; + } { + const calls: string[] = []; + const results: string[] = []; + for (const turn of turns) { + for (const block of turn.content) { + if (block.type === "tool_call") calls.push(block.id); + if (block.type === "tool_result") results.push(block.callId); + } + } + return { calls, results }; + } + + // allText above only sees top-level text blocks; tail excerpts live inside + // tool_result bodies, so the tail assertions read those too. + function liveResultText(turns: ConversationTurn[]): string { + return turns + .flatMap((t) => + t.content.flatMap((b) => { + if (b.type === "text") return [b.text]; + if (b.type === "tool_result") + return b.content.map((c) => (c.type === "text" ? c.text : "")); + return []; + }), + ) + .join("\n"); + } + + test("large tool outputs in the tail are shortened rather than copied verbatim", async () => { + const result = await tailCompactor().apply(tailSession(), mockStrategyCtx); + expect(result.record.reason.startsWith("compacted")).toBe(true); + const live = liveResultText(result.output); + expect(live).not.toContain(BIG_OUTPUT); + expect(live).toContain(BIG_HEAD); + expect(live).toContain(BIG_TAIL); + expect(live).toContain("[tail-shortened"); + expect(live).toContain(String(BIG_OUTPUT.length)); + expect(result.record.decisions).toMatchObject({ shortenedToolOutputs: 1 }); + }); + + test("the emitted tail fits the configured token budget", async () => { + const result = await tailCompactor().apply(tailSession(), mockStrategyCtx); + expect(result.record.decisions).toMatchObject({ tailBudgetTokens: 1000 }); + const estimate = result.record.decisions.tailTokenEstimate; + expect(typeof estimate).toBe("number"); + expect(estimate as number).toBeLessThanOrEqual(1000); + }); + + test("cut points never split a tool call from its result", async () => { + const result = await tailCompactor().apply(tailSession(), mockStrategyCtx); + const { calls, results } = toolPairIds(result.output); + expect([...calls].sort()).toEqual([...results].sort()); + expect(calls).toContain("big"); + expect(liveResultText(result.output)).toContain( + "newest ask: keep this newest user message whole verbatim", + ); + }); + + test("the shape travels as one param object with safe pair/user defaults", async () => { + const result = await tailCompactor().apply(tailSession(), mockStrategyCtx); + expect(result.record.parameters).toMatchObject({ + compactionShape: { + tailBudgetTokens: 1000, + maxTailToolOutputChars: 2048, + excerptHead: true, + excerptTail: true, + preserveWholeUserMessages: true, + pairSafe: true, + }, + }); + }); + + test("the default tail budget applies when no shape is given", async () => { + const compactor = createPruningCompactor({ keepRecentTurns: 2 }); + const result = await compactor.apply( + [textTurn("user", "goal"), textTurn("assistant", "reply")], + mockStrategyCtx, + ); + expect(result.record.parameters).toMatchObject({ + compactionShape: { tailBudgetTokens: 7500, pairSafe: true }, + }); + }); +}); + describe("continuation facts survive many folds", () => { test("verify signal holds after five lossy folds", async () => { let priorFile: string | undefined; const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 4000, + // CL-9007: pin a tiny tail budget so each fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, summarize: async () => "Work continues. Next: fix tests.", readPriorHandoff: async () => priorFile, }); @@ -767,6 +938,9 @@ describe("completeness gate plus verify repair", () => { const inner = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 4000, + // CL-9007: pin a tiny tail budget so each fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, summarize: async () => "Work continues. Next: fix tests.", readPriorHandoff: async () => priorFile, }); From 7703277ff4d50d40ffd64fd2e439e57b120fec43 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 10:14:07 -0700 Subject: [PATCH 2/3] feat(session): fold a structured summary plus a small budgeted tail Route every pruning-compactor fold through one budgeted tail selector: the tail holds at least the last keepRecentTurns turns and extends older while the next whole pick fits tailBudgetTokens. Tail tool outputs ride head/tail excerpted with a sentinel; cuts never split a tool pair; newest user messages stay whole; prior spines fold into the next handoff while the summary updates them via priorSummary. --- src/context-compactor.test.ts | 94 +++- src/session/compaction-handoff.ts | 17 +- src/session/compactor.ts | 408 ++++++++++++++++-- src/session/runtime-assembly.test.ts | 4 + src/session/runtime-assembly.ts | 15 + src/session/summarizer.ts | 13 + tests/integration/compaction-baseline.test.ts | 3 + tests/integration/harness.ts | 12 + tests/unit/compactor-pairing.test.ts | 42 ++ 9 files changed, 558 insertions(+), 50 deletions(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 03bdcf8b5..246790adf 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -59,6 +59,9 @@ function hasConsecutiveSameRole(turns: ConversationTurn[]): boolean { describe("createPruningCompactor", () => { test("returns turns unchanged when under the keep threshold", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 5, summaryMaxChars: 500, }); @@ -78,16 +81,28 @@ describe("createPruningCompactor", () => { // compactorNoOpFloor accordingly breaks that guarantee silently. const keepRecentTurns = 3; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns, summaryMaxChars: 500, }); const floor = compactorNoOpFloor(keepRecentTurns); + // Bodies exceed the pinned tail budget, so past-floor always has a folded + // region while at-floor still no-ops on the count check alone. + const body = (i: number): string => `body ${i} ` + "x".repeat(100); const atFloor = Array.from({ length: floor }, (_, i) => - makeTurn({ role: i % 2 === 0 ? "user" : "assistant" }), + makeTurn({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: body(i) }], + }), ); const pastFloor = Array.from({ length: floor + 1 }, (_, i) => - makeTurn({ role: i % 2 === 0 ? "user" : "assistant" }), + makeTurn({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: body(i) }], + }), ); expect( @@ -100,6 +115,9 @@ describe("createPruningCompactor", () => { test("compacts old turns and preserves recent ones", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 2, summaryMaxChars: 500, }); @@ -150,6 +168,9 @@ describe("createPruningCompactor", () => { test("preserves tool_call and tool_result blocks in recent turns", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 1, summaryMaxChars: 500, }); @@ -183,6 +204,9 @@ describe("createPruningCompactor", () => { describe("createPruningCompactor — initiating task preservation", () => { test("keeps the initiating task verbatim even when it is far outside the recent window", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 2, maxAnchorTurns: 1, summaryMaxChars: 500, @@ -232,6 +256,9 @@ describe("createPruningCompactor — initiating task preservation", () => { test("emits the compaction summary as a user turn, never system", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a one-token tail budget — only the mandatory floor stays + // live, so this tiny fixture still folds the same older region. + compactionShape: { tailBudgetTokens: 1 }, keepRecentTurns: 1, summaryMaxChars: 500, }); @@ -247,6 +274,9 @@ describe("createPruningCompactor — initiating task preservation", () => { test("never emits consecutive same-role turns, even with adjacent user anchors", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a one-token tail budget — only the mandatory floor stays + // live, so this tiny fixture still folds the same older region. + compactionShape: { tailBudgetTokens: 1 }, keepRecentTurns: 2, summaryMaxChars: 500, }); @@ -270,6 +300,9 @@ describe("createPruningCompactor — initiating task preservation", () => { test("keeps alternating roles when a tool_result user turn abuts a plain user turn", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 1, maxAnchorTurns: 3, summaryMaxChars: 500, @@ -338,6 +371,9 @@ describe("createPruningCompactor — image aging", () => { test("strips image bytes from an anchored (aged) turn but keeps its text", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 2, maxAnchorTurns: 1, summaryMaxChars: 500, @@ -405,6 +441,9 @@ describe("createPruningCompactor — image aging", () => { test("keeps an image intact when its turn is still within the recent window", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 3, summaryMaxChars: 500, }); @@ -437,6 +476,9 @@ describe("createPruningCompactor — image aging", () => { // With few turns, full pruning is a no-op, but images outside keepRecentTurns // must still spill so they are not resent as base64 forever. const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 2, summaryMaxChars: 500, }); @@ -478,6 +520,9 @@ describe("createPruningCompactor — image aging", () => { test("records the number of turns aged out in the transform record", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 1, maxAnchorTurns: 1, summaryMaxChars: 500, @@ -540,6 +585,9 @@ describe("createPruningCompactor — error anchoring (CL-6906)", () => { ...padding(8, "after"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 8, summaryMaxChars: 2000, @@ -587,6 +635,9 @@ describe("createPruningCompactor — error anchoring (CL-6906)", () => { ...padding(8, "after"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 8, summaryMaxChars: 2000, @@ -648,6 +699,9 @@ describe("createPruningCompactor — error anchoring (CL-6906)", () => { ...padding(8, "after"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 8, summaryMaxChars: 2000, @@ -734,6 +788,9 @@ describe("createPruningCompactor — maxAnchorTurns caps pairing pulls (CL-6906) const maxAnchorTurns = 4; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns, summaryMaxChars: 2000, @@ -755,6 +812,9 @@ describe("createPruningCompactor — summarize receives the workflow context (CL let capturedCtx: unknown = "not called"; const workflowCtx = { workflow: { name: "build", stepIndex: 2, total: 7 } }; const compactor = createPruningCompactor({ + // CL-9007: pin a one-token tail budget — only the mandatory floor stays + // live, so this tiny fixture still folds the same older region. + compactionShape: { tailBudgetTokens: 1 }, keepRecentTurns: 1, summaryMaxChars: 500, summaryContext: () => workflowCtx, @@ -776,6 +836,9 @@ describe("createPruningCompactor — summarize receives the workflow context (CL describe("createPruningCompactor — operator extra instructions", () => { test("stores extra instructions on the compact record", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 1, summaryMaxChars: 500, summaryContext: () => ({ extraInstructions: "keep the auth discussion" }), @@ -799,6 +862,9 @@ describe("createPruningCompactor — operator extra instructions", () => { makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), ]; const written = await createPruningCompactor({ + // CL-9007: pin a one-token tail budget — only the mandatory floor stays + // live, so this tiny fixture still folds the same older region. + compactionShape: { tailBudgetTokens: 1 }, keepRecentTurns: 1, summaryMaxChars: 500, summaryContext: () => ({ extraInstructions: "keep the auth discussion" }), @@ -812,6 +878,9 @@ describe("createPruningCompactor — operator extra instructions", () => { let captured: { extraInstructions?: string } | undefined; const next = await createPruningCompactor({ + // CL-9007: pin a one-token tail budget — only the mandatory floor stays + // live, so this tiny fixture still folds the same older region. + compactionShape: { tailBudgetTokens: 1 }, keepRecentTurns: 1, summaryMaxChars: 500, summaryContext: () => { @@ -860,6 +929,9 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { test("second apply replaces the prior summary instead of accumulating", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 2, summaryMaxChars: 500, }); @@ -880,6 +952,9 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { test("second apply keeps the initiating task as its own user turn", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 2, maxAnchorTurns: 1, summaryMaxChars: 500, @@ -941,6 +1016,9 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { test("harness spacer is stamped with the reserved producer id and a visible sentinel", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 2, summaryMaxChars: 500, }); @@ -983,6 +1061,9 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { test("empty-fold keep-set returns the input unchanged", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 1, maxAnchorTurns: 8, summaryMaxChars: 500, @@ -1028,6 +1109,9 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { }, }); const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 2, summaryMaxChars: 500, summarize, @@ -1248,6 +1332,9 @@ describe("buildLLMTurnSummary", () => { describe("buildTurnSummary via createPruningCompactor", () => { test("summarizes tool_call and tool_result blocks in compacted turns", async () => { const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 1, summaryMaxChars: 2000, }); @@ -1319,6 +1406,9 @@ describe("buildTurnSummary via createPruningCompactor", () => { test("a truncated lying spine aborts instead of shipping", async () => { const maxChars = 20; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 1, summaryMaxChars: maxChars, }); diff --git a/src/session/compaction-handoff.ts b/src/session/compaction-handoff.ts index 6fbe06fba..05b91e074 100644 --- a/src/session/compaction-handoff.ts +++ b/src/session/compaction-handoff.ts @@ -30,6 +30,7 @@ import { ArkErrors, type } from "arktype"; import type { ConversationTurn, StrategyBlob } from "@intx/types/runtime"; +import { VERIFY_REPAIR_HEADING } from "./compaction-verify.js"; // Canonical home of the fold marker. compactor.ts re-exports it so existing // importers keep working; this module owns the literal. @@ -483,11 +484,25 @@ export function extractHandoffArtifact( const nonEmptyUserTexts = freshUserTexts.filter( (text) => text.trim().length > 0, ); + // CL-9007: the budgeted tail can lift every user turn out of the folded + // region (anchored initiating task, whole newest messages), leaving no fresh + // user text to name the goal. Fall back to the fold's own summary narrative + // — the model's statement of what the region was about — before Unknown, so + // the next fold's "update this" context still names the work (e.g. src/a.ts) + // instead of dropping it. The verify-repair appendix is stripped first: it + // carries exact file lists that stay in the fat file by design (CL-8744) and + // must not leak into the thin spine. + const narrativeGoal = oneLine( + narrative.split(VERIFY_REPAIR_HEADING)[0] ?? "", + MAX_GOAL_CHARS, + ); const extractedGoal = carried.goal ?? (nonEmptyUserTexts.length > 0 ? oneLine(nonEmptyUserTexts[0] ?? "", MAX_GOAL_CHARS) - : "Unknown (no user message in folded turns)"); + : narrativeGoal.length > 0 + ? narrativeGoal + : "Unknown (no user message in folded turns)"); const goalFromFreshIndex = carried.goal === undefined ? 0 : -1; const fileGoal = preferFull(priorFile.goal, extractedGoal); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 325c7b226..27e7f6681 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -222,6 +222,49 @@ export interface CompactorConfig { // before the summary stub. Selected from the end of the older set so the // most-recent anchors survive; pair partners count against the cap too. maxAnchorTurns: number; + /** + * CL-9007 budgeted-tail shape. keepRecentTurns stays as the legacy floor + * (the tail always holds at least the last keepRecentTurns turns) — the + * budget decides how far past it the live tail extends. Partial: missing + * fields resolve against DEFAULT_TAIL_COMPACTION_SHAPE. + */ + compactionShape?: Partial; +} + +/** + * CL-9007 shape of the live tail the fold keeps: a structured summary plus a + * small recent tail (~5-10k tokens by default), not full raw recent turns. + * One object so CL-7686 research can tune per-family values later; the + * governor (CL-9006) reads the resolved copy off record.parameters. + */ +export interface CompactionShape { + /** Live-tail budget in tokens (chars/4 estimate). Default ~7500. */ + tailBudgetTokens: number; + /** Tool outputs in the tail longer than this are head+tail excerpted. */ + maxTailToolOutputChars: number; + /** Keep the head of a shortened tail tool output. */ + excerptHead: boolean; + /** Keep the tail of a shortened tail tool output. */ + excerptTail: boolean; + /** Newest user messages (plus attachments) stay whole up to the budget. */ + preserveWholeUserMessages: boolean; + /** Cut points never split a tool call from its result (whole-or-nothing). */ + pairSafe: boolean; +} + +export const DEFAULT_TAIL_COMPACTION_SHAPE: CompactionShape = { + tailBudgetTokens: 7500, + maxTailToolOutputChars: 2048, + excerptHead: true, + excerptTail: true, + preserveWholeUserMessages: true, + pairSafe: true, +}; + +export function resolveCompactionShape( + partial?: Partial, +): CompactionShape { + return { ...DEFAULT_TAIL_COMPACTION_SHAPE, ...partial }; } // Recent turns kept verbatim by both real pruning-compactor registrations @@ -772,11 +815,20 @@ async function ageImagesOutsideRecentWindow( // so this removes all of them. It does not repair a non-alternating sequence // that was already present in the input. // -// Only the later turn must be plain text; the earlier one may carry a -// tool_result. A surviving tool_result is always immediately preceded by its -// assistant tool_call, never by a text turn, so it only ever merges as the -// first block of the combined turn — its position relative to its tool_call is -// preserved, and no tool_call/tool_result sequence is disturbed. +// A turn carrying a tool_result body never fuses into a neighbor: result +// bodies are the bulk the tail budgets and excerpts per turn, and fusing an +// already-excerpted result into adjacent text would build a heavy hybrid turn +// the next fold cannot budget independently — live user text dragged into the +// summarized region together with old bulk instead of riding the tail +// forward. Call headers stay fusible (merging a following text turn into its +// call turn preserves role alternation without moving bulk), and a surviving +// result still lands immediately after its assistant tool_call either way, so +// no tool_call/tool_result sequence is disturbed. Result/text neighbors that +// no longer fuse get a [compact] spacer from separateAdjacentUserTurns. +function carriesToolResult(turn: ConversationTurn): boolean { + return turn.content.some((block) => block.type === "tool_result"); +} + function coalesceAdjacentTextTurns( turns: ConversationTurn[], ): ConversationTurn[] { @@ -786,6 +838,7 @@ function coalesceAdjacentTextTurns( if ( prev !== undefined && prev.role === turn.role && + !carriesToolResult(prev) && isPlainTextTurn(turn) && !isCompactedSummaryTurn(prev) && !isCompactedSummaryTurn(turn) && @@ -878,6 +931,218 @@ function compactSpacerTurn(timestamp: number): ConversationTurn { }; } +// --------------------------------------------------------------------------- +// CL-9007 budgeted tail +// --------------------------------------------------------------------------- + +// Rough token estimate for tail budgeting: ~4 chars per token, matching the +// estimator buildTurnSummary uses. +function estimateTextTokens(chars: number): number { + return Math.ceil(chars / 4); +} + +// Marker stamped by excerptTailText below. A tail turn carried forward into +// the next fold already wears it: excerpting is idempotent so a live excerpt +// rides unchanged (summarized from its shortened text, never re-expanded raw +// and never re-shortened into nested sentinels). +const TAIL_EXCERPT_SENTINEL = "[tail-shortened "; + +// Shorten one oversized text part of a tail tool result to a head+tail +// excerpt. The excerpt carries a sentinel, the original length, and the kept +// length so the tail is visibly lossy; the full text stays stored (archive +// blob / adopted handoff file) and is never rewritten by excerpting. +function excerptTailText( + text: string, + shape: CompactionShape, +): { text: string; shortened: boolean } { + if ( + text.length <= shape.maxTailToolOutputChars || + text.includes(TAIL_EXCERPT_SENTINEL) + ) + return { text, shortened: false }; + const headChars = shape.excerptHead + ? Math.ceil(shape.maxTailToolOutputChars / 2) + : shape.maxTailToolOutputChars; + const tailChars = shape.excerptTail + ? Math.floor(shape.maxTailToolOutputChars / 2) + : 0; + const head = text.slice(0, headChars); + const tail = tailChars > 0 ? text.slice(text.length - tailChars) : ""; + return { + text: + `${head}\n${TAIL_EXCERPT_SENTINEL}${text.length}→${head.length + tail.length} chars; ` + + `full text remains in the archived transcript]` + + (tail.length > 0 ? `\n${tail}` : ""), + shortened: true, + }; +} + +// Excerpted live copy of a tail turn: large tool_result text parts shrink to +// head+tail excerpts, everything else (user text, attachments, tool calls, +// error results stay whole — errors are resume state, not bulk) passes +// through untouched. +function excerptTailTurn( + turn: ConversationTurn, + shape: CompactionShape, +): { turn: ConversationTurn; shortenedOutputs: number } { + let shortenedOutputs = 0; + let changed = false; + const content = turn.content.map( + (block): ConversationTurn["content"][number] => { + if (block.type !== "tool_result" || block.isError === true) return block; + const parts = block.content.map((c) => { + if (c.type !== "text") return c; + const excerpted = excerptTailText(c.text, shape); + if (!excerpted.shortened) return c; + shortenedOutputs += 1; + changed = true; + return { ...c, text: excerpted.text }; + }); + return changed ? { ...block, content: parts } : block; + }, + ); + return { turn: changed ? { ...turn, content } : turn, shortenedOutputs }; +} + +interface TailSelection { + /** Contiguous live-tail boundary: tail is turns[tailStart..]. */ + tailStart: number; + /** Excerpted live copies for tail turns that needed shortening. */ + excerpted: Map; + shortenedToolOutputs: number; + /** Token estimate over the emitted (excerpted) tail. */ + tailTokenEstimate: number; +} + +// Newest→oldest budgeted tail selection. The last keepRecentTurns turns are +// the legacy floor (always kept); older turns are picked whole-or-nothing — +// user messages with attachments first-class whole, tool pairs only with +// their partners — until the next pick would overflow the token budget. Pair +// partners are dragged in even past the budget: pair-safety outranks size. +function selectTail( + turns: readonly ConversationTurn[], + keepRecentTurns: number, + shape: CompactionShape, + partnerIndex: ReadonlyMap, +): TailSelection { + const n = turns.length; + const excerpted = new Map(); + const picked = new Set(); + let shortenedToolOutputs = 0; + let usedChars = 0; + const budgetChars = shape.tailBudgetTokens * 4; + + const turnCost = (idx: number): { chars: number; shortened: number } => { + const turn = turns[idx]; + if (turn === undefined) return { chars: 0, shortened: 0 }; + const { turn: live, shortenedOutputs } = excerptTailTurn(turn, shape); + if (shortenedOutputs > 0) excerpted.set(idx, live); + let chars = 0; + for (const block of live.content) { + if (block.type === "text") chars += block.text.length; + else if (block.type === "tool_call") + chars += JSON.stringify(block.arguments).length; + else if (block.type === "tool_result") chars += resultContentSize(block); + } + return { chars, shortened: shortenedOutputs }; + }; + + const pick = (idx: number): void => { + if (picked.has(idx)) return; + const turn = turns[idx]; + // A dragged pair partner that is a foldable handoff turn stays out of the + // tail — it folds with the summarized region instead of riding live. + if (turn === undefined || isFoldableHandoffTurn(turn)) return; + picked.add(idx); + const { chars, shortened } = turnCost(idx); + usedChars += chars; + shortenedToolOutputs += shortened; + }; + + // Whole-or-nothing pair closure for the tail: the turn plus any partners + // the budget walk has not picked yet (newer partners are already held). + const tailClosure = (idx: number): number[] => { + const closure = [idx]; + const queue = [idx]; + const seen = new Set([idx]); + while (queue.length > 0) { + const current = queue.pop(); + if (current === undefined) continue; + for (const partner of partnerIndex.get(current) ?? []) { + if (seen.has(partner)) continue; + seen.add(partner); + closure.push(partner); + queue.push(partner); + } + } + return closure; + }; + + // Legacy floor: the newest turns stay live no matter the budget. Foldable + // handoff turns are never tail candidates — they belong to the summarized + // region that folds them, otherwise a fresh summary would stack beside a + // live prior spine. + const floorCount = Math.min(Math.max(keepRecentTurns, 0), n); + for (let i = n - floorCount; i < n; i++) { + const turn = turns[i]; + if (turn === undefined || isFoldableHandoffTurn(turn)) continue; + const closure = shape.pairSafe ? tailClosure(i) : [i]; + for (const idx of closure) pick(idx); + } + + // Newest→oldest budget walk. Foldable handoff turns are never tail + // candidates — they belong to the summarized region that folds them. + for (let i = n - floorCount - 1; i >= 0; i--) { + if (picked.has(i)) continue; + const turn = turns[i]; + if (turn === undefined || isFoldableHandoffTurn(turn)) continue; + const closure = (shape.pairSafe ? tailClosure(i) : [i]).filter( + (idx) => !picked.has(idx), + ); + let closureChars = 0; + for (const idx of closure) { + const t = turns[idx]; + if (t === undefined) continue; + const { turn: live } = excerptTailTurn(t, shape); + for (const block of live.content) { + if (block.type === "text") closureChars += block.text.length; + else if (block.type === "tool_call") + closureChars += JSON.stringify(block.arguments).length; + else if (block.type === "tool_result") + closureChars += resultContentSize(block); + } + } + if (usedChars + closureChars > budgetChars) break; + for (const idx of closure) pick(idx); + } + + let tailStart = n; + for (const idx of picked) tailStart = Math.min(tailStart, idx); + return { + tailStart, + excerpted, + shortenedToolOutputs, + tailTokenEstimate: estimateTextTokens(usedChars), + }; +} + +// Thin spine text of prior folds still live in the input: the next summary +// updates this text with what changed instead of summarizing beside it. The +// spine turn itself stays in the summarized region so the fold carries it +// forward; this is the copy the summarizer sees. +function extractFoldableSpineText( + turns: readonly ConversationTurn[], +): string | undefined { + const parts: string[] = []; + for (const turn of turns) { + if (!isCompactedSummaryTurn(turn)) continue; + const text = firstTextBlock(turn); + if (text !== undefined && text.length > 0) parts.push(text); + } + if (parts.length === 0) return undefined; + return parts.join("\n"); +} + export function createPruningCompactor( config: Partial = {}, ): Compactor { @@ -885,7 +1150,7 @@ export function createPruningCompactor( return { name: "pruning-compactor", - version: "1.6.0", + version: "1.7.0", async apply( turns: ConversationTurn[], _ctx: StrategyContext, @@ -893,6 +1158,7 @@ export function createPruningCompactor( // Prior compacted summaries are folded into the next handoff, not frozen. // Image aging still skips the recent window so a just-pasted screenshot // stays live. + const shape = resolveCompactionShape(cfg.compactionShape); // Eager image aging runs before the compact/no-op branch so base64 pastes // leave the inference-facing context as soon as they exit the recent window. @@ -909,6 +1175,7 @@ export function createPruningCompactor( version: this.version, parameters: { keepRecentTurns: cfg.keepRecentTurns, + compactionShape: shape, ...extraInstructionParameter(cfg), }, reason: @@ -925,40 +1192,56 @@ export function createPruningCompactor( // result can still name its path even when its call turn was summarized. const callIndex = buildCallIndex(aged.turns); - const keepCount = Math.min(cfg.keepRecentTurns, aged.turns.length - 1); - const keepFrom = aged.turns.length - keepCount; - const recentTurns = aged.turns.slice(keepFrom); - const olderTurns = aged.turns.slice(0, keepFrom); - const pairs = buildPairIndex(aged.turns); const partnerIndex = buildPartnerIndex(pairs); + // CL-9007 budgeted tail replaces the last-N-verbatim keep window: the + // tail always holds at least the last keepRecentTurns turns (legacy + // floor) and extends older while the next whole pick fits the token + // budget. Pair partners are dragged in whole-or-nothing, so no pair + // ever straddles the tail boundary and the old mandatory-pull rescue + // has nothing left to do. Large tail tool outputs ride excerpted; the + // excerpted live copies below are the only shortened text — stored + // turns (handoff file, archive) keep full bodies. + const tail = selectTail( + aged.turns, + cfg.keepRecentTurns, + shape, + partnerIndex, + ); + const tailStart = tail.tailStart; + // Foldable handoff turns inside the tail range ride the summarized region + // so the fold absorbs them; otherwise a fresh summary would stack beside + // a live prior spine. They sort after every excluded turn, keeping the + // summarized region in global index order. + const tailTurns: ConversationTurn[] = []; + const carriedSpines: ConversationTurn[] = []; + aged.turns.forEach((turn, idx) => { + if (idx < tailStart) return; + if (isFoldableHandoffTurn(turn)) { + carriedSpines.push(turn); + return; + } + tailTurns.push(tail.excerpted.get(idx) ?? turn); + }); + const excludedTurns = aged.turns.slice(0, tailStart); + // Repeated identical errors collapse to their last occurrence before // scoring, so a failing retry loop contributes one representative // instead of scoring every iteration. const repeatedErrors = repeatedErroredResultCallIds( - olderTurns, + excludedTurns, callIndex, ); - const scoredOlder = olderTurns.map((t, i) => ({ + const scoredOlder = excludedTurns.map((t, i) => ({ index: i, score: anchorScore(t, repeatedErrors), })); - // Keep tool_call/tool_result pairs together across the keep/summarize - // boundary: a surviving turn whose partner is summarized leaves a - // dangling tool_call or an orphaned tool_result, which the inference - // layer rejects. Partners of recent-window turns are mandatory pulls - // and are counted against maxAnchorTurns first, so the cap bounds the - // total turns pulled forward past the summary. + // The tail boundary never splits a tool pair (partners are dragged into + // the tail whole-or-nothing during selection), so there are no straddling + // partners left to rescue — anchors here are importance pulls only. const anchorIndices = new Set(); - for (const { callIdx, resultIdx } of pairs.values()) { - if (callIdx === undefined || resultIdx === undefined) continue; - if (callIdx >= keepFrom && resultIdx < keepFrom) - addPairClosure(resultIdx, partnerIndex, keepFrom, anchorIndices); - else if (resultIdx >= keepFrom && callIdx < keepFrom) - addPairClosure(callIdx, partnerIndex, keepFrom, anchorIndices); - } // Pull high-importance turns forward regardless of age, most recent // first so the freshest anchors survive. Each candidate is taken with @@ -968,7 +1251,7 @@ export function createPruningCompactor( for (let i = scoredOlder.length - 1; i >= 0; i--) { const candidate = scoredOlder[i]; if (candidate === undefined) continue; - const candidateTurn = olderTurns[candidate.index]; + const candidateTurn = excludedTurns[candidate.index]; if (candidateTurn !== undefined && isFoldableHandoffTurn(candidateTurn)) continue; if ( @@ -979,7 +1262,7 @@ export function createPruningCompactor( const closure = pairClosure( candidate.index, partnerIndex, - keepFrom, + tailStart, anchorIndices, ); if (closure.size > anchorBudget) continue; @@ -991,45 +1274,58 @@ export function createPruningCompactor( // cap. Losing the oldest user turn is how the agent forgets what it was // asked to do; correctness outranks the size target here. Prior compacted // summaries are not the initiating task — they get folded. - const initiatingIdx = firstUserTurnIndex(olderTurns); + const initiatingIdx = firstUserTurnIndex(excludedTurns); if (initiatingIdx >= 0) - addPairClosure(initiatingIdx, partnerIndex, keepFrom, anchorIndices); + addPairClosure(initiatingIdx, partnerIndex, tailStart, anchorIndices); for (const idx of [...anchorIndices]) { - const turn = olderTurns[idx]; + const turn = excludedTurns[idx]; if (turn !== undefined && isFoldableHandoffTurn(turn)) anchorIndices.delete(idx); } - // Ascending original order keeps the concatenated [anchors, recent] + // Ascending original order keeps the concatenated [anchors, tail] // sequence globally index-ordered, so every result still follows its call. const sortedAnchorIndices = [...anchorIndices].sort((a, b) => a - b); const anchorTurns = sortedAnchorIndices.flatMap((i) => { - const turn = olderTurns[i]; + const turn = excludedTurns[i]; return turn === undefined ? [] : [turn]; }); - const summarizedTurns = olderTurns.filter( - (_, i) => !anchorIndices.has(i), - ); + // Summarized region: everything outside the tail that is not an anchor. + // Prior fold spines ride along so buildHandoffFold folds them (never + // stacked); on a repeat fold the live tail carried forward re-enters + // here already excerpted — summarized from its shortened text, never + // re-expanded raw. + const summarizedTurns = [ + ...excludedTurns.filter((_, i) => !anchorIndices.has(i)), + ...carriedSpines, + ]; // Keep-set covered everything foldable: nothing to replace. Leave the - // input untouched rather than rewriting the head with an empty summary. + // input untouched rather than rewriting the head with an empty summary — + // but return the image-aged turns (plus their spill blobs), not the raw + // input, so eager aging outside the tail is not silently dropped. if (summarizedTurns.length === 0) { return { - output: turns, + output: aged.turns, record: { strategy: this.name, version: this.version, parameters: { keepRecentTurns: cfg.keepRecentTurns, + compactionShape: shape, ...extraInstructionParameter(cfg), }, reason: "no compaction needed", decisions: { summarizedTurnCount: 0, + tailBudgetTokens: shape.tailBudgetTokens, + tailTokenEstimate: tail.tailTokenEstimate, + shortenedToolOutputs: tail.shortenedToolOutputs, agedImageCount: aged.agedImageCount, }, }, + ...(aged.blobs.length > 0 ? { blobs: aged.blobs } : {}), }; } @@ -1037,12 +1333,20 @@ export function createPruningCompactor( // transcript would hollow a kept older read when the newer re-read is only // in the summary (CL-4374 review follow-up). const pathToReads = buildPathToReads( - [...anchorTurns, ...recentTurns], + [...anchorTurns, ...tailTurns], callIndex, ); const supersededReads = supersededReadCallIds(pathToReads); - const summaryCtx = cfg.summaryContext?.(); + // Repeat folds update the prior summary instead of summarizing beside + // it: the prior spine text rides the summary context while the spine + // turn itself stays in the summarized region for the handoff to fold. + const priorSummaryForFold = extractFoldableSpineText(aged.turns); + const operatorCtx = cfg.summaryContext?.(); + const summaryCtx: SummaryContext | undefined = + priorSummaryForFold === undefined + ? operatorCtx + : { ...operatorCtx, priorSummary: priorSummaryForFold }; let summary: string; try { summary = @@ -1061,6 +1365,7 @@ export function createPruningCompactor( version: this.version, parameters: { keepRecentTurns: cfg.keepRecentTurns, + compactionShape: shape, ...extraInstructionParameter(cfg), }, reason: "summarize failed", @@ -1079,6 +1384,7 @@ export function createPruningCompactor( version: this.version, parameters: { keepRecentTurns: cfg.keepRecentTurns, + compactionShape: shape, ...extraInstructionParameter(cfg), }, reason: "summarize failed", @@ -1105,7 +1411,10 @@ export function createPruningCompactor( record: { strategy: this.name, version: this.version, - parameters: { keepRecentTurns: cfg.keepRecentTurns }, + parameters: { + keepRecentTurns: cfg.keepRecentTurns, + compactionShape: shape, + }, reason: "verify failed — keeping prior context", decisions: { verifyAborted: 1, @@ -1156,14 +1465,15 @@ export function createPruningCompactor( const summaryTurn: ConversationTurn = { role: "user", content: [{ type: "text", text: handoff.spineText }], - timestamp: olderTurns[olderTurns.length - 1]?.timestamp ?? Date.now(), + timestamp: + excludedTurns[excludedTurns.length - 1]?.timestamp ?? Date.now(), }; - // Anchors and recent turns stay contentful except for path-dedup: when the + // Anchors and tail turns stay contentful except for path-dedup: when the // same file was read successfully more than once among kept turns, older // results become a one-line stub and the newest stays whole. Error results // are never stubbed. SummarizedTurns lose content wholesale via the summary - // above. Anchors are already image-aged (outside the recent window). Recent + // above. Anchors are already image-aged (outside the recent window). Tail // turns keep live base64 so a just-pasted screenshot still reaches the model. const process = (t: ConversationTurn): ConversationTurn => stubSupersededReads(t, supersededReads, callIndex); @@ -1171,7 +1481,7 @@ export function createPruningCompactor( coalesceAdjacentTextTurns([ summaryTurn, ...anchorTurns.map(process), - ...recentTurns.map(process), + ...tailTurns.map(process), ]), ); @@ -1184,13 +1494,17 @@ export function createPruningCompactor( keepRecentTurns: cfg.keepRecentTurns, summaryMaxChars: cfg.summaryMaxChars, maxAnchorTurns: cfg.maxAnchorTurns, + compactionShape: shape, ...extraInstructionParameter(cfg), }, - reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${keepCount} recent`, + reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${tailTurns.length} tail`, decisions: { summarizedTurnCount: summarizedTurns.length, anchorTurnCount: anchorTurns.length, - recentTurnCount: recentTurns.length, + recentTurnCount: tailTurns.length, + tailBudgetTokens: shape.tailBudgetTokens, + tailTokenEstimate: tail.tailTokenEstimate, + shortenedToolOutputs: tail.shortenedToolOutputs, summaryLength: summary.length, handoffBlobKey: handoff.blob.key, handoffSpineLength: handoff.spineText.length, diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index dae9b9090..b02badfc8 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -472,6 +472,8 @@ describe("createSessionPruningCompactor", () => { const llm = createSessionPruningCompactor({ summarize, summaryContext: () => ctx, + // CL-9007: pin a one-token tail budget so this tiny fixture still folds. + compactionShape: { tailBudgetTokens: 1 }, }); const now = Date.now(); const turns = Array.from({ length: 8 }, (_, i) => ({ @@ -489,6 +491,8 @@ describe("createSessionPruningCompactor", () => { const folding = createSessionPruningCompactor({ summarize, onFolded: (info) => folds.push(info), + // CL-9007: pin a one-token tail budget so this tiny fixture still folds. + compactionShape: { tailBudgetTokens: 1 }, }); const now = Date.now(); const many = Array.from({ length: 8 }, (_, i) => ({ diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 21ff6c61d..080293340 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -54,7 +54,9 @@ import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import type { SubAgentProvider } from "../subagent/index.js"; import { COMPACTOR_KEEP_RECENT_TURNS, + DEFAULT_TAIL_COMPACTION_SHAPE, createPruningCompactor, + type CompactionShape, } from "./compactor.js"; import type { SummaryContext } from "./summarizer.js"; import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js"; @@ -412,6 +414,12 @@ export interface SessionPruningCompactorArgs { * no onFolded side effects for work that never landed. */ isAborted?: () => boolean; + /** + * CL-9007 budgeted-tail shape override. Absent means the shared production + * default (DEFAULT_TAIL_COMPACTION_SHAPE); tests pin a tiny budget so small + * fixtures still fold the same region the old keep-window cut folded. + */ + compactionShape?: Partial; } /** Shared pruning-compactor defaults for the main session agent. */ @@ -421,6 +429,13 @@ export function createSessionPruningCompactor( const compactor = createPruningCompactor({ keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS, + // CL-9007 budgeted-tail shape: explicit defaults (same object the record + // carries under parameters.compactionShape). keepRecentTurns stays as the + // legacy floor only — the budget decides how far past it the tail extends. + compactionShape: { + ...DEFAULT_TAIL_COMPACTION_SHAPE, + ...args.compactionShape, + }, ...(args.summarize !== undefined ? { summarize: args.summarize } : {}), ...(args.summaryContext ? { summaryContext: args.summaryContext } : {}), ...(args.readPriorHandoff !== undefined diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index f181255b3..d09a8e4e4 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -146,6 +146,13 @@ export interface SummaryContext { * record. */ extraInstructions?: string; + /** + * Newest prior handoff spine text on a repeat fold. The next summary updates + * this text with what changed instead of summarizing beside it — the spine + * turn itself rides the summarized region so the handoff fold absorbs it; + * this is the copy the model prompt carries. + */ + priorSummary?: string; } const SYSTEM_INSTRUCTION = [ @@ -279,6 +286,12 @@ function contextPreamble(ctx: SummaryContext | undefined): string { `Operator compact instructions (honor these while keeping the sections above):\n${extra}`, ); } + const prior = ctx?.priorSummary?.trim(); + if (prior !== undefined && prior.length > 0) { + parts.push( + `Prior handoff summary (update this with what changed since — do not restate it wholesale):\n${prior}`, + ); + } if (parts.length === 0) return ""; return `${parts.join("\n\n")}\n\n`; } diff --git a/tests/integration/compaction-baseline.test.ts b/tests/integration/compaction-baseline.test.ts index 64cb8265a..80d26ac98 100644 --- a/tests/integration/compaction-baseline.test.ts +++ b/tests/integration/compaction-baseline.test.ts @@ -265,6 +265,9 @@ describe("integration — compaction mechanics baseline", () => { skipPermissions: true, reactorGated: false, }), + // CL-9007: pin a tiny tail budget so the calibrated growth volumes + // still fold instead of fitting the default live tail. + compactionShape: { tailBudgetTokens: 10 }, compactionCompletion: async (turns) => { const context = turns .flatMap((turn) => diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 30c1ad42b..c86a64b9d 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -61,6 +61,7 @@ import { createContinuationGate, createSessionPruningCompactor, } from "../../src/session/runtime-assembly.js"; +import type { CompactionShape } from "../../src/session/compactor.js"; import { COMPACTION_CONTINUATION_EVENT } from "../../src/agent/compaction.js"; export const INTEGRATION_SOURCE: InferenceSource = { @@ -99,6 +100,12 @@ export interface OpenIntegrationSessionOpts { contextTransforms?: ContextTransform[]; /** Override to pin the published createAgent snapshot (characterization). */ createAgentFn?: typeof createAgent; + /** + * CL-9007 tail-budget override for the production compactor. Absent means + * the shared default; compaction tests pin a tiny budget so their + * calibrated growth volumes still fold instead of fitting the live tail. + */ + compactionShape?: Partial; } export async function openIntegrationSession( @@ -239,6 +246,11 @@ export async function openIntegrationSession( compactors: { "pruning-compactor": wrapCompactorWithCompletenessGate( createSessionPruningCompactor({ + // Absent compactionShape falls back to the shared production + // default inside createSessionPruningCompactor. + ...(opts.compactionShape !== undefined + ? { compactionShape: opts.compactionShape } + : {}), summarize: createModelSummarizer({ getSource: () => INTEGRATION_SOURCE, deps: harness.deps, diff --git a/tests/unit/compactor-pairing.test.ts b/tests/unit/compactor-pairing.test.ts index 53273cca4..da027a603 100644 --- a/tests/unit/compactor-pairing.test.ts +++ b/tests/unit/compactor-pairing.test.ts @@ -53,6 +53,9 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { userText("g"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -74,6 +77,9 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { userText("g"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -117,6 +123,9 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { userText("g"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -168,6 +177,9 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { userText("g"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -270,6 +282,9 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -302,6 +317,9 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -330,6 +348,9 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { ]; // keep=3 → recent is kept call + kept result + end; the older pair summarizes. const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 3, maxAnchorTurns: 0, }); @@ -376,6 +397,9 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -423,6 +447,9 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -468,6 +495,9 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -496,6 +526,9 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -521,6 +554,9 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -545,6 +581,9 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); @@ -573,6 +612,9 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C userText("e"), ]; const compactor = createPruningCompactor({ + // CL-9007: pin a tiny tail budget so the fold covers the same older + // region the old keepRecentTurns cut folded. + compactionShape: { tailBudgetTokens: 10 }, keepRecentTurns: 6, maxAnchorTurns: 2, }); From 4caced698e14cab4febb17587b0254037b77c8e6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 26 Sep 2026 08:28:11 -0700 Subject: [PATCH 3/3] fix(session): emit excerpted tail copies when nothing is summarized A keep-set that covers every foldable turn still has to ship the excerpted live copies the tail selector already paid for. Returning the unexcerpted turns keeps the occupancy that armed the compact. Skip re-excerpting only on the structured marker so a body that merely mentions the prefix is still shortened. --- src/session/compaction-verify.test.ts | 74 ++++++++++++++++++++++++++- src/session/compactor.ts | 19 ++++--- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index e1f0c598f..fc6a8095f 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { createPruningCompactor } from "./compactor.js"; import { condenseTurns } from "./summarizer.js"; -import { HANDOFF_LATEST_KEY } from "./compaction-handoff.js"; +import { COMPACTED_PREFIX, HANDOFF_LATEST_KEY } from "./compaction-handoff.js"; import { createCompactionArchive, wrapCompactorWithCompletenessGate, @@ -786,6 +786,27 @@ describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { .join("\n"); } + function liveTokenEstimate(turns: ConversationTurn[]): number { + let chars = 0; + for (const turn of turns) { + for (const block of turn.content) { + if (block.type === "text") chars += block.text.length; + else if (block.type === "tool_call") + chars += JSON.stringify(block.arguments).length; + else if (block.type === "tool_result") { + for (const part of block.content) { + if (part.type === "text") chars += part.text.length; + } + } + } + } + return Math.ceil(chars / 4); + } + + function countStructuredTailExcerpts(text: string): number { + return text.match(/\[tail-shortened \d+→/g)?.length ?? 0; + } + test("large tool outputs in the tail are shortened rather than copied verbatim", async () => { const result = await tailCompactor().apply(tailSession(), mockStrategyCtx); expect(result.record.reason.startsWith("compacted")).toBe(true); @@ -840,6 +861,57 @@ describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { compactionShape: { tailBudgetTokens: 7500, pairSafe: true }, }); }); + + test("budget-swallow still emits excerpted tail copies; live tokens ≤ budget; shortenedToolOutputs matches live sentinels", async () => { + const dump = "z".repeat(12_000); + const turns: ConversationTurn[] = [ + textTurn("user", "do the work"), + ...pairTurns("a", "read_file", { path: "src/a.ts" }, dump), + ...pairTurns("b", "read_file", { path: "src/b.ts" }, dump), + ...pairTurns("c", "read_file", { path: "src/c.ts" }, dump), + textTurn("user", "newest ask"), + textTurn("assistant", "newest reply"), + ]; + const result = await createPruningCompactor({ + keepRecentTurns: 2, + compactionShape: { tailBudgetTokens: 7500 }, + summarize: async () => { + throw new Error("must not invent a summary on the budget-swallow path"); + }, + }).apply(turns, mockStrategyCtx); + + expect(result.record.reason).toBe("no compaction needed"); + expect(allText(result.output)).not.toContain(COMPACTED_PREFIX); + const live = liveResultText(result.output); + expect(live).not.toContain(dump); + expect(result.record.decisions).toMatchObject({ shortenedToolOutputs: 3 }); + expect(countStructuredTailExcerpts(live)).toBe(3); + expect(liveTokenEstimate(result.output)).toBeLessThanOrEqual(7500); + }); + + test("a body containing the substring but not the structured marker is still excerpted", async () => { + const bait = "[tail-shortened "; + const body = `${bait}in the docs\n${"z".repeat(8000)}`; + expect(body.includes(bait)).toBe(true); + expect(/\[tail-shortened \d+→/.test(body)).toBe(false); + + const turns: ConversationTurn[] = [ + textTurn("user", "goal"), + ...pairTurns("bait", "read_file", { path: "src/bait.ts" }, body), + textTurn("user", "newest ask"), + textTurn("assistant", "newest reply"), + ]; + const result = await createPruningCompactor({ + keepRecentTurns: 2, + compactionShape: { tailBudgetTokens: 7500 }, + }).apply(turns, mockStrategyCtx); + + const live = liveResultText(result.output); + expect(live).toMatch(/\[tail-shortened \d+→/); + expect(live).not.toContain("z".repeat(8000)); + expect(result.record.decisions).toMatchObject({ shortenedToolOutputs: 1 }); + expect(countStructuredTailExcerpts(live)).toBe(1); + }); }); describe("continuation facts survive many folds", () => { diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 27e7f6681..b10dd2523 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -946,6 +946,9 @@ function estimateTextTokens(chars: number): number { // rides unchanged (summarized from its shortened text, never re-expanded raw // and never re-shortened into nested sentinels). const TAIL_EXCERPT_SENTINEL = "[tail-shortened "; +// Match the stamped marker (`[tail-shortened N→`), not a raw prefix: a body +// that happens to mention the substring must still be excerpted. +const TAIL_EXCERPT_MARKER = /\[tail-shortened \d+→/; // Shorten one oversized text part of a tail tool result to a head+tail // excerpt. The excerpt carries a sentinel, the original length, and the kept @@ -957,7 +960,7 @@ function excerptTailText( ): { text: string; shortened: boolean } { if ( text.length <= shape.maxTailToolOutputChars || - text.includes(TAIL_EXCERPT_SENTINEL) + TAIL_EXCERPT_MARKER.test(text) ) return { text, shortened: false }; const headChars = shape.excerptHead @@ -1301,13 +1304,17 @@ export function createPruningCompactor( ...carriedSpines, ]; - // Keep-set covered everything foldable: nothing to replace. Leave the - // input untouched rather than rewriting the head with an empty summary — - // but return the image-aged turns (plus their spill blobs), not the raw - // input, so eager aging outside the tail is not silently dropped. + // Keep-set covered everything foldable: nothing to replace. Do not + // invent an empty summary — but still emit the excerpted live copies + // selectTail already paid for (plus image-aged turns and their spill + // blobs). Returning the unexcerpted aged turns would keep the occupancy + // that armed the compact on a short-but-bulky first fold. if (summarizedTurns.length === 0) { return { - output: aged.turns, + output: + tail.excerpted.size === 0 + ? aged.turns + : aged.turns.map((turn, idx) => tail.excerpted.get(idx) ?? turn), record: { strategy: this.name, version: this.version,