@hicaru/pi-rlm 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -4
- package/package.json +2 -1
- package/src/bridge/library.ts +155 -0
- package/src/bridge/llm-query.ts +1 -0
- package/src/bridge/rlm-query.ts +56 -12
- package/src/config/defaults.ts +2 -0
- package/src/config/settings.ts +4 -0
- package/src/context/library-context.ts +266 -0
- package/src/context/repomix-context.ts +2 -48
- package/src/core/answer.ts +1 -10
- package/src/core/artifacts.ts +88 -0
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +446 -53
- package/src/core/gates.ts +301 -0
- package/src/core/iteration.ts +7 -2
- package/src/core/pipeline.ts +196 -28
- package/src/core/types.ts +5 -3
- package/src/index.ts +3 -6
- package/src/mode/native-guards.ts +2 -2
- package/src/prompts/phases.ts +104 -0
- package/src/prompts/system.ts +59 -16
- package/src/prompts/user.ts +12 -4
- package/src/sandbox/protocol.ts +29 -11
- package/src/sandbox/sandbox.ts +77 -2
- package/src/sandbox/worker.py +215 -46
- package/src/state/index.ts +2 -1
- package/src/state/paths.ts +4 -2
- package/src/state/reads.ts +31 -2
- package/src/state/resume.ts +31 -6
- package/src/state/rows.ts +8 -2
- package/src/state/writes.ts +5 -3
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +52 -57
- package/src/tool/rlm-aggregator.ts +7 -7
- package/src/tool/rlm-details.ts +6 -3
- package/src/tool/rlm-events.ts +14 -11
- package/src/tool/rlm-tool.ts +2 -8
- package/src/tool/subcall-store.ts +2 -0
- package/src/ui/config-panel.ts +8 -1
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -16
- package/src/tool/apply-edits-tool.ts +0 -288
package/src/sandbox/worker.py
CHANGED
|
@@ -7,9 +7,9 @@ This is NOT a security sandbox: __import__ and open are available, so code can i
|
|
|
7
7
|
Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
|
|
8
8
|
Protocol (worker -> parent): {"id","ok",...result} # response to a request
|
|
9
9
|
{"type":"llm_query"|"llm_query_batched"|"rlm_query"|...
|
|
10
|
-
"advance_phase"|"ask_user_question"|"todo","rid",...}
|
|
10
|
+
"advance_phase"|"save_artifact"|"ask_user_question"|"todo","rid",...}
|
|
11
11
|
# mid-exec helper request
|
|
12
|
-
When sandbox code calls llm_query/rlm_query/advance_phase/ask_user_question/todo, the worker writes a request line
|
|
12
|
+
When sandbox code calls llm_query/rlm_query/advance_phase/save_artifact/ask_user_question/todo, the worker writes a request line
|
|
13
13
|
and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
|
|
14
14
|
services the request in-process (it holds API keys).
|
|
15
15
|
"""
|
|
@@ -57,8 +57,44 @@ _SAFE_BUILTINS = {
|
|
|
57
57
|
"ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
|
|
58
58
|
)
|
|
59
59
|
}
|
|
60
|
-
# `open` is allowed
|
|
61
|
-
|
|
60
|
+
# `open` is allowed for data work; eval/exec/compile/input/globals/locals are not.
|
|
61
|
+
# When read_only=True (pipeline runs), write modes raise PermissionError via
|
|
62
|
+
# builtins.open, io.open (pathlib), and os.open. Steering, not a security sandbox.
|
|
63
|
+
_WRITE_MODE_CHARS = frozenset("wax+")
|
|
64
|
+
_OS_WRITE_FLAGS = os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_TRUNC
|
|
65
|
+
|
|
66
|
+
_REAL_IO_OPEN = io.open
|
|
67
|
+
_REAL_OS_OPEN = os.open
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _install_read_only_guards():
|
|
71
|
+
"""Route every common file-open path through the read-only check.
|
|
72
|
+
|
|
73
|
+
Steering, not a sandbox: closes builtins.open, io.open (hence pathlib), and
|
|
74
|
+
os.open. A determined model can still reach the filesystem via ctypes or a
|
|
75
|
+
subprocess — the goal is that ACCIDENTAL writes cannot pass silently.
|
|
76
|
+
Worker-internal I/O keeps using _REAL_IO_OPEN / _REAL_OS_OPEN.
|
|
77
|
+
"""
|
|
78
|
+
def guarded_io_open(file, mode="r", *args, **kwargs):
|
|
79
|
+
if _WRITE_MODE_CHARS & set(str(mode)):
|
|
80
|
+
raise PermissionError(
|
|
81
|
+
f"read-only RLM run: refusing to open {file!r} with mode {mode!r}. "
|
|
82
|
+
"This pipeline produces a plan; file changes go through the host edit tool."
|
|
83
|
+
)
|
|
84
|
+
return _REAL_IO_OPEN(file, mode, *args, **kwargs)
|
|
85
|
+
|
|
86
|
+
def guarded_os_open(path, flags, *args, **kwargs):
|
|
87
|
+
if flags & _OS_WRITE_FLAGS:
|
|
88
|
+
raise PermissionError(
|
|
89
|
+
f"read-only RLM run: refusing os.open({path!r}) with write flags."
|
|
90
|
+
)
|
|
91
|
+
return _REAL_OS_OPEN(path, flags, *args, **kwargs)
|
|
92
|
+
|
|
93
|
+
io.open = guarded_io_open
|
|
94
|
+
os.open = guarded_os_open
|
|
95
|
+
return guarded_io_open
|
|
96
|
+
|
|
97
|
+
|
|
62
98
|
for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
|
|
63
99
|
_SAFE_BUILTINS[_blocked] = None
|
|
64
100
|
|
|
@@ -66,13 +102,14 @@ RESERVED = frozenset(
|
|
|
66
102
|
{
|
|
67
103
|
"llm_query", "llm_query_batched", "llm_query_chunked",
|
|
68
104
|
"rlm_query", "rlm_query_batched",
|
|
69
|
-
"advance_phase",
|
|
105
|
+
"advance_phase", "save_artifact",
|
|
70
106
|
"ask_user_question", "todo",
|
|
71
|
-
"
|
|
107
|
+
"load_library",
|
|
72
108
|
"SHOW_VARS", "answer", "context",
|
|
73
109
|
}
|
|
74
110
|
)
|
|
75
|
-
|
|
111
|
+
# Only the single name `context` is the packed world. Legacy context_N names are filtered out.
|
|
112
|
+
_CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
|
|
76
113
|
|
|
77
114
|
# Sizing for llm_query_chunked: leave room for the instruction and the chunk header.
|
|
78
115
|
_CHUNK_HEADER_OVERHEAD = 64
|
|
@@ -118,21 +155,24 @@ def _send(obj: dict[str, Any]) -> None:
|
|
|
118
155
|
|
|
119
156
|
|
|
120
157
|
class Worker:
|
|
121
|
-
def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int):
|
|
158
|
+
def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int, read_only: bool = False):
|
|
122
159
|
self.depth = depth
|
|
123
160
|
self.exec_timeout_s = exec_timeout_s
|
|
124
161
|
self.max_prompt_chars = max_prompt_chars
|
|
162
|
+
self.read_only = read_only
|
|
125
163
|
self._rid = 0
|
|
126
164
|
self._final_answer: str | None = None
|
|
127
|
-
self._context_count = 0
|
|
128
165
|
self.ns: dict[str, Any] = {}
|
|
129
166
|
self._setup()
|
|
130
167
|
|
|
131
168
|
def _setup(self) -> None:
|
|
132
|
-
|
|
133
|
-
self.
|
|
134
|
-
|
|
135
|
-
|
|
169
|
+
builtins = _SAFE_BUILTINS.copy()
|
|
170
|
+
if self.read_only:
|
|
171
|
+
builtins["open"] = _install_read_only_guards()
|
|
172
|
+
else:
|
|
173
|
+
builtins["open"] = open
|
|
174
|
+
self.ns = {"__builtins__": builtins, "__name__": "__main__"}
|
|
175
|
+
self._context_payload: Any | None = None # pristine restore for the single `context` var
|
|
136
176
|
self._nudged: set[str] = set()
|
|
137
177
|
self._restore_scaffold()
|
|
138
178
|
|
|
@@ -148,9 +188,10 @@ class Worker:
|
|
|
148
188
|
ns["rlm_query"] = self._rlm_query
|
|
149
189
|
ns["rlm_query_batched"] = self._rlm_query_batched
|
|
150
190
|
ns["advance_phase"] = self._advance_phase
|
|
191
|
+
ns["save_artifact"] = self._save_artifact
|
|
151
192
|
ns["ask_user_question"] = self._ask_user_question
|
|
152
193
|
ns["todo"] = self._todo
|
|
153
|
-
ns["
|
|
194
|
+
ns["load_library"] = self._load_library
|
|
154
195
|
ns["SHOW_VARS"] = self._show_vars
|
|
155
196
|
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
156
197
|
cur = ns.get("answer")
|
|
@@ -161,17 +202,18 @@ class Worker:
|
|
|
161
202
|
if cur.get("ready") and self._final_answer is None:
|
|
162
203
|
self._final_answer = str(cur.get("content", ""))
|
|
163
204
|
ns["answer"] = ans
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
# Single context variable (RLM paper: the context lives in the environment and
|
|
206
|
+
# the model may transform it in place). Re-inject only if the model deleted the
|
|
207
|
+
# name entirely; mutations and re-binds persist within the run.
|
|
208
|
+
if self._context_payload is not None:
|
|
209
|
+
ns.setdefault("context", self._context_payload)
|
|
210
|
+
# Scrub any legacy context_N names so the model never sees multi-slot APIs.
|
|
211
|
+
for k in list(ns.keys()):
|
|
212
|
+
if k != "context" and _CONTEXT_NAME.match(k):
|
|
213
|
+
del ns[k]
|
|
172
214
|
|
|
173
215
|
def _user_var_names(self) -> list[str]:
|
|
174
|
-
"""User-created variable names — filters builtins, scaffold, and context
|
|
216
|
+
"""User-created variable names — filters builtins, scaffold, and `context`.
|
|
175
217
|
|
|
176
218
|
Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
|
|
177
219
|
This is the cheap orientation hint that goes into history instead of full stdout.
|
|
@@ -179,7 +221,7 @@ class Worker:
|
|
|
179
221
|
return [
|
|
180
222
|
k for k in self.ns
|
|
181
223
|
if not k.startswith("_")
|
|
182
|
-
and not
|
|
224
|
+
and not _CONTEXT_NAME.match(k)
|
|
183
225
|
and k not in RESERVED
|
|
184
226
|
]
|
|
185
227
|
|
|
@@ -323,19 +365,125 @@ class Worker:
|
|
|
323
365
|
return f"Error: {r['error']}"
|
|
324
366
|
return str(r.get("response", "ok"))
|
|
325
367
|
|
|
326
|
-
def
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
368
|
+
def _load_library(self, source: str) -> dict[str, Any] | str:
|
|
369
|
+
"""Pack an external dir/file/git-URL on the host and append it into `context`.
|
|
370
|
+
|
|
371
|
+
Paths are namespaced under lib/<source_id>/ (host). Content is always in the
|
|
372
|
+
single `context` list — never a new context_N variable.
|
|
373
|
+
Host-side idempotency may return already_loaded without a payload path.
|
|
374
|
+
"""
|
|
375
|
+
r = self._rpc("load_library", {"source": str(source)})
|
|
376
|
+
if r.get("error"):
|
|
377
|
+
return f"Error: {r['error']}"
|
|
378
|
+
if r.get("already_loaded"):
|
|
379
|
+
source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "lib"
|
|
380
|
+
path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"lib/{source_id}/"
|
|
381
|
+
ctx = self.ns.get("context")
|
|
382
|
+
ctx_len = len(ctx) if isinstance(ctx, list) else 0
|
|
383
|
+
print(
|
|
384
|
+
f"[rlm] load_library: already loaded {source_id} "
|
|
385
|
+
f"(paths under {path_prefix}, context len={ctx_len})"
|
|
386
|
+
)
|
|
387
|
+
return {
|
|
388
|
+
"source": str(source),
|
|
389
|
+
"source_id": source_id,
|
|
390
|
+
"path_prefix": path_prefix,
|
|
391
|
+
"files": 0,
|
|
392
|
+
"chars": r.get("chars"),
|
|
393
|
+
"context_len": ctx_len,
|
|
394
|
+
"already_loaded": True,
|
|
395
|
+
}
|
|
396
|
+
path = r.get("path")
|
|
397
|
+
if not isinstance(path, str):
|
|
398
|
+
return "Error: malformed load_library reply (no path)"
|
|
399
|
+
try:
|
|
400
|
+
# Worker-internal read — use real io.open so read-only guards never block us.
|
|
401
|
+
with _REAL_IO_OPEN(path, "r") as f:
|
|
402
|
+
payload = json.load(f) if r.get("json") else f.read()
|
|
403
|
+
finally:
|
|
404
|
+
try:
|
|
405
|
+
os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
|
|
406
|
+
except OSError:
|
|
407
|
+
pass
|
|
408
|
+
return self._append_library(str(source), payload, r)
|
|
409
|
+
|
|
410
|
+
def _append_library(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
|
|
411
|
+
"""Append host-packed library files into `context` (idempotent by path prefix)."""
|
|
412
|
+
ctx = self.ns.get("context")
|
|
413
|
+
if not isinstance(ctx, list):
|
|
414
|
+
kind = type(ctx).__name__ if ctx is not None else "None"
|
|
415
|
+
return f"Error: load_library requires list context (file bundle); got {kind}"
|
|
416
|
+
|
|
417
|
+
source_id = meta.get("source_id")
|
|
418
|
+
if not isinstance(source_id, str) or not source_id:
|
|
419
|
+
source_id = "lib"
|
|
420
|
+
path_prefix = meta.get("path_prefix")
|
|
421
|
+
if not isinstance(path_prefix, str) or not path_prefix:
|
|
422
|
+
path_prefix = f"lib/{source_id}/"
|
|
423
|
+
|
|
424
|
+
# Idempotent: already present if any path uses this library prefix.
|
|
425
|
+
for item in ctx:
|
|
426
|
+
if isinstance(item, dict) and str(item.get("path", "")).startswith(path_prefix):
|
|
427
|
+
print(
|
|
428
|
+
f"[rlm] load_library: already loaded {source_id} "
|
|
429
|
+
f"(paths under {path_prefix}, context len={len(ctx)})"
|
|
430
|
+
)
|
|
431
|
+
return {
|
|
432
|
+
"source": source,
|
|
433
|
+
"source_id": source_id,
|
|
434
|
+
"path_prefix": path_prefix,
|
|
435
|
+
"files": 0,
|
|
436
|
+
"chars": meta.get("chars"),
|
|
437
|
+
"context_len": len(ctx),
|
|
438
|
+
"already_loaded": True,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
files = self._library_file_entries(payload, path_prefix)
|
|
442
|
+
if not files:
|
|
443
|
+
return "Error: load_library produced no files"
|
|
444
|
+
|
|
445
|
+
ctx.extend(files)
|
|
446
|
+
# Keep restore payload in sync with the live list.
|
|
447
|
+
self._context_payload = ctx
|
|
448
|
+
self.ns["context"] = ctx
|
|
449
|
+
|
|
450
|
+
print(
|
|
451
|
+
f"[rlm] load_library: +{len(files)} files into context "
|
|
452
|
+
f"(len={len(ctx)}); paths under {path_prefix}"
|
|
453
|
+
)
|
|
454
|
+
return {
|
|
455
|
+
"source": source,
|
|
456
|
+
"source_id": source_id,
|
|
457
|
+
"path_prefix": path_prefix,
|
|
458
|
+
"files": len(files),
|
|
459
|
+
"chars": meta.get("chars"),
|
|
460
|
+
"context_len": len(ctx),
|
|
461
|
+
"already_loaded": False,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def _library_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
|
|
466
|
+
"""Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
|
|
467
|
+
if isinstance(payload, str):
|
|
468
|
+
return [{
|
|
469
|
+
"path": f"{path_prefix}content",
|
|
470
|
+
"content": payload,
|
|
471
|
+
"tokens": max(1, (len(payload) + 3) // 4),
|
|
472
|
+
}]
|
|
473
|
+
if not isinstance(payload, list):
|
|
474
|
+
return []
|
|
475
|
+
out: list[dict[str, Any]] = []
|
|
476
|
+
for item in payload:
|
|
477
|
+
if isinstance(item, dict) and "path" in item and "content" in item:
|
|
478
|
+
out.append(item)
|
|
479
|
+
return out
|
|
333
480
|
|
|
334
481
|
def _advance_phase(self, phase: str, summary: str | None = None) -> str:
|
|
335
482
|
"""Transition the root RLM pipeline to a new phase.
|
|
336
483
|
|
|
337
484
|
Only callable at depth 0. The parent handler validates the transition
|
|
338
|
-
against the phase state machine (research → blueprint →
|
|
485
|
+
against the phase state machine (research → blueprint → validate)
|
|
486
|
+
and runs deterministic artifact gates before accepting the transition.
|
|
339
487
|
Returns a short confirmation, or an `Error: …` string the model can act on.
|
|
340
488
|
"""
|
|
341
489
|
if self.depth > 0:
|
|
@@ -348,6 +496,22 @@ class Worker:
|
|
|
348
496
|
return response
|
|
349
497
|
return response if isinstance(response, str) else "ok"
|
|
350
498
|
|
|
499
|
+
def _save_artifact(self, kind: str, content: str) -> str:
|
|
500
|
+
"""Persist a stage artifact (research/plan/validation) under .rlm/artifacts/.
|
|
501
|
+
|
|
502
|
+
Only callable at depth 0. The engine gates advance_phase against the latest
|
|
503
|
+
saved artifact for the current stage.
|
|
504
|
+
"""
|
|
505
|
+
if self.depth > 0:
|
|
506
|
+
return "Error: save_artifact is only available at the root RLM depth"
|
|
507
|
+
r = self._rpc("save_artifact", {"artifactKind": str(kind), "content": str(content)})
|
|
508
|
+
if r.get("error"):
|
|
509
|
+
return f"Error: {r['error']}"
|
|
510
|
+
response = r.get("response", "ok")
|
|
511
|
+
if isinstance(response, str) and response.startswith("Error:"):
|
|
512
|
+
return response
|
|
513
|
+
return response if isinstance(response, str) else "ok"
|
|
514
|
+
|
|
351
515
|
def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
|
|
352
516
|
prompts = [str(p) for p in prompts]
|
|
353
517
|
if not prompts:
|
|
@@ -363,16 +527,20 @@ class Worker:
|
|
|
363
527
|
# ---- context + execution --------------------------------------------------------------
|
|
364
528
|
|
|
365
529
|
def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
|
|
366
|
-
|
|
367
|
-
|
|
530
|
+
"""Load the packed world into the single REPL variable `context`.
|
|
531
|
+
|
|
532
|
+
`index` is accepted for protocol compatibility but ignored — there is only
|
|
533
|
+
one context slot. Libraries are merged on the host (or via load_library).
|
|
534
|
+
"""
|
|
368
535
|
with open(path, "r") as f:
|
|
369
536
|
payload = json.load(f) if is_json else f.read()
|
|
370
|
-
self.
|
|
371
|
-
self.ns[
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
537
|
+
self._context_payload = payload
|
|
538
|
+
self.ns["context"] = payload
|
|
539
|
+
# Drop legacy multi-slot names if present.
|
|
540
|
+
for k in list(self.ns.keys()):
|
|
541
|
+
if k != "context" and _CONTEXT_NAME.match(k):
|
|
542
|
+
del self.ns[k]
|
|
543
|
+
return 0
|
|
376
544
|
|
|
377
545
|
@contextmanager
|
|
378
546
|
def _capture(self):
|
|
@@ -434,7 +602,6 @@ class Worker:
|
|
|
434
602
|
stdout = out.getvalue()
|
|
435
603
|
stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
|
|
436
604
|
final, self._final_answer = self._final_answer, None
|
|
437
|
-
edits, self._staged_edits = self._staged_edits, []
|
|
438
605
|
answer = self.ns.get("answer")
|
|
439
606
|
answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
|
|
440
607
|
# ready may have been flipped with empty content before content was assigned later
|
|
@@ -451,7 +618,6 @@ class Worker:
|
|
|
451
618
|
"stderr": stderr,
|
|
452
619
|
"final_answer": final,
|
|
453
620
|
"answer_content": str(answer_content),
|
|
454
|
-
"edits": edits,
|
|
455
621
|
"raised": raised,
|
|
456
622
|
"execution_time": time.perf_counter() - start,
|
|
457
623
|
"var_names": self._user_var_names(),
|
|
@@ -475,7 +641,7 @@ class Worker:
|
|
|
475
641
|
out, skipped = {}, []
|
|
476
642
|
MAX_VAR_BYTES = 50 * 1024 * 1024
|
|
477
643
|
for k, v in self.ns.items():
|
|
478
|
-
if k.startswith("_") or
|
|
644
|
+
if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
|
|
479
645
|
continue
|
|
480
646
|
try:
|
|
481
647
|
blob = s.dumps(v)
|
|
@@ -488,7 +654,7 @@ class Worker:
|
|
|
488
654
|
if skipped:
|
|
489
655
|
print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
|
|
490
656
|
tmp = path + ".tmp"
|
|
491
|
-
with
|
|
657
|
+
with _REAL_IO_OPEN(tmp, "wb") as f:
|
|
492
658
|
s.dump({"nonce": nonce, "vars": out}, f)
|
|
493
659
|
os.rename(tmp, path) # atomic rename
|
|
494
660
|
return {"skipped": skipped}
|
|
@@ -501,7 +667,7 @@ class Worker:
|
|
|
501
667
|
history-only replay (caller skips restore when sessionNonce is undefined).
|
|
502
668
|
"""
|
|
503
669
|
s = self._serializer()
|
|
504
|
-
with
|
|
670
|
+
with _REAL_IO_OPEN(path, "rb") as f:
|
|
505
671
|
data = s.load(f)
|
|
506
672
|
if not isinstance(data, dict) or data.get("nonce") != nonce:
|
|
507
673
|
raise ValueError("snapshot nonce mismatch — not from this session")
|
|
@@ -516,10 +682,13 @@ def main() -> None:
|
|
|
516
682
|
ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
|
|
517
683
|
ap.add_argument("--max-prompt-chars", type=int,
|
|
518
684
|
default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
|
|
685
|
+
ap.add_argument("--read-only", action="store_true",
|
|
686
|
+
default=os.environ.get("RLM_READ_ONLY", "").lower() in ("1", "true", "yes"),
|
|
687
|
+
help="Reject open() write modes (pipeline runs)")
|
|
519
688
|
args = ap.parse_args()
|
|
520
689
|
|
|
521
690
|
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
|
|
522
|
-
max_prompt_chars=args.max_prompt_chars)
|
|
691
|
+
max_prompt_chars=args.max_prompt_chars, read_only=args.read_only)
|
|
523
692
|
_send({"id": "_init", "ok": True})
|
|
524
693
|
|
|
525
694
|
for raw in _REAL_STDIN:
|
package/src/state/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type {
|
|
|
18
18
|
} from "./rows.ts";
|
|
19
19
|
export { STATE_SCHEMA_VERSION, isHeader, isTurn, isCompaction, isPhase, isTodo, isTerminal, isRow } from "./rows.ts";
|
|
20
20
|
export { appendRow, appendTodoRow, pruneRuns, writeContextSidecar } from "./writes.ts";
|
|
21
|
-
export { readRows, readHeader, readContextSidecar, listRunIds, resolveRunId } from "./reads.ts";
|
|
21
|
+
export { readRows, readHeader, readContextSidecar, readLibrarySidecars, listRunIds, resolveRunId } from "./reads.ts";
|
|
22
|
+
export type { LibrarySlot } from "./reads.ts";
|
|
22
23
|
export { reconstructRlmState } from "./resume.ts";
|
|
23
24
|
export type { PhaseRecon, ReconstructResult } from "./resume.ts";
|
package/src/state/paths.ts
CHANGED
|
@@ -32,8 +32,10 @@ export const runDir = (cwd: string, dir: string, runId: string): string => join(
|
|
|
32
32
|
|
|
33
33
|
export const trailPath = (cwd: string, dir: string, runId: string): string => join(runDir(cwd, dir, runId), "trail.jsonl");
|
|
34
34
|
|
|
35
|
-
export const contextPath = (cwd: string, dir: string, runId: string, json: boolean): string =>
|
|
36
|
-
join(runDir(cwd, dir, runId),
|
|
35
|
+
export const contextPath = (cwd: string, dir: string, runId: string, json: boolean, index = 0): string =>
|
|
36
|
+
join(runDir(cwd, dir, runId), index === 0
|
|
37
|
+
? (json ? "context.json" : "context.txt")
|
|
38
|
+
: `context.${index}.${json ? "json" : "txt"}`);
|
|
37
39
|
|
|
38
40
|
/** R-C1: per-turn snapshot files — `sandbox-<turn>.pkl` so resume can fall back to a prior turn if the latest rename failed. */
|
|
39
41
|
export function snapshotPath(cwd: string, dir: string, runId: string, turn?: number): string {
|
package/src/state/reads.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* by the slug (ISO-like timestamps are self-sorting).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { open, readFile } from "node:fs/promises";
|
|
10
|
-
import {
|
|
9
|
+
import { open, readdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { runsDir, runDir, trailPath, contextPath } from "./paths.ts";
|
|
11
12
|
import { isHeader, isRow, type RunHeader, type Row } from "./rows.ts";
|
|
12
13
|
import { errorMessage, failSoft, listDirectoriesSorted, pathExists, warn } from "./internal.ts";
|
|
13
14
|
|
|
@@ -83,6 +84,34 @@ export async function readContextSidecar(cwd: string, dir: string, runId: string
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
const LIBRARY_SIDECAR = /^context\.(\d+)\.(json|txt)$/;
|
|
88
|
+
|
|
89
|
+
export interface LibrarySlot {
|
|
90
|
+
readonly index: number;
|
|
91
|
+
readonly payload: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fail-soft lister for load_library resume sidecars (`context.<index>.json|txt`). */
|
|
95
|
+
export async function readLibrarySidecars(cwd: string, dir: string, runId: string): Promise<LibrarySlot[]> {
|
|
96
|
+
const entries = await failSoft(() => readdir(runDir(cwd, dir, runId)), [] as string[]);
|
|
97
|
+
const slots: LibrarySlot[] = [];
|
|
98
|
+
for (const name of entries) {
|
|
99
|
+
const m = LIBRARY_SIDECAR.exec(name);
|
|
100
|
+
if (!m) continue;
|
|
101
|
+
const index = Number(m[1]);
|
|
102
|
+
const json = m[2] === "json";
|
|
103
|
+
const content = await failSoft(
|
|
104
|
+
() => readFile(join(runDir(cwd, dir, runId), name), "utf-8"),
|
|
105
|
+
undefined as string | undefined,
|
|
106
|
+
);
|
|
107
|
+
if (content === undefined) continue;
|
|
108
|
+
try {
|
|
109
|
+
slots.push({ index, payload: json ? JSON.parse(content) as unknown : content });
|
|
110
|
+
} catch (e) { warn(e); }
|
|
111
|
+
}
|
|
112
|
+
return slots.sort((a, b) => a.index - b.index);
|
|
113
|
+
}
|
|
114
|
+
|
|
86
115
|
/** Enumerate run-ids by directory listing; newest first (slug sorts chronologically). */
|
|
87
116
|
export async function listRunIds(cwd: string, dir: string): Promise<string[]> {
|
|
88
117
|
return await failSoft(() => listDirectoriesSorted(runsDir(cwd, dir)), [], { warn: false });
|
package/src/state/resume.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { readFile } from "node:fs/promises";
|
|
|
11
11
|
import { type ChatMsg } from "../bridge/model.ts";
|
|
12
12
|
import { appendUserMessage } from "../core/history.ts";
|
|
13
13
|
import { buildTurnPrompt } from "../prompts/user.ts";
|
|
14
|
-
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
15
14
|
import { readHeader } from "./reads.ts";
|
|
16
15
|
import {
|
|
17
16
|
isCompaction,
|
|
@@ -28,10 +27,19 @@ import {
|
|
|
28
27
|
import { trailPath, snapshotPath } from "./paths.ts";
|
|
29
28
|
import { failSoft, pathExists } from "./internal.ts";
|
|
30
29
|
|
|
30
|
+
/** Artifact path + supersede flag reconstructed from phase rows. */
|
|
31
|
+
export interface PhaseReconArtifact {
|
|
32
|
+
readonly path: string;
|
|
33
|
+
readonly superseded: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
export interface PhaseRecon {
|
|
32
37
|
readonly current: string;
|
|
33
38
|
readonly advancedAt: number;
|
|
34
39
|
readonly summary?: string;
|
|
40
|
+
/** Repo-relative artifacts keyed by the phase that produced them. */
|
|
41
|
+
readonly artifacts?: Readonly<Partial<Record<string, PhaseReconArtifact>>>;
|
|
42
|
+
readonly backwardJumps?: number;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export type ReconstructResult =
|
|
@@ -42,7 +50,6 @@ export type ReconstructResult =
|
|
|
42
50
|
readonly pendingReplOutputs?: string;
|
|
43
51
|
readonly usageSeed: { readonly costUsd: number; readonly inputTokens: number; readonly outputTokens: number; readonly durationMs: number };
|
|
44
52
|
readonly best: string;
|
|
45
|
-
readonly editsAcc: ProposedEdit[];
|
|
46
53
|
readonly completedTurns: number;
|
|
47
54
|
readonly compactions: number;
|
|
48
55
|
/** R-C1: the latest turn whose per-turn snapshot file exists on disk (undefined ⇒ no restore). */
|
|
@@ -99,7 +106,6 @@ export async function reconstructRlmState(
|
|
|
99
106
|
let history: ChatMsg[] = [{ role: "system", content: systemPrompt }];
|
|
100
107
|
const usageSeed = { costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
|
|
101
108
|
let best = "";
|
|
102
|
-
let editsAcc: ProposedEdit[] = [];
|
|
103
109
|
let completedTurns = 0;
|
|
104
110
|
let compactions = 0;
|
|
105
111
|
let snapshotTurn: number | undefined; // R-C1: latest turn with an existing snapshot file
|
|
@@ -107,6 +113,8 @@ export async function reconstructRlmState(
|
|
|
107
113
|
const todoRows: { action: string; params: Record<string, unknown>; result: string }[] = [];
|
|
108
114
|
let terminated = false;
|
|
109
115
|
let phase: PhaseRecon | undefined;
|
|
116
|
+
// Append-only journal: paths stay; supersededPath flips the blueprint slot.
|
|
117
|
+
const artifactsAcc = new Map<string, PhaseReconArtifact>();
|
|
110
118
|
|
|
111
119
|
for (const row of rows) {
|
|
112
120
|
if (isHeader(row)) continue;
|
|
@@ -129,7 +137,6 @@ export async function reconstructRlmState(
|
|
|
129
137
|
usageSeed.outputTokens += row.usage.outputTokens;
|
|
130
138
|
if (row.answerContent) best = row.answerContent;
|
|
131
139
|
else if (!best && row.response.trim()) best = row.response; // C3: mirror engine fallback
|
|
132
|
-
if (row.edits && row.edits.length > 0) editsAcc = [...row.edits];
|
|
133
140
|
completedTurns = row.turn;
|
|
134
141
|
// R-C1: verify the per-turn snapshot file exists — a crashed finalize leaves the row claiming snapshotOk:true with no pkl.
|
|
135
142
|
if (row.snapshotOk && await pathExists(snapshotPath(cwd, dir, runId, row.turn)))
|
|
@@ -139,7 +146,25 @@ export async function reconstructRlmState(
|
|
|
139
146
|
continue;
|
|
140
147
|
}
|
|
141
148
|
if (isPhase(row)) {
|
|
142
|
-
|
|
149
|
+
if (row.artifactPath !== undefined && row.artifactPhase !== undefined) {
|
|
150
|
+
artifactsAcc.set(row.artifactPhase, { path: row.artifactPath, superseded: false });
|
|
151
|
+
}
|
|
152
|
+
// Append-only: a superseded artifact keeps its slot, flipped to superseded.
|
|
153
|
+
if (row.supersededPath !== undefined) {
|
|
154
|
+
const prior = artifactsAcc.get("blueprint");
|
|
155
|
+
if (prior !== undefined) {
|
|
156
|
+
artifactsAcc.set("blueprint", { path: prior.path, superseded: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const artifactsObj: Record<string, PhaseReconArtifact> = {};
|
|
160
|
+
for (const [k, v] of artifactsAcc) artifactsObj[k] = v;
|
|
161
|
+
phase = {
|
|
162
|
+
current: row.phase,
|
|
163
|
+
advancedAt: row.turn - 1,
|
|
164
|
+
summary: row.summary,
|
|
165
|
+
artifacts: artifactsAcc.size > 0 ? artifactsObj : undefined,
|
|
166
|
+
backwardJumps: row.backwardJumps,
|
|
167
|
+
};
|
|
143
168
|
continue;
|
|
144
169
|
}
|
|
145
170
|
if (isTodo(row)) {
|
|
@@ -150,5 +175,5 @@ export async function reconstructRlmState(
|
|
|
150
175
|
}
|
|
151
176
|
|
|
152
177
|
if (completedTurns === 0 && !terminated) return { ok: false, reason: "no-turns", detail: runId };
|
|
153
|
-
return { ok: true, header, history, pendingReplOutputs, usageSeed, best,
|
|
178
|
+
return { ok: true, header, history, pendingReplOutputs, usageSeed, best, completedTurns, compactions, snapshotTurn, todoRows, terminated, phase };
|
|
154
179
|
}
|
package/src/state/rows.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ChatMsg } from "../bridge/model.ts";
|
|
12
|
-
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
13
12
|
|
|
14
13
|
/** Bump when a row shape changes such that the resume fold cannot replay older files. */
|
|
15
14
|
export const STATE_SCHEMA_VERSION = 5;
|
|
@@ -47,7 +46,6 @@ export interface TurnRow {
|
|
|
47
46
|
readonly response: string; // assistant message
|
|
48
47
|
readonly replOutputs?: string; // formatReplOutputs(results) → next user message
|
|
49
48
|
readonly answerContent?: string; // restores `best`
|
|
50
|
-
readonly edits?: readonly ProposedEdit[]; // restores editsAcc (latest wins)
|
|
51
49
|
readonly error: boolean; // turnHadError → limits.observe on resume
|
|
52
50
|
readonly usage: UsageRow;
|
|
53
51
|
readonly cumulativeDurationMs: number; // limits.usage().durationMs at turn-write time
|
|
@@ -88,6 +86,14 @@ export interface PhaseRow {
|
|
|
88
86
|
readonly ts: string;
|
|
89
87
|
readonly phase: string;
|
|
90
88
|
readonly summary?: string;
|
|
89
|
+
/** Optional fields (no schema-version break — isPhase guard unchanged). */
|
|
90
|
+
readonly artifactPath?: string;
|
|
91
|
+
/** Phase that produced `artifactPath` (not inferred from order — loop-back safe). */
|
|
92
|
+
readonly artifactPhase?: string;
|
|
93
|
+
readonly blockersCount?: number;
|
|
94
|
+
readonly backwardJumps?: number;
|
|
95
|
+
/** Path of the artifact this transition superseded (validate loop-back). */
|
|
96
|
+
readonly supersededPath?: string;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
export type Row = RunHeader | TurnRow | CompactionRow | TodoRow | TerminalRow | PhaseRow;
|
package/src/state/writes.ts
CHANGED
|
@@ -32,11 +32,13 @@ export async function appendTodoRow(cwd: string, dir: string, runId: string, row
|
|
|
32
32
|
return await appendRow(cwd, dir, runId, { kind: "todo", ...row });
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Persist
|
|
36
|
-
export async function writeContextSidecar(
|
|
35
|
+
/** Persist a context payload for resume. Slot 0 = repo context; index ≥ 1 = load_library slots. */
|
|
36
|
+
export async function writeContextSidecar(
|
|
37
|
+
cwd: string, dir: string, runId: string, context: unknown, json: boolean, index = 0,
|
|
38
|
+
): Promise<boolean> {
|
|
37
39
|
return await failSoft(async () => {
|
|
38
40
|
await mkdir(runDir(cwd, dir, runId), { recursive: true });
|
|
39
|
-
await writeFile(contextPath(cwd, dir, runId, json), json ? JSON.stringify(context) : String(context), "utf-8");
|
|
41
|
+
await writeFile(contextPath(cwd, dir, runId, json, index), json ? JSON.stringify(context) : String(context), "utf-8");
|
|
40
42
|
return true;
|
|
41
43
|
}, false);
|
|
42
44
|
}
|
package/src/text/tokens.ts
CHANGED
|
@@ -8,11 +8,17 @@
|
|
|
8
8
|
|
|
9
9
|
const CHARS_PER_TOKEN = 4;
|
|
10
10
|
|
|
11
|
+
/** Rough token count for a character length (≈4 chars/token). Always ≥ 1 for non-empty text. */
|
|
12
|
+
export function estimateTokens(charCount: number): number {
|
|
13
|
+
if (charCount <= 0) return 0;
|
|
14
|
+
return Math.ceil(charCount / CHARS_PER_TOKEN);
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
/** Rough token count for a list of role/content messages. */
|
|
12
18
|
export function estimateMessageTokens(messages: { content: string }[]): number {
|
|
13
19
|
let chars = 0;
|
|
14
20
|
for (const m of messages) chars += m.content.length + 8; // small per-message overhead
|
|
15
|
-
return
|
|
21
|
+
return estimateTokens(chars);
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/** Total character length of a context payload (string or list of strings). */
|
package/src/tool/repl-details.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* accumulated into the subcalls array for tree rendering.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
10
9
|
import type { RlmSubcall } from "./rlm-details.ts";
|
|
11
10
|
|
|
12
11
|
export interface ReplDetails {
|
|
@@ -23,6 +22,6 @@ export interface ReplDetails {
|
|
|
23
22
|
readonly totals: { readonly costUsd: number; readonly tokens: number };
|
|
24
23
|
/** Final answer submitted through answer["ready"] without echoing it to the model. */
|
|
25
24
|
readonly finalAnswer?: string;
|
|
26
|
-
/**
|
|
27
|
-
readonly
|
|
25
|
+
/** Advisory diagnostics — surfaced to the user, never a failure. */
|
|
26
|
+
readonly warnings?: readonly string[];
|
|
28
27
|
}
|