@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,12 +6,12 @@ This is NOT a security sandbox: __import__ and open are available, so code can i
|
|
|
6
6
|
|
|
7
7
|
Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
|
|
8
8
|
Protocol (worker -> parent): {"id","ok",...result} # response to a request
|
|
9
|
-
{"type":"llm_query"|"llm_query_batched"|"rlm_query"
|
|
10
|
-
"
|
|
9
|
+
{"type":"llm_query"|"llm_query_batched"|"rlm_query"|
|
|
10
|
+
"rlm_query_batched"|"load_library","rid",...}
|
|
11
11
|
# mid-exec helper request
|
|
12
|
-
When sandbox code calls llm_query/rlm_query/
|
|
13
|
-
and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives.
|
|
14
|
-
services the request in-process (it holds API keys).
|
|
12
|
+
When sandbox code calls llm_query/rlm_query/load_library, the worker writes a
|
|
13
|
+
request line and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives.
|
|
14
|
+
The parent services the request in-process (it holds API keys).
|
|
15
15
|
|
|
16
16
|
Requests and replies are decoupled: `_post` writes a request and returns its rid without
|
|
17
17
|
waiting, and replies are parked in `_inbox` keyed by rid until something asks for them. That
|
|
@@ -19,18 +19,12 @@ is what makes `spawn()` / `rlm_await()` / `rlm_await_all()` possible — many re
|
|
|
19
19
|
in flight at once (the parent already services interrupts concurrently), and a task may be
|
|
20
20
|
awaited in a LATER exec than the one that started it.
|
|
21
21
|
"""
|
|
22
|
-
|
|
23
22
|
from __future__ import annotations
|
|
24
23
|
|
|
25
24
|
import argparse
|
|
26
|
-
import fnmatch
|
|
27
|
-
import heapq
|
|
28
25
|
import io
|
|
29
26
|
import json
|
|
30
|
-
import math
|
|
31
27
|
import os
|
|
32
|
-
import pickle
|
|
33
|
-
import re
|
|
34
28
|
import signal
|
|
35
29
|
import sys
|
|
36
30
|
import time
|
|
@@ -38,283 +32,39 @@ import traceback
|
|
|
38
32
|
from contextlib import contextmanager
|
|
39
33
|
from typing import Any
|
|
40
34
|
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# Restricted builtins: enough for real data work, minus the dangerous reflection escapes.
|
|
53
|
-
_SAFE_BUILTINS = {
|
|
54
|
-
name: _builtin(name)
|
|
55
|
-
for name in (
|
|
56
|
-
"abs", "all", "any", "ascii", "bin", "bool", "bytearray", "bytes", "callable",
|
|
57
|
-
"chr", "classmethod", "complex", "dict", "dir", "divmod", "enumerate", "filter",
|
|
58
|
-
"float", "format", "frozenset", "getattr", "hasattr", "hash", "hex", "id", "int",
|
|
59
|
-
"isinstance", "issubclass", "iter", "len", "list", "map", "max", "min", "next",
|
|
60
|
-
"object", "oct", "ord", "pow", "print", "property", "range", "repr", "reversed",
|
|
61
|
-
"round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super",
|
|
62
|
-
"tuple", "type", "vars", "zip", "delattr", "memoryview", "__import__", "__build_class__",
|
|
63
|
-
"Exception", "BaseException", "ValueError", "TypeError", "KeyError", "IndexError",
|
|
64
|
-
"AttributeError", "FileNotFoundError", "OSError", "IOError", "RuntimeError",
|
|
65
|
-
"NameError", "ImportError", "StopIteration", "AssertionError", "NotImplementedError",
|
|
66
|
-
"ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
# `open` is allowed for data work; eval/exec/compile/input/globals/locals are not.
|
|
70
|
-
# When read_only=True (pipeline runs), write modes raise PermissionError via
|
|
71
|
-
# builtins.open, io.open (pathlib), and os.open. Steering, not a security sandbox.
|
|
72
|
-
_WRITE_MODE_CHARS = frozenset("wax+")
|
|
73
|
-
_OS_WRITE_FLAGS = os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_TRUNC
|
|
74
|
-
|
|
75
|
-
_REAL_IO_OPEN = io.open
|
|
76
|
-
_REAL_OS_OPEN = os.open
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _install_read_only_guards():
|
|
80
|
-
"""Route every common file-open path through the read-only check.
|
|
81
|
-
|
|
82
|
-
Steering, not a sandbox: closes builtins.open, io.open (hence pathlib), and
|
|
83
|
-
os.open. A determined model can still reach the filesystem via ctypes or a
|
|
84
|
-
subprocess — the goal is that ACCIDENTAL writes cannot pass silently.
|
|
85
|
-
Worker-internal I/O keeps using _REAL_IO_OPEN / _REAL_OS_OPEN.
|
|
86
|
-
"""
|
|
87
|
-
def guarded_io_open(file, mode="r", *args, **kwargs):
|
|
88
|
-
if _WRITE_MODE_CHARS & set(str(mode)):
|
|
89
|
-
raise PermissionError(
|
|
90
|
-
f"read-only RLM run: refusing to open {file!r} with mode {mode!r}. "
|
|
91
|
-
"This pipeline produces a plan; file changes go through the host edit tool."
|
|
92
|
-
)
|
|
93
|
-
return _REAL_IO_OPEN(file, mode, *args, **kwargs)
|
|
94
|
-
|
|
95
|
-
def guarded_os_open(path, flags, *args, **kwargs):
|
|
96
|
-
if flags & _OS_WRITE_FLAGS:
|
|
97
|
-
raise PermissionError(
|
|
98
|
-
f"read-only RLM run: refusing os.open({path!r}) with write flags."
|
|
99
|
-
)
|
|
100
|
-
return _REAL_OS_OPEN(path, flags, *args, **kwargs)
|
|
101
|
-
|
|
102
|
-
io.open = guarded_io_open
|
|
103
|
-
os.open = guarded_os_open
|
|
104
|
-
return guarded_io_open
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# _builtin()'s getattr(..., None) fallback would silently inject None for a name this
|
|
108
|
-
# interpreter lacks, surfacing much later as "'NoneType' object is not callable" inside model
|
|
109
|
-
# code. Fail at startup instead. Note "None" is legitimately None, and the block-list below is
|
|
110
|
-
# deliberate — which is why this check runs BEFORE it.
|
|
111
|
-
_MISSING = sorted(name for name, value in _SAFE_BUILTINS.items() if value is None and name != "None")
|
|
112
|
-
if _MISSING:
|
|
113
|
-
raise RuntimeError(f"unsupported Python interpreter: missing builtins {_MISSING}")
|
|
114
|
-
|
|
115
|
-
def _blocked_builtin(name: str):
|
|
116
|
-
"""Bind a disabled builtin to a callable that explains itself.
|
|
117
|
-
|
|
118
|
-
Binding these to None made `eval(...)` fail with a bare "'NoneType' object is not callable",
|
|
119
|
-
which reads as a broken sandbox rather than a deliberate block: an audit session spent six
|
|
120
|
-
execs on it and filed a phantom "namespace corruption" bug. Saying so at the point of failure
|
|
121
|
-
fixes it for every model without spending native-prompt budget on a rule most runs never hit.
|
|
122
|
-
"""
|
|
123
|
-
def blocked(*_args, **_kwargs):
|
|
124
|
-
raise PermissionError(
|
|
125
|
-
f"{name}() is disabled in the RLM sandbox by design — it is not missing and the "
|
|
126
|
-
"namespace is not corrupt. Names are already bound, so reference them directly; "
|
|
127
|
-
"inspect `context` with search() / grep_context() / outline()."
|
|
128
|
-
)
|
|
129
|
-
blocked.__name__ = name
|
|
130
|
-
return blocked
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
# Blocked on purpose (NOT missing) — see _blocked_builtin. The _MISSING check above runs first,
|
|
134
|
-
# so a genuinely absent builtin is still a startup failure rather than a silent None.
|
|
135
|
-
for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
|
|
136
|
-
_SAFE_BUILTINS[_blocked] = _blocked_builtin(_blocked)
|
|
137
|
-
|
|
138
|
-
RESERVED = frozenset(
|
|
139
|
-
{
|
|
140
|
-
"llm_query", "llm_query_batched", "llm_query_chunked",
|
|
141
|
-
"rlm_query", "rlm_query_batched",
|
|
142
|
-
"spawn", "rlm_await", "rlm_await_all",
|
|
143
|
-
"map_files", "llm_map_reduce",
|
|
144
|
-
"search", "grep_context", "outline",
|
|
145
|
-
"advance_phase", "save_artifact",
|
|
146
|
-
"ask_user_question", "todo",
|
|
147
|
-
"load_library",
|
|
148
|
-
"SHOW_VARS", "answer", "context",
|
|
149
|
-
}
|
|
35
|
+
# Sibling modules: Python puts this file's directory on sys.path[0], so these resolve without
|
|
36
|
+
# any packaging step. They ship inside `src/` like everything else.
|
|
37
|
+
from guards import (
|
|
38
|
+
_SAFE_BUILTINS,
|
|
39
|
+
_CONTEXT_NAME,
|
|
40
|
+
_send,
|
|
41
|
+
_stall_alarm,
|
|
42
|
+
_surfaced_error,
|
|
43
|
+
RESERVED,
|
|
44
|
+
REAL_STDERR as _REAL_STDERR,
|
|
45
|
+
REAL_STDIN as _REAL_STDIN,
|
|
150
46
|
)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if end < n:
|
|
172
|
-
nl = text.rfind("\n", start, end)
|
|
173
|
-
if nl > start:
|
|
174
|
-
end = nl + 1
|
|
175
|
-
chunks.append(text[start:end])
|
|
176
|
-
start = end
|
|
177
|
-
return chunks
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
# ---- deterministic retrieval over `context` -----------------------------------------------
|
|
181
|
-
#
|
|
182
|
-
# The RLM paper's trajectories retrieve by having the root model hand-write regex over the
|
|
183
|
-
# context (App. E.1). Frontier models do that well; small/fast models guess keywords badly and
|
|
184
|
-
# the first decomposition attempt disproportionately decides the outcome (paper §5, Fig. 4a).
|
|
185
|
-
# These primitives make retrieval deterministic and token-free: no sub-LLM call, no root tokens
|
|
186
|
-
# spent on printed file bodies — the model gets ranked pointers and decides what to delegate.
|
|
187
|
-
|
|
188
|
-
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
189
|
-
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge load_library() cannot exhaust worker memory
|
|
190
|
-
_SNIPPET_CHARS = 400
|
|
191
|
-
_GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
|
|
192
|
-
_BM25_K1 = 1.2
|
|
193
|
-
_BM25_B = 0.75
|
|
194
|
-
|
|
195
|
-
_TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
|
|
196
|
-
_CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
197
|
-
|
|
198
|
-
# Definition-ish lines across the languages this plugin is likely to meet. Deliberately
|
|
199
|
-
# lexical: an outline is an orientation aid, not a parse tree.
|
|
200
|
-
_OUTLINE_LINE = re.compile(
|
|
201
|
-
r"^\s*(?:"
|
|
202
|
-
r"(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|namespace)\s+\w+"
|
|
203
|
-
r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:function|\(|<)"
|
|
204
|
-
r"|(?:pub\s+)?(?:async\s+)?fn\s+\w+"
|
|
205
|
-
r"|def\s+\w+|class\s+\w+"
|
|
206
|
-
r"|func\s+\w+"
|
|
207
|
-
r"|#{1,4}\s+\S"
|
|
208
|
-
r")"
|
|
47
|
+
from retrieval import (
|
|
48
|
+
_Bm25Index,
|
|
49
|
+
_chunk_text,
|
|
50
|
+
_context_entries,
|
|
51
|
+
_CHUNK_HEADER_OVERHEAD,
|
|
52
|
+
_MAX_CHUNK_BATCH,
|
|
53
|
+
_MAX_CHUNKS,
|
|
54
|
+
_NUDGE_CHARS,
|
|
55
|
+
grep_context as _grep_context_impl,
|
|
56
|
+
outline as _outline_impl,
|
|
57
|
+
search as _search_impl,
|
|
58
|
+
)
|
|
59
|
+
from tasks import (
|
|
60
|
+
_clean_paths,
|
|
61
|
+
_reduce_batch,
|
|
62
|
+
_reduce_chunked,
|
|
63
|
+
_reduce_map_files,
|
|
64
|
+
_reduce_one,
|
|
65
|
+
_spawnable,
|
|
66
|
+
Task,
|
|
209
67
|
)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def _tokenize(text: str) -> list[str]:
|
|
213
|
-
"""Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
|
|
214
|
-
out: list[str] = []
|
|
215
|
-
for raw in _TOKEN_SPLIT.split(text):
|
|
216
|
-
if not raw:
|
|
217
|
-
continue
|
|
218
|
-
lowered = raw.lower()
|
|
219
|
-
out.append(lowered)
|
|
220
|
-
if len(raw) > 3:
|
|
221
|
-
parts = _CAMEL_SPLIT.split(raw)
|
|
222
|
-
if len(parts) > 1:
|
|
223
|
-
for part in parts:
|
|
224
|
-
piece = part.lower()
|
|
225
|
-
if piece and piece != lowered:
|
|
226
|
-
out.append(piece)
|
|
227
|
-
return out
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def _context_entries(context: Any) -> list[tuple[str, str]]:
|
|
231
|
-
"""(path, content) pairs for either context shape: list[dict] bundles or a raw string."""
|
|
232
|
-
if isinstance(context, str):
|
|
233
|
-
return [("<context>", context)]
|
|
234
|
-
if not isinstance(context, list):
|
|
235
|
-
return []
|
|
236
|
-
out: list[tuple[str, str]] = []
|
|
237
|
-
for i, item in enumerate(context):
|
|
238
|
-
if isinstance(item, dict):
|
|
239
|
-
content = item.get("content", "")
|
|
240
|
-
out.append((
|
|
241
|
-
str(item.get("path", f"<context[{i}]>")),
|
|
242
|
-
content if isinstance(content, str) else str(content),
|
|
243
|
-
))
|
|
244
|
-
elif isinstance(item, str):
|
|
245
|
-
out.append((f"<context[{i}]>", item))
|
|
246
|
-
return out
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
class _Bm25Index:
|
|
250
|
-
"""Okapi BM25 over fixed-line windows of `context`. Built lazily, discarded on change."""
|
|
251
|
-
|
|
252
|
-
__slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
|
|
253
|
-
|
|
254
|
-
def __init__(self, entries: list[tuple[str, str]]) -> None:
|
|
255
|
-
self.paths: list[str] = []
|
|
256
|
-
self.starts: list[int] = []
|
|
257
|
-
self.texts: list[str] = []
|
|
258
|
-
self.postings: dict[str, list[tuple[int, int]]] = {}
|
|
259
|
-
self.doc_len: list[int] = []
|
|
260
|
-
self.truncated = False
|
|
261
|
-
|
|
262
|
-
for path, content in entries:
|
|
263
|
-
if not content:
|
|
264
|
-
continue
|
|
265
|
-
lines = content.split("\n")
|
|
266
|
-
for start in range(0, len(lines), _INDEX_WINDOW_LINES):
|
|
267
|
-
if len(self.texts) >= _INDEX_MAX_WINDOWS:
|
|
268
|
-
self.truncated = True
|
|
269
|
-
break
|
|
270
|
-
window = "\n".join(lines[start:start + _INDEX_WINDOW_LINES])
|
|
271
|
-
idx = len(self.texts)
|
|
272
|
-
self.paths.append(path)
|
|
273
|
-
self.starts.append(start + 1)
|
|
274
|
-
self.texts.append(window)
|
|
275
|
-
terms = _tokenize(window)
|
|
276
|
-
self.doc_len.append(len(terms))
|
|
277
|
-
freq: dict[str, int] = {}
|
|
278
|
-
for term in terms:
|
|
279
|
-
freq[term] = freq.get(term, 0) + 1
|
|
280
|
-
for term, tf in freq.items():
|
|
281
|
-
self.postings.setdefault(term, []).append((idx, tf))
|
|
282
|
-
if self.truncated:
|
|
283
|
-
break
|
|
284
|
-
|
|
285
|
-
total = len(self.doc_len)
|
|
286
|
-
self.avg_len = (sum(self.doc_len) / total) if total else 1.0
|
|
287
|
-
|
|
288
|
-
def query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
|
|
289
|
-
total = len(self.texts)
|
|
290
|
-
if total == 0:
|
|
291
|
-
return []
|
|
292
|
-
scores: dict[int, float] = {}
|
|
293
|
-
for term in set(terms):
|
|
294
|
-
posting = self.postings.get(term)
|
|
295
|
-
if not posting:
|
|
296
|
-
continue
|
|
297
|
-
df = len(posting)
|
|
298
|
-
idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
|
299
|
-
for idx, tf in posting:
|
|
300
|
-
norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
|
|
301
|
-
scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
|
|
302
|
-
if path_glob:
|
|
303
|
-
scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
|
|
304
|
-
if not scores:
|
|
305
|
-
return []
|
|
306
|
-
top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
|
|
307
|
-
out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
|
|
308
|
-
for i, (idx, score) in enumerate(top):
|
|
309
|
-
text = self.texts[idx]
|
|
310
|
-
out[i] = {
|
|
311
|
-
"path": self.paths[idx],
|
|
312
|
-
"line": self.starts[idx],
|
|
313
|
-
"score": round(score, 3),
|
|
314
|
-
"snippet": text[:_SNIPPET_CHARS],
|
|
315
|
-
}
|
|
316
|
-
return out
|
|
317
|
-
|
|
318
68
|
|
|
319
69
|
class _AnswerDict(dict):
|
|
320
70
|
"""`answer` dict; flipping `ready` True captures the final answer for the parent."""
|
|
@@ -331,157 +81,6 @@ class _AnswerDict(dict):
|
|
|
331
81
|
self._on_ready(self.get("content", ""))
|
|
332
82
|
|
|
333
83
|
|
|
334
|
-
def _send(obj: dict[str, Any]) -> None:
|
|
335
|
-
_REAL_STDOUT.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
336
|
-
_REAL_STDOUT.flush()
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
class _StallTimeout(Exception):
|
|
340
|
-
"""No frame from the host while a sub-call was pending."""
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
@contextmanager
|
|
344
|
-
def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
|
|
345
|
-
"""Swap the per-cell alarm for a stall alarm while blocked on the parent.
|
|
346
|
-
|
|
347
|
-
Sub-LLM latency is network time, not cell compute time, so it must not count against the
|
|
348
|
-
```repl``` block timeout — but an unbounded wait is exactly how a lost reply turns into a
|
|
349
|
-
dead session. The yielded `rearm()` restarts the stall clock on every frame, so a healthy
|
|
350
|
-
long-running child never trips it.
|
|
351
|
-
"""
|
|
352
|
-
use = hasattr(signal, "SIGALRM")
|
|
353
|
-
remaining = signal.getitimer(signal.ITIMER_REAL)[0] if (use and exec_timeout_s > 0) else 0.0
|
|
354
|
-
|
|
355
|
-
def _fire(signum, frame): # noqa: ARG001
|
|
356
|
-
raise _StallTimeout(
|
|
357
|
-
f"sub-call stalled — no reply from the host for {stall_timeout_s:g}s "
|
|
358
|
-
"(the task may still be running; rlm_await it again in a later block)"
|
|
359
|
-
)
|
|
360
|
-
|
|
361
|
-
old = signal.signal(signal.SIGALRM, _fire) if use else None
|
|
362
|
-
|
|
363
|
-
def rearm() -> None:
|
|
364
|
-
if use and stall_timeout_s > 0:
|
|
365
|
-
signal.setitimer(signal.ITIMER_REAL, stall_timeout_s)
|
|
366
|
-
|
|
367
|
-
rearm()
|
|
368
|
-
try:
|
|
369
|
-
yield rearm
|
|
370
|
-
finally:
|
|
371
|
-
if use:
|
|
372
|
-
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
373
|
-
if old is not None:
|
|
374
|
-
signal.signal(signal.SIGALRM, old)
|
|
375
|
-
if remaining > 0: # restore the cell's remaining budget
|
|
376
|
-
signal.setitimer(signal.ITIMER_REAL, remaining)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
def _surfaced_error(message: str) -> str:
|
|
380
|
-
"""The "Error: …" contract value, ALSO written to the cell's stderr.
|
|
381
|
-
|
|
382
|
-
A spawn/await misuse whose only trace is the returned value reads to the model as a random
|
|
383
|
-
string much later — which is exactly how `tasks.items()` blew up on a str.
|
|
384
|
-
"""
|
|
385
|
-
print(f"[rlm] {message}", file=sys.stderr)
|
|
386
|
-
return f"Error: {message}"
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
# ---- reply reducers: raw parent replies -> the value the scaffold fn returns ----------------
|
|
390
|
-
# One reducer per result shape, shared by the sync helpers and their spawned equivalents.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def _reduce_one(replies: list[dict[str, Any]]) -> str:
|
|
394
|
-
r = replies[0]
|
|
395
|
-
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def _reduce_batch(n: int):
|
|
399
|
-
"""Reducer for a single *_query_batched reply of n prompts."""
|
|
400
|
-
def reduce(replies: list[dict[str, Any]]) -> list[str]:
|
|
401
|
-
r = replies[0]
|
|
402
|
-
if r.get("error"):
|
|
403
|
-
return [f"Error: {r['error']}"] * n
|
|
404
|
-
out = r.get("responses")
|
|
405
|
-
if not isinstance(out, list) or len(out) != n:
|
|
406
|
-
return ["Error: malformed batched response"] * n
|
|
407
|
-
return [s if isinstance(s, str) else f"Error: {s}" for s in out]
|
|
408
|
-
return reduce
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def _reduce_chunked(sizes: list[int]):
|
|
412
|
-
"""Concatenate several llm_query_batched replies back into one flat chunk list."""
|
|
413
|
-
per = [_reduce_batch(n) for n in sizes]
|
|
414
|
-
|
|
415
|
-
def reduce(replies: list[dict[str, Any]]) -> list[str]:
|
|
416
|
-
out: list[str] = []
|
|
417
|
-
for red, rep in zip(per, replies):
|
|
418
|
-
out.extend(red([rep]))
|
|
419
|
-
return out
|
|
420
|
-
return reduce
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def _reduce_map_files(sizes: list[int], spans: list[tuple[str, int]]):
|
|
424
|
-
"""Flatten the batch replies (same as chunked), then regroup them per path.
|
|
425
|
-
|
|
426
|
-
A file larger than the per-prompt budget contributed several requests; its answers rejoin
|
|
427
|
-
in order, which is what makes map_files a {path: answer} dict rather than a flat list.
|
|
428
|
-
"""
|
|
429
|
-
flatten = _reduce_chunked(sizes)
|
|
430
|
-
|
|
431
|
-
def reduce(replies: list[dict[str, Any]]) -> dict[str, str]:
|
|
432
|
-
responses = flatten(replies)
|
|
433
|
-
out: dict[str, str] = {}
|
|
434
|
-
cursor = 0
|
|
435
|
-
for path, count in spans:
|
|
436
|
-
part = responses[cursor:cursor + count]
|
|
437
|
-
cursor += count
|
|
438
|
-
out[path] = part[0] if count == 1 and part else "\n\n".join(part)
|
|
439
|
-
return out
|
|
440
|
-
return reduce
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
def _spawnable(name: str):
|
|
444
|
-
"""Tag a sync scaffold fn with the request kind spawn() should route it to."""
|
|
445
|
-
def mark(fn):
|
|
446
|
-
fn._rlm_name = name
|
|
447
|
-
return fn
|
|
448
|
-
return mark
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
class Task:
|
|
452
|
-
"""Handle for parent-side work in flight, returned by spawn().
|
|
453
|
-
|
|
454
|
-
Opaque to model code apart from `done` and repr. A Task may be awaited in a later
|
|
455
|
-
```repl``` block than the one that created it.
|
|
456
|
-
"""
|
|
457
|
-
|
|
458
|
-
__slots__ = ("kind", "label", "_worker", "_rids", "_reduce", "_value", "_settled")
|
|
459
|
-
|
|
460
|
-
def __init__(self, worker: "Worker", kind: str, rids, reduce, label: str = ""):
|
|
461
|
-
self.kind = kind
|
|
462
|
-
self.label = label
|
|
463
|
-
self._worker = worker
|
|
464
|
-
self._rids = tuple(rids)
|
|
465
|
-
self._reduce = reduce
|
|
466
|
-
self._value: Any = None
|
|
467
|
-
self._settled = False
|
|
468
|
-
|
|
469
|
-
@staticmethod
|
|
470
|
-
def resolved(worker: "Worker", kind: str, value: Any, label: str = "") -> "Task":
|
|
471
|
-
"""A Task that never hit the wire — validation errors and empty inputs."""
|
|
472
|
-
task = Task(worker, kind, (), lambda _replies: value, label)
|
|
473
|
-
task._value = value
|
|
474
|
-
task._settled = True
|
|
475
|
-
return task
|
|
476
|
-
|
|
477
|
-
@property
|
|
478
|
-
def done(self) -> bool:
|
|
479
|
-
"""True once every reply has landed — awaiting will not block."""
|
|
480
|
-
return self._settled or all(r in self._worker.inbox for r in self._rids)
|
|
481
|
-
|
|
482
|
-
def __repr__(self) -> str:
|
|
483
|
-
return f"<Task {self.kind} {'done' if self.done else 'running'} {self.label}>"
|
|
484
|
-
|
|
485
84
|
|
|
486
85
|
class Worker:
|
|
487
86
|
def __init__(
|
|
@@ -489,13 +88,11 @@ class Worker:
|
|
|
489
88
|
depth: int,
|
|
490
89
|
exec_timeout_s: float,
|
|
491
90
|
max_prompt_chars: int,
|
|
492
|
-
read_only: bool = False,
|
|
493
91
|
await_timeout_s: float = 600.0,
|
|
494
92
|
):
|
|
495
93
|
self.depth = depth
|
|
496
94
|
self.exec_timeout_s = exec_timeout_s
|
|
497
95
|
self.max_prompt_chars = max_prompt_chars
|
|
498
|
-
self.read_only = read_only
|
|
499
96
|
self.await_timeout_s = await_timeout_s
|
|
500
97
|
self._rid = 0
|
|
501
98
|
self._final_answer: str | None = None
|
|
@@ -505,7 +102,7 @@ class Worker:
|
|
|
505
102
|
# rlm_await, which is strictly worse than the memory.
|
|
506
103
|
self.inbox: dict[str, dict[str, Any]] = {}
|
|
507
104
|
self._inflight: set[str] = set()
|
|
508
|
-
# Requests (exec/
|
|
105
|
+
# Requests (exec/shutdown) that arrived mid-exec; main() replays them.
|
|
509
106
|
self._deferred: list[Any] = []
|
|
510
107
|
# True only while spawn() runs a builder — marks requests that may outlive this exec.
|
|
511
108
|
self._detached = False
|
|
@@ -514,10 +111,7 @@ class Worker:
|
|
|
514
111
|
|
|
515
112
|
def _setup(self) -> None:
|
|
516
113
|
builtins = _SAFE_BUILTINS.copy()
|
|
517
|
-
|
|
518
|
-
builtins["open"] = _install_read_only_guards()
|
|
519
|
-
else:
|
|
520
|
-
builtins["open"] = open
|
|
114
|
+
builtins["open"] = open
|
|
521
115
|
self.ns = {"__builtins__": builtins, "__name__": "__main__"}
|
|
522
116
|
self._context_payload: Any | None = None # pristine restore for the single `context` var
|
|
523
117
|
self._nudged: set[str] = set()
|
|
@@ -550,10 +144,6 @@ class Worker:
|
|
|
550
144
|
ns["answers"] = {}
|
|
551
145
|
if not isinstance(ns.get("plan"), dict):
|
|
552
146
|
ns["plan"] = {}
|
|
553
|
-
ns["advance_phase"] = self._advance_phase
|
|
554
|
-
ns["save_artifact"] = self._save_artifact
|
|
555
|
-
ns["ask_user_question"] = self._ask_user_question
|
|
556
|
-
ns["todo"] = self._todo
|
|
557
147
|
ns["load_library"] = self._load_library
|
|
558
148
|
ns["SHOW_VARS"] = self._show_vars
|
|
559
149
|
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
@@ -638,7 +228,7 @@ class Worker:
|
|
|
638
228
|
return True
|
|
639
229
|
if self.park_reply(msg):
|
|
640
230
|
return True
|
|
641
|
-
# A request (exec/
|
|
231
|
+
# A request (exec/shutdown) arriving mid-exec: main() replays it.
|
|
642
232
|
self._deferred.append(msg)
|
|
643
233
|
return True
|
|
644
234
|
|
|
@@ -667,17 +257,21 @@ class Worker:
|
|
|
667
257
|
|
|
668
258
|
# ---- spawn / await ---------------------------------------------------------------------
|
|
669
259
|
|
|
670
|
-
def _start_prompt(self, kind: str, prompt, model) -> Task:
|
|
260
|
+
def _start_prompt(self, kind: str, prompt, model, paths=None) -> Task:
|
|
671
261
|
text = str(prompt)
|
|
672
262
|
# A sub-LLM asked nothing answers something: the confabulation then sits in `answers`
|
|
673
263
|
# looking exactly like data. Refuse instead of spending a call on it.
|
|
674
264
|
if not text.strip():
|
|
675
265
|
return Task.resolved(self, kind, _surfaced_error(
|
|
676
266
|
f"{kind}() got an empty prompt — a sub-LLM would confabulate an answer to nothing"))
|
|
677
|
-
|
|
267
|
+
payload: dict[str, Any] = {"prompt": text, "model": model}
|
|
268
|
+
clean = _clean_paths(paths)
|
|
269
|
+
if clean is not None:
|
|
270
|
+
payload["paths"] = clean
|
|
271
|
+
rid = self._post(kind, payload)
|
|
678
272
|
return Task(self, kind, (rid,), _reduce_one, text[:40])
|
|
679
273
|
|
|
680
|
-
def _start_prompts(self, kind: str, prompts, model) -> Task:
|
|
274
|
+
def _start_prompts(self, kind: str, prompts, model, paths=None) -> Task:
|
|
681
275
|
prompts = [str(p) for p in prompts]
|
|
682
276
|
if not prompts:
|
|
683
277
|
return Task.resolved(self, kind, [])
|
|
@@ -686,20 +280,26 @@ class Worker:
|
|
|
686
280
|
return Task.resolved(self, kind, [
|
|
687
281
|
_surfaced_error(f"{kind}() got only empty prompts")
|
|
688
282
|
] * len(prompts))
|
|
689
|
-
|
|
283
|
+
payload: dict[str, Any] = {"prompts": prompts, "model": model}
|
|
284
|
+
# One prefix set for the whole batch: a per-prompt aligned list is an API nobody uses
|
|
285
|
+
# correctly, and every prompt in a batch is asking about the same slice anyway.
|
|
286
|
+
clean = _clean_paths(paths)
|
|
287
|
+
if clean is not None:
|
|
288
|
+
payload["paths"] = clean
|
|
289
|
+
rid = self._post(kind, payload)
|
|
690
290
|
return Task(self, kind, (rid,), _reduce_batch(len(prompts)), f"×{len(prompts)}")
|
|
691
291
|
|
|
692
292
|
def _start_llm_query(self, prompt, model: str | None = None) -> Task:
|
|
693
293
|
return self._start_prompt("llm_query", prompt, model)
|
|
694
294
|
|
|
695
|
-
def _start_rlm_query(self, prompt, model: str | None = None) -> Task:
|
|
696
|
-
return self._start_prompt("rlm_query", prompt, model)
|
|
295
|
+
def _start_rlm_query(self, prompt, model: str | None = None, paths=None) -> Task:
|
|
296
|
+
return self._start_prompt("rlm_query", prompt, model, paths)
|
|
697
297
|
|
|
698
298
|
def _start_llm_query_batched(self, prompts, model: str | None = None) -> Task:
|
|
699
299
|
return self._start_prompts("llm_query_batched", prompts, model)
|
|
700
300
|
|
|
701
|
-
def _start_rlm_query_batched(self, prompts, model: str | None = None) -> Task:
|
|
702
|
-
return self._start_prompts("rlm_query_batched", prompts, model)
|
|
301
|
+
def _start_rlm_query_batched(self, prompts, model: str | None = None, paths=None) -> Task:
|
|
302
|
+
return self._start_prompts("rlm_query_batched", prompts, model, paths)
|
|
703
303
|
|
|
704
304
|
def _start_llm_query_chunked(self, text, prompt: str, model: str | None = None) -> Task:
|
|
705
305
|
"""Split oversized text into cap-sized chunks and post EVERY batch at once.
|
|
@@ -825,17 +425,9 @@ class Worker:
|
|
|
825
425
|
def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
|
|
826
426
|
"""Rank `context` windows against a natural-language query (BM25).
|
|
827
427
|
|
|
828
|
-
Returns [{path, line, score, snippet}] — pointers, not bodies.
|
|
829
|
-
named files out of `context` and delegating them to llm_query / map_files.
|
|
428
|
+
Returns [{path, line, score, snippet}] — pointers, not bodies.
|
|
830
429
|
"""
|
|
831
|
-
|
|
832
|
-
if not terms:
|
|
833
|
-
return []
|
|
834
|
-
try:
|
|
835
|
-
limit = max(1, min(int(k), 100))
|
|
836
|
-
except (TypeError, ValueError):
|
|
837
|
-
limit = 10
|
|
838
|
-
return self._get_index().query(terms, limit, path_glob)
|
|
430
|
+
return _search_impl(self._entries(), self._get_index(), query, k, path_glob)
|
|
839
431
|
|
|
840
432
|
def _grep_context(
|
|
841
433
|
self,
|
|
@@ -845,71 +437,12 @@ class Worker:
|
|
|
845
437
|
before: int = 0,
|
|
846
438
|
after: int = 0,
|
|
847
439
|
) -> dict[str, Any]:
|
|
848
|
-
"""Regex over `context`, capped and shaped.
|
|
849
|
-
|
|
850
|
-
Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
|
|
851
|
-
`counts` is complete even when `hits` is capped, so a wide pattern reports its shape
|
|
852
|
-
instead of flooding stdout.
|
|
853
|
-
"""
|
|
854
|
-
try:
|
|
855
|
-
rx = re.compile(pattern)
|
|
856
|
-
except re.error as e:
|
|
857
|
-
return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
|
|
858
|
-
try:
|
|
859
|
-
limit = max(1, min(int(k), _GREP_HARD_CAP))
|
|
860
|
-
except (TypeError, ValueError):
|
|
861
|
-
limit = 50
|
|
862
|
-
pad_before = max(0, min(int(before or 0), 10))
|
|
863
|
-
pad_after = max(0, min(int(after or 0), 10))
|
|
864
|
-
|
|
865
|
-
hits: list[dict[str, Any]] = []
|
|
866
|
-
counts: dict[str, int] = {}
|
|
867
|
-
total = 0
|
|
868
|
-
for path, content in self._entries():
|
|
869
|
-
if path_glob and not fnmatch.fnmatch(path, path_glob):
|
|
870
|
-
continue
|
|
871
|
-
if not rx.search(content):
|
|
872
|
-
continue
|
|
873
|
-
lines = content.split("\n")
|
|
874
|
-
for i, line in enumerate(lines):
|
|
875
|
-
if not rx.search(line):
|
|
876
|
-
continue
|
|
877
|
-
total += 1
|
|
878
|
-
counts[path] = counts.get(path, 0) + 1
|
|
879
|
-
if len(hits) >= limit:
|
|
880
|
-
continue
|
|
881
|
-
lo = max(0, i - pad_before)
|
|
882
|
-
hi = min(len(lines), i + pad_after + 1)
|
|
883
|
-
hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
|
|
884
|
-
return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
|
|
440
|
+
"""Regex over `context`, capped and shaped. See retrieval.grep_context."""
|
|
441
|
+
return _grep_context_impl(self._entries(), pattern, k, path_glob, before, after)
|
|
885
442
|
|
|
886
443
|
def _outline(self, path: str) -> str:
|
|
887
|
-
"""Definition/heading skeleton of one context file
|
|
888
|
-
|
|
889
|
-
`path` matches exactly, then by suffix, then as a glob.
|
|
890
|
-
"""
|
|
891
|
-
target = str(path)
|
|
892
|
-
entries = self._entries()
|
|
893
|
-
content: str | None = None
|
|
894
|
-
for p, c in entries:
|
|
895
|
-
if p == target:
|
|
896
|
-
content = c
|
|
897
|
-
break
|
|
898
|
-
if content is None:
|
|
899
|
-
for p, c in entries:
|
|
900
|
-
if p.endswith(target) or fnmatch.fnmatch(p, target):
|
|
901
|
-
content = c
|
|
902
|
-
target = p
|
|
903
|
-
break
|
|
904
|
-
if content is None:
|
|
905
|
-
return f"Error: no context file matching {path!r} — use search() or list paths from context"
|
|
906
|
-
out: list[str] = [f"# {target}"]
|
|
907
|
-
for i, line in enumerate(content.split("\n")):
|
|
908
|
-
if _OUTLINE_LINE.match(line):
|
|
909
|
-
out.append(f"{i + 1}: {line.strip()[:160]}")
|
|
910
|
-
if len(out) == 1:
|
|
911
|
-
return f"# {target}\n(no definition-like lines found)"
|
|
912
|
-
return "\n".join(out)
|
|
444
|
+
"""Definition/heading skeleton of one context file. See retrieval.outline."""
|
|
445
|
+
return _outline_impl(self._entries(), path)
|
|
913
446
|
|
|
914
447
|
# ---- one-line delegation (structural: orchestrating must be easier than solving) -------
|
|
915
448
|
|
|
@@ -1020,66 +553,8 @@ class Worker:
|
|
|
1020
553
|
return self._await_task(self._start_llm_query_chunked(text, prompt, model))
|
|
1021
554
|
|
|
1022
555
|
@_spawnable("rlm_query")
|
|
1023
|
-
def _rlm_query(self, prompt: str, model: str | None = None) -> str:
|
|
1024
|
-
return self._await_task(self._start_rlm_query(prompt, model))
|
|
1025
|
-
|
|
1026
|
-
def _ask_user_question(self, questions: list[dict]) -> list[dict]:
|
|
1027
|
-
"""Present structured questions to the user; blocks until answered.
|
|
1028
|
-
|
|
1029
|
-
Returns a list of {question, selected, custom?} dicts.
|
|
1030
|
-
Each dict has: question (str), selected (list[str]), custom (str|None).
|
|
1031
|
-
Only valid at root depth; sub-RLM calls return an error answer.
|
|
1032
|
-
"""
|
|
1033
|
-
if self.depth > 0:
|
|
1034
|
-
qlist = questions if isinstance(questions, list) else []
|
|
1035
|
-
return [
|
|
1036
|
-
{"question": str(q.get("question", "")) if isinstance(q, dict) else "",
|
|
1037
|
-
"selected": [],
|
|
1038
|
-
"custom": "Error: ask_user_question not available inside rlm_query sub-calls"}
|
|
1039
|
-
for q in qlist
|
|
1040
|
-
] or [{"question": "", "selected": [],
|
|
1041
|
-
"custom": "Error: ask_user_question not available inside rlm_query sub-calls"}]
|
|
1042
|
-
if not isinstance(questions, list) or not questions:
|
|
1043
|
-
return [{"question": "", "selected": [], "custom": "Error: questions must be a non-empty list"}]
|
|
1044
|
-
cleaned = []
|
|
1045
|
-
for q in questions:
|
|
1046
|
-
if not isinstance(q, dict) or "question" not in q or "options" not in q:
|
|
1047
|
-
return [{"question": "", "selected": [], "custom": "Error: each question needs 'question', 'header', 'options'"}]
|
|
1048
|
-
opts = q.get("options")
|
|
1049
|
-
if not isinstance(opts, list):
|
|
1050
|
-
return [{"question": str(q.get("question", "")), "selected": [], "custom": "Error: options must be a list"}]
|
|
1051
|
-
cleaned_opts = []
|
|
1052
|
-
for o in opts:
|
|
1053
|
-
if not isinstance(o, dict) or "label" not in o:
|
|
1054
|
-
return [{"question": str(q.get("question", "")), "selected": [], "custom": "Error: each option needs 'label'"}]
|
|
1055
|
-
item = {"label": str(o["label"]), "description": str(o.get("description", ""))}
|
|
1056
|
-
if "preview" in o:
|
|
1057
|
-
item["preview"] = str(o["preview"])
|
|
1058
|
-
cleaned_opts.append(item)
|
|
1059
|
-
cleaned.append({
|
|
1060
|
-
"question": str(q["question"]),
|
|
1061
|
-
"header": str(q.get("header", "Q")),
|
|
1062
|
-
"multiSelect": bool(q.get("multiSelect", False)),
|
|
1063
|
-
"options": cleaned_opts,
|
|
1064
|
-
})
|
|
1065
|
-
r = self._rpc("ask_user_question", {"questions": cleaned})
|
|
1066
|
-
if r.get("error"):
|
|
1067
|
-
return [{"question": q["question"], "selected": [], "custom": f"Error: {r['error']}"} for q in cleaned]
|
|
1068
|
-
answers = r.get("answers", [])
|
|
1069
|
-
return answers if isinstance(answers, list) else []
|
|
1070
|
-
|
|
1071
|
-
def _todo(self, action: str, **kwargs) -> str:
|
|
1072
|
-
"""Manage the run's task list.
|
|
1073
|
-
|
|
1074
|
-
action: "create" | "update" | "list" | "get" | "delete" | "clear"
|
|
1075
|
-
kwargs: id, subject, description, status, activeForm, blockedBy, owner, filterStatus
|
|
1076
|
-
Returns a human-readable string result.
|
|
1077
|
-
"""
|
|
1078
|
-
params = {k: v for k, v in kwargs.items() if v is not None}
|
|
1079
|
-
r = self._rpc("todo", {"action": str(action), **params})
|
|
1080
|
-
if r.get("error"):
|
|
1081
|
-
return f"Error: {r['error']}"
|
|
1082
|
-
return str(r.get("response", "ok"))
|
|
556
|
+
def _rlm_query(self, prompt: str, model: str | None = None, paths=None) -> str:
|
|
557
|
+
return self._await_task(self._start_rlm_query(prompt, model, paths))
|
|
1083
558
|
|
|
1084
559
|
def _load_library(self, source: str) -> dict[str, Any] | str:
|
|
1085
560
|
"""Pack an external dir/file/git-URL on the host and append it into `context`.
|
|
@@ -1113,8 +588,7 @@ class Worker:
|
|
|
1113
588
|
if not isinstance(path, str):
|
|
1114
589
|
return "Error: malformed load_library reply (no path)"
|
|
1115
590
|
try:
|
|
1116
|
-
|
|
1117
|
-
with _REAL_IO_OPEN(path, "r") as f:
|
|
591
|
+
with io.open(path, "r") as f:
|
|
1118
592
|
payload = json.load(f) if r.get("json") else f.read()
|
|
1119
593
|
finally:
|
|
1120
594
|
try:
|
|
@@ -1124,7 +598,13 @@ class Worker:
|
|
|
1124
598
|
return self._append_library(str(source), payload, r)
|
|
1125
599
|
|
|
1126
600
|
def _append_library(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
|
|
1127
|
-
"""Append host-packed library files into `context` (idempotent by path prefix).
|
|
601
|
+
"""Append host-packed library files into `context` (idempotent by path prefix).
|
|
602
|
+
|
|
603
|
+
The two refusals below are pre-flighted host-side by LIST_CONTEXT_REQUIRED /
|
|
604
|
+
NO_FILES_PRODUCED in src/bridge/library.ts, so the host never commits a
|
|
605
|
+
loaded-prefix for an append that fails here. Reaching either one means host and worker
|
|
606
|
+
disagree about `context`; keep the wording identical to its twin.
|
|
607
|
+
"""
|
|
1128
608
|
ctx = self.ns.get("context")
|
|
1129
609
|
if not isinstance(ctx, list):
|
|
1130
610
|
kind = type(ctx).__name__ if ctx is not None else "None"
|
|
@@ -1194,43 +674,9 @@ class Worker:
|
|
|
1194
674
|
out.append(item)
|
|
1195
675
|
return out
|
|
1196
676
|
|
|
1197
|
-
def _advance_phase(self, phase: str, summary: str | None = None) -> str:
|
|
1198
|
-
"""Transition the root RLM pipeline to a new phase.
|
|
1199
|
-
|
|
1200
|
-
Only callable at depth 0. The parent handler validates the transition
|
|
1201
|
-
against the phase state machine (research → blueprint → validate)
|
|
1202
|
-
and runs deterministic artifact gates before accepting the transition.
|
|
1203
|
-
Returns a short confirmation, or an `Error: …` string the model can act on.
|
|
1204
|
-
"""
|
|
1205
|
-
if self.depth > 0:
|
|
1206
|
-
return "Error: advance_phase is only available at the root RLM depth"
|
|
1207
|
-
r = self._rpc("advance_phase", {"phase": str(phase), "summary": summary})
|
|
1208
|
-
if r.get("error"):
|
|
1209
|
-
return f"Error: {r['error']}"
|
|
1210
|
-
response = r.get("response", "ok")
|
|
1211
|
-
if isinstance(response, str) and response.startswith("Error:"):
|
|
1212
|
-
return response
|
|
1213
|
-
return response if isinstance(response, str) else "ok"
|
|
1214
|
-
|
|
1215
|
-
def _save_artifact(self, kind: str, content: str) -> str:
|
|
1216
|
-
"""Persist a stage artifact (research/plan/validation) under .rlm/artifacts/.
|
|
1217
|
-
|
|
1218
|
-
Only callable at depth 0. The engine gates advance_phase against the latest
|
|
1219
|
-
saved artifact for the current stage.
|
|
1220
|
-
"""
|
|
1221
|
-
if self.depth > 0:
|
|
1222
|
-
return "Error: save_artifact is only available at the root RLM depth"
|
|
1223
|
-
r = self._rpc("save_artifact", {"artifactKind": str(kind), "content": str(content)})
|
|
1224
|
-
if r.get("error"):
|
|
1225
|
-
return f"Error: {r['error']}"
|
|
1226
|
-
response = r.get("response", "ok")
|
|
1227
|
-
if isinstance(response, str) and response.startswith("Error:"):
|
|
1228
|
-
return response
|
|
1229
|
-
return response if isinstance(response, str) else "ok"
|
|
1230
|
-
|
|
1231
677
|
@_spawnable("rlm_query_batched")
|
|
1232
|
-
def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
|
|
1233
|
-
return self._await_task(self._start_rlm_query_batched(prompts, model))
|
|
678
|
+
def _rlm_query_batched(self, prompts, model: str | None = None, paths=None) -> list[str]:
|
|
679
|
+
return self._await_task(self._start_rlm_query_batched(prompts, model, paths))
|
|
1234
680
|
|
|
1235
681
|
# ---- context + execution --------------------------------------------------------------
|
|
1236
682
|
|
|
@@ -1331,65 +777,6 @@ class Worker:
|
|
|
1331
777
|
"var_names": self._user_var_names(),
|
|
1332
778
|
}
|
|
1333
779
|
|
|
1334
|
-
def _serializer(self):
|
|
1335
|
-
try:
|
|
1336
|
-
import dill as s
|
|
1337
|
-
return s
|
|
1338
|
-
except ImportError:
|
|
1339
|
-
return pickle
|
|
1340
|
-
|
|
1341
|
-
def snapshot(self, path: str, nonce: str) -> dict:
|
|
1342
|
-
"""Pickle user variables atomically to path. Stores session nonce for restore verification.
|
|
1343
|
-
|
|
1344
|
-
Writes to path.tmp then os.rename — atomic on POSIX, so no .tmp leak and no
|
|
1345
|
-
TypeScript-side finalize step needed. On resume (fresh session = different nonce),
|
|
1346
|
-
restore fails — caller falls back to history-only replay.
|
|
1347
|
-
"""
|
|
1348
|
-
s = self._serializer()
|
|
1349
|
-
out, skipped = {}, []
|
|
1350
|
-
MAX_VAR_BYTES = 50 * 1024 * 1024
|
|
1351
|
-
for k, v in self.ns.items():
|
|
1352
|
-
if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
|
|
1353
|
-
continue
|
|
1354
|
-
# A Task holds a back-reference to this Worker, so dill would happily pickle the
|
|
1355
|
-
# whole process. Top-level guard only: a Task nested inside a list/dict still
|
|
1356
|
-
# falls to the generic `except` below and skips the variable — which is why this
|
|
1357
|
-
# guard is explicit rather than left to that fallback.
|
|
1358
|
-
if isinstance(v, Task):
|
|
1359
|
-
skipped.append(k)
|
|
1360
|
-
continue
|
|
1361
|
-
try:
|
|
1362
|
-
blob = s.dumps(v)
|
|
1363
|
-
if len(blob) > MAX_VAR_BYTES:
|
|
1364
|
-
skipped.append(k)
|
|
1365
|
-
continue
|
|
1366
|
-
out[k] = v
|
|
1367
|
-
except Exception:
|
|
1368
|
-
skipped.append(k)
|
|
1369
|
-
if skipped:
|
|
1370
|
-
print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
|
|
1371
|
-
tmp = path + ".tmp"
|
|
1372
|
-
with _REAL_IO_OPEN(tmp, "wb") as f:
|
|
1373
|
-
s.dump({"nonce": nonce, "vars": out}, f)
|
|
1374
|
-
os.rename(tmp, path) # atomic rename
|
|
1375
|
-
return {"skipped": skipped}
|
|
1376
|
-
|
|
1377
|
-
def restore(self, path: str, nonce: str) -> dict:
|
|
1378
|
-
"""Restore user variables from a pickle file. Verifies session nonce before deserializing.
|
|
1379
|
-
|
|
1380
|
-
SECURITY: pickle.load executes arbitrary code. The session nonce check ensures the
|
|
1381
|
-
.pkl was written by THIS engine session. Cross-session resume falls back to
|
|
1382
|
-
history-only replay (caller skips restore when sessionNonce is undefined).
|
|
1383
|
-
"""
|
|
1384
|
-
s = self._serializer()
|
|
1385
|
-
with _REAL_IO_OPEN(path, "rb") as f:
|
|
1386
|
-
data = s.load(f)
|
|
1387
|
-
if not isinstance(data, dict) or data.get("nonce") != nonce:
|
|
1388
|
-
raise ValueError("snapshot nonce mismatch — not from this session")
|
|
1389
|
-
self.ns.update(data.get("vars", {}))
|
|
1390
|
-
self._restore_scaffold()
|
|
1391
|
-
return {"restored": list(data.get("vars", {}).keys())}
|
|
1392
|
-
|
|
1393
780
|
|
|
1394
781
|
def main() -> None:
|
|
1395
782
|
ap = argparse.ArgumentParser()
|
|
@@ -1399,13 +786,10 @@ def main() -> None:
|
|
|
1399
786
|
default=float(os.environ.get("RLM_AWAIT_TIMEOUT_S", "600")))
|
|
1400
787
|
ap.add_argument("--max-prompt-chars", type=int,
|
|
1401
788
|
default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
|
|
1402
|
-
ap.add_argument("--read-only", action="store_true",
|
|
1403
|
-
default=os.environ.get("RLM_READ_ONLY", "").lower() in ("1", "true", "yes"),
|
|
1404
|
-
help="Reject open() write modes (pipeline runs)")
|
|
1405
789
|
args = ap.parse_args()
|
|
1406
790
|
|
|
1407
791
|
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
|
|
1408
|
-
max_prompt_chars=args.max_prompt_chars,
|
|
792
|
+
max_prompt_chars=args.max_prompt_chars,
|
|
1409
793
|
await_timeout_s=args.await_timeout)
|
|
1410
794
|
_send({"id": "_init", "ok": True})
|
|
1411
795
|
|
|
@@ -1442,10 +826,6 @@ def main() -> None:
|
|
|
1442
826
|
elif kind == "shutdown":
|
|
1443
827
|
_send({"id": rid, "ok": True})
|
|
1444
828
|
return
|
|
1445
|
-
elif kind == "snapshot":
|
|
1446
|
-
_send({"id": rid, "ok": True, **worker.snapshot(req.get("path", ""), req.get("nonce", ""))})
|
|
1447
|
-
elif kind == "restore":
|
|
1448
|
-
_send({"id": rid, "ok": True, **worker.restore(req.get("path", ""), req.get("nonce", ""))})
|
|
1449
829
|
else:
|
|
1450
830
|
_send({"id": rid, "ok": False, "error": f"unknown type: {kind!r}"})
|
|
1451
831
|
except BaseException as e: # noqa: BLE001
|