@hicaru/pi-rlm 0.2.0 → 0.2.2

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 (68) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +382 -0
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +7 -15
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +115 -360
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +49 -10
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -386
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +14 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/py/worker.py +836 -0
  30. package/src/sandbox/sandbox-manager.ts +33 -6
  31. package/src/sandbox/sandbox.ts +153 -182
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/background-tasks.ts +95 -0
  34. package/src/tool/repl-details.ts +4 -2
  35. package/src/tool/repl-render.ts +58 -0
  36. package/src/tool/repl-result.ts +70 -0
  37. package/src/tool/repl-tool.ts +178 -216
  38. package/src/tool/rlm-aggregator.ts +2 -10
  39. package/src/tool/rlm-details.ts +0 -2
  40. package/src/tool/rlm-events.ts +10 -16
  41. package/src/tool/rlm-tool.ts +1 -12
  42. package/src/tool/subcall-render.ts +15 -3
  43. package/src/tool/subcall-store.ts +57 -1
  44. package/src/ui/config-panel.ts +4 -16
  45. package/src/ui/intro.ts +1 -2
  46. package/src/ui/model-picker.ts +34 -10
  47. package/src/ui/status.ts +3 -7
  48. package/src/util/concurrency.ts +91 -13
  49. package/src/util/trace.ts +42 -0
  50. package/src/bridge/fallback-todo.ts +0 -137
  51. package/src/bridge/interactive.ts +0 -65
  52. package/src/bridge/llm-query.ts +0 -156
  53. package/src/bridge/pi-interactive.ts +0 -41
  54. package/src/bridge/rlm-query.ts +0 -108
  55. package/src/core/artifacts.ts +0 -89
  56. package/src/core/critique.ts +0 -92
  57. package/src/core/gates.ts +0 -301
  58. package/src/core/pipeline-handlers.ts +0 -319
  59. package/src/core/pipeline.ts +0 -268
  60. package/src/prompts/phases.ts +0 -104
  61. package/src/sandbox/worker.py +0 -1078
  62. package/src/state/index.ts +0 -24
  63. package/src/state/internal.ts +0 -46
  64. package/src/state/paths.ts +0 -44
  65. package/src/state/reads.ts +0 -133
  66. package/src/state/resume.ts +0 -173
  67. package/src/state/rows.ts +0 -123
  68. package/src/state/writes.ts +0 -58
@@ -6,27 +6,29 @@
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) 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.
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.
13
16
  */
14
17
 
15
18
  import { Type } from "typebox";
16
- import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
17
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
19
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
20
+ import { Text } from "@earendil-works/pi-tui";
18
21
  import type { Model, Usage, Api } from "@earendil-works/pi-ai";
19
22
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
20
- import { displayModelRef } from "../config/settings.ts";
21
- import { buildInteractiveHandlers } from "../bridge/interactive.ts";
22
23
  import { buildLibraryHandler } from "../bridge/library.ts";
23
- import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
24
- import { createLlmBridge } from "../bridge/llm-query.ts";
25
- import { createRlmHandlers } from "../bridge/rlm-query.ts";
24
+ import { libraryPrefixesIn } from "../context/library-context.ts";
25
+ import type { SubcallGates } from "../util/concurrency.ts";
26
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";
27
+ import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
29
28
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
29
+ import type { SubcallOpts } from "../sandbox/sandbox.ts";
30
+ import { createSubcallHandlers, type Invocation } from "../bridge/subcall-handlers.ts";
31
+ import { BackgroundTasks } from "./background-tasks.ts";
30
32
  import type { ReplResult } from "../sandbox/protocol.ts";
31
33
  import { RlmEmitter } from "./rlm-events.ts";
32
34
  import { SubcallStore } from "./subcall-store.ts";
@@ -36,19 +38,23 @@ import { createEngine } from "../core/engine.ts";
36
38
  import { spinnerFrame } from "../ui/theme.ts";
37
39
  import { previewText } from "../text/preview.ts";
38
40
  import { errorMessage } from "../util/errors.ts";
39
- import {
40
- cardHeader,
41
- cardStatsLine,
42
- renderCollapsedCard,
43
- renderExpandedSubcallTree,
44
- } from "./subcall-render.ts";
45
41
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
46
- import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
42
+ import { buildReplResultText, collectReplWarnings } from "./repl-result.ts";
43
+ import { renderReplCollapsed, renderReplExpanded } from "./repl-render.ts";
44
+ import { attachTracer, trace, traceEnabled } from "../util/trace.ts";
47
45
 
