@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.
@@ -57,8 +57,44 @@ _SAFE_BUILTINS = {
57
57
  "ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
58
58
  )
59
59
  }
60
- # `open` is allowed (data work needs files); eval/exec/compile/input/globals/locals are not.
61
- _SAFE_BUILTINS["open"] = open
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
- "stage_edit", "load_library",
107
+ "load_library",
72
108
  "SHOW_VARS", "answer", "context",
73
109
  }
74
110
  )
75
- _CONTEXT_SLOT = re.compile(r"context(_\d+)?\Z")
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
- self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
133
- self._ctx_payloads: dict[int, Any] = {}
134
- self._staged_edits: list[dict[str, str]] = []
135
- self._edit_counter = 0
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
- # Context slots are ordinary variables (RLM paper: the context lives in the
167
- # environment and the model may transform it in place). Re-inject only if the
168
- # model deleted the name entirely; mutations and re-binds persist within the run.
169
- # Resume reloads pristine context; keep derived resume-critical values in user vars.
170
- for idx, payload in self._ctx_payloads.items():
171
- ns.setdefault(f"context_{idx}", payload)
172
- if 0 in self._ctx_payloads:
173
- ns.setdefault("context", self._ctx_payloads[0])
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 slots.
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 _CONTEXT_SLOT.match(k)
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
- """Ask the host to pack an external dir/file/git-URL and load it as a new context slot."""
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
- idx = self.load_context(path, r.get("index"), bool(r.get("json")))
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 {"index": idx, "var": f"context_{idx}",
352
- "files": r.get("files"), "chars": r.get("chars")}
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 → implement → validate)
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
- if index is None:
404
- index = self._context_count
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._ctx_payloads[index] = payload
408
- self.ns[f"context_{index}"] = payload
409
- if index == 0:
410
- self.ns["context"] = payload
411
- self._context_count = max(self._context_count, index + 1)
412
- return index
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 _CONTEXT_SLOT.match(k) or k in RESERVED or k == "__builtins__":
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 open(tmp, "wb") as f:
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 open(path, "rb") as f:
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:
@@ -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 artifact paths keyed by the phase that produced them. */
36
- readonly artifacts?: Readonly<Partial<Record<string, 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
- // Accumulate artifact paths keyed by the producing phase (artifactPhase on the row).
114
- const artifactsAcc: Record<string, string> = {};
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[row.artifactPhase] = row.artifactPath;
150
+ artifactsAcc.set(row.artifactPhase, { path: row.artifactPath, superseded: false });
149
151
  }
150
- // On loop-back to blueprint, drop stale plan so resume cannot re-gate with it.
151
- if (row.phase === "blueprint" && row.backwardJumps !== undefined && row.backwardJumps > 0) {
152
- delete artifactsAcc.blueprint;
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: Object.keys(artifactsAcc).length > 0 ? { ...artifactsAcc } : undefined,
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, editsAcc, completedTurns, compactions, snapshotTurn, todoRows, terminated, phase };
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;
@@ -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 Math.ceil(chars / CHARS_PER_TOKEN);
21
+ return estimateTokens(chars);
16
22
  }
17
23
 
18
24
  /** Total character length of a context payload (string or list of strings). */
@@ -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
- /** File edits staged inside the REPL for native relay through apply_edits(). */
27
- readonly edits?: readonly ProposedEdit[];
25
+ /** Advisory diagnostics — surfaced to the user, never a failure. */
26
+ readonly warnings?: readonly string[];
28
27
  }