@hicaru/pi-rlm 0.1.3 → 0.1.6

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.
@@ -21,6 +21,7 @@ import io
21
21
  import json
22
22
  import os
23
23
  import pickle
24
+ import re
24
25
  import signal
25
26
  import sys
26
27
  import time
@@ -28,10 +29,11 @@ import traceback
28
29
  from contextlib import contextmanager
29
30
  from typing import Any
30
31
 
31
- # Capture the REAL stdout/stdin before exec() redirects sys.stdout into a buffer.
32
+ # Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
32
33
  # All protocol writes must go to the real stdout even while user code's prints are captured.
33
34
  _REAL_STDOUT = sys.stdout
34
35
  _REAL_STDIN = sys.stdin
36
+ _REAL_STDERR = sys.stderr
35
37
 
36
38
 
37
39
  def _builtin(name: str):
@@ -62,13 +64,37 @@ for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
62
64
 
63
65
  RESERVED = frozenset(
64
66
  {
65
- "llm_query", "llm_query_batched", "rlm_query", "rlm_query_batched",
67
+ "llm_query", "llm_query_batched", "llm_query_chunked",
68
+ "rlm_query", "rlm_query_batched",
66
69
  "advance_phase",
67
70
  "ask_user_question", "todo",
68
71
  "stage_edit",
69
72
  "SHOW_VARS", "answer", "context",
70
73
  }
71
74
  )
75
+ _CONTEXT_SLOT = re.compile(r"context(_\d+)?\Z")
76
+
77
+ # Sizing for llm_query_chunked: leave room for the instruction and the chunk header.
78
+ _CHUNK_HEADER_OVERHEAD = 64
79
+ _MAX_CHUNK_BATCH = 20 # fan-out per llm_query_batched call (matches prompt guidance)
80
+ _MAX_CHUNKS = 500 # ceiling: above this, force pre-filtering in Python
81
+ _NUDGE_CHARS = 500_000 # str/bytes vars above this trigger a one-time stdout hint
82
+
83
+
84
+ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
85
+ """Split text into <=chunk_chars pieces, preferring newline boundaries."""
86
+ chunks: list[str] = []
87
+ n = len(text)
88
+ start = 0
89
+ while start < n:
90
+ end = min(start + chunk_chars, n)
91
+ if end < n:
92
+ nl = text.rfind("\n", start, end)
93
+ if nl > start:
94
+ end = nl + 1
95
+ chunks.append(text[start:end])
96
+ start = end
97
+ return chunks
72
98
 
73
99
 
74
100
  class _AnswerDict(dict):
@@ -92,9 +118,10 @@ def _send(obj: dict[str, Any]) -> None:
92
118
 
93
119
 
94
120
  class Worker:
95
- def __init__(self, depth: int, exec_timeout_s: float):
121
+ def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int):
96
122
  self.depth = depth
97
123
  self.exec_timeout_s = exec_timeout_s
124
+ self.max_prompt_chars = max_prompt_chars
98
125
  self._rid = 0
99
126
  self._final_answer: str | None = None
100
127
  self._context_count = 0
@@ -105,6 +132,8 @@ class Worker:
105
132
  self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
106
133
  self._ctx_payloads: dict[int, Any] = {}
107
134
  self._staged_edits: list[dict[str, str]] = []
135
+ self._edit_counter = 0
136
+ self._nudged: set[str] = set()
108
137
  self._restore_scaffold()
109
138
 
110
139
  def _capture_answer(self, content: Any) -> None:
@@ -115,6 +144,7 @@ class Worker:
115
144
  ns = self.ns
116
145
  ns["llm_query"] = self._llm_query
117
146
  ns["llm_query_batched"] = self._llm_query_batched
147
+ ns["llm_query_chunked"] = self._llm_query_chunked
118
148
  ns["rlm_query"] = self._rlm_query
119
149
  ns["rlm_query_batched"] = self._rlm_query_batched
120
150
  ns["advance_phase"] = self._advance_phase
@@ -131,11 +161,14 @@ class Worker:
131
161
  if cur.get("ready") and self._final_answer is None:
132
162
  self._final_answer = str(cur.get("content", ""))
133
163
  ns["answer"] = ans
