@hicaru/pi-rlm 0.1.8 → 0.2.0
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 +22 -19
- package/package.json +2 -1
- package/src/bridge/library.ts +93 -15
- package/src/bridge/llm-query.ts +60 -36
- package/src/bridge/rlm-query.ts +63 -79
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- package/src/config/settings.ts +33 -3
- package/src/context/library-context.ts +209 -22
- package/src/context/repomix-context.ts +7 -58
- package/src/core/answer.ts +5 -13
- package/src/core/artifacts.ts +4 -3
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +94 -299
- package/src/core/gates.ts +33 -4
- package/src/core/limits.ts +19 -1
- package/src/core/pipeline-handlers.ts +319 -0
- package/src/core/pipeline.ts +40 -15
- package/src/core/types.ts +26 -30
- package/src/index.ts +36 -26
- package/src/mode/native-guards.ts +2 -2
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/phases.ts +18 -39
- package/src/prompts/system.ts +167 -64
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +5 -17
- package/src/sandbox/sandbox-manager.ts +5 -5
- package/src/sandbox/sandbox.ts +67 -27
- package/src/sandbox/worker.py +534 -48
- package/src/state/paths.ts +1 -1
- package/src/state/reads.ts +12 -4
- package/src/state/resume.ts +26 -25
- package/src/state/rows.ts +2 -2
- package/src/text/parsing.ts +0 -6
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +132 -337
- package/src/tool/rlm-aggregator.ts +7 -7
- package/src/tool/rlm-details.ts +6 -13
- package/src/tool/rlm-events.ts +14 -11
- package/src/tool/rlm-tool.ts +20 -38
- package/src/tool/subcall-render.ts +61 -9
- package/src/tool/subcall-store.ts +4 -2
- package/src/ui/config-panel.ts +43 -23
- package/src/ui/intro.ts +2 -1
- package/src/ui/status.ts +8 -5
- package/src/ui/theme-adapter.ts +36 -0
- package/src/ui/theme.ts +0 -25
- package/src/mode/input-router.ts +0 -23
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -164
- package/src/tool/apply-edits-tool.ts +0 -295
package/src/sandbox/worker.py
CHANGED
|
@@ -17,8 +17,11 @@ services the request in-process (it holds API keys).
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import argparse
|
|
20
|
+
import fnmatch
|
|
21
|
+
import heapq
|
|
20
22
|
import io
|
|
21
23
|
import json
|
|
24
|
+
import math
|
|
22
25
|
import os
|
|
23
26
|
import pickle
|
|
24
27
|
import re
|
|
@@ -57,8 +60,44 @@ _SAFE_BUILTINS = {
|
|
|
57
60
|
"ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
|
|
58
61
|
)
|
|
59
62
|
}
|
|
60
|
-
# `open` is allowed
|
|
61
|
-
|
|
63
|
+
# `open` is allowed for data work; eval/exec/compile/input/globals/locals are not.
|
|
64
|
+
# When read_only=True (pipeline runs), write modes raise PermissionError via
|
|
65
|
+
# builtins.open, io.open (pathlib), and os.open. Steering, not a security sandbox.
|
|
66
|
+
_WRITE_MODE_CHARS = frozenset("wax+")
|
|
67
|
+
_OS_WRITE_FLAGS = os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_TRUNC
|
|
68
|
+
|
|
69
|
+
_REAL_IO_OPEN = io.open
|
|
70
|
+
_REAL_OS_OPEN = os.open
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _install_read_only_guards():
|
|
74
|
+
"""Route every common file-open path through the read-only check.
|
|
75
|
+
|
|
76
|
+
Steering, not a sandbox: closes builtins.open, io.open (hence pathlib), and
|
|
77
|
+
os.open. A determined model can still reach the filesystem via ctypes or a
|
|
78
|
+
subprocess — the goal is that ACCIDENTAL writes cannot pass silently.
|
|
79
|
+
Worker-internal I/O keeps using _REAL_IO_OPEN / _REAL_OS_OPEN.
|
|
80
|
+
"""
|
|
81
|
+
def guarded_io_open(file, mode="r", *args, **kwargs):
|
|
82
|
+
if _WRITE_MODE_CHARS & set(str(mode)):
|
|
83
|
+
raise PermissionError(
|
|
84
|
+
f"read-only RLM run: refusing to open {file!r} with mode {mode!r}. "
|
|
85
|
+
"This pipeline produces a plan; file changes go through the host edit tool."
|
|
86
|
+
)
|
|
87
|
+
return _REAL_IO_OPEN(file, mode, *args, **kwargs)
|
|
88
|
+
|
|
89
|
+
def guarded_os_open(path, flags, *args, **kwargs):
|
|
90
|
+
if flags & _OS_WRITE_FLAGS:
|
|
91
|
+
raise PermissionError(
|
|
92
|
+
f"read-only RLM run: refusing os.open({path!r}) with write flags."
|
|
93
|
+
)
|
|
94
|
+
return _REAL_OS_OPEN(path, flags, *args, **kwargs)
|
|
95
|
+
|
|
96
|
+
io.open = guarded_io_open
|
|
97
|
+
os.open = guarded_os_open
|
|
98
|
+
return guarded_io_open
|
|
99
|
+
|
|
100
|
+
|
|
62
101
|
for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
|
|
63
102
|
_SAFE_BUILTINS[_blocked] = None
|
|
64
103
|
|
|
@@ -66,13 +105,19 @@ RESERVED = frozenset(
|
|
|
66
105
|
{
|
|
67
106
|
"llm_query", "llm_query_batched", "llm_query_chunked",
|
|
68
107
|
"rlm_query", "rlm_query_batched",
|
|
108
|
+
"map_files", "llm_map_reduce",
|
|
109
|
+
"search", "grep_context", "outline",
|
|
69
110
|
"advance_phase", "save_artifact",
|
|
70
111
|
"ask_user_question", "todo",
|
|
71
|
-
"
|
|
112
|
+
"load_library",
|
|
72
113
|
"SHOW_VARS", "answer", "context",
|
|
73
114
|
}
|
|
74
115
|
)
|
|
75
|
-
|
|
116
|
+
# NOTE: `answers` and `plan` are deliberately NOT reserved. They are seeded by the scaffold but
|
|
117
|
+
# owned by the model, so they must appear in SHOW_VARS and be captured by snapshots — losing a
|
|
118
|
+
# memoized answer across a resume is exactly the failure the memo exists to prevent.
|
|
119
|
+
# Only the single name `context` is the packed world. Legacy context_N names are filtered out.
|
|
120
|
+
_CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
|
|
76
121
|
|
|
77
122
|
# Sizing for llm_query_chunked: leave room for the instruction and the chunk header.
|
|
78
123
|
_CHUNK_HEADER_OVERHEAD = 64
|
|
@@ -97,6 +142,145 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
|
97
142
|
return chunks
|
|
98
143
|
|
|
99
144
|
|
|
145
|
+
# ---- deterministic retrieval over `context` -----------------------------------------------
|
|
146
|
+
#
|
|
147
|
+
# The RLM paper's trajectories retrieve by having the root model hand-write regex over the
|
|
148
|
+
# context (App. E.1). Frontier models do that well; small/fast models guess keywords badly and
|
|
149
|
+
# the first decomposition attempt disproportionately decides the outcome (paper §5, Fig. 4a).
|
|
150
|
+
# These primitives make retrieval deterministic and token-free: no sub-LLM call, no root tokens
|
|
151
|
+
# spent on printed file bodies — the model gets ranked pointers and decides what to delegate.
|
|
152
|
+
|
|
153
|
+
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
154
|
+
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge load_library() cannot exhaust worker memory
|
|
155
|
+
_SNIPPET_CHARS = 400
|
|
156
|
+
_GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
|
|
157
|
+
_BM25_K1 = 1.2
|
|
158
|
+
_BM25_B = 0.75
|
|
159
|
+
|
|
160
|
+
_TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
|
|
161
|
+
_CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
162
|
+
|
|
163
|
+
# Definition-ish lines across the languages this plugin is likely to meet. Deliberately
|
|
164
|
+
# lexical: an outline is an orientation aid, not a parse tree.
|
|
165
|
+
_OUTLINE_LINE = re.compile(
|
|
166
|
+
r"^\s*(?:"
|
|
167
|
+
r"(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|namespace)\s+\w+"
|
|
168
|
+
r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:function|\(|<)"
|
|
169
|
+
r"|(?:pub\s+)?(?:async\s+)?fn\s+\w+"
|
|
170
|
+
r"|def\s+\w+|class\s+\w+"
|
|
171
|
+
r"|func\s+\w+"
|
|
172
|
+
r"|#{1,4}\s+\S"
|
|
173
|
+
r")"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _tokenize(text: str) -> list[str]:
|
|
178
|
+
"""Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
|
|
179
|
+
out: list[str] = []
|
|
180
|
+
for raw in _TOKEN_SPLIT.split(text):
|
|
181
|
+
if not raw:
|
|
182
|
+
continue
|
|
183
|
+
lowered = raw.lower()
|
|
184
|
+
out.append(lowered)
|
|
185
|
+
if len(raw) > 3:
|
|
186
|
+
parts = _CAMEL_SPLIT.split(raw)
|
|
187
|
+
if len(parts) > 1:
|
|
188
|
+
for part in parts:
|
|
189
|
+
piece = part.lower()
|
|
190
|
+
if piece and piece != lowered:
|
|
191
|
+
out.append(piece)
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _context_entries(context: Any) -> list[tuple[str, str]]:
|
|
196
|
+
"""(path, content) pairs for either context shape: list[dict] bundles or a raw string."""
|
|
197
|
+
if isinstance(context, str):
|
|
198
|
+
return [("<context>", context)]
|
|
199
|
+
if not isinstance(context, list):
|
|
200
|
+
return []
|
|
201
|
+
out: list[tuple[str, str]] = []
|
|
202
|
+
for i, item in enumerate(context):
|
|
203
|
+
if isinstance(item, dict):
|
|
204
|
+
content = item.get("content", "")
|
|
205
|
+
out.append((
|
|
206
|
+
str(item.get("path", f"<context[{i}]>")),
|
|
207
|
+
content if isinstance(content, str) else str(content),
|
|
208
|
+
))
|
|
209
|
+
elif isinstance(item, str):
|
|
210
|
+
out.append((f"<context[{i}]>", item))
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _Bm25Index:
|
|
215
|
+
"""Okapi BM25 over fixed-line windows of `context`. Built lazily, discarded on change."""
|
|
216
|
+
|
|
217
|
+
__slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
|
|
218
|
+
|
|
219
|
+
def __init__(self, entries: list[tuple[str, str]]) -> None:
|
|
220
|
+
self.paths: list[str] = []
|
|
221
|
+
self.starts: list[int] = []
|
|
222
|
+
self.texts: list[str] = []
|
|
223
|
+
self.postings: dict[str, list[tuple[int, int]]] = {}
|
|
224
|
+
self.doc_len: list[int] = []
|
|
225
|
+
self.truncated = False
|
|
226
|
+
|
|
227
|
+
for path, content in entries:
|
|
228
|
+
if not content:
|
|
229
|
+
continue
|
|
230
|
+
lines = content.split("\n")
|
|
231
|
+
for start in range(0, len(lines), _INDEX_WINDOW_LINES):
|
|
232
|
+
if len(self.texts) >= _INDEX_MAX_WINDOWS:
|
|
233
|
+
self.truncated = True
|
|
234
|
+
break
|
|
235
|
+
window = "\n".join(lines[start:start + _INDEX_WINDOW_LINES])
|
|
236
|
+
idx = len(self.texts)
|
|
237
|
+
self.paths.append(path)
|
|
238
|
+
self.starts.append(start + 1)
|
|
239
|
+
self.texts.append(window)
|
|
240
|
+
terms = _tokenize(window)
|
|
241
|
+
self.doc_len.append(len(terms))
|
|
242
|
+
freq: dict[str, int] = {}
|
|
243
|
+
for term in terms:
|
|
244
|
+
freq[term] = freq.get(term, 0) + 1
|
|
245
|
+
for term, tf in freq.items():
|
|
246
|
+
self.postings.setdefault(term, []).append((idx, tf))
|
|
247
|
+
if self.truncated:
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
total = len(self.doc_len)
|
|
251
|
+
self.avg_len = (sum(self.doc_len) / total) if total else 1.0
|
|
252
|
+
|
|
253
|
+
def query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
|
|
254
|
+
total = len(self.texts)
|
|
255
|
+
if total == 0:
|
|
256
|
+
return []
|
|
257
|
+
scores: dict[int, float] = {}
|
|
258
|
+
for term in set(terms):
|
|
259
|
+
posting = self.postings.get(term)
|
|
260
|
+
if not posting:
|
|
261
|
+
continue
|
|
262
|
+
df = len(posting)
|
|
263
|
+
idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
|
264
|
+
for idx, tf in posting:
|
|
265
|
+
norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
|
|
266
|
+
scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
|
|
267
|
+
if path_glob:
|
|
268
|
+
scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
|
|
269
|
+
if not scores:
|
|
270
|
+
return []
|
|
271
|
+
top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
|
|
272
|
+
out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
|
|
273
|
+
for i, (idx, score) in enumerate(top):
|
|
274
|
+
text = self.texts[idx]
|
|
275
|
+
out[i] = {
|
|
276
|
+
"path": self.paths[idx],
|
|
277
|
+
"line": self.starts[idx],
|
|
278
|
+
"score": round(score, 3),
|
|
279
|
+
"snippet": text[:_SNIPPET_CHARS],
|
|
280
|
+
}
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
100
284
|
class _AnswerDict(dict):
|
|
101
285
|
"""`answer` dict; flipping `ready` True captures the final answer for the parent."""
|
|
102
286
|
|
|
@@ -118,22 +302,27 @@ def _send(obj: dict[str, Any]) -> None:
|
|
|
118
302
|
|
|
119
303
|
|
|
120
304
|
class Worker:
|
|
121
|
-
def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int):
|
|
305
|
+
def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int, read_only: bool = False):
|
|
122
306
|
self.depth = depth
|
|
123
307
|
self.exec_timeout_s = exec_timeout_s
|
|
124
308
|
self.max_prompt_chars = max_prompt_chars
|
|
309
|
+
self.read_only = read_only
|
|
125
310
|
self._rid = 0
|
|
126
311
|
self._final_answer: str | None = None
|
|
127
|
-
self._context_count = 0
|
|
128
312
|
self.ns: dict[str, Any] = {}
|
|
129
313
|
self._setup()
|
|
130
314
|
|
|
131
315
|
def _setup(self) -> None:
|
|
132
|
-
|
|
133
|
-
self.
|
|
134
|
-
|
|
135
|
-
|
|
316
|
+
builtins = _SAFE_BUILTINS.copy()
|
|
317
|
+
if self.read_only:
|
|
318
|
+
builtins["open"] = _install_read_only_guards()
|
|
319
|
+
else:
|
|
320
|
+
builtins["open"] = open
|
|
321
|
+
self.ns = {"__builtins__": builtins, "__name__": "__main__"}
|
|
322
|
+
self._context_payload: Any | None = None # pristine restore for the single `context` var
|
|
136
323
|
self._nudged: set[str] = set()
|
|
324
|
+
self._index: _Bm25Index | None = None
|
|
325
|
+
self._index_stamp: tuple[int, int] | None = None # (id(context), len(context))
|
|
137
326
|
self._restore_scaffold()
|
|
138
327
|
|
|
139
328
|
def _capture_answer(self, content: Any) -> None:
|
|
@@ -147,11 +336,21 @@ class Worker:
|
|
|
147
336
|
ns["llm_query_chunked"] = self._llm_query_chunked
|
|
148
337
|
ns["rlm_query"] = self._rlm_query
|
|
149
338
|
ns["rlm_query_batched"] = self._rlm_query_batched
|
|
339
|
+
ns["map_files"] = self._map_files
|
|
340
|
+
ns["llm_map_reduce"] = self._llm_map_reduce
|
|
341
|
+
ns["search"] = self._search
|
|
342
|
+
ns["grep_context"] = self._grep_context
|
|
343
|
+
ns["outline"] = self._outline
|
|
344
|
+
# env_tips memo (paper App. C.3): "If a value isn't in `answers`, it doesn't exist."
|
|
345
|
+
# Re-created only when deleted — contents must survive every turn.
|
|
346
|
+
if not isinstance(ns.get("answers"), dict):
|
|
347
|
+
ns["answers"] = {}
|
|
348
|
+
if not isinstance(ns.get("plan"), dict):
|
|
349
|
+
ns["plan"] = {}
|
|
150
350
|
ns["advance_phase"] = self._advance_phase
|
|
151
351
|
ns["save_artifact"] = self._save_artifact
|
|
152
352
|
ns["ask_user_question"] = self._ask_user_question
|
|
153
353
|
ns["todo"] = self._todo
|
|
154
|
-
ns["stage_edit"] = self._stage_edit
|
|
155
354
|
ns["load_library"] = self._load_library
|
|
156
355
|
ns["SHOW_VARS"] = self._show_vars
|
|
157
356
|
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
@@ -163,17 +362,18 @@ class Worker:
|
|
|
163
362
|
if cur.get("ready") and self._final_answer is None:
|
|
164
363
|
self._final_answer = str(cur.get("content", ""))
|
|
165
364
|
ns["answer"] = ans
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
365
|
+
# Single context variable (RLM paper: the context lives in the environment and
|
|
366
|
+
# the model may transform it in place). Re-inject only if the model deleted the
|
|
367
|
+
# name entirely; mutations and re-binds persist within the run.
|
|
368
|
+
if self._context_payload is not None:
|
|
369
|
+
ns.setdefault("context", self._context_payload)
|
|
370
|
+
# Scrub any legacy context_N names so the model never sees multi-slot APIs.
|
|
371
|
+
for k in list(ns.keys()):
|
|
372
|
+
if k != "context" and _CONTEXT_NAME.match(k):
|
|
373
|
+
del ns[k]
|
|
174
374
|
|
|
175
375
|
def _user_var_names(self) -> list[str]:
|
|
176
|
-
"""User-created variable names — filters builtins, scaffold, and context
|
|
376
|
+
"""User-created variable names — filters builtins, scaffold, and `context`.
|
|
177
377
|
|
|
178
378
|
Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
|
|
179
379
|
This is the cheap orientation hint that goes into history instead of full stdout.
|
|
@@ -181,7 +381,7 @@ class Worker:
|
|
|
181
381
|
return [
|
|
182
382
|
k for k in self.ns
|
|
183
383
|
if not k.startswith("_")
|
|
184
|
-
and not
|
|
384
|
+
and not _CONTEXT_NAME.match(k)
|
|
185
385
|
and k not in RESERVED
|
|
186
386
|
]
|
|
187
387
|
|
|
@@ -263,6 +463,200 @@ class Worker:
|
|
|
263
463
|
results.extend(self._llm_query_batched(batch, model))
|
|
264
464
|
return results
|
|
265
465
|
|
|
466
|
+
# ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
|
|
467
|
+
|
|
468
|
+
def _entries(self) -> list[tuple[str, str]]:
|
|
469
|
+
return _context_entries(self.ns.get("context"))
|
|
470
|
+
|
|
471
|
+
def _get_index(self) -> _Bm25Index:
|
|
472
|
+
"""Build the BM25 index on first use; rebuild when `context` was replaced or resized.
|
|
473
|
+
|
|
474
|
+
Identity+length is a cheap stamp that catches the two ways context actually changes:
|
|
475
|
+
load_library() extending the list, and the model re-binding the name. In-place edits
|
|
476
|
+
that preserve length are not detected — documented, and rare in practice.
|
|
477
|
+
"""
|
|
478
|
+
ctx = self.ns.get("context")
|
|
479
|
+
stamp = (id(ctx), len(ctx) if isinstance(ctx, (list, str)) else 0)
|
|
480
|
+
if self._index is None or self._index_stamp != stamp:
|
|
481
|
+
self._index = _Bm25Index(self._entries())
|
|
482
|
+
self._index_stamp = stamp
|
|
483
|
+
return self._index
|
|
484
|
+
|
|
485
|
+
def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
|
|
486
|
+
"""Rank `context` windows against a natural-language query (BM25).
|
|
487
|
+
|
|
488
|
+
Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
|
|
489
|
+
named files out of `context` and delegating them to llm_query / map_files.
|
|
490
|
+
"""
|
|
491
|
+
terms = _tokenize(str(query))
|
|
492
|
+
if not terms:
|
|
493
|
+
return []
|
|
494
|
+
try:
|
|
495
|
+
limit = max(1, min(int(k), 100))
|
|
496
|
+
except (TypeError, ValueError):
|
|
497
|
+
limit = 10
|
|
498
|
+
return self._get_index().query(terms, limit, path_glob)
|
|
499
|
+
|
|
500
|
+
def _grep_context(
|
|
501
|
+
self,
|
|
502
|
+
pattern: str,
|
|
503
|
+
k: int = 50,
|
|
504
|
+
path_glob: str | None = None,
|
|
505
|
+
before: int = 0,
|
|
506
|
+
after: int = 0,
|
|
507
|
+
) -> dict[str, Any]:
|
|
508
|
+
"""Regex over `context`, capped and shaped.
|
|
509
|
+
|
|
510
|
+
Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
|
|
511
|
+
`counts` is complete even when `hits` is capped, so a wide pattern reports its shape
|
|
512
|
+
instead of flooding stdout.
|
|
513
|
+
"""
|
|
514
|
+
try:
|
|
515
|
+
rx = re.compile(pattern)
|
|
516
|
+
except re.error as e:
|
|
517
|
+
return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
|
|
518
|
+
try:
|
|
519
|
+
limit = max(1, min(int(k), _GREP_HARD_CAP))
|
|
520
|
+
except (TypeError, ValueError):
|
|
521
|
+
limit = 50
|
|
522
|
+
pad_before = max(0, min(int(before or 0), 10))
|
|
523
|
+
pad_after = max(0, min(int(after or 0), 10))
|
|
524
|
+
|
|
525
|
+
hits: list[dict[str, Any]] = []
|
|
526
|
+
counts: dict[str, int] = {}
|
|
527
|
+
total = 0
|
|
528
|
+
for path, content in self._entries():
|
|
529
|
+
if path_glob and not fnmatch.fnmatch(path, path_glob):
|
|
530
|
+
continue
|
|
531
|
+
if not rx.search(content):
|
|
532
|
+
continue
|
|
533
|
+
lines = content.split("\n")
|
|
534
|
+
for i, line in enumerate(lines):
|
|
535
|
+
if not rx.search(line):
|
|
536
|
+
continue
|
|
537
|
+
total += 1
|
|
538
|
+
counts[path] = counts.get(path, 0) + 1
|
|
539
|
+
if len(hits) >= limit:
|
|
540
|
+
continue
|
|
541
|
+
lo = max(0, i - pad_before)
|
|
542
|
+
hi = min(len(lines), i + pad_after + 1)
|
|
543
|
+
hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
|
|
544
|
+
return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
|
|
545
|
+
|
|
546
|
+
def _outline(self, path: str) -> str:
|
|
547
|
+
"""Definition/heading skeleton of one context file — orient in ~200 chars, not 20K.
|
|
548
|
+
|
|
549
|
+
`path` matches exactly, then by suffix, then as a glob.
|
|
550
|
+
"""
|
|
551
|
+
target = str(path)
|
|
552
|
+
entries = self._entries()
|
|
553
|
+
content: str | None = None
|
|
554
|
+
for p, c in entries:
|
|
555
|
+
if p == target:
|
|
556
|
+
content = c
|
|
557
|
+
break
|
|
558
|
+
if content is None:
|
|
559
|
+
for p, c in entries:
|
|
560
|
+
if p.endswith(target) or fnmatch.fnmatch(p, target):
|
|
561
|
+
content = c
|
|
562
|
+
target = p
|
|
563
|
+
break
|
|
564
|
+
if content is None:
|
|
565
|
+
return f"Error: no context file matching {path!r} — use search() or list paths from context"
|
|
566
|
+
out: list[str] = [f"# {target}"]
|
|
567
|
+
for i, line in enumerate(content.split("\n")):
|
|
568
|
+
if _OUTLINE_LINE.match(line):
|
|
569
|
+
out.append(f"{i + 1}: {line.strip()[:160]}")
|
|
570
|
+
if len(out) == 1:
|
|
571
|
+
return f"# {target}\n(no definition-like lines found)"
|
|
572
|
+
return "\n".join(out)
|
|
573
|
+
|
|
574
|
+
# ---- one-line delegation (structural: orchestrating must be easier than solving) -------
|
|
575
|
+
|
|
576
|
+
def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
|
|
577
|
+
"""Ask `prompt` of every given file, batched, and return {path: answer}.
|
|
578
|
+
|
|
579
|
+
`files` accepts context entries (dicts), paths (strings), or a mix — the whole
|
|
580
|
+
chunk/batch/collect loop the system prompt used to spell out, as one call.
|
|
581
|
+
Oversized files are split and their per-chunk answers joined.
|
|
582
|
+
"""
|
|
583
|
+
prompt = str(prompt)
|
|
584
|
+
by_path: list[tuple[str, str]] = []
|
|
585
|
+
lookup: dict[str, str] | None = None
|
|
586
|
+
for item in files if isinstance(files, (list, tuple)) else [files]:
|
|
587
|
+
if isinstance(item, dict):
|
|
588
|
+
content = item.get("content", "")
|
|
589
|
+
by_path.append((str(item.get("path", "?")), content if isinstance(content, str) else str(content)))
|
|
590
|
+
elif isinstance(item, str):
|
|
591
|
+
if lookup is None:
|
|
592
|
+
lookup = {p: c for p, c in self._entries()}
|
|
593
|
+
if item in lookup:
|
|
594
|
+
by_path.append((item, lookup[item]))
|
|
595
|
+
else:
|
|
596
|
+
by_path.append((item, ""))
|
|
597
|
+
if not by_path:
|
|
598
|
+
return {}
|
|
599
|
+
|
|
600
|
+
# Per-file prompt budget; anything larger is chunked and its answers concatenated.
|
|
601
|
+
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
|
|
602
|
+
if budget < 1_000:
|
|
603
|
+
return {p: "Error: prompt too long to leave room for file content" for p, _ in by_path}
|
|
604
|
+
|
|
605
|
+
requests: list[str] = []
|
|
606
|
+
spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
|
|
607
|
+
for path, content in by_path:
|
|
608
|
+
chunks = _chunk_text(content, budget) if len(content) > budget else [content]
|
|
609
|
+
spans.append((path, len(chunks)))
|
|
610
|
+
for j, chunk in enumerate(chunks):
|
|
611
|
+
header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
|
|
612
|
+
requests.append(f"{prompt}\n\n{header}\n{chunk}")
|
|
613
|
+
|
|
614
|
+
responses: list[str] = []
|
|
615
|
+
for i in range(0, len(requests), _MAX_CHUNK_BATCH):
|
|
616
|
+
responses.extend(self._llm_query_batched(requests[i:i + _MAX_CHUNK_BATCH], model))
|
|
617
|
+
|
|
618
|
+
out: dict[str, str] = {}
|
|
619
|
+
cursor = 0
|
|
620
|
+
for path, count in spans:
|
|
621
|
+
part = responses[cursor:cursor + count]
|
|
622
|
+
cursor += count
|
|
623
|
+
out[path] = part[0] if count == 1 and part else "\n\n".join(part)
|
|
624
|
+
return out
|
|
625
|
+
|
|
626
|
+
def _llm_map_reduce(
|
|
627
|
+
self,
|
|
628
|
+
items: Any,
|
|
629
|
+
map_prompt: str,
|
|
630
|
+
reduce_prompt: str,
|
|
631
|
+
model: str | None = None,
|
|
632
|
+
) -> str:
|
|
633
|
+
"""Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
|
|
634
|
+
|
|
635
|
+
The paper's canonical strategy ("query an LLM per chunk ... then query an LLM with all
|
|
636
|
+
the buffers") as a single call, so the root never hand-rolls the loop.
|
|
637
|
+
"""
|
|
638
|
+
map_prompt, reduce_prompt = str(map_prompt), str(reduce_prompt)
|
|
639
|
+
seq = list(items) if isinstance(items, (list, tuple)) else [items]
|
|
640
|
+
if not seq:
|
|
641
|
+
return "Error: llm_map_reduce got no items"
|
|
642
|
+
texts = [
|
|
643
|
+
(str(it.get("content", "")) if isinstance(it, dict) else str(it))
|
|
644
|
+
for it in seq
|
|
645
|
+
]
|
|
646
|
+
labels = [
|
|
647
|
+
(str(it.get("path", f"item {i + 1}")) if isinstance(it, dict) else f"item {i + 1}")
|
|
648
|
+
for i, it in enumerate(seq)
|
|
649
|
+
]
|
|
650
|
+
mapped: list[str] = []
|
|
651
|
+
for i in range(0, len(texts), _MAX_CHUNK_BATCH):
|
|
652
|
+
batch = [
|
|
653
|
+
f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
|
|
654
|
+
for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
|
|
655
|
+
]
|
|
656
|
+
mapped.extend(self._llm_query_batched(batch, model))
|
|
657
|
+
joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
|
|
658
|
+
return self._llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}", model)
|
|
659
|
+
|
|
266
660
|
def _rlm_query(self, prompt: str, model: str | None = None) -> str:
|
|
267
661
|
r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
|
|
268
662
|
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
@@ -325,37 +719,124 @@ class Worker:
|
|
|
325
719
|
return f"Error: {r['error']}"
|
|
326
720
|
return str(r.get("response", "ok"))
|
|
327
721
|
|
|
328
|
-
def _stage_edit(self, path: str, old_text: str, new_text: str) -> str:
|
|
329
|
-
if not isinstance(path, str) or not isinstance(old_text, str) or not isinstance(new_text, str):
|
|
330
|
-
return "Error: path, old_text, new_text must be strings"
|
|
331
|
-
self._edit_counter += 1
|
|
332
|
-
edit_id = f"e{self._edit_counter}"
|
|
333
|
-
self._staged_edits.append({"id": edit_id, "path": path, "oldText": old_text, "newText": new_text})
|
|
334
|
-
return edit_id
|
|
335
|
-
|
|
336
722
|
def _load_library(self, source: str) -> dict[str, Any] | str:
|
|
337
|
-
"""
|
|
723
|
+
"""Pack an external dir/file/git-URL on the host and append it into `context`.
|
|
724
|
+
|
|
725
|
+
Paths are namespaced under lib/<source_id>/ (host). Content is always in the
|
|
726
|
+
single `context` list — never a new context_N variable.
|
|
727
|
+
Host-side idempotency may return already_loaded without a payload path.
|
|
728
|
+
"""
|
|
338
729
|
r = self._rpc("load_library", {"source": str(source)})
|
|
339
730
|
if r.get("error"):
|
|
340
731
|
return f"Error: {r['error']}"
|
|
732
|
+
if r.get("already_loaded"):
|
|
733
|
+
source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "lib"
|
|
734
|
+
path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"lib/{source_id}/"
|
|
735
|
+
ctx = self.ns.get("context")
|
|
736
|
+
ctx_len = len(ctx) if isinstance(ctx, list) else 0
|
|
737
|
+
print(
|
|
738
|
+
f"[rlm] load_library: already loaded {source_id} "
|
|
739
|
+
f"(paths under {path_prefix}, context len={ctx_len})"
|
|
740
|
+
)
|
|
741
|
+
return {
|
|
742
|
+
"source": str(source),
|
|
743
|
+
"source_id": source_id,
|
|
744
|
+
"path_prefix": path_prefix,
|
|
745
|
+
"files": 0,
|
|
746
|
+
"chars": r.get("chars"),
|
|
747
|
+
"context_len": ctx_len,
|
|
748
|
+
"already_loaded": True,
|
|
749
|
+
}
|
|
341
750
|
path = r.get("path")
|
|
342
751
|
if not isinstance(path, str):
|
|
343
752
|
return "Error: malformed load_library reply (no path)"
|
|
344
753
|
try:
|
|
345
|
-
|
|
754
|
+
# Worker-internal read — use real io.open so read-only guards never block us.
|
|
755
|
+
with _REAL_IO_OPEN(path, "r") as f:
|
|
756
|
+
payload = json.load(f) if r.get("json") else f.read()
|
|
346
757
|
finally:
|
|
347
758
|
try:
|
|
348
759
|
os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
|
|
349
760
|
except OSError:
|
|
350
761
|
pass
|
|
351
|
-
return
|
|
352
|
-
|
|
762
|
+
return self._append_library(str(source), payload, r)
|
|
763
|
+
|
|
764
|
+
def _append_library(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
|
|
765
|
+
"""Append host-packed library files into `context` (idempotent by path prefix)."""
|
|
766
|
+
ctx = self.ns.get("context")
|
|
767
|
+
if not isinstance(ctx, list):
|
|
768
|
+
kind = type(ctx).__name__ if ctx is not None else "None"
|
|
769
|
+
return f"Error: load_library requires list context (file bundle); got {kind}"
|
|
770
|
+
|
|
771
|
+
source_id = meta.get("source_id")
|
|
772
|
+
if not isinstance(source_id, str) or not source_id:
|
|
773
|
+
source_id = "lib"
|
|
774
|
+
path_prefix = meta.get("path_prefix")
|
|
775
|
+
if not isinstance(path_prefix, str) or not path_prefix:
|
|
776
|
+
path_prefix = f"lib/{source_id}/"
|
|
777
|
+
|
|
778
|
+
# Idempotent: already present if any path uses this library prefix.
|
|
779
|
+
for item in ctx:
|
|
780
|
+
if isinstance(item, dict) and str(item.get("path", "")).startswith(path_prefix):
|
|
781
|
+
print(
|
|
782
|
+
f"[rlm] load_library: already loaded {source_id} "
|
|
783
|
+
f"(paths under {path_prefix}, context len={len(ctx)})"
|
|
784
|
+
)
|
|
785
|
+
return {
|
|
786
|
+
"source": source,
|
|
787
|
+
"source_id": source_id,
|
|
788
|
+
"path_prefix": path_prefix,
|
|
789
|
+
"files": 0,
|
|
790
|
+
"chars": meta.get("chars"),
|
|
791
|
+
"context_len": len(ctx),
|
|
792
|
+
"already_loaded": True,
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
files = self._library_file_entries(payload, path_prefix)
|
|
796
|
+
if not files:
|
|
797
|
+
return "Error: load_library produced no files"
|
|
798
|
+
|
|
799
|
+
ctx.extend(files)
|
|
800
|
+
# Keep restore payload in sync with the live list.
|
|
801
|
+
self._context_payload = ctx
|
|
802
|
+
self.ns["context"] = ctx
|
|
803
|
+
|
|
804
|
+
print(
|
|
805
|
+
f"[rlm] load_library: +{len(files)} files into context "
|
|
806
|
+
f"(len={len(ctx)}); paths under {path_prefix}"
|
|
807
|
+
)
|
|
808
|
+
return {
|
|
809
|
+
"source": source,
|
|
810
|
+
"source_id": source_id,
|
|
811
|
+
"path_prefix": path_prefix,
|
|
812
|
+
"files": len(files),
|
|
813
|
+
"chars": meta.get("chars"),
|
|
814
|
+
"context_len": len(ctx),
|
|
815
|
+
"already_loaded": False,
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
@staticmethod
|
|
819
|
+
def _library_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
|
|
820
|
+
"""Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
|
|
821
|
+
if isinstance(payload, str):
|
|
822
|
+
return [{
|
|
823
|
+
"path": f"{path_prefix}content",
|
|
824
|
+
"content": payload,
|
|
825
|
+
"tokens": max(1, (len(payload) + 3) // 4),
|
|
826
|
+
}]
|
|
827
|
+
if not isinstance(payload, list):
|
|
828
|
+
return []
|
|
829
|
+
out: list[dict[str, Any]] = []
|
|
830
|
+
for item in payload:
|
|
831
|
+
if isinstance(item, dict) and "path" in item and "content" in item:
|
|
832
|
+
out.append(item)
|
|
833
|
+
return out
|
|
353
834
|
|
|
354
835
|
def _advance_phase(self, phase: str, summary: str | None = None) -> str:
|
|
355
836
|
"""Transition the root RLM pipeline to a new phase.
|
|
356
837
|
|
|
357
838
|
Only callable at depth 0. The parent handler validates the transition
|
|
358
|
-
against the phase state machine (research → blueprint →
|
|
839
|
+
against the phase state machine (research → blueprint → validate)
|
|
359
840
|
and runs deterministic artifact gates before accepting the transition.
|
|
360
841
|
Returns a short confirmation, or an `Error: …` string the model can act on.
|
|
361
842
|
"""
|
|
@@ -400,16 +881,20 @@ class Worker:
|
|
|
400
881
|
# ---- context + execution --------------------------------------------------------------
|
|
401
882
|
|
|
402
883
|
def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
|
|
403
|
-
|
|
404
|
-
|
|
884
|
+
"""Load the packed world into the single REPL variable `context`.
|
|
885
|
+
|
|
886
|
+
`index` is accepted for protocol compatibility but ignored — there is only
|
|
887
|
+
one context slot. Libraries are merged on the host (or via load_library).
|
|
888
|
+
"""
|
|
405
889
|
with open(path, "r") as f:
|
|
406
890
|
payload = json.load(f) if is_json else f.read()
|
|
407
|
-
self.
|
|
408
|
-
self.ns[
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
891
|
+
self._context_payload = payload
|
|
892
|
+
self.ns["context"] = payload
|
|
893
|
+
# Drop legacy multi-slot names if present.
|
|
894
|
+
for k in list(self.ns.keys()):
|
|
895
|
+
if k != "context" and _CONTEXT_NAME.match(k):
|
|
896
|
+
del self.ns[k]
|
|
897
|
+
return 0
|
|
413
898
|
|
|
414
899
|
@contextmanager
|
|
415
900
|
def _capture(self):
|
|
@@ -471,7 +956,6 @@ class Worker:
|
|
|
471
956
|
stdout = out.getvalue()
|
|
472
957
|
stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
|
|
473
958
|
final, self._final_answer = self._final_answer, None
|
|
474
|
-
edits, self._staged_edits = self._staged_edits, []
|
|
475
959
|
answer = self.ns.get("answer")
|
|
476
960
|
answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
|
|
477
961
|
# ready may have been flipped with empty content before content was assigned later
|
|
@@ -488,7 +972,6 @@ class Worker:
|
|
|
488
972
|
"stderr": stderr,
|
|
489
973
|
"final_answer": final,
|
|
490
974
|
"answer_content": str(answer_content),
|
|
491
|
-
"edits": edits,
|
|
492
975
|
"raised": raised,
|
|
493
976
|
"execution_time": time.perf_counter() - start,
|
|
494
977
|
"var_names": self._user_var_names(),
|
|
@@ -512,7 +995,7 @@ class Worker:
|
|
|
512
995
|
out, skipped = {}, []
|
|
513
996
|
MAX_VAR_BYTES = 50 * 1024 * 1024
|
|
514
997
|
for k, v in self.ns.items():
|
|
515
|
-
if k.startswith("_") or
|
|
998
|
+
if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
|
|
516
999
|
continue
|
|
517
1000
|
try:
|
|
518
1001
|
blob = s.dumps(v)
|
|
@@ -525,7 +1008,7 @@ class Worker:
|
|
|
525
1008
|
if skipped:
|
|
526
1009
|
print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
|
|
527
1010
|
tmp = path + ".tmp"
|
|
528
|
-
with
|
|
1011
|
+
with _REAL_IO_OPEN(tmp, "wb") as f:
|
|
529
1012
|
s.dump({"nonce": nonce, "vars": out}, f)
|
|
530
1013
|
os.rename(tmp, path) # atomic rename
|
|
531
1014
|
return {"skipped": skipped}
|
|
@@ -538,7 +1021,7 @@ class Worker:
|
|
|
538
1021
|
history-only replay (caller skips restore when sessionNonce is undefined).
|
|
539
1022
|
"""
|
|
540
1023
|
s = self._serializer()
|
|
541
|
-
with
|
|
1024
|
+
with _REAL_IO_OPEN(path, "rb") as f:
|
|
542
1025
|
data = s.load(f)
|
|
543
1026
|
if not isinstance(data, dict) or data.get("nonce") != nonce:
|
|
544
1027
|
raise ValueError("snapshot nonce mismatch — not from this session")
|
|
@@ -553,10 +1036,13 @@ def main() -> None:
|
|
|
553
1036
|
ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
|
|
554
1037
|
ap.add_argument("--max-prompt-chars", type=int,
|
|
555
1038
|
default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
|
|
1039
|
+
ap.add_argument("--read-only", action="store_true",
|
|
1040
|
+
default=os.environ.get("RLM_READ_ONLY", "").lower() in ("1", "true", "yes"),
|
|
1041
|
+
help="Reject open() write modes (pipeline runs)")
|
|
556
1042
|
args = ap.parse_args()
|
|
557
1043
|
|
|
558
1044
|
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
|
|
559
|
-
max_prompt_chars=args.max_prompt_chars)
|
|
1045
|
+
max_prompt_chars=args.max_prompt_chars, read_only=args.read_only)
|
|
560
1046
|
_send({"id": "_init", "ok": True})
|
|
561
1047
|
|
|
562
1048
|
for raw in _REAL_STDIN:
|