@hicaru/pi-rlm 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) 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 +382 -0
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +7 -15
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +115 -360
  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 +49 -10
  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 -386
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +14 -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/py/worker.py +836 -0
  30. package/src/sandbox/sandbox-manager.ts +33 -6
  31. package/src/sandbox/sandbox.ts +153 -182
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/background-tasks.ts +95 -0
  34. package/src/tool/repl-details.ts +4 -2
  35. package/src/tool/repl-render.ts +58 -0
  36. package/src/tool/repl-result.ts +70 -0
  37. package/src/tool/repl-tool.ts +178 -216
  38. package/src/tool/rlm-aggregator.ts +2 -10
  39. package/src/tool/rlm-details.ts +0 -2
  40. package/src/tool/rlm-events.ts +10 -16
  41. package/src/tool/rlm-tool.ts +1 -12
  42. package/src/tool/subcall-render.ts +15 -3
  43. package/src/tool/subcall-store.ts +57 -1
  44. package/src/ui/config-panel.ts +4 -16
  45. package/src/ui/intro.ts +1 -2
  46. package/src/ui/model-picker.ts +34 -10
  47. package/src/ui/status.ts +3 -7
  48. package/src/util/concurrency.ts +91 -13
  49. package/src/util/trace.ts +42 -0
  50. package/src/bridge/fallback-todo.ts +0 -137
  51. package/src/bridge/interactive.ts +0 -65
  52. package/src/bridge/llm-query.ts +0 -156
  53. package/src/bridge/pi-interactive.ts +0 -41
  54. package/src/bridge/rlm-query.ts +0 -108
  55. package/src/core/artifacts.ts +0 -89
  56. package/src/core/critique.ts +0 -92
  57. package/src/core/gates.ts +0 -301
  58. package/src/core/pipeline-handlers.ts +0 -319
  59. package/src/core/pipeline.ts +0 -268
  60. package/src/prompts/phases.ts +0 -104
  61. package/src/sandbox/worker.py +0 -1078
  62. package/src/state/index.ts +0 -24
  63. package/src/state/internal.ts +0 -46
  64. package/src/state/paths.ts +0 -44
  65. package/src/state/reads.ts +0 -133
  66. package/src/state/resume.ts +0 -173
  67. package/src/state/rows.ts +0 -123
  68. package/src/state/writes.ts +0 -58
