@hicaru/pi-rlm 0.1.8 → 0.2.0

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.
Files changed (52) hide show
  1. package/README.md +22 -19
  2. package/package.json +2 -1
  3. package/src/bridge/library.ts +93 -15
  4. package/src/bridge/llm-query.ts +60 -36
  5. package/src/bridge/rlm-query.ts +63 -79
  6. package/src/commands/rlm-config.ts +8 -8
  7. package/src/commands/rlm.ts +48 -12
  8. package/src/config/settings.ts +33 -3
  9. package/src/context/library-context.ts +209 -22
  10. package/src/context/repomix-context.ts +7 -58
  11. package/src/core/answer.ts +5 -13
  12. package/src/core/artifacts.ts +4 -3
  13. package/src/core/critique.ts +92 -0
  14. package/src/core/engine.ts +94 -299
  15. package/src/core/gates.ts +33 -4
  16. package/src/core/limits.ts +19 -1
  17. package/src/core/pipeline-handlers.ts +319 -0
  18. package/src/core/pipeline.ts +40 -15
  19. package/src/core/types.ts +26 -30
  20. package/src/index.ts +36 -26
  21. package/src/mode/native-guards.ts +2 -2
  22. package/src/mode/rlm-mode.ts +8 -11
  23. package/src/prompts/phases.ts +18 -39
  24. package/src/prompts/system.ts +167 -64
  25. package/src/prompts/user.ts +1 -5
  26. package/src/sandbox/protocol.ts +5 -17
  27. package/src/sandbox/sandbox-manager.ts +5 -5
  28. package/src/sandbox/sandbox.ts +67 -27
  29. package/src/sandbox/worker.py +534 -48
  30. package/src/state/paths.ts +1 -1
  31. package/src/state/reads.ts +12 -4
  32. package/src/state/resume.ts +26 -25
  33. package/src/state/rows.ts +2 -2
  34. package/src/text/parsing.ts +0 -6
  35. package/src/text/tokens.ts +7 -1
  36. package/src/tool/repl-details.ts +2 -3
  37. package/src/tool/repl-tool.ts +132 -337
  38. package/src/tool/rlm-aggregator.ts +7 -7
  39. package/src/tool/rlm-details.ts +6 -13
  40. package/src/tool/rlm-events.ts +14 -11
  41. package/src/tool/rlm-tool.ts +20 -38
  42. package/src/tool/subcall-render.ts +61 -9
  43. package/src/tool/subcall-store.ts +4 -2
  44. package/src/ui/config-panel.ts +43 -23
  45. package/src/ui/intro.ts +2 -1
  46. package/src/ui/status.ts +8 -5
  47. package/src/ui/theme-adapter.ts +36 -0
  48. package/src/ui/theme.ts +0 -25
  49. package/src/mode/input-router.ts +0 -23
  50. package/src/registry/edit-registry.ts +0 -22
  51. package/src/text/edits.ts +0 -164
  52. package/src/tool/apply-edits-tool.ts +0 -295
@@ -6,9 +6,10 @@
6
6
  * and collects sub-calls manually from emitter events. No RlmEventAggregator is used
7
7
  * (ReplDetails ≠ RlmDetails structural mismatch).
8
8
  *
9
- * Sandbox handlers (llm_query, rlm_query, todo, ask_user_question) use mutable refs
10
- * so the tool can swap per-invocation state (emitter, depth, limits) without recreating
11
- * the sandbox preserving REPL variable state across calls.
9
+ * Sandbox handlers (llm_query, rlm_query, todo, ask_user_question) are the *shared* bridges
10
+ * from bridge/llm-query.ts and bridge/rlm-query.ts, bound to NativeBridgeState accessors so
11
+ * the tool can swap per-invocation state (emitter, depth, limits) without recreating the
12
+ * sandbox — preserving REPL variable state across calls.
12
13
  */
13
14
 
14
15
  import { Type } from "typebox";
@@ -16,103 +17,91 @@ import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
16
17
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
17
18
  import type { Model, Usage, Api } from "@earendil-works/pi-ai";
18
19
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
19
- import { modelRef, resolveModelId } from "../config/settings.ts";
20
+ import { displayModelRef } from "../config/settings.ts";
20
21
  import { buildInteractiveHandlers } from "../bridge/interactive.ts";