134
- # Restore context slots from immutable originals so REPL mutations don't persist.
164
+ # Context slots are ordinary variables (RLM paper: the context lives in the
165
+ # environment and the model may transform it in place). Re-inject only if the
166
+ # model deleted the name entirely; mutations and re-binds persist within the run.
167
+ # Resume reloads pristine context; keep derived resume-critical values in user vars.
135
168
  for idx, payload in self._ctx_payloads.items():
136
- ns[f"context_{idx}"] = payload
169
+ ns.setdefault(f"context_{idx}", payload)
137
170
  if 0 in self._ctx_payloads:
138
- ns["context"] = self._ctx_payloads[0]
171
+ ns.setdefault("context", self._ctx_payloads[0])
139
172
 
140
173
  def _user_var_names(self) -> list[str]:
141
174
  """User-created variable names — filters builtins, scaffold, and context slots.
@@ -146,7 +179,7 @@ class Worker:
146
179
  return [
147
180
  k for k in self.ns
148
181
  if not k.startswith("_")
149
- and not k.startswith("context_")
182
+ and not _CONTEXT_SLOT.match(k)
150
183
  and k not in RESERVED
151
184
  ]
152
185
 
@@ -174,8 +207,11 @@ class Worker:
174
207
  msg = json.loads(line)
175
208
  if msg.get("type") == "llm_reply" and msg.get("rid") == rid:
176
209
  return msg
177
- # The parent only ever sends our reply mid-exec; anything else is a protocol error.
178
- raise RuntimeError(f"unexpected parent message during sub-LLM request: {msg!r}")
210
+ # Stray/late message (e.g. a reply to an earlier timed-out request): skip it.
211
+ print(
212
+ f"[rlm-sandbox] ignoring unexpected message during sub-LLM request: {str(msg)[:200]}",
213
+ file=_REAL_STDERR,
214
+ )
179
215
  finally:
180
216
  if pause and remaining > 0:
181
217
  signal.setitimer(signal.ITIMER_REAL, remaining)
@@ -196,6 +232,35 @@ class Worker:
196
232
  return ["Error: malformed batched response"] * len(prompts)
197
233
  return [s if isinstance(s, str) else f"Error: {s}" for s in out]
198
234
 
235
+ def _llm_query_chunked(self, text, prompt: str, model: str | None = None) -> list[str]:
236
+ """Split oversized text into cap-sized chunks and fan out via llm_query_batched.
237
+
238
+ Returns one answer per chunk, order preserved. No exceptions escape: errors come
239
+ back as "Error: ..." strings per chunk (same contract as llm_query_batched).
240
+
241
+ NOTE: budget uses Python code-point length (len) while the parent-side cap check counts
242
+ UTF-16 units (JS string.length); astral/emoji-heavy text may be marginally larger on the
243
+ parent and get per-chunk rejected. Acceptable trade-off for typical code/log/profile text.
244
+ """
245
+ text, prompt = str(text), str(prompt)
246
+ if not text:
247
+ return []
248
+ budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD
249
+ if budget < 1_000:
250
+ return [f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) — shorten the instruction"]
251
+ chunks = _chunk_text(text, budget)
252
+ total = len(chunks)
253
+ if total > _MAX_CHUNKS:
254
+ return [f"Error: {total} chunks would be needed — filter/slice the text in Python first"]
255
+ results: list[str] = []
256
+ for i in range(0, total, _MAX_CHUNK_BATCH):
257
+ batch = [
258
+ f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
259
+ for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
260
+ ]
261
+ results.extend(self._llm_query_batched(batch, model))
262
+ return results
263
+
199
264
  def _rlm_query(self, prompt: str, model: str | None = None) -> str:
200
265
  r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
201
266
  return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
@@ -261,8 +326,10 @@ class Worker:
261
326
  def _stage_edit(self, path: str, old_text: str, new_text: str) -> str:
262
327
  if not isinstance(path, str) or not isinstance(old_text, str) or not isinstance(new_text, str):
263
328
  return "Error: path, old_text, new_text must be strings"
264
- self._staged_edits.append({"path": path, "oldText": old_text, "newText": new_text})
265
- return f"Staged edit for {path} ({len(old_text)} → {len(new_text)} chars)"
329
+ self._edit_counter += 1
330
+ edit_id = f"e{self._edit_counter}"
331
+ self._staged_edits.append({"id": edit_id, "path": path, "oldText": old_text, "newText": new_text})
332
+ return edit_id
266
333
 
267
334
  def _advance_phase(self, phase: str, summary: str | None = None) -> str:
268
335
  """Transition the root RLM pipeline to a new phase.
