@hicaru/pi-rlm 0.1.9 → 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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/bridge/fallback-todo.ts +12 -1
  3. package/src/bridge/subcall-handlers.ts +336 -0
  4. package/src/commands/rlm-config.ts +8 -8
  5. package/src/commands/rlm.ts +48 -12
  6. package/src/config/defaults.ts +4 -1
  7. package/src/config/settings.ts +33 -3
  8. package/src/context/repomix-context.ts +5 -10
  9. package/src/core/answer.ts +4 -3
  10. package/src/core/artifacts.ts +4 -3
  11. package/src/core/engine.ts +101 -267
  12. package/src/core/gates.ts +3 -3
  13. package/src/core/limits.ts +19 -1
  14. package/src/core/pipeline-handlers.ts +319 -0
  15. package/src/core/pipeline.ts +2 -2
  16. package/src/core/types.ts +25 -27
  17. package/src/index.ts +63 -17
  18. package/src/mode/rlm-mode.ts +8 -11
  19. package/src/prompts/system.ts +164 -52
  20. package/src/prompts/user.ts +1 -5
  21. package/src/sandbox/protocol.ts +6 -7
  22. package/src/sandbox/sandbox-manager.ts +25 -11
  23. package/src/sandbox/sandbox.ts +93 -22
  24. package/src/sandbox/worker.py +798 -66
  25. package/src/state/paths.ts +1 -1
  26. package/src/state/reads.ts +12 -4
  27. package/src/state/resume.ts +5 -11
  28. package/src/text/parsing.ts +0 -6
  29. package/src/tool/background-tasks.ts +95 -0
  30. package/src/tool/repl-details.ts +2 -0
  31. package/src/tool/repl-tool.ts +223 -318
  32. package/src/tool/rlm-details.ts +0 -10
  33. package/src/tool/rlm-events.ts +10 -2
  34. package/src/tool/rlm-tool.ts +18 -31
  35. package/src/tool/subcall-render.ts +75 -11
  36. package/src/tool/subcall-store.ts +57 -1
  37. package/src/ui/config-panel.ts +41 -21
  38. package/src/ui/intro.ts +2 -1
  39. package/src/ui/status.ts +8 -5
  40. package/src/ui/theme-adapter.ts +36 -0
  41. package/src/ui/theme.ts +0 -25
  42. package/src/util/concurrency.ts +87 -13
  43. package/src/util/trace.ts +42 -0
  44. package/src/bridge/llm-query.ts +0 -133
  45. package/src/bridge/rlm-query.ts +0 -122
  46. package/src/mode/input-router.ts +0 -23
@@ -24,7 +24,8 @@ import {
24
24
  type WorkerRequest,
25
25
  type WorkerResponse,
26
26
  } from "./protocol.ts";
27
- import { formatError } from "../util/errors.ts";
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,9 +85,15 @@ 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");
96
+ const STDERR_TAIL_CHARS = 8_192;
79
97
  const TODO_PROTO_KEYS = new Set(["type", "rid", "depth", "action"]);
80
98
 
81
99
  // The sandbox runs untrusted model-authored code; it must never inherit provider secrets.
