From 6ab04465c0961615fc31a75037df86ea74c8309a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 6 Oct 2026 14:06:24 -0700 Subject: [PATCH 1/2] fix(compaction): keep thinking on the last live tool-use assistant --- docs/ARCHITECTURE.md | 2 +- src/context-compactor.test.ts | 204 ++++++++++++++++++++++++++++++++-- src/session/compactor.ts | 36 ++++-- 3 files changed, 223 insertions(+), 19 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 236e9b64a..d31ed9547 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -201,7 +201,7 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h #### Context compaction (the compaction governor) -When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The live tail drops `thinking` and `redacted_thinking` blocks so they are not billed against occupancy; full thinking stays in the archived transcript. The governor covers five cases: +When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The live tail drops `thinking` and `redacted_thinking` blocks except on the last live assistant that still has a `tool_call`; full thinking stays in the archived transcript. The governor covers five cases: - **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After every compact (threshold, operator, overflow), the governor snapshots post-compact usage (`tokensAtLastCompact`) and keeps that growth latch. It does not re-arm the proactive path on growth alone; re-arm requires a wide resume gap (~20% of the window, a full warning band) past that snapshot. Usage back at or under the 60% threshold restores the consecutive-compact cap and the overflow-recovery budget; it does not drop the growth latch. A fold that landed well under (snapshot + 20% already past the 60% trigger) still re-arms at the next 60% crossing because the gap predicate is already satisfied. A fold that only just got under (a marginal fold, not a deep one) does not sawtooth on the next 60% crossing; it waits until post-compact + 20%. A consecutive-compact cap holds across tool-call occupancy as a second rail. - **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it. diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index a758b5a6b..7a7e15ece 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -1187,7 +1187,7 @@ describe("CL-9489 zero-verbatim fold", () => { ); }); - test("a newest-pair thinking block is dropped from the live tail", async () => { + test("the last live tool-use assistant keeps thinking unmodified", async () => { const thinking = "t".repeat(160_000); const filler = "x".repeat(1_500); const turns: ConversationTurn[] = []; @@ -1241,17 +1241,25 @@ describe("CL-9489 zero-verbatim fold", () => { "Folded dummy pairs. Next: resume the newest tool.", }).apply(turns, mockStrategyCtx); - const liveTypes = result.output.flatMap((t) => - t.content.map((b) => b.type), - ); - expect(liveTypes).not.toContain("thinking"); - expect(liveTypes).not.toContain("redacted_thinking"); - expect(estimateContextTokens(result.output)).toBeLessThan(12_800); - expect( - result.output.some((t) => + const lastToolUse = result.output.find( + (t) => + t.role === "assistant" && t.content.some((b) => b.type === "tool_call" && b.id === "c-newest"), - ), - ).toBe(true); + ); + expect(lastToolUse).toBeDefined(); + expect(lastToolUse?.content).toEqual([ + { + type: "thinking", + thinking, + signature: "sig-thinking-1", + }, + { + type: "tool_call", + id: "c-newest", + name: "read_file", + arguments: { path: "src/app.ts" }, + }, + ]); expect( result.output.some((t) => t.content.some( @@ -1264,6 +1272,180 @@ describe("CL-9489 zero-verbatim fold", () => { ); }); + test("older live-tail tool-use thinking is dropped when a newer last assistant has a tool call", async () => { + const olderThinking = "older-thinking-block"; + const newerThinking = "newer-thinking-block"; + const filler = "x".repeat(1_500); + const turns: ConversationTurn[] = []; + for (let i = 0; i < 6; i++) { + turns.push( + makeTurn({ + role: "user", + content: [{ type: "text", text: `dummy ask ${i} ${filler}` }], + }), + ); + turns.push( + makeTurn({ + role: "assistant", + content: [{ type: "text", text: `dummy reply ${i} ${filler}` }], + }), + ); + } + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "thinking", + thinking: olderThinking, + signature: "sig-older", + }, + { + type: "tool_call", + id: "c-older", + name: "read_file", + arguments: { path: "src/old.ts" }, + }, + ], + }), + ); + turns.push( + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: "c-older", + content: [{ type: "text", text: "export const old = 1;\n" }], + }, + ], + }), + ); + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "thinking", + thinking: newerThinking, + signature: "sig-newer", + }, + { + type: "tool_call", + id: "c-newer", + name: "read_file", + arguments: { path: "src/new.ts" }, + }, + ], + }), + ); + turns.push( + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: "c-newer", + content: [{ type: "text", text: "export const neu = 1;\n" }], + }, + ], + }), + ); + + const result = await createPruningCompactor({ + summarize: async () => + "Folded dummy pairs. Next: resume the newest tool.", + }).apply(turns, mockStrategyCtx); + + const older = result.output.find( + (t) => + t.role === "assistant" && + t.content.some((b) => b.type === "tool_call" && b.id === "c-older"), + ); + const newer = result.output.find( + (t) => + t.role === "assistant" && + t.content.some((b) => b.type === "tool_call" && b.id === "c-newer"), + ); + expect(older).toBeDefined(); + expect(newer).toBeDefined(); + expect(older?.content.some((b) => b.type === "thinking")).toBe(false); + expect(older?.content).toEqual([ + { + type: "tool_call", + id: "c-older", + name: "read_file", + arguments: { path: "src/old.ts" }, + }, + ]); + expect(newer?.content).toEqual([ + { + type: "thinking", + thinking: newerThinking, + signature: "sig-newer", + }, + { + type: "tool_call", + id: "c-newer", + name: "read_file", + arguments: { path: "src/new.ts" }, + }, + ]); + }); + + test("a text-only last assistant drops all live-tail thinking", async () => { + const thinking = "t".repeat(20_000); + const filler = "x".repeat(1_500); + const turns: ConversationTurn[] = []; + for (let i = 0; i < 6; i++) { + turns.push( + makeTurn({ + role: "user", + content: [{ type: "text", text: `dummy ask ${i} ${filler}` }], + }), + ); + turns.push( + makeTurn({ + role: "assistant", + content: [{ type: "text", text: `dummy reply ${i} ${filler}` }], + }), + ); + } + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "thinking", + thinking, + signature: "sig-text-only", + }, + { type: "text", text: "here is the answer" }, + ], + }), + ); + + const result = await createPruningCompactor({ + summarize: async () => + "Folded dummy pairs. Next: resume after a text reply.", + }).apply(turns, mockStrategyCtx); + + const liveTypes = result.output.flatMap((t) => + t.content.map((b) => b.type), + ); + expect(liveTypes).not.toContain("thinking"); + expect(liveTypes).not.toContain("redacted_thinking"); + expect( + result.output.some( + (t) => + t.role === "assistant" && + t.content.some( + (b) => b.type === "text" && b.text === "here is the answer", + ), + ), + ).toBe(true); + }); + test("a thinking-only live turn is replaced with THINKING_ONLY_OMITTED", async () => { const thinking = "t".repeat(20_000); const filler = "x".repeat(1_500); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index ecfea59b1..716271903 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -715,20 +715,23 @@ function excerptTailText( } // Excerpted live copy of a tail turn: thinking/redacted_thinking are dropped -// from the live copy (full bodies stay in the archive) because uncapped -// thinking on the newest pair was pinning occupancy near 45%. Drop whole -// blocks so signatures are not sent with truncated text. Large tool_result +// from the live copy except on the last assistant that still has a tool_call +// (Anthropic continuation requires unmodified thinking+signature on that +// tool_use). Full bodies stay in the archive. Drop whole blocks so +// signatures are not sent with truncated text. 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, + keepThinking = false, ): { turn: ConversationTurn; shortenedOutputs: number; changed: boolean } { let shortenedOutputs = 0; let changed = false; const content = turn.content.flatMap((block): ConversationTurn["content"] => { if (block.type === "thinking" || block.type === "redacted_thinking") { + if (keepThinking) return [block]; changed = true; return []; } @@ -769,11 +772,28 @@ interface TailSelection { tailTokenEstimate: number; } +function lastAssistantToolCallIndex( + turns: readonly ConversationTurn[], +): number | undefined { + for (let i = turns.length - 1; i >= 0; i--) { + const turn = turns[i]; + if ( + turn !== undefined && + turn.role === "assistant" && + turn.content.some((block) => block.type === "tool_call") + ) { + return i; + } + } + return undefined; +} + function excerptedTurnTokens( turn: ConversationTurn, shape: CompactionShape, + keepThinking = false, ): number { - const { turn: live } = excerptTailTurn(turn, shape); + const { turn: live } = excerptTailTurn(turn, shape, keepThinking); let tokens = 0; for (const block of live.content) { tokens += estimateContentBlockTokens(block); @@ -822,18 +842,20 @@ function selectTail( let shortenedToolOutputs = 0; let usedTokens = 0; const budgetTokens = shape.tailBudgetTokens; + const keepThinkingAt = lastAssistantToolCallIndex(turns); const turnCost = (idx: number): { tokens: number; shortened: number } => { const turn = turns[idx]; if (turn === undefined) return { tokens: 0, shortened: 0 }; + const keepThinking = idx === keepThinkingAt; const { turn: live, shortenedOutputs, changed, - } = excerptTailTurn(turn, shape); + } = excerptTailTurn(turn, shape, keepThinking); if (changed) excerpted.set(idx, live); return { - tokens: excerptedTurnTokens(turn, shape), + tokens: excerptedTurnTokens(turn, shape, keepThinking), shortened: shortenedOutputs, }; }; @@ -889,7 +911,7 @@ function selectTail( for (const idx of closure) { const t = turns[idx]; if (t === undefined || isFoldableHandoffTurn(t)) continue; - closureTokens += excerptedTurnTokens(t, shape); + closureTokens += excerptedTurnTokens(t, shape, idx === keepThinkingAt); } if (picked.size > 0 && usedTokens + closureTokens > budgetTokens) break; for (const idx of closure) pick(idx); From 3f81e4f23af63aea41d3b4261eaa1883b0486825 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 6 Oct 2026 14:21:54 -0700 Subject: [PATCH 2/2] fix(compaction): drop thinking when last assistant has no tools --- src/context-compactor.test.ts | 91 +++++++++++++++++++++++++++++++++++ src/session/compactor.ts | 11 ++--- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 7a7e15ece..6a9332a4b 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -1446,6 +1446,97 @@ describe("CL-9489 zero-verbatim fold", () => { ).toBe(true); }); + test("earlier live tool-use thinking is dropped when the last assistant is text-only", async () => { + const toolThinking = "tool-use-thinking-block"; + const lastThinking = "text-only-thinking-block"; + const filler = "x".repeat(1_500); + const turns: ConversationTurn[] = []; + for (let i = 0; i < 6; i++) { + turns.push( + makeTurn({ + role: "user", + content: [{ type: "text", text: `dummy ask ${i} ${filler}` }], + }), + ); + turns.push( + makeTurn({ + role: "assistant", + content: [{ type: "text", text: `dummy reply ${i} ${filler}` }], + }), + ); + } + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "thinking", + thinking: toolThinking, + signature: "sig-tool-use", + }, + { + type: "tool_call", + id: "c-earlier", + name: "read_file", + arguments: { path: "src/app.ts" }, + }, + ], + }), + ); + turns.push( + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: "c-earlier", + content: [{ type: "text", text: "export const app = 1;\n" }], + }, + ], + }), + ); + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "thinking", + thinking: lastThinking, + signature: "sig-text-only", + }, + { type: "text", text: "here is the answer" }, + ], + }), + ); + + const result = await createPruningCompactor({ + summarize: async () => + "Folded dummy pairs. Next: resume after a text reply.", + }).apply(turns, mockStrategyCtx); + + const liveTypes = result.output.flatMap((t) => + t.content.map((b) => b.type), + ); + expect(liveTypes).not.toContain("thinking"); + expect(liveTypes).not.toContain("redacted_thinking"); + expect( + result.output.some( + (t) => + t.role === "assistant" && + t.content.some((b) => b.type === "tool_call" && b.id === "c-earlier"), + ), + ).toBe(true); + expect( + result.output.some( + (t) => + t.role === "assistant" && + t.content.some( + (b) => b.type === "text" && b.text === "here is the answer", + ), + ), + ).toBe(true); + }); + test("a thinking-only live turn is replaced with THINKING_ONLY_OMITTED", async () => { const thinking = "t".repeat(20_000); const filler = "x".repeat(1_500); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 716271903..e93992fb5 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -777,13 +777,10 @@ function lastAssistantToolCallIndex( ): number | undefined { for (let i = turns.length - 1; i >= 0; i--) { const turn = turns[i]; - if ( - turn !== undefined && - turn.role === "assistant" && - turn.content.some((block) => block.type === "tool_call") - ) { - return i; - } + if (turn === undefined || turn.role !== "assistant") continue; + return turn.content.some((block) => block.type === "tool_call") + ? i + : undefined; } return undefined; }