@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.
Files changed (61) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +6 -17
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +55 -335
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +23 -12
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -407
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +8 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/{worker.py → py/worker.py} +76 -696
  30. package/src/sandbox/sandbox-manager.ts +13 -0
  31. package/src/sandbox/sandbox.ts +99 -193
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/repl-details.ts +2 -2
  34. package/src/tool/repl-render.ts +58 -0
  35. package/src/tool/repl-result.ts +70 -0
  36. package/src/tool/repl-tool.ts +37 -159
  37. package/src/tool/rlm-aggregator.ts +2 -10
  38. package/src/tool/rlm-details.ts +0 -2
  39. package/src/tool/rlm-events.ts +0 -14
  40. package/src/tool/rlm-tool.ts +1 -12
  41. package/src/ui/config-panel.ts +4 -16
  42. package/src/ui/intro.ts +1 -2
  43. package/src/ui/model-picker.ts +34 -10
  44. package/src/ui/status.ts +3 -7
  45. package/src/util/concurrency.ts +9 -5
  46. package/src/bridge/fallback-todo.ts +0 -148
  47. package/src/bridge/interactive.ts +0 -65
  48. package/src/bridge/pi-interactive.ts +0 -41
  49. package/src/core/artifacts.ts +0 -89
  50. package/src/core/critique.ts +0 -92
  51. package/src/core/gates.ts +0 -301
  52. package/src/core/pipeline-handlers.ts +0 -319
  53. package/src/core/pipeline.ts +0 -268
  54. package/src/prompts/phases.ts +0 -104
  55. package/src/state/index.ts +0 -24
  56. package/src/state/internal.ts +0 -46
  57. package/src/state/paths.ts +0 -44
  58. package/src/state/reads.ts +0 -133
  59. package/src/state/resume.ts +0 -173
  60. package/src/state/rows.ts +0 -123
  61. package/src/state/writes.ts +0 -58
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Temp-file transport for sandbox context payloads, with refcounted sharing.
3
+ *
4
+ * Two callers, two ownership models, one writer:
5
+ * - `writeContextTempFile` — non-owning. `load_library` uses it because the WORKER unlinks
6
+ * that file after reading it (see sandbox.ts serviceInterrupt / worker.py `_load_library`).
7
+ * - `pinContext` — refcounted. Every child RLM of one node inherits the SAME payload, so an
8
+ * 18-way fan-out would otherwise cost 18 serializations and 18 files. Pins are keyed by
9
+ * payload identity, which is a free version key: `mergeLibraryIntoContext` always returns a
10
+ * NEW array, so loading a library mints a new key and old holders keep their own file.
11
+ *
12
+ * Serialization is chunked with an await between chunks so the event loop is never blocked for
13
+ * more than ~SERIALIZE_CHUNK entries. A Worker Thread was considered and rejected: posting the
14
+ * payload structured-clones the whole array, which costs about what the stringify costs and
15
+ * doubles peak RSS.
16
+ */
17
+
18
+ import { open, unlink, type FileHandle } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ /** Entries serialized per await. Bounds the longest synchronous span on the event loop. */
23
+ const SERIALIZE_CHUNK = 64;
24
+
25
+ /** A temp file on disk holding a serialized context payload. */
26
+ export interface ContextTempFile {
27
+ readonly path: string;
28
+ /** True when the file holds JSON; false when the payload was a raw string. */
29
+ readonly json: boolean;
30
+ }
31
+
32
+ /** A shared, refcounted context file. Every holder must `release()` exactly once. */
33
+ export interface PinnedContext extends ContextTempFile {
34
+ /** Drop this holder's reference; unlinks once the last holder releases. Idempotent. */
35
+ release(): Promise<void>;
36
+ }
37
+
38
+ interface PinEntry extends ContextTempFile {
39
+ refs: number;
40
+ }
41
+
42
+ /**
43
+ * Live pins keyed by payload identity, storing the in-flight PROMISE rather than the settled
44
+ * entry. Children of one node race here (each drives its own sandbox, so nothing else
45
+ * serializes them); inserting the promise before the first await makes them join one write
46
+ * instead of each starting their own and orphaning the loser's file.
47
+ */
48
+ const pins = new Map<unknown, Promise<PinEntry>>();
49
+
50
+ function tempPath(isJson: boolean): string {
51
+ const suffix = isJson ? "json" : "txt";
52
+ return join(
53
+ tmpdir(),
54
+ `rlm-ctx-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${suffix}`,
55
+ );
56
+ }
57
+
58
+ /** Write a JSON array incrementally, yielding to the event loop between chunks. */
59
+ async function writeChunkedArray(handle: FileHandle, items: readonly unknown[]): Promise<void> {
60
+ await handle.write("[");
61
+ const buf = new Array<string>(SERIALIZE_CHUNK);
62
+ let n = 0;
63
+ for (let i = 0; i < items.length; i++) {
64
+ // Comma prefix beats trimming a trailing one; no `+=` accumulation anywhere.
65
+ buf[n++] = i === 0 ? JSON.stringify(items[i]) : `,${JSON.stringify(items[i])}`;
66
+ if (n === SERIALIZE_CHUNK) {
67
+ await handle.write(buf.join("")); // the await is the yield point
68
+ n = 0;
69
+ }
70
+ }
71
+ if (n > 0) await handle.write(buf.slice(0, n).join(""));
72
+ await handle.write("]");
73
+ }
74
+
75
+ /**
76
+ * Serialize a payload to a fresh temp file. The caller owns the file and decides when (or
77
+ * whether) to unlink it.
78
+ */
79
+ export async function writeContextTempFile(payload: unknown): Promise<ContextTempFile> {
80
+ const json = typeof payload !== "string";
81
+ const path = tempPath(json);
82
+ try {
83
+ const handle = await open(path, "w");
84
+ try {
85
+ if (typeof payload === "string") await handle.write(payload);
86
+ else if (Array.isArray(payload)) await writeChunkedArray(handle, payload);
87
+ else await handle.write(JSON.stringify(payload));
88
+ } finally {
89
+ await handle.close();
90
+ }
91
+ } catch (err) {
92
+ await unlink(path).catch(() => {});
93
+ throw err;
94
+ }
95
+ return Object.freeze({ path, json });
96
+ }
97
+
98
+ async function writePinEntry(payload: unknown): Promise<PinEntry> {
99
+ const file = await writeContextTempFile(payload);
100
+ return { path: file.path, json: file.json, refs: 1 };
101
+ }
102
+
103
+ /** One holder's view of a pin. `shared` entries are evicted from the map at refcount zero. */
104
+ function handleFor(key: unknown, entry: PinEntry, shared: boolean): PinnedContext {
105
+ let released = false;
106
+ return Object.freeze({
107
+ path: entry.path,
108
+ json: entry.json,
109
+ release: async (): Promise<void> => {
110
+ if (released) return; // idempotent per handle, so a `finally` cannot double-decrement
111
+ released = true;
112
+ entry.refs -= 1;
113
+ if (entry.refs > 0) return;
114
+ if (shared) pins.delete(key);
115
+ await unlink(entry.path).catch(() => {});
116
+ },
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Acquire a shared context file for `payload`. Holders must `release()` exactly once; the file
122
+ * is unlinked when the last one does.
123
+ */
124
+ export async function pinContext(payload: unknown): Promise<PinnedContext> {
125
+ // Only arrays are shared. Their identity is a meaningful version key; a string's is not
126
+ // (two equal strings may or may not be the same reference), and the string payloads here are
127
+ // one-off child prompts with nothing to share anyway.
128
+ if (!Array.isArray(payload)) {
129
+ return handleFor(payload, await writePinEntry(payload), false);
130
+ }
131
+
132
+ const existing = pins.get(payload);
133
+ if (existing !== undefined) {
134
+ const entry = await existing;
135
+ entry.refs += 1;
136
+ return handleFor(payload, entry, true);
137
+ }
138
+
139
+ // Insert synchronously, BEFORE any await, so a concurrent caller sees this write in flight.
140
+ const pending = writePinEntry(payload);
141
+ pins.set(payload, pending);
142
+ try {
143
+ return handleFor(payload, await pending, true);
144
+ } catch (err) {
145
+ // Evict the rejected promise so a later caller retries instead of awaiting a poisoned pin.
146
+ pins.delete(payload);
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ /** Live pin count. Exported for tests asserting the sharing and the unlink-once behaviour. */
152
+ export function pinnedCount(): number {
153
+ return pins.size;
154
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The interrupt surface: what the worker can ask the host for mid-exec, and how each request is
3
+ * turned into a reply frame.
4
+ *
5
+ * Split from sandbox.ts, which owns the subprocess and the JSONL pump. Adding a sandbox function
6
+ * touches this file and worker.py; the transport underneath does not change.
7
+ */
8
+
9
+ import type { WorkerInterrupt } from "./protocol.ts";
10
+ import { writeContextTempFile } from "./context-file.ts";
11
+ import { errorMessage, formatError } from "../util/errors.ts";
12
+
13
+ /** Result of a host-side library pack requested by `load_library`. */
14
+ export interface LibraryLoadResult {
15
+ readonly payload: unknown; // always ContextFile[] under lib/<id>/
16
+ readonly files?: number;
17
+ readonly chars: number;
18
+ readonly sourceId: string;
19
+ readonly pathPrefix: string;
20
+ /** Host already has this library — no pack, empty payload. */
21
+ readonly alreadyLoaded?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Per-interrupt routing context for the sub-LLM handlers.
26
+ *
27
+ * Only the four sub-call kinds can be spawned, so only they carry it; load_library is
28
+ * always synchronous within one exec.
29
+ */
30
+ export interface SubcallOpts {
31
+ /** Started via `spawn()` — route to session-scoped state, not the current invocation. */
32
+ readonly detached: boolean;
33
+ /**
34
+ * `rlm_query(paths=[…])` — path prefixes narrowing the child's inherited context.
35
+ * Absent on every other path; `llm_query` never carries it.
36
+ */
37
+ readonly paths?: readonly string[];
38
+ }
39
+
40
+ /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
41
+ export interface SubLlmHandlers {
42
+ llmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
43
+ llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
44
+ rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
45
+ rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
46
+ loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
47
+ }
48
+
49
+ /** Narrow an unknown JSON value to a frozen string array. Non-strings and blanks are dropped. */
50
+ function toStringArray(value: unknown): readonly string[] | undefined {
51
+ if (!Array.isArray(value)) return undefined;
52
+ const out = new Array<string>(value.length);
53
+ let n = 0;
54
+ for (let i = 0; i < value.length; i++) {
55
+ const item: unknown = value[i];
56
+ if (typeof item === "string" && item.trim() !== "") out[n++] = item;
57
+ }
58
+ out.length = n;
59
+ return n > 0 ? Object.freeze(out) : undefined;
60
+ }
61
+
62
+ /** Default handlers — every sandbox function refuses until a bridge installs a real one. */
63
+ export const REJECT: SubLlmHandlers = {
64
+ llmQuery: async () => formatError("sub-LLM bridge not configured"),
65
+ llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
66
+ rlmQuery: async () => formatError("sub-LLM bridge not configured"),
67
+ rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
68
+ loadLibrary: async () => { throw new Error("load_library not configured"); },
69
+ };
70
+
71
+ /** Body of a reply frame — the union of every handler's payload shape. */
72
+ export interface ReplyBody {
73
+ response?: string;
74
+ responses?: string[];
75
+ path?: string;
76
+ json?: boolean;
77
+ files?: number;
78
+ chars?: number;
79
+ source_id?: string;
80
+ path_prefix?: string;
81
+ already_loaded?: boolean;
82
+ error?: string;
83
+ }
84
+
85
+ /**
86
+ * Service one interrupt and hand the reply body to `reply`.
87
+ *
88
+ * Errors are replied, never thrown: the caller invokes this from the stdio pump, where a
89
+ * rejection would surface as an unhandled promise and leave the worker parked forever.
90
+ */
91
+ export async function serviceInterrupt(
92
+ msg: WorkerInterrupt,
93
+ h: SubLlmHandlers,
94
+ reply: (rid: string, body: ReplyBody) => void,
95
+ ): Promise<void> {
96
+ const d = msg.depth;
97
+ const opts: SubcallOpts = Object.freeze({
98
+ detached: msg.detached === true,
99
+ // Only the recursive kinds carry a context slice; the value crossed JSON, so guard it.
100
+ paths: msg.type === "rlm_query" || msg.type === "rlm_query_batched"
101
+ ? toStringArray(msg.paths)
102
+ : undefined,
103
+ });
104
+ try {
105
+ if (msg.type === "llm_query") {
106
+ const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
107
+ reply(msg.rid, { response });
108
+ } else if (msg.type === "rlm_query") {
109
+ const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
110
+ reply(msg.rid, { response });
111
+ } else if (msg.type === "llm_query_batched") {
112
+ const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
113
+ reply(msg.rid, { responses });
114
+ } else if (msg.type === "rlm_query_batched") {
115
+ const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
116
+ reply(msg.rid, { responses });
117
+ } else if (msg.type === "load_library") {
118
+ const lib = await h.loadLibrary(msg.source ?? "", d);
119
+ if (lib.alreadyLoaded) {
120
+ // No temp file — worker short-circuits on already_loaded.
121
+ reply(msg.rid, {
122
+ already_loaded: true,
123
+ files: 0,
124
+ chars: lib.chars,
125
+ source_id: lib.sourceId,
126
+ path_prefix: lib.pathPrefix,
127
+ });
128
+ } else {
129
+ const { path, json: isJson } = await writeContextTempFile(lib.payload);
130
+ // Worker reads then unlinks (worker._load_library). Host must not unlink here —
131
+ // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
132
+ reply(msg.rid, {
133
+ path,
134
+ json: isJson,
135
+ files: lib.files,
136
+ chars: lib.chars,
137
+ source_id: lib.sourceId,
138
+ path_prefix: lib.pathPrefix,
139
+ });
140
+ }
141
+ }
142
+ } catch (err) {
143
+ reply(msg.rid, { error: errorMessage(err) });
144
+ }
145
+ }
@@ -10,8 +10,6 @@
10
10
  export type WorkerRequest =
11
11
  | { readonly id: string; readonly type: "exec"; readonly code: string }
12
12
  | { readonly id: string; readonly type: "load_context"; readonly path: string; readonly index?: number; readonly json: boolean }
13
- | { readonly id: string; readonly type: "snapshot"; readonly path: string; readonly nonce: string }
14
- | { readonly id: string; readonly type: "restore"; readonly path: string; readonly nonce: string }
15
13
  | { readonly id: string; readonly type: "shutdown" };
16
14
 
17
15
  /** Reply the parent sends to satisfy a sub-LLM interrupt. */
@@ -20,11 +18,9 @@ export interface LlmReply {
20
18
  readonly rid: string;
21
19
  readonly response?: string;
22
20
  readonly responses?: readonly string[];
23
- readonly answers?: readonly AskAnswer[];
24
- /** load_library reply: temp file with the packed payload (+ resume index / namespace). */
21
+ /** load_library reply: temp file with the packed payload (+ namespace metadata). */
25
22
  readonly path?: string;
26
23
  readonly json?: boolean;
27
- readonly index?: number;
28
24
  readonly files?: number;
29
25
  readonly chars?: number;
30
26
  readonly source_id?: string;
@@ -60,31 +56,8 @@ export type InterruptKind =
60
56
  | "llm_query_batched"
61
57
  | "rlm_query"
62
58
  | "rlm_query_batched"
63
- | "advance_phase"
64
- | "save_artifact"
65
- | "ask_user_question"
66
- | "todo"
67
59
  | "load_library";
68
60
 
69
- export interface AskOption {
70
- readonly label: string;
71
- readonly description?: string;
72
- readonly preview?: string;
73
- }
74
-
75
- export interface AskQuestion {
76
- readonly question: string;
77
- readonly header: string;
78
- readonly multiSelect?: boolean;
79
- readonly options: readonly AskOption[];
80
- }
81
-
82
- export interface AskAnswer {
83
- readonly question: string;
84
- readonly selected: readonly string[];
85
- readonly custom?: string;
86
- }
87
-
88
61
  interface InterruptBase {
89
62
  readonly rid: string;
90
63
  readonly depth: number;
@@ -100,45 +73,19 @@ interface PromptInterrupt extends InterruptBase {
100
73
  readonly type: "llm_query" | "rlm_query";
101
74
  readonly prompt?: string;
102
75
  readonly model?: string | null;
76
+ /**
77
+ * `rlm_query` only — path prefixes narrowing the child's inherited context. Never sent for
78
+ * `llm_query`, whose frame stays byte-identical to before.
79
+ */
80
+ readonly paths?: readonly string[];
103
81
  }
104
82
 
105
83
  interface BatchedPromptInterrupt extends InterruptBase {
106
84
  readonly type: "llm_query_batched" | "rlm_query_batched";
107
85
  readonly prompts?: readonly string[];
108
86
  readonly model?: string | null;
109
- }
110
-
111
- interface AdvancePhaseInterrupt extends InterruptBase {
112
- readonly type: "advance_phase";
113
- readonly phase?: string;
114
- readonly summary?: string;
115
- }
116
-
117
- interface SaveArtifactInterrupt extends InterruptBase {
118
- readonly type: "save_artifact";
119
- readonly artifactKind?: string;
120
- readonly content?: string;
121
- }
122
-
123
- export interface AskUserQuestionInterrupt extends InterruptBase {
124
- readonly type: "ask_user_question";
125
- readonly questions: readonly AskQuestion[];
126
- }
127
-
128
- export interface TodoInterrupt extends InterruptBase {
129
- readonly type: "todo";
130
- readonly action: "create" | "update" | "list" | "get" | "delete" | "clear";
131
- readonly id?: number;
132
- readonly subject?: string;
133
- readonly description?: string;
134
- readonly status?: "pending" | "in_progress" | "completed" | "deleted";
135
- readonly activeForm?: string;
136
- readonly blockedBy?: readonly number[];
137
- readonly addBlockedBy?: readonly number[];
138
- readonly removeBlockedBy?: readonly number[];
139
- readonly owner?: string;
140
- readonly filterStatus?: string;
141
- readonly includeDeleted?: boolean;
87
+ /** `rlm_query_batched` only — one prefix set shared by every prompt in the batch. */
88
+ readonly paths?: readonly string[];
142
89
  }
143
90
 
144
91
  export interface LoadLibraryInterrupt extends InterruptBase {
@@ -150,10 +97,6 @@ export interface LoadLibraryInterrupt extends InterruptBase {
150
97
  export type WorkerInterrupt =
151
98
  | PromptInterrupt
152
99
  | BatchedPromptInterrupt
153
- | AdvancePhaseInterrupt
154
- | SaveArtifactInterrupt
155
- | AskUserQuestionInterrupt
156
- | TodoInterrupt
157
100
  | LoadLibraryInterrupt;
158
101
 
159
102
  export type WorkerMessage = WorkerResponse | WorkerInterrupt;
@@ -163,10 +106,6 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
163
106
  "llm_query_batched",
164
107
  "rlm_query",
165
108
  "rlm_query_batched",
166
- "advance_phase",
167
- "save_artifact",
168
- "ask_user_question",
169
- "todo",
170
109
  "load_library",
171
110
  ]));
172
111
 
@@ -0,0 +1,150 @@
1
+ """Execution guardrails for the RLM sandbox worker.
2
+
3
+ The restricted builtin table, the reserved-name set that keeps scaffold functions out of
4
+ SHOW_VARS, the protocol writer that must always reach the REAL stdout (user prints are
5
+ captured into a buffer), and the per-exec stall alarm.
6
+
7
+ This is steering, not a security boundary: `__import__` and `open` are deliberately available,
8
+ so model code can still reach the network and the filesystem. What it does buy is that the
9
+ scaffold cannot be clobbered silently and that a blocked builtin explains itself instead of
10
+ failing as "'NoneType' object is not callable".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import signal
18
+ import sys
19
+ from contextlib import contextmanager
20
+ from typing import Any
21
+
22
+ # Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
23
+ # All protocol writes must go to the real stdout even while user code's prints are captured.
24
+ REAL_STDOUT = sys.stdout
25
+ REAL_STDIN = sys.stdin
26
+ REAL_STDERR = sys.stderr
27
+
28
+
29
+ def _builtin(name: str):
30
+ return __builtins__[name] if isinstance(__builtins__, dict) else getattr(__builtins__, name, None)
31
+
32
+
33
+ # Restricted builtins: enough for real data work, minus the dangerous reflection escapes.
34
+ _SAFE_BUILTINS = {
35
+ name: _builtin(name)
36
+ for name in (
37
+ "abs", "all", "any", "ascii", "bin", "bool", "bytearray", "bytes", "callable",
38
+ "chr", "classmethod", "complex", "dict", "dir", "divmod", "enumerate", "filter",
39
+ "float", "format", "frozenset", "getattr", "hasattr", "hash", "hex", "id", "int",
40
+ "isinstance", "issubclass", "iter", "len", "list", "map", "max", "min", "next",
41
+ "object", "oct", "ord", "pow", "print", "property", "range", "repr", "reversed",
42
+ "round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super",
43
+ "tuple", "type", "vars", "zip", "delattr", "memoryview", "__import__", "__build_class__",
44
+ "Exception", "BaseException", "ValueError", "TypeError", "KeyError", "IndexError",
45
+ "AttributeError", "FileNotFoundError", "OSError", "IOError", "RuntimeError",
46
+ "NameError", "ImportError", "StopIteration", "AssertionError", "NotImplementedError",
47
+ "ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
48
+ )
49
+ }
50
+ # `open` is allowed for data work; eval/exec/compile/input/globals/locals are not.
51
+ # _builtin()'s getattr(..., None) fallback would silently inject None for a name this
52
+ # interpreter lacks, surfacing much later as "'NoneType' object is not callable" inside model
53
+ # code. Fail at startup instead. Note "None" is legitimately None, and the block-list below is
54
+ # deliberate — which is why this check runs BEFORE it.
55
+ _MISSING = sorted(name for name, value in _SAFE_BUILTINS.items() if value is None and name != "None")
56
+ if _MISSING:
57
+ raise RuntimeError(f"unsupported Python interpreter: missing builtins {_MISSING}")
58
+
59
+ def _blocked_builtin(name: str):
60
+ """Bind a disabled builtin to a callable that explains itself.
61
+
62
+ Binding these to None made `eval(...)` fail with a bare "'NoneType' object is not callable",
63
+ which reads as a broken sandbox rather than a deliberate block: an audit session spent six
64
+ execs on it and filed a phantom "namespace corruption" bug. Saying so at the point of failure
65
+ fixes it for every model without spending native-prompt budget on a rule most runs never hit.
66
+ """
67
+ def blocked(*_args, **_kwargs):
68
+ raise PermissionError(
69
+ f"{name}() is disabled in the RLM sandbox by design — it is not missing and the "
70
+ "namespace is not corrupt. Names are already bound, so reference them directly; "
71
+ "inspect `context` with search() / grep_context() / outline()."
72
+ )
73
+ blocked.__name__ = name
74
+ return blocked
75
+
76
+
77
+ # Blocked on purpose (NOT missing) — see _blocked_builtin. The _MISSING check above runs first,
78
+ # so a genuinely absent builtin is still a startup failure rather than a silent None.
79
+ for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
80
+ _SAFE_BUILTINS[_blocked] = _blocked_builtin(_blocked)
81
+
82
+ RESERVED = frozenset(
83
+ {
84
+ "llm_query", "llm_query_batched", "llm_query_chunked",
85
+ "rlm_query", "rlm_query_batched",
86
+ "spawn", "rlm_await", "rlm_await_all",
87
+ "map_files", "llm_map_reduce",
88
+ "search", "grep_context", "outline",
89
+ "load_library",
90
+ "SHOW_VARS", "answer", "context",
91
+ }
92
+ )
93
+ # NOTE: `answers` and `plan` are deliberately NOT reserved. They are seeded by the scaffold but
94
+ # owned by the model, so they must appear in SHOW_VARS.
95
+ # Only the single name `context` is the packed world. Legacy context_N names are filtered out.
96
+ _CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
97
+
98
+ def _send(obj: dict[str, Any]) -> None:
99
+ REAL_STDOUT.write(json.dumps(obj, ensure_ascii=False) + "\n")
100
+ REAL_STDOUT.flush()
101
+
102
+
103
+ class _StallTimeout(Exception):
104
+ """No frame from the host while a sub-call was pending."""
105
+
106
+
107
+ @contextmanager
108
+ def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
109
+ """Swap the per-cell alarm for a stall alarm while blocked on the parent.
110
+
111
+ Sub-LLM latency is network time, not cell compute time, so it must not count against the
112
+ ```repl``` block timeout — but an unbounded wait is exactly how a lost reply turns into a
113
+ dead session. The yielded `rearm()` restarts the stall clock on every frame, so a healthy
114
+ long-running child never trips it.
115
+ """
116
+ use = hasattr(signal, "SIGALRM")
117
+ remaining = signal.getitimer(signal.ITIMER_REAL)[0] if (use and exec_timeout_s > 0) else 0.0
118
+
119
+ def _fire(signum, frame): # noqa: ARG001
120
+ raise _StallTimeout(
121
+ f"sub-call stalled — no reply from the host for {stall_timeout_s:g}s "
122
+ "(the task may still be running; rlm_await it again in a later block)"
123
+ )
124
+
125
+ old = signal.signal(signal.SIGALRM, _fire) if use else None
126
+
127
+ def rearm() -> None:
128
+ if use and stall_timeout_s > 0:
129
+ signal.setitimer(signal.ITIMER_REAL, stall_timeout_s)
130
+
131
+ rearm()
132
+ try:
133
+ yield rearm
134
+ finally:
135
+ if use:
136
+ signal.setitimer(signal.ITIMER_REAL, 0)
137
+ if old is not None:
138
+ signal.signal(signal.SIGALRM, old)
139
+ if remaining > 0: # restore the cell's remaining budget
140
+ signal.setitimer(signal.ITIMER_REAL, remaining)
141
+
142
+
143
+ def _surfaced_error(message: str) -> str:
144
+ """The "Error: …" contract value, ALSO written to the cell's stderr.
145
+
146
+ A spawn/await misuse whose only trace is the returned value reads to the model as a random
147
+ string much later — which is exactly how `tasks.items()` blew up on a str.
148
+ """
149
+ print(f"[rlm] {message}", file=sys.stderr)
150
+ return f"Error: {message}"