@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.
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/README.zh-CN.md +1 -0
- package/package.json +1 -1
- package/src/bridge/llm-query.ts +8 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/settings.ts +2 -0
- package/src/context/repomix-context.ts +3 -2
- package/src/core/answer.ts +16 -5
- package/src/core/engine.ts +21 -16
- package/src/core/iteration.ts +7 -1
- package/src/core/pipeline.ts +1 -1
- package/src/core/types.ts +2 -0
- package/src/index.ts +47 -11
- package/src/mode/native-guards.ts +92 -0
- package/src/prompts/system.ts +155 -71
- package/src/registry/edit-registry.ts +22 -0
- package/src/sandbox/protocol.ts +1 -0
- package/src/sandbox/sandbox-manager.ts +9 -2
- package/src/sandbox/sandbox.ts +11 -1
- package/src/sandbox/worker.py +111 -14
- package/src/text/parsing.ts +2 -2
- package/src/tool/apply-edits-tool.ts +126 -0
- package/src/tool/repl-details.ts +3 -1
- package/src/tool/repl-tool.ts +84 -11
- package/src/ui/config-panel.ts +3 -0
package/src/sandbox/worker.py
CHANGED
|
@@ -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
|
|
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", "
|
|
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
|
-
#
|
|
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
|
|
169
|
+
ns.setdefault(f"context_{idx}", payload)
|
|
137
170
|
if 0 in self._ctx_payloads:
|
|
138
|
-
ns
|
|
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
|
|
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
|
-
#
|
|
178
|
-
|
|
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.
|
|
265
|
-
|
|
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
|
|
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=
|
|
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:
|
package/src/text/parsing.ts
CHANGED
|
@@ -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 =
|
|
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[
|
|
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
|
+
}
|
package/src/tool/repl-details.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
}
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -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:
|
|
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
|
|
425
|
-
const surfacedEdits =
|
|
426
|
-
|
|
427
|
-
|
|
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:
|
|
441
|
-
return { content: [{ type: "text", text:
|
|
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);
|
package/src/ui/config-panel.ts
CHANGED
|
@@ -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;
|