@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.
- package/README.md +12 -35
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +1 -1
- package/src/bridge/library.ts +61 -26
- package/src/bridge/subcall-handlers.ts +382 -0
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +7 -15
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +115 -360
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +3 -36
- package/src/index.ts +49 -10
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +26 -57
- package/src/prompts/glossary.ts +287 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +14 -386
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +14 -69
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +116 -0
- package/src/sandbox/py/worker.py +836 -0
- package/src/sandbox/sandbox-manager.ts +33 -6
- package/src/sandbox/sandbox.ts +153 -182
- package/src/text/tokens.ts +29 -3
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +4 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +178 -216
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +10 -16
- package/src/tool/rlm-tool.ts +1 -12
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +4 -16
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +91 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/fallback-todo.ts +0 -137
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/bridge/rlm-query.ts +0 -108
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/sandbox/worker.py +0 -1078
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- 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;
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -8,48 +8,23 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
11
|
-
import {
|
|
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 {
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
162
|
-
workerArgs.push("--
|
|
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
|
-
|
|
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
|
|
209
|
-
let path: string | undefined;
|
|
191
|
+
const pinned = await pinContext(payload);
|
|
210
192
|
try {
|
|
211
|
-
|
|
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
|
-
|
|
195
|
+
await pinned.release();
|
|
217
196
|
}
|
|
218
197
|
}
|
|
219
198
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
package/src/text/tokens.ts
CHANGED
|
@@ -21,11 +21,37 @@ export function estimateMessageTokens(messages: { content: string }[]): number {
|
|
|
21
21
|
return estimateTokens(chars);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/**
|
|
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
|
|
28
|
-
|
|
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. */
|