Skip to content

Commit 44c66c7

Browse files
Merge pull request #1444 from corbitsdev/cl-10668-toolsplugins-de-dupe-shared-argguardspawn
refactor(tools): share arg guards, bounded reads, and process spawn
2 parents 495c992 + abc7db7 commit 44c66c7

26 files changed

Lines changed: 746 additions & 777 deletions

‎src/io/bounded-read.ts‎

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import { Readable } from "node:stream";
2+
import { StringDecoder } from "node:string_decoder";
3+
4+
import { formatReadFileTimeoutMessage } from "../plugins/tool-time-budget.js";
5+
6+
// The bounded read engine behind read_file's guard: streams a source in one
7+
// pass, emitting up to `limit` line-numbered lines while never buffering more
8+
// than the byte budget plus one chunk. Filesystem and blob adapters (the
9+
// read-file-guard plugin, archive search) feed it their own stream and error
10+
// mapping; policy like the paging default stays with the caller.
11+
12+
export const READ_FILE_MAX_BYTES = 50 * 1024;
13+
export const READ_FILE_MAX_LINE_LENGTH = 2000;
14+
// Absolute ceiling on bytes scanned from disk past the requested offset, so
15+
// an emission window stays time-bounded. Bytes skipped to reach a nonzero
16+
// offset do not count: continuation past the ceiling must read through to
17+
// the end, not dead-end at the limit.
18+
export const READ_FILE_MAX_SCAN_BYTES = 8 * 1024 * 1024;
19+
// Headroom reserved out of the byte budget for the continuation notice, so
20+
// the payload plus notice stays under READ_FILE_MAX_BYTES.
21+
const NOTICE_RESERVE_BYTES = 256;
22+
23+
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]`;
24+
const TOOL_OUTPUT_CHUNK_BYTES = 64 * 1024;
25+
26+
export type TruncReason = "lines" | "bytes" | "scan";
27+
28+
export interface BoundedRead {
29+
content: string;
30+
isError?: boolean;
31+
}
32+
33+
// A truncated read tells the model to continue with the same path and the
34+
// explicit next offset from the notice. There is no continuation handle:
35+
// every read is a stateless, idempotent ranged read, so following a notice
36+
// verbatim works on first use, replay, and a fresh plugin instance after
37+
// compaction or session resume. Chunked reads carry rising offsets, so
38+
// call-keying detectors see one ranged read per window, not a same-path
39+
// loop.
40+
41+
function numberLine(lineNo: number, text: string): string {
42+
return `${String(lineNo).padStart(6, " ")}\t${text}`;
43+
}
44+
45+
/**
46+
* Streams UTF-8 from `stream`, emitting up to `limit` line-numbered lines
47+
* after skipping `offset` lines (zero-based); never decodes the full text in
48+
* one pass. With `wrapLongLines`, overlong lines split into numbered windows
49+
* so a giant JSON line pages like a multi-line file. With `windowHugeLines`
50+
* instead, only lines that alone exceed the output budget are windowed, so
51+
* plain path+offset pagination stays line-aligned.
52+
*/
53+
export function readStreamBounded(
54+
stream: Readable,
55+
displayPath: string,
56+
offset: number,
57+
limit: number,
58+
signal: AbortSignal,
59+
options: {
60+
mapStreamError?: (err: NodeJS.ErrnoException) => Error;
61+
wrapLongLines?: boolean;
62+
windowHugeLines?: boolean;
63+
diagnoseFirstChunk?: (chunk: Buffer) => string | undefined;
64+
maxScanBytes?: number;
65+
} = {},
66+
): Promise<BoundedRead> {
67+
return new Promise<BoundedRead>((resolveP, rejectP) => {
68+
const {
69+
mapStreamError,
70+
wrapLongLines = false,
71+
windowHugeLines = false,
72+
diagnoseFirstChunk,
73+
maxScanBytes,
74+
} = options;
75+
const scanCeiling = maxScanBytes ?? READ_FILE_MAX_SCAN_BYTES;
76+
const decoder = new StringDecoder("utf8");
77+
const contentBudget = READ_FILE_MAX_BYTES - NOTICE_RESERVE_BYTES;
78+
79+
let pending = "";
80+
let pendingOverflow = false;
81+
let firstChunk = true;
82+
let lineNo = 0;
83+
let scanned = 0;
84+
let skipDone = offset <= 0;
85+
let outBytes = 0;
86+
let emitted = 0;
87+
let lastEmittedLine = 0;
88+
let truncReason: TruncReason | undefined;
89+
let endReached = false;
90+
let settled = false;
91+
92+
const out: string[] = [];
93+
94+
const onAbort = () => {
95+
if (settled) return;
96+
settled = true;
97+
stream.destroy();
98+
const partial = out.length > 0 ? out.join("\n") : undefined;
99+
rejectP(new Error(formatReadFileTimeoutMessage(displayPath, partial)));
100+
};
101+
signal.addEventListener("abort", onAbort, { once: true });
102+
if (signal.aborted) {
103+
onAbort();
104+
return;
105+
}
106+
107+
const done = (result: BoundedRead) => {
108+
if (settled) return;
109+
settled = true;
110+
stream.destroy();
111+
signal.removeEventListener("abort", onAbort);
112+
resolveP(result);
113+
};
114+
115+
const handleLine = (raw: string, overflow: boolean): boolean => {
116+
lineNo++;
117+
if (lineNo <= offset) return true;
118+
skipDone = true;
119+
if (emitted >= limit) {
120+
truncReason = "lines";
121+
return false;
122+
}
123+
const tooLong = overflow || raw.length > READ_FILE_MAX_LINE_LENGTH;
124+
const text = tooLong
125+
? raw.slice(0, READ_FILE_MAX_LINE_LENGTH) + LINE_TRUNC_SUFFIX
126+
: raw;
127+
const numbered = numberLine(lineNo, text);
128+
const bytes = Buffer.byteLength(numbered, "utf8") + 1;
129+
if (emitted > 0 && outBytes + bytes > contentBudget) {
130+
truncReason = "bytes";
131+
return false;
132+
}
133+
out.push(numbered);
134+
outBytes += bytes;
135+
emitted++;
136+
lastEmittedLine = lineNo;
137+
return true;
138+
};
139+
140+
const emitWrapped = (raw: string, keepTail: boolean): boolean => {
141+
let rest = raw;
142+
while (rest.length > READ_FILE_MAX_LINE_LENGTH) {
143+
if (!handleLine(rest.slice(0, READ_FILE_MAX_LINE_LENGTH), false))
144+
return false;
145+
rest = rest.slice(READ_FILE_MAX_LINE_LENGTH);
146+
}
147+
if (keepTail) return handleLine(rest, false);
148+
pending = rest;
149+
return true;
150+
};
151+
152+
const drainPending = (): boolean => {
153+
for (;;) {
154+
const nl = pending.indexOf("\n");
155+
if (nl === -1) {
156+
if (wrapLongLines) return emitWrapped(pending, false);
157+
if (pending.length > READ_FILE_MAX_LINE_LENGTH && !windowHugeLines) {
158+
pending = pending.slice(0, READ_FILE_MAX_LINE_LENGTH);
159+
pendingOverflow = true;
160+
}
161+
// windowHugeLines keeps the full pending: the scan trip ends the
162+
// stream, and flushRemainder windows the remainder so every scanned
163+
// byte stays reachable through offset continuation.
164+
return true;
165+
}
166+
const line = pending.slice(0, nl);
167+
pending = pending.slice(nl + 1);
168+
if (wrapLongLines) {
169+
if (!emitWrapped(line, true)) return false;
170+
} else {
171+
const overflow = pendingOverflow;
172+
pendingOverflow = false;
173+
if (!overflow && windowHugeLines && line.length > contentBudget) {
174+
if (!emitWrapped(line, true)) return false;
175+
} else if (!handleLine(line, overflow)) return false;
176+
}
177+
}
178+
};
179+
180+
const flushRemainder = (): void => {
181+
if (pending.length === 0) return;
182+
if (wrapLongLines) {
183+
emitWrapped(pending, true);
184+
return;
185+
}
186+
// Windowed even when scan-capped: every window burns a line number,
187+
// so the footer's offset resumes at the next window instead of promising
188+
// continuation that skips the unshown middle of an overlong line.
189+
if (
190+
!pendingOverflow &&
191+
windowHugeLines &&
192+
pending.length > contentBudget
193+
) {
194+
emitWrapped(pending, true);
195+
return;
196+
}
197+
handleLine(pending, pendingOverflow);
198+
};
199+
200+
const finishOk = () => {
201+
if (emitted === 0) {
202+
if (lineNo === 0 && endReached) {
203+
done({ content: "" });
204+
return;
205+
}
206+
// Skip bytes are not scanned, so a dead offset on a file larger than
207+
// the ceiling still reaches EOF. Report the true range, not a scan
208+
// limit that would hide a reachable EOF.
209+
if (endReached) {
210+
done({
211+
content: `[offset ${offset} is beyond end of file ${displayPath} (${lineNo} lines); valid offsets 0-${lineNo - 1}]`,
212+
isError: true,
213+
});
214+
} else {
215+
done({
216+
content: `[reached the ${
217+
scanCeiling / (1024 * 1024)
218+
}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.]`,
219+
isError: true,
220+
});
221+
}
222+
return;
223+
}
224+
225+
let content = out.join("\n");
226+
if (truncReason !== undefined) {
227+
content += `\n\n${continuationNotice(truncReason, offset + 1, lastEmittedLine, limit, scanCeiling)}`;
228+
}
229+
done({ content });
230+
};
231+
232+
stream.on("data", (chunk: Buffer) => {
233+
if (settled) return;
234+
if (firstChunk) {
235+
firstChunk = false;
236+
const diagnosed = diagnoseFirstChunk?.(chunk);
237+
if (diagnosed !== undefined) {
238+
done({
239+
content: diagnosed,
240+
isError: true,
241+
});
242+
return;
243+
}
244+
if (chunk.includes(0)) {
245+
done({
246+
content: `refusing to read binary file: ${displayPath}`,
247+
isError: true,
248+
});
249+
return;
250+
}
251+
}
252+
if (skipDone) scanned += chunk.length;
253+
pending += decoder.write(chunk);
254+
if (!drainPending()) {
255+
finishOk();
256+
return;
257+
}
258+
if (scanned >= scanCeiling) {
259+
flushRemainder();
260+
if (truncReason === undefined) truncReason = "scan";
261+
finishOk();
262+
}
263+
});
264+
265+
stream.on("end", () => {
266+
if (settled) return;
267+
endReached = true;
268+
pending += decoder.end();
269+
flushRemainder();
270+
finishOk();
271+
});
272+
273+
stream.on("error", (err: NodeJS.ErrnoException) => {
274+
if (settled) return;
275+
settled = true;
276+
signal.removeEventListener("abort", onAbort);
277+
if (mapStreamError !== undefined) rejectP(mapStreamError(err));
278+
else rejectP(err);
279+
});
280+
});
281+
}
282+
283+
/**
284+
* Bounded read over an in-memory UTF-8 blob (tool-output spills). Feeds the
285+
* buffer in chunks so offset/limit never require a full-text split; overlong
286+
* lines wrap into numbered windows instead of being truncated. Callers pass
287+
* a high `limit` so the byte budget, not the source-file 2000-line cap,
288+
* pages the spill.
289+
*/
290+
export function readBytesBounded(
291+
bytes: Uint8Array,
292+
offset: number,
293+
limit: number,
294+
signal: AbortSignal,
295+
displayPath: string,
296+
): Promise<BoundedRead> {
297+
async function* byteChunks(): AsyncGenerator<Buffer> {
298+
for (let i = 0; i < bytes.length; i += TOOL_OUTPUT_CHUNK_BYTES) {
299+
yield Buffer.from(
300+
bytes.subarray(i, Math.min(i + TOOL_OUTPUT_CHUNK_BYTES, bytes.length)),
301+
);
302+
}
303+
}
304+
return readStreamBounded(
305+
Readable.from(byteChunks()),
306+
displayPath,
307+
offset,
308+
limit,
309+
signal,
310+
{
311+
wrapLongLines: true,
312+
},
313+
);
314+
}
315+
316+
function continuationNotice(
317+
reason: TruncReason,
318+
firstLine: number,
319+
lastLine: number,
320+
limit: number,
321+
scanCeiling: number,
322+
): string {
323+
const next = `Use offset=${lastLine} to continue.`;
324+
if (reason === "lines") {
325+
return `[Showing lines ${firstLine}-${lastLine}; stopped at the ${limit}-line limit. ${next}]`;
326+
}
327+
if (reason === "bytes") {
328+
return `[Showing lines ${firstLine}-${lastLine}; stopped at the ${
329+
READ_FILE_MAX_BYTES / 1024
330+
}KB output limit. ${next}]`;
331+
}
332+
return `[Showing lines ${firstLine}-${lastLine}; stopped at the ${
333+
scanCeiling / (1024 * 1024)
334+
}MB scan limit. ${next}]`;
335+
}

‎src/plugins/bounded-grep-fallback.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,35 @@ describe("runBoundedGrep", () => {
9797
).rejects.toThrow();
9898
});
9999
});
100+
101+
test("accepts regex quantifier braces and still rejects glob braces", async () => {
102+
await withTempDir(async (dir) => {
103+
await writeFile(join(dir, "q.txt"), "foo\nfooo\n");
104+
const out = await runBoundedGrep(
105+
{ pattern: "foo{1,3}", path: "." },
106+
new AbortController().signal,
107+
dir,
108+
);
109+
expect(out).toContain("q.txt");
110+
expect(out).not.toContain("brace expansion");
111+
112+
await expect(
113+
runBoundedGrep(
114+
{ pattern: "foo", path: ".", glob: "*.{ts,txt}" },
115+
new AbortController().signal,
116+
dir,
117+
),
118+
).rejects.toThrow(/brace expansion is not supported/);
119+
120+
await expect(
121+
runBoundedSearchFiles(
122+
{ pattern: "*.{ts,txt}", path: "." },
123+
new AbortController().signal,
124+
dir,
125+
),
126+
).rejects.toThrow(/brace expansion is not supported/);
127+
});
128+
});
100129
});
101130

102131
describe("runBoundedSearchFiles", () => {

0 commit comments

Comments
 (0)