@@ -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.
229229const 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.
281411function 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 } : { } ) ,
0 commit comments