@@ -124,7 +142,23 @@ export class PythonSandbox {
124
142
  private readonly handlers: SubLlmHandlers;
125
143
  private readonly requestTimeoutMs: number;
126
144
  private readonly initTimeoutMs: number;
127
- private stderr = "";
145
+ /** Bounded stderr tail (chunks, newest last) — avoids rebuilding the buffer per chunk. */
146
+ private readonly stderrTail: string[] = [];
147
+ private stderrLen = 0;
148
+
149
+ /** Bounded tail of everything written to stderr, oldest chunks already dropped. */
150
+ private get stderr(): string {
151
+ return this.stderrTail.join("");
152
+ }
153
+
154
+ /** Record a diagnostic on the same bounded tail as real worker stderr. */
155
+ private appendStderr(text: string): void {
156
+ this.stderrTail.push(text);
157
+ this.stderrLen += text.length;
158
+ while (this.stderrLen > STDERR_TAIL_CHARS && this.stderrTail.length > 1) {
159
+ this.stderrLen -= (this.stderrTail.shift() ?? "").length;
160
+ }
161
+ }
128
162
  private disposed = false;
129
163
  private ready: Promise<void>;
130
164
 
@@ -144,6 +178,9 @@ export class PythonSandbox {
144
178
  if (opts.readOnly) {
145
179
  workerArgs.push("--read-only");
146
180
  }
181
+ if (opts.awaitTimeoutS !== undefined) {
182
+ workerArgs.push("--await-timeout", String(opts.awaitTimeoutS));
183
+ }
147
184
  this.proc = spawn(
148
185
  python,
149
186
  workerArgs,
@@ -153,9 +190,7 @@ export class PythonSandbox {
153
190
  this.proc.stdout.setEncoding("utf8");
154
191
  this.proc.stdout.on("data", (chunk: string) => this.onData(chunk));
155
192
  this.proc.stderr.setEncoding("utf8");
156
- this.proc.stderr.on("data", (chunk: string) => {
157
- this.stderr = (this.stderr + chunk).slice(-8192);
158
- });
193
+ this.proc.stderr.on("data", (chunk: string) => this.appendStderr(chunk));
159
194
  this.proc.on("error", (err: NodeJS.ErrnoException) => {
160
195
  const hint = err.code === "ENOENT" ? ` ('${python}' not found — is Python installed and on PATH?)` : "";
161
196
  this.failAll(new Error(`failed to start sandbox${hint}: ${err.message}`));
@@ -189,12 +224,12 @@ export class PythonSandbox {
189
224
  return sandbox;
190
225
  }
191
226
 
192
- async loadContext(payload: unknown, index?: number): Promise<number> {
227
+ async loadContext(payload: unknown): Promise<number> {
193
228
  const isJson = typeof payload !== "string";
194
229
  let path: string | undefined;
195
230
  try {
196
231
  path = await this.writeContextFile(payload, isJson);
197
- const res = await this.request({ type: "load_context", path, index, json: isJson });
232
+ const res = await this.request({ type: "load_context", path, json: isJson });
198
233
  if (!res.ok) throw new Error(res.error ?? "load_context failed");
199
234
  return res.index ?? 0;
200
235
  } finally {
@@ -216,8 +251,8 @@ export class PythonSandbox {
216
251
  return file;
217
252
  }
218
253
 
219
- async exec(code: string): Promise<ReplResult> {
220
- 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);
221
256
  if (!res.ok) throw new Error(res.error ?? "exec failed");
222
257
  return {
223
258
  stdout: res.stdout ?? "",
@@ -282,12 +317,27 @@ export class PythonSandbox {
282
317
  });
283
318
  }
284
319
 
285
- private request(payload: RequestBody): Promise<WorkerResponse> {
320
+ private request(payload: RequestBody, signal?: AbortSignal): Promise<WorkerResponse> {
286
321
  if (this.disposed) return Promise.reject(new Error("sandbox disposed"));
322
+ if (signal?.aborted) return Promise.reject(new Error("repl execution aborted"));
287
323
  const id = `r${++this.seq}`;
288
324
  return new Promise<WorkerResponse>((resolve, reject) => {
289
325
  const timer = this.createWatchdog(id, payload.type, reject);
290
- 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 });
291
341
  this.send({ id, ...payload } as ParentMessage);
292
342
  });
293
343
  }
@@ -318,6 +368,13 @@ export class PythonSandbox {
318
368
  }
319
369
 
320
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
+ }
321
378
  this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
322
379
  }
323
380
 
@@ -331,11 +388,11 @@ export class PythonSandbox {
331
388
  try {
332
389
  const message = JSON.parse(line) as unknown;
333
390
  if (isWorkerMessage(message)) this.dispatch(message);
334
- else this.stderr = `${this.stderr}\n[protocol] skipped invalid stdout message: ${line.slice(0, 200)}`.slice(-8192);
391
+ else this.appendStderr(`\n[protocol] skipped invalid stdout message: ${line.slice(0, 200)}`);
335
392
  } catch {
336
393
  // Non-JSON line on the protocol stream — likely a subprocess writing to fd 1.
337
394
  // Skip it so a rogue write doesn't kill the pump, but retain a breadcrumb for watchdog errors.
338
- this.stderr = `${this.stderr}\n[protocol] skipped non-JSON stdout line: ${line.slice(0, 200)}`.slice(-8192);
395
+ this.appendStderr(`\n[protocol] skipped non-JSON stdout line: ${line.slice(0, 200)}`);
339
396
  }
340
397
  }
341
398
  }
@@ -347,6 +404,19 @@ export class PythonSandbox {
347
404
  }
348
405
 
349
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
+ }
350
420
  if (isInterrupt(msg)) {
351
421
  this.touchPending();
352
422
  void this.serviceInterrupt(msg);
@@ -362,18 +432,19 @@ export class PythonSandbox {
362
432
  private async serviceInterrupt(msg: WorkerInterrupt): Promise<void> {
363
433
  const h = this.handlers;
364
434
  const d = msg.depth;
435
+ const opts: SubcallOpts = { detached: msg.detached === true };
365
436
  try {
366
437
  if (msg.type === "llm_query") {
367
- const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d);
438
+ const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
368
439
  this.reply(msg.rid, { response });
369
440
  } else if (msg.type === "rlm_query") {
370
- const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d);
441
+ const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
371
442
  this.reply(msg.rid, { response });
372
443
  } else if (msg.type === "llm_query_batched") {
373
- const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d);
444
+ const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
374
445
  this.reply(msg.rid, { responses });
375
446
  } else if (msg.type === "rlm_query_batched") {
376
- const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d);
447
+ const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
377
448
  this.reply(msg.rid, { responses });
378
449
  } else if (msg.type === "advance_phase") {
379
450
  const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
@@ -419,7 +490,7 @@ export class PythonSandbox {
419
490
  }
420
491
  }
421
492
  } catch (err) {
422
- this.reply(msg.rid, { error: err instanceof Error ? err.message : String(err) });
493
+ this.reply(msg.rid, { error: errorMessage(err) });
423
494
  }
424
495
  }
425
496