Skip to content

Commit 396339a

Browse files
Merge pull request #869 from corbitsdev/cl-7620-page-tool-output-blobs-by-byte-not-by-line
Page tool-output blobs by byte windows not line caps
2 parents cbdd3ae + f808073 commit 396339a

2 files changed

Lines changed: 198 additions & 19 deletions

File tree

src/plugins/read-file-guard-plugin.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,61 @@ describe("readFileBounded", () => {
216216
);
217217
});
218218

219+
test("pages a giant one-line blob by wrapping through the byte window", async () => {
220+
const giant = `HEAD-${"x".repeat(READ_FILE_MAX_BYTES)}-TAIL`;
221+
const bytes = new TextEncoder().encode(giant);
222+
const { content, isError } = await readBytesBounded(
223+
bytes,
224+
0,
225+
Number.POSITIVE_INFINITY,
226+
neverAbort(),
227+
"tool-output:///giant-line",
228+
);
229+
expect(isError).toBeUndefined();
230+
expect(content).toContain("HEAD-");
231+
expect(content).not.toContain("-TAIL");
232+
expect(content).not.toContain("line truncated");
233+
expect(content).toContain("output limit");
234+
expect(content).toContain("Use offset=");
235+
expect(Buffer.byteLength(content, "utf8")).toBeLessThanOrEqual(
236+
READ_FILE_MAX_BYTES,
237+
);
238+
const body = content.split("\n\n")[0] ?? "";
239+
const numbered = body.trimEnd().split("\n");
240+
expect(numbered.length).toBeGreaterThan(1);
241+
for (const line of numbered) {
242+
const text = line.replace(/^\s*\d+\t/, "");
243+
expect(text.length).toBeLessThanOrEqual(READ_FILE_MAX_LINE_LENGTH);
244+
}
245+
});
246+
247+
test("returns a pretty-printed blob past the 2000-line file cap when it fits the byte window", async () => {
248+
const pretty = `${JSON.stringify(
249+
Array.from({ length: READ_FILE_DEFAULT_MAX_LINES + 500 }, (_, i) => i),
250+
null,
251+
2,
252+
)}\n`;
253+
const bytes = new TextEncoder().encode(pretty);
254+
const { content, isError } = await readBytesBounded(
255+
bytes,
256+
0,
257+
Number.POSITIVE_INFINITY,
258+
neverAbort(),
259+
"tool-output:///pretty-json",
260+
);
261+
expect(isError).toBeUndefined();
262+
const sourceLines = pretty.trimEnd().split("\n").length;
263+
expect(sourceLines).toBeGreaterThan(READ_FILE_DEFAULT_MAX_LINES);
264+
const body = content.split("\n\n")[0] ?? "";
265+
expect(body.trimEnd().split("\n").length).toBe(sourceLines);
266+
expect(content).toContain(String(READ_FILE_DEFAULT_MAX_LINES + 499));
267+
expect(content).not.toContain("line limit");
268+
expect(content).not.toContain("Use offset=");
269+
expect(Buffer.byteLength(content, "utf8")).toBeLessThanOrEqual(
270+
READ_FILE_MAX_BYTES,
271+
);
272+
});
273+
219274
test("offset past the scan ceiling reports the scan limit, not a fake EOF", async () => {
220275
// Many short lines totaling more than the scan ceiling; a huge offset can
221276
// never be reached within one scan pass.
@@ -313,6 +368,78 @@ describe("readFileGuardPlugin", () => {
313368
expect(result.content).not.toBe("FALLBACK");
314369
});
315370

371+
test("pages a giant one-line tool-output blob across byte windows and resumes via the minted cursor", async () => {
372+
const encoder = new TextEncoder();
373+
const payload = `HEAD-${"x".repeat(READ_FILE_MAX_BYTES)}-TAIL`;
374+
const blobReader = createBlobReader({
375+
async readBlob(key) {
376+
if (key === "giant-line") return encoder.encode(payload);
377+
throw new Error(`missing ${key}`);
378+
},
379+
});
380+
const plugin = readFileGuardPlugin(dir, { blobReader });
381+
const middleware = defined(plugin.middleware)(fallback);
382+
const first = await middleware(
383+
{
384+
id: "g1",
385+
name: "read_file",
386+
arguments: { path: "tool-output:///giant-line" },
387+
},
388+
neverAbort(),
389+
);
390+
expect(first.isError).toBeFalsy();
391+
const firstContent = String(first.content);
392+
expect(firstContent).toContain("HEAD-");
393+
expect(firstContent).not.toContain("-TAIL");
394+
expect(firstContent).not.toContain("line truncated");
395+
expect(firstContent).toContain("output limit");
396+
expect(Buffer.byteLength(firstContent, "utf8")).toBeLessThanOrEqual(
397+
READ_FILE_MAX_BYTES,
398+
);
399+
const match = /Use path="(tool-output:\/\/\/[^"]+)"/.exec(firstContent);
400+
expect(match).not.toBeNull();
401+
const nextPath = (match as RegExpExecArray)[1] as string;
402+
expect(nextPath).toMatch(/^tool-output:\/\/\//);
403+
404+
const second = await middleware(
405+
{ id: "g2", name: "read_file", arguments: { path: nextPath } },
406+
neverAbort(),
407+
);
408+
expect(second.isError).toBeFalsy();
409+
expect(String(second.content)).toContain("-TAIL");
410+
});
411+
412+
test("returns pretty-printed tool-output past the 2000-line file cap when it fits the byte window", async () => {
413+
const encoder = new TextEncoder();
414+
const pretty = `${JSON.stringify(
415+
Array.from({ length: READ_FILE_DEFAULT_MAX_LINES + 500 }, (_, i) => i),
416+
null,
417+
2,
418+
)}\n`;
419+
const blobReader = createBlobReader({
420+
async readBlob(key) {
421+
if (key === "pretty-json") return encoder.encode(pretty);
422+
throw new Error(`missing ${key}`);
423+
},
424+
});
425+
const result = await run(
426+
{
427+
id: "pretty1",
428+
name: "read_file",
429+
arguments: { path: "tool-output:///pretty-json" },
430+
},
431+
blobReader,
432+
);
433+
expect(result.isError).toBeFalsy();
434+
const content = String(result.content);
435+
expect(pretty.trimEnd().split("\n").length).toBeGreaterThan(
436+
READ_FILE_DEFAULT_MAX_LINES,
437+
);
438+
expect(content).toContain(String(READ_FILE_DEFAULT_MAX_LINES + 499));
439+
expect(content).not.toContain("line limit");
440+
expect(content).not.toContain('Use path="tool-output:///');
441+
});
442+
316443
test("pages tool-output blobs above the display ceiling instead of rejecting the spill", async () => {
317444
const encoder = new TextEncoder();
318445
const huge = encoder.encode(

src/plugins/read-file-guard-plugin.ts

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
2828
export 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. */
3030
export 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
*/
162165
function 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
*/
348386
export 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
*/
410454
export 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

Comments
 (0)