@hicaru/pi-rlm 0.2.1 → 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 +63 -17
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +6 -17
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +55 -335
- 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 +23 -12
- 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 -407
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +8 -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/{worker.py → py/worker.py} +76 -696
- package/src/sandbox/sandbox-manager.ts +13 -0
- package/src/sandbox/sandbox.ts +99 -193
- package/src/text/tokens.ts +29 -3
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +37 -159
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +0 -14
- package/src/tool/rlm-tool.ts +1 -12
- 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 +9 -5
- package/src/bridge/fallback-todo.ts +0 -148
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/pi-interactive.ts +0 -41
- 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/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 {
|
|
@@ -34,6 +35,18 @@ export class SandboxManager {
|
|
|
34
35
|
|
|
35
36
|
constructor(private readonly config: SandboxManagerConfig) {}
|
|
36
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
|
+
|
|
37
50
|
/**
|
|
38
51
|
* Lazy get-or-create the sandbox. On first call, spawns PythonSandbox with the
|
|
39
52
|
* given handlers. Subsequent calls return the existing sandbox immediately.
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -8,60 +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 {
|
|
23
|
+
import { pinContext, type PinnedContext } from "./context-file.ts";
|
|
24
|
+
import { REJECT, serviceInterrupt, type ReplyBody, type SubLlmHandlers } from "./interrupts.ts";
|
|
28
25
|
import { trace, traceEnabled } from "../util/trace.ts";
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
export interface LibraryLoadResult {
|
|
32
|
-
readonly payload: unknown; // always ContextFile[] under lib/<id>/
|
|
33
|
-
readonly index: number; // resume-sidecar index (not a REPL var name); -1 if alreadyLoaded
|
|
34
|
-
readonly files?: number;
|
|
35
|
-
readonly chars: number;
|
|
36
|
-
readonly sourceId: string;
|
|
37
|
-
readonly pathPrefix: string;
|
|
38
|
-
/** Host already has this library — no pack, no sidecar, empty payload. */
|
|
39
|
-
readonly alreadyLoaded?: boolean;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Per-interrupt routing context for the sub-LLM handlers.
|
|
44
|
-
*
|
|
45
|
-
* Only the four sub-call kinds can be spawned, so only they carry it; the interactive and
|
|
46
|
-
* pipeline handlers are always synchronous within one exec.
|
|
47
|
-
*/
|
|
48
|
-
export interface SubcallOpts {
|
|
49
|
-
/** Started via `spawn()` — route to session-scoped state, not the current invocation. */
|
|
50
|
-
readonly detached: boolean;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
|
|
54
|
-
export interface SubLlmHandlers {
|
|
55
|
-
llmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
|
|
56
|
-
llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
|
|
57
|
-
rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
|
|
58
|
-
rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
|
|
59
|
-
advancePhase(phase: string, summary: string | undefined, depth: number): Promise<string>;
|
|
60
|
-
saveArtifact(kind: string, content: string, depth: number): Promise<string>;
|
|
61
|
-
askUserQuestion(questions: readonly AskQuestion[], depth: number): Promise<AskAnswer[]>;
|
|
62
|
-
todo(action: string, params: Record<string, unknown>, depth: number): Promise<string>;
|
|
63
|
-
loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
|
|
64
|
-
}
|
|
27
|
+
export type { LibraryLoadResult, SubcallOpts, SubLlmHandlers } from "./interrupts.ts";
|
|
65
28
|
|
|
66
29
|
export interface SandboxOptions {
|
|
67
30
|
/** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
|
|
@@ -80,11 +43,6 @@ export interface SandboxOptions {
|
|
|
80
43
|
readonly initTimeoutMs?: number;
|
|
81
44
|
/** Sub-LLM prompt cap (chars) — sizes llm_query_chunked chunks inside the worker. */
|
|
82
45
|
readonly maxPromptChars?: number;
|
|
83
|
-
/**
|
|
84
|
-
* When true, the worker rejects open() write modes (pipeline read-only runs).
|
|
85
|
-
* Native repl() data work leaves this false so scratch-file writes still work.
|
|
86
|
-
*/
|
|
87
|
-
readonly readOnly?: boolean;
|
|
88
46
|
/**
|
|
89
47
|
* Max seconds the worker will wait for a host reply while parked in `_drain_until`
|
|
90
48
|
* (rlm_await / sync sub-call). Defaults to the worker's own RLM_AWAIT_TIMEOUT_S (600).
|
|
@@ -92,9 +50,10 @@ export interface SandboxOptions {
|
|
|
92
50
|
readonly awaitTimeoutS?: number;
|
|
93
51
|
}
|
|
94
52
|
|
|
95
|
-
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
|
|
53
|
+
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "py", "worker.py");
|
|
96
54
|
const STDERR_TAIL_CHARS = 8_192;
|
|
97
|
-
|
|
55
|
+
/** How long dispose() waits for a clean worker exit before escalating to SIGKILL. */
|
|
56
|
+
const SHUTDOWN_GRACE_MS = 50;
|
|
98
57
|
|
|
99
58
|
// The sandbox runs untrusted model-authored code; it must never inherit provider secrets.
|
|
100
59
|
const SENSITIVE_ENV = /API[_-]?KEY|ACCESS[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|ANTHROPIC|OPENAI|_KEY$/i;
|
|
@@ -107,22 +66,6 @@ function sanitizedEnv(): NodeJS.ProcessEnv {
|
|
|
107
66
|
return env;
|
|
108
67
|
}
|
|
109
68
|
|
|
110
|
-
const REJECT: SubLlmHandlers = {
|
|
111
|
-
llmQuery: async () => formatError("sub-LLM bridge not configured"),
|
|
112
|
-
llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
|
|
113
|
-
rlmQuery: async () => formatError("sub-LLM bridge not configured"),
|
|
114
|
-
rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
|
|
115
|
-
advancePhase: async () => formatError("phase advancement not available"),
|
|
116
|
-
saveArtifact: async () => formatError("save_artifact not available"),
|
|
117
|
-
askUserQuestion: async (questions) => questions.map((q) => ({
|
|
118
|
-
question: q.question,
|
|
119
|
-
selected: [],
|
|
120
|
-
custom: formatError("ask_user_question not configured"),
|
|
121
|
-
})),
|
|
122
|
-
todo: async () => formatError("todo not configured"),
|
|
123
|
-
loadLibrary: async () => { throw new Error("load_library not configured"); },
|
|
124
|
-
};
|
|
125
|
-
|
|
126
69
|
/** Distributive omit so each union member keeps its own fields (plain Omit collapses to shared keys). */
|
|
127
70
|
type RequestBody = WorkerRequest extends infer T ? (T extends { id: string } ? Omit<T, "id"> : never) : never;
|
|
128
71
|
|
|
@@ -175,9 +118,6 @@ export class PythonSandbox {
|
|
|
175
118
|
if (opts.maxPromptChars !== undefined) {
|
|
176
119
|
workerArgs.push("--max-prompt-chars", String(opts.maxPromptChars));
|
|
177
120
|
}
|
|
178
|
-
if (opts.readOnly) {
|
|
179
|
-
workerArgs.push("--read-only");
|
|
180
|
-
}
|
|
181
121
|
if (opts.awaitTimeoutS !== undefined) {
|
|
182
122
|
workerArgs.push("--await-timeout", String(opts.awaitTimeoutS));
|
|
183
123
|
}
|
|
@@ -191,11 +131,30 @@ export class PythonSandbox {
|
|
|
191
131
|
this.proc.stdout.on("data", (chunk: string) => this.onData(chunk));
|
|
192
132
|
this.proc.stderr.setEncoding("utf8");
|
|
193
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
|
+
|
|
194
148
|
this.proc.on("error", (err: NodeJS.ErrnoException) => {
|
|
195
149
|
const hint = err.code === "ENOENT" ? ` ('${python}' not found — is Python installed and on PATH?)` : "";
|
|
196
150
|
this.failAll(new Error(`failed to start sandbox${hint}: ${err.message}`));
|
|
197
151
|
});
|
|
198
|
-
|
|
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
|
+
)));
|
|
199
158
|
|
|
200
159
|
this.ready = this.waitForInit();
|
|
201
160
|
|
|
@@ -224,31 +183,27 @@ export class PythonSandbox {
|
|
|
224
183
|
return sandbox;
|
|
225
184
|
}
|
|
226
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
|
+
*/
|
|
227
190
|
async loadContext(payload: unknown): Promise<number> {
|
|
228
|
-
const
|
|
229
|
-
let path: string | undefined;
|
|
191
|
+
const pinned = await pinContext(payload);
|
|
230
192
|
try {
|
|
231
|
-
|
|
232
|
-
const res = await this.request({ type: "load_context", path, json: isJson });
|
|
233
|
-
if (!res.ok) throw new Error(res.error ?? "load_context failed");
|
|
234
|
-
return res.index ?? 0;
|
|
193
|
+
return await this.loadContextPinned(pinned);
|
|
235
194
|
} finally {
|
|
236
|
-
|
|
195
|
+
await pinned.release();
|
|
237
196
|
}
|
|
238
197
|
}
|
|
239
198
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
await unlink(file).catch(() => {});
|
|
249
|
-
throw e;
|
|
250
|
-
}
|
|
251
|
-
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;
|
|
252
207
|
}
|
|
253
208
|
|
|
254
209
|
async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
|
|
@@ -268,38 +223,22 @@ export class PythonSandbox {
|
|
|
268
223
|
async dispose(): Promise<void> {
|
|
269
224
|
if (this.disposed) return;
|
|
270
225
|
this.disposed = true;
|
|
271
|
-
|
|
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) {
|
|
272
231
|
this.send({ id: "_shutdown", type: "shutdown" });
|
|
273
|
-
|
|
274
|
-
|
|
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 */ });
|
|
275
237
|
}
|
|
276
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
277
238
|
if (this.proc.exitCode === null) this.proc.kill("SIGKILL");
|
|
278
239
|
this.failAll(new Error("sandbox disposed"));
|
|
279
240
|
}
|
|
280
241
|
|
|
281
|
-
/** Pickle the worker's user namespace atomically to path (rename .tmp \u2192 final inside worker). */
|
|
282
|
-
async snapshot(path: string, nonce: string): Promise<boolean> {
|
|
283
|
-
try {
|
|
284
|
-
const res = await this.request({ type: "snapshot", path, nonce });
|
|
285
|
-
if (!res.ok) return false;
|
|
286
|
-
return res.ok;
|
|
287
|
-
} catch {
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Restore user variables. Worker verifies session nonce before deserializing. */
|
|
293
|
-
async restore(path: string, nonce: string): Promise<boolean> {
|
|
294
|
-
try {
|
|
295
|
-
const res = await this.request({ type: "restore", path, nonce });
|
|
296
|
-
if (!res.ok) return false;
|
|
297
|
-
return res.ok;
|
|
298
|
-
} catch {
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
242
|
// ---- internals ------------------------------------------------------------------------
|
|
304
243
|
|
|
305
244
|
private waitForInit(): Promise<void> {
|
|
@@ -320,6 +259,13 @@ export class PythonSandbox {
|
|
|
320
259
|
private request(payload: RequestBody, signal?: AbortSignal): Promise<WorkerResponse> {
|
|
321
260
|
if (this.disposed) return Promise.reject(new Error("sandbox disposed"));
|
|
322
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
|
+
}
|
|
323
269
|
const id = `r${++this.seq}`;
|
|
324
270
|
return new Promise<WorkerResponse>((resolve, reject) => {
|
|
325
271
|
const timer = this.createWatchdog(id, payload.type, reject);
|
|
@@ -375,9 +321,47 @@ export class PythonSandbox {
|
|
|
375
321
|
rid: "rid" in msg ? msg.rid : undefined,
|
|
376
322
|
});
|
|
377
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
|
+
}
|
|
378
332
|
this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
379
333
|
}
|
|
380
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
|
+
|
|
381
365
|
private onData(chunk: string): void {
|
|
382
366
|
this.buf += chunk;
|
|
383
367
|
let nl: number;
|
|
@@ -419,7 +403,7 @@ export class PythonSandbox {
|
|
|
419
403
|
}
|
|
420
404
|
if (isInterrupt(msg)) {
|
|
421
405
|
this.touchPending();
|
|
422
|
-
void this.
|
|
406
|
+
void serviceInterrupt(msg, this.handlers, (rid, body) => this.reply(rid, body));
|
|
423
407
|
return;
|
|
424
408
|
}
|
|
425
409
|
const p = this.pending.get(msg.id);
|
|
@@ -429,85 +413,7 @@ export class PythonSandbox {
|
|
|
429
413
|
p.resolve(msg);
|
|
430
414
|
}
|
|
431
415
|
|
|
432
|
-
private
|
|
433
|
-
const h = this.handlers;
|
|
434
|
-
const d = msg.depth;
|
|
435
|
-
const opts: SubcallOpts = { detached: msg.detached === true };
|
|
436
|
-
try {
|
|
437
|
-
if (msg.type === "llm_query") {
|
|
438
|
-
const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
|
|
439
|
-
this.reply(msg.rid, { response });
|
|
440
|
-
} else if (msg.type === "rlm_query") {
|
|
441
|
-
const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
|
|
442
|
-
this.reply(msg.rid, { response });
|
|
443
|
-
} else if (msg.type === "llm_query_batched") {
|
|
444
|
-
const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
|
|
445
|
-
this.reply(msg.rid, { responses });
|
|
446
|
-
} else if (msg.type === "rlm_query_batched") {
|
|
447
|
-
const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
|
|
448
|
-
this.reply(msg.rid, { responses });
|
|
449
|
-
} else if (msg.type === "advance_phase") {
|
|
450
|
-
const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
|
|
451
|
-
this.reply(msg.rid, { response });
|
|
452
|
-
} else if (msg.type === "save_artifact") {
|
|
453
|
-
const response = await h.saveArtifact(msg.artifactKind ?? "", msg.content ?? "", d);
|
|
454
|
-
this.reply(msg.rid, { response });
|
|
455
|
-
} else if (msg.type === "ask_user_question") {
|
|
456
|
-
const answers = await h.askUserQuestion(msg.questions ?? [], d);
|
|
457
|
-
this.reply(msg.rid, { answers });
|
|
458
|
-
} else if (msg.type === "todo") {
|
|
459
|
-
const params = Object.fromEntries(
|
|
460
|
-
Object.entries(msg).filter(([key]) => !TODO_PROTO_KEYS.has(key)),
|
|
461
|
-
);
|
|
462
|
-
const response = await h.todo(msg.action ?? "list", params, d);
|
|
463
|
-
this.reply(msg.rid, { response });
|
|
464
|
-
} else if (msg.type === "load_library") {
|
|
465
|
-
const lib = await h.loadLibrary(msg.source ?? "", d);
|
|
466
|
-
if (lib.alreadyLoaded) {
|
|
467
|
-
// No temp file — worker short-circuits on already_loaded.
|
|
468
|
-
this.reply(msg.rid, {
|
|
469
|
-
already_loaded: true,
|
|
470
|
-
index: lib.index,
|
|
471
|
-
files: 0,
|
|
472
|
-
chars: lib.chars,
|
|
473
|
-
source_id: lib.sourceId,
|
|
474
|
-
path_prefix: lib.pathPrefix,
|
|
475
|
-
});
|
|
476
|
-
} else {
|
|
477
|
-
const isJson = typeof lib.payload !== "string";
|
|
478
|
-
const path = await this.writeContextFile(lib.payload, isJson);
|
|
479
|
-
// Worker reads then unlinks (worker._load_library). Host must not unlink here —
|
|
480
|
-
// if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
|
|
481
|
-
this.reply(msg.rid, {
|
|
482
|
-
path,
|
|
483
|
-
json: isJson,
|
|
484
|
-
index: lib.index,
|
|
485
|
-
files: lib.files,
|
|
486
|
-
chars: lib.chars,
|
|
487
|
-
source_id: lib.sourceId,
|
|
488
|
-
path_prefix: lib.pathPrefix,
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
} catch (err) {
|
|
493
|
-
this.reply(msg.rid, { error: errorMessage(err) });
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
private reply(rid: string, body: {
|
|
498
|
-
response?: string;
|
|
499
|
-
responses?: string[];
|
|
500
|
-
answers?: AskAnswer[];
|
|
501
|
-
path?: string;
|
|
502
|
-
json?: boolean;
|
|
503
|
-
index?: number;
|
|
504
|
-
files?: number;
|
|
505
|
-
chars?: number;
|
|
506
|
-
source_id?: string;
|
|
507
|
-
path_prefix?: string;
|
|
508
|
-
already_loaded?: boolean;
|
|
509
|
-
error?: string;
|
|
510
|
-
}): void {
|
|
416
|
+
private reply(rid: string, body: ReplyBody): void {
|
|
511
417
|
if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
|
|
512
418
|
}
|
|
513
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. */
|
package/src/tool/repl-details.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ReplDetails — structured payload for the repl() tool's AgentToolResult<T>.
|
|
3
3
|
*
|
|
4
4
|
* Mirrors RlmDetails but scoped to a single code execution. Sub-calls (llm_query,
|
|
5
|
-
* rlm_query,
|
|
5
|
+
* rlm_query, load_library) triggered during sandbox execution are
|
|
6
6
|
* accumulated into the subcalls array for tree rendering.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -16,7 +16,7 @@ export interface ReplDetails {
|
|
|
16
16
|
readonly stderr: string;
|
|
17
17
|
/** Wall-clock execution time in milliseconds. */
|
|
18
18
|
readonly executionTimeMs: number;
|
|
19
|
-
/** Sub-calls triggered during this execution (llm_query, rlm_query,
|
|
19
|
+
/** Sub-calls triggered during this execution (llm_query, rlm_query, etc.). */
|
|
20
20
|
readonly subcalls: readonly RlmSubcall[];
|
|
21
21
|
/** Running totals for this repl() call (cost + tokens from sub-LLM calls). */
|
|
22
22
|
readonly totals: { readonly costUsd: number; readonly tokens: number };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** repl() tool TUI views — collapsed one-liner card and the expanded output/sub-call tree. */
|
|
2
|
+
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { ReplDetails } from "./repl-details.ts";
|
|
6
|
+
import { cardHeader, cardStatsLine, renderCollapsedCard, renderExpandedSubcallTree } from "./subcall-render.ts";
|
|
7
|
+
|
|
8
|
+
/** Chars of stdout/stderr shown in the expanded view. */
|
|
9
|
+
const EXPANDED_STDOUT_CHARS = 2_000;
|
|
10
|
+
const EXPANDED_STDERR_CHARS = 500;
|
|
11
|
+
|
|
12
|
+
// ── Collapsed view ──
|
|
13
|
+
|
|
14
|
+
export function replStats(details: ReplDetails, theme: Theme): string {
|
|
15
|
+
const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
|
|
16
|
+
return cardStatsLine(details.totals, theme, elapsed, details.backgroundPending);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
|
|
20
|
+
return renderCollapsedCard("REPL", details.status, replStats(details, theme), details.subcalls, theme);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Expanded view ──
|
|
24
|
+
|
|
25
|
+
export function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
|
|
26
|
+
const container = new Container();
|
|
27
|
+
|
|
28
|
+
container.addChild(new Text(cardHeader("REPL", details.status, replStats(details, theme), theme), 0, 0));
|
|
29
|
+
|
|
30
|
+
// Output
|
|
31
|
+
if (details.output) {
|
|
32
|
+
container.addChild(new Spacer(1));
|
|
33
|
+
const out = details.output.length > EXPANDED_STDOUT_CHARS
|
|
34
|
+
? `${details.output.slice(0, EXPANDED_STDOUT_CHARS)}…`
|
|
35
|
+
: details.output;
|
|
36
|
+
container.addChild(new Text(out, 0, 0));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (details.warnings && details.warnings.length > 0) {
|
|
40
|
+
container.addChild(new Spacer(1));
|
|
41
|
+
container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Stderr
|
|
45
|
+
if (details.stderr) {
|
|
46
|
+
container.addChild(new Spacer(1));
|
|
47
|
+
container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Sub-call tree
|
|
51
|
+
if (details.subcalls.length > 0) {
|
|
52
|
+
container.addChild(new Spacer(1));
|
|
53
|
+
container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
|
|
54
|
+
container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return container;
|
|
58
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-visible text assembly for a repl() result, plus the advisory diagnostics derived from
|
|
3
|
+
* its sub-calls. Split out of repl-tool.ts: this is pure string/array work with no sandbox,
|
|
4
|
+
* emitter, or TUI dependency, and both halves are asserted directly by test/phase-guards.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RlmSubcall } from "./rlm-details.ts";
|
|
8
|
+
import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
|
|
9
|
+
|
|
10
|
+
/** Model-visible text assembled from a repl() result. */
|
|
11
|
+
export interface ReplResultText {
|
|
12
|
+
readonly text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
|
|
17
|
+
* delegation nudge when a bulk read went undelegated, and report tasks still running.
|
|
18
|
+
*
|
|
19
|
+
* The pending line is the model's only signal that `spawn()`ed work is outstanding — without
|
|
20
|
+
* it a model that spawned and moved on has no way to know it should still collect.
|
|
21
|
+
*
|
|
22
|
+
* `varNames` covers the opposite failure: a block that stores its results in `answers` and
|
|
23
|
+
* prints nothing reads as a bare "(no output)", so the model concludes the block did nothing
|
|
24
|
+
* and re-runs it — paying twice for the same sub-calls. The headless engine already answers
|
|
25
|
+
* this with the same hint (core/answer.ts); native mode was the only path missing it.
|
|
26
|
+
*/
|
|
27
|
+
export function buildReplResultText(
|
|
28
|
+
stdout: string,
|
|
29
|
+
finalAnswer: string | undefined,
|
|
30
|
+
subcalls: readonly RlmSubcall[],
|
|
31
|
+
backgroundPending = 0,
|
|
32
|
+
varNames: readonly string[] = [],
|
|
33
|
+
): ReplResultText {
|
|
34
|
+
const answerSubmitted = finalAnswer !== undefined;
|
|
35
|
+
const noOutput = !answerSubmitted && !stdout;
|
|
36
|
+
const varsHint = noOutput && varNames.length > 0
|
|
37
|
+
? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
|
|
38
|
+
+ "Do NOT re-run it; read them in the next block."
|
|
39
|
+
: "";
|
|
40
|
+
const rawText = answerSubmitted
|
|
41
|
+
? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
|
|
42
|
+
: stdout || `(no output)${varsHint}`;
|
|
43
|
+
// Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
|
|
44
|
+
const cappedText = capReplResultText(rawText) ?? rawText;
|
|
45
|
+
const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
|
|
46
|
+
const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
47
|
+
const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
|
|
48
|
+
const pendingLine = backgroundPending > 0
|
|
49
|
+
? `\n\n[rlm] ${backgroundPending} background task(s) still running — rlm_await_all(tasks) to collect.`
|
|
50
|
+
: "";
|
|
51
|
+
const failedLine = failedBg > 0
|
|
52
|
+
? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their rlm_await value is an "Error: …" string, not data.`
|
|
53
|
+
: "";
|
|
54
|
+
return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Advisory diagnostics derived from a completed invocation's sub-calls. */
|
|
58
|
+
export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly string[] | undefined {
|
|
59
|
+
let failed = 0;
|
|
60
|
+
let total = 0;
|
|
61
|
+
for (let i = 0; i < subcalls.length; i++) {
|
|
62
|
+
const call = subcalls[i];
|
|
63
|
+
if (call.status !== "error") continue;
|
|
64
|
+
// A batch subcall stands for many prompts; a single call stands for one.
|
|
65
|
+
failed += call.failedCount ?? 1;
|
|
66
|
+
total += call.totalCount ?? 1;
|
|
67
|
+
}
|
|
68
|
+
if (failed === 0) return undefined;
|
|
69
|
+
return Object.freeze([`${failed}/${total} sub-call(s) failed — results may be incomplete`]);
|
|
70
|
+
}
|