@@ -334,6 +401,24 @@ class Worker:
334
401
  signal.setitimer(signal.ITIMER_REAL, 0)
335
402
  signal.signal(signal.SIGALRM, old)
336
403
 
404
+ def _nudge_lines(self) -> list[str]:
405
+ """One-time hint for newly created huge raw-text variables (single line).
406
+
407
+ Collapses to one line so it survives headless stdout elision (head 200 + tail 200).
408
+ """
409
+ names: list[str] = []
410
+ for k in self._user_var_names():
411
+ v = self.ns.get(k)
412
+ if isinstance(v, (str, bytes)) and len(v) > _NUDGE_CHARS and k not in self._nudged:
413
+ self._nudged.add(k)
414
+ names.append(f"{k} ({len(v):,} chars)")
415
+ if not names:
416
+ return []
417
+ return [
418
+ f"[rlm] huge raw-text variable(s): {', '.join(names)} — do NOT analyze them yourself; "
419
+ 'delegate with llm_query_chunked(name, "your question") or slice + llm_query_batched.'
420
+ ]
421
+
337
422
  def execute(self, code: str) -> dict[str, Any]:
338
423
  start = time.perf_counter()
339
424
  raised = False
@@ -352,6 +437,15 @@ class Worker:
352
437
  edits, self._staged_edits = self._staged_edits, []
353
438
  answer = self.ns.get("answer")
354
439
  answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
440
+ # ready may have been flipped with empty content before content was assigned later
441
+ # in the same block; the dict's current content is the real submission.
442
+ if final is not None and not final.strip() and str(answer_content).strip():
443
+ final = str(answer_content)
444
+ nudges = self._nudge_lines()
445
+ if nudges:
446
+ parts = [stdout] if stdout else []
447
+ parts.extend(nudges)
448
+ stdout = "\n".join(parts) + "\n"
355
449
  return {
356
450
  "stdout": stdout,
357
451
  "stderr": stderr,
@@ -381,7 +475,7 @@ class Worker:
381
475
  out, skipped = {}, []
382
476
  MAX_VAR_BYTES = 50 * 1024 * 1024
383
477
  for k, v in self.ns.items():
384
- if k.startswith("_") or k.startswith("context") or k in RESERVED or k == "__builtins__":
478
+ if k.startswith("_") or _CONTEXT_SLOT.match(k) or k in RESERVED or k == "__builtins__":
385
479
  continue
386
480
  try:
387
481
  blob = s.dumps(v)
@@ -392,7 +486,7 @@ class Worker:
392
486
  except Exception:
393
487
  skipped.append(k)
394
488
  if skipped:
395
- print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=sys.stderr)
489
+ print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
396
490
  tmp = path + ".tmp"
397
491
  with open(tmp, "wb") as f:
398
492
  s.dump({"nonce": nonce, "vars": out}, f)
@@ -420,9 +514,12 @@ def main() -> None:
420
514
  ap = argparse.ArgumentParser()
421
515
  ap.add_argument("--depth", type=int, default=int(os.environ.get("RLM_DEPTH", "1")))
422
516
  ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
517
+ ap.add_argument("--max-prompt-chars", type=int,
518
+ default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
423
519
  args = ap.parse_args()
424
520
 
425
- worker = Worker(depth=args.depth, exec_timeout_s=args.timeout)
521
+ worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
522
+ max_prompt_chars=args.max_prompt_chars)
426
523
  _send({"id": "_init", "ok": True})
427
524
 
428
525
  for raw in _REAL_STDIN:
@@ -5,7 +5,7 @@
5
5
  * blocks in order; everything else is prose the model uses to think out loud.
6
6
  */
7
7
 
