Skip to content

Commit 496415b

Browse files
committed
refactor(tools): share arg guards, bounded reads, and process spawn
1 parent 69c4ed4 commit 496415b

24 files changed

Lines changed: 733 additions & 777 deletions

‎src/io/bounded-read.ts‎

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

0 commit comments

Comments
 (0)