@hicaru/pi-rlm 0.1.8 → 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 +22 -19
- package/package.json +2 -1
- package/src/bridge/library.ts +93 -15
- package/src/bridge/llm-query.ts +1 -0
- package/src/bridge/rlm-query.ts +2 -4
- package/src/context/library-context.ts +209 -22
- package/src/context/repomix-context.ts +2 -48
- package/src/core/answer.ts +1 -10
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +130 -134
- package/src/core/gates.ts +30 -1
- package/src/core/pipeline.ts +38 -13
- package/src/core/types.ts +1 -3
- package/src/index.ts +1 -9
- package/src/mode/native-guards.ts +2 -2
- package/src/prompts/phases.ts +18 -39
- package/src/prompts/system.ts +31 -19
- package/src/sandbox/protocol.ts +5 -10
- package/src/sandbox/sandbox.ts +43 -9
- package/src/sandbox/worker.py +180 -48
- package/src/state/resume.ts +21 -14
- package/src/state/rows.ts +2 -2
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +29 -55
- 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 -7
- package/src/tool/subcall-store.ts +2 -0
- package/src/ui/config-panel.ts +2 -2
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -164
- package/src/tool/apply-edits-tool.ts +0 -295
package/src/sandbox/worker.py
CHANGED
|
@@ -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
|
|
|
@@ -68,11 +104,12 @@ RESERVED = frozenset(
|
|
|
68
104
|
"rlm_query", "rlm_query_batched",
|
|
69
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
|
|
|
@@ -151,7 +191,6 @@ class Worker:
|
|
|
151
191
|
ns["save_artifact"] = self._save_artifact
|
|
152
192
|
ns["ask_user_question"] = self._ask_user_question
|
|
153
193
|
ns["todo"] = self._todo
|
|
154
|
-
ns["stage_edit"] = self._stage_edit
|
|
155
194
|
ns["load_library"] = self._load_library
|
|
156
195
|
ns["SHOW_VARS"] = self._show_vars
|
|
157
196
|
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
@@ -163,17 +202,18 @@ class Worker:
|
|
|
163
202
|
if cur.get("ready") and self._final_answer is None:
|
|
164
203
|
self._final_answer = str(cur.get("content", ""))
|
|
165
204
|
ns["answer"] = ans
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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]
|
|
174
214
|
|
|
175
215
|
def _user_var_names(self) -> list[str]:
|
|
176
|
-
"""User-created variable names — filters builtins, scaffold, and context
|
|
216
|
+
"""User-created variable names — filters builtins, scaffold, and `context`.
|
|
177
217
|
|
|
178
218
|
Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
|
|
179
219
|
This is the cheap orientation hint that goes into history instead of full stdout.
|
|
@@ -181,7 +221,7 @@ class Worker:
|
|
|
181
221
|
return [
|
|
182
222
|
k for k in self.ns
|
|
183
223
|
if not k.startswith("_")
|
|
184
|
-
and not
|
|
224
|
+
and not _CONTEXT_NAME.match(k)
|
|
185
225
|
and k not in RESERVED
|
|
186
226
|
]
|
|
187
227
|
|
|
@@ -325,37 +365,124 @@ class Worker:
|
|
|
325
365
|
return f"Error: {r['error']}"
|
|
326
366
|
return str(r.get("response", "ok"))
|
|
327
367
|
|
|
328
|
-
def _stage_edit(self, path: str, old_text: str, new_text: str) -> str:
|
|
329
|
-
if not isinstance(path, str) or not isinstance(old_text, str) or not isinstance(new_text, str):
|
|
330
|
-
return "Error: path, old_text, new_text must be strings"
|
|
331
|
-
self._edit_counter += 1
|
|
332
|
-
edit_id = f"e{self._edit_counter}"
|
|
333
|
-
self._staged_edits.append({"id": edit_id, "path": path, "oldText": old_text, "newText": new_text})
|
|
334
|
-
return edit_id
|
|
335
|
-
|
|
336
368
|
def _load_library(self, source: str) -> dict[str, Any] | str:
|
|
337
|
-
"""
|
|
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
|
+
"""
|
|
338
375
|
r = self._rpc("load_library", {"source": str(source)})
|
|
339
376
|
if r.get("error"):
|
|
340
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
|
+
}
|
|
341
396
|
path = r.get("path")
|
|
342
397
|
if not isinstance(path, str):
|
|
343
398
|
return "Error: malformed load_library reply (no path)"
|
|
344
399
|
try:
|
|
345
|
-
|
|
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()
|
|
346
403
|
finally:
|
|
347
404
|
try:
|
|
348
405
|
os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
|
|
349
406
|
except OSError:
|
|
350
407
|
pass
|
|
351
|
-
return
|
|
352
|
-
|
|
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
|
|
353
480
|
|
|
354
481
|
def _advance_phase(self, phase: str, summary: str | None = None) -> str:
|
|
355
482
|
"""Transition the root RLM pipeline to a new phase.
|
|
356
483
|
|
|
357
484
|
Only callable at depth 0. The parent handler validates the transition
|
|
358
|
-
against the phase state machine (research → blueprint →
|
|
485
|
+
against the phase state machine (research → blueprint → validate)
|
|
359
486
|
and runs deterministic artifact gates before accepting the transition.
|
|
360
487
|
Returns a short confirmation, or an `Error: …` string the model can act on.
|
|
361
488
|
"""
|
|
@@ -400,16 +527,20 @@ class Worker:
|
|
|
400
527
|
# ---- context + execution --------------------------------------------------------------
|
|
401
528
|
|
|
402
529
|
def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
"""
|
|
405
535
|
with open(path, "r") as f:
|
|
406
536
|
payload = json.load(f) if is_json else f.read()
|
|
407
|
-
self.
|
|
408
|
-
self.ns[
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
413
544
|
|
|
414
545
|
@contextmanager
|
|
415
546
|
def _capture(self):
|
|
@@ -471,7 +602,6 @@ class Worker:
|
|
|
471
602
|
stdout = out.getvalue()
|
|
472
603
|
stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
|
|
473
604
|
final, self._final_answer = self._final_answer, None
|
|
474
|
-
edits, self._staged_edits = self._staged_edits, []
|
|
475
605
|
answer = self.ns.get("answer")
|
|
476
606
|
answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
|
|
477
607
|
# ready may have been flipped with empty content before content was assigned later
|
|
@@ -488,7 +618,6 @@ class Worker:
|
|
|
488
618
|
"stderr": stderr,
|
|
489
619
|
"final_answer": final,
|
|
490
620
|
"answer_content": str(answer_content),
|
|
491
|
-
"edits": edits,
|
|
492
621
|
"raised": raised,
|
|
493
622
|
"execution_time": time.perf_counter() - start,
|
|
494
623
|
"var_names": self._user_var_names(),
|
|
@@ -512,7 +641,7 @@ class Worker:
|
|
|
512
641
|
out, skipped = {}, []
|
|
513
642
|
MAX_VAR_BYTES = 50 * 1024 * 1024
|
|
514
643
|
for k, v in self.ns.items():
|
|
515
|
-
if k.startswith("_") or
|
|
644
|
+
if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
|
|
516
645
|
continue
|
|
517
646
|
try:
|
|
518
647
|
blob = s.dumps(v)
|
|
@@ -525,7 +654,7 @@ class Worker:
|
|
|
525
654
|
if skipped:
|
|
526
655
|
print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
|
|
527
656
|
tmp = path + ".tmp"
|
|
528
|
-
with
|
|
657
|
+
with _REAL_IO_OPEN(tmp, "wb") as f:
|
|
529
658
|
s.dump({"nonce": nonce, "vars": out}, f)
|
|
530
659
|
os.rename(tmp, path) # atomic rename
|
|
531
660
|
return {"skipped": skipped}
|
|
@@ -538,7 +667,7 @@ class Worker:
|
|
|
538
667
|
history-only replay (caller skips restore when sessionNonce is undefined).
|
|
539
668
|
"""
|
|
540
669
|
s = self._serializer()
|
|
541
|
-
with
|
|
670
|
+
with _REAL_IO_OPEN(path, "rb") as f:
|
|
542
671
|
data = s.load(f)
|
|
543
672
|
if not isinstance(data, dict) or data.get("nonce") != nonce:
|
|
544
673
|
raise ValueError("snapshot nonce mismatch — not from this session")
|
|
@@ -553,10 +682,13 @@ def main() -> None:
|
|
|
553
682
|
ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
|
|
554
683
|
ap.add_argument("--max-prompt-chars", type=int,
|
|
555
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)")
|
|
556
688
|
args = ap.parse_args()
|
|
557
689
|
|
|
558
690
|
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
|
|
559
|
-
max_prompt_chars=args.max_prompt_chars)
|
|
691
|
+
max_prompt_chars=args.max_prompt_chars, read_only=args.read_only)
|
|
560
692
|
_send({"id": "_init", "ok": True})
|
|
561
693
|
|
|
562
694
|
for raw in _REAL_STDIN:
|
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,12 +27,18 @@ 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;
|
|
35
|
-
/** Repo-relative
|
|
36
|
-
readonly artifacts?: Readonly<Partial<Record<string,
|
|
40
|
+
/** Repo-relative artifacts keyed by the phase that produced them. */
|
|
41
|
+
readonly artifacts?: Readonly<Partial<Record<string, PhaseReconArtifact>>>;
|
|
37
42
|
readonly backwardJumps?: number;
|
|
38
43
|
}
|
|
39
44
|
|
|
@@ -45,7 +50,6 @@ export type ReconstructResult =
|
|
|
45
50
|
readonly pendingReplOutputs?: string;
|
|
46
51
|
readonly usageSeed: { readonly costUsd: number; readonly inputTokens: number; readonly outputTokens: number; readonly durationMs: number };
|
|
47
52
|
readonly best: string;
|
|
48
|
-
readonly editsAcc: ProposedEdit[];
|
|
49
53
|
readonly completedTurns: number;
|
|
50
54
|
readonly compactions: number;
|
|
51
55
|
/** R-C1: the latest turn whose per-turn snapshot file exists on disk (undefined ⇒ no restore). */
|
|
@@ -102,7 +106,6 @@ export async function reconstructRlmState(
|
|
|
102
106
|
let history: ChatMsg[] = [{ role: "system", content: systemPrompt }];
|
|
103
107
|
const usageSeed = { costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
|
|
104
108
|
let best = "";
|
|
105
|
-
let editsAcc: ProposedEdit[] = [];
|
|
106
109
|
let completedTurns = 0;
|
|
107
110
|
let compactions = 0;
|
|
108
111
|
let snapshotTurn: number | undefined; // R-C1: latest turn with an existing snapshot file
|
|
@@ -110,8 +113,8 @@ export async function reconstructRlmState(
|
|
|
110
113
|
const todoRows: { action: string; params: Record<string, unknown>; result: string }[] = [];
|
|
111
114
|
let terminated = false;
|
|
112
115
|
let phase: PhaseRecon | undefined;
|
|
113
|
-
//
|
|
114
|
-
const artifactsAcc
|
|
116
|
+
// Append-only journal: paths stay; supersededPath flips the blueprint slot.
|
|
117
|
+
const artifactsAcc = new Map<string, PhaseReconArtifact>();
|
|
115
118
|
|
|
116
119
|
for (const row of rows) {
|
|
117
120
|
if (isHeader(row)) continue;
|
|
@@ -134,7 +137,6 @@ export async function reconstructRlmState(
|
|
|
134
137
|
usageSeed.outputTokens += row.usage.outputTokens;
|
|
135
138
|
if (row.answerContent) best = row.answerContent;
|
|
136
139
|
else if (!best && row.response.trim()) best = row.response; // C3: mirror engine fallback
|
|
137
|
-
if (row.edits && row.edits.length > 0) editsAcc = [...row.edits];
|
|
138
140
|
completedTurns = row.turn;
|
|
139
141
|
// R-C1: verify the per-turn snapshot file exists — a crashed finalize leaves the row claiming snapshotOk:true with no pkl.
|
|
140
142
|
if (row.snapshotOk && await pathExists(snapshotPath(cwd, dir, runId, row.turn)))
|
|
@@ -145,17 +147,22 @@ export async function reconstructRlmState(
|
|
|
145
147
|
}
|
|
146
148
|
if (isPhase(row)) {
|
|
147
149
|
if (row.artifactPath !== undefined && row.artifactPhase !== undefined) {
|
|
148
|
-
artifactsAcc
|
|
150
|
+
artifactsAcc.set(row.artifactPhase, { path: row.artifactPath, superseded: false });
|
|
149
151
|
}
|
|
150
|
-
//
|
|
151
|
-
if (row.
|
|
152
|
-
|
|
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
|
+
}
|
|
153
158
|
}
|
|
159
|
+
const artifactsObj: Record<string, PhaseReconArtifact> = {};
|
|
160
|
+
for (const [k, v] of artifactsAcc) artifactsObj[k] = v;
|
|
154
161
|
phase = {
|
|
155
162
|
current: row.phase,
|
|
156
163
|
advancedAt: row.turn - 1,
|
|
157
164
|
summary: row.summary,
|
|
158
|
-
artifacts:
|
|
165
|
+
artifacts: artifactsAcc.size > 0 ? artifactsObj : undefined,
|
|
159
166
|
backwardJumps: row.backwardJumps,
|
|
160
167
|
};
|
|
161
168
|
continue;
|
|
@@ -168,5 +175,5 @@ export async function reconstructRlmState(
|
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
if (completedTurns === 0 && !terminated) return { ok: false, reason: "no-turns", detail: runId };
|
|
171
|
-
return { ok: true, header, history, pendingReplOutputs, usageSeed, best,
|
|
178
|
+
return { ok: true, header, history, pendingReplOutputs, usageSeed, best, completedTurns, compactions, snapshotTurn, todoRows, terminated, phase };
|
|
172
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
|
|
@@ -94,6 +92,8 @@ export interface PhaseRow {
|
|
|
94
92
|
readonly artifactPhase?: string;
|
|
95
93
|
readonly blockersCount?: number;
|
|
96
94
|
readonly backwardJumps?: number;
|
|
95
|
+
/** Path of the artifact this transition superseded (validate loop-back). */
|
|
96
|
+
readonly supersededPath?: string;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
export type Row = RunHeader | TurnRow | CompactionRow | TodoRow | TerminalRow | PhaseRow;
|
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
|
}
|