8
- const FENCE = /```[ \t]*repl[ \t]*\r?\n([\s\S]*?)```/g;
8
+ const FENCE = /(`{3,})[ \t]*repl[ \t]*\r?\n([\s\S]*?)\1/g;
9
9
 
10
10
  /** Return every ```repl``` block body, in document order. */
11
11
  export function findReplBlocks(text: string): string[] {
@@ -13,7 +13,7 @@ export function findReplBlocks(text: string): string[] {
13
13
  let m: RegExpExecArray | null;
14
14
  FENCE.lastIndex = 0;
15
15
  while ((m = FENCE.exec(text)) !== null) {
16
- const code = m[1] ?? "";
16
+ const code = m[2] ?? "";
17
17
  if (code.trim()) blocks.push(code.replace(/\s+$/, ""));
18
18
  }
19
19
  return blocks;
@@ -0,0 +1,126 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { createEditToolDefinition, type AgentToolResult, type ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+ import type { EditToolDetails } from "@earendil-works/pi-coding-agent";
7
+ import type { EditRegistry } from "../registry/edit-registry.ts";
8
+ import { countOccurrences } from "../text/edits.ts";
9
+ import { errorMessage, formatError } from "../util/errors.ts";
10
+
11
+ export const ApplyEditsToolParams = Object.freeze(Type.Object({
12
+ ids: Type.Array(Type.String({ description: "A staged edit ID returned by stage_edit()." }), {
13
+ description: "Staged edit IDs to apply.",
14
+ }),
15
+ }));
16
+
17
+ export interface ApplyEditsFailure {
18
+ readonly id: string;
19
+ readonly error: string;
20
+ }
21
+
22
+ export interface ApplyEditsDetails {
23
+ readonly status: "done" | "partial" | "error";
24
+ readonly appliedIds: readonly string[];
25
+ readonly errors: readonly ApplyEditsFailure[];
26
+ readonly editDetails: readonly EditToolDetails[];
27
+ }
28
+
29
+ function statusFor(appliedCount: number, errorCount: number): ApplyEditsDetails["status"] {
30
+ if (errorCount === 0) return "done";
31
+ return appliedCount > 0 ? "partial" : "error";
32
+ }
33
+
34
+ function summarize(details: ApplyEditsDetails): string {
35
+ const head = details.errors.length > 0
36
+ ? `apply_edits: ${details.appliedIds.length} applied, ${details.errors.length} failed`
37
+ : `apply_edits: ${details.appliedIds.length} applied`;
38
+ if (details.errors.length === 0) return `${head}.`;
39
+ const rows = new Array<string>(details.errors.length);
40
+ for (let i = 0; i < details.errors.length; i++) {
41
+ const error = details.errors[i];
42
+ rows[i] = `${error.id}: ${error.error}`;
43
+ }
44
+ return `${head}.\n${rows.join("\n")}`;
45
+ }
46
+
47
+ export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition<typeof ApplyEditsToolParams, ApplyEditsDetails> {
48
+ return {
49
+ name: "apply_edits",
50
+ label: "Apply Edits",
51
+ description: "Apply staged REPL edits by ID without re-typing file paths or edit bodies.",
52
+ parameters: ApplyEditsToolParams,
53
+
54
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ApplyEditsDetails>> {
55
+ const appliedIds = new Array<string>(params.ids.length);
56
+ const errors = new Array<ApplyEditsFailure>(params.ids.length);
57
+ const editDetails = new Array<EditToolDetails>(params.ids.length);
58
+ let appliedCount = 0;
59
+ let errorCount = 0;
60
+ let detailCount = 0;
61
+
62
+ const editTool = createEditToolDefinition(ctx.cwd);
63
+ for (let i = 0; i < params.ids.length; i++) {
64
+ const id = params.ids[i];
65
+ const edit = editRegistry.get(id);
66
+ if (edit === undefined) {
67
+ errors[errorCount] = { id, error: formatError("unknown edit id") };
68
+ errorCount++;
69
+ continue;
70
+ }
71
+
72
+ try {
73
+ const fullPath = resolve(ctx.cwd, edit.path);
74
+ const content = await readFile(fullPath, "utf8");
75
+ const occurrences = countOccurrences(content, edit.oldText);
76
+ if (occurrences !== 1) {
77
+ errors[errorCount] = { id, error: formatError(`anchor occurs ${occurrences} times in ${edit.path}`) };
78
+ errorCount++;
79
+ continue;
80
+ }
81
+
82
+ const result = await editTool.execute(
83
+ toolCallId,
84
+ { path: edit.path, edits: [{ oldText: edit.oldText, newText: edit.newText }] },
85
+ signal,
86
+ undefined,
87
+ ctx,
88
+ );
89
+ if (result.details !== undefined) {
90
+ editDetails[detailCount] = result.details;
91
+ detailCount++;
92
+ }
93
+ editRegistry.delete(id);
94
+ appliedIds[appliedCount] = id;
95
+ appliedCount++;
96
+ } catch (error) {
97
+ errors[errorCount] = { id, error: formatError(errorMessage(error)) };
98
+ errorCount++;
99
+ }
100
+ }
101
+
102
+ const details: ApplyEditsDetails = {
103
+ status: statusFor(appliedCount, errorCount),
104
+ appliedIds: appliedIds.slice(0, appliedCount),
105
+ errors: errors.slice(0, errorCount),
106
+ editDetails: editDetails.slice(0, detailCount),
107
+ };
108
+ return { content: [{ type: "text", text: summarize(details) }], details };
109
+ },
110
+
111
+ renderCall(args, theme) {
112
+ return new Text(
113
+ theme.fg("toolTitle", theme.bold("apply_edits ")) + theme.fg("dim", args.ids.join(", ")),
114
+ 0,
115
+ 0,
116
+ );
117
+ },
118
+
119
+ renderResult(result, _options, theme) {
120
+ const details = result.details;
121
+ if (details === undefined) return new Text("(no apply_edits details)", 0, 0);
122
+ const summary = summarize(details);
123
+ return new Text(theme.fg(details.status === "error" ? "error" : "success", summary), 0, 0);
124
+ },
125
+ };
126
+ }
@@ -21,6 +21,8 @@ export interface ReplDetails {
21
21
  readonly subcalls: readonly RlmSubcall[];
22
22
  /** Running totals for this repl() call (cost + tokens from sub-LLM calls). */
23
23
  readonly totals: { readonly costUsd: number; readonly tokens: number };
24
- /** File edits staged inside the REPL for native relay through edit(). */
24
+ /** Final answer submitted through answer["ready"] without echoing it to the model. */
25
+ readonly finalAnswer?: string;
26
+ /** File edits staged inside the REPL for native relay through apply_edits(). */
25
27
  readonly edits?: readonly ProposedEdit[];
26
28
  }
@@ -31,8 +31,10 @@ import type { ProposedEdit, ReplResult } from "../sandbox/protocol.ts";
31
31
  import { RlmEmitter } from "./rlm-events.ts";
32
32
  import { SubcallStore } from "./subcall-store.ts";
33
33
  import type { ReplDetails } from "./repl-details.ts";
34
+ import type { RlmSubcall } from "./rlm-details.ts";
34
35
  import { createEngine } from "../core/engine.ts";
35
36
  import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
37
+ import type { EditRegistry } from "../registry/edit-registry.ts";
36
38
  import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
37
39
  import {
38
40
  headlineStatusGlyph,
@@ -40,6 +42,7 @@ import {
40
42
  renderExpandedSubcallTree,
41
43
  } from "./subcall-render.ts";
42
44
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
45
+ import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
43
46
 
44
47
  // ── Parameter schema ──
45
48
 
@@ -51,6 +54,57 @@ export function surfaceReplEdits(edits: readonly ProposedEdit[], raised: boolean
51
54
  return edits.length > 0 && !raised ? edits : undefined;
52
55
  }
53
56
 
57
+ /** Model-visible text assembled from a repl() result, plus the surfaced edits for `details`. */
58
+ export interface ReplResultText {
59
+ readonly text: string;
60
+ readonly surfacedEdits: readonly ProposedEdit[] | undefined;
61
+ }
62
+
63
+ function countLines(text: string): number {
64
+ if (text.length === 0) return 0;
65
+ let count = 1;
66
+ for (const ch of text) if (ch === "\n") count++;
67
+ return count;
68
+ }
69
+
70
+ function stagedEditSummary(edits: readonly ProposedEdit[]): string {
71
+ const rows = new Array<string>(edits.length);
72
+ for (let i = 0; i < edits.length; i++) {
73
+ const edit = edits[i];
74
+ rows[i] = ` ${edit.id} ${edit.path} (-${countLines(edit.oldText)}/+${countLines(edit.newText)} lines)`;
75
+ }
76
+ return [
77
+ "STAGED_EDITS (apply by id with apply_edits; do NOT re-type content):",
78
+ ...rows,
79
+ ].join("\n");
80
+ }
81
+
82
+ /**
83
+ * Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
84
+ * delegation nudge (suppressed when edits were staged), and summarize staged edits by ID
85
+ * without exposing oldText/newText bodies to the root model.
86
+ */
87
+ export function buildReplResultText(
88
+ stdout: string,
89
+ finalAnswer: string | undefined,
90
+ edits: readonly ProposedEdit[],
91
+ raised: boolean,
92
+ subcalls: readonly RlmSubcall[],
93
+ ): ReplResultText {
94
+ const answerSubmitted = finalAnswer !== undefined;
95
+ const rawText = answerSubmitted
96
+ ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
97
+ : stdout || "(no output)";
98
+ const surfacedEdits = surfaceReplEdits(edits, raised);
99
+ const editsBlock = surfacedEdits ? `\n\n${stagedEditSummary(surfacedEdits)}` : "";
100
+ const modelText = rawText + editsBlock;
101
+ // Model-visible text is capped; the caller keeps full stdout/final answer in `details` for the TUI.
102
+ const cappedText = capReplResultText(modelText) ?? modelText;
103
+ const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
104
+ const nudge = surfacedEdits || answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
105
+ return { text: cappedText + (nudge ?? ""), surfacedEdits };
106
+ }
107
+
54
108
  // ── Mutable bridge state (handler indirection) ──
55
109
 
56
110
  /**
@@ -91,6 +145,11 @@ class NativeBridgeState {
91
145
  modelRef(model ? (resolveModelId(deps.registry, model) ?? workerModel()) : workerModel()) ?? workerModel().id;
92
146
 
93
147
  async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
148
+ const limits = state.currentLimits;
149
+ if (limits) {
150
+ const limitError = checkResourceLimits({ budgetUsd: limits.remainingBudgetUsd(), timeoutMs: limits.remainingTimeoutMs() });
151
+ if (limitError !== undefined) return limitError;
152
+ }
94
153
  if (prompt.length > deps.maxPromptChars) {
95
154
  return formatError(`sub-LLM prompt exceeded size limit (${prompt.length.toLocaleString()} chars > ${deps.maxPromptChars.toLocaleString()})`);
96
155
  }
@@ -107,6 +166,7 @@ class NativeBridgeState {
107
166
  reasoning: deps.sampling?.reasoning,
108
167
  signal: deps.signal,
109
168
  });
169
+ limits?.addUsage(res.usage);
110
170
  track(res.usage);
111
171
  return res.text;
112
172
  } catch (err) {
@@ -132,7 +192,6 @@ class NativeBridgeState {
132
192
  costUsd: cost, tokens, resultPreview: previewText(out),
133
193
  detail: isErrorText(out) ? out : undefined,
134
194
  });
135
- state.currentLimits?.addRaw(cost, 0, tokens);
136
195
  return out;
137
196
  },
138
197
 
@@ -155,7 +214,6 @@ class NativeBridgeState {
155
214
  status: error ? "error" : "done", costUsd: cost, tokens,
156
215
  resultPreview: previewText(out[0] ?? ""), detail: error,
157
216
  });
158
- state.currentLimits?.addRaw(cost, 0, tokens);
159
217
  return out;
160
218
  },
161
219
  };
@@ -278,6 +336,7 @@ export interface ReplToolDeps {
278
336
  readonly getModel?: () => Model<Api> | undefined;
279
337
  readonly getWorkerModel?: () => Model<Api> | undefined;
280
338
  readonly registry: ModelRegistry;
339
+ readonly editRegistry?: EditRegistry;
281
340
  readonly config: RlmConfig;
282
341
  readonly signal?: AbortSignal;
283
342
  readonly onUsage?: (usage: Usage, role: "sub") => void;
@@ -285,7 +344,7 @@ export interface ReplToolDeps {
285
344
  }
286
345
 
287
346
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
288
- const { sandboxManager, workerModel, registry, config, signal, onUsage } = deps;
347
+ const { sandboxManager, workerModel, registry, editRegistry, config, signal, onUsage } = deps;
289
348
  const bridgeState = new NativeBridgeState();
290
349
 
291
350
  // Build handlers once — llm/rlm use mutable refs, interactive is session-stable
@@ -317,7 +376,13 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
317
376
  return {
318
377
  name: "repl",
319
378
  label: "REPL",
320
- description: "Execute Python code in a persistent REPL sandbox with the full repository context pre-loaded. Variables, imports, and state persist across calls. Supports llm_query, rlm_query, todo, and ask_user_question inside the sandbox.",
379
+ description:
380
+ "PRIMARY tool for ALL repository reading and analysis (read/grep are disabled in RLM mode). " +
381
+ "Persistent Python sandbox with every file pre-loaded in `context`. You are an orchestrator: " +
382
+ "chunk `context` and delegate semantic work to llm_query / llm_query_batched / " +
383
+ "llm_query_chunked / rlm_query — stdout returned to you is hard-capped at 4K chars, so " +
384
+ "printing file bodies is useless. Variables, imports, and state persist across calls. " +
385
+ "Also supports todo and ask_user_question inside the sandbox.",
321
386
  parameters: ReplToolParams,
322
387
 
323
388
  async execute(_toolCallId, rawParams, _execSignal, onUpdate, ctx) {
@@ -421,11 +486,15 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
421
486
 
422
487
  if (queuedId) emitter.emitSubcallUpdated({ id: queuedId, status: "done" });
423
488
 
424
- const baseText = result.stdout || result.answerContent || "(no output)";
425
- const surfacedEdits = surfaceReplEdits(result.edits, result.raised);
426
- const editsBlock = surfacedEdits
427
- ? `\n\nSTAGED_EDITS:\n${JSON.stringify(surfacedEdits)}`
428
- : "";
489
+ const finalAnswer = result.finalAnswer ?? undefined;
490
+ const { text: resultText, surfacedEdits } = buildReplResultText(
491
+ result.stdout,
492
+ finalAnswer,
493
+ result.edits,
494
+ result.raised,
495
+ store.getSubcalls(),
496
+ );
497
+ editRegistry?.registerAll(surfacedEdits);
429
498
 
430
499
  const details: ReplDetails = {
431
500
  status: "done",
@@ -434,11 +503,15 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
434
503
  executionTimeMs: elapsed,
435
504
  subcalls: store.getSubcalls(),
436
505
  totals: store.getTotals(),
506
+ finalAnswer,
437
507
  edits: surfacedEdits,
438
508
  };
509
+ const progressText = finalAnswer !== undefined
510
+ ? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
511
+ : result.stdout.slice(0, 500) || "(no output)";
439
512
  // Final progressive update
440
- onUpdate?.({ content: [{ type: "text", text: result.stdout.slice(0, 500) || "(no output)" }], details });
441
- return { content: [{ type: "text", text: baseText + editsBlock }], details };
513
+ onUpdate?.({ content: [{ type: "text", text: progressText }], details });
514
+ return { content: [{ type: "text", text: resultText }], details };
442
515
  } catch (e) {
443
516
  progressStatus = "error";
444
517
  const msg = errorMessage(e);
@@ -15,6 +15,7 @@ const CHOICES = Object.freeze({
15
15
  maxTokens: Object.freeze(["none", "10000", "50000", "100000"]),
16
16
  maxErrors: Object.freeze(["3", "5", "10", "none"]),
17
17
  orchestrator: Object.freeze(["on", "off"]),
18
+ pipeline: Object.freeze(["on", "off"]),
18
19
  compaction: Object.freeze(["on", "off"]),
19
20
  rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
20
21
  sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
@@ -38,6 +39,7 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
38
39
  item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
39
40
  item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
40
41
  item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
42
+ item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable advance_phase plus phase-stall reminders at root depth."),
41
43
  item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
42
44
  item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
43
45
  item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
@@ -83,6 +85,7 @@ function applySetting(config: RlmConfig, id: string, value: string): void {
83
85
  case "maxTokens": config.maxTokens = value === "none" ? undefined : Number(value); break;
84
86
  case "maxErrors": config.maxErrors = value === "none" ? undefined : Number(value); break;
85
87
  case "orchestrator": config.orchestrator = value === "on"; break;
88
+ case "pipeline": config.pipeline = value === "on"; break;
86
89
  case "compaction": config.compaction = value === "on"; break;
87
90
  case "rootSamplingMaxTokens": config.rootSampling = Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }); break;
88
91
  case "sandboxInitTimeoutMs": config.sandboxInitTimeoutMs = Number(value); break;