@draht/rlm 2026.7.12-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.
@@ -0,0 +1,803 @@
1
+ /**
2
+ * `RlmSession` — the RLM root loop.
3
+ *
4
+ * root-LLM-produces-code -> REPL-executes -> truncated-stdout ->
5
+ * history-append -> FINAL-check, per
6
+ * .planning/phases/26-rlm-core-primitives/26-01-PLAN.md, Architecture
7
+ * section 4. Talks to `../python/repl_driver.py` over the newline-delimited
8
+ * JSON wire protocol documented in that file's module docstring (and
9
+ * section 2-3 of the plan).
10
+ */
11
+ import { execFile } from "node:child_process";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { readFile } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { spawnSandboxed } from "./sandbox.js";
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const DRIVER_PATH = join(__dirname, "..", "python", "repl_driver.py");
20
+ const DEFAULT_MAX_ITERATIONS = 24;
21
+ const DEFAULT_STDOUT_TRUNCATE_CHARS = 2000;
22
+ // Resource-limit defaults (Phase 28, Architecture section 3). These are the
23
+ // REAL enforcement mechanisms -- Node-side wall-clock + RSS polling, not any
24
+ // Python-level RLIMIT/timer (see repl_driver.py's Linux-only RLIMIT_AS,
25
+ // which is explicitly a backstop, not the primary mechanism, and isn't used
26
+ // on macOS at all per the plan).
27
+ const DEFAULT_STEP_TIMEOUT_MS = 30_000;
28
+ const DEFAULT_MAX_RSS_BYTES = 256 * 1024 * 1024;
29
+ const DEFAULT_RSS_POLL_INTERVAL_MS = 250;
30
+ /**
31
+ * Thrown internally when a step's Node-side wall-clock timeout fires and the
32
+ * driver subprocess is hard-killed. Caught in `run()` and mapped to
33
+ * `RlmResult.kind === "timeout"` -- never surfaced to callers directly.
34
+ */
35
+ class RlmStepTimeoutError extends Error {
36
+ }
37
+ /**
38
+ * Thrown internally when a `maxSubCalls`/`maxTotalCostUsd` pre-check fails
39
+ * and the driver subprocess is hard-killed as a result. Caught in `run()`
40
+ * and mapped to `RlmResult.kind === "budget_exhausted"` -- never surfaced to
41
+ * callers directly.
42
+ */
43
+ class RlmBudgetExhaustedError extends Error {
44
+ }
45
+ /**
46
+ * Thrown internally when the OS-level sandbox's runtime startup self-test
47
+ * (Phase 28 Architecture section 1) doesn't confirm the sandbox is actually
48
+ * enforcing for this process -- either the driver never answered, answered
49
+ * with something other than a well-formed `self_test_result`, or reported
50
+ * that a network connect or an out-of-workdir file read *succeeded*. Caught
51
+ * in `run()` and mapped to `RlmResult.kind === "sandbox_violation"`. This is
52
+ * distinct from the synchronous `SandboxUnavailableError` thrown by
53
+ * `spawnSandboxed` itself (missing wrapper binary/profile/interpreter) --
54
+ * that one fails closed before any process exists; this one fails closed
55
+ * after a process exists but before it's ever trusted with real
56
+ * root-LLM-authored code.
57
+ */
58
+ class RlmSandboxViolationError extends Error {
59
+ }
60
+ /**
61
+ * Reads the driver subprocess's current resident-set size in bytes, or
62
+ * `null` if it can't be determined right now (process already exited,
63
+ * `ps`/`/proc` unavailable, a transient read race, etc.). `null` always
64
+ * means "skip this poll" -- never treated as "assume 0 bytes" or "assume
65
+ * over the limit", since either guess could produce a wrong kill/no-kill
66
+ * decision.
67
+ *
68
+ * Linux: reads `/proc/<pid>/status`'s `VmRSS` line directly (no subprocess
69
+ * spawn needed). Every other platform (macOS in practice): shells out to
70
+ * `ps -o rss= -p <pid>` (BSD `ps` reports RSS in KiB) since there's no
71
+ * `/proc` to read. See Phase 28 Architecture section 3.
72
+ */
73
+ function readRssBytes(pid) {
74
+ if (process.platform === "linux") {
75
+ return readFile(`/proc/${pid}/status`, "utf8").then((status) => {
76
+ const match = /^VmRSS:\s*(\d+)\s*kB$/m.exec(status);
77
+ return match ? Number(match[1]) * 1024 : null;
78
+ }, () => null);
79
+ }
80
+ return new Promise((resolve) => {
81
+ execFile("ps", ["-o", "rss=", "-p", String(pid)], { encoding: "utf8" }, (err, stdout) => {
82
+ if (err) {
83
+ resolve(null);
84
+ return;
85
+ }
86
+ const trimmed = stdout.trim().split("\n")[0]?.trim();
87
+ if (!trimmed) {
88
+ resolve(null);
89
+ return;
90
+ }
91
+ const kb = Number(trimmed);
92
+ resolve(Number.isFinite(kb) ? kb * 1024 : null);
93
+ });
94
+ });
95
+ }
96
+ /**
97
+ * Extracts Python source from a root-LLM response.
98
+ *
99
+ * Per R26-RLM.4, the response is expected to contain a ```repl or ```python
100
+ * fenced block (possibly surrounded by prose explaining the plan). Falls
101
+ * back to the whole (trimmed) response when no fence is present, so mocked
102
+ * `rootLlm` callbacks in tests can return raw code directly.
103
+ */
104
+ export function extractPythonCode(response) {
105
+ const match = /```(?:repl|python)[ \t]*\r?\n([\s\S]*?)```/.exec(response);
106
+ if (!match)
107
+ return response.trim();
108
+ return match[1].replace(/\r?\n$/, "");
109
+ }
110
+ /**
111
+ * Truncates captured stdout to `limit` characters, appending an explicit
112
+ * marker for how many characters were cut — matching the `[truncated N
113
+ * chars]` format R28-SBX.4 (Phase 28) will enforce as a hard cap. This phase
114
+ * only applies it as a plain formatting rule, not a safety mechanism.
115
+ */
116
+ export function truncateStdout(stdout, limit) {
117
+ if (stdout.length <= limit)
118
+ return stdout;
119
+ const omitted = stdout.length - limit;
120
+ return `${stdout.slice(0, limit)}\n[truncated ${omitted} chars]`;
121
+ }
122
+ /**
123
+ * Restricted Python-literal parser used to turn a `FINAL_VAR`-produced
124
+ * `repr()` string back into a real JS value.
125
+ *
126
+ * Handles the JSON-safe subset the plan calls out: `None`/`True`/`False`,
127
+ * int/float, single- or double-quoted strings (with the common backslash
128
+ * escapes), lists, tuples (mapped to arrays — JS has no tuple type), and
129
+ * dicts (mapped to plain objects; keys are coerced with `String(...)`).
130
+ * Anything it can't parse (custom object reprs like `<Foo object at
131
+ * 0x...>`, sets, NaN/Infinity, etc.) falls back to the raw repr string
132
+ * rather than throwing, so a result is never lost — see the module-level
133
+ * note in this file's neighboring test for why raw-string fallback beats
134
+ * failing the whole run.
135
+ */
136
+ export function pythonReprToValue(repr) {
137
+ try {
138
+ const parser = new PyReprParser(repr);
139
+ const value = parser.parseValue();
140
+ parser.expectEnd();
141
+ return value;
142
+ }
143
+ catch {
144
+ return repr;
145
+ }
146
+ }
147
+ class PyReprParseError extends Error {
148
+ }
149
+ /** Minimal recursive-descent parser for a subset of Python literal syntax. */
150
+ class PyReprParser {
151
+ constructor(src) {
152
+ this.src = src;
153
+ this.i = 0;
154
+ }
155
+ skipWs() {
156
+ while (this.i < this.src.length && /\s/.test(this.src[this.i]))
157
+ this.i++;
158
+ }
159
+ expectEnd() {
160
+ this.skipWs();
161
+ if (this.i !== this.src.length) {
162
+ throw new PyReprParseError(`unexpected trailing characters at ${this.i}`);
163
+ }
164
+ }
165
+ parseValue() {
166
+ this.skipWs();
167
+ const c = this.src[this.i];
168
+ if (c === undefined)
169
+ throw new PyReprParseError("unexpected end of repr");
170
+ if (c === "'" || c === '"')
171
+ return this.parseString();
172
+ if (c === "[")
173
+ return this.parseSequence("[", "]");
174
+ if (c === "(")
175
+ return this.parseSequence("(", ")");
176
+ if (c === "{")
177
+ return this.parseDictOrSet();
178
+ if (this.src.startsWith("None", this.i)) {
179
+ this.i += 4;
180
+ return null;
181
+ }
182
+ if (this.src.startsWith("True", this.i)) {
183
+ this.i += 4;
184
+ return true;
185
+ }
186
+ if (this.src.startsWith("False", this.i)) {
187
+ this.i += 5;
188
+ return false;
189
+ }
190
+ return this.parseNumber();
191
+ }
192
+ parseString() {
193
+ const quote = this.src[this.i];
194
+ this.i++;
195
+ let out = "";
196
+ while (this.i < this.src.length && this.src[this.i] !== quote) {
197
+ const ch = this.src[this.i];
198
+ if (ch === "\\") {
199
+ this.i++;
200
+ const esc = this.src[this.i];
201
+ switch (esc) {
202
+ case "n":
203
+ out += "\n";
204
+ break;
205
+ case "t":
206
+ out += "\t";
207
+ break;
208
+ case "r":
209
+ out += "\r";
210
+ break;
211
+ case "b":
212
+ out += "\b";
213
+ break;
214
+ case "f":
215
+ out += "\f";
216
+ break;
217
+ case "v":
218
+ out += "\v";
219
+ break;
220
+ case "0":
221
+ out += "\0";
222
+ break;
223
+ case "\\":
224
+ out += "\\";
225
+ break;
226
+ case "'":
227
+ out += "'";
228
+ break;
229
+ case '"':
230
+ out += '"';
231
+ break;
232
+ case "x": {
233
+ out += String.fromCharCode(Number.parseInt(this.src.slice(this.i + 1, this.i + 3), 16));
234
+ this.i += 2;
235
+ break;
236
+ }
237
+ case "u": {
238
+ out += String.fromCharCode(Number.parseInt(this.src.slice(this.i + 1, this.i + 5), 16));
239
+ this.i += 4;
240
+ break;
241
+ }
242
+ case "U": {
243
+ out += String.fromCodePoint(Number.parseInt(this.src.slice(this.i + 1, this.i + 9), 16));
244
+ this.i += 8;
245
+ break;
246
+ }
247
+ default:
248
+ out += esc ?? "";
249
+ }
250
+ this.i++;
251
+ }
252
+ else {
253
+ out += ch;
254
+ this.i++;
255
+ }
256
+ }
257
+ if (this.src[this.i] !== quote)
258
+ throw new PyReprParseError("unterminated string literal");
259
+ this.i++;
260
+ return out;
261
+ }
262
+ parseNumber() {
263
+ const start = this.i;
264
+ if (this.src[this.i] === "-" || this.src[this.i] === "+")
265
+ this.i++;
266
+ while (this.i < this.src.length && /[0-9]/.test(this.src[this.i]))
267
+ this.i++;
268
+ if (this.src[this.i] === ".") {
269
+ this.i++;
270
+ while (this.i < this.src.length && /[0-9]/.test(this.src[this.i]))
271
+ this.i++;
272
+ }
273
+ if (this.src[this.i] === "e" || this.src[this.i] === "E") {
274
+ this.i++;
275
+ if (this.src[this.i] === "-" || this.src[this.i] === "+")
276
+ this.i++;
277
+ while (this.i < this.src.length && /[0-9]/.test(this.src[this.i]))
278
+ this.i++;
279
+ }
280
+ const numStr = this.src.slice(start, this.i);
281
+ if (!/^[+-]?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$/.test(numStr)) {
282
+ throw new PyReprParseError(`invalid number literal at ${start}`);
283
+ }
284
+ return Number(numStr);
285
+ }
286
+ /** Parses `[...]` (list) or `(...)` (tuple) into a JS array. */
287
+ parseSequence(_open, close) {
288
+ this.i++; // opening bracket
289
+ const out = [];
290
+ this.skipWs();
291
+ if (this.src[this.i] === close) {
292
+ this.i++;
293
+ return out;
294
+ }
295
+ while (true) {
296
+ out.push(this.parseValue());
297
+ this.skipWs();
298
+ if (this.src[this.i] === ",") {
299
+ this.i++;
300
+ this.skipWs();
301
+ if (this.src[this.i] === close) {
302
+ this.i++;
303
+ break;
304
+ }
305
+ continue;
306
+ }
307
+ if (this.src[this.i] === close) {
308
+ this.i++;
309
+ break;
310
+ }
311
+ throw new PyReprParseError(`expected ',' or '${close}' at ${this.i}`);
312
+ }
313
+ return out;
314
+ }
315
+ /** Parses `{...}` as a dict (if any `key: value` pair is found) or a set (mapped to an array). */
316
+ parseDictOrSet() {
317
+ this.i++; // {
318
+ this.skipWs();
319
+ if (this.src[this.i] === "}") {
320
+ this.i++;
321
+ return {};
322
+ }
323
+ const firstKeyOrValue = this.parseValue();
324
+ this.skipWs();
325
+ if (this.src[this.i] === ":") {
326
+ this.i++;
327
+ const out = {};
328
+ out[String(firstKeyOrValue)] = this.parseValue();
329
+ this.skipWs();
330
+ while (this.src[this.i] === ",") {
331
+ this.i++;
332
+ this.skipWs();
333
+ if (this.src[this.i] === "}")
334
+ break;
335
+ const key = this.parseValue();
336
+ this.skipWs();
337
+ if (this.src[this.i] !== ":")
338
+ throw new PyReprParseError(`expected ':' at ${this.i}`);
339
+ this.i++;
340
+ out[String(key)] = this.parseValue();
341
+ this.skipWs();
342
+ }
343
+ if (this.src[this.i] !== "}")
344
+ throw new PyReprParseError("expected '}' to close dict");
345
+ this.i++;
346
+ return out;
347
+ }
348
+ const out = [firstKeyOrValue];
349
+ this.skipWs();
350
+ while (this.src[this.i] === ",") {
351
+ this.i++;
352
+ this.skipWs();
353
+ if (this.src[this.i] === "}")
354
+ break;
355
+ out.push(this.parseValue());
356
+ this.skipWs();
357
+ }
358
+ if (this.src[this.i] !== "}")
359
+ throw new PyReprParseError("expected '}' to close set");
360
+ this.i++;
361
+ return out;
362
+ }
363
+ }
364
+ export class RlmSession {
365
+ constructor(opts) {
366
+ this.opts = opts;
367
+ this.history = [];
368
+ this.buffer = "";
369
+ this.messageQueue = [];
370
+ this.waiters = [];
371
+ this.terminated = false;
372
+ this.terminationError = null;
373
+ this.lastFinal = null;
374
+ this.subCallCount = 0;
375
+ this.rssPollInFlight = false;
376
+ this.stdoutTruncateChars = opts.stdoutTruncateChars ?? DEFAULT_STDOUT_TRUNCATE_CHARS;
377
+ this.maxIterations = opts.maxIterations ?? DEFAULT_MAX_ITERATIONS;
378
+ this.stepTimeoutMs = opts.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
379
+ this.maxRssBytes = opts.maxRssBytes ?? DEFAULT_MAX_RSS_BYTES;
380
+ this.rssPollIntervalMs = opts.rssPollIntervalMs ?? DEFAULT_RSS_POLL_INTERVAL_MS;
381
+ this.maxSubCalls = opts.maxSubCalls;
382
+ this.maxTotalCostUsd = opts.maxTotalCostUsd;
383
+ this.getAccumulatedCostUsd = opts.getAccumulatedCostUsd;
384
+ // The session's scratch workdir -- the only place the OS-sandboxed
385
+ // driver process may read *and* write freely (see sandbox.ts /
386
+ // ../sandbox/macos.sb). Created fresh per session and cleaned up in
387
+ // dispose(). Not caller-configurable (yet): Phase 28 scopes the
388
+ // sandbox to this session-owned directory, not an arbitrary
389
+ // caller-supplied path.
390
+ this.workdir = mkdtempSync(join(tmpdir(), "rlm-session-"));
391
+ // spawnSandboxed wraps python3/repl_driver.py in the platform's
392
+ // OS-level sandbox (sandbox-exec on macOS, unshare/bwrap on Linux) --
393
+ // see sandbox.ts's module docstring for why this, and not
394
+ // repl_driver.py's Python-level guardrails, is the real security
395
+ // boundary. It throws SandboxUnavailableError synchronously (never
396
+ // returns a fallback unwrapped command) if the sandbox can't be
397
+ // established, which is exactly what should happen here: this
398
+ // constructor must fail closed, not silently spawn the driver
399
+ // unsandboxed.
400
+ this.child = spawnSandboxed({ driverPath: DRIVER_PATH, workdir: this.workdir });
401
+ this.child.stdout?.setEncoding("utf8");
402
+ this.child.stdout?.on("data", (chunk) => this.onStdoutChunk(chunk));
403
+ this.child.stderr?.setEncoding("utf8");
404
+ this.child.stderr?.on("data", (chunk) => {
405
+ // Driver-side crashes (as opposed to in-REPL exceptions, which are
406
+ // reported via exec_result.error) surface here. Don't throw on
407
+ // stray stderr output alone -- just make it visible for debugging.
408
+ console.error(`[rlm repl_driver stderr] ${chunk}`);
409
+ });
410
+ this.child.once("exit", (code, signal) => {
411
+ this.markTerminated(new Error(`RlmSession: python3 driver exited (code=${code}, signal=${signal})`));
412
+ });
413
+ this.child.once("error", (err) => {
414
+ this.markTerminated(new Error(`RlmSession: failed to spawn python3 driver: ${err.message}`));
415
+ });
416
+ // Node-side RSS polling (Phase 28 Architecture section 3) -- the real
417
+ // memory enforcement mechanism, running for the whole lifetime of the
418
+ // child process, independent of any single step's wall-clock timeout.
419
+ this.startRssPolling();
420
+ // Tell the driver how aggressively to cap captured stdout *before* any
421
+ // real step runs (see repl_driver.py's `configure` handling / Phase 28
422
+ // Architecture section 5) -- fire-and-forget: writes to child.stdin are
423
+ // delivered strictly in order, so this is guaranteed to be processed
424
+ // before the `self_test`/`exec` messages sent next.
425
+ this.send({ type: "configure", stdoutTruncateChars: this.stdoutTruncateChars });
426
+ // Runtime startup self-test (Phase 28 Architecture section 1) -- runs
427
+ // before `context` is even seeded, let alone before any root-LLM-
428
+ // authored `exec`. If the OS sandbox isn't actually enforcing for this
429
+ // process, `runSelfTest()` throws and `seedContext()` never runs, so no
430
+ // step ever executes against an unconfined driver. See
431
+ // `RlmSandboxViolationError`'s doc comment for how this differs from
432
+ // `spawnSandboxed`'s synchronous fail-closed throw.
433
+ this.ready = this.runSelfTest().then(() => this.seedContext());
434
+ // Attach a no-op rejection handler so Node doesn't report this as an
435
+ // unhandled rejection when the failure path is exercised without every
436
+ // caller immediately awaiting `run()`/`step()` -- `await this.ready`
437
+ // elsewhere still observes the same rejection (a promise notifies every
438
+ // attached reaction, not just the first).
439
+ this.ready.catch(() => { });
440
+ }
441
+ onStdoutChunk(chunk) {
442
+ this.buffer += chunk;
443
+ let newlineIndex = this.buffer.indexOf("\n");
444
+ while (newlineIndex !== -1) {
445
+ const line = this.buffer.slice(0, newlineIndex);
446
+ this.buffer = this.buffer.slice(newlineIndex + 1);
447
+ newlineIndex = this.buffer.indexOf("\n");
448
+ if (!line.trim())
449
+ continue;
450
+ let message;
451
+ try {
452
+ message = JSON.parse(line);
453
+ }
454
+ catch {
455
+ this.markTerminated(new Error(`RlmSession: driver emitted non-JSON output: ${line}`));
456
+ continue;
457
+ }
458
+ const waiter = this.waiters.shift();
459
+ if (waiter)
460
+ waiter.resolve(message);
461
+ else
462
+ this.messageQueue.push(message);
463
+ }
464
+ }
465
+ markTerminated(error) {
466
+ if (this.terminated)
467
+ return;
468
+ this.terminated = true;
469
+ this.terminationError = error;
470
+ if (this.rssPollTimer) {
471
+ clearInterval(this.rssPollTimer);
472
+ this.rssPollTimer = undefined;
473
+ }
474
+ const waiters = this.waiters;
475
+ this.waiters = [];
476
+ for (const waiter of waiters)
477
+ waiter.reject(error);
478
+ }
479
+ /**
480
+ * Hard-kills the driver subprocess and marks the session terminated with
481
+ * `error` in one step. Used by every resource-limit enforcement path (RSS
482
+ * ceiling, wall-clock timeout, budget exhaustion) -- these are Node-side
483
+ * hard kills, not attempts at graceful in-process recovery (Phase 28
484
+ * Architecture sections 3 and 6). `markTerminated`'s first-write-wins
485
+ * guard means whichever of these fires first supplies the message callers
486
+ * actually see, even though the child's own `exit` handler will also fire
487
+ * shortly after and try to (harmlessly, redundantly) mark terminated too.
488
+ */
489
+ killAndTerminate(error) {
490
+ this.markTerminated(error);
491
+ this.child.kill("SIGKILL");
492
+ }
493
+ /**
494
+ * Starts the Node-side RSS poll loop (Phase 28 Architecture section 3) --
495
+ * the real memory enforcement mechanism, not any Python-level
496
+ * `RLIMIT_AS`. Runs for the child process's whole lifetime; `unref()`d so
497
+ * it never by itself keeps the host Node process alive past everything
498
+ * else finishing. A `maxRssBytes <= 0` disables polling entirely.
499
+ */
500
+ startRssPolling() {
501
+ if (!(this.maxRssBytes > 0))
502
+ return;
503
+ const timer = setInterval(() => {
504
+ void this.pollRss();
505
+ }, this.rssPollIntervalMs);
506
+ timer.unref();
507
+ this.rssPollTimer = timer;
508
+ }
509
+ async pollRss() {
510
+ if (this.terminated || this.rssPollInFlight)
511
+ return;
512
+ const pid = this.child.pid;
513
+ if (!pid)
514
+ return;
515
+ this.rssPollInFlight = true;
516
+ try {
517
+ const rssBytes = await readRssBytes(pid);
518
+ if (rssBytes === null || rssBytes <= this.maxRssBytes)
519
+ return;
520
+ this.killAndTerminate(new Error(`RlmSession: python3 driver exceeded the configured RSS ceiling of ${this.maxRssBytes} bytes ` +
521
+ `(observed ${rssBytes} bytes) -- killed. This is a resource-limit kill (Phase 28 Architecture ` +
522
+ "section 3), not the OS sandbox itself failing."));
523
+ }
524
+ finally {
525
+ this.rssPollInFlight = false;
526
+ }
527
+ }
528
+ /**
529
+ * Pre-dispatch sub-call budget check (Phase 28 Architecture section 6,
530
+ * R28-SBX.5): called *before* an `llm_query_request` is ever forwarded to
531
+ * `opts.llmQuery`, so the (budget+1)th attempted sub-call never actually
532
+ * dispatches -- the session is killed right here instead. Deliberately
533
+ * NOT also checked before dispatching a whole step: a step that never
534
+ * attempts another sub-call can still legitimately finish (e.g. call
535
+ * FINAL immediately) even after the budget is otherwise exhausted, and
536
+ * pre-emptively killing such a step here would be wrong.
537
+ */
538
+ checkSubCallBudget() {
539
+ if (this.maxSubCalls === undefined)
540
+ return;
541
+ if (this.subCallCount < this.maxSubCalls)
542
+ return;
543
+ const error = new RlmBudgetExhaustedError(`RlmSession: llm_query sub-call budget exhausted (maxSubCalls=${this.maxSubCalls}) -- refusing to ` +
544
+ `dispatch sub-call #${this.subCallCount + 1} and killing the driver subprocess.`);
545
+ this.killAndTerminate(error);
546
+ throw error;
547
+ }
548
+ /**
549
+ * Pre-dispatch cost budget check (Phase 28 Architecture section 6,
550
+ * R28-SBX.5): called before dispatching a step's `rootLlm` turn and
551
+ * before forwarding a sub-call to `opts.llmQuery`. No-ops unless both
552
+ * `maxTotalCostUsd` and `getAccumulatedCostUsd` are supplied -- see
553
+ * `RlmSessionOptions.maxTotalCostUsd`'s doc comment for why an unpaired
554
+ * option is a no-op rather than a guess.
555
+ */
556
+ checkCostBudget() {
557
+ if (this.maxTotalCostUsd === undefined || !this.getAccumulatedCostUsd)
558
+ return;
559
+ const accumulated = this.getAccumulatedCostUsd();
560
+ if (accumulated < this.maxTotalCostUsd)
561
+ return;
562
+ const error = new RlmBudgetExhaustedError(`RlmSession: cost budget exhausted (maxTotalCostUsd=$${this.maxTotalCostUsd}, accumulated=$${accumulated}) ` +
563
+ "-- killing the driver subprocess.");
564
+ this.killAndTerminate(error);
565
+ throw error;
566
+ }
567
+ /**
568
+ * Races `promise` against the configured wall-clock step timeout
569
+ * (Phase 28 Architecture section 3, R28-SBX.3) -- the real timeout
570
+ * enforcement mechanism. On expiry, hard-kills the driver subprocess and
571
+ * rejects with `RlmStepTimeoutError`, even if `promise` itself is stuck
572
+ * awaiting something that `markTerminated`'s waiter-rejection can't reach
573
+ * (e.g. a slow/hung `opts.llmQuery` callback, as opposed to a pending
574
+ * `nextMessage()` wait) -- the explicit `reject` here is what actually
575
+ * unblocks the caller in that case, not just a formality.
576
+ */
577
+ withStepTimeout(promise) {
578
+ if (!(this.stepTimeoutMs > 0))
579
+ return promise;
580
+ return new Promise((resolve, reject) => {
581
+ const timer = setTimeout(() => {
582
+ const error = new RlmStepTimeoutError(`RlmSession: step exceeded the configured wall-clock timeout of ${this.stepTimeoutMs}ms -- killed.`);
583
+ this.killAndTerminate(error);
584
+ reject(error);
585
+ }, this.stepTimeoutMs);
586
+ timer.unref();
587
+ promise.then((value) => {
588
+ clearTimeout(timer);
589
+ resolve(value);
590
+ }, (err) => {
591
+ clearTimeout(timer);
592
+ reject(err);
593
+ });
594
+ });
595
+ }
596
+ nextMessage() {
597
+ const queued = this.messageQueue.shift();
598
+ if (queued)
599
+ return Promise.resolve(queued);
600
+ if (this.terminated) {
601
+ return Promise.reject(this.terminationError ?? new Error("RlmSession: driver process is no longer running"));
602
+ }
603
+ return new Promise((resolve, reject) => {
604
+ this.waiters.push({ resolve, reject });
605
+ });
606
+ }
607
+ send(message) {
608
+ if (this.terminated) {
609
+ throw this.terminationError ?? new Error("RlmSession: cannot send to a terminated driver process");
610
+ }
611
+ this.child.stdin?.write(`${JSON.stringify(message)}\n`);
612
+ }
613
+ /**
614
+ * Sends one `exec` message and handles any `llm_query_request` round-trips
615
+ * until the matching `exec_result`, all under the wall-clock step timeout
616
+ * (`withStepTimeout`, Phase 28 Architecture section 3).
617
+ */
618
+ async runExec(code) {
619
+ this.send({ type: "exec", code });
620
+ return this.withStepTimeout(this.collectExecResult());
621
+ }
622
+ /** The message loop `runExec` races against the step timeout -- split out so `withStepTimeout` can wrap just this. */
623
+ async collectExecResult() {
624
+ while (true) {
625
+ const message = await this.nextMessage();
626
+ if (message.type === "llm_query_request") {
627
+ const id = message.id;
628
+ if (!this.opts.llmQuery) {
629
+ throw new Error("RlmSession: REPL code called llm_query(...) but no `llmQuery` callback was provided in RlmSessionOptions.");
630
+ }
631
+ // Budget pre-checks (Phase 28 Architecture section 6, R28-SBX.5) --
632
+ // before this sub-call is dispatched, not after: either check can
633
+ // throw (and kill the driver), in which case opts.llmQuery is never
634
+ // called for this attempt at all.
635
+ this.checkSubCallBudget();
636
+ this.checkCostBudget();
637
+ this.subCallCount++;
638
+ const text = await this.opts.llmQuery(message.prompt);
639
+ this.send({ type: "llm_query_response", id, text });
640
+ continue;
641
+ }
642
+ if (message.type === "exec_result") {
643
+ return message;
644
+ }
645
+ throw new Error(`RlmSession: unexpected message from driver: ${JSON.stringify(message)}`);
646
+ }
647
+ }
648
+ async seedContext() {
649
+ // JSON string escaping and Python double-quoted string literal escaping
650
+ // agree on the escapes that matter here (\", \\, \n, \t, \r, and
651
+ // \uXXXX), so JSON.stringify produces a valid Python string literal.
652
+ await this.runExec(`context = ${JSON.stringify(this.opts.prompt)}`);
653
+ }
654
+ /**
655
+ * Runtime proof the OS-level sandbox is actually enforcing for *this*
656
+ * process (Phase 28 Architecture section 1) -- sends `{"type":
657
+ * "self_test"}` and requires the driver's `self_test_result` response to
658
+ * report both a network connect attempt and an out-of-workdir file read
659
+ * failing. Throws `RlmSandboxViolationError` (fail-closed, refusing to run
660
+ * at all) if:
661
+ * - the driver never responds (crash/hang -- guarded by the same
662
+ * wall-clock timeout as a normal step, via `withStepTimeout`),
663
+ * - the response isn't a well-formed `self_test_result`, or
664
+ * - either check reports the sandbox did NOT block the attempt.
665
+ *
666
+ * Deliberately does not go through `runExec`/the guarded `exec()` wire
667
+ * message: `repl_driver.py`'s `_run_self_test` handles `self_test`
668
+ * on a route that bypasses its own Python-level guardrails (restricted
669
+ * builtins, import allowlist, AST screen) entirely, because a self-test
670
+ * *routed through* those guardrails would be a false positive -- e.g.
671
+ * `import socket` failing on the guardrail import allowlist looks
672
+ * identical to `import socket` failing because the OS sandbox denied the
673
+ * connection, even with the OS sandbox completely disabled. See
674
+ * `_run_self_test`'s docstring in repl_driver.py for the full reasoning.
675
+ */
676
+ async runSelfTest() {
677
+ this.send({ type: "self_test" });
678
+ let message;
679
+ try {
680
+ message = await this.withStepTimeout(this.nextMessage());
681
+ }
682
+ catch (err) {
683
+ throw new RlmSandboxViolationError("RlmSession: OS-level sandbox startup self-test did not complete " +
684
+ `(refusing to run -- fail-closed): ${err instanceof Error ? err.message : String(err)}`);
685
+ }
686
+ const networkBlocked = message.type === "self_test_result" && message.networkBlocked === true;
687
+ const fileReadBlocked = message.type === "self_test_result" && message.fileReadBlocked === true;
688
+ if (!networkBlocked || !fileReadBlocked) {
689
+ const error = new RlmSandboxViolationError("RlmSession: OS-level sandbox startup self-test failed -- the sandbox is not confining this " +
690
+ `process (networkBlocked=${networkBlocked}, fileReadBlocked=${fileReadBlocked}, ` +
691
+ `response=${JSON.stringify(message)}). Refusing to run rather than executing unconfined ` +
692
+ "root-LLM-authored code (fail-closed).");
693
+ this.killAndTerminate(error);
694
+ throw error;
695
+ }
696
+ }
697
+ resolveFinal(final) {
698
+ if (final.kind === "value") {
699
+ // FINAL(answer) already sent `str(answer)` -- use as-is.
700
+ return final.value;
701
+ }
702
+ // FINAL_VAR(name) sent `repr(globals_dict[name])`. Parse it back into a
703
+ // real JS value for JSON-safe types; fall back to the raw repr string
704
+ // for anything else. See `pythonReprToValue`'s doc comment for why.
705
+ return pythonReprToValue(final.value);
706
+ }
707
+ /** One root-LLM turn: get code, exec it, append history, may resolve a final. */
708
+ async step() {
709
+ await this.ready;
710
+ // Pre-dispatch cost budget check (Phase 28 Architecture section 6,
711
+ // R28-SBX.5) -- before the root LLM is even asked for this step's code,
712
+ // not just before a sub-call within it.
713
+ this.checkCostBudget();
714
+ const response = await this.opts.rootLlm(this.history.slice());
715
+ const code = extractPythonCode(response);
716
+ const result = await this.runExec(code);
717
+ const entry = {
718
+ step: this.history.length + 1,
719
+ code,
720
+ // Already capped+marked incrementally by the driver's
721
+ // `_TruncatingStdout` (Phase 28 Architecture section 5) -- no
722
+ // second Node-side truncation pass here (Phase 26's original
723
+ // post-hoc `truncateStdout` call is exactly the OOM-via-print gap
724
+ // this phase closes; re-applying it here on top of the driver's
725
+ // own `[truncated N chars]` marker would risk mangling it).
726
+ truncatedStdout: result.stdout,
727
+ error: result.error ?? null,
728
+ timestamp: new Date().toISOString(),
729
+ };
730
+ this.history.push(entry);
731
+ this.lastFinal = result.final ?? null;
732
+ return entry;
733
+ }
734
+ /** Loops `step()` until a FINAL/FINAL_VAR, `maxIterations`, or an unrecoverable driver error. */
735
+ async run() {
736
+ try {
737
+ while (this.history.length < this.maxIterations) {
738
+ await this.step();
739
+ if (this.lastFinal) {
740
+ const final = this.lastFinal;
741
+ return {
742
+ kind: final.kind === "var" ? "final_var" : "final",
743
+ value: this.resolveFinal(final),
744
+ steps: this.history.length,
745
+ history: this.history.slice(),
746
+ };
747
+ }
748
+ }
749
+ return { kind: "max_iterations", steps: this.history.length, history: this.history.slice() };
750
+ }
751
+ catch (err) {
752
+ // Typed stop reasons (Phase 28 Architecture section 6, R28-SBX.6):
753
+ // a hard-killed step surfaces here as one of these internal error
754
+ // classes; anything else (a genuine driver crash, a step
755
+ // referencing llm_query with no callback configured, etc.) falls
756
+ // through to the generic "error" kind, same as Phase 26.
757
+ if (err instanceof RlmStepTimeoutError) {
758
+ return {
759
+ kind: "timeout",
760
+ value: err.message,
761
+ steps: this.history.length,
762
+ history: this.history.slice(),
763
+ };
764
+ }
765
+ if (err instanceof RlmBudgetExhaustedError) {
766
+ return {
767
+ kind: "budget_exhausted",
768
+ value: err.message,
769
+ steps: this.history.length,
770
+ history: this.history.slice(),
771
+ };
772
+ }
773
+ if (err instanceof RlmSandboxViolationError) {
774
+ return {
775
+ kind: "sandbox_violation",
776
+ value: err.message,
777
+ steps: this.history.length,
778
+ history: this.history.slice(),
779
+ };
780
+ }
781
+ return {
782
+ kind: "error",
783
+ value: err instanceof Error ? err.message : String(err),
784
+ steps: this.history.length,
785
+ history: this.history.slice(),
786
+ };
787
+ }
788
+ }
789
+ /** Kills the underlying python3 subprocess and removes its scratch workdir. Safe to call more than once. */
790
+ dispose() {
791
+ this.markTerminated(new Error("RlmSession: disposed"));
792
+ this.child.stdin?.end();
793
+ this.child.kill();
794
+ try {
795
+ rmSync(this.workdir, { recursive: true, force: true });
796
+ }
797
+ catch {
798
+ // Best-effort cleanup only -- a leftover empty-ish tmp dir isn't
799
+ // worth failing dispose() over.
800
+ }
801
+ }
802
+ }
803
+ //# sourceMappingURL=session.js.map