@hicaru/pi-rlm 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +6 -17
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +55 -335
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +23 -12
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -407
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +8 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/{worker.py → py/worker.py} +76 -696
  30. package/src/sandbox/sandbox-manager.ts +13 -0
  31. package/src/sandbox/sandbox.ts +99 -193
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/repl-details.ts +2 -2
  34. package/src/tool/repl-render.ts +58 -0
  35. package/src/tool/repl-result.ts +70 -0
  36. package/src/tool/repl-tool.ts +37 -159
  37. package/src/tool/rlm-aggregator.ts +2 -10
  38. package/src/tool/rlm-details.ts +0 -2
  39. package/src/tool/rlm-events.ts +0 -14
  40. package/src/tool/rlm-tool.ts +1 -12
  41. package/src/ui/config-panel.ts +4 -16
  42. package/src/ui/intro.ts +1 -2
  43. package/src/ui/model-picker.ts +34 -10
  44. package/src/ui/status.ts +3 -7
  45. package/src/util/concurrency.ts +9 -5
  46. package/src/bridge/fallback-todo.ts +0 -148
  47. package/src/bridge/interactive.ts +0 -65
  48. package/src/bridge/pi-interactive.ts +0 -41
  49. package/src/core/artifacts.ts +0 -89
  50. package/src/core/critique.ts +0 -92
  51. package/src/core/gates.ts +0 -301
  52. package/src/core/pipeline-handlers.ts +0 -319
  53. package/src/core/pipeline.ts +0 -268
  54. package/src/prompts/phases.ts +0 -104
  55. package/src/state/index.ts +0 -24
  56. package/src/state/internal.ts +0 -46
  57. package/src/state/paths.ts +0 -44
  58. package/src/state/reads.ts +0 -133
  59. package/src/state/resume.ts +0 -173
  60. package/src/state/rows.ts +0 -123
  61. package/src/state/writes.ts +0 -58
@@ -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
- "advance_phase"|"save_artifact"|"ask_user_question"|"todo","rid",...}
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/advance_phase/save_artifact/ask_user_question/todo, the worker writes a request line
13
- and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
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
- # Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
42
- # All protocol writes must go to the real stdout even while user code's prints are captured.
43
- _REAL_STDOUT = sys.stdout
44
- _REAL_STDIN = sys.stdin
45
- _REAL_STDERR = sys.stderr
46
-
47
-
48
- def _builtin(name: str):
49
- return __builtins__[name] if isinstance(__builtins__, dict) else getattr(__builtins__, name, None)
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
- # NOTE: `answers` and `plan` are deliberately NOT reserved. They are seeded by the scaffold but
152
- # owned by the model, so they must appear in SHOW_VARS and be captured by snapshots — losing a
153
- # memoized answer across a resume is exactly the failure the memo exists to prevent.
154
- # Only the single name `context` is the packed world. Legacy context_N names are filtered out.
155
- _CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
156
-
157
- # Sizing for llm_query_chunked: leave room for the instruction and the chunk header.
158
- _CHUNK_HEADER_OVERHEAD = 64
159
- _MAX_CHUNK_BATCH = 20 # fan-out per llm_query_batched call (matches prompt guidance)
160
- _MAX_CHUNKS = 500 # ceiling: above this, force pre-filtering in Python
161
- _NUDGE_CHARS = 500_000 # str/bytes vars above this trigger a one-time stdout hint
162
-
163
-
164
- def _chunk_text(text: str, chunk_chars: int) -> list[str]:
165
- """Split text into <=chunk_chars pieces, preferring newline boundaries."""
166
- chunks: list[str] = []
167
- n = len(text)
168
- start = 0
169
- while start < n:
170
- end = min(start + chunk_chars, n)
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/snapshot/shutdown) that arrived mid-exec; main() replays them.
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
- if self.read_only:
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/snapshot/shutdown) arriving mid-exec: main() replays it.
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
- rid = self._post(kind, {"prompt": text, "model": model})
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
- rid = self._post(kind, {"prompts": prompts, "model": model})
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. Follow up by slicing the
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
- terms = _tokenize(str(query))
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 orient in ~200 chars, not 20K.
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
- # Worker-internal read — use real io.open so read-only guards never block us.
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, read_only=args.read_only,
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