48
- /** Chars of code shown on the tool call line, and of stdout in the expanded view. */
46
+ /** Chars of code shown on the tool call line. */
49
47
  const CALL_PREVIEW_CHARS = 80;
50
- const EXPANDED_STDOUT_CHARS = 2_000;
51
- const EXPANDED_STDERR_CHARS = 500;
48
+
49
+ /** Last non-empty line of a Python traceback — the `TypeError: …` line, not the frames. */
50
+ function lastLine(text: string): string {
51
+ const lines = text.trimEnd().split("\n");
52
+ for (let i = lines.length - 1; i >= 0; i--) {
53
+ const line = lines[i]?.trim();
54
+ if (line) return line.slice(0, 200);
55
+ }
56
+ return "";
57
+ }
52
58
 
53
59
  // ── Parameter schema ──
54
60
 
@@ -56,87 +62,56 @@ export const ReplToolParams = Object.freeze(Type.Object({
56
62
  code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
57
63
  }));
58
64
 
59
- /** Model-visible text assembled from a repl() result. */
60
- export interface ReplResultText {
61
- readonly text: string;
62
- }
65
+ // ── Mutable bridge state (handler indirection) ──
63
66
 
64
67
  /**
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.
68
+ * Holds per-invocation state that the sandbox handlers resolve against.
69
+ *
70
+ * The sandbox is created once, so the tool swaps the current Invocation between repl()
71
+ * calls rather than rebuilding handlers (which would lose REPL variable state). Handlers
72
+ * capture the Invocation synchronously at interrupt entry and never re-read it — with
73
+ * spawn() a sub-call can outlive its exec, and a later read would attribute it to whichever
74
+ * turn happened to be current when it settled.
75
+ *
76
+ * Detached work resolves to the session-scoped background Invocation instead, whose emitter
77
+ * and LimitGuard are not torn down at the end of a turn.
67
78
  */
