@hicaru/pi-rlm 0.1.9 → 0.2.1

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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/bridge/fallback-todo.ts +12 -1
  3. package/src/bridge/subcall-handlers.ts +336 -0
  4. package/src/commands/rlm-config.ts +8 -8
  5. package/src/commands/rlm.ts +48 -12
  6. package/src/config/defaults.ts +4 -1
  7. package/src/config/settings.ts +33 -3
  8. package/src/context/repomix-context.ts +5 -10
  9. package/src/core/answer.ts +4 -3
  10. package/src/core/artifacts.ts +4 -3
  11. package/src/core/engine.ts +101 -267
  12. package/src/core/gates.ts +3 -3
  13. package/src/core/limits.ts +19 -1
  14. package/src/core/pipeline-handlers.ts +319 -0
  15. package/src/core/pipeline.ts +2 -2
  16. package/src/core/types.ts +25 -27
  17. package/src/index.ts +63 -17
  18. package/src/mode/rlm-mode.ts +8 -11
  19. package/src/prompts/system.ts +164 -52
  20. package/src/prompts/user.ts +1 -5
  21. package/src/sandbox/protocol.ts +6 -7
  22. package/src/sandbox/sandbox-manager.ts +25 -11
  23. package/src/sandbox/sandbox.ts +93 -22
  24. package/src/sandbox/worker.py +798 -66
  25. package/src/state/paths.ts +1 -1
  26. package/src/state/reads.ts +12 -4
  27. package/src/state/resume.ts +5 -11
  28. package/src/text/parsing.ts +0 -6
  29. package/src/tool/background-tasks.ts +95 -0
  30. package/src/tool/repl-details.ts +2 -0
  31. package/src/tool/repl-tool.ts +223 -318
  32. package/src/tool/rlm-details.ts +0 -10
  33. package/src/tool/rlm-events.ts +10 -2
  34. package/src/tool/rlm-tool.ts +18 -31
  35. package/src/tool/subcall-render.ts +75 -11
  36. package/src/tool/subcall-store.ts +57 -1
  37. package/src/ui/config-panel.ts +41 -21
  38. package/src/ui/intro.ts +2 -1
  39. package/src/ui/status.ts +8 -5
  40. package/src/ui/theme-adapter.ts +36 -0
  41. package/src/ui/theme.ts +0 -25
  42. package/src/util/concurrency.ts +87 -13
  43. package/src/util/trace.ts +42 -0
  44. package/src/bridge/llm-query.ts +0 -133
  45. package/src/bridge/rlm-query.ts +0 -122
  46. package/src/mode/input-router.ts +0 -23
@@ -6,9 +6,13 @@
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
+ * Sub-call handling itself lives in bridge/subcall-handlers.ts; this file only supplies the
10
+ * per-invocation Invocation those handlers resolve against, swapping it inside the
11
+ * serialized exec slot so a queued repl() cannot claim the running one's emitter.
12
+ *
13
+ * Work started with `spawn()` may still be running when the call returns, so it resolves to
14
+ * the session-scoped BackgroundTasks registry instead and is drained back into whichever
15
+ * turn is reporting next.
12
16
  */
13
17
 
14
18
  import { Type } from "typebox";
@@ -16,33 +20,39 @@ import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
16
20
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
17
21
  import type { Model, Usage, Api } from "@earendil-works/pi-ai";
18
22
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
19
- import { modelRef, resolveModelId } from "../config/settings.ts";
20
23
  import { buildInteractiveHandlers } from "../bridge/interactive.ts";
21
24
  import { buildLibraryHandler } from "../bridge/library.ts";
22
25
  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";
26
+ import type { SubcallGates } from "../util/concurrency.ts";
27
+ import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
28
+ import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
29
29
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
30
- import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
30
+ import type { SubcallOpts } from "../sandbox/sandbox.ts";
31
+ import { createSubcallHandlers, type Invocation } from "../bridge/subcall-handlers.ts";
32
+ import { BackgroundTasks } from "./background-tasks.ts";
31
33
  import type { ReplResult } from "../sandbox/protocol.ts";
32
34
  import { RlmEmitter } from "./rlm-events.ts";
33
35
  import { SubcallStore } from "./subcall-store.ts";
34
36
  import type { ReplDetails } from "./repl-details.ts";
35
37
  import type { RlmSubcall } from "./rlm-details.ts";
36
38
  import { createEngine } from "../core/engine.ts";
37
- import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
38
- import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
39
+ import { spinnerFrame } from "../ui/theme.ts";
40
+ import { previewText } from "../text/preview.ts";
41
+ import { errorMessage } from "../util/errors.ts";
39
42
  import {
40
- headlineStatusGlyph,
41
- renderCollapsedSubcallTree,
43
+ cardHeader,
44
+ cardStatsLine,
45
+ renderCollapsedCard,
42
46
  renderExpandedSubcallTree,
43
47
  } from "./subcall-render.ts";
