@@ -26,7 +26,7 @@ export const READ_FILE_MAX_LINE_LENGTH = 2000;
2626// Absolute ceiling on bytes scanned from disk, so a deep offset into a huge file
2727// stays time-bounded even though memory is already bounded by the streaming read.
2828export const READ_FILE_MAX_SCAN_BYTES = 8 * 1024 * 1024 ;
29- /** Refuse tool-output blobs larger than this before bounded line processing . */
29+ /** Refuse tool-output blobs larger than this before bounded paging . */
3030export const READ_FILE_MAX_TOOL_OUTPUT_BYTES = READ_FILE_MAX_SCAN_BYTES ;
3131// Headroom reserved out of the byte budget for the continuation notice, so the
3232// returned payload including the notice stays under READ_FILE_MAX_BYTES.
@@ -158,16 +158,23 @@ function mapFilesystemStreamError(
158158/**
159159 * Streams UTF-8 from `stream`, emitting up to `limit` line-numbered lines after
160160 * skipping `offset` lines (zero-based). Never splits the full decoded text in one pass.
161+ * When `wrapLongLines` is set, overlong lines are split into successive numbered
162+ * windows instead of being truncated and dropped — so a giant JSON line can be
163+ * paged through with the same offset/cursor protocol as a multi-line file.
161164 */
162165function readStreamBounded (
163166 stream : Readable ,
164167 displayPath : string ,
165168 offset : number ,
166169 limit : number ,
167170 signal : AbortSignal ,
168- mapStreamError ?: ( err : NodeJS . ErrnoException ) => Error ,
171+ options : {
172+ mapStreamError ?: ( err : NodeJS . ErrnoException ) => Error ;
173+ wrapLongLines ?: boolean ;
174+ } = { } ,
169175) : Promise < BoundedRead > {
170176 return new Promise < BoundedRead > ( ( resolveP , rejectP ) => {
177+ const { mapStreamError, wrapLongLines = false } = options ;
171178 const decoder = new StringDecoder ( "utf8" ) ;
172179 const contentBudget = READ_FILE_MAX_BYTES - NOTICE_RESERVE_BYTES ;
173180
@@ -230,10 +237,23 @@ function readStreamBounded(
230237 return true ;
231238 } ;
232239
240+ const emitWrapped = ( raw : string , keepTail : boolean ) : boolean => {
241+ let rest = raw ;
242+ while ( rest . length > READ_FILE_MAX_LINE_LENGTH ) {
243+ if ( ! handleLine ( rest . slice ( 0 , READ_FILE_MAX_LINE_LENGTH ) , false ) )
244+ return false ;
245+ rest = rest . slice ( READ_FILE_MAX_LINE_LENGTH ) ;
246+ }
247+ if ( keepTail ) return handleLine ( rest , false ) ;
248+ pending = rest ;
249+ return true ;
250+ } ;
251+
233252 const drainPending = ( ) : boolean => {
234253 for ( ; ; ) {
235254 const nl = pending . indexOf ( "\n" ) ;
236255 if ( nl === - 1 ) {
256+ if ( wrapLongLines ) return emitWrapped ( pending , false ) ;
237257 if ( pending . length > READ_FILE_MAX_LINE_LENGTH ) {
238258 pending = pending . slice ( 0 , READ_FILE_MAX_LINE_LENGTH ) ;
239259 pendingOverflow = true ;
@@ -242,12 +262,25 @@ function readStreamBounded(
242262 }
243263 const line = pending . slice ( 0 , nl ) ;
244264 pending = pending . slice ( nl + 1 ) ;
245- const overflow = pendingOverflow ;
246- pendingOverflow = false ;
247- if ( ! handleLine ( line , overflow ) ) return false ;
265+ if ( wrapLongLines ) {
266+ if ( ! emitWrapped ( line , true ) ) return false ;
267+ } else {
268+ const overflow = pendingOverflow ;
269+ pendingOverflow = false ;
270+ if ( ! handleLine ( line , overflow ) ) return false ;
271+ }
248272 }
249273 } ;
250274
275+ const flushRemainder = ( ) : void => {
276+ if ( pending . length === 0 ) return ;
277+ if ( wrapLongLines ) {
278+ emitWrapped ( pending , true ) ;
279+ return ;
280+ }
281+ handleLine ( pending , pendingOverflow ) ;
282+ } ;
283+
251284 const finishOk = ( ) => {
252285 if ( emitted === 0 ) {
253286 if ( lineNo === 0 && endReached ) {
@@ -296,7 +329,7 @@ function readStreamBounded(
296329 return ;
297330 }
298331 if ( scanned >= READ_FILE_MAX_SCAN_BYTES ) {
299- if ( pending . length > 0 ) handleLine ( pending , pendingOverflow ) ;
332+ flushRemainder ( ) ;
300333 if ( truncReason === undefined ) truncReason = "scan" ;
301334 finishOk ( ) ;
302335 }
@@ -306,7 +339,7 @@ function readStreamBounded(
306339 if ( settled ) return ;
307340 endReached = true ;
308341 pending += decoder . end ( ) ;
309- if ( pending . length > 0 ) handleLine ( pending , pendingOverflow ) ;
342+ flushRemainder ( ) ;
310343 finishOk ( ) ;
311344 } ) ;
312345
@@ -337,13 +370,18 @@ export function readFileBounded(
337370 offset ,
338371 limit ,
339372 signal ,
340- ( err ) => mapFilesystemStreamError ( absolutePath , err ) ,
373+ {
374+ mapStreamError : ( err ) => mapFilesystemStreamError ( absolutePath , err ) ,
375+ } ,
341376 ) ;
342377}
343378
344379/**
345- * Bounded line read over an in-memory UTF-8 blob (tool-output spills). Feeds the
346- * buffer in chunks so offset/limit never require a full-text split.
380+ * Bounded read over an in-memory UTF-8 blob (tool-output spills). Feeds the
381+ * buffer in chunks so offset/limit never require a full-text split. Overlong
382+ * lines wrap into numbered windows instead of being truncated and dropped, and
383+ * callers should pass a high `limit` so the byte budget — not the source-file
384+ * 2000-line cap — pages the spill.
347385 */
348386export function readBytesBounded (
349387 bytes : Uint8Array ,
@@ -365,6 +403,9 @@ export function readBytesBounded(
365403 offset ,
366404 limit ,
367405 signal ,
406+ {
407+ wrapLongLines : true ,
408+ } ,
368409 ) ;
369410}
370411
@@ -388,7 +429,10 @@ function continuationNotice(
388429 } MB scan limit. ${ next } ]`;
389430}
390431
391- function resolveReadFilePaging ( call : { arguments : Record < string , unknown > } ) : {
432+ function resolveReadFilePaging (
433+ call : { arguments : Record < string , unknown > } ,
434+ defaultLimit = READ_FILE_DEFAULT_MAX_LINES ,
435+ ) : {
392436 offset : number ;
393437 limit : number ;
394438} {
@@ -399,13 +443,13 @@ function resolveReadFilePaging(call: { arguments: Record<string, unknown> }): {
399443 const limit =
400444 limitArg !== undefined && limitArg > 0
401445 ? Math . floor ( limitArg )
402- : READ_FILE_DEFAULT_MAX_LINES ;
446+ : defaultLimit ;
403447 return { offset, limit } ;
404448}
405449
406450/**
407- * Short-circuits read_file for real filesystem paths and configured tool-output URIs
408- * with streaming, byte- and line-capped reads . Does not modify interchange.
451+ * Short-circuits read_file for filesystem paths (line-capped) and tool-output
452+ * URIs ( byte-windowed, wrapping long lines) . Does not modify interchange.
409453 */
410454export function readFileGuardPlugin (
411455 cwd : string ,
@@ -426,7 +470,11 @@ export function readFileGuardPlugin(
426470 return next ( call , signal ) ;
427471 }
428472
429- const { limit } = resolveReadFilePaging ( call ) ;
473+ const { offset, limit } = resolveReadFilePaging ( call ) ;
474+ const { limit : blobLimit } = resolveReadFilePaging (
475+ call ,
476+ Number . POSITIVE_INFINITY ,
477+ ) ;
430478
431479 if ( isToolOutputLike ( rawPath ) ) {
432480 const uri = canonicalToolOutputUri ( rawPath ) ;
@@ -482,7 +530,7 @@ export function readFileGuardPlugin(
482530 const res = await readBytesBounded (
483531 bytes ,
484532 cursor . offset ,
485- limit ,
533+ blobLimit ,
486534 signal ,
487535 cursor . uri ,
488536 ) ;
@@ -513,9 +561,14 @@ export function readFileGuardPlugin(
513561 }
514562 try {
515563 signal . throwIfAborted ( ) ;
516- const { offset } = resolveReadFilePaging ( call ) ;
517564 const bytes = await blobReader . read ( uri ) ;
518- const res = await readBytesBounded ( bytes , offset , limit , signal , uri ) ;
565+ const res = await readBytesBounded (
566+ bytes ,
567+ offset ,
568+ blobLimit ,
569+ signal ,
570+ uri ,
571+ ) ;
519572 return res . isError
520573 ? { callId : call . id , content : res . content , isError : true }
521574 : {
@@ -544,7 +597,6 @@ export function readFileGuardPlugin(
544597 }
545598
546599 try {
547- const { offset } = resolveReadFilePaging ( call ) ;
548600 const res = await readFileBounded ( absolutePath , offset , limit , signal ) ;
549601 return res . isError
550602 ? { callId : call . id , content : res . content , isError : true }
0 commit comments