Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/plugins/read-file-guard-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
90 changes: 71 additions & 19 deletions src/plugins/read-file-guard-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -158,16 +158,23 @@ 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,
displayPath: string,
offset: number,
limit: number,
signal: AbortSignal,
mapStreamError?: (err: NodeJS.ErrnoException) => Error,
options: {
mapStreamError?: (err: NodeJS.ErrnoException) => Error;
wrapLongLines?: boolean;
} = {},
): Promise<BoundedRead> {
return new Promise<BoundedRead>((resolveP, rejectP) => {
const { mapStreamError, wrapLongLines = false } = options;
const decoder = new StringDecoder("utf8");
const contentBudget = READ_FILE_MAX_BYTES - NOTICE_RESERVE_BYTES;

Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
}
Expand All @@ -306,7 +339,7 @@ function readStreamBounded(
if (settled) return;
endReached = true;
pending += decoder.end();
if (pending.length > 0) handleLine(pending, pendingOverflow);
flushRemainder();
finishOk();
});

Expand Down Expand Up @@ -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,
Expand All @@ -365,6 +403,9 @@ export function readBytesBounded(
offset,
limit,
signal,
{
wrapLongLines: true,
},
);
}

Expand All @@ -388,7 +429,10 @@ function continuationNotice(
}MB scan limit. ${next}]`;
}

function resolveReadFilePaging(call: { arguments: Record<string, unknown> }): {
function resolveReadFilePaging(
call: { arguments: Record<string, unknown> },
defaultLimit = READ_FILE_DEFAULT_MAX_LINES,
): {
offset: number;
limit: number;
} {
Expand All @@ -399,13 +443,13 @@ function resolveReadFilePaging(call: { arguments: Record<string, unknown> }): {
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,
Expand All @@ -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);
Expand Down Expand Up @@ -482,7 +530,7 @@ export function readFileGuardPlugin(
const res = await readBytesBounded(
bytes,
cursor.offset,
limit,
blobLimit,
signal,
cursor.uri,
);
Expand Down Expand Up @@ -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 }
: {
Expand Down Expand Up @@ -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 }
Expand Down
Loading