21
22
  import { buildLibraryHandler } from "../bridge/library.ts";
22
23
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
23
- import { type ChatMsg, modelComplete } from "../bridge/model.ts";
24
- import { previewText } from "../text/preview.ts";
25
- import { mapPool } from "../util/concurrency.ts";
26
- import { LimitGuard } from "../core/limits.ts";
27
- import { checkResourceLimits } from "../core/resource-limits.ts";
28
- import type { InteractiveDeps, RlmConfig, Sampling } from "../core/types.ts";
24
+ import { createLlmBridge } from "../bridge/llm-query.ts";
25
+ import { createRlmHandlers } from "../bridge/rlm-query.ts";
26
+ import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
27
+ import type { RemainingResources } from "../core/resource-limits.ts";
28
+ import type { InteractiveDeps, RlmConfig, RunRlm } from "../core/types.ts";
29
29
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
30
- import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
31
- import type { ProposedEdit, ReplResult } from "../sandbox/protocol.ts";
30
+ import type { ReplResult } from "../sandbox/protocol.ts";
32
31
  import { RlmEmitter } from "./rlm-events.ts";
33
32
  import { SubcallStore } from "./subcall-store.ts";
34
33
  import type { ReplDetails } from "./repl-details.ts";
35
34
  import type { RlmSubcall } from "./rlm-details.ts";
36
35
  import { createEngine } from "../core/engine.ts";
37
- import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
38
- import type { EditRegistry } from "../registry/edit-registry.ts";
39
- import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
36
+ import { spinnerFrame } from "../ui/theme.ts";
37
+ import { previewText } from "../text/preview.ts";
38
+ import { errorMessage } from "../util/errors.ts";
40
39
  import {
41
- headlineStatusGlyph,
42
- renderCollapsedSubcallTree,
40
+ cardHeader,
41
+ cardStatsLine,
42
+ renderCollapsedCard,
43
43
  renderExpandedSubcallTree,
44
44
  } from "./subcall-render.ts";
45
45
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
46
46
  import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
47
47
 
48
+ /** Chars of code shown on the tool call line, and of stdout in the expanded view. */
49
+ const CALL_PREVIEW_CHARS = 80;
50
+ const EXPANDED_STDOUT_CHARS = 2_000;
51
+ const EXPANDED_STDERR_CHARS = 500;
52
+
48
53
  // ── Parameter schema ──
49
54
 
50
55
  export const ReplToolParams = Object.freeze(Type.Object({
51
56
  code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
52
57
  }));
53
58
 
54
- export function surfaceReplEdits(edits: readonly ProposedEdit[], raised: boolean): readonly ProposedEdit[] | undefined {
55
- return edits.length > 0 && !raised ? edits : undefined;
56
- }
57
-
58
- /** Model-visible text assembled from a repl() result, plus the surfaced edits for `details`. */
59
+ /** Model-visible text assembled from a repl() result. */
59
60
  export interface ReplResultText {
60
61
  readonly text: string;
61
- readonly surfacedEdits: readonly ProposedEdit[] | undefined;
62
- }
63
-
64
- function countLines(text: string): number {
65
- if (text.length === 0) return 0;
66
- let count = 1;
67
- for (const ch of text) if (ch === "\n") count++;
68
- return count;
69
- }
70
-
71
- function stagedEditSummary(edits: readonly ProposedEdit[]): string {
72
- const rows = new Array<string>(edits.length);
73
- for (let i = 0; i < edits.length; i++) {
74
- const edit = edits[i];
75
- rows[i] = ` ${edit.id} ${edit.path} (-${countLines(edit.oldText)}/+${countLines(edit.newText)} lines)`;
76
- }
77
- return [
78
- "STAGED_EDITS (apply by id with apply_edits; do NOT re-type content):",
79
- ...rows,
80
- ].join("\n");
81
62
  }
82
63
 
83
64
  /**
84
- * Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
85
- * delegation nudge (suppressed when edits were staged), and summarize staged edits by ID
86
- * without exposing oldText/newText bodies to the root model.
65
+ * Assemble the model-visible text for a repl() result: cap stdout and append a
66
+ * zero-subcall delegation nudge when a bulk read went undelegated.
87
67
  */
