Skip to content

Commit a7a8635

Browse files
committed
feat(read-file): page large read_file responses through to the end
Large-file pages used to dead-end: the scan ceiling fired while unread content remained, overlong lines were grep-only, and the 10k re-cut mangled footer-bearing pages. Resume now seeks to the stored point, pages carry both offset and cursor continuations, and footer-bearing pages pass the truncation layer untouched.
1 parent 5d573a3 commit a7a8635

4 files changed

Lines changed: 81 additions & 16 deletions

File tree

‎src/agent/posix-tool-plugins.test.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,13 @@ describe("buildCorePosixToolPlugins", () => {
119119
new AbortController().signal,
120120
);
121121
expect(allowed.isError).not.toBe(true);
122-
// The read-file guard caps the read before result-truncation would run,
123-
// so a 90KB single line comes back line-truncated and bounded.
124-
expect(String(allowed.content)).toContain("line truncated at 2000 chars");
125-
expect(Buffer.byteLength(String(allowed.content), "utf8")).toBeLessThan(
126-
4096,
122+
// The read-file guard windows the overlong line into a bounded page, so
123+
// a 90KB single line comes back pageable: the tail stays reachable by
124+
// path+offset continuation, not only by grep.
125+
expect(String(allowed.content)).not.toContain("line truncated");
126+
expect(String(allowed.content)).toContain("to continue");
127+
expect(Buffer.byteLength(String(allowed.content), "utf8")).toBeLessThanOrEqual(
128+
50 * 1024,
127129
);
128130
} finally {
129131
await rm(cwd, { recursive: true, force: true });

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

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,19 @@ import { formatReadFileTimeoutMessage } from "./tool-time-budget.js";
2121
export const READ_FILE_MAX_BYTES = 50 * 1024;
2222
export const READ_FILE_DEFAULT_MAX_LINES = 2000;
2323
export const READ_FILE_MAX_LINE_LENGTH = 2000;
24-
// Absolute ceiling on bytes scanned from disk, so a deep offset into a huge file
25-
// stays time-bounded even though memory is already bounded by the streaming read.
24+
// Absolute ceiling on bytes scanned from disk past the requested offset, so an
25+
// emission window stays time-bounded even though memory is already bounded by
26+
// the streaming read. Bytes skipped to reach a nonzero offset do not count:
27+
// continuation past the ceiling must read through to the end, not dead-end
28+
// with a scan limit while unread content remains.
2629
export const READ_FILE_MAX_SCAN_BYTES = 8 * 1024 * 1024;
2730
/** Refuse tool-output blobs larger than this before bounded paging. */
2831
export const READ_FILE_MAX_TOOL_OUTPUT_BYTES = READ_FILE_MAX_SCAN_BYTES;
2932
// Headroom reserved out of the byte budget for the continuation notice, so the
30-
// returned payload including the notice stays under READ_FILE_MAX_BYTES.
31-
const NOTICE_RESERVE_BYTES = 256;
33+
// returned payload including the notice stays under READ_FILE_MAX_BYTES. Sized
34+
// for a dual footer: the plain `Use offset=` continuation plus the appended
35+
// single-use cursor alias on byte/scan-limit pages.
36+
const NOTICE_RESERVE_BYTES = 384;
3237

3338
const LINE_TRUNC_SUFFIX = ` ... [line truncated at ${READ_FILE_MAX_LINE_LENGTH} chars; full line remains in the file — use grep to match within the line]`;
3439
const TOOL_OUTPUT_CHUNK_BYTES = 64 * 1024;
@@ -81,6 +86,13 @@ function mapFilesystemStreamError(
8186
* When `wrapLongLines` is set, overlong lines are split into successive numbered
8287
* windows instead of being truncated and dropped — so a giant JSON line can be
8388
* paged through with the same offset protocol as a multi-line file.
89+
* When `windowHugeLines` is set instead, only single lines that on their own
90+
* exceed the output budget are windowed; ordinary lines keep their numbers, so
91+
* plain path+offset pagination stays line-aligned.
92+
* The scan ceiling counts only bytes past the requested offset: bytes skipped
93+
* to reach a nonzero offset never trip it, so continuation on a large file
94+
* reads through to the end instead of dead-ending with a scan limit while
95+
* unread content remains.
8496
*/
8597
function readStreamBounded(
8698
stream: Readable,
@@ -91,10 +103,12 @@ function readStreamBounded(
91103
options: {
92104
mapStreamError?: (err: NodeJS.ErrnoException) => Error;
93105
wrapLongLines?: boolean;
106+
windowHugeLines?: boolean;
94107
} = {},
95108
): Promise<BoundedRead> {
96109
return new Promise<BoundedRead>((resolveP, rejectP) => {
97-
const { mapStreamError, wrapLongLines = false } = options;
110+
const { mapStreamError, wrapLongLines = false, windowHugeLines = false } =
111+
options;
98112
const decoder = new StringDecoder("utf8");
99113
const contentBudget = READ_FILE_MAX_BYTES - NOTICE_RESERVE_BYTES;
100114

@@ -103,12 +117,17 @@ function readStreamBounded(
103117
let firstChunk = true;
104118
let lineNo = 0;
105119
let scanned = 0;
120+
let skipDone = offset <= 0;
106121
let outBytes = 0;
107122
let emitted = 0;
108123
let lastEmittedLine = 0;
109124
let truncReason: TruncReason | undefined;
110125
let endReached = false;
111126
let settled = false;
127+
// Set when the scan ceiling trips: the trailing partial is reported
128+
// truncated (never windowed), so a line longer than one scan pass keeps
129+
// the scan-limit notice instead of a byte-limit page.
130+
let scanCapped = false;
112131

113132
const out: string[] = [];
114133

@@ -136,6 +155,7 @@ function readStreamBounded(
136155
const handleLine = (raw: string, overflow: boolean): boolean => {
137156
lineNo++;
138157
if (lineNo <= offset) return true;
158+
skipDone = true;
139159
if (emitted >= limit) {
140160
truncReason = "lines";
141161
return false;
@@ -174,7 +194,13 @@ function readStreamBounded(
174194
const nl = pending.indexOf("\n");
175195
if (nl === -1) {
176196
if (wrapLongLines) return emitWrapped(pending, false);
177-
if (pending.length > READ_FILE_MAX_LINE_LENGTH) {
197+
if (pending.length > READ_FILE_MAX_LINE_LENGTH && !windowHugeLines) {
198+
pending = pending.slice(0, READ_FILE_MAX_LINE_LENGTH);
199+
pendingOverflow = true;
200+
} else if (
201+
windowHugeLines &&
202+
pending.length > READ_FILE_MAX_SCAN_BYTES
203+
) {
178204
pending = pending.slice(0, READ_FILE_MAX_LINE_LENGTH);
179205
pendingOverflow = true;
180206
}
@@ -187,7 +213,9 @@ function readStreamBounded(
187213
} else {
188214
const overflow = pendingOverflow;
189215
pendingOverflow = false;
190-
if (!handleLine(line, overflow)) return false;
216+
if (!overflow && windowHugeLines && line.length > contentBudget) {
217+
if (!emitWrapped(line, true)) return false;
218+
} else if (!handleLine(line, overflow)) return false;
191219
}
192220
}
193221
};
@@ -198,6 +226,10 @@ function readStreamBounded(
198226
emitWrapped(pending, true);
199227
return;
200228
}
229+
if (!pendingOverflow && !scanCapped && windowHugeLines && pending.length > contentBudget) {
230+
emitWrapped(pending, true);
231+
return;
232+
}
201233
handleLine(pending, pendingOverflow);
202234
};
203235

@@ -207,6 +239,9 @@ function readStreamBounded(
207239
done({ content: "" });
208240
return;
209241
}
242+
// Skip bytes are not scanned, so a dead offset on a file larger than
243+
// the ceiling still reaches EOF. Report the true range, not a scan
244+
// limit that would hide a reachable end of file.
210245
if (endReached) {
211246
done({
212247
content: `[offset ${offset} is beyond end of file ${displayPath} (${lineNo} lines); valid offsets 0-${lineNo - 1}]`,
@@ -242,13 +277,14 @@ function readStreamBounded(
242277
return;
243278
}
244279
}
245-
scanned += chunk.length;
280+
if (skipDone) scanned += chunk.length;
246281
pending += decoder.write(chunk);
247282
if (!drainPending()) {
248283
finishOk();
249284
return;
250285
}
251286
if (scanned >= READ_FILE_MAX_SCAN_BYTES) {
287+
scanCapped = true;
252288
flushRemainder();
253289
if (truncReason === undefined) truncReason = "scan";
254290
finishOk();
@@ -292,6 +328,7 @@ export function readFileBounded(
292328
signal,
293329
{
294330
mapStreamError: (err) => mapFilesystemStreamError(absolutePath, err),
331+
windowHugeLines: true,
295332
},
296333
);
297334
}

‎src/plugins/result-truncation-plugin.ts‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,29 @@ export async function applyToolResultTruncation(
293293
return result;
294294
}
295295

296+
// A read_file page already carries its own continuation contract (a plain
297+
// `Use offset=` footer, a single-use cursor alias, or both). Re-cutting it at
298+
// the 10k leisure cap would slice the footer off the page boundary and strand
299+
// the pagination chain, so footer-bearing read_file pages pass through intact.
300+
// Pages without a footer take the normal path.
301+
const READ_FILE_CONTINUATION_RE = /Use offset=\d+ to continue\.|Use path="tool-output:\/\/\//;
302+
303+
function isPagedReadFilePage(
304+
toolName: string | undefined,
305+
result: ToolResult,
306+
): boolean {
307+
return (
308+
toolName === "read_file" &&
309+
typeof result.content === "string" &&
310+
READ_FILE_CONTINUATION_RE.test(result.content)
311+
);
312+
}
313+
296314
async function archiveThenTruncate(
297315
result: ToolResult,
298316
callId: string,
299317
options: ResultTruncationPluginOptions,
318+
toolName?: string,
300319
): Promise<ToolResult> {
301320
const archive = options.getEvidenceArchive?.();
302321
try {
@@ -320,6 +339,7 @@ async function archiveThenTruncate(
320339
}
321340

322341
const before = result.content;
342+
if (isPagedReadFilePage(toolName, result)) return result;
323343
const truncated = await applyToolResultTruncation(
324344
result,
325345
spillOptionsForCall(callId, options),
@@ -368,7 +388,12 @@ export function wrapAgentToolResultTruncation(
368388
return {
369389
...tool,
370390
handler: async (call: ToolCall, signal: AbortSignal) =>
371-
archiveThenTruncate(await inner(call, signal), call.id, options),
391+
archiveThenTruncate(
392+
await inner(call, signal),
393+
call.id,
394+
options,
395+
tool.definition.name,
396+
),
372397
};
373398
}
374399
const inner = tool.handler;
@@ -380,6 +405,7 @@ export function wrapAgentToolResultTruncation(
380405
{ callId: call.id, content: await inner(call.arguments, signal) },
381406
call.id,
382407
options,
408+
tool.definition.name,
383409
),
384410
};
385411
}
@@ -397,7 +423,7 @@ export function resultTruncationPlugin(
397423
return {
398424
middleware: (next) => async (call, signal) => {
399425
const result = await next(call, signal);
400-
return archiveThenTruncate(result, call.id, options);
426+
return archiveThenTruncate(result, call.id, options, call.name);
401427
},
402428
};
403429
}

‎vendor/intx-tools-posix/src/registry.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
4242
{
4343
name: TOOL_NAMES.READ_FILE,
4444
description:
45-
"Read a file and return its content with line numbers. The path argument accepts either a filesystem path or a tool-output URI of the form tool-output:///{callId} that references a prior tool result.",
45+
"Read a file and return its content with line numbers. The path argument accepts either a filesystem path or a tool-output URI of the form tool-output:///{callId} that references a prior tool result. Large files are returned one page at a time: pass offset and limit to page through the file, then follow the `Use offset=N to continue.` footer to read the next page through to the end.",
4646
inputSchema: {
4747
type: "object",
4848
properties: {

0 commit comments

Comments
 (0)