Skip to content

Commit 166ac47

Browse files
Merge pull request #573 from corbitsdev/cl-6961-read_files-80k-truncation-cap-turns-one-large-file-into-many
read_file: resumable cursor instead of same-path pagination
2 parents 3c2ddea + 1661d8e commit 166ac47

3 files changed

Lines changed: 292 additions & 6 deletions

File tree

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

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ describe("readFileGuardPlugin", () => {
200200
expect(result.content).toContain(" 2\tl2");
201201
expect(result.content).toContain(" 3\tl3");
202202
expect(result.content).not.toContain(" 4\tl4");
203-
expect(result.content).toContain("Use offset=");
203+
expect(result.content).toContain('Use path="tool-output:///');
204+
expect(result.content).not.toContain("Use offset=");
204205
});
205206

206207
test("rejects tool-output URIs when no blob reader is configured", async () => {
@@ -277,4 +278,143 @@ describe("readFileGuardPlugin", () => {
277278
const result = await run({ id: "r4", name: "grep", arguments: { pattern: "x" } });
278279
expect(result.content).toBe("FALLBACK");
279280
});
281+
282+
test("a truncated read never asks the model to re-read the same path (CL-6961)", async () => {
283+
await fixture("many-lines.txt", Array.from({ length: 10 }, (_, i) => `line-${i}`).join("\n"));
284+
const plugin = readFileGuardPlugin(dir, {});
285+
const middleware = plugin.middleware!(fallback);
286+
const result = await middleware(
287+
{ id: "c1", name: "read_file", arguments: { path: "many-lines.txt", limit: 4 } },
288+
neverAbort(),
289+
);
290+
expect(result.content).not.toContain("Use offset=");
291+
expect(String(result.content)).toContain('Use path="tool-output:///');
292+
// The literal source path never reappears as the thing to read next.
293+
expect(String(result.content)).not.toContain("many-lines.txt");
294+
});
295+
296+
test("following the minted cursor resumes and eventually reads a large file to completion without any repeat call on the original path (CL-6961)", async () => {
297+
const lines = Array.from({ length: 9_000 }, (_, i) => `line-${i} payload`);
298+
await fixture("huge.txt", lines.join("\n"));
299+
const plugin = readFileGuardPlugin(dir, {});
300+
const middleware = plugin.middleware!(fallback);
301+
302+
const pathsRead: string[] = ["huge.txt"];
303+
let result = await middleware(
304+
{ id: "c1", name: "read_file", arguments: { path: "huge.txt" } },
305+
neverAbort(),
306+
);
307+
let seen = 0;
308+
let guard = 0;
309+
for (;;) {
310+
guard++;
311+
expect(guard).toBeLessThan(50); // fails loudly instead of hanging on a broken cursor chain
312+
const content = String(result.content);
313+
const numbered = content.split("\n\n")[0] ?? "";
314+
seen += numbered.trimEnd().split("\n").length;
315+
316+
const match = /Use path="(tool-output:\/\/\/[^"]+)"/.exec(content);
317+
if (match === undefined || match === null) break;
318+
const nextPath = match[1] as string;
319+
expect(pathsRead).not.toContain(nextPath); // every hop targets a fresh, distinct path
320+
pathsRead.push(nextPath);
321+
322+
result = await middleware(
323+
{ id: `c${pathsRead.length}`, name: "read_file", arguments: { path: nextPath } },
324+
neverAbort(),
325+
);
326+
}
327+
328+
expect(seen).toBe(lines.length);
329+
expect(pathsRead.length).toBeGreaterThan(1); // it actually paginated
330+
// Never told to re-issue a call against the literal original path.
331+
expect(pathsRead.filter((p) => p === "huge.txt").length).toBe(1);
332+
});
333+
334+
test("a stale (already-consumed) cursor names the original path and offset instead of a dead end", async () => {
335+
const absolutePath = await fixture(
336+
"stale.txt",
337+
Array.from({ length: 10 }, (_, i) => `line-${i}`).join("\n"),
338+
);
339+
const plugin = readFileGuardPlugin(dir, {});
340+
const middleware = plugin.middleware!(fallback);
341+
const first = await middleware(
342+
{ id: "s1", name: "read_file", arguments: { path: "stale.txt", limit: 4 } },
343+
neverAbort(),
344+
);
345+
const match = /Use path="(tool-output:\/\/\/[^"]+)"/.exec(String(first.content));
346+
expect(match).not.toBeNull();
347+
const cursorPath = (match as RegExpExecArray)[1] as string;
348+
349+
await middleware(
350+
{ id: "s2", name: "read_file", arguments: { path: cursorPath } },
351+
neverAbort(),
352+
);
353+
// Second use of the same, already-consumed cursor: distinct from a
354+
// generic missing-blob error, this must name a followable next step —
355+
// the original source and the offset to resume from — rather than
356+
// leaving the model to re-read the whole file from scratch.
357+
const replay = await middleware(
358+
{ id: "s3", name: "read_file", arguments: { path: cursorPath } },
359+
neverAbort(),
360+
);
361+
expect(replay.isError).toBe(true);
362+
expect(String(replay.content)).toContain("already used");
363+
expect(String(replay.content)).toContain(absolutePath);
364+
expect(String(replay.content)).toMatch(/offset=4\b/);
365+
});
366+
367+
test("an unknown tool-output URI against a real blobReader gets the production 'blob not found' error, not a stale-cursor message", async () => {
368+
const blobReader = {
369+
async read(uri: string): Promise<Uint8Array> {
370+
throw new Error(`Blob not found for key: ${uri}`);
371+
},
372+
};
373+
const result = await run(
374+
{ id: "u1", name: "read_file", arguments: { path: "tool-output:///never-minted" } },
375+
blobReader,
376+
);
377+
expect(result.isError).toBe(true);
378+
expect(String(result.content)).toContain("Blob not found for key");
379+
// Never a cursor's own wording, since this ID was never one of ours.
380+
expect(String(result.content)).not.toContain("already used");
381+
});
382+
383+
test("a stale cursor short-circuits before reaching a real blobReader's production 'blob not found' error", async () => {
384+
const encoder = new TextEncoder();
385+
const body = Array.from({ length: 8_000 }, (_, i) => `row-${i}`).join("\n");
386+
const blobReader = {
387+
async read(uri: string): Promise<Uint8Array> {
388+
if (uri === "tool-output:///spill-1") return encoder.encode(body);
389+
throw new Error(`Blob not found for key: ${uri}`);
390+
},
391+
};
392+
const plugin = readFileGuardPlugin(dir, { blobReader });
393+
const middleware = plugin.middleware!(fallback);
394+
395+
const first = await middleware(
396+
{ id: "b1", name: "read_file", arguments: { path: "tool-output:///spill-1", limit: 5 } },
397+
neverAbort(),
398+
);
399+
const match = /Use path="(tool-output:\/\/\/[^"]+)"/.exec(String(first.content));
400+
expect(match).not.toBeNull();
401+
const cursorPath = (match as RegExpExecArray)[1] as string;
402+
403+
await middleware(
404+
{ id: "b2", name: "read_file", arguments: { path: cursorPath } },
405+
neverAbort(),
406+
);
407+
// Replaying the consumed cursor must not fall through to blobReader.read()
408+
// (which would throw the opaque "Blob not found" error naming only the
409+
// random cursor UUID) -- it must short-circuit to the actionable message
410+
// naming the real spill URI and the offset to resume from.
411+
const replay = await middleware(
412+
{ id: "b3", name: "read_file", arguments: { path: cursorPath } },
413+
neverAbort(),
414+
);
415+
expect(replay.isError).toBe(true);
416+
expect(String(replay.content)).toContain("already used");
417+
expect(String(replay.content)).toContain("tool-output:///spill-1");
418+
expect(String(replay.content)).not.toContain("Blob not found");
419+
});
280420
});

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

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import { randomUUID } from "node:crypto";
12
import { createReadStream } from "node:fs";
23
import { stat } from "node:fs/promises";
34
import { resolve } from "node:path";
45
import { Readable } from "node:stream";
56
import { StringDecoder } from "node:string_decoder";
67
import type { ToolPlugin } from "@intx/tools-posix";
78
import type { BlobReader } from "@intx/types/runtime";
8-
import { canonicalToolOutputUri, isToolOutputLike } from "../util/tool-output-uri.js";
9+
import {
10+
canonicalToolOutputUri,
11+
isToolOutputLike,
12+
TOOL_OUTPUT_URI_PREFIX,
13+
} from "../util/tool-output-uri.js";
914
import { formatReadFileTimeoutMessage } from "./tool-time-budget.js";
1015