88
68
  export function buildReplResultText(
89
69
  stdout: string,
90
70
  finalAnswer: string | undefined,
91
- edits: readonly ProposedEdit[],
92
- raised: boolean,
93
71
  subcalls: readonly RlmSubcall[],
94
72
  ): ReplResultText {
95
73
  const answerSubmitted = finalAnswer !== undefined;
96
74
  const rawText = answerSubmitted
97
75
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
98
76
  : stdout || "(no output)";
99
- const surfacedEdits = surfaceReplEdits(edits, raised);
100
- const editsBlock = surfacedEdits ? `\n\n${stagedEditSummary(surfacedEdits)}` : "";
101
- const modelText = rawText + editsBlock;
102
- // Model-visible text is capped; the caller keeps full stdout/final answer in `details` for the TUI.
103
- const cappedText = capReplResultText(modelText) ?? modelText;
77
+ // Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
78
+ const cappedText = capReplResultText(rawText) ?? rawText;
104
79
  const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
105
- const nudge = surfacedEdits || answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
106
- return { text: cappedText + (nudge ?? ""), surfacedEdits };
80
+ const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
81
+ return { text: cappedText + (nudge ?? "") };
82
+ }
83
+
84
+ /** Advisory diagnostics derived from a completed invocation's sub-calls. */
85
+ export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly string[] | undefined {
86
+ let failed = 0;
87
+ let total = 0;
88
+ for (let i = 0; i < subcalls.length; i++) {
89
+ const call = subcalls[i];
90
+ if (call.status !== "error") continue;
91
+ // A batch subcall stands for many prompts; a single call stands for one.
92
+ failed += call.failedCount ?? 1;
93
+ total += call.totalCount ?? 1;
94
+ }
95
+ if (failed === 0) return undefined;
96
+ return Object.freeze([`${failed}/${total} sub-call(s) failed — results may be incomplete`]);
107
97
  }
108
98
 
109
99
  // ── Mutable bridge state (handler indirection) ──
110
100
 
111
101
  /**
112
- * Holds per-invocation mutable state that sandbox handlers dereference.
113
- * The sandbox is created once with handlers that read from this object's
114
- * current fields, so the tool can swap emitter/depth/limits between calls
115
- * without recreating the sandbox (preserving REPL variable state).
102
+ * Holds per-invocation mutable state that the shared bridges dereference through accessors.
103
+ * The sandbox is created once with handlers bound to this object, so the tool can swap
104
+ * emitter/depth/limits between calls without recreating the sandbox (preserving REPL state).
116
105
  */
