@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.
@@ -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;
@@ -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
- this.pending.set(id, { resolve, reject, timer, requestType: payload.type });
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);