@hicaru/pi-rlm 0.2.0 → 0.2.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.
- package/package.json +1 -1
- package/src/bridge/fallback-todo.ts +12 -1
- package/src/bridge/subcall-handlers.ts +336 -0
- package/src/config/defaults.ts +4 -1
- package/src/core/engine.ts +65 -30
- package/src/index.ts +28 -0
- package/src/prompts/system.ts +23 -2
- package/src/sandbox/protocol.ts +6 -0
- package/src/sandbox/sandbox-manager.ts +20 -6
- package/src/sandbox/sandbox.ts +77 -12
- package/src/sandbox/worker.py +460 -82
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +2 -0
- package/src/tool/repl-tool.ts +186 -102
- package/src/tool/rlm-events.ts +10 -2
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/util/concurrency.ts +87 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/rlm-query.ts +0 -108
|
@@ -14,6 +14,8 @@ export interface SandboxManagerConfig {
|
|
|
14
14
|
readonly python: string;
|
|
15
15
|
readonly sandboxInitTimeoutMs: number;
|
|
16
16
|
readonly maxPromptChars: number;
|
|
17
|
+
/** Max seconds the worker waits for a host reply while parked in rlm_await. */
|
|
18
|
+
readonly awaitTimeoutS: number;
|
|
17
19
|
readonly signal?: AbortSignal;
|
|
18
20
|
readonly onSandboxDiscarded?: () => void;
|
|
19
21
|
}
|
|
@@ -61,6 +63,7 @@ export class SandboxManager {
|
|
|
61
63
|
signal: this.config.signal,
|
|
62
64
|
initTimeoutMs: this.config.sandboxInitTimeoutMs,
|
|
63
65
|
maxPromptChars: this.config.maxPromptChars,
|
|
66
|
+
awaitTimeoutS: this.config.awaitTimeoutS,
|
|
64
67
|
handlers,
|
|
65
68
|
}).then(async (s) => {
|
|
66
69
|
// Load context on first creation if available.
|
|
@@ -85,19 +88,19 @@ export class SandboxManager {
|
|
|
85
88
|
* a promise queue (second call waits for the first, no interleaving). On failure the sandbox
|
|
86
89
|
* is nullified so the next call recreates it (death-recreate).
|
|
87
90
|
*/
|
|
88
|
-
async exec(code: string): Promise<ReplResult> {
|
|
89
|
-
return this.execQueued(code);
|
|
91
|
+
async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
|
|
92
|
+
return this.execQueued(code, undefined, signal);
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
/**
|
|
93
96
|
* Execute code after running `setup` inside the serialized execution slot, so per-invocation
|
|
94
97
|
* handler state (emitter, limits, depth) always matches the active REPL run.
|
|
95
98
|
*/
|
|
96
|
-
async execWithSetup(code: string, setup: () => void): Promise<ReplResult> {
|
|
97
|
-
return this.execQueued(code, setup);
|
|
99
|
+
async execWithSetup(code: string, setup: () => void, signal?: AbortSignal): Promise<ReplResult> {
|
|
100
|
+
return this.execQueued(code, setup, signal);
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
private async execQueued(code: string, setup?: () => void): Promise<ReplResult> {
|
|
103
|
+
private async execQueued(code: string, setup?: () => void, signal?: AbortSignal): Promise<ReplResult> {
|
|
101
104
|
if (!this.sandbox) throw new Error("Sandbox not initialized — call getOrCreate first");
|
|
102
105
|
|
|
103
106
|
// Serialize: queue behind any in-flight execution
|
|
@@ -111,7 +114,7 @@ export class SandboxManager {
|
|
|
111
114
|
const sandbox = this.sandbox;
|
|
112
115
|
if (!sandbox) throw new Error("Sandbox not initialized — previous execution disposed it");
|
|
113
116
|
setup?.();
|
|
114
|
-
return await sandbox.exec(code);
|
|
117
|
+
return await sandbox.exec(code, signal);
|
|
115
118
|
} catch (err) {
|
|
116
119
|
// Death-recreate: worker died — nullify so next repl() recreates
|
|
117
120
|
if (this.sandbox) {
|
|
@@ -128,6 +131,17 @@ export class SandboxManager {
|
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Keep the live sandbox's request watchdog from firing while it is legitimately idle.
|
|
136
|
+
*
|
|
137
|
+
* The watchdog only refreshes on frames arriving at THIS sandbox, but a detached
|
|
138
|
+
* rlm_query child does its work in its own sandbox — so without a heartbeat a healthy
|
|
139
|
+
* long-running child would trip death-recreate and destroy the REPL namespace.
|
|
140
|
+
*/
|
|
141
|
+
refreshWatchdog(): void {
|
|
142
|
+
this.sandbox?.refreshWatchdog();
|
|
143
|
+
}
|
|
144
|
+
|
|
131
145
|
/** True if the sandbox is alive and not disposed. */
|
|
132
146
|
get isAlive(): boolean {
|
|
133
147
|
return this.sandbox !== null && !this.disposed;
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type WorkerResponse,
|
|
26
26
|
} from "./protocol.ts";
|
|
27
27
|
import { errorMessage, formatError } from "../util/errors.ts";
|
|
28
|
+
import { trace, traceEnabled } from "../util/trace.ts";
|
|
28
29
|
|
|
29
30
|
/** Result of a host-side library pack requested by `load_library`. */
|
|
30
31
|
export interface LibraryLoadResult {
|
|
@@ -38,12 +39,23 @@ export interface LibraryLoadResult {
|
|
|
38
39
|
readonly alreadyLoaded?: boolean;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Per-interrupt routing context for the sub-LLM handlers.
|
|
44
|
+
*
|
|
45
|
+
* Only the four sub-call kinds can be spawned, so only they carry it; the interactive and
|
|
46
|
+
* pipeline handlers are always synchronous within one exec.
|
|
47
|
+
*/
|
|
48
|
+
export interface SubcallOpts {
|
|
49
|
+
/** Started via `spawn()` — route to session-scoped state, not the current invocation. */
|
|
50
|
+
readonly detached: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
/** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
|
|
42
54
|
export interface SubLlmHandlers {
|
|
43
|
-
llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
44
|
-
llmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
|
|
45
|
-
rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
46
|
-
rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
|
|
55
|
+
llmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
|
|
56
|
+
llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
|
|
57
|
+
rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
|
|
58
|
+
rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
|
|
47
59
|
advancePhase(phase: string, summary: string | undefined, depth: number): Promise<string>;
|
|
48
60
|
saveArtifact(kind: string, content: string, depth: number): Promise<string>;
|
|
49
61
|
askUserQuestion(questions: readonly AskQuestion[], depth: number): Promise<AskAnswer[]>;
|
|
@@ -73,6 +85,11 @@ export interface SandboxOptions {
|
|
|
73
85
|
* Native repl() data work leaves this false so scratch-file writes still work.
|
|
74
86
|
*/
|
|
75
87
|
readonly readOnly?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Max seconds the worker will wait for a host reply while parked in `_drain_until`
|
|
90
|
+
* (rlm_await / sync sub-call). Defaults to the worker's own RLM_AWAIT_TIMEOUT_S (600).
|
|
91
|
+
*/
|
|
92
|
+
readonly awaitTimeoutS?: number;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
|
|
@@ -161,6 +178,9 @@ export class PythonSandbox {
|
|
|
161
178
|
if (opts.readOnly) {
|
|
162
179
|
workerArgs.push("--read-only");
|
|
163
180
|
}
|
|
181
|
+
if (opts.awaitTimeoutS !== undefined) {
|
|
182
|
+
workerArgs.push("--await-timeout", String(opts.awaitTimeoutS));
|
|
183
|
+
}
|
|
164
184
|
this.proc = spawn(
|
|
165
185
|
python,
|
|
166
186
|
workerArgs,
|
|
@@ -231,8 +251,8 @@ export class PythonSandbox {
|
|
|
231
251
|
return file;
|
|
232
252
|
}
|
|
233
253
|
|
|
234
|
-
async exec(code: string): Promise<ReplResult> {
|
|
235
|
-
const res = await this.request({ type: "exec", code });
|
|
254
|
+
async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
|
|
255
|
+
const res = await this.request({ type: "exec", code }, signal);
|
|
236
256
|
if (!res.ok) throw new Error(res.error ?? "exec failed");
|
|
237
257
|
return {
|
|
238
258
|
stdout: res.stdout ?? "",
|
|
@@ -297,12 +317,27 @@ export class PythonSandbox {
|
|
|
297
317
|
});
|
|
298
318
|
}
|
|
299
319
|
|
|
300
|
-
private request(payload: RequestBody): Promise<WorkerResponse> {
|
|
320
|
+
private request(payload: RequestBody, signal?: AbortSignal): Promise<WorkerResponse> {
|
|
301
321
|
if (this.disposed) return Promise.reject(new Error("sandbox disposed"));
|
|
322
|
+
if (signal?.aborted) return Promise.reject(new Error("repl execution aborted"));
|
|
302
323
|
const id = `r${++this.seq}`;
|
|
303
324
|
return new Promise<WorkerResponse>((resolve, reject) => {
|
|
304
325
|
const timer = this.createWatchdog(id, payload.type, reject);
|
|
305
|
-
|
|
326
|
+
// Cancel == kill. The worker may be parked inside `_drain_until` with no other way out;
|
|
327
|
+
// `proc.on("exit") -> failAll` settles this request and SandboxManager's catch recreates
|
|
328
|
+
// the sandbox. REPL variables are lost — the documented price of interrupting.
|
|
329
|
+
const onAbort = (): void => {
|
|
330
|
+
this.pending.delete(id);
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
try { this.proc.kill("SIGKILL"); } catch { /* already dead */ }
|
|
333
|
+
reject(new Error("repl execution aborted — REPL variables were reset"));
|
|
334
|
+
};
|
|
335
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
336
|
+
const once = <T>(settle: (value: T) => void) => (value: T): void => {
|
|
337
|
+
signal?.removeEventListener("abort", onAbort);
|
|
338
|
+
settle(value);
|
|
339
|
+
};
|
|
340
|
+
this.pending.set(id, { resolve: once(resolve), reject: once(reject), timer, requestType: payload.type });
|
|
306
341
|
this.send({ id, ...payload } as ParentMessage);
|
|
307
342
|
});
|
|
308
343
|
}
|
|
@@ -323,7 +358,23 @@ export class PythonSandbox {
|
|
|
323
358
|
}
|
|
324
359
|
}
|
|
325
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Refresh the parent-side request watchdog for every pending request.
|
|
363
|
+
* Used during long mid-exec work that does not
|
|
364
|
+
* produce additional worker interrupts on this sandbox.
|
|
365
|
+
*/
|
|
366
|
+
refreshWatchdog(): void {
|
|
367
|
+
this.touchPending();
|
|
368
|
+
}
|
|
369
|
+
|
|
326
370
|
private send(msg: ParentMessage): void {
|
|
371
|
+
if (traceEnabled) {
|
|
372
|
+
trace("frame.out", {
|
|
373
|
+
frame: msg.type,
|
|
374
|
+
id: "id" in msg ? msg.id : undefined,
|
|
375
|
+
rid: "rid" in msg ? msg.rid : undefined,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
327
378
|
this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
328
379
|
}
|
|
329
380
|
|
|
@@ -353,6 +404,19 @@ export class PythonSandbox {
|
|
|
353
404
|
}
|
|
354
405
|
|
|
355
406
|
private dispatch(msg: WorkerMessage): void {
|
|
407
|
+
if (traceEnabled) {
|
|
408
|
+
if (isInterrupt(msg)) {
|
|
409
|
+
trace("frame.in", {
|
|
410
|
+
frame: msg.type,
|
|
411
|
+
rid: msg.rid,
|
|
412
|
+
depth: msg.depth,
|
|
413
|
+
detached: msg.detached === true,
|
|
414
|
+
prompts: "prompts" in msg ? msg.prompts?.length : 1,
|
|
415
|
+
});
|
|
416
|
+
} else {
|
|
417
|
+
trace("frame.in", { frame: "response", id: msg.id, ok: msg.ok });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
356
420
|
if (isInterrupt(msg)) {
|
|
357
421
|
this.touchPending();
|
|
358
422
|
void this.serviceInterrupt(msg);
|
|
@@ -368,18 +432,19 @@ export class PythonSandbox {
|
|
|
368
432
|
private async serviceInterrupt(msg: WorkerInterrupt): Promise<void> {
|
|
369
433
|
const h = this.handlers;
|
|
370
434
|
const d = msg.depth;
|
|
435
|
+
const opts: SubcallOpts = { detached: msg.detached === true };
|
|
371
436
|
try {
|
|
372
437
|
if (msg.type === "llm_query") {
|
|
373
|
-
const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d);
|
|
438
|
+
const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
|
|
374
439
|
this.reply(msg.rid, { response });
|
|
375
440
|
} else if (msg.type === "rlm_query") {
|
|
376
|
-
const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d);
|
|
441
|
+
const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
|
|
377
442
|
this.reply(msg.rid, { response });
|
|
378
443
|
} else if (msg.type === "llm_query_batched") {
|
|
379
|
-
const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d);
|
|
444
|
+
const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
|
|
380
445
|
this.reply(msg.rid, { responses });
|
|
381
446
|
} else if (msg.type === "rlm_query_batched") {
|
|
382
|
-
const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d);
|
|
447
|
+
const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
|
|
383
448
|
this.reply(msg.rid, { responses });
|
|
384
449
|
} else if (msg.type === "advance_phase") {
|
|
385
450
|
const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
|