@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.
- package/README.md +167 -0
- package/dist/cost-comparison.d.ts +81 -0
- package/dist/cost-comparison.d.ts.map +1 -0
- package/dist/cost-comparison.js +93 -0
- package/dist/cost-comparison.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders.d.ts +44 -0
- package/dist/loaders.d.ts.map +1 -0
- package/dist/loaders.js +156 -0
- package/dist/loaders.js.map +1 -0
- package/dist/prompts.d.ts +49 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +71 -0
- package/dist/prompts.js.map +1 -0
- package/dist/router-session.d.ts +50 -0
- package/dist/router-session.d.ts.map +1 -0
- package/dist/router-session.js +251 -0
- package/dist/router-session.js.map +1 -0
- package/dist/sandbox.d.ts +95 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +273 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/session.d.ts +162 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +803 -0
- package/dist/session.js.map +1 -0
- package/dist/trajectory.d.ts +71 -0
- package/dist/trajectory.d.ts.map +1 -0
- package/dist/trajectory.js +65 -0
- package/dist/trajectory.js.map +1 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +35 -0
- package/python/repl_driver.py +744 -0
- package/sandbox/macos.sb +89 -0
package/dist/session.js
ADDED
|
@@ -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
|