117
106
  class NativeBridgeState {
118
107
  currentEmitter: RlmEmitter | null = null;
@@ -129,202 +118,11 @@ class NativeBridgeState {
129
118
  this.currentInteractive = inv.interactive;
130
119
  }
131
120
 
132
- buildLlmHandlers(deps: {
133
- workerModel: Model<Api>;
134
- getWorkerModel?: () => Model<Api> | undefined;
135
- registry: ModelRegistry;
136
- maxPromptChars: number;
137
- maxConcurrent: number;
138
- sampling?: Sampling;
139
- subSystem?: string;
140
- signal?: AbortSignal;
141
- }): Pick<SubLlmHandlers, "llmQuery" | "llmQueryBatched"> {
142
- const state = this;
143
-
144
- const workerModel = (): Model<Api> => deps.getWorkerModel?.() ?? deps.workerModel;
145
- const displayModel = (model: string | null): string =>
146
- modelRef(model ? (resolveModelId(deps.registry, model) ?? workerModel()) : workerModel()) ?? workerModel().id;
147
-
148
- async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
149
- const limits = state.currentLimits;
150
- if (limits) {
151
- const limitError = checkResourceLimits({ budgetUsd: limits.remainingBudgetUsd(), timeoutMs: limits.remainingTimeoutMs() });
152
- if (limitError !== undefined) return limitError;
153
- }
154
- if (prompt.length > deps.maxPromptChars) {
155
- return formatError(`sub-LLM prompt exceeded size limit (${prompt.length.toLocaleString()} chars > ${deps.maxPromptChars.toLocaleString()})`);
156
- }
157
- const resolved = model ? resolveModelId(deps.registry, model) : undefined;
158
- if (model && !resolved) return formatError(`unknown model override '${model}'`);
159
- try {
160
- const messages: ChatMsg[] = [{ role: "user", content: prompt }];
161
- const res = await modelComplete(messages, {
162
- model: resolved ?? workerModel(),
163
- registry: deps.registry,
164
- system: deps.subSystem,
165
- maxTokens: deps.sampling?.maxTokens,
166
- temperature: deps.sampling?.temperature,
167
- reasoning: deps.sampling?.reasoning,
168
- signal: deps.signal,
169
- });
170
- limits?.addUsage(res.usage);
171
- track(res.usage);
172
- return res.text;
173
- } catch (err) {
174
- const msg = errorMessage(err);
175
- const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
176
- ? " — try smaller batches or individual llm_query calls"
177
- : "";
178
- return formatError(`${msg}${hint}`);
179
- }
180
- }
181
-
182
- return {
183
- async llmQuery(prompt, model, _depth) {
184
- const id = state.currentEmitter?.emitSubcallCreated({
185
- kind: "llm", parentId: state.currentParentId, label: "llm_query",
186
- model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
187
- depth: state.currentDepth,
188
- });
189
- let cost = 0; let tokens = 0;
190
- const out = await complete1(prompt, model, (u) => { cost += u.cost.total; tokens += u.totalTokens; });
191
- if (id) state.currentEmitter?.emitSubcallUpdated({ id,
192
- status: isErrorText(out) ? "error" : "done",
193
- costUsd: cost, tokens, resultPreview: previewText(out),
194
- detail: isErrorText(out) ? out : undefined,
195
- });
196
- return out;
197
- },
198
-
199
- async llmQueryBatched(prompts: readonly string[], model, _depth): Promise<string[]> {
200
- const id = state.currentEmitter?.emitSubcallCreated({
201
- kind: "batch", parentId: state.currentParentId, label: `llm_query ×${prompts.length}`,
202
- model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
203
- depth: state.currentDepth,
204
- });
205
- let cost = 0; let tokens = 0;
206
- const out: string[] = await mapPool(prompts, deps.maxConcurrent, (p) =>
207
- complete1(p, model, (u) => { cost += u.cost.total; tokens += u.totalTokens; }),
208
- );
209
- const failed = out.filter(isErrorText).length;
210
- const allFailed = failed === out.length;
211
- const error = allFailed
212
- ? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
213
- : failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
214
- if (id) state.currentEmitter?.emitSubcallUpdated({ id,
215
- status: error ? "error" : "done", costUsd: cost, tokens,
216
- resultPreview: previewText(out[0] ?? ""), detail: error,
217
- });
218
- return out;
219
- },
220
- };
221
- }
222
-
223
- /**
224
- * Build real recursive rlm_query / rlm_query_batched handlers that spawn
225
- * child RLM engines (each with its own sandbox and turn loop) rather than
226
- * falling back to a one-shot llm_query.
227
- *
228
- * At the maxDepth cap the handler degrades to a plain llm_query via the
229
- * already-wired llmHandlers (which read from the same mutable state).
230
- */
231
- buildRlmHandlers(deps: {
232
- model: Model<Api>;
233
- workerModel: Model<Api>;
234
- getModel?: () => Model<Api> | undefined;
235
- getWorkerModel?: () => Model<Api> | undefined;
236
- registry: ModelRegistry;
237
- config: RlmConfig;
238
- signal?: AbortSignal;
239
- onUsage?: (usage: Usage, role: "sub") => void;
240
- llmHandlers: Pick<SubLlmHandlers, "llmQuery" | "llmQueryBatched">;
241
- }): Pick<SubLlmHandlers, "rlmQuery" | "rlmQueryBatched"> {
242
- const state = this;
243
-
244
- async function rlmQueryImpl(prompt: string, model: string | null, depth: number): Promise<string> {
245
- const emitter = state.currentEmitter;
246
- const limits = state.currentLimits;
247
- if (!emitter || !limits) return formatError("RLM bridge not wired for this invocation");
248
-
249
- const childDepth = state.currentDepth + 1;
250
-
251
- // Depth cap: degrade to a one-shot llm_query.
252
- if (childDepth >= deps.config.maxDepth) {
253
- return deps.llmHandlers.llmQuery(prompt, model, depth);
254
- }
255
-
256
- const remBudget = limits.remainingBudgetUsd();
257
- const remTimeout = limits.remainingTimeoutMs();
258
- const limitError = checkResourceLimits({ budgetUsd: remBudget, timeoutMs: remTimeout });
259
- if (limitError) return limitError;
260
-
261
- const rootModel = deps.getModel?.() ?? deps.model;
262
- const workerModel = deps.getWorkerModel?.() ?? deps.workerModel;
263
- const resolvedOverride = model ? resolveModelId(deps.registry, model) : undefined;
264
- const subId = emitter.emitSubcallCreated({
265
- kind: "rlm", parentId: state.currentParentId, label: "rlm_query",
266
- model: model ? (modelRef(resolvedOverride) ?? `unknown/${model}`) : (modelRef(rootModel) ?? rootModel.id),
267
- detail: prompt.slice(0, 60),
268
- depth: childDepth,
269
- });
270
-
271
- // Per-call engine creation with the visible emitter — child llm_query subcalls,
272
- // turn progress, and cost deltas land on the per-invocation emitter, visible to
273
- // SubcallStore and the live visual tree.
274
- const runRlm = createEngine({
275
- model: rootModel,
276
- workerModel,
277
- registry: deps.registry,
278
- config: deps.config,
279
- signal: deps.signal,
280
- emitter: emitter,
281
- onUsage: deps.onUsage as ((usage: Usage, role: "root" | "sub") => void) | undefined,
282
- limits: {
283
- maxBudgetUsd: deps.config.maxBudgetUsd,
284
- maxTimeoutMs: deps.config.maxTimeoutMs,
285
- maxTokens: deps.config.maxTokens,
286
- maxErrors: deps.config.maxErrors,
287
- },
288
- onTodo: state.currentInteractive?.onTodo,
289
- onAskUserQuestion: state.currentInteractive?.onAskUserQuestion,
290
- });
291
-
292
- try {
293
- const res = await runRlm({
294
- rootPrompt: "",
295
- context: prompt,
296
- depth: childDepth,
297
- parentNodeId: subId,
298
- modelOverride: model ?? undefined,
299
- remainingBudgetUsd: remBudget,
300
- remainingTimeoutMs: remTimeout,
301
- });
302
-
303
- // Debit parent limit guard for the entire child run.
304
- limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
305
-
306
- // Child engine emits live usage deltas via the shared emitter — SubcallStore
307
- // accumulates them. No final aggregate costUsd/tokens to prevent double-counting
308
- // (matches canonical rlm-query.ts:60-63).
309
- emitter.emitSubcallUpdated({
310
- id: subId,
311
- status: "done",
312
- resultPreview: res.answer.slice(0, 200),
313
- });
314
-
315
- return res.answer;
316
- } catch (err) {
317
- const msg = errorMessage(err);
318
- emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
319
- return formatError(`child RLM failed - ${msg}`);
320
- }
321
- }
322
-
323
- return {
324
- rlmQuery: rlmQueryImpl,
325
- rlmQueryBatched: (prompts, model, depth) =>
326
- mapPool(prompts, deps.config.maxConcurrentSubcalls, (p) => rlmQueryImpl(p, model, depth)),
327
- };
121
+ /** Remaining budget/timeout of the invocation that currently owns the exec slot. */
122
+ remainingBudget(): RemainingResources | undefined {
123
+ const limits = this.currentLimits;
124
+ if (!limits) return undefined;
125
+ return { budgetUsd: limits.remainingBudgetUsd(), timeoutMs: limits.remainingTimeoutMs() };
328
126
  }
329
127
  }
