@hicaru/pi-rlm 0.1.5 → 0.1.7
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/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/prompts/system.ts +8 -11
- package/src/registry/edit-registry.ts +22 -0
- package/src/sandbox/protocol.ts +1 -0
- package/src/sandbox/sandbox-manager.ts +7 -2
- package/src/sandbox/worker.py +5 -2
- package/src/tool/apply-edits-tool.ts +288 -0
- package/src/tool/repl-details.ts +3 -1
- package/src/tool/repl-tool.ts +43 -15
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { registerRlmCommand } from "./commands/rlm.ts";
|
|
|
7
7
|
import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
|
|
8
8
|
import { createRlmTool } from "./tool/rlm-tool.ts";
|
|
9
9
|
import { createReplTool } from "./tool/repl-tool.ts";
|
|
10
|
+
import { createApplyEditsTool } from "./tool/apply-edits-tool.ts";
|
|
11
|
+
import { EditRegistry } from "./registry/edit-registry.ts";
|
|
10
12
|
import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
|
|
11
13
|
import { RlmController, cheapestModel } from "./mode/rlm-mode.ts";
|
|
12
14
|
import { postRlmGuide } from "./ui/intro.ts";
|
|
@@ -24,12 +26,14 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
24
26
|
// Init synchronously with defaults — ensures commands/tools/handlers register before session_start
|
|
25
27
|
const config = mergeConfig({});
|
|
26
28
|
const controller = new RlmController(config);
|
|
29
|
+
const editRegistry = new EditRegistry();
|
|
27
30
|
const sandboxManager = new SandboxManager({
|
|
28
31
|
execTimeoutS: config.execTimeoutS,
|
|
29
32
|
requestTimeoutMs: config.requestTimeoutMs,
|
|
30
33
|
python: config.python,
|
|
31
34
|
sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
|
|
32
35
|
maxPromptChars: config.maxPromptChars,
|
|
36
|
+
onSandboxDiscarded: () => { editRegistry.clear(); },
|
|
33
37
|
});
|
|
34
38
|
let packedContextText: string | undefined;
|
|
35
39
|
let contextPackPromise: Promise<string | undefined> | undefined;
|
|
@@ -77,6 +81,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
77
81
|
|
|
78
82
|
// ── Tool registration ──
|
|
79
83
|
pi.registerTool(createRlmTool(controller));
|
|
84
|
+
pi.registerTool(createApplyEditsTool(editRegistry));
|
|
80
85
|
let guidePosted = false;
|
|
81
86
|
|
|
82
87
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -100,6 +105,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
100
105
|
getModel: () => controller.resolveModels(ctx)?.model,
|
|
101
106
|
getWorkerModel: () => controller.resolveModels(ctx)?.worker,
|
|
102
107
|
registry: ctx.modelRegistry,
|
|
108
|
+
editRegistry,
|
|
103
109
|
config: controller.config,
|
|
104
110
|
ensureContext: async () => {
|
|
105
111
|
const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
|
package/src/prompts/system.ts
CHANGED
|
@@ -233,8 +233,8 @@ function nativeReplGlossary(): string {
|
|
|
233
233
|
"",
|
|
234
234
|
"- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
|
|
235
235
|
"- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
|
|
236
|
-
"- `stage_edit(path, old_text, new_text)`:
|
|
237
|
-
"- `answer`: dict `{\"content\": \"\", \"ready\": False}`.
|
|
236
|
+
"- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
|
|
237
|
+
"- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
|
|
238
238
|
"",
|
|
239
239
|
"### Orchestrator Pattern",
|
|
240
240
|
"You are an **orchestrator, not a solver**. After probing `context`, decompose the task into sub-LLM / REPL steps,",
|
|
@@ -262,18 +262,18 @@ function nativeReplGlossary(): string {
|
|
|
262
262
|
"|------|------|",
|
|
263
263
|
"| `repl({code})` | Need to chunk/delegate `context` to sub-LLMs; need Python scripting; need REPL state across calls |",
|
|
264
264
|
"| `zebra-mcp` | Semantic search over the codebase |",
|
|
265
|
-
"| `edit` |
|
|
265
|
+
"| `edit` | Native Pi edit tool; prefer `stage_edit` + `apply_edits` from REPL for file changes in native RLM mode |",
|
|
266
266
|
"| `write` | Create a new file (native Pi flow, visible to all plugins) |",
|
|
267
267
|
"| `llm_query` (inside repl) | Extract, summarize, or classify a chunk of text |",
|
|
268
268
|
"| `rlm_query` (inside repl) | Complex sub-task needing iterative reasoning with its own REPL |",
|
|
269
269
|
"| `todo` (inside repl) | Track multi-step progress visibly to the user |",
|
|
270
|
-
"| `stage_edit(path, old, new)` (inside repl) |
|
|
270
|
+
"| `stage_edit(path, old, new)` (inside repl) | Stage exact edit params; apply returned IDs with `apply_edits` |",
|
|
271
271
|
"",
|
|
272
272
|
"### Workflow",
|
|
273
273
|
"1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
|
|
274
274
|
"2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
|
|
275
275
|
"3. **Aggregate**: Collect results in Python, pass aggregated results to a final `llm_query` or produce the answer directly.",
|
|
276
|
-
"4. **Finalize**: For file changes, stage them inside repl() via stage_edit(path, old, new)
|
|
276
|
+
"4. **Finalize**: For file changes, stage them inside repl() via `stage_edit(path, old, new)`, then apply the returned IDs with `apply_edits({ ids })`. Do not use the native `edit` tool directly for native RLM file changes unless explicitly asked. For analysis tasks, write a normal message.",
|
|
277
277
|
"",
|
|
278
278
|
"### Task-Specific Patterns",
|
|
279
279
|
LARGE_FILE_RULE_NATIVE,
|
|
@@ -305,12 +305,9 @@ export function buildNativeSystemPrompt(): string {
|
|
|
305
305
|
"All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
|
|
306
306
|
"If sub-LLM credits are exhausted → report the error to the user and stop.",
|
|
307
307
|
"",
|
|
308
|
-
"For file changes,
|
|
309
|
-
"
|
|
310
|
-
"",
|
|
311
|
-
"When repl() returns a STAGED_EDITS block, apply each entry by calling `edit` verbatim:",
|
|
312
|
-
" edit({ path: entry.path, edits: [{ oldText: entry.oldText, newText: entry.newText }] })",
|
|
313
|
-
"Do not analyze or modify the parameters — relay them exactly as provided by the sub-agent.",
|
|
308
|
+
"For file changes, prefer `stage_edit()` inside repl(); it returns edit IDs and keeps edit bodies out of your output.",
|
|
309
|
+
"When repl() returns STAGED_EDITS, apply them with `apply_edits({ ids: [\"e1\", ...] })`.",
|
|
310
|
+
"Never re-type file paths, oldText, newText, file bodies, or `answer[\"content\"]` in your own output.",
|
|
314
311
|
"",
|
|
315
312
|
nativeReplGlossary(),
|
|
316
313
|
].join("\n");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
2
|
+
|
|
3
|
+
export class EditRegistry {
|
|
4
|
+
private readonly edits = new Map<string, ProposedEdit>();
|
|
5
|
+
|
|
6
|
+
registerAll(edits: readonly ProposedEdit[] | undefined): void {
|
|
7
|
+
if (edits === undefined) return;
|
|
8
|
+
for (const edit of edits) this.edits.set(edit.id, edit);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
get(id: string): ProposedEdit | undefined {
|
|
12
|
+
return this.edits.get(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
delete(id: string): boolean {
|
|
16
|
+
return this.edits.delete(id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clear(): void {
|
|
20
|
+
this.edits.clear();
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface SandboxManagerConfig {
|
|
|
15
15
|
readonly sandboxInitTimeoutMs: number;
|
|
16
16
|
readonly maxPromptChars: number;
|
|
17
17
|
readonly signal?: AbortSignal;
|
|
18
|
+
readonly onSandboxDiscarded?: () => void;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export class SandboxManager {
|
|
@@ -118,6 +119,7 @@ export class SandboxManager {
|
|
|
118
119
|
try { await this.sandbox.dispose(); } catch { /* already dead */ }
|
|
119
120
|
this.sandbox = null;
|
|
120
121
|
this.contextLoaded = false;
|
|
122
|
+
this.config.onSandboxDiscarded?.();
|
|
121
123
|
}
|
|
122
124
|
throw err;
|
|
123
125
|
} finally {
|
|
@@ -141,7 +143,10 @@ export class SandboxManager {
|
|
|
141
143
|
if (this.disposed) return;
|
|
142
144
|
this.disposed = true;
|
|
143
145
|
await this.sandbox?.dispose();
|
|
144
|
-
this.sandbox
|
|
145
|
-
|
|
146
|
+
if (this.sandbox !== null) {
|
|
147
|
+
this.sandbox = null;
|
|
148
|
+
this.contextLoaded = false;
|
|
149
|
+
this.config.onSandboxDiscarded?.();
|
|
150
|
+
}
|
|
146
151
|
}
|
|
147
152
|
}
|
package/src/sandbox/worker.py
CHANGED
|
@@ -132,6 +132,7 @@ class Worker:
|
|
|
132
132
|
self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
|
|
133
133
|
self._ctx_payloads: dict[int, Any] = {}
|
|
134
134
|
self._staged_edits: list[dict[str, str]] = []
|
|
135
|
+
self._edit_counter = 0
|
|
135
136
|
self._nudged: set[str] = set()
|
|
136
137
|
self._restore_scaffold()
|
|
137
138
|
|
|
@@ -325,8 +326,10 @@ class Worker:
|
|
|
325
326
|
def _stage_edit(self, path: str, old_text: str, new_text: str) -> str:
|
|
326
327
|
if not isinstance(path, str) or not isinstance(old_text, str) or not isinstance(new_text, str):
|
|
327
328
|
return "Error: path, old_text, new_text must be strings"
|
|
328
|
-
self.
|
|
329
|
-
|
|
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
|
|
330
333
|
|
|
331
334
|
def _advance_phase(self, phase: str, summary: str | None = None) -> str:
|
|
332
335
|
"""Transition the root RLM pipeline to a new phase.
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { createEditToolDefinition, type AgentToolResult, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Container, Text, type Component } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import type { EditRegistry } from "../registry/edit-registry.ts";
|
|
7
|
+
import { countOccurrences } from "../text/edits.ts";
|
|
8
|
+
import { errorMessage, formatError } from "../util/errors.ts";
|
|
9
|
+
|
|
10
|
+
export const ApplyEditsToolParams = Object.freeze(Type.Object({
|
|
11
|
+
ids: Type.Array(Type.String({ description: "A staged edit ID returned by stage_edit()." }), {
|
|
12
|
+
description: "Staged edit IDs to apply.",
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
export interface ApplyEditsFailure {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly error: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ApplyEditsPatch {
|
|
23
|
+
readonly oldText: string;
|
|
24
|
+
readonly newText: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApplyEditsFileStat {
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly status: "applied" | "failed";
|
|
30
|
+
readonly added: number;
|
|
31
|
+
readonly removed: number;
|
|
32
|
+
readonly edits: readonly ApplyEditsPatch[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApplyEditsDetails {
|
|
36
|
+
readonly status: "done" | "partial" | "error";
|
|
37
|
+
readonly appliedCount: number;
|
|
38
|
+
readonly failedCount: number;
|
|
39
|
+
readonly errors: readonly ApplyEditsFailure[];
|
|
40
|
+
readonly fileStats: readonly ApplyEditsFileStat[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LineStats {
|
|
44
|
+
readonly added: number;
|
|
45
|
+
readonly removed: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function countLines(text: string): number {
|
|
49
|
+
return text.length === 0 ? 0 : text.split("\n").length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function diffStats(before: string, after: string): LineStats {
|
|
53
|
+
const beforeLineCount = countLines(before);
|
|
54
|
+
const afterLineCount = countLines(after);
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
added: Math.max(0, afterLineCount - beforeLineCount),
|
|
57
|
+
removed: Math.max(0, beforeLineCount - afterLineCount),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function statusFor(appliedCount: number, failedCount: number): ApplyEditsDetails["status"] {
|
|
62
|
+
if (failedCount === 0 && appliedCount > 0) return "done";
|
|
63
|
+
return appliedCount > 0 ? "partial" : "error";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function aggregateLineStats(fileStats: readonly ApplyEditsFileStat[]): LineStats {
|
|
67
|
+
let added = 0;
|
|
68
|
+
let removed = 0;
|
|
69
|
+
for (let i = 0; i < fileStats.length; i++) {
|
|
70
|
+
const stat = fileStats[i];
|
|
71
|
+
added += stat.added;
|
|
72
|
+
removed += stat.removed;
|
|
73
|
+
}
|
|
74
|
+
return Object.freeze({ added, removed });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function appendPatch(existing: readonly ApplyEditsPatch[], patch: ApplyEditsPatch | undefined): readonly ApplyEditsPatch[] {
|
|
78
|
+
if (patch === undefined) return existing;
|
|
79
|
+
const patches = new Array<ApplyEditsPatch>(existing.length + 1);
|
|
80
|
+
for (let i = 0; i < existing.length; i++) {
|
|
81
|
+
patches[i] = existing[i];
|
|
82
|
+
}
|
|
83
|
+
patches[existing.length] = patch;
|
|
84
|
+
return Object.freeze(patches);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mergeFileStat(
|
|
88
|
+
fileStatsByPath: Map<string, ApplyEditsFileStat>,
|
|
89
|
+
path: string,
|
|
90
|
+
status: ApplyEditsFileStat["status"],
|
|
91
|
+
stats: LineStats,
|
|
92
|
+
patch?: ApplyEditsPatch,
|
|
93
|
+
): void {
|
|
94
|
+
const existing = fileStatsByPath.get(path);
|
|
95
|
+
const nextStatus: ApplyEditsFileStat["status"] = existing?.status === "failed" || status === "failed" ? "failed" : "applied";
|
|
96
|
+
fileStatsByPath.set(path, {
|
|
97
|
+
path,
|
|
98
|
+
status: nextStatus,
|
|
99
|
+
added: (existing?.added ?? 0) + stats.added,
|
|
100
|
+
removed: (existing?.removed ?? 0) + stats.removed,
|
|
101
|
+
edits: appendPatch(existing?.edits ?? Object.freeze([]), patch),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderLineStats(stats: LineStats, theme: Theme): string {
|
|
106
|
+
return `${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)} lines`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatEditCounts(details: ApplyEditsDetails): string {
|
|
110
|
+
return details.failedCount > 0
|
|
111
|
+
? `${details.appliedCount} applied, ${details.failedCount} failed`
|
|
112
|
+
: `${details.appliedCount} applied`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatFileCount(fileCount: number): string {
|
|
116
|
+
return `${fileCount} file${fileCount === 1 ? "" : "s"}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function summarizeFiles(details: ApplyEditsDetails): string {
|
|
120
|
+
return `apply_edits: ${formatFileCount(details.fileStats.length)}, ${formatEditCounts(details)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function summarize(details: ApplyEditsDetails): string {
|
|
124
|
+
const stats = aggregateLineStats(details.fileStats);
|
|
125
|
+
const head = `${summarizeFiles(details)} (+${stats.added} -${stats.removed} lines)`;
|
|
126
|
+
if (details.errors.length === 0) return `${head}.`;
|
|
127
|
+
const rows = new Array<string>(details.errors.length);
|
|
128
|
+
for (let i = 0; i < details.errors.length; i++) {
|
|
129
|
+
const error = details.errors[i];
|
|
130
|
+
rows[i] = `${error.id}: ${error.error}`;
|
|
131
|
+
}
|
|
132
|
+
return `${head}.\n${rows.join("\n")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderCollapsed(details: ApplyEditsDetails, theme: Theme): Text {
|
|
136
|
+
const stats = aggregateLineStats(details.fileStats);
|
|
137
|
+
const statusColor = details.status === "error" ? "error" : "success";
|
|
138
|
+
return new Text(`${theme.fg(statusColor, summarizeFiles(details))} ${renderLineStats(stats, theme)}`, 0, 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function renderFileLine(fileStat: ApplyEditsFileStat, theme: Theme): Text {
|
|
142
|
+
const glyph = fileStat.status === "applied" ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
143
|
+
const stats = renderLineStats(fileStat, theme);
|
|
144
|
+
return new Text(`${glyph} ${theme.fg("dim", fileStat.path)} ${stats}`, 0, 0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function limitPatchText(text: string): string {
|
|
148
|
+
const maxChars = 2_000;
|
|
149
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderPatchLines(text: string, prefix: string, color: "error" | "success", theme: Theme): string {
|
|
153
|
+
const limitedText = limitPatchText(text);
|
|
154
|
+
const lines = limitedText.split("\n");
|
|
155
|
+
const rendered = new Array<string>(lines.length);
|
|
156
|
+
for (let i = 0; i < lines.length; i++) {
|
|
157
|
+
rendered[i] = theme.fg(color, `${prefix}${lines[i]}`);
|
|
158
|
+
}
|
|
159
|
+
return rendered.join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderPatch(patch: ApplyEditsPatch, theme: Theme): Text {
|
|
163
|
+
const oldText = patch.oldText.length === 0 ? "(new file)" : patch.oldText;
|
|
164
|
+
const text = [
|
|
165
|
+
theme.fg("error", "--- old"),
|
|
166
|
+
renderPatchLines(oldText, "- ", "error", theme),
|
|
167
|
+
theme.fg("success", "+++ new"),
|
|
168
|
+
renderPatchLines(patch.newText, "+ ", "success", theme),
|
|
169
|
+
].join("\n");
|
|
170
|
+
return new Text(text, 2, 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderExpanded(details: ApplyEditsDetails, theme: Theme): Container {
|
|
174
|
+
const container = new Container();
|
|
175
|
+
const header = summarizeFiles(details);
|
|
176
|
+
const headerColor = details.status === "error" ? "error" : "success";
|
|
177
|
+
container.addChild(new Text(theme.fg(headerColor, header), 0, 0));
|
|
178
|
+
for (let i = 0; i < details.fileStats.length; i++) {
|
|
179
|
+
const fileStat = details.fileStats[i];
|
|
180
|
+
container.addChild(renderFileLine(fileStat, theme));
|
|
181
|
+
for (let editIndex = 0; editIndex < fileStat.edits.length; editIndex++) {
|
|
182
|
+
container.addChild(renderPatch(fileStat.edits[editIndex], theme));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (details.errors.length > 0) {
|
|
186
|
+
const rows = new Array<string>(details.errors.length);
|
|
187
|
+
for (let i = 0; i < details.errors.length; i++) {
|
|
188
|
+
const error = details.errors[i];
|
|
189
|
+
rows[i] = `${error.id}: ${error.error}`;
|
|
190
|
+
}
|
|
191
|
+
container.addChild(new Text(theme.fg("error", rows.join("\n")), 0, 0));
|
|
192
|
+
}
|
|
193
|
+
return container;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition<typeof ApplyEditsToolParams, ApplyEditsDetails> {
|
|
197
|
+
return {
|
|
198
|
+
name: "apply_edits",
|
|
199
|
+
label: "Apply Edits",
|
|
200
|
+
description: "Apply staged REPL edits by ID without re-typing file paths or edit bodies.",
|
|
201
|
+
parameters: ApplyEditsToolParams,
|
|
202
|
+
|
|
203
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ApplyEditsDetails>> {
|
|
204
|
+
const errors = new Array<ApplyEditsFailure>(params.ids.length);
|
|
205
|
+
const fileStatsByPath = new Map<string, ApplyEditsFileStat>();
|
|
206
|
+
let appliedCount = 0;
|
|
207
|
+
let failedCount = 0;
|
|
208
|
+
|
|
209
|
+
const editTool = createEditToolDefinition(ctx.cwd);
|
|
210
|
+
for (let i = 0; i < params.ids.length; i++) {
|
|
211
|
+
const id = params.ids[i];
|
|
212
|
+
const edit = editRegistry.get(id);
|
|
213
|
+
if (edit === undefined) {
|
|
214
|
+
errors[failedCount] = { id, path: id, error: formatError("unknown edit id") };
|
|
215
|
+
mergeFileStat(fileStatsByPath, id, "failed", { added: 0, removed: 0 });
|
|
216
|
+
failedCount++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const fullPath = resolve(ctx.cwd, edit.path);
|
|
222
|
+
if (edit.oldText.length === 0) {
|
|
223
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
224
|
+
await writeFile(fullPath, edit.newText, "utf8");
|
|
225
|
+
mergeFileStat(fileStatsByPath, edit.path, "applied", diffStats("", edit.newText), { oldText: edit.oldText, newText: edit.newText });
|
|
226
|
+
editRegistry.delete(id);
|
|
227
|
+
appliedCount++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const content = await readFile(fullPath, "utf8");
|
|
232
|
+
const occurrences = countOccurrences(content, edit.oldText);
|
|
233
|
+
if (occurrences !== 1) {
|
|
234
|
+
errors[failedCount] = { id, path: edit.path, error: formatError(`anchor occurs ${occurrences} times in ${edit.path}`) };
|
|
235
|
+
mergeFileStat(fileStatsByPath, edit.path, "failed", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
|
|
236
|
+
failedCount++;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const after = content.replace(edit.oldText, edit.newText);
|
|
241
|
+
await editTool.execute(
|
|
242
|
+
toolCallId,
|
|
243
|
+
{ path: edit.path, edits: [{ oldText: edit.oldText, newText: edit.newText }] },
|
|
244
|
+
signal,
|
|
245
|
+
undefined,
|
|
246
|
+
ctx,
|
|
247
|
+
);
|
|
248
|
+
mergeFileStat(fileStatsByPath, edit.path, "applied", diffStats(content, after), { oldText: edit.oldText, newText: edit.newText });
|
|
249
|
+
editRegistry.delete(id);
|
|
250
|
+
appliedCount++;
|
|
251
|
+
} catch (error: unknown) {
|
|
252
|
+
const path = edit.path;
|
|
253
|
+
errors[failedCount] = { id, path, error: formatError(errorMessage(error)) };
|
|
254
|
+
mergeFileStat(fileStatsByPath, path, "failed", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
|
|
255
|
+
failedCount++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const fileStats = new Array<ApplyEditsFileStat>(fileStatsByPath.size);
|
|
260
|
+
let fileStatIndex = 0;
|
|
261
|
+
for (const stat of fileStatsByPath.values()) {
|
|
262
|
+
fileStats[fileStatIndex] = stat;
|
|
263
|
+
fileStatIndex++;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const details = Object.freeze({
|
|
267
|
+
status: statusFor(appliedCount, failedCount),
|
|
268
|
+
appliedCount,
|
|
269
|
+
failedCount,
|
|
270
|
+
errors: Object.freeze(errors.slice(0, failedCount)),
|
|
271
|
+
fileStats: Object.freeze(fileStats),
|
|
272
|
+
});
|
|
273
|
+
return { content: [{ type: "text", text: summarize(details) }], details };
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
renderCall(args, theme) {
|
|
277
|
+
const editCount = args.ids.length;
|
|
278
|
+
const summary = `apply_edits: ${editCount} edit${editCount === 1 ? "" : "s"}`;
|
|
279
|
+
return new Text(theme.fg("toolTitle", theme.bold(summary)), 0, 0);
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
renderResult(result, options, theme): Component {
|
|
283
|
+
const details = result.details;
|
|
284
|
+
if (details === undefined) return new Text("(no apply_edits details)", 0, 0);
|
|
285
|
+
return options.expanded ? renderExpanded(details, theme) : renderCollapsed(details, theme);
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
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
|
@@ -34,6 +34,7 @@ import type { ReplDetails } from "./repl-details.ts";
|
|
|
34
34
|
import type { RlmSubcall } from "./rlm-details.ts";
|
|
35
35
|
import { createEngine } from "../core/engine.ts";
|
|
36
36
|
import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
|
|
37
|
+
import type { EditRegistry } from "../registry/edit-registry.ts";
|
|
37
38
|
import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
|
|
38
39
|
import {
|
|
39
40
|
headlineStatusGlyph,
|
|
@@ -59,29 +60,49 @@ export interface ReplResultText {
|
|
|
59
60
|
readonly surfacedEdits: readonly ProposedEdit[] | undefined;
|
|
60
61
|
}
|
|
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
|
+
|
|
62
82
|
/**
|
|
63
83
|
* Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
|
|
64
|
-
* delegation nudge (suppressed when edits were staged), and
|
|
65
|
-
*
|
|
66
|
-
* capping/ordering invariants are testable independently of the sandbox.
|
|
84
|
+
* delegation nudge (suppressed when edits were staged), and summarize staged edits by ID
|
|
85
|
+
* without exposing oldText/newText bodies to the root model.
|
|
67
86
|
*/
|
|
68
87
|
export function buildReplResultText(
|
|
69
88
|
stdout: string,
|
|
70
|
-
|
|
89
|
+
finalAnswer: string | undefined,
|
|
71
90
|
edits: readonly ProposedEdit[],
|
|
72
91
|
raised: boolean,
|
|
73
92
|
subcalls: readonly RlmSubcall[],
|
|
74
93
|
): ReplResultText {
|
|
75
|
-
const
|
|
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)";
|
|
76
98
|
const surfacedEdits = surfaceReplEdits(edits, raised);
|
|
77
|
-
const editsBlock = surfacedEdits
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const cappedText = capReplResultText(rawText) ?? rawText;
|
|
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;
|
|
82
103
|
const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
|
|
83
|
-
const nudge = surfacedEdits ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
84
|
-
return { text: cappedText + (nudge ?? "")
|
|
104
|
+
const nudge = surfacedEdits || answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
105
|
+
return { text: cappedText + (nudge ?? ""), surfacedEdits };
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
// ── Mutable bridge state (handler indirection) ──
|
|
@@ -315,6 +336,7 @@ export interface ReplToolDeps {
|
|
|
315
336
|
readonly getModel?: () => Model<Api> | undefined;
|
|
316
337
|
readonly getWorkerModel?: () => Model<Api> | undefined;
|
|
317
338
|
readonly registry: ModelRegistry;
|
|
339
|
+
readonly editRegistry?: EditRegistry;
|
|
318
340
|
readonly config: RlmConfig;
|
|
319
341
|
readonly signal?: AbortSignal;
|
|
320
342
|
readonly onUsage?: (usage: Usage, role: "sub") => void;
|
|
@@ -322,7 +344,7 @@ export interface ReplToolDeps {
|
|
|
322
344
|
}
|
|
323
345
|
|
|
324
346
|
export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
|
|
325
|
-
const { sandboxManager, workerModel, registry, config, signal, onUsage } = deps;
|
|
347
|
+
const { sandboxManager, workerModel, registry, editRegistry, config, signal, onUsage } = deps;
|
|
326
348
|
const bridgeState = new NativeBridgeState();
|
|
327
349
|
|
|
328
350
|
// Build handlers once — llm/rlm use mutable refs, interactive is session-stable
|
|
@@ -464,13 +486,15 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
464
486
|
|
|
465
487
|
if (queuedId) emitter.emitSubcallUpdated({ id: queuedId, status: "done" });
|
|
466
488
|
|
|
489
|
+
const finalAnswer = result.finalAnswer ?? undefined;
|
|
467
490
|
const { text: resultText, surfacedEdits } = buildReplResultText(
|
|
468
491
|
result.stdout,
|
|
469
|
-
|
|
492
|
+
finalAnswer,
|
|
470
493
|
result.edits,
|
|
471
494
|
result.raised,
|
|
472
495
|
store.getSubcalls(),
|
|
473
496
|
);
|
|
497
|
+
editRegistry?.registerAll(surfacedEdits);
|
|
474
498
|
|
|
475
499
|
const details: ReplDetails = {
|
|
476
500
|
status: "done",
|
|
@@ -479,10 +503,14 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
479
503
|
executionTimeMs: elapsed,
|
|
480
504
|
subcalls: store.getSubcalls(),
|
|
481
505
|
totals: store.getTotals(),
|
|
506
|
+
finalAnswer,
|
|
482
507
|
edits: surfacedEdits,
|
|
483
508
|
};
|
|
509
|
+
const progressText = finalAnswer !== undefined
|
|
510
|
+
? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
|
|
511
|
+
: result.stdout.slice(0, 500) || "(no output)";
|
|
484
512
|
// Final progressive update
|
|
485
|
-
onUpdate?.({ content: [{ type: "text", text:
|
|
513
|
+
onUpdate?.({ content: [{ type: "text", text: progressText }], details });
|
|
486
514
|
return { content: [{ type: "text", text: resultText }], details };
|
|
487
515
|
} catch (e) {
|
|
488
516
|
progressStatus = "error";
|