Skip to content

Commit 54c2e9c

Browse files
committed
Give read_file a resumable cursor instead of same-path pagination
read-file-guard-plugin truncated large reads and told the model to "use offset=N to continue" against the identical path. Each page was a technically-legitimate but literally same-path read_file call, which is exactly the shape a trace scan (CL-6961) found in 97% of "4+ reads of one path" clusters: chunked pagination indistinguishable from looping. Every truncated read now mints a single-use tool-output:///{cursor} handle pointing at the exact resumption point (source path/blob URI + next offset) and rewrites the notice to hand back that path instead. Following the cursor resolves through the same isToolOutputLike branch already used for real tool-output spills, so no new call surface is needed. Each hop therefore targets a distinct path, and the handle is real and resolvable -- it does not promise retrievable bytes that don't exist, it just remembers where to resume a fresh bounded read. Rejected: raising MAX_RESULT_CHARS just moves the same boundary. Rejected: spilling the full remainder into a tool-output blob (making the existing "not retrievable" promise literally true by storing the bytes) would defeat read-file-guard-plugin's whole reason for existing -- streaming reads so a huge file is never buffered into memory to avoid OOM. A cursor gets the same "keep re-reading" ergonomics without ever materializing more than one bounded page at a time.
1 parent 2369021 commit 54c2e9c

3 files changed

Lines changed: 181 additions & 6 deletions

File tree

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

Lines changed: 77 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,79 @@ 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 is rejected rather than silently re-served", async () => {
335+
await fixture("stale.txt", Array.from({ length: 10 }, (_, i) => `line-${i}`).join("\n"));
336+
const plugin = readFileGuardPlugin(dir, {});
337+
const middleware = plugin.middleware!(fallback);
338+
const first = await middleware(
339+
{ id: "s1", name: "read_file", arguments: { path: "stale.txt", limit: 4 } },
340+
neverAbort(),
341+
);
342+
const match = /Use path="(tool-output:\/\/\/[^"]+)"/.exec(String(first.content));
343+
expect(match).not.toBeNull();
344+
const cursorPath = (match as RegExpExecArray)[1] as string;
345+
346+
await middleware({ id: "s2", name: "read_file", arguments: { path: cursorPath } }, neverAbort());
347+
// Second use of the same, already-consumed cursor: no blob reader is
348+
// configured, so it falls through to the direct tool-output-URI path
349+
// and reports the honest error rather than fabricating stale content.
350+
const replay = await middleware(
351+
{ id: "s3", name: "read_file", arguments: { path: cursorPath } },
352+
neverAbort(),
353+
);
354+
expect(replay.isError).toBe(true);
355+
});
280356
});

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

