From f80807394401ec508148bc5578f3dafc1c774d07 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 01:14:42 -0700 Subject: [PATCH] Page tool-output blobs by byte windows not line caps A giant one-line spill was truncated at 2000 characters and the rest dropped, so the pager could not continue. Pretty-printed spills hit the source-file line cap even when they fit in the 50KB window. Blob reads now wrap long lines and page on the byte budget, reusing the existing cursor protocol. --- src/plugins/read-file-guard-plugin.test.ts | 127 +++++++++++++++++++++ src/plugins/read-file-guard-plugin.ts | 90 ++++++++++++--- 2 files changed, 198 insertions(+), 19 deletions(-) diff --git a/src/plugins/read-file-guard-plugin.test.ts b/src/plugins/read-file-guard-plugin.test.ts index da3674d25..5a3e920fe 100644 --- a/src/plugins/read-file-guard-plugin.test.ts +++ b/src/plugins/read-file-guard-plugin.test.ts @@ -216,6 +216,61 @@ describe("readFileBounded", () => { ); }); + test("pages a giant one-line blob by wrapping through the byte window", async () => { + const giant = `HEAD-${"x".repeat(READ_FILE_MAX_BYTES)}-TAIL`; + const bytes = new TextEncoder().encode(giant); + const { content, isError } = await readBytesBounded( + bytes, + 0, + Number.POSITIVE_INFINITY, + neverAbort(), + "tool-output:///giant-line", + ); + expect(isError).toBeUndefined(); + expect(content).toContain("HEAD-"); + expect(content).not.toContain("-TAIL"); + expect(content).not.toContain("line truncated"); + expect(content).toContain("output limit"); + expect(content).toContain("Use offset="); + expect(Buffer.byteLength(content, "utf8")).toBeLessThanOrEqual( + READ_FILE_MAX_BYTES, + ); + const body = content.split("\n\n")[0] ?? ""; + const numbered = body.trimEnd().split("\n"); + expect(numbered.length).toBeGreaterThan(1); + for (const line of numbered) { + const text = line.replace(/^\s*\d+\t/, ""); + expect(text.length).toBeLessThanOrEqual(READ_FILE_MAX_LINE_LENGTH); + } + }); + + test("returns a pretty-printed blob past the 2000-line file cap when it fits the byte window", async () => { + const pretty = `${JSON.stringify( + Array.from({ length: READ_FILE_DEFAULT_MAX_LINES + 500 }, (_, i) => i), + null, + 2, + )}\n`; + const bytes = new TextEncoder().encode(pretty); + const { content, isError } = await readBytesBounded( + bytes, + 0, + Number.POSITIVE_INFINITY, + neverAbort(), + "tool-output:///pretty-json", + ); + expect(isError).toBeUndefined(); + const sourceLines = pretty.trimEnd().split("\n").length; + expect(sourceLines).toBeGreaterThan(READ_FILE_DEFAULT_MAX_LINES); + const body = content.split("\n\n")[0] ?? ""; + expect(body.trimEnd().split("\n").length).toBe(sourceLines); + expect(content).toContain(String(READ_FILE_DEFAULT_MAX_LINES + 499)); + expect(content).not.toContain("line limit"); + expect(content).not.toContain("Use offset="); + expect(Buffer.byteLength(content, "utf8")).toBeLessThanOrEqual( + READ_FILE_MAX_BYTES, + ); + }); + test("offset past the scan ceiling reports the scan limit, not a fake EOF", async () => { // Many short lines totaling more than the scan ceiling; a huge offset can // never be reached within one scan pass. @@ -313,6 +368,78 @@ describe("readFileGuardPlugin", () => { expect(result.content).not.toBe("FALLBACK"); }); + test("pages a giant one-line tool-output blob across byte windows and resumes via the minted cursor", async () => { + const encoder = new TextEncoder(); + const payload = `HEAD-${"x".repeat(READ_FILE_MAX_BYTES)}-TAIL`; + const blobReader = createBlobReader({ + async readBlob(key) { + if (key === "giant-line") return encoder.encode(payload); + throw new Error(`missing ${key}`); + }, + }); + const plugin = readFileGuardPlugin(dir, { blobReader }); + const middleware = defined(plugin.middleware)(fallback); + const first = await middleware( + { + id: "g1", + name: "read_file", + arguments: { path: "tool-output:///giant-line" }, + }, + neverAbort(), + ); + expect(first.isError).toBeFalsy(); + const firstContent = String(first.content); + expect(firstContent).toContain("HEAD-"); + expect(firstContent).not.toContain("-TAIL"); + expect(firstContent).not.toContain("line truncated"); + expect(firstContent).toContain("output limit"); + expect(Buffer.byteLength(firstContent, "utf8")).toBeLessThanOrEqual( + READ_FILE_MAX_BYTES, + ); + const match = /Use path="(tool-output:\/\/\/[^"]+)"/.exec(firstContent); + expect(match).not.toBeNull(); + const nextPath = (match as RegExpExecArray)[1] as string; + expect(nextPath).toMatch(/^tool-output:\/\/\//); + + const second = await middleware( + { id: "g2", name: "read_file", arguments: { path: nextPath } }, + neverAbort(), + ); + expect(second.isError).toBeFalsy(); + expect(String(second.content)).toContain("-TAIL"); + }); + + test("returns pretty-printed tool-output past the 2000-line file cap when it fits the byte window", async () => { + const encoder = new TextEncoder(); + const pretty = `${JSON.stringify( + Array.from({ length: READ_FILE_DEFAULT_MAX_LINES + 500 }, (_, i) => i), + null, + 2, + )}\n`; + const blobReader = createBlobReader({ + async readBlob(key) { + if (key === "pretty-json") return encoder.encode(pretty); + throw new Error(`missing ${key}`); + }, + }); + const result = await run( + { + id: "pretty1", + name: "read_file", + arguments: { path: "tool-output:///pretty-json" }, + }, + blobReader, + ); + expect(result.isError).toBeFalsy(); + const content = String(result.content); + expect(pretty.trimEnd().split("\n").length).toBeGreaterThan( + READ_FILE_DEFAULT_MAX_LINES, + ); + expect(content).toContain(String(READ_FILE_DEFAULT_MAX_LINES + 499)); + expect(content).not.toContain("line limit"); + expect(content).not.toContain('Use path="tool-output:///'); + }); + test("pages tool-output blobs above the display ceiling instead of rejecting the spill", async () => { const encoder = new TextEncoder(); const huge = encoder.encode( diff --git a/src/plugins/read-file-guard-plugin.ts b/src/plugins/read-file-guard-plugin.ts index a1cd47d4a..e5f556d83 100644 --- a/src/plugins/read-file-guard-plugin.ts +++ b/src/plugins/read-file-guard-plugin.ts @@ -26,7 +26,7 @@ export const READ_FILE_MAX_LINE_LENGTH = 2000; // Absolute ceiling on bytes scanned from disk, so a deep offset into a huge file // stays time-bounded even though memory is already bounded by the streaming read. export const READ_FILE_MAX_SCAN_BYTES = 8 * 1024 * 1024; -/** Refuse tool-output blobs larger than this before bounded line processing. */ +/** Refuse tool-output blobs larger than this before bounded paging. */ export const READ_FILE_MAX_TOOL_OUTPUT_BYTES = READ_FILE_MAX_SCAN_BYTES; // Headroom reserved out of the byte budget for the continuation notice, so the // returned payload including the notice stays under READ_FILE_MAX_BYTES. @@ -158,6 +158,9 @@ function mapFilesystemStreamError( /** * Streams UTF-8 from `stream`, emitting up to `limit` line-numbered lines after * skipping `offset` lines (zero-based). Never splits the full decoded text in one pass. + * When `wrapLongLines` is set, overlong lines are split into successive numbered + * windows instead of being truncated and dropped — so a giant JSON line can be + * paged through with the same offset/cursor protocol as a multi-line file. */ function readStreamBounded( stream: Readable, @@ -165,9 +168,13 @@ function readStreamBounded( offset: number, limit: number, signal: AbortSignal, - mapStreamError?: (err: NodeJS.ErrnoException) => Error, + options: { + mapStreamError?: (err: NodeJS.ErrnoException) => Error; + wrapLongLines?: boolean; + } = {}, ): Promise { return new Promise((resolveP, rejectP) => { + const { mapStreamError, wrapLongLines = false } = options; const decoder = new StringDecoder("utf8"); const contentBudget = READ_FILE_MAX_BYTES - NOTICE_RESERVE_BYTES; @@ -230,10 +237,23 @@ function readStreamBounded( return true; }; + const emitWrapped = (raw: string, keepTail: boolean): boolean => { + let rest = raw; + while (rest.length > READ_FILE_MAX_LINE_LENGTH) { + if (!handleLine(rest.slice(0, READ_FILE_MAX_LINE_LENGTH), false)) + return false; + rest = rest.slice(READ_FILE_MAX_LINE_LENGTH); + } + if (keepTail) return handleLine(rest, false); + pending = rest; + return true; + }; + const drainPending = (): boolean => { for (;;) { const nl = pending.indexOf("\n"); if (nl === -1) { + if (wrapLongLines) return emitWrapped(pending, false); if (pending.length > READ_FILE_MAX_LINE_LENGTH) { pending = pending.slice(0, READ_FILE_MAX_LINE_LENGTH); pendingOverflow = true; @@ -242,12 +262,25 @@ function readStreamBounded( } const line = pending.slice(0, nl); pending = pending.slice(nl + 1); - const overflow = pendingOverflow; - pendingOverflow = false; - if (!handleLine(line, overflow)) return false; + if (wrapLongLines) { + if (!emitWrapped(line, true)) return false; + } else { + const overflow = pendingOverflow; + pendingOverflow = false; + if (!handleLine(line, overflow)) return false; + } } }; + const flushRemainder = (): void => { + if (pending.length === 0) return; + if (wrapLongLines) { + emitWrapped(pending, true); + return; + } + handleLine(pending, pendingOverflow); + }; + const finishOk = () => { if (emitted === 0) { if (lineNo === 0 && endReached) { @@ -296,7 +329,7 @@ function readStreamBounded( return; } if (scanned >= READ_FILE_MAX_SCAN_BYTES) { - if (pending.length > 0) handleLine(pending, pendingOverflow); + flushRemainder(); if (truncReason === undefined) truncReason = "scan"; finishOk(); } @@ -306,7 +339,7 @@ function readStreamBounded( if (settled) return; endReached = true; pending += decoder.end(); - if (pending.length > 0) handleLine(pending, pendingOverflow); + flushRemainder(); finishOk(); }); @@ -337,13 +370,18 @@ export function readFileBounded( offset, limit, signal, - (err) => mapFilesystemStreamError(absolutePath, err), + { + mapStreamError: (err) => mapFilesystemStreamError(absolutePath, err), + }, ); } /** - * Bounded line read over an in-memory UTF-8 blob (tool-output spills). Feeds the - * buffer in chunks so offset/limit never require a full-text split. + * Bounded read over an in-memory UTF-8 blob (tool-output spills). Feeds the + * buffer in chunks so offset/limit never require a full-text split. Overlong + * lines wrap into numbered windows instead of being truncated and dropped, and + * callers should pass a high `limit` so the byte budget — not the source-file + * 2000-line cap — pages the spill. */ export function readBytesBounded( bytes: Uint8Array, @@ -365,6 +403,9 @@ export function readBytesBounded( offset, limit, signal, + { + wrapLongLines: true, + }, ); } @@ -388,7 +429,10 @@ function continuationNotice( }MB scan limit. ${next}]`; } -function resolveReadFilePaging(call: { arguments: Record }): { +function resolveReadFilePaging( + call: { arguments: Record }, + defaultLimit = READ_FILE_DEFAULT_MAX_LINES, +): { offset: number; limit: number; } { @@ -399,13 +443,13 @@ function resolveReadFilePaging(call: { arguments: Record }): { const limit = limitArg !== undefined && limitArg > 0 ? Math.floor(limitArg) - : READ_FILE_DEFAULT_MAX_LINES; + : defaultLimit; return { offset, limit }; } /** - * Short-circuits read_file for real filesystem paths and configured tool-output URIs - * with streaming, byte- and line-capped reads. Does not modify interchange. + * Short-circuits read_file for filesystem paths (line-capped) and tool-output + * URIs (byte-windowed, wrapping long lines). Does not modify interchange. */ export function readFileGuardPlugin( cwd: string, @@ -426,7 +470,11 @@ export function readFileGuardPlugin( return next(call, signal); } - const { limit } = resolveReadFilePaging(call); + const { offset, limit } = resolveReadFilePaging(call); + const { limit: blobLimit } = resolveReadFilePaging( + call, + Number.POSITIVE_INFINITY, + ); if (isToolOutputLike(rawPath)) { const uri = canonicalToolOutputUri(rawPath); @@ -482,7 +530,7 @@ export function readFileGuardPlugin( const res = await readBytesBounded( bytes, cursor.offset, - limit, + blobLimit, signal, cursor.uri, ); @@ -513,9 +561,14 @@ export function readFileGuardPlugin( } try { signal.throwIfAborted(); - const { offset } = resolveReadFilePaging(call); const bytes = await blobReader.read(uri); - const res = await readBytesBounded(bytes, offset, limit, signal, uri); + const res = await readBytesBounded( + bytes, + offset, + blobLimit, + signal, + uri, + ); return res.isError ? { callId: call.id, content: res.content, isError: true } : { @@ -544,7 +597,6 @@ export function readFileGuardPlugin( } try { - const { offset } = resolveReadFilePaging(call); const res = await readFileBounded(absolutePath, offset, limit, signal); return res.isError ? { callId: call.id, content: res.content, isError: true }