@hicaru/pi-rlm 0.2.0 → 0.2.2

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.
Files changed (68) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +382 -0
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +7 -15
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +115 -360
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +49 -10
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -386
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +14 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/py/worker.py +836 -0
  30. package/src/sandbox/sandbox-manager.ts +33 -6
  31. package/src/sandbox/sandbox.ts +153 -182
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/background-tasks.ts +95 -0
  34. package/src/tool/repl-details.ts +4 -2
  35. package/src/tool/repl-render.ts +58 -0
  36. package/src/tool/repl-result.ts +70 -0
  37. package/src/tool/repl-tool.ts +178 -216
  38. package/src/tool/rlm-aggregator.ts +2 -10
  39. package/src/tool/rlm-details.ts +0 -2
  40. package/src/tool/rlm-events.ts +10 -16
  41. package/src/tool/rlm-tool.ts +1 -12
  42. package/src/tool/subcall-render.ts +15 -3
  43. package/src/tool/subcall-store.ts +57 -1
  44. package/src/ui/config-panel.ts +4 -16
  45. package/src/ui/intro.ts +1 -2
  46. package/src/ui/model-picker.ts +34 -10
  47. package/src/ui/status.ts +3 -7
  48. package/src/util/concurrency.ts +91 -13
  49. package/src/util/trace.ts +42 -0
  50. package/src/bridge/fallback-todo.ts +0 -137
  51. package/src/bridge/interactive.ts +0 -65
  52. package/src/bridge/llm-query.ts +0 -156
  53. package/src/bridge/pi-interactive.ts +0 -41
  54. package/src/bridge/rlm-query.ts +0 -108
  55. package/src/core/artifacts.ts +0 -89
  56. package/src/core/critique.ts +0 -92
  57. package/src/core/gates.ts +0 -301
  58. package/src/core/pipeline-handlers.ts +0 -319
  59. package/src/core/pipeline.ts +0 -268
  60. package/src/prompts/phases.ts +0 -104
  61. package/src/sandbox/worker.py +0 -1078
  62. package/src/state/index.ts +0 -24
  63. package/src/state/internal.ts +0 -46
  64. package/src/state/paths.ts +0 -44
  65. package/src/state/reads.ts +0 -133
  66. package/src/state/resume.ts +0 -173
  67. package/src/state/rows.ts +0 -123
  68. package/src/state/writes.ts +0 -58
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { PythonSandbox, type SubLlmHandlers } from "./sandbox.ts";
8
8
  import type { ReplResult } from "./protocol.ts";
9
+ import { mergeLibraryIntoContext } from "../context/library-context.ts";
9
10
 
10
11
  /** Static configuration for sandbox creation — set once, reused across getOrCreate calls. */
