@@ -207,7 +207,7 @@ export type CompactorConfig = {
207207} ;
208208
209209const DEFAULT_COMPACTOR_CONFIG : CompactorConfig = {
210- keepRecentTurns : 5 ,
210+ keepRecentTurns : 6 ,
211211 summaryMaxChars : 2000 ,
212212 maxAnchorTurns : 8 ,
213213} ;
@@ -231,11 +231,17 @@ const ANCHOR_SCORE_THRESHOLD = 5;
231231// Tool names whose results are path-keyed for re-read dedup during compaction.
232232const READ_TOOLS = new Set ( [ "read_file" ] ) ;
233233
234- // Call-id index for stub rendering (name + path). Not path-keyed — that is
235- // buildPathToReads below.
234+ // Call-id index for stub rendering (name + path). Dedup keys live on `readKey`.
236235type ToolCallInfo = {
237236 name : string ;
237+ /** Display path for stubs (always the raw path arg when present). */
238238 pathArg ?: string ;
239+ /**
240+ * Dedup identity for re-read stubbing. Full-file reads share the path alone;
241+ * ranged reads (offset/limit) get a distinct key so chunked reads of the same
242+ * file do not hollow each other.
243+ */
244+ readKey ?: string ;
239245} ;
240246
241247type PathRead = {
@@ -245,7 +251,20 @@ type PathRead = {
245251 isError : boolean ;
246252} ;
247253
248- function pathArgFromArguments ( raw : unknown ) : string | undefined {
254+ function scalarArg ( value : unknown ) : string {
255+ if ( typeof value === "number" && Number . isFinite ( value ) ) return String ( value ) ;
256+ if ( typeof value === "string" ) return value ;
257+ return "" ;
258+ }
259+
260+ /**
261+ * Extract path + re-read identity from a tool_call's arguments.
262+ * Identity is path alone for full-file reads; path+offset+limit when either
263+ * range arg is present so partial reads don't supersede each other.
264+ */
265+ function readIdentityFromArguments (
266+ raw : unknown ,
267+ ) : { path : string ; readKey : string } | undefined {
249268 let args : unknown = raw ?? { } ;
250269 if ( typeof args === "string" ) {
251270 try {
@@ -255,8 +274,16 @@ function pathArgFromArguments(raw: unknown): string | undefined {
255274 }
256275 }
257276 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 ;
277+ const rec = args as Record < string , unknown > ;
278+ const path = rec [ "path" ] ;
279+ if ( typeof path !== "string" || path . length === 0 ) return undefined ;
280+ const offsetPart = scalarArg ( rec [ "offset" ] ) ;
281+ const limitPart = scalarArg ( rec [ "limit" ] ) ;
282+ const readKey =
283+ offsetPart === "" && limitPart === ""
284+ ? path
285+ : `${ path } \0${ offsetPart } \0${ limitPart } ` ;
286+ return { path, readKey } ;
260287}
261288
262289// callId → tool name/path for readable stubs. Inverse of path-to-reads.
@@ -266,18 +293,26 @@ function buildCallIndex(turns: readonly ConversationTurn[]): Map<string, ToolCal
266293 for ( const block of turn . content ) {
267294 if ( block . type !== "tool_call" ) continue ;
268295 const info : ToolCallInfo = { name : block . name } ;
269- const path = pathArgFromArguments ( block . arguments ) ;
270- if ( path !== undefined ) info . pathArg = path ;
296+ const identity = readIdentityFromArguments ( block . arguments ) ;
297+ if ( identity !== undefined ) {
298+ info . pathArg = identity . path ;
299+ info . readKey = identity . readKey ;
300+ }
271301 index . set ( block . id , info ) ;
272302 }
273303 }
274304 return index ;
275305}
276306
277307/**
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.
308+ * Read-identity → every read_file result that matched it, in session order.
309+ * Groups repeated full-file (or same-range) reads so older successful results
310+ * can be stubbed when a later identical read survives compaction.
311+ *
312+ * Callers must pass only turns that survive compaction (anchors + recent).
313+ * Computing supersession over the full transcript would hollow a kept older
314+ * read when the newer re-read was summarized away — leaving the model with a
315+ * stub and no full body.
281316 */
282317function buildPathToReads (
283318 turns : readonly ConversationTurn [ ] ,
@@ -289,14 +324,14 @@ function buildPathToReads(
289324 for ( const block of turn . content ) {
290325 if ( block . type !== "tool_result" ) continue ;
291326 const info = callIndex . get ( block . callId ) ;
292- if ( info === undefined || ! READ_TOOLS . has ( info . name ) || info . pathArg === undefined ) continue ;
327+ if ( info === undefined || ! READ_TOOLS . has ( info . name ) || info . readKey === undefined ) continue ;
293328 const entry : PathRead = {
294329 callId : block . callId ,
295330 order : order ++ ,
296331 isError : block . isError === true ,
297332 } ;
298- const list = pathToReads . get ( info . pathArg ) ;
299- if ( list === undefined ) pathToReads . set ( info . pathArg , [ entry ] ) ;
333+ const list = pathToReads . get ( info . readKey ) ;
334+ if ( list === undefined ) pathToReads . set ( info . readKey , [ entry ] ) ;
300335 else list . push ( entry ) ;
301336 }
302337 }
@@ -305,8 +340,9 @@ function buildPathToReads(
305340
306341/**
307342 * 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.
343+ * successful read of the same identity (path, or path+offset+limit). Error
344+ * results never appear here — they stay verbatim so the model still sees the
345+ * failure.
310346 */
311347function supersededReadCallIds ( pathToReads : ReadonlyMap < string , PathRead [ ] > ) : Set < string > {
312348 const superseded = new Set < string > ( ) ;
@@ -494,7 +530,7 @@ export function createPruningCompactor(
494530
495531 return {
496532 name : "pruning-compactor" ,
497- version : "1.3.0 " ,
533+ version : "1.3.1 " ,
498534 async apply (
499535 turns : ConversationTurn [ ] ,
500536 _ctx : StrategyContext ,
@@ -520,12 +556,9 @@ export function createPruningCompactor(
520556 } ;
521557 }
522558
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).
559+ // callId → name/path for stubs. Built over the full transcript so a kept
560+ // result can still name its path even when its call turn was summarized.
526561 const callIndex = buildCallIndex ( aged . turns ) ;
527- const pathToReads = buildPathToReads ( aged . turns , callIndex ) ;
528- const supersededReads = supersededReadCallIds ( pathToReads ) ;
529562
530563 const keepCount = Math . min ( cfg . keepRecentTurns , aged . turns . length - 1 ) ;
531564 const keepFrom = aged . turns . length - keepCount ;
@@ -568,6 +601,12 @@ export function createPruningCompactor(
568601 const anchorTurns = sortedAnchorIndices . map ( ( i ) => olderTurns [ i ] ! ) ;
569602 const summarizedTurns = olderTurns . filter ( ( _ , i ) => ! anchorIndices . has ( i ) ) ;
570603
604+ // Path-dedup only among turns that survive. Supersession over the full
605+ // transcript would hollow a kept older read when the newer re-read is only
606+ // in the summary (CL-4374 review follow-up).
607+ const pathToReads = buildPathToReads ( [ ...anchorTurns , ...recentTurns ] , callIndex ) ;
608+ const supersededReads = supersededReadCallIds ( pathToReads ) ;
609+
571610 const summary = cfg . summarize !== undefined
572611 ? await cfg . summarize ( summarizedTurns )
573612 : buildTurnSummary ( summarizedTurns , cfg . summaryMaxChars , anchorTurns . length ) ;
@@ -584,11 +623,11 @@ export function createPruningCompactor(
584623 } ;
585624
586625 // 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.
626+ // same file was read successfully more than once among kept turns, older
627+ // results become a one-line stub and the newest stays whole. Error results
628+ // are never stubbed. SummarizedTurns lose content wholesale via the summary
629+ // above. Anchors are already image-aged (outside the recent window). Recent
630+ // turns keep live base64 so a just-pasted screenshot still reaches the model.
592631 const process = ( t : ConversationTurn ) : ConversationTurn =>
593632 stubSupersededReads ( t , supersededReads , callIndex ) ;
594633 const output = coalesceAdjacentTextTurns ( [
0 commit comments