68
- export function buildReplResultText(
69
- stdout: string,
70
- finalAnswer: string | undefined,
71
- subcalls: readonly RlmSubcall[],
72
- ): ReplResultText {
73
- const answerSubmitted = finalAnswer !== undefined;
74
- const rawText = answerSubmitted
75
- ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
76
- : stdout || "(no output)";
77
- // Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
78
- const cappedText = capReplResultText(rawText) ?? rawText;
79
- const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
80
- const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
81
- return { text: cappedText + (nudge ?? "") };
82
- }
79
+ class NativeBridgeState {
80
+ private current: Invocation | null = null;
83
81
 
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`]);
97
- }
82
+ constructor(private readonly background: BackgroundTasks) {}
98
83
 
99
- // ── Mutable bridge state (handler indirection) ──
84
+ swap(inv: Invocation): void {
85
+ this.current = Object.freeze({ ...inv });
86
+ }
100
87
 
101
- /**
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).
105
- */
106
- class NativeBridgeState {
107
- currentEmitter: RlmEmitter | null = null;
108
- currentParentId: string | undefined;
109
- currentDepth = 0;
110
- currentLimits: LimitGuard | null = null;
111
- currentInteractive: InteractiveDeps | null = null;
112
-
113
- swap(inv: { emitter: RlmEmitter; parentId?: string; depth: number; limits: LimitGuard; interactive: InteractiveDeps }): void {
114
- this.currentEmitter = inv.emitter;
115
- this.currentParentId = inv.parentId;
116
- this.currentDepth = inv.depth;
117
- this.currentLimits = inv.limits;
118
- this.currentInteractive = inv.interactive;
88
+ /** Detached ⇒ session registry; otherwise the turn that is currently executing. */
89
+ resolve(opts: SubcallOpts): Invocation | null {
90
+ return opts.detached ? this.background.invocation : this.current;
119
91
  }
120
92
 
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() };
93
+ /** The turn emitter, for library-load reporting. Null between repl() calls. */
94
+ get currentEmitter(): RlmEmitter | null {
95
+ return this.current?.emitter ?? null;
126
96
  }
127
97
  }
128
98
 
99
+
129
100
  // ── Tool factory ──
130
101
 
131
102
  export interface ReplToolDeps {
132
103
  readonly sandboxManager: SandboxManager;
133
104
  readonly model: Model<Api>;
134
- readonly workerModel: Model<Api>;
105
+ readonly llmModel: Model<Api>;
135
106
  readonly getModel?: () => Model<Api> | undefined;
136
- readonly getWorkerModel?: () => Model<Api> | undefined;
107
+ readonly getLlmModel?: () => Model<Api> | undefined;
137
108
  readonly registry: ModelRegistry;
138
109
  /** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
139
110
  readonly getConfig: () => RlmConfig;
111
+ /** Session-wide sub-call admission, shared with every child engine this tool spawns. */
112
+ readonly gates: SubcallGates;
113
+ /** Session-scoped home for detached spawn() work. */
114
+ readonly background: BackgroundTasks;
140
115
  readonly signal?: AbortSignal;
141
116
  readonly onUsage?: (usage: Usage, role: "sub") => void;
142
117
  readonly ensureContext?: () => Promise<void>;
@@ -145,73 +120,72 @@ export interface ReplToolDeps {
145
120
  }
146
121
 
147
122
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
148
- const { sandboxManager, workerModel, registry, getConfig, signal, onUsage } = deps;
149
- const bridgeState = new NativeBridgeState();
123
+ const { sandboxManager, llmModel, registry, getConfig, signal, onUsage, background } = deps;
124
+ const bridgeState = new NativeBridgeState(background);
150
125
 
151
126
  // Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
152
127
  let sessionCwd = process.cwd();
153
128
 
154
- const rootModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
129
+ const getLlmModel = (): Model<Api> => deps.getLlmModel?.() ?? llmModel;
130
+ const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
155
131
 
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,
132
+ // Each rlm_query spawns a child RLM with its own sandbox and turn loop, not a flat
133
+ // one-shot llm_query. The engine is created per call so the child's subcalls, turn
134
+ // progress and cost deltas land on the emitter the parent invocation is using.
135
+ const runChild = (input: RlmInput, inv: Invocation): Promise<RlmResult> => createEngine({
136
+ model: getModel(),
137
+ llmModel: getLlmModel(),
160
138
  registry,
161
- config: getConfig,
139
+ config: getConfig(),
162
140
  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,
168
- });
169
-
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
- },
141
+ gates: deps.gates,
142
+ // Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
143
+ emitter: inv.emitter,
144
+ // Everything a child engine spends is sub-work from this tool's perspective, including
145
+ // the child's own root turns — so fold both roles into "sub" rather than casting.
146
+ onUsage: onUsage === undefined ? undefined : (usage: Usage) => onUsage(usage, "sub"),
147
+ limits: limitsFromConfig(getConfig()),
148
+ })(input);
149
+
150
+ // Built once: the same closures stay correct across repl() calls because everything
151
+ // per-invocation is reached through bridgeState.resolve, not captured here.
152
+ const subcallHandlers = createSubcallHandlers({
153
+ resolve: (opts) => bridgeState.resolve(opts),
154
+ gates: deps.gates,
155
+ registry,
156
+ getLlmModel,
157
+ getModel,
158
+ getConfig,
159
+ signal,
160
+ onUsage,
161
+ runChild,
162
+ // The session sandbox's context is the child's world. Read lazily so a load_library from an
163
+ // earlier repl() reaches a child spawned in a later one. Populated before any interrupt can
164
+ // fire: execute() awaits ensureContext() before getOrCreate().
165
+ getChildContext: () => sandboxManager.contextPayload ?? undefined,
166
+ trackDetached: (task) => background.track(task),
203
167
  });
204
168
 
205
169
  const libraryBundle = getConfig().libraryLoader
206
170
  ? buildLibraryHandler({
207
171
  getCwd: () => sessionCwd,
208
172
  getEmitter: () => bridgeState.currentEmitter,
173
+ // Refuse pre-flight whatever the worker would reject, so host idempotency is never
174
+ // committed for an append that did not happen.
175
+ getContext: () => sandboxManager.contextPayload,
209
176
  parentId: undefined,
210
177
  signal,
211
- startIndex: 1,
178
+ // Keep the manager's replay copy in step with the worker's live `context`, and with it
179
+ // whatever a child spawned after this load will inherit.
180
+ onLoaded: (payload) => { sandboxManager.appendLibrary(payload); },
212
181
  })
213
182
  : undefined;
214
- if (libraryBundle) deps.registerDiscardHook?.(libraryBundle.reset);
183
+ if (libraryBundle) {
184
+ // Re-derive the loaded-prefix cache from the payload that will actually be replayed —
185
+ // clearing it outright would make the host re-clone a library the recreated worker already has.
186
+ const bundle = libraryBundle;
187
+ deps.registerDiscardHook?.(() => bundle.reset(libraryPrefixesIn(sandboxManager.contextPayload)));
188
+ }
215
189
 
216
190
  return {
217
191
  name: "repl",
@@ -223,7 +197,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
223
197
  "semantic reading to map_files / llm_query / llm_query_batched / llm_query_chunked " +
224
198
  "(rlm_query for iterative sub-tasks) — stdout returned to you is hard-capped at 4K chars, " +
225
199
  "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.",
200
+ "persist across calls. Also supports load_library.",
227
201
  promptSnippet:
228
202
  "repl: run Python in a persistent sandbox holding the whole repository in `context`; " +
229
203
  "search/grep_context/outline to locate, map_files/llm_query* to read.",
@@ -233,7 +207,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
233
207
  ],
234
208
  parameters: ReplToolParams,
235
209
 
236
- async execute(_toolCallId, rawParams, _execSignal, onUpdate, ctx) {
210
+ async execute(_toolCallId, rawParams, execSignal, onUpdate, ctx) {
237
211
  const validation = validateToolParams(ReplToolParams, rawParams, "REPL", (errors): ReplDetails => ({
238
212
  status: "error",
239
213
  output: "",
@@ -253,17 +227,29 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
253
227
  const startedAt = Date.now();
254
228
  const limits = new LimitGuard(limitsFromConfig(getConfig()));
255
229
 
230
+ const detachTracers = traceEnabled
231
+ ? [attachTracer(emitter, "turn"), attachTracer(background.emitter, "background")]
232
+ : [];
233
+
256
234
  // ── Progressive rendering: spinner + live sub-call tree ──
257
235
  const progress = createProgressNotifier<ReplDetails>({
258
236
  onUpdate,
259
- getDetails: () => ({
260
- status: progressStatus,
261
- output: capturedStdout,
262
- stderr: capturedStderr,
263
- executionTimeMs: Date.now() - startedAt,
264
- subcalls: store.getSubcalls(),
265
- totals: store.getTotals(),
266
- }),
237
+ getDetails: () => {
238
+ // Detached spawn() nodes live on the SESSION emitter, so without this merge the card
239
+ // stays empty for the entire time background work is running.
240
+ const live = background.liveSubcalls();
241
+ const bg = background.liveTotals();
242
+ const own = store.getTotals();
243
+ return {
244
+ status: progressStatus,
245
+ output: capturedStdout,
246
+ stderr: capturedStderr,
247
+ executionTimeMs: Date.now() - startedAt,
248
+ subcalls: live.length > 0 ? [...store.getSubcalls(), ...live] : store.getSubcalls(),
249
+ totals: { costUsd: own.costUsd + bg.costUsd, tokens: own.tokens + bg.tokens },
250
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
251
+ };
252
+ },
267
253
  isRunning: (details) => details.status === "running",
268
254
  renderText: (details) => details.output.slice(0, 500) || (details.status === "running" ? `${spinnerFrame()} Running…` : "(no output)"),
269
255
  });
@@ -280,25 +266,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
280
266
  }
281
267
 
282
268
  try {
283
- // Build interactive handlers (session-stable callbacks)
284
- const interactive = createPiInteractiveDeps(ctx);
285
- const interactiveHandlers = buildInteractiveHandlers({
286
- onAskUserQuestion: getConfig().askUserQuestion ? interactive.onAskUserQuestion : undefined,
287
- onTodo: interactive.onTodo,
288
- onTodoRow: undefined,
289
- emitter,
290
- depth: 0,
291
- parentId: undefined,
292
- });
293
-
294
269
  sessionCwd = ctx.cwd ?? process.cwd();
295
270
 
296
271
  await deps.ensureContext?.();
297
272
  await sandboxManager.getOrCreate({
298
- ...llmHandlers,
299
- ...rlmHandlers,
300
- askUserQuestion: interactiveHandlers.askUserQuestion,
301
- todo: interactiveHandlers.todo,
273
+ ...subcallHandlers,
302
274
  ...(libraryBundle?.handlers ?? {}),
303
275
  });
304
276
 
@@ -311,19 +283,46 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
311
283
  });
312
284
  }
313
285
 
286
+ if (traceEnabled) {
287
+ trace("repl.exec.start", { chars: params.code.length, code: params.code.slice(0, 400) });
288
+ }
289
+
314
290
  const start = Date.now();
315
291
  const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
316
292
  // Wire per-invocation mutable state only after the serialized exec slot
317
293
  // is active. Swapping earlier would let queued repl() calls overwrite
318
294
  // emitter/limits for the currently running REPL execution.
319
- bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits, interactive });
320
- });
295
+ bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
296
+ }, execSignal);
321
297
  const elapsed = Date.now() - start;
322
298
  capturedStdout = result.stdout;
323
299
  capturedStderr = result.stderr;
324
300
  progressStatus = "done";
325
301
 
326
- const totals = store.getTotals();
302
+ if (traceEnabled) {
303
+ trace("repl.exec.end", {
304
+ ms: elapsed,
305
+ stdout: result.stdout.length,
306
+ raised: result.raised,
307
+ pending: background.pending,
308
+ // A block that raised delegated nothing; without the exception the trace shows a
309
+ // silent turn and the reason is only in the TUI card.
310
+ error: result.raised ? lastLine(result.stderr) : undefined,
311
+ });
312
+ }
313
+
314
+ // Adopt every background subtree that has settled, whether or not this turn awaited
315
+ // it — otherwise a spawn the model never collects would never reach the user's cost
316
+ // totals. IDs are "bg"-prefixed, so they cannot collide with this turn's.
317
+ // (drain() removes what it hands over, so live view + accounted view never double-count.)
318
+ const adopted = background.drain();
319
+ const subcalls: readonly RlmSubcall[] = adopted.subcalls.length > 0
320
+ ? [...store.getSubcalls(), ...adopted.subcalls]
321
+ : store.getSubcalls();
322
+ const totals = {
323
+ costUsd: store.getTotals().costUsd + adopted.totals.costUsd,
324
+ tokens: store.getTotals().tokens + adopted.totals.tokens,
325
+ };
327
326
  const subUsage: Usage = {
328
327
  input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: totals.tokens,
329
328
  cost: { total: totals.costUsd, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -336,7 +335,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
336
335
  const { text: resultText } = buildReplResultText(
337
336
  result.stdout,
338
337
  finalAnswer,
339
- store.getSubcalls(),
338
+ subcalls,
339
+ background.pending,
340
+ result.varNames,
340
341
  );
341
342
 
342
343
  const details: ReplDetails = {
@@ -344,10 +345,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
344
345
  output: result.stdout,
345
346
  stderr: result.stderr,
346
347
  executionTimeMs: elapsed,
347
- subcalls: store.getSubcalls(),
348
- totals: store.getTotals(),
348
+ subcalls,
349
+ totals,
349
350
  finalAnswer,
350
- warnings: collectReplWarnings(store.getSubcalls()),
351
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
352
+ warnings: collectReplWarnings(subcalls),
351
353
  };
352
354
  const progressText = finalAnswer !== undefined
353
355
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
@@ -358,13 +360,20 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
358
360
  } catch (e) {
359
361
  progressStatus = "error";
360
362
  const msg = errorMessage(e);
363
+ // Drain here too: a failing turn must not swallow the cost of background work that
364
+ // settled during it, or a run that keeps erroring would never report any of it.
365
+ const adopted = background.drain();
361
366
  const details: ReplDetails = {
362
367
  status: "error",
363
368
  output: "",
364
369
  stderr: msg,
365
370
  executionTimeMs: 0,
366
- subcalls: store.getSubcalls(),
367
- totals: store.getTotals(),
371
+ subcalls: [...store.getSubcalls(), ...adopted.subcalls],
372
+ totals: {
373
+ costUsd: store.getTotals().costUsd + adopted.totals.costUsd,
374
+ tokens: store.getTotals().tokens + adopted.totals.tokens,
375
+ },
376
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
368
377
  };
369
378
  onUpdate?.({ content: [{ type: "text", text: `REPL error: ${msg}` }], details });
370
379
  return {
@@ -373,6 +382,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
373
382
  };
374
383
  } finally {
375
384
  progress.stop();
385
+ for (const off of detachTracers) off();
376
386
  store.dispose();
377
387
  emitter.shutdown();
378
388
  }
@@ -396,51 +406,3 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
396
406
  },
397
407
  };
398
408
  }
399
-
400
- // ── Collapsed view ──
401
-
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
- }
406
-
407
- function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
408
- return renderCollapsedCard("REPL", details.status, replStats(details, theme), details.subcalls, theme);
409
- }
410
-
411
- // ── Expanded view ──
412
-
413
- function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
414
- const container = new Container();
415
-
416
- container.addChild(new Text(cardHeader("REPL", details.status, replStats(details, theme), theme), 0, 0));
417
-
418
- // Output
419
- if (details.output) {
420
- container.addChild(new Spacer(1));
421
- const out = details.output.length > EXPANDED_STDOUT_CHARS
422
- ? `${details.output.slice(0, EXPANDED_STDOUT_CHARS)}…`
423
- : details.output;
424
- container.addChild(new Text(out, 0, 0));
425
- }
426
-
427
- if (details.warnings && details.warnings.length > 0) {
428
- container.addChild(new Spacer(1));
429
- container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
430
- }
431
-
432
- // Stderr
433
- if (details.stderr) {
434
- container.addChild(new Spacer(1));
435
- container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
436
- }
437
-
438
- // Sub-call tree
439
- if (details.subcalls.length > 0) {
440
- container.addChild(new Spacer(1));
441
- container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
442
- container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
443
- }
444
-
445
- return container;
446
- }
@@ -6,14 +6,14 @@
6
6
  * getState(): RlmDetails for direct access (spinner loop, final return).
7
7
  *
8
8
  * Subcall storage and totals are delegated to SubcallStore. Root-level state
9
- * (status, prompt, turns, answer, warnings) is kept in the aggregator.
9
+ * (status, prompt, turns, answer) is kept in the aggregator.
10
10
  *
11
11
  * Replaces RlmToolBridge's internal state accumulation. The emitter is pure
12
12
  * dispatch; the aggregator is pure state. Separated for independent testing.
13
13
  */
14
14
 
15
15
  import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
16
- import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, StatusEvent, RootPromptEvent, WarningsEvent } from "./rlm-events.ts";
16
+ import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, StatusEvent, RootPromptEvent } from "./rlm-events.ts";
17
17
  import type { RlmDetails, RlmRunStatus } from "./rlm-details.ts";
18
18
  import { EmitterListener } from "./emitter-listener.ts";
19
19
  import { SubcallStore } from "./subcall-store.ts";
@@ -27,7 +27,6 @@ export class RlmEventAggregator extends EmitterListener {
27
27
  private turnCurrent = 0;
28
28
  private turnMax = 0;
29
29
  private answer?: string;
30
- private warnings?: readonly string[];
31
30
 
32
31
  constructor(
33
32
  emitter: RlmEmitter,
@@ -40,7 +39,6 @@ export class RlmEventAggregator extends EmitterListener {
40
39
  emitter.onTurn((e) => this.handleTurn(e)),
41
40
  emitter.onRootUsage((e) => this.handleRootUsage(e)),
42
41
  emitter.onAnswer((e) => this.handleAnswer(e)),
43
- emitter.onWarnings((e) => this.handleWarnings(e)),
44
42
  emitter.onStatus((e) => this.handleStatus(e)),
45
43
  emitter.onRootPrompt((e) => this.handleRootPrompt(e)),
46
44
  ]);
@@ -64,11 +62,6 @@ export class RlmEventAggregator extends EmitterListener {
64
62
  this.notify();
65
63
  }
66
64
 
67
- private handleWarnings(event: WarningsEvent): void {
68
- this.warnings = event.warnings;
69
- this.notify();
70
- }
71
-
72
65
  private handleStatus(event: StatusEvent): void {
73
66
  this.rootStatus = event.status;
74
67
  this.notify();
@@ -90,7 +83,6 @@ export class RlmEventAggregator extends EmitterListener {
90
83
  subcalls: this.store.getSubcalls(),
91
84
  totals: this.store.getTotals(),
92
85
  answer: this.answer,
93
- warnings: this.warnings,
94
86
  };
95
87
  }
96
88
 
@@ -40,7 +40,5 @@ export interface RlmDetails {
40
40
  readonly subcalls: readonly RlmSubcall[];
41
41
  readonly totals: { readonly costUsd: number; readonly tokens: number };
42
42
  readonly answer?: string;
43
- /** Advisory diagnostics — surfaced to the user, never a failure. */
44
- readonly warnings?: readonly string[];
45
43
  }
46
44