Skip to content

Commit 7f836db

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 d4de77f commit 7f836db

4 files changed

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

3540
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]`;
3641
const TOOL_OUTPUT_CHUNK_BYTES = 64 * 1024;
@@ -50,14 +55,16 @@ export interface ReadFileGuardPluginOptions {
5055
// the identical path -- exactly the same-path pagination fan-out CL-6961
5156
// measured (97% of 4+-reads-per-path clusters were legitimate chunked reads
5257
// 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://
58+
// calls"). Line-limit pages still mint only a single-use tool-output://
5459
// 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.
60+
// tell the model to pass THAT as `path`, so small-file pagination never looks
61+
// like a same-path loop. Byte/scan-limit pages (large files) keep the plain
62+
// `Use offset=` continuation -- the first-class, fresh-instance-resumable
63+
// path -- and append the cursor alias alongside it, so the tail stays
64+
// reachable by path+offset alone. The cursor is a real, resolvable handle --
65+
// not the "see the blob" promise result-truncation-plugin.ts's comment
66+
// forbids, since nothing here claims discarded bytes are retrievable; it just
67+
// remembers where to resume a fresh bounded read.
6168
type ReadCursor =
6269
| { kind: "file"; absolutePath: string; offset: number; consumed: boolean }
6370
| { kind: "blob"; uri: string; offset: number; consumed: boolean };
@@ -70,7 +77,8 @@ type ReadCursor =
7077
// original path, no offset) as the model's only move.
7178
const MAX_CURSOR_HISTORY = 200;
7279

73-
const CONTINUE_OFFSET_RE = /Use offset=(\d+) to continue\.\]$/;
80+
const CONTINUE_OFFSET_RE = /Use offset=(\d+) to continue\.\]/;
81+
const LINE_LIMIT_NOTICE_RE = /stopped at the \d+-line limit\./;
7482

7583
function pruneCursorHistory(cursors: Map<string, ReadCursor>): void {
7684
while (cursors.size > MAX_CURSOR_HISTORY) {
@@ -80,6 +88,21 @@ function pruneCursorHistory(cursors: Map<string, ReadCursor>): void {
8088
}
8189
}
8290

91+
function sameCursorSource(
92+
cursor: ReadCursor,
93+
source:
94+
| { kind: "file"; absolutePath: string }
95+
| { kind: "blob"; uri: string },
96+
): boolean {
97+
return source.kind === "file"
98+
? cursor.kind === "file" && cursor.absolutePath === source.absolutePath
99+
: cursor.kind === "blob" && cursor.uri === source.uri;
100+
}
101+
102+
function cursorAlias(id: string): string {
103+
return `Use path="${TOOL_OUTPUT_URI_PREFIX}///${id}" (same tool, no offset needed) to continue reading the remainder — a fresh, working handle, not the original path.]`;
104+
}
105+
83106
function mintCursor(
84107
content: string,
85108
cursors: Map<string, ReadCursor>,
@@ -90,6 +113,19 @@ function mintCursor(
90113
const match = CONTINUE_OFFSET_RE.exec(content);
91114
if (match === null) return content;
92115
const offset = Number(match[1]);
116+
// Re-reading an unconsumed resumption point reuses its live cursor, so two
117+
// independent reads of the same page carry the same handle and the page
118+
// survives downstream layers byte-identical. Consumed cursors stay retired:
119+
// only the stale-replay message may name them.
120+
for (const [id, cursor] of cursors) {
121+
if (!cursor.consumed && cursor.offset === offset && sameCursorSource(cursor, source)) {
122+
const alias = cursorAlias(id);
123+
if (LINE_LIMIT_NOTICE_RE.test(content)) {
124+
return content.replace(CONTINUE_OFFSET_RE, alias);
125+
}
126+
return `${content.slice(0, -1)} Or ${alias}`;
127+
}
128+
}
93129
const cursorId = randomUUID();
94130
cursors.set(
95131
cursorId,
@@ -103,10 +139,11 @@ function mintCursor(
103139
: { kind: "blob", uri: source.uri, offset, consumed: false },
104140
);
105141
pruneCursorHistory(cursors);
106-
return content.replace(
107-
CONTINUE_OFFSET_RE,
108-
`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.]`,
109-
);
142+
const alias = cursorAlias(cursorId);
143+
if (LINE_LIMIT_NOTICE_RE.test(content)) {
144+
return content.replace(CONTINUE_OFFSET_RE, alias);
145+
}
146+
return `${content.slice(0, -1)} Or ${alias}`;
110147
}
111148

112149
// Bound the source shown in a stale-cursor message: an adversarial or
@@ -161,6 +198,13 @@ function mapFilesystemStreamError(
161198
* When `wrapLongLines` is set, overlong lines are split into successive numbered
162199
* windows instead of being truncated and dropped — so a giant JSON line can be
163200
* paged through with the same offset/cursor protocol as a multi-line file.
201+
* When `windowHugeLines` is set instead, only single lines that on their own
202+
* exceed the output budget are windowed; ordinary lines keep their numbers, so
203+
* plain path+offset pagination stays line-aligned.
204+
* The scan ceiling counts only bytes past the requested offset: bytes skipped
205+
* to reach a nonzero offset never trip it, so continuation on a large file
206+
* reads through to the end instead of dead-ending with a scan limit while
207+
* unread content remains.
164208
*/
165209
function readStreamBounded(
166210
stream: Readable,
@@ -171,10 +215,12 @@ function readStreamBounded(
171215
options: {
172216
mapStreamError?: (err: NodeJS.ErrnoException) => Error;
173217
wrapLongLines?: boolean;
218+
windowHugeLines?: boolean;
174219
} = {},
175220
): Promise<BoundedRead> {
176221
return new Promise<BoundedRead>((resolveP, rejectP) => {
177-
const { mapStreamError, wrapLongLines = false } = options;
222+
const { mapStreamError, wrapLongLines = false, windowHugeLines = false } =
223+
options;
178224
const decoder = new StringDecoder("utf8");
179225
const contentBudget = READ_FILE_MAX_BYTES - NOTICE_RESERVE_BYTES;
180226

@@ -183,12 +229,18 @@ function readStreamBounded(
183229
let firstChunk = true;
184230
let lineNo = 0;
185231
let scanned = 0;
232+
let diskBytes = 0;
233+
let skipDone = offset <= 0;
186234
let outBytes = 0;
187235
let emitted = 0;
188236
let lastEmittedLine = 0;
189237
let truncReason: TruncReason | undefined;
190238
let endReached = false;
191239
let settled = false;
240+
// Set when the scan ceiling trips: the trailing partial is reported
241+
// truncated (never windowed), so a line longer than one scan pass keeps
242+
// the scan-limit notice instead of a byte-limit page.
243+
let scanCapped = false;
192244

193245
const out: string[] = [];
194246

@@ -216,6 +268,7 @@ function readStreamBounded(
216268
const handleLine = (raw: string, overflow: boolean): boolean => {
217269
lineNo++;
218270
if (lineNo <= offset) return true;
271+
skipDone = true;
219272
if (emitted >= limit) {
220273
truncReason = "lines";
221274
return false;
@@ -254,7 +307,13 @@ function readStreamBounded(
254307
const nl = pending.indexOf("\n");
255308
if (nl === -1) {
256309
if (wrapLongLines) return emitWrapped(pending, false);
257-
if (pending.length > READ_FILE_MAX_LINE_LENGTH) {
310+
if (pending.length > READ_FILE_MAX_LINE_LENGTH && !windowHugeLines) {
311+
pending = pending.slice(0, READ_FILE_MAX_LINE_LENGTH);
312+
pendingOverflow = true;
313+
} else if (
314+
windowHugeLines &&
315+
pending.length > READ_FILE_MAX_SCAN_BYTES
316+
) {
258317
pending = pending.slice(0, READ_FILE_MAX_LINE_LENGTH);
259318
pendingOverflow = true;
260319
}
@@ -267,7 +326,9 @@ function readStreamBounded(
267326
} else {
268327
const overflow = pendingOverflow;
269328
pendingOverflow = false;
270-
if (!handleLine(line, overflow)) return false;
329+
if (!overflow && windowHugeLines && line.length > contentBudget) {
330+
if (!emitWrapped(line, true)) return false;
331+
} else if (!handleLine(line, overflow)) return false;
271332
}
272333
}
273334
};
@@ -278,6 +339,10 @@ function readStreamBounded(
278339
emitWrapped(pending, true);
279340
return;
280341
}
342+
if (!pendingOverflow && !scanCapped && windowHugeLines && pending.length > contentBudget) {
343+
emitWrapped(pending, true);
344+
return;
345+
}
281346
handleLine(pending, pendingOverflow);
282347
};
283348

@@ -287,16 +352,20 @@ function readStreamBounded(
287352
done({ content: "" });
288353
return;
289354
}
290-
if (endReached) {
355+
// An offset never reached after more than one scan pass of the source
356+
// is unlocatable in a single pass: report the scan limit, not a
357+
// beyond-EOF count. Offsets reached within the pass read through even
358+
// when the skipped prefix alone exceeds the ceiling.
359+
if (!endReached || diskBytes > READ_FILE_MAX_SCAN_BYTES) {
291360
done({
292-
content: `[offset ${offset} is beyond end of file (${lineNo} lines)]`,
361+
content: `[reached the ${
362+
READ_FILE_MAX_SCAN_BYTES / (1024 * 1024)
363+
}MB scan limit before offset ${offset}; the file is larger than read_file scans in one pass. Use a smaller offset or grep to locate content.]`,
293364
isError: true,
294365
});
295366
} else {
296367
done({
297-
content: `[reached the ${
298-
READ_FILE_MAX_SCAN_BYTES / (1024 * 1024)
299-
}MB scan limit before offset ${offset}; the file is larger than read_file scans in one pass. Use a smaller offset or grep to locate content.]`,
368+
content: `[offset ${offset} is beyond end of file (${lineNo} lines)]`,
300369
isError: true,
301370
});
302371
}
@@ -322,13 +391,15 @@ function readStreamBounded(
322391
return;
323392
}
324393
}
325-
scanned += chunk.length;
394+
if (skipDone) scanned += chunk.length;
395+
diskBytes += chunk.length;
326396
pending += decoder.write(chunk);
327397
if (!drainPending()) {
328398
finishOk();
329399
return;
330400
}
331401
if (scanned >= READ_FILE_MAX_SCAN_BYTES) {
402+
scanCapped = true;
332403
flushRemainder();
333404
if (truncReason === undefined) truncReason = "scan";
334405
finishOk();
@@ -372,6 +443,7 @@ export function readFileBounded(
372443
signal,
373444
{
374445
mapStreamError: (err) => mapFilesystemStreamError(absolutePath, err),
446+
windowHugeLines: true,
375447
},
376448
);
377449
}

‎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)