@hicaru/pi-rlm 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -35
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +1 -1
- package/src/bridge/library.ts +61 -26
- package/src/bridge/subcall-handlers.ts +382 -0
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +7 -15
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +115 -360
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +3 -36
- package/src/index.ts +49 -10
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +26 -57
- package/src/prompts/glossary.ts +287 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +14 -386
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +14 -69
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +116 -0
- package/src/sandbox/py/worker.py +836 -0
- package/src/sandbox/sandbox-manager.ts +33 -6
- package/src/sandbox/sandbox.ts +153 -182
- package/src/text/tokens.ts +29 -3
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +4 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +178 -216
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +10 -16
- package/src/tool/rlm-tool.ts +1 -12
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +4 -16
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +91 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/fallback-todo.ts +0 -137
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/bridge/rlm-query.ts +0 -108
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/sandbox/worker.py +0 -1078
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
|
@@ -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,79 +56,36 @@ 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;
|
|
64
|
+
/**
|
|
65
|
+
* Started via `spawn()`: the request may outlive the `exec` that issued it, so the host
|
|
66
|
+
* must not attach it to that invocation's emitter or LimitGuard. Absent on the
|
|
67
|
+
* synchronous path.
|
|
68
|
+
*/
|
|
69
|
+
readonly detached?: boolean;
|
|
91
70
|
}
|
|
92
71
|
|
|
93
72
|
interface PromptInterrupt extends InterruptBase {
|
|
94
73
|
readonly type: "llm_query" | "rlm_query";
|
|
95
74
|
readonly prompt?: string;
|
|
96
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[];
|
|
97
81
|
}
|
|
98
82
|
|
|
99
83
|
interface BatchedPromptInterrupt extends InterruptBase {
|
|
100
84
|
readonly type: "llm_query_batched" | "rlm_query_batched";
|
|
101
85
|
readonly prompts?: readonly string[];
|
|
102
86
|
readonly model?: string | null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
interface AdvancePhaseInterrupt extends InterruptBase {
|
|
106
|
-
readonly type: "advance_phase";
|
|
107
|
-
readonly phase?: string;
|
|
108
|
-
readonly summary?: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
interface SaveArtifactInterrupt extends InterruptBase {
|
|
112
|
-
readonly type: "save_artifact";
|
|
113
|
-
readonly artifactKind?: string;
|
|
114
|
-
readonly content?: string;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface AskUserQuestionInterrupt extends InterruptBase {
|
|
118
|
-
readonly type: "ask_user_question";
|
|
119
|
-
readonly questions: readonly AskQuestion[];
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface TodoInterrupt extends InterruptBase {
|
|
123
|
-
readonly type: "todo";
|
|
124
|
-
readonly action: "create" | "update" | "list" | "get" | "delete" | "clear";
|
|
125
|
-
readonly id?: number;
|
|
126
|
-
readonly subject?: string;
|
|
127
|
-
readonly description?: string;
|
|
128
|
-
readonly status?: "pending" | "in_progress" | "completed" | "deleted";
|
|
129
|
-
readonly activeForm?: string;
|
|
130
|
-
readonly blockedBy?: readonly number[];
|
|
131
|
-
readonly addBlockedBy?: readonly number[];
|
|
132
|
-
readonly removeBlockedBy?: readonly number[];
|
|
133
|
-
readonly owner?: string;
|
|
134
|
-
readonly filterStatus?: string;
|
|
135
|
-
readonly includeDeleted?: boolean;
|
|
87
|
+
/** `rlm_query_batched` only — one prefix set shared by every prompt in the batch. */
|
|
88
|
+
readonly paths?: readonly string[];
|
|
136
89
|
}
|
|
137
90
|
|
|
138
91
|
export interface LoadLibraryInterrupt extends InterruptBase {
|
|
@@ -144,10 +97,6 @@ export interface LoadLibraryInterrupt extends InterruptBase {
|
|
|
144
97
|
export type WorkerInterrupt =
|
|
145
98
|
| PromptInterrupt
|
|
146
99
|
| BatchedPromptInterrupt
|
|
147
|
-
| AdvancePhaseInterrupt
|
|
148
|
-
| SaveArtifactInterrupt
|
|
149
|
-
| AskUserQuestionInterrupt
|
|
150
|
-
| TodoInterrupt
|
|
151
100
|
| LoadLibraryInterrupt;
|
|
152
101
|
|
|
153
102
|
export type WorkerMessage = WorkerResponse | WorkerInterrupt;
|
|
@@ -157,10 +106,6 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
|
|
|
157
106
|
"llm_query_batched",
|
|
158
107
|
"rlm_query",
|
|
159
108
|
"rlm_query_batched",
|
|
160
|
-
"advance_phase",
|
|
161
|
-
"save_artifact",
|
|
162
|
-
"ask_user_question",
|
|
163
|
-
"todo",
|
|
164
109
|
"load_library",
|
|
165
110
|
]));
|
|
166
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}"
|