Skip to content

Commit e9be150

Browse files
committed
Stub superseded read_file results during compaction
Sessions re-read the same path often; each full result stayed in context. Build a path-to-reads index and stub older successful reads when a later success of the same path survives, keeping errors whole. CL-4374
1 parent bf898e4 commit e9be150

2 files changed

Lines changed: 244 additions & 10 deletions

File tree

src/session/compactor.ts

Lines changed: 149 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,99 @@ export function compactorNoOpFloor(keepRecentTurns: number): number {
228228
// Minimum anchor score for a turn to be pulled forward past the summary boundary.
229229
const ANCHOR_SCORE_THRESHOLD = 5;
230230

231+
// Tool names whose results are path-keyed for re-read dedup during compaction.
232+
const READ_TOOLS = new Set(["read_file"]);
233+
234+
// Call-id index for stub rendering (name + path). Not path-keyed — that is
235+
// buildPathToReads below.
236+
type ToolCallInfo = {
237+
name: string;
238+
pathArg?: string;
239+
};
240+
241+
type PathRead = {
242+
callId: string;
243+
/** Monotonic order across the turn list; higher = later in the session. */
244+
order: number;
245+
isError: boolean;
246+
};
247+
248+
function pathArgFromArguments(raw: unknown): string | undefined {
249+
let args: unknown = raw ?? {};
250+
if (typeof args === "string") {
251+
try {
252+
args = JSON.parse(args) as unknown;
253+
} catch {
254+
return undefined;
255+
}
256+
}
257+
if (args === null || typeof args !== "object" || Array.isArray(args)) return undefined;
258+
const path = (args as Record<string, unknown>)["path"];
259+
return typeof path === "string" && path.length > 0 ? path : undefined;
260+
}
261+
262+
// callId → tool name/path for readable stubs. Inverse of path-to-reads.
263+
function buildCallIndex(turns: readonly ConversationTurn[]): Map<string, ToolCallInfo> {
264+
const index = new Map<string, ToolCallInfo>();
265+
for (const turn of turns) {
266+
for (const block of turn.content) {
267+
if (block.type !== "tool_call") continue;
268+
const info: ToolCallInfo = { name: block.name };
269+
const path = pathArgFromArguments(block.arguments);
270+
if (path !== undefined) info.pathArg = path;
271+
index.set(block.id, info);
272+
}
273+
}
274+
return index;
275+
}
276+
277+
/**
278+
* Path → every read_file result that targeted it, in session order.
279+
* Groups repeated reads so older successful results can be stubbed when a
280+
* later read of the same path survives compaction.
281+
*/
282+
function buildPathToReads(
283+
turns: readonly ConversationTurn[],
284+
callIndex: ReadonlyMap<string, ToolCallInfo>,
285+
): Map<string, PathRead[]> {
286+
const pathToReads = new Map<string, PathRead[]>();
287+
let order = 0;
288+
for (const turn of turns) {
289+
for (const block of turn.content) {
290+
if (block.type !== "tool_result") continue;
291+
const info = callIndex.get(block.callId);
292+
if (info === undefined || !READ_TOOLS.has(info.name) || info.pathArg === undefined) continue;
293+
const entry: PathRead = {
294+
callId: block.callId,
295+
order: order++,
296+
isError: block.isError === true,
297+
};
298+
const list = pathToReads.get(info.pathArg);
299+
if (list === undefined) pathToReads.set(info.pathArg, [entry]);
300+
else list.push(entry);
301+
}
302+
}
303+
return pathToReads;
304+
}
305+
306+
/**
307+
* Call ids of successful read_file results that are superseded by a later
308+
* successful read of the same path. Error results never appear here — they
309+
* stay verbatim so the model still sees the failure.
310+
*/
311+
function supersededReadCallIds(pathToReads: ReadonlyMap<string, PathRead[]>): Set<string> {
312+
const superseded = new Set<string>();
313+
for (const reads of pathToReads.values()) {
314+
const successes = reads.filter((r) => !r.isError);
315+
if (successes.length < 2) continue;
316+
// Newest success (highest order) stays whole; every earlier success stubs.
317+
for (let i = 0; i < successes.length - 1; i++) {
318+
superseded.add(successes[i]!.callId);
319+
}
320+
}
321+
return superseded;
322+
}
323+
231324
// Locate the turn index of each tool_call and its matching tool_result. In this
232325
// runtime a call lives on one turn and its result on the following turn, so the
233326
// two halves of a pair can straddle a keep/summarize boundary.
@@ -277,6 +370,43 @@ function resultContentSize(block: Extract<ConversationTurn["content"][number], {
277370
return block.content.reduce((sum, c) => sum + (c.type === "text" ? c.text.length : 0), 0);
278371
}
279372

373+
function buildResultStub(
374+
block: Extract<ConversationTurn["content"][number], { type: "tool_result" }>,
375+
callIndex: ReadonlyMap<string, ToolCallInfo>,
376+
): string {
377+
const info = callIndex.get(block.callId);
378+
const name = info?.name ?? "tool_result";
379+
const size = resultContentSize(block);
380+
if (info?.pathArg !== undefined) {
381+
const path = info.pathArg;
382+
const spillHint =
383+
path.startsWith("tool-output://")
384+
? " Re-read with read_file offset/limit or grep on that URI."
385+
: "";
386+
return `[${name} ${path}${size} chars omitted from context; source unchanged.${spillHint}]`;
387+
}
388+
return `[${name}${size} chars, omitted]`;
389+
}
390+
391+
// Hollow out superseded successful read_file results; leave everything else.
392+
// Errors and the newest successful read of each path stay whole.
393+
function stubSupersededReads(
394+
turn: ConversationTurn,
395+
superseded: ReadonlySet<string>,
396+
callIndex: ReadonlyMap<string, ToolCallInfo>,
397+
): ConversationTurn {
398+
if (superseded.size === 0) return turn;
399+
let changed = false;
400+
const content = turn.content.map((block): ConversationTurn["content"][number] => {
401+
if (block.type !== "tool_result" || !superseded.has(block.callId)) return block;
402+
// Defensive: errors never enter the superseded set, but keep them whole.
403+
if (block.isError === true) return block;
404+
changed = true;
405+
return { ...block, content: [{ type: "text", text: buildResultStub(block, callIndex) }] };
406+
});
407+
return changed ? { ...turn, content } : turn;
408+
}
409+
280410
// True when a turn carries no tool_call/tool_result blocks.
281411
function isPlainTextTurn(turn: ConversationTurn): boolean {
282412
return !turn.content.some((b) => b.type === "tool_call" || b.type === "tool_result");
@@ -364,7 +494,7 @@ export function createPruningCompactor(
364494

365495
return {
366496
name: "pruning-compactor",
367-
version: "1.2.0",
497+
version: "1.3.0",
368498
async apply(
369499
turns: ConversationTurn[],
370500
_ctx: StrategyContext,
@@ -390,6 +520,13 @@ export function createPruningCompactor(
390520
};
391521
}
392522

523+
// callId → name/path for stubs; path → ordered reads for re-read dedup.
524+
// Only older successful reads of a path re-read later are stubbed — not a
525+
// blanket strip of every kept tool_result (see CL-5595 / CL-4374).
526+
const callIndex = buildCallIndex(aged.turns);
527+
const pathToReads = buildPathToReads(aged.turns, callIndex);
528+
const supersededReads = supersededReadCallIds(pathToReads);
529+
393530
const keepCount = Math.min(cfg.keepRecentTurns, aged.turns.length - 1);
394531
const keepFrom = aged.turns.length - keepCount;
395532
const recentTurns = aged.turns.slice(keepFrom);
@@ -446,17 +583,18 @@ export function createPruningCompactor(
446583
timestamp: olderTurns[olderTurns.length - 1]?.timestamp ?? Date.now(),
447584
};
448585

449-
// Anchors and recent turns are exactly what compaction chose to keep —
450-
// pulling a turn forward and then hollowing out its tool_result defeats
451-
// the reason it was kept. Only summarizedTurns lose their content, and
452-
// they lose it wholesale (folded into `summary` above), not stubbed
453-
// in place. Anchors are already image-aged (outside the recent window).
454-
// Recent turns keep live base64 so a just-pasted screenshot still
455-
// reaches the model.
586+
// Anchors and recent turns stay contentful except for path-dedup: when the
587+
// same file was read successfully more than once, older results become a
588+
// one-line stub and the newest stays whole. Error results are never
589+
// stubbed. SummarizedTurns lose content wholesale via the summary above.
590+
// Anchors are already image-aged (outside the recent window). Recent turns
591+
// keep live base64 so a just-pasted screenshot still reaches the model.
592+
const process = (t: ConversationTurn): ConversationTurn =>
593+
stubSupersededReads(t, supersededReads, callIndex);
456594
const output = coalesceAdjacentTextTurns([
457595
summaryTurn,
458-
...anchorTurns,
459-
...recentTurns,
596+
...anchorTurns.map(process),
597+
...recentTurns.map(process),
460598
]);
461599

462600
return {
@@ -476,6 +614,7 @@ export function createPruningCompactor(
476614
recentTurnCount: recentTurns.length,
477615
summaryLength: summary.length,
478616
agedImageCount: aged.agedImageCount,
617+
supersededReadCount: supersededReads.size,
479618
},
480619
},
481620
...(aged.blobs.length > 0 ? { blobs: aged.blobs } : {}),

tests/unit/compactor-pairing.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,98 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => {
9898
expect(tokens).toBeGreaterThan(20000);
9999
});
100100
});
101+
102+
// CL-4374: when the same path is read more than once and both results survive
103+
// compaction (recent window / anchors), older successful reads become one-line
104+
// stubs; the newest successful read stays whole; error results stay whole.
105+
function assistantRead(id: string, path: string): ConversationTurn {
106+
return {
107+
role: "assistant",
108+
content: [{ type: "tool_call", id, name: "read_file", arguments: { path } }],
109+
timestamp: 1,
110+
};
111+
}
112+
113+
function userReadResult(callId: string, body: string, isError = false): ConversationTurn {
114+
return {
115+
role: "user",
116+
content: [
117+
{
118+
type: "tool_result",
119+
callId,
120+
content: [{ type: "text", text: body }],
121+
...(isError ? { isError: true } : {}),
122+
},
123+
],
124+
timestamp: 1,
125+
};
126+
}
127+
128+
function resultText(output: ConversationTurn[], callId: string): string | undefined {
129+
for (const turn of output) {
130+
for (const block of turn.content) {
131+
if (block.type === "tool_result" && block.callId === callId) {
132+
return block.content
133+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
134+
.map((c) => c.text)
135+
.join("");
136+
}
137+
}
138+
}
139+
return undefined;
140+
}
141+
142+
describe("pruning compactor stubs superseded file reads (CL-4374)", () => {
143+
test("stubs an older successful read of a path re-read later; newest stays whole", async () => {
144+
const oldBody = "OLD_CONTENT_" + "a".repeat(200);
145+
const newBody = "NEW_CONTENT_" + "b".repeat(200);
146+
// Both read pairs sit in the recent window so neither is folded into the
147+
// summary — only the path-dedup pass should hollow the older result.
148+
const turns: ConversationTurn[] = [
149+
userText("start"),
150+
userText("a"),
151+
userText("b"),
152+
userText("c"),
153+
assistantRead("r1", "src/hot.ts"),
154+
userReadResult("r1", oldBody),
155+
assistantRead("r2", "src/hot.ts"),
156+
userReadResult("r2", newBody),
157+
userText("d"),
158+
userText("e"),
159+
];
160+
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
161+
const { output } = await compactor.apply(turns, {} as never);
162+
163+
const older = resultText(output, "r1");
164+
const newer = resultText(output, "r2");
165+
expect(older).toBeDefined();
166+
expect(newer).toBe(newBody);
167+
expect(older).not.toBe(oldBody);
168+
expect(older).toMatch(/read_file/);
169+
expect(older).toMatch(/src\/hot\.ts/);
170+
expect(older).toMatch(/omitted|chars/);
171+
expect(older!.length).toBeLessThan(oldBody.length);
172+
});
173+
174+
test("preserves error read results verbatim even when a later success supersedes the path", async () => {
175+
const errBody = "Error: ENOENT no such file " + "e".repeat(80);
176+
const okBody = "OK_CONTENT_" + "c".repeat(200);
177+
const turns: ConversationTurn[] = [
178+
userText("start"),
179+
userText("a"),
180+
userText("b"),
181+
userText("c"),
182+
assistantRead("r1", "src/hot.ts"),
183+
userReadResult("r1", errBody, true),
184+
assistantRead("r2", "src/hot.ts"),
185+
userReadResult("r2", okBody),
186+
userText("d"),
187+
userText("e"),
188+
];
189+
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
190+
const { output } = await compactor.apply(turns, {} as never);
191+
192+
expect(resultText(output, "r1")).toBe(errBody);
193+
expect(resultText(output, "r2")).toBe(okBody);
194+
});
195+
});

0 commit comments

Comments
 (0)