1116
// Corbits Code-side guard for read_file. Stock @intx/tools-posix read-file loads the
@@ -41,6 +46,86 @@ export interface ReadFileGuardPluginOptions {
4146
blobReader?: BlobReader;
4247
}
4348

49+
// A truncated read used to tell the model "Use offset=N to continue" against
50+
// the identical path -- exactly the same-path pagination fan-out CL-6961
51+
// measured (97% of 4+-reads-per-path clusters were legitimate chunked reads
52+
// 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://
54+
// 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.
61+
type ReadCursor =
62+
| { kind: "file"; absolutePath: string; offset: number; consumed: boolean }
63+
| { kind: "blob"; uri: string; offset: number; consumed: boolean };
64+
65+
// A cursor is single-use, but the record survives consumption (bounded by
66+
// MAX_CURSOR_HISTORY below) so a stale replay -- consumed already, or a
67+
// second process/turn racing the first -- can be told exactly where to
68+
// resume instead of hitting an opaque "blob not found" dead end that names
69+
// neither the file nor an offset and leaves re-reading from scratch (the
70+
// original path, no offset) as the model's only move.
71+
const MAX_CURSOR_HISTORY = 200;
72+
73+
const CONTINUE_OFFSET_RE = /Use offset=(\d+) to continue\.\]$/;
74+
75+
function pruneCursorHistory(cursors: Map<string, ReadCursor>): void {
76+
while (cursors.size > MAX_CURSOR_HISTORY) {
77+
const oldest = cursors.keys().next().value;
78+
if (oldest === undefined) break;
79+
cursors.delete(oldest);
80+
}
81+
}
82+
83+
function mintCursor(
84+
content: string,
85+
cursors: Map<string, ReadCursor>,
86+
source: { kind: "file"; absolutePath: string } | { kind: "blob"; uri: string },
87+
): string {
88+
const match = CONTINUE_OFFSET_RE.exec(content);
89+
if (match === null) return content;
90+
const offset = Number(match[1]);
91+
const cursorId = randomUUID();
92+
cursors.set(
93+
cursorId,
94+
source.kind === "file"
95+
? { kind: "file", absolutePath: source.absolutePath, offset, consumed: false }
96+
: { kind: "blob", uri: source.uri, offset, consumed: false },
97+
);
98+
pruneCursorHistory(cursors);
99+
return content.replace(
100+
CONTINUE_OFFSET_RE,
101+
`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.]`,
102+
);
103+
}
104+
105+
// Bound the source shown in a stale-cursor message: an adversarial or
106+
// pathological path must not blow past a reasonable notice size.
107+
const STALE_CURSOR_SOURCE_MAX = 300;
108+
109+
function displaySource(source: string): string {
110+
return source.length > STALE_CURSOR_SOURCE_MAX
111+
? `${source.slice(0, STALE_CURSOR_SOURCE_MAX)}…`
112+
: source;
113+
}
114+
115+
/**
116+
* Message for a cursor that is known but already used (or is being replayed
117+
* from a stale/compacted turn). Distinct from "blob not found": it names the
118+
* original source and the exact offset to resume from, so recovery is a
119+
* single new call rather than a re-read from scratch of the whole file.
120+
*/
121+
function staleCursorMessage(cursor: ReadCursor): string {
122+
const source = cursor.kind === "file" ? cursor.absolutePath : cursor.uri;
123+
return (
124+
`this read_file continuation handle was already used (each cursor is single-use). ` +
125+
`Resume with read_file, path="${displaySource(source)}", offset=${cursor.offset}.`
126+
);
127+
}
128+
44129
function numArg(value: unknown): number | undefined {
45130
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
46131
}
@@ -297,6 +382,11 @@ export function readFileGuardPlugin(
297382
options: ReadFileGuardPluginOptions = {},
298383
): ToolPlugin {
299384
const { blobReader } = options;
385+
// Single-use resumption pointers minted by mintCursor(); scoped to this
386+
// plugin instance (one per session/agent, per buildCorePosixToolPlugins), so
387+
// it never outlives the session and never crosses sessions.
388+
const cursors = new Map<string, ReadCursor>();
389+
const cursorUriPrefix = `${TOOL_OUTPUT_URI_PREFIX}///`;
300390
return {
301391
middleware: (next) => async (call, signal) => {
302392
if (call.name !== "read_file") return next(call, signal);
@@ -306,13 +396,64 @@ export function readFileGuardPlugin(
306396
return next(call, signal);
307397
}
308398

309-
const { offset, limit } = resolveReadFilePaging(call);
399+
const { limit } = resolveReadFilePaging(call);
310400

311401
if (isToolOutputLike(rawPath)) {
312402
const uri = canonicalToolOutputUri(rawPath);
313403
if (uri === undefined) {
314404
return next(call, signal);
315405
}
406+
407+
const cursorId = uri.startsWith(cursorUriPrefix) ? uri.slice(cursorUriPrefix.length) : "";
408+
const cursor = cursorId.length > 0 ? cursors.get(cursorId) : undefined;
409+
if (cursor !== undefined && cursor.consumed) {
410+
// Known cursor, already used -- distinct from a genuine missing
411+
// blob: name the original source and offset so recovery is one
412+
// targeted call, not a from-scratch re-read of the whole file.
413+
return { callId: call.id, content: staleCursorMessage(cursor), isError: true };
414+
}
415+
if (cursor !== undefined) {
416+
// A cursor is authoritative on position: the model passes only the
417+
// handle (and optionally a limit), never an offset back into it.
418+
cursor.consumed = true;
419+
try {
420+
signal.throwIfAborted();
421+
if (cursor.kind === "file") {
422+
const res = await readFileBounded(cursor.absolutePath, cursor.offset, limit, signal);
423+
return res.isError
424+
? { callId: call.id, content: res.content, isError: true }
425+
: {
426+
callId: call.id,
427+
content: mintCursor(res.content, cursors, {
428+
kind: "file",
429+
absolutePath: cursor.absolutePath,
430+
}),
431+
};
432+
}
433+
if (blobReader === undefined) {
434+
return {
435+
callId: call.id,
436+
content: `cannot read ${rawPath}: no blob reader is configured for tool-output spills`,
437+
isError: true,
438+
};
439+
}
440+
const bytes = await blobReader.read(cursor.uri);
441+
const res = await readBytesBounded(bytes, cursor.offset, limit, signal, cursor.uri);
442+
return res.isError
443+
? { callId: call.id, content: res.content, isError: true }
444+
: {
445+
callId: call.id,
446+
content: mintCursor(res.content, cursors, { kind: "blob", uri: cursor.uri }),
447+
};
448+
} catch (err) {
449+
return {
450+
callId: call.id,
451+
content: err instanceof Error ? err.message : String(err),
452+
isError: true,
453+
};
454+
}
455+
}
456+
316457
if (blobReader === undefined) {
317458
return {
318459
callId: call.id,
@@ -322,11 +463,12 @@ export function readFileGuardPlugin(
322463
}
323464
try {
324465
signal.throwIfAborted();
466+
const { offset } = resolveReadFilePaging(call);
325467
const bytes = await blobReader.read(uri);
326468
const res = await readBytesBounded(bytes, offset, limit, signal, uri);
327469
return res.isError
328470
? { callId: call.id, content: res.content, isError: true }
329-
: { callId: call.id, content: res.content };
471+
: { callId: call.id, content: mintCursor(res.content, cursors, { kind: "blob", uri }) };
330472
} catch (err) {
331473
return {
332474
callId: call.id,
@@ -346,10 +488,14 @@ export function readFileGuardPlugin(
346488
}
347489

348490
try {
491+
const { offset } = resolveReadFilePaging(call);
349492
const res = await readFileBounded(absolutePath, offset, limit, signal);
350493
return res.isError
351494
? { callId: call.id, content: res.content, isError: true }
352-
: { callId: call.id, content: res.content };
495+
: {
496+
callId: call.id,
497+
content: mintCursor(res.content, cursors, { kind: "file", absolutePath }),
498+
};
353499
} catch (err) {
354500
return {
355501
callId: call.id,

src/util/tool-output-uri.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const TOOL_OUTPUT_URI_PREFIX = "tool-output:";
1+
export const TOOL_OUTPUT_URI_PREFIX = "tool-output:";
22

33
export function isToolOutputLike(path: string): boolean {
44
return path.startsWith(TOOL_OUTPUT_URI_PREFIX);

0 commit comments

Comments
 (0)