Lines changed: 103 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,45 @@ 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 }
63+
| { kind: "blob"; uri: string; offset: number };
64+
65+
const CONTINUE_OFFSET_RE = /Use offset=(\d+) to continue\.\]$/;
66+
67+
function mintCursor(
68+
content: string,
69+
cursors: Map<string, ReadCursor>,
70+
source: { kind: "file"; absolutePath: string } | { kind: "blob"; uri: string },
71+
): string {
72+
const match = CONTINUE_OFFSET_RE.exec(content);
73+
if (match === null) return content;
74+
const offset = Number(match[1]);
75+
const cursorId = randomUUID();
76+
cursors.set(
77+
cursorId,
78+
source.kind === "file"
79+
? { kind: "file", absolutePath: source.absolutePath, offset }
80+
: { kind: "blob", uri: source.uri, offset },
81+
);
82+
return content.replace(
83+
CONTINUE_OFFSET_RE,
84+
`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.]`,
85+
);
86+
}
87+
4488
function numArg(value: unknown): number | undefined {
4589
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
4690
}
@@ -297,6 +341,11 @@ export function readFileGuardPlugin(
297341
options: ReadFileGuardPluginOptions = {},
298342
): ToolPlugin {
299343
const { blobReader } = options;
344+
// Single-use resumption pointers minted by mintCursor(); scoped to this
345+
// plugin instance (one per session/agent, per buildCorePosixToolPlugins), so
346+
// it never outlives the session and never crosses sessions.
347+
const cursors = new Map<string, ReadCursor>();
348+
const cursorUriPrefix = `${TOOL_OUTPUT_URI_PREFIX}///`;
300349
return {
301350
middleware: (next) => async (call, signal) => {
302351
if (call.name !== "read_file") return next(call, signal);
@@ -306,13 +355,58 @@ export function readFileGuardPlugin(
306355
return next(call, signal);
307356
}
308357

309-
const { offset, limit } = resolveReadFilePaging(call);
358+
const { limit } = resolveReadFilePaging(call);
310359

311360
if (isToolOutputLike(rawPath)) {
312361
const uri = canonicalToolOutputUri(rawPath);
313362
if (uri === undefined) {
314363
return next(call, signal);
315364
}
365+
366+
const cursorId = uri.startsWith(cursorUriPrefix) ? uri.slice(cursorUriPrefix.length) : "";
367+
const cursor = cursorId.length > 0 ? cursors.get(cursorId) : undefined;
368+
if (cursor !== undefined) {
369+
// A cursor is authoritative on position: the model passes only the
370+
// handle (and optionally a limit), never an offset back into it.
371+
cursors.delete(cursorId);
372+
try {
373+
signal.throwIfAborted();
374+
if (cursor.kind === "file") {
375+
const res = await readFileBounded(cursor.absolutePath, cursor.offset, limit, signal);
376+
return res.isError
377+
? { callId: call.id, content: res.content, isError: true }
378+
: {
379+
callId: call.id,
380+
content: mintCursor(res.content, cursors, {
381+
kind: "file",
382+
absolutePath: cursor.absolutePath,
383+
}),
384+
};
385+
}
386+
if (blobReader === undefined) {
387+
return {
388+
callId: call.id,
389+
content: `cannot read ${rawPath}: no blob reader is configured for tool-output spills`,
390+
isError: true,
391+
};
392+
}
393+
const bytes = await blobReader.read(cursor.uri);
394+
const res = await readBytesBounded(bytes, cursor.offset, limit, signal, cursor.uri);
395+
return res.isError
396+
? { callId: call.id, content: res.content, isError: true }
397+
: {
398+
callId: call.id,
399+
content: mintCursor(res.content, cursors, { kind: "blob", uri: cursor.uri }),
400+
};
401+
} catch (err) {
402+
return {
403+
callId: call.id,
404+
content: err instanceof Error ? err.message : String(err),
405+
isError: true,
406+
};
407+
}
408+
}
409+
316410
if (blobReader === undefined) {
317411
return {
318412
callId: call.id,
@@ -322,11 +416,12 @@ export function readFileGuardPlugin(
322416
}
323417
try {
324418
signal.throwIfAborted();
419+
const { offset } = resolveReadFilePaging(call);
325420
const bytes = await blobReader.read(uri);
326421
const res = await readBytesBounded(bytes, offset, limit, signal, uri);
327422
return res.isError
328423
? { callId: call.id, content: res.content, isError: true }
329-
: { callId: call.id, content: res.content };
424+
: { callId: call.id, content: mintCursor(res.content, cursors, { kind: "blob", uri }) };
330425
} catch (err) {
331426
return {
332427
callId: call.id,
@@ -346,10 +441,14 @@ export function readFileGuardPlugin(
346441
}
347442

348443
try {
444+
const { offset } = resolveReadFilePaging(call);
349445
const res = await readFileBounded(absolutePath, offset, limit, signal);
350446
return res.isError
351447
? { callId: call.id, content: res.content, isError: true }
352-
: { callId: call.id, content: res.content };
448+
: {
449+
callId: call.id,
450+
content: mintCursor(res.content, cursors, { kind: "file", absolutePath }),
451+
};
353452
} catch (err) {
354453
return {
355454
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)