@@ -1,1078 +0,0 @@
1
- """RLM sandbox worker: a persistent Python REPL driven over a JSONL stdio protocol.
2
-
3
- Executes model-authored Python with secrets stripped from the environment.
4
- This is NOT a security sandbox: __import__ and open are available, so code can import networking modules
5
- (socket, urllib, subprocess) and read/write local files. Trust the root model's code.
6
-
7
- Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
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",...}
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).
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import argparse
20
- import fnmatch
21
- import heapq
22
- import io
23
- import json
24
- import math
25
- import os
26
- import pickle
27
- import re
28
- import signal
29
- import sys
30
- import time
31
- import traceback
32
- from contextlib import contextmanager
33
- from typing import Any
34
-
35
- # Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
36
- # All protocol writes must go to the real stdout even while user code's prints are captured.
37
- _REAL_STDOUT = sys.stdout
38
- _REAL_STDIN = sys.stdin
39
- _REAL_STDERR = sys.stderr
40
-
41
-
42
- def _builtin(name: str):
43
- return __builtins__[name] if isinstance(__builtins__, dict) else getattr(__builtins__, name, None)
44
-
45
-
46
- # Restricted builtins: enough for real data work, minus the dangerous reflection escapes.
47
- _SAFE_BUILTINS = {
48
- name: _builtin(name)
49
- for name in (
50
- "abs", "all", "any", "ascii", "bin", "bool", "bytearray", "bytes", "callable",
51
- "chr", "classmethod", "complex", "dict", "dir", "divmod", "enumerate", "filter",
52
- "float", "format", "frozenset", "getattr", "hasattr", "hash", "hex", "id", "int",
53
- "isinstance", "issubclass", "iter", "len", "list", "map", "max", "min", "next",
54
- "object", "oct", "ord", "pow", "print", "property", "range", "repr", "reversed",
55
- "round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super",
56
- "tuple", "type", "vars", "zip", "delattr", "memoryview", "__import__", "__build_class__",
57
- "Exception", "BaseException", "ValueError", "TypeError", "KeyError", "IndexError",
58
- "AttributeError", "FileNotFoundError", "OSError", "IOError", "RuntimeError",
59
- "NameError", "ImportError", "StopIteration", "AssertionError", "NotImplementedError",
60
- "ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
61
- )
62
- }
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
-
101
- for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
102
- _SAFE_BUILTINS[_blocked] = None
103
-
104
- RESERVED = frozenset(
105
- {
106
- "llm_query", "llm_query_batched", "llm_query_chunked",
107
- "rlm_query", "rlm_query_batched",
108
- "map_files", "llm_map_reduce",
109
- "search", "grep_context", "outline",
110
- "advance_phase", "save_artifact",
111
- "ask_user_question", "todo",
112
- "load_library",
113
- "SHOW_VARS", "answer", "context",
114
- }
115
- )
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")
121
-
122
- # Sizing for llm_query_chunked: leave room for the instruction and the chunk header.
123
- _CHUNK_HEADER_OVERHEAD = 64
124
- _MAX_CHUNK_BATCH = 20 # fan-out per llm_query_batched call (matches prompt guidance)
125
- _MAX_CHUNKS = 500 # ceiling: above this, force pre-filtering in Python
126
- _NUDGE_CHARS = 500_000 # str/bytes vars above this trigger a one-time stdout hint
127
-
128
-
129
- def _chunk_text(text: str, chunk_chars: int) -> list[str]:
130
- """Split text into <=chunk_chars pieces, preferring newline boundaries."""
131
- chunks: list[str] = []
132
- n = len(text)
133
- start = 0
134
- while start < n:
135
- end = min(start + chunk_chars, n)
136
- if end < n:
137
- nl = text.rfind("\n", start, end)
138
- if nl > start:
139
- end = nl + 1
140
- chunks.append(text[start:end])
141
- start = end
142
- return chunks
143
-
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
-
284
- class _AnswerDict(dict):
285
- """`answer` dict; flipping `ready` True captures the final answer for the parent."""
286
-
287
- def __init__(self, on_ready):
288
- super().__init__()
289
- super().__setitem__("content", "")
290
- super().__setitem__("ready", False)
291
- self._on_ready = on_ready
292
-
293
- def __setitem__(self, key, value):
294
- super().__setitem__(key, value)
295
- if key == "ready" and value:
296
- self._on_ready(self.get("content", ""))
297
-
298
-
299
- def _send(obj: dict[str, Any]) -> None:
300
- _REAL_STDOUT.write(json.dumps(obj, ensure_ascii=False) + "\n")
301
- _REAL_STDOUT.flush()
302
-
303
-
304
- class Worker:
305
- def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int, read_only: bool = False):
306
- self.depth = depth
307
- self.exec_timeout_s = exec_timeout_s
308
- self.max_prompt_chars = max_prompt_chars
309
- self.read_only = read_only
310
- self._rid = 0
311
- self._final_answer: str | None = None
312
- self.ns: dict[str, Any] = {}
313
- self._setup()
314
-
315
- def _setup(self) -> None:
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
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))
326
- self._restore_scaffold()
327
-
328
- def _capture_answer(self, content: Any) -> None:
329
- self._final_answer = str(content)
330
-
331
- def _restore_scaffold(self) -> None:
332
- # Re-inject any scaffolding the user code clobbered.
333
- ns = self.ns
334
- ns["llm_query"] = self._llm_query
335
- ns["llm_query_batched"] = self._llm_query_batched
336
- ns["llm_query_chunked"] = self._llm_query_chunked
337
- ns["rlm_query"] = self._rlm_query
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"] = {}
350
- ns["advance_phase"] = self._advance_phase
351
- ns["save_artifact"] = self._save_artifact
352
- ns["ask_user_question"] = self._ask_user_question
353
- ns["todo"] = self._todo
354
- ns["load_library"] = self._load_library
355
- ns["SHOW_VARS"] = self._show_vars
356
- if not isinstance(ns.get("answer"), _AnswerDict):
357
- cur = ns.get("answer")
358
- ans = _AnswerDict(self._capture_answer)
359
- if isinstance(cur, dict):
360
- for k, v in cur.items():
361
- dict.__setitem__(ans, k, v)
362
- if cur.get("ready") and self._final_answer is None:
363
- self._final_answer = str(cur.get("content", ""))
364
- ns["answer"] = ans
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]
374
-
375
- def _user_var_names(self) -> list[str]:
376
- """User-created variable names — filters builtins, scaffold, and `context`.
377
-
378
- Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
379
- This is the cheap orientation hint that goes into history instead of full stdout.
380
- """
381
- return [
382
- k for k in self.ns
383
- if not k.startswith("_")
384
- and not _CONTEXT_NAME.match(k)
385
- and k not in RESERVED
386
- ]
387
-
388
- def _show_vars(self) -> str:
389
- avail = {k: type(self.ns[k]).__name__ for k in self._user_var_names()}
390
- return f"Available variables: {avail}" if avail else "No variables created yet."
391
-
392
- # ---- sub-LLM bridge over stdio --------------------------------------------------------
393
-
394
- def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
395
- self._rid += 1
396
- rid = f"q{self._rid}"
397
- _send({"type": kind, "rid": rid, "depth": self.depth, **payload})
398
- # The per-cell SIGALRM is wall-clock; it must not count time blocked here waiting for
399
- # a sub-LLM reply (network/LLM latency, not local CPU). Pause it across the readline.
400
- pause = self.exec_timeout_s > 0 and hasattr(signal, "SIGALRM")
401
- if pause:
402
- remaining = signal.getitimer(signal.ITIMER_REAL)[0]
403
- signal.setitimer(signal.ITIMER_REAL, 0)
404
- try:
405
- while True:
406
- line = _REAL_STDIN.readline()
407
- if not line:
408
- raise RuntimeError("parent closed the pipe during a sub-LLM request")
409
- msg = json.loads(line)
410
- if msg.get("type") == "llm_reply" and msg.get("rid") == rid:
411
- return msg
412
- # Stray/late message (e.g. a reply to an earlier timed-out request): skip it.
413
- print(
414
- f"[rlm-sandbox] ignoring unexpected message during sub-LLM request: {str(msg)[:200]}",
415
- file=_REAL_STDERR,
416
- )
417
- finally:
418
- if pause and remaining > 0:
419
- signal.setitimer(signal.ITIMER_REAL, remaining)
420
-
421
- def _llm_query(self, prompt: str, model: str | None = None) -> str:
422
- r = self._rpc("llm_query", {"prompt": str(prompt), "model": model})
423
- return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
424
-
425
- def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
426
- prompts = [str(p) for p in prompts]
427
- if not prompts:
428
- return []
429
- r = self._rpc("llm_query_batched", {"prompts": prompts, "model": model})
430
- if r.get("error"):
431
- return [f"Error: {r['error']}"] * len(prompts)
432
- out = r.get("responses")
433
- if not isinstance(out, list) or len(out) != len(prompts):
434
- return ["Error: malformed batched response"] * len(prompts)
435
- return [s if isinstance(s, str) else f"Error: {s}" for s in out]
436
-
437
- def _llm_query_chunked(self, text, prompt: str, model: str | None = None) -> list[str]:
438
- """Split oversized text into cap-sized chunks and fan out via llm_query_batched.
439
-
440
- Returns one answer per chunk, order preserved. No exceptions escape: errors come
441
- back as "Error: ..." strings per chunk (same contract as llm_query_batched).
442
-
443
- NOTE: budget uses Python code-point length (len) while the parent-side cap check counts
444
- UTF-16 units (JS string.length); astral/emoji-heavy text may be marginally larger on the
445
- parent and get per-chunk rejected. Acceptable trade-off for typical code/log/profile text.
446
- """
447
- text, prompt = str(text), str(prompt)
448
- if not text:
449
- return []
450
- budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD
451
- if budget < 1_000:
452
- return [f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) — shorten the instruction"]
453
- chunks = _chunk_text(text, budget)
454
- total = len(chunks)
455
- if total > _MAX_CHUNKS:
456
- return [f"Error: {total} chunks would be needed — filter/slice the text in Python first"]
457
- results: list[str] = []
458
- for i in range(0, total, _MAX_CHUNK_BATCH):
459
- batch = [
460
- f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
461
- for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
462
- ]
463
- results.extend(self._llm_query_batched(batch, model))
464
- return results
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
-
660
- def _rlm_query(self, prompt: str, model: str | None = None) -> str:
661
- r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
662
- return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
663
-
664
- def _ask_user_question(self, questions: list[dict]) -> list[dict]:
665
- """Present structured questions to the user; blocks until answered.
666
-
667
- Returns a list of {question, selected, custom?} dicts.
668
- Each dict has: question (str), selected (list[str]), custom (str|None).
669
- Only valid at root depth; sub-RLM calls return an error answer.
670
- """
671
- if self.depth > 0:
672
- qlist = questions if isinstance(questions, list) else []
673
- return [
674
- {"question": str(q.get("question", "")) if isinstance(q, dict) else "",
675
- "selected": [],
676
- "custom": "Error: ask_user_question not available inside rlm_query sub-calls"}
677
- for q in qlist
678
- ] or [{"question": "", "selected": [],
679
- "custom": "Error: ask_user_question not available inside rlm_query sub-calls"}]
680
- if not isinstance(questions, list) or not questions:
681
- return [{"question": "", "selected": [], "custom": "Error: questions must be a non-empty list"}]
682
- cleaned = []
683
- for q in questions:
684
- if not isinstance(q, dict) or "question" not in q or "options" not in q:
685
- return [{"question": "", "selected": [], "custom": "Error: each question needs 'question', 'header', 'options'"}]
686
- opts = q.get("options")
687
- if not isinstance(opts, list):
688
- return [{"question": str(q.get("question", "")), "selected": [], "custom": "Error: options must be a list"}]
689
- cleaned_opts = []
690
- for o in opts:
691
- if not isinstance(o, dict) or "label" not in o:
692
- return [{"question": str(q.get("question", "")), "selected": [], "custom": "Error: each option needs 'label'"}]
693
- item = {"label": str(o["label"]), "description": str(o.get("description", ""))}
694
- if "preview" in o:
695
- item["preview"] = str(o["preview"])
696
- cleaned_opts.append(item)
697
- cleaned.append({
698
- "question": str(q["question"]),
699
- "header": str(q.get("header", "Q")),
700
- "multiSelect": bool(q.get("multiSelect", False)),
701
- "options": cleaned_opts,
702
- })
703
- r = self._rpc("ask_user_question", {"questions": cleaned})
704
- if r.get("error"):
705
- return [{"question": q["question"], "selected": [], "custom": f"Error: {r['error']}"} for q in cleaned]
706
- answers = r.get("answers", [])
707
- return answers if isinstance(answers, list) else []
708
-
709
- def _todo(self, action: str, **kwargs) -> str:
710
- """Manage the run's task list.
711
-
712
- action: "create" | "update" | "list" | "get" | "delete" | "clear"
713
- kwargs: id, subject, description, status, activeForm, blockedBy, owner, filterStatus
714
- Returns a human-readable string result.
715
- """
716
- params = {k: v for k, v in kwargs.items() if v is not None}
717
- r = self._rpc("todo", {"action": str(action), **params})
718
- if r.get("error"):
719
- return f"Error: {r['error']}"
720
- return str(r.get("response", "ok"))
721
-
722
- def _load_library(self, source: str) -> dict[str, Any] | str:
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
- """
729
- r = self._rpc("load_library", {"source": str(source)})
730
- if r.get("error"):
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
- }
750
- path = r.get("path")
751
- if not isinstance(path, str):
752
- return "Error: malformed load_library reply (no path)"
753
- try:
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()
757
- finally:
758
- try:
759
- os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
760
- except OSError:
761
- pass
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
834
-
835
- def _advance_phase(self, phase: str, summary: str | None = None) -> str:
836
- """Transition the root RLM pipeline to a new phase.
837
-
838
- Only callable at depth 0. The parent handler validates the transition
839
- against the phase state machine (research → blueprint → validate)
840
- and runs deterministic artifact gates before accepting the transition.
841
- Returns a short confirmation, or an `Error: …` string the model can act on.
842
- """
843
- if self.depth > 0:
844
- return "Error: advance_phase is only available at the root RLM depth"
845
- r = self._rpc("advance_phase", {"phase": str(phase), "summary": summary})
846
- if r.get("error"):
847
- return f"Error: {r['error']}"
848
- response = r.get("response", "ok")
849
- if isinstance(response, str) and response.startswith("Error:"):
850
- return response
851
- return response if isinstance(response, str) else "ok"
852
-
853
- def _save_artifact(self, kind: str, content: str) -> str:
854
- """Persist a stage artifact (research/plan/validation) under .rlm/artifacts/.
855
-
856
- Only callable at depth 0. The engine gates advance_phase against the latest
857
- saved artifact for the current stage.
858
- """
859
- if self.depth > 0:
860
- return "Error: save_artifact is only available at the root RLM depth"
861
- r = self._rpc("save_artifact", {"artifactKind": str(kind), "content": str(content)})
862
- if r.get("error"):
863
- return f"Error: {r['error']}"
864
- response = r.get("response", "ok")
865
- if isinstance(response, str) and response.startswith("Error:"):
866
- return response
867
- return response if isinstance(response, str) else "ok"
868
-
869
- def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
870
- prompts = [str(p) for p in prompts]
871
- if not prompts:
872
- return []
873
- r = self._rpc("rlm_query_batched", {"prompts": prompts, "model": model})
874
- if r.get("error"):
875
- return [f"Error: {r['error']}"] * len(prompts)
876
- out = r.get("responses")
877
- if not isinstance(out, list) or len(out) != len(prompts):
878
- return ["Error: malformed batched response"] * len(prompts)
879
- return [s if isinstance(s, str) else f"Error: {s}" for s in out]
880
-
881
- # ---- context + execution --------------------------------------------------------------
882
-
883
- def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
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
- """
889
- with open(path, "r") as f:
890
- payload = json.load(f) if is_json else f.read()
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
898
-
899
- @contextmanager
900
- def _capture(self):
901
- out, err = io.StringIO(), io.StringIO()
902
- old_out, old_err = sys.stdout, sys.stderr
903
- sys.stdout, sys.stderr = out, err
904
- try:
905
- yield out, err
906
- finally:
907
- sys.stdout, sys.stderr = old_out, old_err
908
-
909
- def _exec(self, code: str, ns: dict[str, Any]) -> None:
910
- t = self.exec_timeout_s
911
- if t <= 0 or not hasattr(signal, "SIGALRM"):
912
- exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
913
- return
914
-
915
- def _alarm(signum, frame): # noqa: ARG001
916
- raise TimeoutError(f"```repl``` block exceeded {t:g}s timeout")
917
-
918
- old = signal.signal(signal.SIGALRM, _alarm)
919
- signal.setitimer(signal.ITIMER_REAL, t)
920
- try:
921
- exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
922
- finally:
923
- signal.setitimer(signal.ITIMER_REAL, 0)
924
- signal.signal(signal.SIGALRM, old)
925
-
926
- def _nudge_lines(self) -> list[str]:
927
- """One-time hint for newly created huge raw-text variables (single line).
928
-
929
- Collapses to one line so it survives headless stdout elision (head 200 + tail 200).
930
- """
931
- names: list[str] = []
932
- for k in self._user_var_names():
933
- v = self.ns.get(k)
934
- if isinstance(v, (str, bytes)) and len(v) > _NUDGE_CHARS and k not in self._nudged:
935
- self._nudged.add(k)
936
- names.append(f"{k} ({len(v):,} chars)")
937
- if not names:
938
- return []
939
- return [
940
- f"[rlm] huge raw-text variable(s): {', '.join(names)} — do NOT analyze them yourself; "
941
- 'delegate with llm_query_chunked(name, "your question") or slice + llm_query_batched.'
942
- ]
943
-
944
- def execute(self, code: str) -> dict[str, Any]:
945
- start = time.perf_counter()
946
- raised = False
947
- with self._capture() as (out, err):
948
- try:
949
- self._restore_scaffold()
950
- self._exec(code, self.ns)
951
- self._restore_scaffold()
952
- stdout, stderr = out.getvalue(), err.getvalue()
953
- except BaseException as e: # noqa: BLE001
954
- raised = True
955
- self._restore_scaffold()
956
- stdout = out.getvalue()
957
- stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
958
- final, self._final_answer = self._final_answer, None
959
- answer = self.ns.get("answer")
960
- answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
961
- # ready may have been flipped with empty content before content was assigned later
962
- # in the same block; the dict's current content is the real submission.
963
- if final is not None and not final.strip() and str(answer_content).strip():
964
- final = str(answer_content)
965
- nudges = self._nudge_lines()
966
- if nudges:
967
- parts = [stdout] if stdout else []
968
- parts.extend(nudges)
969
- stdout = "\n".join(parts) + "\n"
970
- return {
971
- "stdout": stdout,
972
- "stderr": stderr,
973
- "final_answer": final,
974
- "answer_content": str(answer_content),
975
- "raised": raised,
976
- "execution_time": time.perf_counter() - start,
977
- "var_names": self._user_var_names(),
978
- }
979
-
980
- def _serializer(self):
981
- try:
982
- import dill as s
983
- return s
984
- except ImportError:
985
- return pickle
986
-
987
- def snapshot(self, path: str, nonce: str) -> dict:
988
- """Pickle user variables atomically to path. Stores session nonce for restore verification.
989
-
990
- Writes to path.tmp then os.rename — atomic on POSIX, so no .tmp leak and no
991
- TypeScript-side finalize step needed. On resume (fresh session = different nonce),
992
- restore fails — caller falls back to history-only replay.
993
- """
994
- s = self._serializer()
995
- out, skipped = {}, []
996
- MAX_VAR_BYTES = 50 * 1024 * 1024
997
- for k, v in self.ns.items():
998
- if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
999
- continue
1000
- try:
1001
- blob = s.dumps(v)
1002
- if len(blob) > MAX_VAR_BYTES:
1003
- skipped.append(k)
1004
- continue
1005
- out[k] = v
1006
- except Exception:
1007
- skipped.append(k)
1008
- if skipped:
1009
- print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
1010
- tmp = path + ".tmp"
1011
- with _REAL_IO_OPEN(tmp, "wb") as f:
1012
- s.dump({"nonce": nonce, "vars": out}, f)
1013
- os.rename(tmp, path) # atomic rename
1014
- return {"skipped": skipped}
1015
-
1016
- def restore(self, path: str, nonce: str) -> dict:
1017
- """Restore user variables from a pickle file. Verifies session nonce before deserializing.
1018
-
1019
- SECURITY: pickle.load executes arbitrary code. The session nonce check ensures the
1020
- .pkl was written by THIS engine session. Cross-session resume falls back to
1021
- history-only replay (caller skips restore when sessionNonce is undefined).
1022
- """
1023
- s = self._serializer()
1024
- with _REAL_IO_OPEN(path, "rb") as f:
1025
- data = s.load(f)
1026
- if not isinstance(data, dict) or data.get("nonce") != nonce:
1027
- raise ValueError("snapshot nonce mismatch — not from this session")
1028
- self.ns.update(data.get("vars", {}))
1029
- self._restore_scaffold()
1030
- return {"restored": list(data.get("vars", {}).keys())}
1031
-
1032
-
1033
- def main() -> None:
1034
- ap = argparse.ArgumentParser()
1035
- ap.add_argument("--depth", type=int, default=int(os.environ.get("RLM_DEPTH", "1")))
1036
- ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
1037
- ap.add_argument("--max-prompt-chars", type=int,
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)")
1042
- args = ap.parse_args()
1043
-
1044
- worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
1045
- max_prompt_chars=args.max_prompt_chars, read_only=args.read_only)
1046
- _send({"id": "_init", "ok": True})
1047
-
1048
- for raw in _REAL_STDIN:
1049
- raw = raw.strip()
1050
- if not raw:
1051
- continue
1052
- try:
1053
- req = json.loads(raw)
1054
- except json.JSONDecodeError as e:
1055
- _send({"id": "?", "ok": False, "error": f"bad json: {e}"})
1056
- continue
1057
- rid, kind = req.get("id", "?"), req.get("type")
1058
- try:
1059
- if kind == "exec":
1060
- _send({"id": rid, "ok": True, **worker.execute(req.get("code", ""))})
1061
- elif kind == "load_context":
1062
- idx = worker.load_context(req.get("path"), req.get("index"), req.get("json"))
1063
- _send({"id": rid, "ok": True, "index": idx})
1064
- elif kind == "shutdown":
1065
- _send({"id": rid, "ok": True})
1066
- return
1067
- elif kind == "snapshot":
1068
- _send({"id": rid, "ok": True, **worker.snapshot(req.get("path", ""), req.get("nonce", ""))})
1069
- elif kind == "restore":
1070
- _send({"id": rid, "ok": True, **worker.restore(req.get("path", ""), req.get("nonce", ""))})
1071
- else:
1072
- _send({"id": rid, "ok": False, "error": f"unknown type: {kind!r}"})
1073
- except BaseException as e: # noqa: BLE001
1074
- _send({"id": rid, "ok": False, "error": f"{type(e).__name__}: {e}\n{traceback.format_exc()}"})
1075
-
1076
-
1077
- if __name__ == "__main__":
1078
- main()