From f4aed277b3dfeecf8aa3e4fd11296199b1dc5dd1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 19:09:14 -0700 Subject: [PATCH 1/3] Stop compaction from anchoring every error and losing the substance Errored tool_results scored above the anchor threshold on their own, so a failing-edit retry loop anchored every iteration verbatim while healthy context got summarized away. Score errors below threshold and collapse runs of the same repeating error to one representative before scoring. Pairing anchors were pulled in with no cap, making maxAnchorTurns not a real bound; the scored-anchor selection now takes each candidate's pair closure whole-or-nothing against a shared budget. The summary prompt seeded from the last 8 assistant snippets, which during degeneration is the looped text itself; filter snippets flagged by the existing detectRepetition detector before they reach the summarizer. Superseded-result stubbing only covered read_file; extend it to grep, search_files, and list_dir (byte-identical arguments), excluding run_shell since the same command is not idempotent. CompactorConfig.summarize was typed with one argument even though the summarizer already accepted a workflow context, so the context never made it through; widen the type and thread cfg.summaryContext() into the call. Replace the bare catch in the model summarizer with a logged warning and a fallback marker so a failed LLM summary is distinguishable from a real one. --- src/context-compactor.test.ts | 323 +++++++++++++++++++++++-- src/session/compactor.ts | 339 ++++++++++++++++++++------- src/session/summarizer.ts | 61 ++++- tests/unit/compactor-pairing.test.ts | 241 +++++++++++++++++-- tests/unit/summarizer.test.ts | 57 ++++- 5 files changed, 878 insertions(+), 143 deletions(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 84149b698..72eb2cfa3 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -15,7 +15,9 @@ const mockStrategyCtx: StrategyContext = { trigger: "test", }; -function makeTurn(overrides: Partial & { role: ConversationTurn["role"] }): ConversationTurn { +function makeTurn( + overrides: Partial & { role: ConversationTurn["role"] }, +): ConversationTurn { return { content: [{ type: "text", text: "" }], timestamp: Date.now(), @@ -126,7 +128,11 @@ 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({ keepRecentTurns: 2, maxAnchorTurns: 1, summaryMaxChars: 500 }); + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + maxAnchorTurns: 1, + summaryMaxChars: 500, + }); const goal = "GOAL: migrate the auth module to opaque tokens"; const turns: ConversationTurn[] = [ makeTurn({ role: "user", content: [{ type: "text", text: goal }] }), @@ -136,7 +142,9 @@ describe("createPruningCompactor — initiating task preservation", () => { } // A later user turn would win the single anchor slot on recency alone; // the initiating task must still survive. - turns.push(makeTurn({ role: "user", content: [{ type: "text", text: "also handle refresh" }] })); + turns.push( + makeTurn({ role: "user", content: [{ type: "text", text: "also handle refresh" }] }), + ); turns.push(makeTurn({ role: "assistant", content: [{ type: "text", text: "recent reply" }] })); turns.push(makeTurn({ role: "user", content: [{ type: "text", text: "recent ask" }] })); @@ -175,12 +183,29 @@ describe("createPruningCompactor — initiating task preservation", () => { }); test("keeps alternating roles when a tool_result user turn abuts a plain user turn", async () => { - const compactor = createPruningCompactor({ keepRecentTurns: 1, maxAnchorTurns: 3, summaryMaxChars: 500 }); + const compactor = createPruningCompactor({ + keepRecentTurns: 1, + maxAnchorTurns: 3, + summaryMaxChars: 500, + }); const turns: ConversationTurn[] = [ makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }), - makeTurn({ role: "assistant", content: [{ type: "tool_call", id: "c1", name: "edit_file", arguments: { path: "src/a.ts" } }] }), - makeTurn({ role: "user", content: [{ type: "tool_result", callId: "c1", content: [{ type: "text", text: "edited" }] }] }), - makeTurn({ role: "assistant", content: [{ type: "text", text: "reasoning that gets summarized" }] }), + makeTurn({ + role: "assistant", + content: [ + { type: "tool_call", id: "c1", name: "edit_file", arguments: { path: "src/a.ts" } }, + ], + }), + makeTurn({ + role: "user", + content: [ + { type: "tool_result", callId: "c1", content: [{ type: "text", text: "edited" }] }, + ], + }), + makeTurn({ + role: "assistant", + content: [{ type: "text", text: "reasoning that gets summarized" }], + }), makeTurn({ role: "user", content: [{ type: "text", text: "the recent ask" }] }), ]; const result = await compactor.apply(turns, mockStrategyCtx); @@ -188,24 +213,32 @@ describe("createPruningCompactor — initiating task preservation", () => { // to the recent user turn; coalescing must still alternate. expect(hasConsecutiveSameRole(result.output)).toBe(false); // The tool_result stays paired with its tool_call. - const callTurnIdx = result.output.findIndex((t) => t.content.some((b) => b.type === "tool_call" && b.id === "c1")); + const callTurnIdx = result.output.findIndex((t) => + t.content.some((b) => b.type === "tool_call" && b.id === "c1"), + ); const resultTurn = result.output[callTurnIdx + 1]; - expect(resultTurn?.content.some((b) => b.type === "tool_result" && b.callId === "c1")).toBe(true); + expect(resultTurn?.content.some((b) => b.type === "tool_result" && b.callId === "c1")).toBe( + true, + ); }); }); describe("createPruningCompactor — image aging", () => { - const imageBlock = { type: "image" as const, source: { kind: "base64" as const, mimeType: "image/png", data: "iVBORw0KGgo=" } }; + const imageBlock = { + type: "image" as const, + source: { kind: "base64" as const, mimeType: "image/png", data: "iVBORw0KGgo=" }, + }; test("strips image bytes from an anchored (aged) turn but keeps its text", async () => { - const compactor = createPruningCompactor({ keepRecentTurns: 2, maxAnchorTurns: 1, summaryMaxChars: 500 }); + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + maxAnchorTurns: 1, + summaryMaxChars: 500, + }); const turns: ConversationTurn[] = [ makeTurn({ role: "user", - content: [ - { type: "text", text: "here's a screenshot of the bug" }, - imageBlock, - ], + content: [{ type: "text", text: "here's a screenshot of the bug" }, imageBlock], }), ]; for (let i = 0; i < 8; i++) { @@ -241,7 +274,10 @@ describe("createPruningCompactor — image aging", () => { const turns: ConversationTurn[] = [ makeTurn({ role: "user", content: [{ type: "text", text: "old 1" }] }), makeTurn({ role: "assistant", content: [{ type: "text", text: "old 2" }] }), - makeTurn({ role: "user", content: [{ type: "text", text: "here's a screenshot" }, imageBlock] }), + makeTurn({ + role: "user", + content: [{ type: "text", text: "here's a screenshot" }, imageBlock], + }), makeTurn({ role: "assistant", content: [{ type: "text", text: "looking at it" }] }), makeTurn({ role: "user", content: [{ type: "text", text: "recent ask" }] }), ]; @@ -258,10 +294,7 @@ describe("createPruningCompactor — image aging", () => { const turns: ConversationTurn[] = [ makeTurn({ role: "user", - content: [ - { type: "text", text: "old screenshot" }, - imageBlock, - ], + content: [{ type: "text", text: "old screenshot" }, imageBlock], }), makeTurn({ role: "assistant", content: [{ type: "text", text: "noted" }] }), makeTurn({ role: "user", content: [{ type: "text", text: "recent ask" }] }), @@ -283,11 +316,18 @@ describe("createPruningCompactor — image aging", () => { }); test("records the number of turns aged out in the transform record", async () => { - const compactor = createPruningCompactor({ keepRecentTurns: 1, maxAnchorTurns: 1, summaryMaxChars: 500 }); + const compactor = createPruningCompactor({ + keepRecentTurns: 1, + maxAnchorTurns: 1, + summaryMaxChars: 500, + }); const turns: ConversationTurn[] = [ makeTurn({ role: "user", content: [{ type: "text", text: "task" }, imageBlock] }), ...Array.from({ length: 6 }, (_, i) => - makeTurn({ role: i % 2 === 0 ? "assistant" : "user", content: [{ type: "text", text: `t${i}` }] }), + makeTurn({ + role: i % 2 === 0 ? "assistant" : "user", + content: [{ type: "text", text: `t${i}` }], + }), ), ]; const result = await compactor.apply(turns, mockStrategyCtx); @@ -295,6 +335,235 @@ describe("createPruningCompactor — image aging", () => { }); }); +describe("createPruningCompactor — error anchoring (CL-6906)", () => { + function assistantErrorCall(id: string, name: string): ConversationTurn { + return makeTurn({ + role: "assistant", + content: [{ type: "tool_call", id, name, arguments: {} }], + }); + } + function errorResult(callId: string, text: string): ConversationTurn { + return makeTurn({ + role: "user", + content: [{ type: "tool_result", callId, content: [{ type: "text", text }], isError: true }], + }); + } + function padding(n: number, prefix: string): ConversationTurn[] { + return Array.from({ length: n }, (_, i) => + makeTurn({ + role: i % 2 === 0 ? "assistant" : "user", + content: [{ type: "text", text: `${prefix}${i}` }], + }), + ); + } + + test("a lone errored tool_result no longer anchors on its own", async () => { + const turns: ConversationTurn[] = [ + makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }), + ...padding(3, "before"), + assistantErrorCall("e1", "run_shell"), + errorResult("e1", "Error: exit code 1 " + "x".repeat(100)), + ...padding(8, "after"), + ]; + const compactor = createPruningCompactor({ + keepRecentTurns: 6, + maxAnchorTurns: 8, + summaryMaxChars: 2000, + }); + const { output } = await compactor.apply(turns, mockStrategyCtx); + // The lone error's own turn score (3) sits below the anchor threshold (5), + // so its body must not survive verbatim outside the recent window. + const survivedVerbatim = output.some((t) => + t.content.some((b) => b.type === "tool_result" && b.callId === "e1"), + ); + expect(survivedVerbatim).toBe(false); + }); + + test("two distinct errors on one turn still clear the anchor threshold", async () => { + const turns: ConversationTurn[] = [ + makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }), + ...padding(3, "before"), + makeTurn({ + role: "assistant", + content: [ + { type: "tool_call", id: "d1", name: "run_shell", arguments: {} }, + { type: "tool_call", id: "d2", name: "grep", arguments: {} }, + ], + }), + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: "d1", + content: [{ type: "text", text: "Error: build failed" }], + isError: true, + }, + { + type: "tool_result", + callId: "d2", + content: [{ type: "text", text: "Error: no matches found" }], + isError: true, + }, + ], + }), + ...padding(8, "after"), + ]; + const compactor = createPruningCompactor({ + keepRecentTurns: 6, + maxAnchorTurns: 8, + summaryMaxChars: 2000, + }); + const { output } = await compactor.apply(turns, mockStrategyCtx); + const kept = output.find((t) => + t.content.some((b) => b.type === "tool_result" && b.callId === "d1"), + ); + expect(kept).toBeDefined(); + expect(kept?.content.some((b) => b.type === "tool_result" && b.callId === "d2")).toBe(true); + }); + + test("repeated identical errors collapse to one representative before anchor selection", async () => { + // "old" repeats the same (tool, error-text) signature that recurs again + // later ("recur"); combined with a distinct error on the same turn, the + // uncollapsed score (3 + 3 = 6) would clear the threshold, but the + // collapsed score (0 + 3 = 3) must not. + const sharedErrorText = "Error: type mismatch on line 12, expected string"; + const turns: ConversationTurn[] = [ + makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }), + ...padding(3, "before"), + makeTurn({ + role: "assistant", + content: [ + { type: "tool_call", id: "old", name: "edit_file_check", arguments: {} }, + { type: "tool_call", id: "uniq", name: "grep", arguments: {} }, + ], + }), + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: "old", + content: [{ type: "text", text: sharedErrorText }], + isError: true, + }, + { + type: "tool_result", + callId: "uniq", + content: [{ type: "text", text: "Error: distinct failure here" }], + isError: true, + }, + ], + }), + ...padding(4, "mid"), + assistantErrorCall("recur", "edit_file_check"), + errorResult("recur", sharedErrorText), + ...padding(8, "after"), + ]; + const compactor = createPruningCompactor({ + keepRecentTurns: 6, + maxAnchorTurns: 8, + summaryMaxChars: 2000, + }); + const { output, record } = await compactor.apply(turns, mockStrategyCtx); + expect(record.decisions["repeatedErrorCount"]).toBe(1); + // The combined turn's score drops below threshold once "old" is + // collapsed, so neither of its results survives verbatim. + const oldSurvived = output.some((t) => + t.content.some((b) => b.type === "tool_result" && b.callId === "old"), + ); + const uniqSurvived = output.some((t) => + t.content.some((b) => b.type === "tool_result" && b.callId === "uniq"), + ); + expect(oldSurvived).toBe(false); + expect(uniqSurvived).toBe(false); + }); +}); + +describe("createPruningCompactor — maxAnchorTurns caps pairing pulls (CL-6906)", () => { + test("bounds the total scored-anchor pull even when many high-score pairs are scattered through history", async () => { + const turns: ConversationTurn[] = [ + makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }), + ]; + // 10 edit_file call/result pairs, well separated from each other and from + // the recent window, each independently clearing the anchor threshold. + for (let i = 0; i < 10; i++) { + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "tool_call", + id: `edit${i}`, + name: "edit_file", + arguments: { path: `f${i}.ts` }, + }, + ], + }), + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: `edit${i}`, + content: [{ type: "text", text: `edited f${i}.ts` }], + }, + ], + }), + makeTurn({ role: "assistant", content: [{ type: "text", text: `note ${i}` }] }), + makeTurn({ role: "user", content: [{ type: "text", text: `ask ${i}` }] }), + ); + } + for (let i = 0; i < 6; i++) { + turns.push( + makeTurn({ + role: i % 2 === 0 ? "assistant" : "user", + content: [{ type: "text", text: `recent${i}` }], + }), + ); + } + + const maxAnchorTurns = 4; + const compactor = createPruningCompactor({ + keepRecentTurns: 6, + maxAnchorTurns, + summaryMaxChars: 2000, + }); + const { record } = await compactor.apply(turns, mockStrategyCtx); + // The initiating task (1 turn, no partners) is kept outside the cap; the + // scored/pair-partner pull must stay within maxAnchorTurns. + const anchorTurnCount = record.decisions["anchorTurnCount"] as number; + expect(anchorTurnCount - 1).toBeLessThanOrEqual(maxAnchorTurns); + // With a budget of 4 and each edit pair costing 2 (call + result), exactly + // two pairs (the most recent two) fit; a third would overshoot and must + // be rejected as a whole, not split. + expect(anchorTurnCount).toBe(1 + 4); + }); +}); + +describe("createPruningCompactor — summarize receives the workflow context (CL-6906)", () => { + test("passes cfg.summaryContext() through to summarize as the second argument", async () => { + let capturedCtx: unknown = "not called"; + const workflowCtx = { workflow: { name: "build", stepIndex: 2, total: 7 } }; + const compactor = createPruningCompactor({ + keepRecentTurns: 1, + summaryMaxChars: 500, + summaryContext: () => workflowCtx, + summarize: async (_turns, ctx) => { + capturedCtx = ctx; + return "summary text"; + }, + }); + const turns: ConversationTurn[] = [ + makeTurn({ role: "assistant", content: [{ type: "text", text: "a" }] }), + makeTurn({ role: "assistant", content: [{ type: "text", text: "b" }] }), + makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), + ]; + await compactor.apply(turns, mockStrategyCtx); + expect(capturedCtx).toBe(workflowCtx); + }); +}); + describe("buildContextEnvelope", () => { test("includes active task label", () => { const result = buildContextEnvelope({ @@ -344,7 +613,9 @@ describe("formatPlan", () => { { file: "src/bar.ts", action: "read", reason: "Verify the fix" }, ]; const result = formatPlan(steps); - expect(result).toBe("1. src/foo.ts — edit (Fix the bug)\n2. src/bar.ts — read (Verify the fix)"); + expect(result).toBe( + "1. src/foo.ts — edit (Fix the bug)\n2. src/bar.ts — read (Verify the fix)", + ); }); }); @@ -477,7 +748,11 @@ describe("buildTurnSummary via createPruningCompactor", () => { makeTurn({ role: "user", content: [ - { type: "tool_result", callId: "c1", content: [{ type: "text", text: "file contents here" }] }, + { + type: "tool_result", + callId: "c1", + content: [{ type: "text", text: "file contents here" }], + }, ], }), makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), diff --git a/src/session/compactor.ts b/src/session/compactor.ts index df056c33d..3ee87f3a5 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -12,8 +12,15 @@ // The persisted run history is always kept complete in the context store. // Only the inference-facing context is curated here. -import type { ConversationTurn, Compactor, StrategyContext, StrategyResult, StrategyBlob } from "@intx/types/runtime"; +import type { + ConversationTurn, + Compactor, + StrategyContext, + StrategyResult, + StrategyBlob, +} from "@intx/types/runtime"; import { ageImageBlocks } from "./attachment-store.js"; +import type { SummaryContext } from "./summarizer.js"; // --------------------------------------------------------------------------- // Task boundary decision @@ -69,11 +76,7 @@ export async function classifyTaskBoundary( } // Tier 1: continuation signals (short follow-ups, answers to questions) - if ( - trimmed.length < 40 && - metadata.turnCount > 0 && - metadata.currentTaskLabel !== undefined - ) { + if (trimmed.length < 40 && metadata.turnCount > 0 && metadata.currentTaskLabel !== undefined) { // Short messages on an established task are almost certainly continuations. return { kind: "same_task", reason: "short continuation message" }; } @@ -170,22 +173,12 @@ export function buildContextEnvelope(envelope: ContextEnvelope): string { sections.push(`Recent turns shown: ${envelope.recentTurns}`); - if ( - envelope.fileReferences !== undefined && - envelope.fileReferences.length > 0 - ) { - sections.push( - `Files referenced: ${envelope.fileReferences.join(", ")}`, - ); + if (envelope.fileReferences !== undefined && envelope.fileReferences.length > 0) { + sections.push(`Files referenced: ${envelope.fileReferences.join(", ")}`); } - if ( - envelope.unresolvedErrors !== undefined && - envelope.unresolvedErrors.length > 0 - ) { - sections.push( - `Unresolved errors:\n${envelope.unresolvedErrors.join("\n")}`, - ); + if (envelope.unresolvedErrors !== undefined && envelope.unresolvedErrors.length > 0) { + sections.push(`Unresolved errors:\n${envelope.unresolvedErrors.join("\n")}`); } sections.push("---"); @@ -199,10 +192,16 @@ export function buildContextEnvelope(envelope: ContextEnvelope): string { export type CompactorConfig = { keepRecentTurns: number; summaryMaxChars: number; - summarize?: (turns: ConversationTurn[]) => Promise; - // Max older turns to pull forward as anchors (file edits, task updates, - // errors) before the summary stub. Pulled from the end of the older set - // so the most-recent anchors survive. + summarize?: (turns: ConversationTurn[], ctx?: SummaryContext) => Promise; + /** + * Read at compaction time and passed to `summarize` so the summary can + * carry live workflow state (which workflow/step was active when the + * compacted turns were dropped). + */ + summaryContext?: () => SummaryContext | undefined; + // Max older turns to pull forward as anchors (file edits, task updates) + // 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; }; @@ -231,6 +230,18 @@ const ANCHOR_SCORE_THRESHOLD = 5; // Tool names whose results are path-keyed for re-read dedup during compaction. const READ_TOOLS = new Set(["read_file"]); +// Replayable query tools deduped by full-argument identity: a later identical +// grep/search_files/list_dir call reflects newer workspace state, so an older +// identical result is stale the same way an older read_file body is. +// run_shell is deliberately excluded — the same command is not idempotent +// (builds, tests, mutations), so an older run_shell result can be the only +// record of a genuinely distinct outcome. +const QUERY_TOOLS = new Set(["grep", "search_files", "list_dir"]); + +function isReplayableResultTool(name: string): boolean { + return READ_TOOLS.has(name) || QUERY_TOOLS.has(name); +} + // Call-id index for stub rendering (name + path). Dedup keys live on `readKey`. type ToolCallInfo = { name: string; @@ -262,9 +273,7 @@ function scalarArg(value: unknown): string { * Identity is path alone for full-file reads; path+offset+limit when either * range arg is present so partial reads don't supersede each other. */ -function readIdentityFromArguments( - raw: unknown, -): { path: string; readKey: string } | undefined { +function readIdentityFromArguments(raw: unknown): { path: string; readKey: string } | undefined { let args: unknown = raw ?? {}; if (typeof args === "string") { try { @@ -280,12 +289,41 @@ function readIdentityFromArguments( const offsetPart = scalarArg(rec["offset"]); const limitPart = scalarArg(rec["limit"]); const readKey = - offsetPart === "" && limitPart === "" - ? path - : `${path}\0${offsetPart}\0${limitPart}`; + offsetPart === "" && limitPart === "" ? path : `${path}\0${offsetPart}\0${limitPart}`; return { path, readKey }; } +// Deterministic key for structurally equal arguments regardless of key order. +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value !== null && typeof value === "object") { + const rec = value as Record; + const entries = Object.keys(rec) + .sort() + .map((k) => `${JSON.stringify(k)}:${stableStringify(rec[k])}`); + return `{${entries.join(",")}}`; + } + const scalar = JSON.stringify(value); + return scalar === undefined ? "undefined" : scalar; +} + +/** + * Dedup identity for a query tool call: tool name + canonicalized arguments. + * Only byte-identical (modulo key order) calls share a key, so a grep for a + * different pattern or a list of a different directory never supersedes. + */ +function queryIdentityFromArguments(name: string, raw: unknown): string | undefined { + let args: unknown = raw ?? {}; + if (typeof args === "string") { + try { + args = JSON.parse(args) as unknown; + } catch { + return undefined; + } + } + return `${name}\0${stableStringify(args)}`; +} + // callId → tool name/path for readable stubs. Inverse of path-to-reads. function buildCallIndex(turns: readonly ConversationTurn[]): Map { const index = new Map(); @@ -298,6 +336,10 @@ function buildCallIndex(turns: readonly ConversationTurn[]): Map): Set { const superseded = new Set(); @@ -379,30 +423,143 @@ function buildPairIndex(turns: ConversationTurn[]): Map { return pairs; } -// Score a turn by its anchor importance. Turns that write files, update -// tasks, or contain errors are load-bearing regardless of age. -function anchorScore(turn: ConversationTurn): number { +// Errored results score BELOW the anchor threshold on purpose: a lone failure +// is context for the summary, not an anchor. Scoring errors at or above the +// threshold preserved every iteration of a failing-edit retry loop verbatim +// past the summary boundary, crowding the kept context with the loop while +// the substance was summarized away. Two distinct errors on one turn still +// clear the threshold. +const ERRORED_RESULT_SCORE = 3; + +// Whitespace-collapsed error-text prefix length compared when deciding two +// errored results are the same failure repeating. Long enough to separate +// distinct errors, short enough that trailing variable detail (line numbers, +// retry counters) does not defeat the collapse. +const ERROR_SIGNATURE_PREFIX_CHARS = 120; + +type ToolResultBlock = Extract; + +function erroredResultSignature( + block: ToolResultBlock, + callIndex: ReadonlyMap, +): string { + const info = callIndex.get(block.callId); + const name = info === undefined ? "" : info.name; + const text = block.content + .flatMap((c) => (c.type === "text" ? [c.text] : [])) + .join("") + .replace(/\s+/g, " ") + .slice(0, ERROR_SIGNATURE_PREFIX_CHARS); + return `${name}\0${text}`; +} + +/** + * Call ids of errored results whose (tool name, error-text prefix) signature + * recurs on a later turn in the same set. Every occurrence but the last is + * returned, collapsing a retry loop's repeats to one representative — the + * most recent failure, which is the state the agent must resume from. + */ +function repeatedErroredResultCallIds( + turns: readonly ConversationTurn[], + callIndex: ReadonlyMap, +): Set { + const lastSeen = new Map(); + const repeated = new Set(); + for (const turn of turns) { + for (const block of turn.content) { + if (block.type !== "tool_result" || block.isError !== true) continue; + const signature = erroredResultSignature(block, callIndex); + const previous = lastSeen.get(signature); + if (previous !== undefined) repeated.add(previous); + lastSeen.set(signature, block.callId); + } + } + return repeated; +} + +// Score a turn by its anchor importance. Turns that write files or update +// tasks are load-bearing regardless of age. Errored results whose failure +// signature repeats later contribute nothing — only the last occurrence of a +// recurring error counts (see repeatedErroredResultCallIds). +function anchorScore(turn: ConversationTurn, suppressedErrorCallIds: ReadonlySet): number { let score = 0; for (const block of turn.content) { if (block.type === "tool_call") { if (block.name === "edit_file" || block.name === "write_file") score += 10; else if (block.name === "manage_tasks") score += 7; } - if (block.type === "tool_result" && block.isError === true) score += 5; + if ( + block.type === "tool_result" && + block.isError === true && + !suppressedErrorCallIds.has(block.callId) + ) { + score += ERRORED_RESULT_SCORE; + } } return score; } +// Turn index → pair-partner turn indices, derived from the pair index, so +// closure walks touch each pair once instead of rescanning all pairs per step. +function buildPartnerIndex(pairs: ReadonlyMap): Map { + const partners = new Map(); + const link = (a: number, b: number): void => { + const list = partners.get(a); + if (list === undefined) partners.set(a, [b]); + else list.push(b); + }; + for (const { callIdx, resultIdx } of pairs.values()) { + if (callIdx === undefined || resultIdx === undefined || callIdx === resultIdx) continue; + link(callIdx, resultIdx); + link(resultIdx, callIdx); + } + return partners; +} + +/** + * Older-region turn indices a candidate anchor drags along: itself plus its + * tool_call/tool_result partners, transitively, minus turns already kept + * (recent window or previously anchored). Selecting anchors closure-at-a-time + * is what lets maxAnchorTurns bound the total pull: a pair is either taken + * whole or not at all, so no partner ever needs an over-budget rescue. + */ +function pairClosure( + start: number, + partnerIndex: ReadonlyMap, + keepFrom: number, + kept: ReadonlySet, +): Set { + const closure = new Set(); + const queue = [start]; + while (queue.length > 0) { + const idx = queue.pop(); + if (idx === undefined || idx >= keepFrom || kept.has(idx) || closure.has(idx)) continue; + closure.add(idx); + const partners = partnerIndex.get(idx); + if (partners !== undefined) queue.push(...partners); + } + return closure; +} + +function addPairClosure( + start: number, + partnerIndex: ReadonlyMap, + keepFrom: number, + kept: Set, +): void { + for (const idx of pairClosure(start, partnerIndex, keepFrom, kept)) kept.add(idx); +} + // Index of the first turn carrying the user's own words. This is the // initiating task; it must survive compaction so the agent never loses what // it was asked to do, even when it falls far outside the recent window. function firstUserTurnIndex(turns: ConversationTurn[]): number { - return turns.findIndex( - (t) => t.role === "user" && t.content.some((b) => b.type === "text"), - ); + return turns.findIndex((t) => t.role === "user" && t.content.some((b) => b.type === "text")); } -function resultContentSize(block: Extract): number { +function resultContentSize( + block: Extract, +): number { return block.content.reduce((sum, c) => sum + (c.type === "text" ? c.text.length : 0), 0); } @@ -415,10 +572,9 @@ function buildResultStub( const size = resultContentSize(block); if (info?.pathArg !== undefined) { const path = info.pathArg; - const spillHint = - path.startsWith("tool-output://") - ? " Re-read with read_file offset/limit or grep on that URI." - : ""; + const spillHint = path.startsWith("tool-output://") + ? " Re-read with read_file offset/limit or grep on that URI." + : ""; return `[${name} ${path} — ${size} chars omitted from context; source unchanged.${spillHint}]`; } return `[${name} — ${size} chars, omitted]`; @@ -523,14 +679,12 @@ function coalesceAdjacentTextTurns(turns: ConversationTurn[]): ConversationTurn[ return out; } -export function createPruningCompactor( - config: Partial = {}, -): Compactor { +export function createPruningCompactor(config: Partial = {}): Compactor { const cfg = { ...DEFAULT_COMPACTOR_CONFIG, ...config }; return { name: "pruning-compactor", - version: "1.3.1", + version: "1.4.0", async apply( turns: ConversationTurn[], _ctx: StrategyContext, @@ -565,36 +719,55 @@ export function createPruningCompactor( const recentTurns = aged.turns.slice(keepFrom); const olderTurns = aged.turns.slice(0, keepFrom); - // Pull high-importance turns forward regardless of age. Take from the - // tail of the older set so the most recent anchors survive. - const scoredOlder = olderTurns.map((t, i) => ({ turn: t, index: i, score: anchorScore(t) })); - const anchorIndices = new Set( - scoredOlder - .filter(({ score }) => score >= ANCHOR_SCORE_THRESHOLD) - .slice(-cfg.maxAnchorTurns) - .map(({ index }) => index), - ); + const pairs = buildPairIndex(aged.turns); + const partnerIndex = buildPartnerIndex(pairs); - // Always keep the initiating task verbatim, outside the maxAnchorTurns - // cap. Losing the oldest user turn is how the agent forgets what it was - // asked to do; correctness outranks the size target here. - const initiatingIdx = firstUserTurnIndex(olderTurns); - if (initiatingIdx >= 0) anchorIndices.add(initiatingIdx); + // 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, callIndex); + const scoredOlder = olderTurns.map((t, i) => ({ + index: i, + score: anchorScore(t, repeatedErrors), + })); // Keep tool_call/tool_result pairs together across the keep/summarize - // boundary. A turn that survives (anchored, or in the recent window) whose - // partner would be summarized leaves a dangling tool_call or an orphaned - // tool_result, which the inference layer rejects. Pull the older partner - // forward as an anchor so the surviving sequence stays well-formed. - // Pairing wins over maxAnchorTurns: correctness outranks the size target. - const pairs = buildPairIndex(aged.turns); - const isKept = (idx: number): boolean => idx >= keepFrom || anchorIndices.has(idx); + // 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. + const anchorIndices = new Set(); for (const { callIdx, resultIdx } of pairs.values()) { if (callIdx === undefined || resultIdx === undefined) continue; - if (isKept(callIdx) && !isKept(resultIdx) && resultIdx < keepFrom) anchorIndices.add(resultIdx); - else if (isKept(resultIdx) && !isKept(callIdx) && callIdx < keepFrom) anchorIndices.add(callIdx); + 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 + // its pair partners, whole closure or not at all, and only while the + // combined pull stays within maxAnchorTurns. + let anchorBudget = Math.max(0, cfg.maxAnchorTurns - anchorIndices.size); + for (let i = scoredOlder.length - 1; i >= 0; i--) { + const candidate = scoredOlder[i]; + if (candidate === undefined) continue; + if (candidate.score < ANCHOR_SCORE_THRESHOLD || anchorIndices.has(candidate.index)) + continue; + const closure = pairClosure(candidate.index, partnerIndex, keepFrom, anchorIndices); + if (closure.size > anchorBudget) continue; + for (const idx of closure) anchorIndices.add(idx); + anchorBudget -= closure.size; } + // Always keep the initiating task verbatim, outside the maxAnchorTurns + // cap. Losing the oldest user turn is how the agent forgets what it was + // asked to do; correctness outranks the size target here. + const initiatingIdx = firstUserTurnIndex(olderTurns); + if (initiatingIdx >= 0) addPairClosure(initiatingIdx, partnerIndex, keepFrom, anchorIndices); + // Ascending original order keeps the concatenated [anchors, recent] // sequence globally index-ordered, so every result still follows its call. const sortedAnchorIndices = [...anchorIndices].sort((a, b) => a - b); @@ -607,9 +780,10 @@ export function createPruningCompactor( const pathToReads = buildPathToReads([...anchorTurns, ...recentTurns], callIndex); const supersededReads = supersededReadCallIds(pathToReads); - const summary = cfg.summarize !== undefined - ? await cfg.summarize(summarizedTurns) - : buildTurnSummary(summarizedTurns, cfg.summaryMaxChars, anchorTurns.length); + const summary = + cfg.summarize !== undefined + ? await cfg.summarize(summarizedTurns, cfg.summaryContext?.()) + : buildTurnSummary(summarizedTurns, cfg.summaryMaxChars, anchorTurns.length); // A user-role turn survives every adapter unchanged. A system-role turn // does not: the Anthropic builder drops mid-conversation system turns @@ -654,6 +828,7 @@ export function createPruningCompactor( summaryLength: summary.length, agedImageCount: aged.agedImageCount, supersededReadCount: supersededReads.size, + repeatedErrorCount: repeatedErrors.size, }, }, ...(aged.blobs.length > 0 ? { blobs: aged.blobs } : {}), @@ -786,8 +961,6 @@ export function formatPlan( steps: Array<{ file: string; action: string; reason?: string }>, ): string { return steps - .map( - (s, i) => `${i + 1}. ${s.file} — ${s.action}${s.reason ? ` (${s.reason})` : ""}`, - ) + .map((s, i) => `${i + 1}. ${s.file} — ${s.action}${s.reason ? ` (${s.reason})` : ""}`) .join("\n"); } diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 3d1f1cd54..59c4af96e 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -9,9 +9,14 @@ import { runInference, type Dependencies } from "@intx/inference"; import { createDefaultDependencies } from "@intx/inference/providers"; +import { getLogger } from "@intx/log"; import type { ConversationTurn, InferenceSource } from "@intx/types/runtime"; +import { LOG_NAMESPACE_ROOT } from "../branding.js"; +import { detectRepetition } from "../subagent/repetition.js"; import { buildTurnSummary } from "./compactor.js"; +const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]); + // What the agent was doing when compaction fired. Lets the summary preserve // the workflow contract ("we are at step 3/7 of /build") rather than dropping // it into the compacted region. @@ -70,7 +75,13 @@ export function condenseTurns(turns: ConversationTurn[]): string { if (turn.role === "user") { userMessages.push(block.text.slice(0, 400)); } else if (turn.role === "assistant" && block.text.length > 0) { - assistantSnippets.push(block.text.slice(0, 300)); + // Compaction often fires mid-degeneration, when the tail of the + // history is the model looping one phrase. Seeding the summary from + // those turns hands the looped text to the summarizer verbatim, so + // repetition-flagged turns are dropped from the excerpt entirely. + if (detectRepetition(block.text) === null) { + assistantSnippets.push(block.text.slice(0, 300)); + } } } if (block.type === "tool_call") { @@ -87,8 +98,18 @@ export function condenseTurns(turns: ConversationTurn[]): string { const sections: Array = [ `Turns dropped: ${turns.length}`, toolNames.size > 0 ? `Tools used: ${[...toolNames].sort().join(", ")}` : null, - files.size > 0 ? `Files touched:\n${[...files].slice(0, 40).map((f) => `- ${f}`).join("\n")}` : null, - links.size > 0 ? `Links/identifiers:\n${[...links].slice(0, 30).map((l) => `- ${l}`).join("\n")}` : null, + files.size > 0 + ? `Files touched:\n${[...files] + .slice(0, 40) + .map((f) => `- ${f}`) + .join("\n")}` + : null, + links.size > 0 + ? `Links/identifiers:\n${[...links] + .slice(0, 30) + .map((l) => `- ${l}`) + .join("\n")}` + : null, userMessages.length > 0 ? `User messages (most recent last):\n${userMessages.slice(-6).join("\n---\n")}` : null, @@ -119,10 +140,7 @@ function workflowPreamble(ctx: SummaryContext | undefined): string { } /** Build the user-content prompt for the summary call. Pure and testable. */ -export function buildSummaryPrompt( - turns: ConversationTurn[], - ctx?: SummaryContext, -): string { +export function buildSummaryPrompt(turns: ConversationTurn[], ctx?: SummaryContext): string { return `${workflowPreamble(ctx)}Session excerpt:\n\n${condenseTurns(turns)}`; } @@ -183,18 +201,35 @@ export function createModelSummarizer( const maxChars = options.maxChars ?? 4000; return async (turns, ctx) => { - const fallback = (): string => buildTurnSummary(turns, maxChars); + // The marker tells the model (and anyone reading a transcript) that the + // compacted region is a lossy stats stub, not a real handoff summary. + const fallback = (reason: string): string => + `[Model summary unavailable (${reason}); deterministic fallback]\n${buildTurnSummary(turns, maxChars)}`; try { const promptTurns: ConversationTurn[] = [ - { role: "system", content: [{ type: "text", text: SYSTEM_INSTRUCTION }], timestamp: turns[0]?.timestamp ?? 0 }, - { role: "user", content: [{ type: "text", text: buildSummaryPrompt(turns, ctx) }], timestamp: 0 }, + { + role: "system", + content: [{ type: "text", text: SYSTEM_INSTRUCTION }], + timestamp: turns[0]?.timestamp ?? 0, + }, + { + role: "user", + content: [{ type: "text", text: buildSummaryPrompt(turns, ctx) }], + timestamp: 0, + }, ]; const signal = options.getSignal?.() ?? new AbortController().signal; const text = await complete(promptTurns, options.getSource(), signal); - if (text.length === 0) return fallback(); + if (text.length === 0) { + logger.warn("compaction summary call returned empty text; using deterministic fallback"); + return fallback("empty model output"); + } return text.length > maxChars ? text.slice(0, maxChars) : text; - } catch { - return fallback(); + } catch (error) { + logger.warn("compaction summary call failed; using deterministic fallback: {error}", { + error: error instanceof Error ? error.message : String(error), + }); + return fallback("summary call failed"); } }; } diff --git a/tests/unit/compactor-pairing.test.ts b/tests/unit/compactor-pairing.test.ts index 62bf76bdf..f4bb1b73a 100644 --- a/tests/unit/compactor-pairing.test.ts +++ b/tests/unit/compactor-pairing.test.ts @@ -8,10 +8,25 @@ import { assertWellFormedToolSequence } from "@intx/inference"; // the compaction boundary. The compactor must keep pairs together; otherwise the // inference layer rejects the compacted prompt (dangling call / orphan result). function assistantCall(id: string, name = "read_file"): ConversationTurn { - return { role: "assistant", content: [{ type: "tool_call", id, name, arguments: { path: `f${id}.ts` } }], timestamp: 1 }; + return { + role: "assistant", + content: [{ type: "tool_call", id, name, arguments: { path: `f${id}.ts` } }], + timestamp: 1, + }; } function userResult(callId: string, isError = false): ConversationTurn { - return { role: "user", content: [{ type: "tool_result", callId, content: [{ type: "text", text: "x".repeat(50) }], ...(isError ? { isError: true } : {}) }], timestamp: 1 }; + return { + role: "user", + content: [ + { + type: "tool_result", + callId, + content: [{ type: "text", text: "x".repeat(50) }], + ...(isError ? { isError: true } : {}), + }, + ], + timestamp: 1, + }; } function userText(text: string): ConversationTurn { return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; @@ -20,10 +35,16 @@ function userText(text: string): ConversationTurn { describe("pruning compactor preserves tool_call/tool_result pairing", () => { test("does not orphan a tool_result at the recent-window boundary", async () => { const turns: ConversationTurn[] = [ - userText("start"), userText("a"), userText("b"), - assistantCall("c1"), // index 3 -> would be summarized - userResult("c1"), // index 4 -> recent window head - userText("c"), userText("d"), userText("e"), userText("f"), userText("g"), + userText("start"), + userText("a"), + userText("b"), + assistantCall("c1"), // index 3 -> would be summarized + userResult("c1"), // index 4 -> recent window head + userText("c"), + userText("d"), + userText("e"), + userText("f"), + userText("g"), ]; const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); const { output } = await compactor.apply(turns, {} as never); @@ -33,10 +54,15 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { test("does not reorder an anchored error result ahead of its call", async () => { const turns: ConversationTurn[] = [ userText("start"), - assistantCall("c1"), // index 1, score 0 -> would be summarized - userResult("c1", true), // index 2, score 5 -> anchored - userText("a"), userText("b"), userText("c"), userText("d"), - userText("e"), userText("f"), userText("g"), + assistantCall("c1"), // index 1, score 0 -> would be summarized + userResult("c1", true), // index 2, score 5 -> anchored + userText("a"), + userText("b"), + userText("c"), + userText("d"), + userText("e"), + userText("f"), + userText("g"), ]; const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); const { output } = await compactor.apply(turns, {} as never); @@ -46,14 +72,32 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { test("keeps tool_result content in a recent-window turn across a pruning pass", async () => { const editResult: ConversationTurn = { role: "user", - content: [{ type: "tool_result", callId: "edit1", content: [{ type: "text", text: "diff applied to file.ts" }] }], + content: [ + { + type: "tool_result", + callId: "edit1", + content: [{ type: "text", text: "diff applied to file.ts" }], + }, + ], timestamp: 1, }; const turns: ConversationTurn[] = [ - userText("start"), userText("a"), userText("b"), userText("c"), userText("d"), - { role: "assistant", content: [{ type: "tool_call", id: "edit1", name: "edit_file", arguments: { path: "file.ts" } }], timestamp: 1 }, + userText("start"), + userText("a"), + userText("b"), + userText("c"), + userText("d"), + { + role: "assistant", + content: [ + { type: "tool_call", id: "edit1", name: "edit_file", arguments: { path: "file.ts" } }, + ], + timestamp: 1, + }, editResult, - userText("e"), userText("f"), userText("g"), + userText("e"), + userText("f"), + userText("g"), ]; const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); const { output } = await compactor.apply(turns, {} as never); @@ -61,21 +105,40 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { t.content.some((b) => b.type === "tool_result" && b.callId === "edit1"), ); const resultBlock = kept?.content.find((b) => b.type === "tool_result" && b.callId === "edit1"); - expect(resultBlock).toMatchObject({ content: [{ type: "text", text: "diff applied to file.ts" }] }); + expect(resultBlock).toMatchObject({ + content: [{ type: "text", text: "diff applied to file.ts" }], + }); }); test("keeps tool_result content in an anchored file-edit turn pulled forward from the discarded middle", async () => { const editResult: ConversationTurn = { role: "user", - content: [{ type: "tool_result", callId: "edit1", content: [{ type: "text", text: "diff applied to file.ts" }] }], + content: [ + { + type: "tool_result", + callId: "edit1", + content: [{ type: "text", text: "diff applied to file.ts" }], + }, + ], timestamp: 1, }; const turns: ConversationTurn[] = [ userText("start"), - { role: "assistant", content: [{ type: "tool_call", id: "edit1", name: "edit_file", arguments: { path: "file.ts" } }], timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "tool_call", id: "edit1", name: "edit_file", arguments: { path: "file.ts" } }, + ], + timestamp: 1, + }, editResult, - userText("a"), userText("b"), userText("c"), userText("d"), - userText("e"), userText("f"), userText("g"), + userText("a"), + userText("b"), + userText("c"), + userText("d"), + userText("e"), + userText("f"), + userText("g"), ]; const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); const { output } = await compactor.apply(turns, {} as never); @@ -83,13 +146,21 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { t.content.some((b) => b.type === "tool_result" && b.callId === "edit1"), ); const resultBlock = kept?.content.find((b) => b.type === "tool_result" && b.callId === "edit1"); - expect(resultBlock).toMatchObject({ content: [{ type: "text", text: "diff applied to file.ts" }] }); + expect(resultBlock).toMatchObject({ + content: [{ type: "text", text: "diff applied to file.ts" }], + }); }); test("buildTurnSummary counts large tool_result payloads", () => { const big: ConversationTurn = { role: "user", - content: [{ type: "tool_result", callId: "c1", content: [{ type: "text", text: "x".repeat(100_000) }] }], + content: [ + { + type: "tool_result", + callId: "c1", + content: [{ type: "text", text: "x".repeat(100_000) }], + }, + ], timestamp: 1, }; const summary = buildTurnSummary([big], 2000, 0); @@ -293,3 +364,131 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { expect(older).toMatch(/omitted|chars/); }); }); + +// grep/search_files/list_dir are replayable the same way read_file is: an +// identical later call reflects newer workspace state, so an older identical +// result is stubbed the same way an older full-file read is (CL-6906). +function assistantQuery(id: string, name: string, args: Record): ConversationTurn { + return { + role: "assistant", + content: [{ type: "tool_call", id, name, arguments: args }], + timestamp: 1, + }; +} + +describe("pruning compactor extends superseded-result stubbing to query tools (CL-6906)", () => { + test("stubs an older successful grep call repeated with byte-identical arguments", async () => { + const oldBody = "OLD_MATCHES_" + "a".repeat(200); + const newBody = "NEW_MATCHES_" + "b".repeat(200); + const args = { pattern: "TODO", path: "src" }; + const turns: ConversationTurn[] = [ + userText("start"), + userText("a"), + userText("b"), + userText("c"), + assistantQuery("g1", "grep", args), + userReadResult("g1", oldBody), + assistantQuery("g2", "grep", args), + userReadResult("g2", newBody), + userText("d"), + userText("e"), + ]; + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); + const { output } = await compactor.apply(turns, {} as never); + const older = resultText(output, "g1"); + expect(resultText(output, "g2")).toBe(newBody); + expect(older).toBeDefined(); + expect(older).not.toBe(oldBody); + expect(older).toMatch(/omitted|chars/); + }); + + test("stubs an older successful search_files call with argument key order irrelevant", async () => { + const oldBody = "OLD_SEARCH_" + "a".repeat(200); + const newBody = "NEW_SEARCH_" + "b".repeat(200); + const turns: ConversationTurn[] = [ + userText("start"), + userText("a"), + userText("b"), + userText("c"), + assistantQuery("s1", "search_files", { query: "widget", limit: 20 }), + userReadResult("s1", oldBody), + // Same arguments, different key order — must still be treated as identical. + assistantQuery("s2", "search_files", { limit: 20, query: "widget" }), + userReadResult("s2", newBody), + userText("d"), + userText("e"), + ]; + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); + const { output } = await compactor.apply(turns, {} as never); + expect(resultText(output, "s2")).toBe(newBody); + expect(resultText(output, "s1")).not.toBe(oldBody); + }); + + test("stubs an older successful list_dir call repeated on the same path", async () => { + const oldBody = "OLD_LISTING_" + "a".repeat(200); + const newBody = "NEW_LISTING_" + "b".repeat(200); + const args = { path: "src/components" }; + const turns: ConversationTurn[] = [ + userText("start"), + userText("a"), + userText("b"), + userText("c"), + assistantQuery("l1", "list_dir", args), + userReadResult("l1", oldBody), + assistantQuery("l2", "list_dir", args), + userReadResult("l2", newBody), + userText("d"), + userText("e"), + ]; + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); + const { output } = await compactor.apply(turns, {} as never); + expect(resultText(output, "l2")).toBe(newBody); + expect(resultText(output, "l1")).not.toBe(oldBody); + }); + + test("does not supersede a grep call with different arguments", async () => { + const body1 = "MATCHES_TODO_" + "a".repeat(200); + const body2 = "MATCHES_FIXME_" + "b".repeat(200); + const turns: ConversationTurn[] = [ + userText("start"), + userText("a"), + userText("b"), + userText("c"), + assistantQuery("g1", "grep", { pattern: "TODO", path: "src" }), + userReadResult("g1", body1), + assistantQuery("g2", "grep", { pattern: "FIXME", path: "src" }), + userReadResult("g2", body2), + userText("d"), + userText("e"), + ]; + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); + const { output } = await compactor.apply(turns, {} as never); + expect(resultText(output, "g1")).toBe(body1); + expect(resultText(output, "g2")).toBe(body2); + }); + + test("never supersedes run_shell results, even with byte-identical commands", async () => { + // The same shell command is not idempotent (builds, tests, mutations can + // each produce a genuinely different outcome), so run_shell is excluded + // from replayable-result stubbing entirely. + const oldBody = "OLD_SHELL_OUTPUT_" + "a".repeat(200); + const newBody = "NEW_SHELL_OUTPUT_" + "b".repeat(200); + const args = { command: "npm test" }; + const turns: ConversationTurn[] = [ + userText("start"), + userText("a"), + userText("b"), + userText("c"), + assistantQuery("sh1", "run_shell", args), + userReadResult("sh1", oldBody), + assistantQuery("sh2", "run_shell", args), + userReadResult("sh2", newBody), + userText("d"), + userText("e"), + ]; + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); + const { output } = await compactor.apply(turns, {} as never); + expect(resultText(output, "sh1")).toBe(oldBody); + expect(resultText(output, "sh2")).toBe(newBody); + }); +}); diff --git a/tests/unit/summarizer.test.ts b/tests/unit/summarizer.test.ts index 66a2e8fe4..9de99de6f 100644 --- a/tests/unit/summarizer.test.ts +++ b/tests/unit/summarizer.test.ts @@ -16,7 +16,11 @@ const source: InferenceSource = { function turns(): ConversationTurn[] { return [ - { role: "user", content: [{ type: "text", text: "Fix the login bug, see https://example.com/ticket/42" }], timestamp: 1 }, + { + role: "user", + content: [{ type: "text", text: "Fix the login bug, see https://example.com/ticket/42" }], + timestamp: 1, + }, { role: "assistant", content: [ @@ -28,7 +32,9 @@ function turns(): ConversationTurn[] { }, { role: "assistant", - content: [{ type: "tool_call", id: "c2", name: "edit_file", arguments: { path: "src/session.ts" } }], + content: [ + { type: "tool_call", id: "c2", name: "edit_file", arguments: { path: "src/session.ts" } }, + ], model: "test-model", timestamp: 3, }, @@ -44,6 +50,22 @@ test("condenseTurns extracts files, tools, and links", () => { expect(out).toContain("https://example.com/ticket/42"); }); +test("condenseTurns drops a degenerate repeated assistant tail from the excerpt (CL-6906)", () => { + const loopPhrase = "we need to check whether the cache key already accounts for locale. "; + const loopText = loopPhrase.repeat(10); + const healthyNote = "Looked at src/auth.ts and found the missing null check."; + const degenerateTurns: ConversationTurn[] = [ + { role: "user", content: [{ type: "text", text: "please continue" }], timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: healthyNote }], timestamp: 2 }, + { role: "assistant", content: [{ type: "text", text: loopText }], timestamp: 3 }, + ]; + const out = condenseTurns(degenerateTurns); + // The looping tail is dropped entirely rather than handed to the summarizer. + expect(out).not.toContain("cache key already accounts for locale"); + // A healthy assistant note elsewhere in the same drop still survives. + expect(out).toContain(healthyNote); +}); + test("buildSummaryPrompt injects active workflow context", () => { const prompt = buildSummaryPrompt(turns(), { workflow: { name: "build", stepLabel: "Implement", stepIndex: 2, total: 7 }, @@ -89,3 +111,34 @@ test("model summarizer falls back when the model returns empty text", async () = const result = await summarize(turns()); expect(result).toContain("Tools called"); }); + +test("model summarizer marks a failure fallback as distinguishable from a real summary (CL-6906)", async () => { + const summarize = createModelSummarizer({ + getSource: () => source, + complete: async () => { + throw new Error("model unreachable"); + }, + }); + const result = await summarize(turns()); + expect(result).toContain("[Model summary unavailable"); + expect(result).toContain("summary call failed"); +}); + +test("model summarizer marks an empty-output fallback as distinguishable from a real summary (CL-6906)", async () => { + const summarize = createModelSummarizer({ + getSource: () => source, + complete: async () => "", + }); + const result = await summarize(turns()); + expect(result).toContain("[Model summary unavailable"); + expect(result).toContain("empty model output"); +}); + +test("model summarizer does not mark a real summary with the fallback marker", async () => { + const summarize = createModelSummarizer({ + getSource: () => source, + complete: async () => "## What Happened\n- read src/auth.ts", + }); + const result = await summarize(turns()); + expect(result).not.toContain("[Model summary unavailable"); +}); From 8d24314bf33d948c295d0651112b7ef3f66fd128 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 22:10:52 -0700 Subject: [PATCH 2/3] Pass workflow context through the session compaction path The TUI wrapped summarize to inject the active workflow, but the pruning compactor always called summarize with one argument, so mid-workflow compaction still lost the step contract. Also make the max-anchor fixture score above the new threshold so the cap test actually exercises it. --- src/context-compactor.test.ts | 25 ++++++++++++++++------ src/session/runtime-assembly.test.ts | 22 +++++++++++++++++++ src/session/runtime-assembly.ts | 5 ++++- src/tui/runner.ts | 32 +++++++++++++--------------- 4 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 72eb2cfa3..ab39e0a30 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -485,8 +485,10 @@ describe("createPruningCompactor — maxAnchorTurns caps pairing pulls (CL-6906) const turns: ConversationTurn[] = [ makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }), ]; - // 10 edit_file call/result pairs, well separated from each other and from - // the recent window, each independently clearing the anchor threshold. + // 10 write-pair call/result turns, well separated from each other and from + // the recent window. A single edit_file scores 3 (below the threshold of + // 5); two writes on the same assistant turn score 6, so each pair + // independently clears the scored-anchor bar. for (let i = 0; i < 10; i++) { turns.push( makeTurn({ @@ -494,9 +496,15 @@ describe("createPruningCompactor — maxAnchorTurns caps pairing pulls (CL-6906) content: [ { type: "tool_call", - id: `edit${i}`, + id: `edit${i}a`, name: "edit_file", - arguments: { path: `f${i}.ts` }, + arguments: { path: `f${i}a.ts` }, + }, + { + type: "tool_call", + id: `edit${i}b`, + name: "edit_file", + arguments: { path: `f${i}b.ts` }, }, ], }), @@ -505,8 +513,13 @@ describe("createPruningCompactor — maxAnchorTurns caps pairing pulls (CL-6906) content: [ { type: "tool_result", - callId: `edit${i}`, - content: [{ type: "text", text: `edited f${i}.ts` }], + callId: `edit${i}a`, + content: [{ type: "text", text: `edited f${i}a.ts` }], + }, + { + type: "tool_result", + callId: `edit${i}b`, + content: [{ type: "text", text: `edited f${i}b.ts` }], }, ], }), diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 6ab85b84a..100112315 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -267,4 +267,26 @@ describe("createSessionPruningCompactor", () => { expect(typeof pruning.apply).toBe("function"); expect(typeof llm.apply).toBe("function"); }); + + test("forwards summaryContext to summarize in llm mode", async () => { + const ctx = { workflow: { name: "build", stepIndex: 1, total: 3 } }; + let captured: unknown; + const summarize = async (_turns: unknown, c?: unknown) => { + captured = c; + return "summary"; + }; + const llm = createSessionPruningCompactor({ + compactionMode: "llm", + summarize, + summaryContext: () => ctx, + }); + const now = Date.now(); + const turns = Array.from({ length: 8 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: `t${i}` }], + timestamp: now, + })); + await llm.apply(turns as never, { state: {} as never, trigger: "test" }); + expect(captured).toBe(ctx); + }); }); diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index bc20634ce..d604d0ddc 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -41,6 +41,7 @@ import type { Approval, GrantScope } from "../permission/types.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import type { SubAgentProvider } from "../subagent/index.js"; import { COMPACTOR_KEEP_RECENT_TURNS, createPruningCompactor } from "./compactor.js"; +import type { SummaryContext } from "./summarizer.js"; import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js"; // --------------------------------------------------------------------------- @@ -274,7 +275,8 @@ const SESSION_COMPACTOR_SUMMARY_MAX_CHARS = 2500; export type SessionPruningCompactorArgs = { compactionMode: "llm" | "pruning"; - summarize: (turns: ConversationTurn[]) => Promise; + summarize: (turns: ConversationTurn[], ctx?: SummaryContext) => Promise; + summaryContext?: () => SummaryContext | undefined; telemetry?: Telemetry; }; @@ -286,6 +288,7 @@ export function createSessionPruningCompactor( keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS, ...(args.compactionMode !== "pruning" ? { summarize: args.summarize } : {}), + ...(args.summaryContext ? { summaryContext: args.summaryContext } : {}), }); const telemetry = args.telemetry ?? NOOP_TELEMETRY; return { diff --git a/src/tui/runner.ts b/src/tui/runner.ts index f994d39da..05057f96a 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -221,7 +221,7 @@ import { skillDirsFromEnabledPlugins, } from "../session/runtime-assembly.js"; import { createAttachmentRehydrateTransform } from "../session/attachment-store.js"; -import { createModelSummarizer } from "../session/summarizer.js"; +import { createModelSummarizer, type SummaryContext } from "../session/summarizer.js"; import { COMMAND_NAME, ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js"; import { deliverAgentMessage } from "./deliver-agent-message.js"; @@ -1394,23 +1394,20 @@ export async function runTUI(initialConfig: Config): Promise { // Compaction summarizer: produces a structured, workflow-aware handoff via a // one-shot call on the live model, falling back to the deterministic summary - // on any failure. The workflow context is read at call time so a compaction - // mid-/build or mid-/plan preserves which step we are on. + // on any failure. Workflow state is read at compaction time so a pass + // mid-/build or mid-/plan still names the active step. const compactionSummarize = createModelSummarizer({ getSource: () => liveSource, deps: inferenceDeps }); - const summarizeForCompaction = (turns: Parameters[0]): Promise => { + const summaryContext = (): SummaryContext | undefined => { const status = workflowController.status(); - return compactionSummarize(turns, { - ...(status.active - ? { - workflow: { - ...(status.name !== undefined ? { name: status.name } : {}), - stepLabel: status.label, - stepIndex: status.stepIndex, - total: status.total, - }, - } - : {}), - }); + if (!status.active) return undefined; + return { + workflow: { + ...(status.name !== undefined ? { name: status.name } : {}), + stepLabel: status.label, + stepIndex: status.stepIndex, + total: status.total, + }, + }; }; // Mutable reference so the compaction summarize callback reads the live mode @@ -1441,7 +1438,8 @@ export async function runTUI(initialConfig: Config): Promise { compactors: { "pruning-compactor": createSessionPruningCompactor({ compactionMode: liveCompactionMode, - summarize: summarizeForCompaction, + summarize: compactionSummarize, + summaryContext, telemetry: liveTelemetry, }), }, From 7d7cc2e5c665d188295b9130bce8b4340eba5ee8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 22:25:10 -0700 Subject: [PATCH 3/3] Note compaction quality in Unreleased and retune the loop fixture Main's repetition detector now needs sixteen consecutive repeats, so ten copies of the loop phrase no longer trip it. Twenty copies still flag, and the Unreleased changelog records the compaction-quality pass. --- CHANGELOG.md | 8 ++++++++ tests/unit/summarizer.test.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b4dd6c34..25c2d38bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Agent + +- **Compaction keeps scored work, not retry loops.** Errored tool results are no + longer auto-pinned; identical errors collapse to one representative. Anchors + are scored (writes, successful task completions, plan updates) and pair + closures count against `maxAnchorTurns`. The LLM summary is workflow-aware + and skips degenerate assistant text. + ### TUI - **Taller live chain-of-thought preview.** Parent reasoning still paints diff --git a/tests/unit/summarizer.test.ts b/tests/unit/summarizer.test.ts index 9de99de6f..9e9b18f26 100644 --- a/tests/unit/summarizer.test.ts +++ b/tests/unit/summarizer.test.ts @@ -51,8 +51,11 @@ test("condenseTurns extracts files, tools, and links", () => { }); test("condenseTurns drops a degenerate repeated assistant tail from the excerpt (CL-6906)", () => { + // The live detector needs 16 consecutive repeats of an 8+ char window + // (DEFAULT_REPETITION_CONFIG). Ten copies of this phrase was enough + // before that raise; twenty still trips it after. const loopPhrase = "we need to check whether the cache key already accounts for locale. "; - const loopText = loopPhrase.repeat(10); + const loopText = loopPhrase.repeat(20); const healthyNote = "Looked at src/auth.ts and found the missing null check."; const degenerateTurns: ConversationTurn[] = [ { role: "user", content: [{ type: "text", text: "please continue" }], timestamp: 1 },