11
12
  export interface SandboxManagerConfig {
@@ -14,6 +15,8 @@ export interface SandboxManagerConfig {
14
15
  readonly python: string;
15
16
  readonly sandboxInitTimeoutMs: number;
16
17
  readonly maxPromptChars: number;
18
+ /** Max seconds the worker waits for a host reply while parked in rlm_await. */
19
+ readonly awaitTimeoutS: number;
17
20
  readonly signal?: AbortSignal;
18
21
  readonly onSandboxDiscarded?: () => void;
19
22
  }
@@ -32,6 +35,18 @@ export class SandboxManager {
32
35
 
33
36
  constructor(private readonly config: SandboxManagerConfig) {}
34
37
 
38
+ /**
39
+ * Append a library payload to the context this manager replays on death-recreate.
40
+ *
41
+ * The live worker has ALREADY appended it in-process (worker.py `_append_library`), so this
42
+ * deliberately does not reload — it only keeps the host's replay copy truthful. Without it a
43
+ * recreate silently rolls the sandbox back to a repo-only context, and any child inheriting
44
+ * this payload would never see the library. Dedups by `lib/<id>/` prefix.
45
+ */
46
+ appendLibrary(payload: unknown): void {
47
+ this.contextPayload = mergeLibraryIntoContext(this.contextPayload, payload);
48
+ }
49
+
35
50
  /**
36
51
  * Lazy get-or-create the sandbox. On first call, spawns PythonSandbox with the
37
52
  * given handlers. Subsequent calls return the existing sandbox immediately.
@@ -61,6 +76,7 @@ export class SandboxManager {
61
76
  signal: this.config.signal,
62
77
  initTimeoutMs: this.config.sandboxInitTimeoutMs,
63
78
  maxPromptChars: this.config.maxPromptChars,
79
+ awaitTimeoutS: this.config.awaitTimeoutS,
64
80
  handlers,
65
81
  }).then(async (s) => {
66
82
  // Load context on first creation if available.
@@ -85,19 +101,19 @@ export class SandboxManager {
85
101
  * a promise queue (second call waits for the first, no interleaving). On failure the sandbox
86
102
  * is nullified so the next call recreates it (death-recreate).
87
103
  */
88
- async exec(code: string): Promise<ReplResult> {
89
- return this.execQueued(code);
104
+ async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
105
+ return this.execQueued(code, undefined, signal);
90
106
  }
91
107
 
92
108
  /**
93
109
  * Execute code after running `setup` inside the serialized execution slot, so per-invocation
94
110
  * handler state (emitter, limits, depth) always matches the active REPL run.
95
111
  */
96
- async execWithSetup(code: string, setup: () => void): Promise<ReplResult> {
97
- return this.execQueued(code, setup);
112
+ async execWithSetup(code: string, setup: () => void, signal?: AbortSignal): Promise<ReplResult> {
113
+ return this.execQueued(code, setup, signal);
98
114
  }
99
115
 
100
- private async execQueued(code: string, setup?: () => void): Promise<ReplResult> {
116
+ private async execQueued(code: string, setup?: () => void, signal?: AbortSignal): Promise<ReplResult> {
101
117
  if (!this.sandbox) throw new Error("Sandbox not initialized — call getOrCreate first");
102
118
 
103
119
  // Serialize: queue behind any in-flight execution
@@ -111,7 +127,7 @@ export class SandboxManager {
111
127
  const sandbox = this.sandbox;
112
128
  if (!sandbox) throw new Error("Sandbox not initialized — previous execution disposed it");
113
129
  setup?.();
114
- return await sandbox.exec(code);
130
+ return await sandbox.exec(code, signal);
115
131
  } catch (err) {
116
132
  // Death-recreate: worker died — nullify so next repl() recreates
117
133
  if (this.sandbox) {
@@ -128,6 +144,17 @@ export class SandboxManager {
128
144
  }
129
145
  }
130
146
 
147
+ /**
148
+ * Keep the live sandbox's request watchdog from firing while it is legitimately idle.
149
+ *
150
+ * The watchdog only refreshes on frames arriving at THIS sandbox, but a detached
151
+ * rlm_query child does its work in its own sandbox — so without a heartbeat a healthy
152
+ * long-running child would trip death-recreate and destroy the REPL namespace.
153
+ */
154
+ refreshWatchdog(): void {
155
+ this.sandbox?.refreshWatchdog();
156
+ }
157
+
131
158
  /** True if the sandbox is alive and not disposed. */
132
159
  get isAlive(): boolean {
133
160
  return this.sandbox !== null && !this.disposed;
@@ -8,48 +8,23 @@
8
8
  */
9
9
 
10
10
  import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
11
- import { writeFile, unlink } from "node:fs/promises";
12
- import { tmpdir } from "node:os";
11
+ import { once } from "node:events";
13
12
  import { dirname, join } from "node:path";
14
13
  import { fileURLToPath } from "node:url";
15
14
  import {
16
15
  isInterrupt,
17
16
  isWorkerMessage,
18
- type AskAnswer,
19
- type AskQuestion,
20
17
  type ParentMessage,
21
18
  type ReplResult,
22
- type WorkerInterrupt,
23
19
  type WorkerMessage,
24
20
  type WorkerRequest,
25
21
  type WorkerResponse,
26
22
  } from "./protocol.ts";
27
- import { errorMessage, formatError } from "../util/errors.ts";
28
-
29
- /** Result of a host-side library pack requested by `load_library`. */
30
- export interface LibraryLoadResult {
31
- readonly payload: unknown; // always ContextFile[] under lib/<id>/
32
- readonly index: number; // resume-sidecar index (not a REPL var name); -1 if alreadyLoaded
33
- readonly files?: number;
34
- readonly chars: number;
35
- readonly sourceId: string;
36
- readonly pathPrefix: string;
37
- /** Host already has this library — no pack, no sidecar, empty payload. */
38
- readonly alreadyLoaded?: boolean;
39
- }
23
+ import { pinContext, type PinnedContext } from "./context-file.ts";
24
+ import { REJECT, serviceInterrupt, type ReplyBody, type SubLlmHandlers } from "./interrupts.ts";
25
+ import { trace, traceEnabled } from "../util/trace.ts";
40
26
 
41
- /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
42
- 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[]>;
47
- advancePhase(phase: string, summary: string | undefined, depth: number): Promise<string>;
48
- saveArtifact(kind: string, content: string, depth: number): Promise<string>;
49
- askUserQuestion(questions: readonly AskQuestion[], depth: number): Promise<AskAnswer[]>;
50
- todo(action: string, params: Record<string, unknown>, depth: number): Promise<string>;
51
- loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
52
- }
27
+ export type { LibraryLoadResult, SubcallOpts, SubLlmHandlers } from "./interrupts.ts";
53
28
 
54
29
  export interface SandboxOptions {
55
30
  /** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
@@ -69,15 +44,16 @@ export interface SandboxOptions {
69
44
  /** Sub-LLM prompt cap (chars) — sizes llm_query_chunked chunks inside the worker. */
70
45
  readonly maxPromptChars?: number;
71
46
  /**
72
- * When true, the worker rejects open() write modes (pipeline read-only runs).
73
- * Native repl() data work leaves this false so scratch-file writes still work.
47
+ * Max seconds the worker will wait for a host reply while parked in `_drain_until`
48
+ * (rlm_await / sync sub-call). Defaults to the worker's own RLM_AWAIT_TIMEOUT_S (600).
74
49
  */
75
- readonly readOnly?: boolean;
50
+ readonly awaitTimeoutS?: number;
76
51
  }
77
52
 
78
- const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
53
+ const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "py", "worker.py");
79
54
  const STDERR_TAIL_CHARS = 8_192;
80
- const TODO_PROTO_KEYS = new Set(["type", "rid", "depth", "action"]);
55
+ /** How long dispose() waits for a clean worker exit before escalating to SIGKILL. */
56
+ const SHUTDOWN_GRACE_MS = 50;
81
57
 
82
58
  // The sandbox runs untrusted model-authored code; it must never inherit provider secrets.
83
59
  const SENSITIVE_ENV = /API[_-]?KEY|ACCESS[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|ANTHROPIC|OPENAI|_KEY$/i;
@@ -90,22 +66,6 @@ function sanitizedEnv(): NodeJS.ProcessEnv {
90
66
  return env;
91
67
  }
92
68
 
93
- const REJECT: SubLlmHandlers = {
94
- llmQuery: async () => formatError("sub-LLM bridge not configured"),
95
- llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
96
- rlmQuery: async () => formatError("sub-LLM bridge not configured"),
97
- rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
98
- advancePhase: async () => formatError("phase advancement not available"),
99
- saveArtifact: async () => formatError("save_artifact not available"),
100
- askUserQuestion: async (questions) => questions.map((q) => ({
101
- question: q.question,
102
- selected: [],
103
- custom: formatError("ask_user_question not configured"),
104
- })),
105
- todo: async () => formatError("todo not configured"),
106
- loadLibrary: async () => { throw new Error("load_library not configured"); },
107
- };
108
-
109
69
  /** Distributive omit so each union member keeps its own fields (plain Omit collapses to shared keys). */
110
70
  type RequestBody = WorkerRequest extends infer T ? (T extends { id: string } ? Omit<T, "id"> : never) : never;
111
71
 
@@ -158,8 +118,8 @@ export class PythonSandbox {
158
118
  if (opts.maxPromptChars !== undefined) {
159
119
  workerArgs.push("--max-prompt-chars", String(opts.maxPromptChars));
160
120
  }
161
- if (opts.readOnly) {
162
- workerArgs.push("--read-only");
121
+ if (opts.awaitTimeoutS !== undefined) {
122
+ workerArgs.push("--await-timeout", String(opts.awaitTimeoutS));
163
123
  }
164
124
  this.proc = spawn(
165
125
  python,
@@ -171,11 +131,30 @@ export class PythonSandbox {
171
131
  this.proc.stdout.on("data", (chunk: string) => this.onData(chunk));
172
132
  this.proc.stderr.setEncoding("utf8");
173
133
  this.proc.stderr.on("data", (chunk: string) => this.appendStderr(chunk));
134
+
135
+ // A dead worker's pipe fails the write ASYNCHRONOUSLY: node emits 'error' on the stream, and
136
+ // an EventEmitter 'error' with no listener is an uncaughtException — which no try/catch
137
+ // around send() and no `await dispose().catch()` can intercept. Without these three
138
+ // listeners a worker dying mid-exec took the entire pi process down with it (EPIPE from the
139
+ // shutdown frame dispose() writes after the watchdog SIGKILLs).
140
+ // Record and swallow: the real reason is already surfaced by failAll on 'exit'.
141
+ const swallowPipeError = (stream: string) => (err: NodeJS.ErrnoException): void => {
142
+ this.appendStderr(`[rlm] worker ${stream} ${err.code ?? "error"}: ${err.message}\n`);
143
+ };
144
+ this.proc.stdin.on("error", swallowPipeError("stdin"));
145
+ this.proc.stdout.on("error", swallowPipeError("stdout"));
146
+ this.proc.stderr.on("error", swallowPipeError("stderr"));
147
+
174
148
  this.proc.on("error", (err: NodeJS.ErrnoException) => {
175
149
  const hint = err.code === "ENOENT" ? ` ('${python}' not found — is Python installed and on PATH?)` : "";
176
150
  this.failAll(new Error(`failed to start sandbox${hint}: ${err.message}`));
177
151
  });
178
- this.proc.on("exit", () => this.failAll(new Error(`worker exited; stderr=${this.stderr.trim()}`)));
152
+ // Name the cause: a SIGKILL (watchdog, abort, OOM killer) reads very differently from a
153
+ // Python-level crash, and this message is what reaches the user as `REPL error: …`.
154
+ this.proc.on("exit", (code, signal) => this.failAll(new Error(
155
+ `worker exited (${signal !== null ? `signal ${signal}` : `code ${code}`}); `
156
+ + `stderr=${this.stderr.trim()}`,
157
+ )));
179
158
 
180
159
  this.ready = this.waitForInit();
181
160
 
@@ -204,35 +183,31 @@ export class PythonSandbox {
204
183
  return sandbox;
205
184
  }
206
185
 
186
+ /**
187
+ * Load a payload whose pin this sandbox does not own — it acquires and releases one itself.
188
+ * Used by SandboxManager and tests; the engine owns its run's pin and calls the pinned form.
189
+ */
207
190
  async loadContext(payload: unknown): Promise<number> {
208
- const isJson = typeof payload !== "string";
209
- let path: string | undefined;
191
+ const pinned = await pinContext(payload);
210
192
  try {
211
- path = await this.writeContextFile(payload, isJson);
212
- const res = await this.request({ type: "load_context", path, json: isJson });
213
- if (!res.ok) throw new Error(res.error ?? "load_context failed");
214
- return res.index ?? 0;
193
+ return await this.loadContextPinned(pinned);
215
194
  } finally {
216
- if (path) await unlink(path).catch(() => {});
195
+ await pinned.release();
217
196
  }
218
197
  }
219
198
 
220
- private async writeContextFile(payload: unknown, isJson: boolean): Promise<string> {
221
- const file = join(
222
- tmpdir(),
223
- `rlm-ctx-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${isJson ? "json" : "txt"}`,
224
- );
225
- try {
226
- await writeFile(file, isJson ? JSON.stringify(payload) : (payload as string));
227
- } catch (e) {
228
- await unlink(file).catch(() => {});
229
- throw e;
230
- }
231
- return file;
199
+ /**
200
+ * Load from a pin the CALLER owns and will release. Keeping ownership outside means a run and
201
+ * every child that inherits its payload share one serialization and one file.
202
+ */
203
+ async loadContextPinned(pinned: PinnedContext): Promise<number> {
204
+ const res = await this.request({ type: "load_context", path: pinned.path, json: pinned.json });
205
+ if (!res.ok) throw new Error(res.error ?? "load_context failed");
206
+ return res.index ?? 0;
232
207
  }
233
208
 
234
- async exec(code: string): Promise<ReplResult> {
235
- const res = await this.request({ type: "exec", code });
209
+ async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
210
+ const res = await this.request({ type: "exec", code }, signal);
236
211
  if (!res.ok) throw new Error(res.error ?? "exec failed");
237
212
  return {
238
213
  stdout: res.stdout ?? "",
@@ -248,38 +223,22 @@ export class PythonSandbox {
248
223
  async dispose(): Promise<void> {
249
224
  if (this.disposed) return;
250
225
  this.disposed = true;
251
- try {
226
+ // Handshake only a live worker. The watchdog SIGKILLs before SandboxManager's catch calls
227
+ // dispose(), and writing the shutdown frame to a process that is already dead is exactly
228
+ // what produced the EPIPE that killed the host. Skipping it also drops a pointless 50ms
229
+ // wait from every dead-worker teardown.
230
+ if (this.workerAlive) {
252
231
  this.send({ id: "_shutdown", type: "shutdown" });
253
- } catch {
254
- /* pipe may already be gone */
232
+ // Wait for the worker to actually go rather than sleeping a fixed 50ms: this returns the
233
+ // instant it exits (the common case) and still gives up promptly if it never will, in
234
+ // which case the SIGKILL below finishes the job.
235
+ await once(this.proc, "exit", { signal: AbortSignal.timeout(SHUTDOWN_GRACE_MS) })
236
+ .catch(() => { /* did not exit in time — SIGKILL below */ });
255
237
  }
256
- await new Promise((r) => setTimeout(r, 50));
257
238
  if (this.proc.exitCode === null) this.proc.kill("SIGKILL");
258
239
  this.failAll(new Error("sandbox disposed"));
259
240
  }
260
241
 
261
- /** Pickle the worker's user namespace atomically to path (rename .tmp \u2192 final inside worker). */
262
- async snapshot(path: string, nonce: string): Promise<boolean> {
263
- try {
264
- const res = await this.request({ type: "snapshot", path, nonce });
265
- if (!res.ok) return false;
266
- return res.ok;
267
- } catch {
268
- return false;
269
- }
270
- }
271
-
272
- /** Restore user variables. Worker verifies session nonce before deserializing. */
273
- async restore(path: string, nonce: string): Promise<boolean> {
274
- try {
275
- const res = await this.request({ type: "restore", path, nonce });
276
- if (!res.ok) return false;
277
- return res.ok;
278
- } catch {
279
- return false;
280
- }
281
- }
282
-
283
242
  // ---- internals ------------------------------------------------------------------------
284
243
 
285
244
  private waitForInit(): Promise<void> {
@@ -297,12 +256,34 @@ export class PythonSandbox {
297
256
  });
298
257
  }
299
258
 
300
- private request(payload: RequestBody): Promise<WorkerResponse> {
259
+ private request(payload: RequestBody, signal?: AbortSignal): Promise<WorkerResponse> {
301
260
  if (this.disposed) return Promise.reject(new Error("sandbox disposed"));
261
+ if (signal?.aborted) return Promise.reject(new Error("repl execution aborted"));
262
+ // Reject before registering the pending entry: `send` no-ops for a dead worker, so a request
263
+ // queued here would otherwise sit until the watchdog fired instead of failing now.
264
+ if (!this.workerAlive) {
265
+ return Promise.reject(new Error(
266
+ `worker is not running (${this.exitDescription()}); request '${payload.type}' not sent`,
267
+ ));
268
+ }
302
269
  const id = `r${++this.seq}`;
303
270
  return new Promise<WorkerResponse>((resolve, reject) => {
304
271
  const timer = this.createWatchdog(id, payload.type, reject);
305
- this.pending.set(id, { resolve, reject, timer, requestType: payload.type });
272
+ // Cancel == kill. The worker may be parked inside `_drain_until` with no other way out;
273
+ // `proc.on("exit") -> failAll` settles this request and SandboxManager's catch recreates
274
+ // the sandbox. REPL variables are lost — the documented price of interrupting.
275
+ const onAbort = (): void => {
276
+ this.pending.delete(id);
277
+ clearTimeout(timer);
278
+ try { this.proc.kill("SIGKILL"); } catch { /* already dead */ }
279
+ reject(new Error("repl execution aborted — REPL variables were reset"));
280
+ };
281
+ signal?.addEventListener("abort", onAbort, { once: true });
282
+ const once = <T>(settle: (value: T) => void) => (value: T): void => {
283
+ signal?.removeEventListener("abort", onAbort);
284
+ settle(value);
285
+ };
286
+ this.pending.set(id, { resolve: once(resolve), reject: once(reject), timer, requestType: payload.type });
306
287
  this.send({ id, ...payload } as ParentMessage);
307
288
  });
308
289
  }
@@ -323,10 +304,64 @@ export class PythonSandbox {
323
304
  }
324
305
  }
325
306
 
307
+ /**
308
+ * Refresh the parent-side request watchdog for every pending request.
309
+ * Used during long mid-exec work that does not
310
+ * produce additional worker interrupts on this sandbox.
311
+ */
312
+ refreshWatchdog(): void {
313
+ this.touchPending();
314
+ }
315
+
326
316
  private send(msg: ParentMessage): void {
317
+ if (traceEnabled) {
318
+ trace("frame.out", {
319
+ frame: msg.type,
320
+ id: "id" in msg ? msg.id : undefined,
321
+ rid: "rid" in msg ? msg.rid : undefined,
322
+ });
323
+ }
324
+ // Never write to a corpse. The write would fail asynchronously and, historically, take the
325
+ // host process with it; even with the stdin 'error' listener in place there is nothing to
326
+ // gain. MUST NOT throw — `reply()` calls this from serviceInterrupt's catch, where a throw
327
+ // would become an unhandled rejection, trading one crash for another.
328
+ if (!this.workerAlive) {
329
+ this.appendStderr(`[rlm] dropped '${msg.type}' frame: worker ${this.exitDescription()}\n`);
330
+ return;
331
+ }
327
332
  this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
328
333
  }
329
334
 
335
+ /**
336
+ * Listener counts on the worker's stdio pipes. Exposed for tests.
337
+ *
338
+ * The `'error'` listener on each is load-bearing: an EventEmitter `'error'` with no listener is
339
+ * an uncaughtException, so a failed write to a dead worker's stdin kills the HOST process. That
340
+ * cannot be behaviour-tested reliably — whether the write reaches the syscall depends on
341
+ * whether node has reaped the child yet — so the invariant is asserted structurally.
342
+ */
343
+ get pipeErrorListenerCounts(): Readonly<Record<"stdin" | "stdout" | "stderr", number>> {
344
+ return Object.freeze({
345
+ stdin: this.proc.stdin.listenerCount("error"),
346
+ stdout: this.proc.stdout.listenerCount("error"),
347
+ stderr: this.proc.stderr.listenerCount("error"),
348
+ });
349
+ }
350
+
351
+ /** False once the worker is gone — exited, signalled, or its stdin torn down. */
352
+ private get workerAlive(): boolean {
353
+ return this.proc.exitCode === null
354
+ && this.proc.signalCode === null
355
+ && this.proc.stdin.writable;
356
+ }
357
+
358
+ /** How the worker went away, for diagnostics. */
359
+ private exitDescription(): string {
360
+ if (this.proc.signalCode !== null) return `killed by ${this.proc.signalCode}`;
361
+ if (this.proc.exitCode !== null) return `exited with code ${this.proc.exitCode}`;
362
+ return "stdin closed";
363
+ }
364
+
330
365
  private onData(chunk: string): void {
331
366
  this.buf += chunk;
332
367
  let nl: number;
@@ -353,9 +388,22 @@ export class PythonSandbox {
353
388
  }
354
389
 
355
390
  private dispatch(msg: WorkerMessage): void {
391
+ if (traceEnabled) {
392
+ if (isInterrupt(msg)) {
393
+ trace("frame.in", {
394
+ frame: msg.type,
395
+ rid: msg.rid,
396
+ depth: msg.depth,
397
+ detached: msg.detached === true,
398
+ prompts: "prompts" in msg ? msg.prompts?.length : 1,
399
+ });
400
+ } else {
401
+ trace("frame.in", { frame: "response", id: msg.id, ok: msg.ok });
402
+ }
403
+ }
356
404
  if (isInterrupt(msg)) {
357
405
  this.touchPending();
358
- void this.serviceInterrupt(msg);
406
+ void serviceInterrupt(msg, this.handlers, (rid, body) => this.reply(rid, body));
359
407
  return;
360
408
  }
361
409
  const p = this.pending.get(msg.id);
@@ -365,84 +413,7 @@ export class PythonSandbox {
365
413
  p.resolve(msg);
366
414
  }
367
415
 
368
- private async serviceInterrupt(msg: WorkerInterrupt): Promise<void> {
369
- const h = this.handlers;
370
- const d = msg.depth;
371
- try {
372
- if (msg.type === "llm_query") {
373
- const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d);
374
- this.reply(msg.rid, { response });
375
- } else if (msg.type === "rlm_query") {
376
- const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d);
377
- this.reply(msg.rid, { response });
378
- } else if (msg.type === "llm_query_batched") {
379
- const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d);
380
- this.reply(msg.rid, { responses });
381
- } else if (msg.type === "rlm_query_batched") {
382
- const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d);
383
- this.reply(msg.rid, { responses });
384
- } else if (msg.type === "advance_phase") {
385
- const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
386
- this.reply(msg.rid, { response });
387
- } else if (msg.type === "save_artifact") {
388
- const response = await h.saveArtifact(msg.artifactKind ?? "", msg.content ?? "", d);
389
- this.reply(msg.rid, { response });
390
- } else if (msg.type === "ask_user_question") {
391
- const answers = await h.askUserQuestion(msg.questions ?? [], d);
392
- this.reply(msg.rid, { answers });
393
- } else if (msg.type === "todo") {
394
- const params = Object.fromEntries(
395
- Object.entries(msg).filter(([key]) => !TODO_PROTO_KEYS.has(key)),
396
- );
397
- const response = await h.todo(msg.action ?? "list", params, d);
398
- this.reply(msg.rid, { response });
399
- } else if (msg.type === "load_library") {
400
- const lib = await h.loadLibrary(msg.source ?? "", d);
401
- if (lib.alreadyLoaded) {
402
- // No temp file — worker short-circuits on already_loaded.
403
- this.reply(msg.rid, {
404
- already_loaded: true,
405
- index: lib.index,
406
- files: 0,
407
- chars: lib.chars,
408
- source_id: lib.sourceId,
409
- path_prefix: lib.pathPrefix,
410
- });
411
- } else {
412
- const isJson = typeof lib.payload !== "string";
413
- const path = await this.writeContextFile(lib.payload, isJson);
414
- // Worker reads then unlinks (worker._load_library). Host must not unlink here —
415
- // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
416
- this.reply(msg.rid, {
417
- path,
418
- json: isJson,
419
- index: lib.index,
420
- files: lib.files,
421
- chars: lib.chars,
422
- source_id: lib.sourceId,
423
- path_prefix: lib.pathPrefix,
424
- });
425
- }
426
- }
427
- } catch (err) {
428
- this.reply(msg.rid, { error: errorMessage(err) });
429
- }
430
- }
431
-
432
- private reply(rid: string, body: {
433
- response?: string;
434
- responses?: string[];
435
- answers?: AskAnswer[];
436
- path?: string;
437
- json?: boolean;
438
- index?: number;
439
- files?: number;
440
- chars?: number;
441
- source_id?: string;
442
- path_prefix?: string;
443
- already_loaded?: boolean;
444
- error?: string;
445
- }): void {
416
+ private reply(rid: string, body: ReplyBody): void {
446
417
  if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
447
418
  }
448
419
 
@@ -21,11 +21,37 @@ export function estimateMessageTokens(messages: { content: string }[]): number {
21
21
  return estimateTokens(chars);
22
22
  }
23
23
 
24
- /** Total character length of a context payload (string or list of strings). */
24
+ /**
25
+ * Character length of one context entry. `ContextFile`-shaped entries report their content
26
+ * length; anything else falls back to its serialized form.
27
+ *
28
+ * Deliberately does NOT import `isContextFile` from context/library-context.ts: that module
29
+ * imports `estimateTokens` from here, so the reverse import would be a cycle. `in`-narrowing
30
+ * needs no type guard and no cast.
31
+ */
32
+ function entryLength(entry: unknown): number {
33
+ if (typeof entry === "string") return entry.length;
34
+ if (entry !== null && typeof entry === "object" && "content" in entry) {
35
+ const content: unknown = entry.content;
36
+ if (typeof content === "string") return content.length;
37
+ }
38
+ return JSON.stringify(entry ?? "").length;
39
+ }
40
+
41
+ /**
42
+ * Total character length of a context payload (string, file bundle, or arbitrary value).
43
+ *
44
+ * The array branch must read each entry's `content`: `String(fileEntry)` yields
45
+ * "[object Object]" (15 chars), which under-reported a packed repository by ~200x. This number
46
+ * is what buildMetadataLine tells the model to size its batches against, and it is replayed
47
+ * into the system prompt, so it has to be real.
48
+ */
25
49
  export function contextLength(context: unknown): number {
26
50
  if (typeof context === "string") return context.length;
27
- if (Array.isArray(context)) return context.reduce<number>((n, x) => n + String(x).length, 0);
28
- return JSON.stringify(context ?? "").length;
51
+ if (!Array.isArray(context)) return JSON.stringify(context ?? "").length;
52
+ let total = 0; // running sum — no intermediate array, no per-entry closure
53
+ for (let i = 0; i < context.length; i++) total += entryLength(context[i]);
54
+ return total;
29
55
  }
30
56
 
31
57
  /** Human label for a context payload's type, used in the metadata prompt. */