@nettee/beacon 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -38,5 +38,19 @@ export declare class PiRuntimeError extends Error {
|
|
|
38
38
|
}
|
|
39
39
|
/** Collect thinking / reasoning blocks from an assistant message content array. */
|
|
40
40
|
export declare function collectThinking(content: unknown[]): string;
|
|
41
|
+
/**
|
|
42
|
+
* Read NDJSON frames from Pi stdout with a hard per-frame byte cap.
|
|
43
|
+
*
|
|
44
|
+
* Oversized frames (for example tool events carrying multi-MB stdout) are
|
|
45
|
+
* discarded through the next newline instead of failing the whole Run. Beacon
|
|
46
|
+
* only needs prompt responses, `message_end`, and `agent_settled`; intermediate
|
|
47
|
+
* tool frames are ignored anyway, and Pi already truncates tool output for the
|
|
48
|
+
* model. The cap still bounds parse/buffer memory — we never raise it here.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createCappedNdjsonReader(maxFrameBytes: number, onFrame: (line: string) => void): {
|
|
51
|
+
push: (chunk: Buffer) => void;
|
|
52
|
+
close: () => void;
|
|
53
|
+
readonly skippedOversizedFrames: number;
|
|
54
|
+
};
|
|
41
55
|
export declare function runPiAgent(request: PiRunRequest, options?: PiRuntimeOptions): Promise<PiRunResult>;
|
|
42
56
|
export {};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { chmod, mkdir } from "node:fs/promises";
|
|
3
3
|
import { isAbsolute } from "node:path";
|
|
4
|
-
import { createInterface } from "node:readline";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
5
|
import { assertRuntimeEnvironmentKey } from "../config/runtime-environment.js";
|
|
7
6
|
export class PiRuntimeError extends Error {
|
|
@@ -155,6 +154,81 @@ async function stopProcess(child, terminateGraceMs) {
|
|
|
155
154
|
});
|
|
156
155
|
});
|
|
157
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Read NDJSON frames from Pi stdout with a hard per-frame byte cap.
|
|
159
|
+
*
|
|
160
|
+
* Oversized frames (for example tool events carrying multi-MB stdout) are
|
|
161
|
+
* discarded through the next newline instead of failing the whole Run. Beacon
|
|
162
|
+
* only needs prompt responses, `message_end`, and `agent_settled`; intermediate
|
|
163
|
+
* tool frames are ignored anyway, and Pi already truncates tool output for the
|
|
164
|
+
* model. The cap still bounds parse/buffer memory — we never raise it here.
|
|
165
|
+
*/
|
|
166
|
+
export function createCappedNdjsonReader(maxFrameBytes, onFrame) {
|
|
167
|
+
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
|
|
168
|
+
throw new Error("Pi RPC frame limit must be a positive integer");
|
|
169
|
+
}
|
|
170
|
+
const chunks = [];
|
|
171
|
+
let bufferedBytes = 0;
|
|
172
|
+
let discarding = false;
|
|
173
|
+
let closed = false;
|
|
174
|
+
let skippedOversizedFrames = 0;
|
|
175
|
+
const resetBuffer = () => {
|
|
176
|
+
chunks.length = 0;
|
|
177
|
+
bufferedBytes = 0;
|
|
178
|
+
};
|
|
179
|
+
const emitFrame = () => {
|
|
180
|
+
const line = Buffer.concat(chunks, bufferedBytes).toString("utf8");
|
|
181
|
+
resetBuffer();
|
|
182
|
+
onFrame(line);
|
|
183
|
+
};
|
|
184
|
+
const push = (chunk) => {
|
|
185
|
+
if (closed || chunk.length === 0)
|
|
186
|
+
return;
|
|
187
|
+
let offset = 0;
|
|
188
|
+
while (offset < chunk.length) {
|
|
189
|
+
if (discarding) {
|
|
190
|
+
const nl = chunk.indexOf(10, offset);
|
|
191
|
+
if (nl === -1)
|
|
192
|
+
return;
|
|
193
|
+
discarding = false;
|
|
194
|
+
offset = nl + 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const nl = chunk.indexOf(10, offset);
|
|
198
|
+
const end = nl === -1 ? chunk.length : nl;
|
|
199
|
+
const pieceLen = end - offset;
|
|
200
|
+
if (bufferedBytes + pieceLen > maxFrameBytes) {
|
|
201
|
+
skippedOversizedFrames += 1;
|
|
202
|
+
resetBuffer();
|
|
203
|
+
if (nl === -1) {
|
|
204
|
+
discarding = true;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
offset = nl + 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (pieceLen > 0) {
|
|
211
|
+
chunks.push(chunk.subarray(offset, end));
|
|
212
|
+
bufferedBytes += pieceLen;
|
|
213
|
+
}
|
|
214
|
+
if (nl === -1)
|
|
215
|
+
return;
|
|
216
|
+
emitFrame();
|
|
217
|
+
offset = nl + 1;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
push,
|
|
222
|
+
close: () => {
|
|
223
|
+
closed = true;
|
|
224
|
+
resetBuffer();
|
|
225
|
+
discarding = false;
|
|
226
|
+
},
|
|
227
|
+
get skippedOversizedFrames() {
|
|
228
|
+
return skippedOversizedFrames;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
158
232
|
export async function runPiAgent(request, options = {}) {
|
|
159
233
|
if (!request.prompt.trim())
|
|
160
234
|
throw new Error("Pi Run prompt must not be empty");
|
|
@@ -199,11 +273,11 @@ export async function runPiAgent(request, options = {}) {
|
|
|
199
273
|
env: buildPiEnvironment(request.outcome, options.environment, options.runtimeEnvironment),
|
|
200
274
|
stdio: ["pipe", "pipe", "pipe"],
|
|
201
275
|
});
|
|
202
|
-
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
203
276
|
let stderr = "";
|
|
204
277
|
let finalMessage;
|
|
205
278
|
let promptAccepted = false;
|
|
206
279
|
let finished = false;
|
|
280
|
+
let frames;
|
|
207
281
|
child.stderr.setEncoding("utf8");
|
|
208
282
|
child.stderr.on("data", (chunk) => {
|
|
209
283
|
stderr = (stderr + chunk).slice(-64 * 1024);
|
|
@@ -218,27 +292,7 @@ export async function runPiAgent(request, options = {}) {
|
|
|
218
292
|
reject(error);
|
|
219
293
|
};
|
|
220
294
|
const timer = setTimeout(() => fail(new PiRuntimeError("runtime_timeout", `Pi Run timed out after ${timeoutMs}ms${stderr.trim() ? `: ${stderr.trim()}` : ""}`)), timeoutMs);
|
|
221
|
-
|
|
222
|
-
child.stdout.on("data", (chunk) => {
|
|
223
|
-
for (const byte of chunk) {
|
|
224
|
-
if (byte === 10)
|
|
225
|
-
frameBytes = 0;
|
|
226
|
-
else
|
|
227
|
-
frameBytes += 1;
|
|
228
|
-
if (frameBytes > maxFrameBytes) {
|
|
229
|
-
fail(new PiRuntimeError("runtime_protocol_error", `Pi RPC frame exceeds ${maxFrameBytes} bytes`));
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
child.once("error", (error) => fail(new PiRuntimeError("runtime_spawn_failed", `Failed to start Pi: ${error.message}`, {
|
|
235
|
-
cause: error,
|
|
236
|
-
})));
|
|
237
|
-
child.once("exit", (code, signal) => {
|
|
238
|
-
if (!finished)
|
|
239
|
-
fail(describeExit(code, signal, stderr));
|
|
240
|
-
});
|
|
241
|
-
lines.on("line", (line) => {
|
|
295
|
+
const handleFrame = (line) => {
|
|
242
296
|
if (finished)
|
|
243
297
|
return;
|
|
244
298
|
let value;
|
|
@@ -295,13 +349,24 @@ export async function runPiAgent(request, options = {}) {
|
|
|
295
349
|
catch (error) {
|
|
296
350
|
fail(error instanceof Error ? error : new Error(String(error)));
|
|
297
351
|
}
|
|
352
|
+
};
|
|
353
|
+
frames = createCappedNdjsonReader(maxFrameBytes, handleFrame);
|
|
354
|
+
child.stdout.on("data", (chunk) => {
|
|
355
|
+
frames?.push(chunk);
|
|
356
|
+
});
|
|
357
|
+
child.once("error", (error) => fail(new PiRuntimeError("runtime_spawn_failed", `Failed to start Pi: ${error.message}`, {
|
|
358
|
+
cause: error,
|
|
359
|
+
})));
|
|
360
|
+
child.once("exit", (code, signal) => {
|
|
361
|
+
if (!finished)
|
|
362
|
+
fail(describeExit(code, signal, stderr));
|
|
298
363
|
});
|
|
299
364
|
child.stdin.on("error", (error) => fail(new PiRuntimeError("runtime_protocol_error", `Failed to write Pi RPC command: ${error.message}`, { cause: error })));
|
|
300
365
|
child.stdin.write(`${JSON.stringify({ id: "run-prompt", type: "prompt", message: request.prompt })}\n`);
|
|
301
366
|
});
|
|
302
367
|
}
|
|
303
368
|
finally {
|
|
304
|
-
|
|
369
|
+
frames?.close();
|
|
305
370
|
await stopProcess(child, terminateGraceMs);
|
|
306
371
|
}
|
|
307
372
|
}
|