@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
|
@@ -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
|
+
}
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -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
|
-
|
|
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}"
|