44
48
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
45
49
  import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
50
+ import { attachTracer, trace, traceEnabled } from "../util/trace.ts";
51
+
52
+ /** Chars of code shown on the tool call line, and of stdout in the expanded view. */
53
+ const CALL_PREVIEW_CHARS = 80;
54
+ const EXPANDED_STDOUT_CHARS = 2_000;
55
+ const EXPANDED_STDERR_CHARS = 500;
46
56
 
47
57
  // ── Parameter schema ──
48
58
 
@@ -50,29 +60,61 @@ export const ReplToolParams = Object.freeze(Type.Object({
50
60
  code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
51
61
  }));
52
62
 
63
+ /** Last non-empty line of a Python traceback — the `TypeError: …` line, not the frames. */
64
+ function lastLine(text: string): string {
65
+ const lines = text.trimEnd().split("\n");
66
+ for (let i = lines.length - 1; i >= 0; i--) {
67
+ const line = lines[i]?.trim();
68
+ if (line) return line.slice(0, 200);
69
+ }
70
+ return "";
71
+ }
72
+
53
73
  /** Model-visible text assembled from a repl() result. */
54
74
  export interface ReplResultText {
55
75
  readonly text: string;
56
76
  }
57
77
 
58
78
  /**
59
- * Assemble the model-visible text for a repl() result: cap stdout and append a
60
- * zero-subcall delegation nudge when a bulk read went undelegated.
79
+ * Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
80
+ * delegation nudge when a bulk read went undelegated, and report tasks still running.
81
+ *
82
+ * The pending line is the model's only signal that `spawn()`ed work is outstanding — without
83
+ * it a model that spawned and moved on has no way to know it should still collect.
84
+ *
85
+ * `varNames` covers the opposite failure: a block that stores its results in `answers` and
86
+ * prints nothing reads as a bare "(no output)", so the model concludes the block did nothing
87
+ * and re-runs it — paying twice for the same sub-calls. The headless engine already answers
88
+ * this with the same hint (core/answer.ts); native mode was the only path missing it.
61
89
  */