330
128
 
@@ -337,8 +135,8 @@ export interface ReplToolDeps {
337
135
  readonly getModel?: () => Model<Api> | undefined;
338
136
  readonly getWorkerModel?: () => Model<Api> | undefined;
339
137
  readonly registry: ModelRegistry;
340
- readonly editRegistry?: EditRegistry;
341
- readonly config: RlmConfig;
138
+ /** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
139
+ readonly getConfig: () => RlmConfig;
342
140
  readonly signal?: AbortSignal;
343
141
  readonly onUsage?: (usage: Usage, role: "sub") => void;
344
142
  readonly ensureContext?: () => Promise<void>;
@@ -347,40 +145,64 @@ export interface ReplToolDeps {
347
145
  }
348
146
 
349
147
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
350
- const { sandboxManager, workerModel, registry, editRegistry, config, signal, onUsage } = deps;
148
+ const { sandboxManager, workerModel, registry, getConfig, signal, onUsage } = deps;
351
149
  const bridgeState = new NativeBridgeState();
352
150
 
353
151
  // Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
354
152
  let sessionCwd = process.cwd();
355
153
 
356
- // Build handlers once llm/rlm/library use late-bound deps so the same closures stay correct
357
- // across repl() calls; counter resets when the sandbox is discarded and re-spawned.
358
- const llmHandlers = bridgeState.buildLlmHandlers({
359
- workerModel,
360
- getWorkerModel: deps.getWorkerModel,
154
+ const rootModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
155
+
156
+ // Build handlers once — llm/rlm/library read late-bound state so the same closures stay
157
+ // correct across repl() calls; counters reset when the sandbox is discarded and re-spawned.
158
+ const llmHandlers = createLlmBridge({
159
+ workerModel: () => deps.getWorkerModel?.() ?? workerModel,
361
160
  registry,
362
- maxPromptChars: config.maxPromptChars,
363
- maxConcurrent: config.maxConcurrentSubcalls,
364
- sampling: config.subSampling,
365
- subSystem: config.subSystemPrompt,
161
+ config: getConfig,
366
162
  signal,
163
+ onUsage: (usage) => { bridgeState.currentLimits?.addUsage(usage); },
164
+ remainingBudget: () => bridgeState.remainingBudget(),
165
+ emitter: () => bridgeState.currentEmitter ?? undefined,
166
+ parentId: () => bridgeState.currentParentId,
167
+ depth: () => bridgeState.currentDepth,
367
168
  });
368
169
 
369
- // Real recursive rlm_query via createEngine — each call spawns a child RLM
370
- // with its own sandbox and turn loop, not a flat one-shot llm_query.
371
- const rlmHandlers = bridgeState.buildRlmHandlers({
372
- model: deps.model,
373
- workerModel,
374
- getModel: deps.getModel,
375
- getWorkerModel: deps.getWorkerModel,
376
- registry,
377
- config,
378
- signal,
379
- onUsage,
380
- llmHandlers,
170
+ // Real recursive rlm_query — each call spawns a child RLM with its own sandbox and turn
171
+ // loop, bound to the *current* invocation's emitter so child sub-calls, turn progress, and
172
+ // cost deltas land on the live visual tree.
173
+ const runChildRlm: RunRlm = (input) => {
174
+ const emitter = bridgeState.currentEmitter;
175
+ // Only reachable while an invocation owns the exec slot, which always swaps in an emitter.
176
+ if (!emitter) throw new Error("RLM bridge not wired for this invocation");
177
+ const config = getConfig();
178
+ return createEngine({
179
+ model: rootModel(),
180
+ workerModel: deps.getWorkerModel?.() ?? workerModel,
181
+ registry,
182
+ config,
183
+ signal,
184
+ emitter,
185
+ onUsage: onUsage === undefined ? undefined : (usage, role) => { if (role === "sub") onUsage(usage, role); },
186
+ limits: limitsFromConfig(config),
187
+ onTodo: bridgeState.currentInteractive?.onTodo,
188
+ onAskUserQuestion: bridgeState.currentInteractive?.onAskUserQuestion,
189
+ })(input);
190
+ };
191
+
192
+ const rlmHandlers = createRlmHandlers({
193
+ run: runChildRlm,
194
+ llm: llmHandlers,
195
+ config: getConfig,
196
+ modelLabel: (override) => displayModelRef(registry, override, rootModel()),
197
+ emitter: () => bridgeState.currentEmitter ?? undefined,
198
+ parentNodeId: () => bridgeState.currentParentId,
199
+ remainingBudget: () => bridgeState.remainingBudget(),
200
+ onChildUsage: (costUsd, inputTokens, outputTokens) => {
201
+ bridgeState.currentLimits?.addRaw(costUsd, inputTokens, outputTokens);
202
+ },
381
203
  });
382
204
 
383
- const libraryBundle = config.libraryLoader
205
+ const libraryBundle = getConfig().libraryLoader
384
206
  ? buildLibraryHandler({
385
207
  getCwd: () => sessionCwd,
386
208
  getEmitter: () => bridgeState.currentEmitter,
@@ -396,11 +218,19 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
396
218
  label: "REPL",
397
219
  description:
398
220
  "PRIMARY tool for ALL repository reading and analysis (read/grep are disabled in RLM mode). " +
399
- "Persistent Python sandbox with every file pre-loaded in `context`. You are an orchestrator: " +
400
- "chunk `context` and delegate semantic work to llm_query / llm_query_batched / " +
401
- "llm_query_chunked / rlm_query — stdout returned to you is hard-capped at 4K chars, so " +
402
- "printing file bodies is useless. Variables, imports, and state persist across calls. " +
403
- "Also supports todo, ask_user_question, and load_library inside the sandbox.",
221
+ "Persistent Python sandbox with every file pre-loaded in `context`. Locate first with the " +
222
+ "free primitives search(query) / grep_context(pattern) / outline(path), then delegate the " +
223
+ "semantic reading to map_files / llm_query / llm_query_batched / llm_query_chunked " +
224
+ "(rlm_query for iterative sub-tasks) stdout returned to you is hard-capped at 4K chars, " +
225
+ "so printing file bodies is useless. Variables, imports, and the `answers`/`plan` memo " +
226
+ "persist across calls. Also supports todo, ask_user_question, and load_library.",
227
+ promptSnippet:
228
+ "repl: run Python in a persistent sandbox holding the whole repository in `context`; " +
229
+ "search/grep_context/outline to locate, map_files/llm_query* to read.",
230
+ promptGuidelines: [
231
+ "In RLM mode, read the repository through `repl` only — `read`/`grep` and bash readers are blocked.",
232
+ "Inside `repl`, locate with search()/grep_context()/outline() before delegating bulk reading to map_files()/llm_query_batched().",
233
+ ],
404
234
  parameters: ReplToolParams,
405
235
 
406
236
  async execute(_toolCallId, rawParams, _execSignal, onUpdate, ctx) {
@@ -421,12 +251,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
421
251
  let capturedStderr = "";
422
252
  let progressStatus: ReplDetails["status"] = "running";
423
253
  const startedAt = Date.now();
424
- const limits = new LimitGuard({
425
- maxBudgetUsd: config.maxBudgetUsd,
426
- maxTimeoutMs: config.maxTimeoutMs,
427
- maxTokens: config.maxTokens,
428
- maxErrors: config.maxErrors,
429
- });
254
+ const limits = new LimitGuard(limitsFromConfig(getConfig()));
430
255
 
431
256
  // ── Progressive rendering: spinner + live sub-call tree ──
432
257
  const progress = createProgressNotifier<ReplDetails>({
@@ -458,7 +283,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
458
283
  // Build interactive handlers (session-stable callbacks)
459
284
  const interactive = createPiInteractiveDeps(ctx);
460
285
  const interactiveHandlers = buildInteractiveHandlers({
461
- onAskUserQuestion: config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
286
+ onAskUserQuestion: getConfig().askUserQuestion ? interactive.onAskUserQuestion : undefined,
462
287
  onTodo: interactive.onTodo,
463
288
  onTodoRow: undefined,
464
289
  emitter,
@@ -508,14 +333,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
508
333
  if (queuedId) emitter.emitSubcallUpdated({ id: queuedId, status: "done" });
509
334
 
510
335
  const finalAnswer = result.finalAnswer ?? undefined;
511
- const { text: resultText, surfacedEdits } = buildReplResultText(
336
+ const { text: resultText } = buildReplResultText(
512
337
  result.stdout,
513
338
  finalAnswer,
514
- result.edits,
515
- result.raised,
516
339
  store.getSubcalls(),
517
340
  );
518
- editRegistry?.registerAll(surfacedEdits);
519
341
 
520
342
  const details: ReplDetails = {
521
343
  status: "done",
@@ -525,7 +347,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
525
347
  subcalls: store.getSubcalls(),
526
348
  totals: store.getTotals(),
527
349
  finalAnswer,
528
- edits: surfacedEdits,
350
+ warnings: collectReplWarnings(store.getSubcalls()),
529
351
  };
530
352
  const progressText = finalAnswer !== undefined
531
353
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
@@ -557,10 +379,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
557
379
  },
558
380
 
559
381
  renderCall(args, theme) {
560
- const preview = args.code.length > 80 ? `${args.code.slice(0, 80)}...` : args.code;
561
382
  return new Text(
562
- theme.fg("toolTitle", theme.bold("repl ")) +
563
- theme.fg("dim", preview.replace(/\n/g, " ")),
383
+ theme.fg("toolTitle", theme.bold("repl ")) + theme.fg("dim", previewText(args.code, CALL_PREVIEW_CHARS)),
564
384
  0, 0,
565
385
  );
566
386
  },
@@ -579,29 +399,13 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
579
399
 
580
400
  // ── Collapsed view ──
581
401
 
582
- function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
583
- const glyph = details.status === "running"
584
- ? headlineStatusGlyph("running", theme)
585
- : details.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓");
586
-
587
- const parts: string[] = [];
588
- parts.push(formatCost(details.totals.costUsd));
589
- if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
590
- if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
591
- if (details.edits && details.edits.length > 0) {
592
- parts.push(theme.fg("success", `${details.edits.length} staged`));
593
- }
594
- const stats = parts.length > 0 ? ` ${theme.fg("dim", parts.join(" · "))}` : "";
595
-
596
- const header = `${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`;
597
-
598
- let body = "";
599
- if (details.subcalls.length > 0) {
600
- body = `\n${renderCollapsedSubcallTree(details.subcalls, theme)}`;
601
- }
402
+ function replStats(details: ReplDetails, theme: Theme): string {
403
+ const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
404
+ return cardStatsLine(details.totals, theme, elapsed);
405
+ }
602
406
 
603
- const expandHint = details.status === "running" ? "" : `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
604
- return new Text(`${header}${body}${expandHint}`, 0, 0);
407
+ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
408
+ return renderCollapsedCard("REPL", details.status, replStats(details, theme), details.subcalls, theme);
605
409
  }
606
410
 
607
411
  // ── Expanded view ──
@@ -609,35 +413,26 @@ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
609
413
  function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
610
414
  const container = new Container();
611
415
 
612
- // Header: status + stats
613
- const glyph = details.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓");
614
- const parts: string[] = [];
615
- parts.push(formatCost(details.totals.costUsd));
616
- if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
617
- if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
618
- const stats = parts.length > 0 ? ` · ${theme.fg("dim", parts.join(" · "))}` : "";
619
- container.addChild(new Text(`${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`, 0, 0));
416
+ container.addChild(new Text(cardHeader("REPL", details.status, replStats(details, theme), theme), 0, 0));
620
417
 
621
418
  // Output
622
419
  if (details.output) {
623
420
  container.addChild(new Spacer(1));
624
- const out = details.output.length > 2000 ? `${details.output.slice(0, 2000)}...` : details.output;
421
+ const out = details.output.length > EXPANDED_STDOUT_CHARS
422
+ ? `${details.output.slice(0, EXPANDED_STDOUT_CHARS)}…`
423
+ : details.output;
625
424
  container.addChild(new Text(out, 0, 0));
626
425
  }
627
426
 
628
- if (details.edits && details.edits.length > 0) {
629
- const editFiles = new Set<string>();
630
- for (const edit of details.edits) editFiles.add(edit.path);
427
+ if (details.warnings && details.warnings.length > 0) {
631
428
  container.addChild(new Spacer(1));
632
- container.addChild(new Text(theme.fg("success",
633
- `${details.edits.length} edit${details.edits.length > 1 ? "s" : ""} staged across ${editFiles.size} file${editFiles.size > 1 ? "s" : ""}`,
634
- ), 0, 0));
429
+ container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
635
430
  }
636
431
 
637
432
  // Stderr
638
433
  if (details.stderr) {
639
434
  container.addChild(new Spacer(1));
640
- container.addChild(new Text(theme.fg("error", details.stderr.slice(0, 500)), 0, 0));
435
+ container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
641
436
  }
642
437
 
643
438
  // Sub-call tree