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-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-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/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 454f9a5f2..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, @@ -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,246 @@ 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"); + } + + 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); + 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 }, + }); + }); + + 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", () => { 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 +1010,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, }); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 325c7b226..b10dd2523 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,221 @@ 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 "; +// 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 +// 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 || + TAIL_EXCERPT_MARKER.test(text) + ) + 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 +1153,7 @@ export function createPruningCompactor( return { name: "pruning-compactor", - version: "1.6.0", + version: "1.7.0", async apply( turns: ConversationTurn[], _ctx: StrategyContext, @@ -893,6 +1161,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 +1178,7 @@ export function createPruningCompactor( version: this.version, parameters: { keepRecentTurns: cfg.keepRecentTurns, + compactionShape: shape, ...extraInstructionParameter(cfg), }, reason: @@ -925,40 +1195,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 +1254,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 +1265,7 @@ export function createPruningCompactor( const closure = pairClosure( candidate.index, partnerIndex, - keepFrom, + tailStart, anchorIndices, ); if (closure.size > anchorBudget) continue; @@ -991,45 +1277,62 @@ 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), - ); - - // Keep-set covered everything foldable: nothing to replace. Leave the - // input untouched rather than rewriting the head with an empty summary. + // 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. 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: 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, 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 +1340,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 +1372,7 @@ export function createPruningCompactor( version: this.version, parameters: { keepRecentTurns: cfg.keepRecentTurns, + compactionShape: shape, ...extraInstructionParameter(cfg), }, reason: "summarize failed", @@ -1079,6 +1391,7 @@ export function createPruningCompactor( version: this.version, parameters: { keepRecentTurns: cfg.keepRecentTurns, + compactionShape: shape, ...extraInstructionParameter(cfg), }, reason: "summarize failed", @@ -1105,7 +1418,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 +1472,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 +1488,7 @@ export function createPruningCompactor( coalesceAdjacentTextTurns([ summaryTurn, ...anchorTurns.map(process), - ...recentTurns.map(process), + ...tailTurns.map(process), ]), ); @@ -1184,13 +1501,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, });