62
90
  export function buildReplResultText(
63
91
  stdout: string,
64
92
  finalAnswer: string | undefined,
65
93
  subcalls: readonly RlmSubcall[],
94
+ backgroundPending = 0,
95
+ varNames: readonly string[] = [],
66
96
  ): ReplResultText {
67
97
  const answerSubmitted = finalAnswer !== undefined;
98
+ const noOutput = !answerSubmitted && !stdout;
99
+ const varsHint = noOutput && varNames.length > 0
100
+ ? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
101
+ + "Do NOT re-run it; read them in the next block."
102
+ : "";
68
103
  const rawText = answerSubmitted
69
104
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
70
- : stdout || "(no output)";
105
+ : stdout || `(no output)${varsHint}`;
71
106
  // Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
72
107
  const cappedText = capReplResultText(rawText) ?? rawText;
73
108
  const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
74
109
  const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
75
- return { text: cappedText + (nudge ?? "") };
110
+ const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
111
+ const pendingLine = backgroundPending > 0
112
+ ? `\n\n[rlm] ${backgroundPending} background task(s) still running — rlm_await_all(tasks) to collect.`
113
+ : "";
114
+ const failedLine = failedBg > 0
115
+ ? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their rlm_await value is an "Error: …" string, not data.`
116
+ : "";
117
+ return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
76
118
  }
77
119
 
78
120
  /** Advisory diagnostics derived from a completed invocation's sub-calls. */
@@ -93,226 +135,41 @@ export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly s
93
135
  // ── Mutable bridge state (handler indirection) ──
94
136
 
95
137
  /**
96
- * Holds per-invocation mutable state that sandbox handlers dereference.
97
- * The sandbox is created once with handlers that read from this object's
98
- * current fields, so the tool can swap emitter/depth/limits between calls
99
- * without recreating the sandbox (preserving REPL variable state).
138
+ * Holds per-invocation state that the sandbox handlers resolve against.
139
+ *
140
+ * The sandbox is created once, so the tool swaps the current Invocation between repl()
141
+ * calls rather than rebuilding handlers (which would lose REPL variable state). Handlers
142
+ * capture the Invocation synchronously at interrupt entry and never re-read it — with
143
+ * spawn() a sub-call can outlive its exec, and a later read would attribute it to whichever
144
+ * turn happened to be current when it resumed.
145
+ *
146
+ * Detached work resolves to the session-scoped background Invocation instead, whose emitter
147
+ * and LimitGuard are not torn down at the end of a turn.
100
148
  */
101
149
  class NativeBridgeState {
102
- currentEmitter: RlmEmitter | null = null;
103
- currentParentId: string | undefined;
104
- currentDepth = 0;
105
- currentLimits: LimitGuard | null = null;
106
- currentInteractive: InteractiveDeps | null = null;
107
-
108
- swap(inv: { emitter: RlmEmitter; parentId?: string; depth: number; limits: LimitGuard; interactive: InteractiveDeps }): void {
109
- this.currentEmitter = inv.emitter;
110
- this.currentParentId = inv.parentId;
111
- this.currentDepth = inv.depth;
112
- this.currentLimits = inv.limits;
113
- this.currentInteractive = inv.interactive;
114
- }
150
+ private current: Invocation | null = null;
151
+ /** Interactive callbacks for the turn in progress; child engines inherit them. */
152
+ interactive: InteractiveDeps | null = null;
115
153
 
116
- buildLlmHandlers(deps: {
117
- workerModel: Model<Api>;
118
- getWorkerModel?: () => Model<Api> | undefined;
119
- registry: ModelRegistry;
120
- maxPromptChars: number;
121
- maxConcurrent: number;
122
- sampling?: Sampling;
123
- subSystem?: string;
124
- signal?: AbortSignal;
125
- }): Pick<SubLlmHandlers, "llmQuery" | "llmQueryBatched"> {
126
- const state = this;
127
-
128
- const workerModel = (): Model<Api> => deps.getWorkerModel?.() ?? deps.workerModel;
129
- const displayModel = (model: string | null): string =>
130
- modelRef(model ? (resolveModelId(deps.registry, model) ?? workerModel()) : workerModel()) ?? workerModel().id;
131
-
132
- async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
133
- const limits = state.currentLimits;
134
- if (limits) {
135
- const limitError = checkResourceLimits({ budgetUsd: limits.remainingBudgetUsd(), timeoutMs: limits.remainingTimeoutMs() });
136
- if (limitError !== undefined) return limitError;
137
- }
138
- if (prompt.length > deps.maxPromptChars) {
139
- return formatError(`sub-LLM prompt exceeded size limit (${prompt.length.toLocaleString()} chars > ${deps.maxPromptChars.toLocaleString()})`);
140
- }
141
- const resolved = model ? resolveModelId(deps.registry, model) : undefined;
142
- if (model && !resolved) return formatError(`unknown model override '${model}'`);
143
- try {
144
- const messages: ChatMsg[] = [{ role: "user", content: prompt }];
145
- const res = await modelComplete(messages, {
146
- model: resolved ?? workerModel(),
147
- registry: deps.registry,
148
- system: deps.subSystem,
149
- maxTokens: deps.sampling?.maxTokens,
150
- temperature: deps.sampling?.temperature,
151
- reasoning: deps.sampling?.reasoning,
152
- signal: deps.signal,
153
- });
154
- limits?.addUsage(res.usage);
155
- track(res.usage);
156
- return res.text;
157
- } catch (err) {
158
- const msg = errorMessage(err);
159
- const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
160
- ? " — try smaller batches or individual llm_query calls"
161
- : "";
162
- return formatError(`${msg}${hint}`);
163
- }
164
- }
165
-
166
- return {
167
- async llmQuery(prompt, model, _depth) {
168
- const id = state.currentEmitter?.emitSubcallCreated({
169
- kind: "llm", parentId: state.currentParentId, label: "llm_query",
170
- model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
171
- depth: state.currentDepth,
172
- });
173
- let cost = 0; let tokens = 0;
174
- const out = await complete1(prompt, model, (u) => { cost += u.cost.total; tokens += u.totalTokens; });
175
- if (id) state.currentEmitter?.emitSubcallUpdated({ id,
176
- status: isErrorText(out) ? "error" : "done",
177
- costUsd: cost, tokens, resultPreview: previewText(out),
178
- detail: isErrorText(out) ? out : undefined,
179
- });
180
- return out;
181
- },
182
-
183
- async llmQueryBatched(prompts: readonly string[], model, _depth): Promise<string[]> {
184
- const id = state.currentEmitter?.emitSubcallCreated({
185
- kind: "batch", parentId: state.currentParentId, label: `llm_query ×${prompts.length}`,
186
- model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
187
- depth: state.currentDepth,
188
- });
189
- let cost = 0; let tokens = 0;
190
- const out: string[] = await mapPool(prompts, deps.maxConcurrent, (p) =>
191
- complete1(p, model, (u) => { cost += u.cost.total; tokens += u.totalTokens; }),
192
- );
193
- const failed = out.filter(isErrorText).length;
194
- const allFailed = failed === out.length;
195
- const error = allFailed
196
- ? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
197
- : failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
198
- if (id) state.currentEmitter?.emitSubcallUpdated({ id,
199
- status: error ? "error" : "done", costUsd: cost, tokens,
200
- resultPreview: previewText(out[0] ?? ""), detail: error,
201
- failedCount: failed, totalCount: out.length,
202
- });
203
- return out;
204
- },
205
- };
206
- }
154
+ constructor(private readonly background: BackgroundTasks) {}
207
155
 
208
- /**
209
- * Build real recursive rlm_query / rlm_query_batched handlers that spawn
210
- * child RLM engines (each with its own sandbox and turn loop) rather than
211
- * falling back to a one-shot llm_query.
212
- *
213
- * At the maxDepth cap the handler degrades to a plain llm_query via the
214
- * already-wired llmHandlers (which read from the same mutable state).
215
- */
216
- buildRlmHandlers(deps: {
217
- model: Model<Api>;
218
- workerModel: Model<Api>;
219
- getModel?: () => Model<Api> | undefined;
220
- getWorkerModel?: () => Model<Api> | undefined;
221
- registry: ModelRegistry;
222
- config: RlmConfig;
223
- signal?: AbortSignal;
224
- onUsage?: (usage: Usage, role: "sub") => void;
225
- llmHandlers: Pick<SubLlmHandlers, "llmQuery" | "llmQueryBatched">;
226
- }): Pick<SubLlmHandlers, "rlmQuery" | "rlmQueryBatched"> {
227
- const state = this;
228
-
229
- async function rlmQueryImpl(prompt: string, model: string | null, depth: number): Promise<string> {
230
- const emitter = state.currentEmitter;
231
- const limits = state.currentLimits;
232
- if (!emitter || !limits) return formatError("RLM bridge not wired for this invocation");
233
-
234
- const childDepth = state.currentDepth + 1;
235
-
236
- // Depth cap: degrade to a one-shot llm_query.
237
- if (childDepth >= deps.config.maxDepth) {
238
- return deps.llmHandlers.llmQuery(prompt, model, depth);
239
- }
240
-
241
- const remBudget = limits.remainingBudgetUsd();
242
- const remTimeout = limits.remainingTimeoutMs();
243
- const limitError = checkResourceLimits({ budgetUsd: remBudget, timeoutMs: remTimeout });
244
- if (limitError) return limitError;
245
-
246
- const rootModel = deps.getModel?.() ?? deps.model;
247
- const workerModel = deps.getWorkerModel?.() ?? deps.workerModel;
248
- const resolvedOverride = model ? resolveModelId(deps.registry, model) : undefined;
249
- const subId = emitter.emitSubcallCreated({
250
- kind: "rlm", parentId: state.currentParentId, label: "rlm_query",
251
- model: model ? (modelRef(resolvedOverride) ?? `unknown/${model}`) : (modelRef(rootModel) ?? rootModel.id),
252
- detail: prompt.slice(0, 60),
253
- depth: childDepth,
254
- });
255
-
256
- // Per-call engine creation with the visible emitter — child llm_query subcalls,
257
- // turn progress, and cost deltas land on the per-invocation emitter, visible to
258
- // SubcallStore and the live visual tree.
259
- const runRlm = createEngine({
260
- model: rootModel,
261
- workerModel,
262
- registry: deps.registry,
263
- config: deps.config,
264
- signal: deps.signal,
265
- emitter: emitter,
266
- onUsage: deps.onUsage as ((usage: Usage, role: "root" | "sub") => void) | undefined,
267
- limits: {
268
- maxBudgetUsd: deps.config.maxBudgetUsd,
269
- maxTimeoutMs: deps.config.maxTimeoutMs,
270
- maxTokens: deps.config.maxTokens,
271
- maxErrors: deps.config.maxErrors,
272
- },
273
- onTodo: state.currentInteractive?.onTodo,
274
- onAskUserQuestion: state.currentInteractive?.onAskUserQuestion,
275
- });
276
-
277
- try {
278
- const res = await runRlm({
279
- rootPrompt: "",
280
- context: prompt,
281
- depth: childDepth,
282
- parentNodeId: subId,
283
- modelOverride: model ?? undefined,
284
- remainingBudgetUsd: remBudget,
285
- remainingTimeoutMs: remTimeout,
286
- });
287
-
288
- // Debit parent limit guard for the entire child run.
289
- limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
290
-
291
- // Child engine emits live usage deltas via the shared emitter — SubcallStore
292
- // accumulates them. No final aggregate costUsd/tokens to prevent double-counting
293
- // (matches canonical rlm-query.ts:60-63).
294
- emitter.emitSubcallUpdated({
295
- id: subId,
296
- status: "done",
297
- resultPreview: res.answer.slice(0, 200),
298
- });
156
+ swap(inv: Invocation, interactive: InteractiveDeps): void {
157
+ this.current = Object.freeze({ ...inv });
158
+ this.interactive = interactive;
159
+ }
299
160
 
300
- return res.answer;
301
- } catch (err) {
302
- const msg = errorMessage(err);
303
- emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
304
- return formatError(`child RLM failed - ${msg}`);
305
- }
306
- }
161
+ /** Detached ⇒ session registry; otherwise the turn that is currently executing. */
162
+ resolve(opts: SubcallOpts): Invocation | null {
163
+ return opts.detached ? this.background.invocation : this.current;
164
+ }
307
165
 
308
- return {
309
- rlmQuery: rlmQueryImpl,
310
- rlmQueryBatched: (prompts, model, depth) =>
311
- mapPool(prompts, deps.config.maxConcurrentSubcalls, (p) => rlmQueryImpl(p, model, depth)),
312
- };
166
+ /** The turn emitter, for library-load reporting. Null between repl() calls. */
167
+ get currentEmitter(): RlmEmitter | null {
168
+ return this.current?.emitter ?? null;
313
169
  }
314
170
  }
315
171
 
172
+
316
173
  // ── Tool factory ──
317
174
 
318
175
  export interface ReplToolDeps {
@@ -322,7 +179,12 @@ export interface ReplToolDeps {
322
179
  readonly getModel?: () => Model<Api> | undefined;
323
180
  readonly getWorkerModel?: () => Model<Api> | undefined;
324
181
  readonly registry: ModelRegistry;
325
- readonly config: RlmConfig;
182
+ /** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
183
+ readonly getConfig: () => RlmConfig;
184
+ /** Session-wide sub-call admission, shared with every child engine this tool spawns. */
185
+ readonly gates: SubcallGates;
186
+ /** Session-scoped home for detached spawn() work. */
187
+ readonly background: BackgroundTasks;
326
188
  readonly signal?: AbortSignal;
327
189
  readonly onUsage?: (usage: Usage, role: "sub") => void;
328
190
  readonly ensureContext?: () => Promise<void>;
@@ -331,40 +193,51 @@ export interface ReplToolDeps {
331
193
  }
332
194
 
333
195
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
334
- const { sandboxManager, workerModel, registry, config, signal, onUsage } = deps;
335
- const bridgeState = new NativeBridgeState();
196
+ const { sandboxManager, workerModel, registry, getConfig, signal, onUsage, background } = deps;
197
+ const bridgeState = new NativeBridgeState(background);
336
198
 
337
199
  // Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
338
200
  let sessionCwd = process.cwd();
339
201
 
340
- // Build handlers once llm/rlm/library use late-bound deps so the same closures stay correct
341
- // across repl() calls; counter resets when the sandbox is discarded and re-spawned.
342
- const llmHandlers = bridgeState.buildLlmHandlers({
343
- workerModel,
344
- getWorkerModel: deps.getWorkerModel,
202
+ const getWorkerModel = (): Model<Api> => deps.getWorkerModel?.() ?? workerModel;
203
+ const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
204
+
205
+ // Each rlm_query spawns a child RLM with its own sandbox and turn loop, not a flat
206
+ // one-shot llm_query. The engine is created per call so the child's subcalls, turn
207
+ // progress and cost deltas land on the emitter the parent invocation is using.
208
+ const runChild = (input: RlmInput, inv: Invocation): Promise<RlmResult> => createEngine({
209
+ model: getModel(),
210
+ workerModel: getWorkerModel(),
345
211
  registry,
346
- maxPromptChars: config.maxPromptChars,
347
- maxConcurrent: config.maxConcurrentSubcalls,
348
- sampling: config.subSampling,
349
- subSystem: config.subSystemPrompt,
212
+ config: getConfig(),
350
213
  signal,
351
- });
352
-
353
- // Real recursive rlm_query via createEngine — each call spawns a child RLM
354
- // with its own sandbox and turn loop, not a flat one-shot llm_query.
355
- const rlmHandlers = bridgeState.buildRlmHandlers({
356
- model: deps.model,
357
- workerModel,
358
- getModel: deps.getModel,
359
- getWorkerModel: deps.getWorkerModel,
214
+ gates: deps.gates,
215
+ // Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
216
+ emitter: inv.emitter,
217
+ // Everything a child engine spends is sub-work from this tool's perspective, including
218
+ // the child's own root turns — so fold both roles into "sub" rather than casting.
219
+ onUsage: onUsage === undefined ? undefined : (usage: Usage) => onUsage(usage, "sub"),
220
+ limits: limitsFromConfig(getConfig()),
221
+ onTodo: bridgeState.interactive?.onTodo,
222
+ onAskUserQuestion: bridgeState.interactive?.onAskUserQuestion,
223
+ })(input);
224
+
225
+ // Built once: the same closures stay correct across repl() calls because everything
226
+ // per-invocation is reached through bridgeState.resolve, not captured here.
227
+ const subcallHandlers = createSubcallHandlers({
228
+ resolve: (opts) => bridgeState.resolve(opts),
229
+ gates: deps.gates,
360
230
  registry,
361
- config,
231
+ getWorkerModel,
232
+ getModel,
233
+ getConfig,
362
234
  signal,
363
235
  onUsage,
364
- llmHandlers,
236
+ runChild,
237
+ trackDetached: (task) => background.track(task),
365
238
  });
366
239
 
367
- const libraryBundle = config.libraryLoader
240
+ const libraryBundle = getConfig().libraryLoader
368
241
  ? buildLibraryHandler({
369
242
  getCwd: () => sessionCwd,
370
243
  getEmitter: () => bridgeState.currentEmitter,
@@ -380,14 +253,22 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
380
253
  label: "REPL",
381
254
  description:
382
255
  "PRIMARY tool for ALL repository reading and analysis (read/grep are disabled in RLM mode). " +
383
- "Persistent Python sandbox with every file pre-loaded in `context`. You are an orchestrator: " +
384
- "chunk `context` and delegate semantic work to llm_query / llm_query_batched / " +
385
- "llm_query_chunked / rlm_query — stdout returned to you is hard-capped at 4K chars, so " +
386
- "printing file bodies is useless. Variables, imports, and state persist across calls. " +
387
- "Also supports todo, ask_user_question, and load_library inside the sandbox.",
256
+ "Persistent Python sandbox with every file pre-loaded in `context`. Locate first with the " +
257
+ "free primitives search(query) / grep_context(pattern) / outline(path), then delegate the " +
258
+ "semantic reading to map_files / llm_query / llm_query_batched / llm_query_chunked " +
259
+ "(rlm_query for iterative sub-tasks) stdout returned to you is hard-capped at 4K chars, " +
260
+ "so printing file bodies is useless. Variables, imports, and the `answers`/`plan` memo " +
261
+ "persist across calls. Also supports todo, ask_user_question, and load_library.",
262
+ promptSnippet:
263
+ "repl: run Python in a persistent sandbox holding the whole repository in `context`; " +
264
+ "search/grep_context/outline to locate, map_files/llm_query* to read.",
265
+ promptGuidelines: [
266
+ "In RLM mode, read the repository through `repl` only — `read`/`grep` and bash readers are blocked.",
267
+ "Inside `repl`, locate with search()/grep_context()/outline() before delegating bulk reading to map_files()/llm_query_batched().",
268
+ ],
388
269
  parameters: ReplToolParams,
389
270
 
390
- async execute(_toolCallId, rawParams, _execSignal, onUpdate, ctx) {
271
+ async execute(_toolCallId, rawParams, execSignal, onUpdate, ctx) {
391
272
  const validation = validateToolParams(ReplToolParams, rawParams, "REPL", (errors): ReplDetails => ({
392
273
  status: "error",
393
274
  output: "",
@@ -405,24 +286,31 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
405
286
  let capturedStderr = "";
406
287
  let progressStatus: ReplDetails["status"] = "running";
407
288
  const startedAt = Date.now();
408
- const limits = new LimitGuard({
409
- maxBudgetUsd: config.maxBudgetUsd,
410
- maxTimeoutMs: config.maxTimeoutMs,
411
- maxTokens: config.maxTokens,
412
- maxErrors: config.maxErrors,
413
- });
289
+ const limits = new LimitGuard(limitsFromConfig(getConfig()));
290
+
291
+ const detachTracers = traceEnabled
292
+ ? [attachTracer(emitter, "turn"), attachTracer(background.emitter, "background")]
293
+ : [];
414
294
 
415
295
  // ── Progressive rendering: spinner + live sub-call tree ──
416
296
  const progress = createProgressNotifier<ReplDetails>({
417
297
  onUpdate,
418
- getDetails: () => ({
419
- status: progressStatus,
420
- output: capturedStdout,
421
- stderr: capturedStderr,
422
- executionTimeMs: Date.now() - startedAt,
423
- subcalls: store.getSubcalls(),
424
- totals: store.getTotals(),
425
- }),
298
+ getDetails: () => {
299
+ // Detached spawn() nodes live on the SESSION emitter, so without this merge the card
300
+ // stays empty for the entire time background work is running.
301
+ const live = background.liveSubcalls();
302
+ const bg = background.liveTotals();
303
+ const own = store.getTotals();
304
+ return {
305
+ status: progressStatus,
306
+ output: capturedStdout,
307
+ stderr: capturedStderr,
308
+ executionTimeMs: Date.now() - startedAt,
309
+ subcalls: live.length > 0 ? [...store.getSubcalls(), ...live] : store.getSubcalls(),
310
+ totals: { costUsd: own.costUsd + bg.costUsd, tokens: own.tokens + bg.tokens },
311
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
312
+ };
313
+ },
426
314
  isRunning: (details) => details.status === "running",
427
315
  renderText: (details) => details.output.slice(0, 500) || (details.status === "running" ? `${spinnerFrame()} Running…` : "(no output)"),
428
316
  });
@@ -442,7 +330,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
442
330
  // Build interactive handlers (session-stable callbacks)
443
331
  const interactive = createPiInteractiveDeps(ctx);
444
332
  const interactiveHandlers = buildInteractiveHandlers({
445
- onAskUserQuestion: config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
333
+ onAskUserQuestion: getConfig().askUserQuestion ? interactive.onAskUserQuestion : undefined,
446
334
  onTodo: interactive.onTodo,
447
335
  onTodoRow: undefined,
448
336
  emitter,
@@ -454,8 +342,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
454
342
 
455
343
  await deps.ensureContext?.();
456
344
  await sandboxManager.getOrCreate({
457
- ...llmHandlers,
458
- ...rlmHandlers,
345
+ ...subcallHandlers,
459
346
  askUserQuestion: interactiveHandlers.askUserQuestion,
460
347
  todo: interactiveHandlers.todo,
461
348
  ...(libraryBundle?.handlers ?? {}),
@@ -470,19 +357,46 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
470
357
  });
471
358
  }
472
359
 
360
+ if (traceEnabled) {
361
+ trace("repl.exec.start", { chars: params.code.length, code: params.code.slice(0, 400) });
362
+ }
363
+
473
364
  const start = Date.now();
474
365
  const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
475
366
  // Wire per-invocation mutable state only after the serialized exec slot
476
367
  // is active. Swapping earlier would let queued repl() calls overwrite
477
368
  // emitter/limits for the currently running REPL execution.
478
- bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits, interactive });
479
- });
369
+ bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits }, interactive);
370
+ }, execSignal);
480
371
  const elapsed = Date.now() - start;
481
372
  capturedStdout = result.stdout;
482
373
  capturedStderr = result.stderr;
483
374
  progressStatus = "done";
484
375
 
485
- const totals = store.getTotals();
376
+ if (traceEnabled) {
377
+ trace("repl.exec.end", {
378
+ ms: elapsed,
379
+ stdout: result.stdout.length,
380
+ raised: result.raised,
381
+ pending: background.pending,
382
+ // A block that raised delegated nothing; without the exception the trace shows a
383
+ // silent turn and the reason is only in the TUI card.
384
+ error: result.raised ? lastLine(result.stderr) : undefined,
385
+ });
386
+ }
387
+
388
+ // Adopt every background subtree that has settled, whether or not this turn awaited
389
+ // it — otherwise a spawn the model never collects would never reach the user's cost
390
+ // totals. IDs are "bg"-prefixed, so they cannot collide with this turn's.
391
+ // (drain() removes what it hands over, so live view + accounted view never double-count.)
392
+ const adopted = background.drain();
393
+ const subcalls: readonly RlmSubcall[] = adopted.subcalls.length > 0
394
+ ? [...store.getSubcalls(), ...adopted.subcalls]
395
+ : store.getSubcalls();
396
+ const totals = {
397
+ costUsd: store.getTotals().costUsd + adopted.totals.costUsd,
398
+ tokens: store.getTotals().tokens + adopted.totals.tokens,
399
+ };
486
400
  const subUsage: Usage = {
487
401
  input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: totals.tokens,
488
402
  cost: { total: totals.costUsd, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -495,7 +409,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
495
409
  const { text: resultText } = buildReplResultText(
496
410
  result.stdout,
497
411
  finalAnswer,
498
- store.getSubcalls(),
412
+ subcalls,
413
+ background.pending,
414
+ result.varNames,
499
415
  );
500
416
 
501
417
  const details: ReplDetails = {
@@ -503,10 +419,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
503
419
  output: result.stdout,
504
420
  stderr: result.stderr,
505
421
  executionTimeMs: elapsed,
506
- subcalls: store.getSubcalls(),
507
- totals: store.getTotals(),
422
+ subcalls,
423
+ totals,
508
424
  finalAnswer,
509
- warnings: collectReplWarnings(store.getSubcalls()),
425
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
426
+ warnings: collectReplWarnings(subcalls),
510
427
  };
511
428
  const progressText = finalAnswer !== undefined
512
429
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
@@ -517,13 +434,20 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
517
434
  } catch (e) {
518
435
  progressStatus = "error";
519
436
  const msg = errorMessage(e);
437
+ // Drain here too: a failing turn must not swallow the cost of background work that
438
+ // settled during it, or a run that keeps erroring would never report any of it.
439
+ const adopted = background.drain();
520
440
  const details: ReplDetails = {
521
441
  status: "error",
522
442
  output: "",
523
443
  stderr: msg,
524
444
  executionTimeMs: 0,
525
- subcalls: store.getSubcalls(),
526
- totals: store.getTotals(),
445
+ subcalls: [...store.getSubcalls(), ...adopted.subcalls],
446
+ totals: {
447
+ costUsd: store.getTotals().costUsd + adopted.totals.costUsd,
448
+ tokens: store.getTotals().tokens + adopted.totals.tokens,
449
+ },
450
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
527
451
  };
528
452
  onUpdate?.({ content: [{ type: "text", text: `REPL error: ${msg}` }], details });
529
453
  return {
@@ -532,16 +456,15 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
532
456
  };
533
457
  } finally {
534
458
  progress.stop();
459
+ for (const off of detachTracers) off();
535
460
  store.dispose();
536
461
  emitter.shutdown();
537
462
  }
538
463
  },
539
464
 
540
465
  renderCall(args, theme) {
541
- const preview = args.code.length > 80 ? `${args.code.slice(0, 80)}...` : args.code;
542
466
  return new Text(
543
- theme.fg("toolTitle", theme.bold("repl ")) +
544
- theme.fg("dim", preview.replace(/\n/g, " ")),
467
+ theme.fg("toolTitle", theme.bold("repl ")) + theme.fg("dim", previewText(args.code, CALL_PREVIEW_CHARS)),
545
468
  0, 0,
546
469
  );
547
470
  },
@@ -560,26 +483,13 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
560
483
 
561
484
  // ── Collapsed view ──
562
485
 
563
- function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
564
- const glyph = details.status === "running"
565
- ? headlineStatusGlyph("running", theme)
566
- : details.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓");
567
-
568
- const parts: string[] = [];
569
- parts.push(formatCost(details.totals.costUsd));
570
- if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
571
- if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
572
- const stats = parts.length > 0 ? ` ${theme.fg("dim", parts.join(" · "))}` : "";
573
-
574
- const header = `${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`;
575
-
576
- let body = "";
577
- if (details.subcalls.length > 0) {
578
- body = `\n${renderCollapsedSubcallTree(details.subcalls, theme)}`;
579
- }
486
+ function replStats(details: ReplDetails, theme: Theme): string {
487
+ const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
488
+ return cardStatsLine(details.totals, theme, elapsed, details.backgroundPending);
489
+ }
580
490
 
581
- const expandHint = details.status === "running" ? "" : `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
582
- return new Text(`${header}${body}${expandHint}`, 0, 0);
491
+ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
492
+ return renderCollapsedCard("REPL", details.status, replStats(details, theme), details.subcalls, theme);
583
493
  }
584
494
 
585
495
  // ── Expanded view ──
@@ -587,19 +497,14 @@ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
587
497
  function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
588
498
  const container = new Container();
589
499
 
590
- // Header: status + stats
591
- const glyph = details.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓");
592
- const parts: string[] = [];
593
- parts.push(formatCost(details.totals.costUsd));
594
- if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
595
- if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
596
- const stats = parts.length > 0 ? ` · ${theme.fg("dim", parts.join(" · "))}` : "";
597
- container.addChild(new Text(`${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`, 0, 0));
500
+ container.addChild(new Text(cardHeader("REPL", details.status, replStats(details, theme), theme), 0, 0));
598
501
 
599
502
  // Output
600
503
  if (details.output) {
601
504
  container.addChild(new Spacer(1));
602
- const out = details.output.length > 2000 ? `${details.output.slice(0, 2000)}...` : details.output;
505
+ const out = details.output.length > EXPANDED_STDOUT_CHARS
506
+ ? `${details.output.slice(0, EXPANDED_STDOUT_CHARS)}…`
507
+ : details.output;
603
508
  container.addChild(new Text(out, 0, 0));
604
509
  }
605
510
 
@@ -611,7 +516,7 @@ function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
611
516
  // Stderr
612
517
  if (details.stderr) {
613
518
  container.addChild(new Spacer(1));
614
- container.addChild(new Text(theme.fg("error", details.stderr.slice(0, 500)), 0, 0));
519
+ container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
615
520
  }
616
521
 
617
522
  // Sub-call tree