1+ import { randomUUID } from "node:crypto" ;
12import { createReadStream } from "node:fs" ;
23import { stat } from "node:fs/promises" ;
34import { resolve } from "node:path" ;
45import { Readable } from "node:stream" ;
56import { StringDecoder } from "node:string_decoder" ;
67import type { ToolPlugin } from "@intx/tools-posix" ;
78import type { BlobReader } from "@intx/types/runtime" ;
8- import { canonicalToolOutputUri , isToolOutputLike } from "../util/tool-output-uri.js" ;
9+ import {
10+ canonicalToolOutputUri ,
11+ isToolOutputLike ,
12+ TOOL_OUTPUT_URI_PREFIX ,
13+ } from "../util/tool-output-uri.js" ;
914import { formatReadFileTimeoutMessage } from "./tool-time-budget.js" ;
1015
1116// Corbits Code-side guard for read_file. Stock @intx/tools-posix read-file loads the
@@ -41,6 +46,86 @@ export interface ReadFileGuardPluginOptions {
4146 blobReader ?: BlobReader ;
4247}
4348
49+ // A truncated read used to tell the model "Use offset=N to continue" against
50+ // the identical path -- exactly the same-path pagination fan-out CL-6961
51+ // measured (97% of 4+-reads-per-path clusters were legitimate chunked reads
52+ // of one large file, penalized by detectors that only see "same path, many
53+ // calls"). Each truncated result instead mints a single-use tool-output://
54+ // cursor pointing at the exact resumption point (source + next offset) and
55+ // tells the model to pass THAT as `path`. Every follow-up read therefore
56+ // targets a distinct path, so pagination no longer looks like a same-path
57+ // loop, and the cursor is a real, resolvable handle -- not the "see the blob"
58+ // promise result-truncation-plugin.ts's comment forbids, since nothing here
59+ // claims discarded bytes are retrievable; it just remembers where to resume
60+ // a fresh bounded read.
61+ type ReadCursor =
62+ | { kind : "file" ; absolutePath : string ; offset : number ; consumed : boolean }
63+ | { kind : "blob" ; uri : string ; offset : number ; consumed : boolean } ;
64+
65+ // A cursor is single-use, but the record survives consumption (bounded by
66+ // MAX_CURSOR_HISTORY below) so a stale replay -- consumed already, or a
67+ // second process/turn racing the first -- can be told exactly where to
68+ // resume instead of hitting an opaque "blob not found" dead end that names
69+ // neither the file nor an offset and leaves re-reading from scratch (the
70+ // original path, no offset) as the model's only move.
71+ const MAX_CURSOR_HISTORY = 200 ;
72+
73+ const CONTINUE_OFFSET_RE = / U s e o f f s e t = ( \d + ) t o c o n t i n u e \. \] $ / ;
74+
75+ function pruneCursorHistory ( cursors : Map < string , ReadCursor > ) : void {
76+ while ( cursors . size > MAX_CURSOR_HISTORY ) {
77+ const oldest = cursors . keys ( ) . next ( ) . value ;
78+ if ( oldest === undefined ) break ;
79+ cursors . delete ( oldest ) ;
80+ }
81+ }
82+
83+ function mintCursor (
84+ content : string ,
85+ cursors : Map < string , ReadCursor > ,
86+ source : { kind : "file" ; absolutePath : string } | { kind : "blob" ; uri : string } ,
87+ ) : string {
88+ const match = CONTINUE_OFFSET_RE . exec ( content ) ;
89+ if ( match === null ) return content ;
90+ const offset = Number ( match [ 1 ] ) ;
91+ const cursorId = randomUUID ( ) ;
92+ cursors . set (
93+ cursorId ,
94+ source . kind === "file"
95+ ? { kind : "file" , absolutePath : source . absolutePath , offset, consumed : false }
96+ : { kind : "blob" , uri : source . uri , offset, consumed : false } ,
97+ ) ;
98+ pruneCursorHistory ( cursors ) ;
99+ return content . replace (
100+ CONTINUE_OFFSET_RE ,
101+ `Use path="${ TOOL_OUTPUT_URI_PREFIX } ///${ cursorId } " (same tool, no offset needed) to continue reading the remainder — a fresh, working handle, not the original path.]` ,
102+ ) ;
103+ }
104+
105+ // Bound the source shown in a stale-cursor message: an adversarial or
106+ // pathological path must not blow past a reasonable notice size.
107+ const STALE_CURSOR_SOURCE_MAX = 300 ;
108+
109+ function displaySource ( source : string ) : string {
110+ return source . length > STALE_CURSOR_SOURCE_MAX
111+ ? `${ source . slice ( 0 , STALE_CURSOR_SOURCE_MAX ) } …`
112+ : source ;
113+ }
114+
115+ /**
116+ * Message for a cursor that is known but already used (or is being replayed
117+ * from a stale/compacted turn). Distinct from "blob not found": it names the
118+ * original source and the exact offset to resume from, so recovery is a
119+ * single new call rather than a re-read from scratch of the whole file.
120+ */
121+ function staleCursorMessage ( cursor : ReadCursor ) : string {
122+ const source = cursor . kind === "file" ? cursor . absolutePath : cursor . uri ;
123+ return (
124+ `this read_file continuation handle was already used (each cursor is single-use). ` +
125+ `Resume with read_file, path="${ displaySource ( source ) } ", offset=${ cursor . offset } .`
126+ ) ;
127+ }
128+
44129function numArg ( value : unknown ) : number | undefined {
45130 return typeof value === "number" && Number . isFinite ( value ) ? value : undefined ;
46131}
@@ -297,6 +382,11 @@ export function readFileGuardPlugin(
297382 options : ReadFileGuardPluginOptions = { } ,
298383) : ToolPlugin {
299384 const { blobReader } = options ;
385+ // Single-use resumption pointers minted by mintCursor(); scoped to this
386+ // plugin instance (one per session/agent, per buildCorePosixToolPlugins), so
387+ // it never outlives the session and never crosses sessions.
388+ const cursors = new Map < string , ReadCursor > ( ) ;
389+ const cursorUriPrefix = `${ TOOL_OUTPUT_URI_PREFIX } ///` ;
300390 return {
301391 middleware : ( next ) => async ( call , signal ) => {
302392 if ( call . name !== "read_file" ) return next ( call , signal ) ;
@@ -306,13 +396,64 @@ export function readFileGuardPlugin(
306396 return next ( call , signal ) ;
307397 }
308398
309- const { offset , limit } = resolveReadFilePaging ( call ) ;
399+ const { limit } = resolveReadFilePaging ( call ) ;
310400
311401 if ( isToolOutputLike ( rawPath ) ) {
312402 const uri = canonicalToolOutputUri ( rawPath ) ;
313403 if ( uri === undefined ) {
314404 return next ( call , signal ) ;
315405 }
406+
407+ const cursorId = uri . startsWith ( cursorUriPrefix ) ? uri . slice ( cursorUriPrefix . length ) : "" ;
408+ const cursor = cursorId . length > 0 ? cursors . get ( cursorId ) : undefined ;
409+ if ( cursor !== undefined && cursor . consumed ) {
410+ // Known cursor, already used -- distinct from a genuine missing
411+ // blob: name the original source and offset so recovery is one
412+ // targeted call, not a from-scratch re-read of the whole file.
413+ return { callId : call . id , content : staleCursorMessage ( cursor ) , isError : true } ;
414+ }
415+ if ( cursor !== undefined ) {
416+ // A cursor is authoritative on position: the model passes only the
417+ // handle (and optionally a limit), never an offset back into it.
418+ cursor . consumed = true ;
419+ try {
420+ signal . throwIfAborted ( ) ;
421+ if ( cursor . kind === "file" ) {
422+ const res = await readFileBounded ( cursor . absolutePath , cursor . offset , limit , signal ) ;
423+ return res . isError
424+ ? { callId : call . id , content : res . content , isError : true }
425+ : {
426+ callId : call . id ,
427+ content : mintCursor ( res . content , cursors , {
428+ kind : "file" ,
429+ absolutePath : cursor . absolutePath ,
430+ } ) ,
431+ } ;
432+ }
433+ if ( blobReader === undefined ) {
434+ return {
435+ callId : call . id ,
436+ content : `cannot read ${ rawPath } : no blob reader is configured for tool-output spills` ,
437+ isError : true ,
438+ } ;
439+ }
440+ const bytes = await blobReader . read ( cursor . uri ) ;
441+ const res = await readBytesBounded ( bytes , cursor . offset , limit , signal , cursor . uri ) ;
442+ return res . isError
443+ ? { callId : call . id , content : res . content , isError : true }
444+ : {
445+ callId : call . id ,
446+ content : mintCursor ( res . content , cursors , { kind : "blob" , uri : cursor . uri } ) ,
447+ } ;
448+ } catch ( err ) {
449+ return {
450+ callId : call . id ,
451+ content : err instanceof Error ? err . message : String ( err ) ,
452+ isError : true ,
453+ } ;
454+ }
455+ }
456+
316457 if ( blobReader === undefined ) {
317458 return {
318459 callId : call . id ,
@@ -322,11 +463,12 @@ export function readFileGuardPlugin(
322463 }
323464 try {
324465 signal . throwIfAborted ( ) ;
466+ const { offset } = resolveReadFilePaging ( call ) ;
325467 const bytes = await blobReader . read ( uri ) ;
326468 const res = await readBytesBounded ( bytes , offset , limit , signal , uri ) ;
327469 return res . isError
328470 ? { callId : call . id , content : res . content , isError : true }
329- : { callId : call . id , content : res . content } ;
471+ : { callId : call . id , content : mintCursor ( res . content , cursors , { kind : "blob" , uri } ) } ;
330472 } catch ( err ) {
331473 return {
332474 callId : call . id ,
@@ -346,10 +488,14 @@ export function readFileGuardPlugin(
346488 }
347489
348490 try {
491+ const { offset } = resolveReadFilePaging ( call ) ;
349492 const res = await readFileBounded ( absolutePath , offset , limit , signal ) ;
350493 return res . isError
351494 ? { callId : call . id , content : res . content , isError : true }
352- : { callId : call . id , content : res . content } ;
495+ : {
496+ callId : call . id ,
497+ content : mintCursor ( res . content , cursors , { kind : "file" , absolutePath } ) ,
498+ } ;
353499 } catch ( err ) {
354500 return {
355501 callId : call . id ,
0 commit comments