diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 33885b9cd..ec7931872 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -204,17 +204,12 @@ export type CompactorConfig = { // errors) before the summary stub. Pulled from the end of the older set // so the most-recent anchors survive. maxAnchorTurns: number; - // When true, replace tool_result content in every kept turn with a - // one-line stub. Safe at compaction time because the cache is already - // cold from the compaction event itself. - stripResultContent: boolean; }; const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { keepRecentTurns: 5, summaryMaxChars: 2000, maxAnchorTurns: 8, - stripResultContent: false, }; // Recent turns kept verbatim by both real pruning-compactor registrations @@ -233,33 +228,6 @@ export function compactorNoOpFloor(keepRecentTurns: number): number { // Minimum anchor score for a turn to be pulled forward past the summary boundary. const ANCHOR_SCORE_THRESHOLD = 5; -// Tool name → path argument, used to build readable stubs. -type ToolCallInfo = { - name: string; - pathArg?: string; - commandArg?: string; -}; - -// Build a callId → tool info index from the full turn list so the strip -// function can produce named stubs without searching across turns. -function buildCallIndex(turns: ConversationTurn[]): Map { - const index = new Map(); - for (const turn of turns) { - for (const block of turn.content) { - if (block.type !== "tool_call") continue; - const info: ToolCallInfo = { name: block.name }; - const args = block.arguments; - if (typeof args === "object" && args !== null) { - const a = args as Record; - if (typeof a["path"] === "string") info.pathArg = a["path"]; - if (typeof a["command"] === "string") info.commandArg = a["command"]; - } - index.set(block.id, info); - } - } - return index; -} - // Locate the turn index of each tool_call and its matching tool_result. In this // runtime a call lives on one turn and its result on the following turn, so the // two halves of a pair can straddle a keep/summarize boundary. @@ -309,39 +277,6 @@ function resultContentSize(block: Extract sum + (c.type === "text" ? c.text.length : 0), 0); } -function buildResultStub( - block: Extract, - callIndex: Map, -): string { - const info = callIndex.get(block.callId); - const name = info?.name ?? "tool_result"; - 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." : ""; - return `[${name} ${path} — ${size} chars omitted from context; source unchanged.${spillHint}]`; - } - if (info?.commandArg !== undefined) { - const cmd = info.commandArg.slice(0, 40); - return `[${name} "${cmd}" — ${size} chars, omitted]`; - } - return `[${name} — ${size} chars, omitted]`; -} - -// Replace tool_result content with a one-line stub. Errors are kept in full -// because they may describe constraints the model still needs to respect. -function stripTurnResults( - turn: ConversationTurn, - callIndex: Map, -): ConversationTurn { - const content = turn.content.map((block): ConversationTurn["content"][number] => { - if (block.type !== "tool_result" || block.isError === true) return block; - return { ...block, content: [{ type: "text", text: buildResultStub(block, callIndex) }] }; - }); - return { ...turn, content }; -} - // True when a turn carries no tool_call/tool_result blocks. function isPlainTextTurn(turn: ConversationTurn): boolean { return !turn.content.some((b) => b.type === "tool_call" || b.type === "tool_result"); @@ -429,7 +364,7 @@ export function createPruningCompactor( return { name: "pruning-compactor", - version: "1.1.0", + version: "1.2.0", async apply( turns: ConversationTurn[], _ctx: StrategyContext, @@ -455,8 +390,6 @@ export function createPruningCompactor( }; } - 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); @@ -513,15 +446,17 @@ export function createPruningCompactor( timestamp: olderTurns[olderTurns.length - 1]?.timestamp ?? Date.now(), }; - const process = (t: ConversationTurn): ConversationTurn => - cfg.stripResultContent ? stripTurnResults(t, callIndex) : t; - - // Anchors are already image-aged (outside the recent window). Recent - // turns keep live base64 so a just-pasted screenshot still reaches the model. + // Anchors and recent turns are exactly what compaction chose to keep — + // pulling a turn forward and then hollowing out its tool_result defeats + // the reason it was kept. Only summarizedTurns lose their content, and + // they lose it wholesale (folded into `summary` above), not stubbed + // in place. Anchors are already image-aged (outside the recent window). + // Recent turns keep live base64 so a just-pasted screenshot still + // reaches the model. const output = coalesceAdjacentTextTurns([ summaryTurn, - ...anchorTurns.map(process), - ...recentTurns.map(process), + ...anchorTurns, + ...recentTurns, ]); return { @@ -533,7 +468,6 @@ export function createPruningCompactor( keepRecentTurns: cfg.keepRecentTurns, summaryMaxChars: cfg.summaryMaxChars, maxAnchorTurns: cfg.maxAnchorTurns, - stripResultContent: cfg.stripResultContent, }, reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${keepCount} recent`, decisions: { diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 4841e38c9..e1eb712b8 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -142,7 +142,7 @@ describe("skillDirsFromEnabledPlugins", () => { }); describe("createSessionPruningCompactor", () => { - test("uses stripResultContent in pruning mode and summarize otherwise", async () => { + test("only wires a summarize function in llm mode", async () => { const summarize = async () => "summary"; const pruning = createSessionPruningCompactor({ compactionMode: "pruning", diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 8780d7265..8dfa3e332 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -255,8 +255,6 @@ export function createSessionPruningCompactor( return createPruningCompactor({ keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS, - ...(args.compactionMode !== "pruning" - ? { summarize: args.summarize } - : { stripResultContent: true }), + ...(args.compactionMode !== "pruning" ? { summarize: args.summarize } : {}), }); } diff --git a/src/subagent/run.ts b/src/subagent/run.ts index f3cce0692..af3edc865 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -491,7 +491,6 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { "pruning-compactor": createPruningCompactor({ keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: 2500, - stripResultContent: true, // A structured model summary keeps sub-agent context useful across a // compaction; the deterministic stub remains the fallback on failure. ...(subagentSource !== undefined diff --git a/tests/unit/compactor-pairing.test.ts b/tests/unit/compactor-pairing.test.ts index ec4cce8aa..558e2d37d 100644 --- a/tests/unit/compactor-pairing.test.ts +++ b/tests/unit/compactor-pairing.test.ts @@ -25,7 +25,7 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { userResult("c1"), // index 4 -> recent window head userText("c"), userText("d"), userText("e"), userText("f"), userText("g"), ]; - const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2, stripResultContent: true }); + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); const { output } = await compactor.apply(turns, {} as never); expect(() => assertWellFormedToolSequence(output)).not.toThrow(); }); @@ -38,11 +38,54 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { userText("a"), userText("b"), userText("c"), userText("d"), userText("e"), userText("f"), userText("g"), ]; - const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2, stripResultContent: true }); + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); const { output } = await compactor.apply(turns, {} as never); expect(() => assertWellFormedToolSequence(output)).not.toThrow(); }); + 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" }] }], + 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 }, + editResult, + userText("e"), userText("f"), userText("g"), + ]; + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); + const { output } = await compactor.apply(turns, {} as never); + const kept = output.find((t) => + 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" }] }); + }); + + 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" }] }], + timestamp: 1, + }; + const turns: ConversationTurn[] = [ + userText("start"), + { 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"), + ]; + const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 }); + const { output } = await compactor.apply(turns, {} as never); + const kept = output.find((t) => + 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" }] }); + }); + test("buildTurnSummary counts large tool_result payloads", () => { const big: ConversationTurn = { role: "user",