@hicaru/pi-rlm 0.2.0 → 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.
- package/package.json +1 -1
- package/src/bridge/fallback-todo.ts +12 -1
- package/src/bridge/subcall-handlers.ts +336 -0
- package/src/config/defaults.ts +4 -1
- package/src/core/engine.ts +65 -30
- package/src/index.ts +28 -0
- package/src/prompts/system.ts +23 -2
- package/src/sandbox/protocol.ts +6 -0
- package/src/sandbox/sandbox-manager.ts +20 -6
- package/src/sandbox/sandbox.ts +77 -12
- package/src/sandbox/worker.py +460 -82
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +2 -0
- package/src/tool/repl-tool.ts +186 -102
- package/src/tool/rlm-events.ts +10 -2
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/util/concurrency.ts +87 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/rlm-query.ts +0 -108
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped home for detached `spawn()` work in native repl() mode.
|
|
3
|
+
*
|
|
4
|
+
* A spawned sub-call may settle after the repl() call that started it has returned, when
|
|
5
|
+
* that turn's RlmEmitter has been shut down and its LimitGuard discarded. Both live here for
|
|
6
|
+
* the whole session instead. The turn that awaits a task — or simply the next turn to run —
|
|
7
|
+
* adopts its settled subtree into its own ReplDetails, so background spend is reported even
|
|
8
|
+
* when the model never collects the result.
|
|
9
|
+
*
|
|
10
|
+
* The headless engine needs none of this: its emitter and guard already outlive every
|
|
11
|
+
* sub-call it services (see core/engine.ts).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { RlmEmitter } from "./rlm-events.ts";
|
|
15
|
+
import { SubcallStore, type SubcallTotals } from "./subcall-store.ts";
|
|
16
|
+
import type { RlmSubcall } from "./rlm-details.ts";
|
|
17
|
+
import { LimitGuard, type Limits } from "../core/limits.ts";
|
|
18
|
+
import type { Invocation } from "../bridge/subcall-handlers.ts";
|
|
19
|
+
import { trace, traceEnabled } from "../util/trace.ts";
|
|
20
|
+
|
|
21
|
+
/** What a drain hands to the turn that is reporting it. */
|
|
22
|
+
export interface BackgroundDrain {
|
|
23
|
+
readonly subcalls: readonly RlmSubcall[];
|
|
24
|
+
readonly totals: SubcallTotals;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class BackgroundTasks {
|
|
28
|
+
/** "bg" prefix so these IDs can never collide with a turn emitter's `s1, s2, …`. */
|
|
29
|
+
private readonly _emitter = new RlmEmitter("bg");
|
|
30
|
+
private readonly store = new SubcallStore(this._emitter);
|
|
31
|
+
private readonly limits: LimitGuard;
|
|
32
|
+
private active = 0;
|
|
33
|
+
|
|
34
|
+
constructor(limits: Limits) {
|
|
35
|
+
this.limits = new LimitGuard(limits);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read-only access for the progressive tracer (scope "background"). */
|
|
39
|
+
get emitter(): RlmEmitter {
|
|
40
|
+
return this._emitter;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The Invocation detached sub-calls resolve to. Stable for the session. */
|
|
44
|
+
get invocation(): Invocation {
|
|
45
|
+
return {
|
|
46
|
+
emitter: this._emitter,
|
|
47
|
+
parentId: undefined,
|
|
48
|
+
depth: 0,
|
|
49
|
+
limits: this.limits,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Detached sub-calls still in flight. The single pending counter for the session. */
|
|
54
|
+
get pending(): number {
|
|
55
|
+
return this.active;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Live snapshot of detached sub-calls, settled or not — progressive rendering only. */
|
|
59
|
+
liveSubcalls(): readonly RlmSubcall[] {
|
|
60
|
+
return this.store.getSubcalls();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Live totals for that same snapshot. Accounting still flows through `drain()`. */
|
|
64
|
+
liveTotals(): SubcallTotals {
|
|
65
|
+
return this.store.getTotals();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Count `run` as in-flight detached work for its duration. */
|
|
69
|
+
async track<T>(run: () => Promise<T>): Promise<T> {
|
|
70
|
+
this.active += 1;
|
|
71
|
+
const startedAt = Date.now();
|
|
72
|
+
if (traceEnabled) trace("bg.start", { pending: this.active });
|
|
73
|
+
try {
|
|
74
|
+
return await run();
|
|
75
|
+
} finally {
|
|
76
|
+
this.active -= 1;
|
|
77
|
+
if (traceEnabled) trace("bg.settle", { pending: this.active, durationMs: Date.now() - startedAt });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Hand over every fully-settled subtree and forget it.
|
|
83
|
+
*
|
|
84
|
+
* Called at the end of every repl() call, not only when a task was awaited, so a spawn
|
|
85
|
+
* the model never collects still reaches the user's cost totals.
|
|
86
|
+
*/
|
|
87
|
+
drain(): BackgroundDrain {
|
|
88
|
+
return this.store.takeSettledSubtrees();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
dispose(): void {
|
|
92
|
+
this.store.dispose();
|
|
93
|
+
this._emitter.shutdown();
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/tool/repl-details.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface ReplDetails {
|
|
|
22
22
|
readonly totals: { readonly costUsd: number; readonly tokens: number };
|
|
23
23
|
/** Final answer submitted through answer["ready"] without echoing it to the model. */
|
|
24
24
|
readonly finalAnswer?: string;
|
|
25
|
+
/** Detached spawn() sub-calls still running when this call returned. Absent when none. */
|
|
26
|
+
readonly backgroundPending?: number;
|
|
25
27
|
/** Advisory diagnostics — surfaced to the user, never a failure. */
|
|
26
28
|
readonly warnings?: readonly string[];
|
|
27
29
|
}
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -6,10 +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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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";
|
|
@@ -17,16 +20,16 @@ import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
|
17
20
|
import { Container, Spacer, 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
23
|
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
22
24
|
import { buildLibraryHandler } from "../bridge/library.ts";
|
|
23
25
|
import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
|
|
24
|
-
import {
|
|
25
|
-
import { createRlmHandlers } from "../bridge/rlm-query.ts";
|
|
26
|
+
import type { SubcallGates } from "../util/concurrency.ts";
|
|
26
27
|
import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
|
|
27
|
-
import type {
|
|
28
|
-
import type { InteractiveDeps, RlmConfig, RunRlm } from "../core/types.ts";
|
|
28
|
+
import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
|
|
29
29
|
import { SandboxManager } from "../sandbox/sandbox-manager.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";
|
|
30
33
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
31
34
|
import { RlmEmitter } from "./rlm-events.ts";
|
|
32
35
|
import { SubcallStore } from "./subcall-store.ts";
|
|
@@ -44,6 +47,7 @@ import {
|
|
|
44
47
|
} from "./subcall-render.ts";
|
|
45
48
|
import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
|
|
46
49
|
import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
|
|
50
|
+
import { attachTracer, trace, traceEnabled } from "../util/trace.ts";
|
|
47
51
|
|
|
48
52
|
/** Chars of code shown on the tool call line, and of stdout in the expanded view. */
|
|
49
53
|
const CALL_PREVIEW_CHARS = 80;
|
|
@@ -56,29 +60,61 @@ export const ReplToolParams = Object.freeze(Type.Object({
|
|
|
56
60
|
code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
|
|
57
61
|
}));
|
|
58
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
|
+
|
|
59
73
|
/** Model-visible text assembled from a repl() result. */
|
|
60
74
|
export interface ReplResultText {
|
|
61
75
|
readonly text: string;
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
/**
|
|
65
|
-
* Assemble the model-visible text for a repl() result: cap stdout
|
|
66
|
-
*
|
|
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.
|
|
67
89
|
*/
|
|
68
90
|
export function buildReplResultText(
|
|
69
91
|
stdout: string,
|
|
70
92
|
finalAnswer: string | undefined,
|
|
71
93
|
subcalls: readonly RlmSubcall[],
|
|
94
|
+
backgroundPending = 0,
|
|
95
|
+
varNames: readonly string[] = [],
|
|
72
96
|
): ReplResultText {
|
|
73
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
|
+
: "";
|
|
74
103
|
const rawText = answerSubmitted
|
|
75
104
|
? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
|
|
76
|
-
: stdout ||
|
|
105
|
+
: stdout || `(no output)${varsHint}`;
|
|
77
106
|
// Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
|
|
78
107
|
const cappedText = capReplResultText(rawText) ?? rawText;
|
|
79
108
|
const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
|
|
80
109
|
const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
81
|
-
|
|
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 };
|
|
82
118
|
}
|
|
83
119
|
|
|
84
120
|
/** Advisory diagnostics derived from a completed invocation's sub-calls. */
|
|
@@ -99,33 +135,41 @@ export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly s
|
|
|
99
135
|
// ── Mutable bridge state (handler indirection) ──
|
|
100
136
|
|
|
101
137
|
/**
|
|
102
|
-
* Holds per-invocation
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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.
|
|
105
148
|
*/
|
|
106
149
|
class NativeBridgeState {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
swap(inv:
|
|
114
|
-
this.
|
|
115
|
-
this.
|
|
116
|
-
this.currentDepth = inv.depth;
|
|
117
|
-
this.currentLimits = inv.limits;
|
|
118
|
-
this.currentInteractive = inv.interactive;
|
|
150
|
+
private current: Invocation | null = null;
|
|
151
|
+
/** Interactive callbacks for the turn in progress; child engines inherit them. */
|
|
152
|
+
interactive: InteractiveDeps | null = null;
|
|
153
|
+
|
|
154
|
+
constructor(private readonly background: BackgroundTasks) {}
|
|
155
|
+
|
|
156
|
+
swap(inv: Invocation, interactive: InteractiveDeps): void {
|
|
157
|
+
this.current = Object.freeze({ ...inv });
|
|
158
|
+
this.interactive = interactive;
|
|
119
159
|
}
|
|
120
160
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The turn emitter, for library-load reporting. Null between repl() calls. */
|
|
167
|
+
get currentEmitter(): RlmEmitter | null {
|
|
168
|
+
return this.current?.emitter ?? null;
|
|
126
169
|
}
|
|
127
170
|
}
|
|
128
171
|
|
|
172
|
+
|
|
129
173
|
// ── Tool factory ──
|
|
130
174
|
|
|
131
175
|
export interface ReplToolDeps {
|
|
@@ -137,6 +181,10 @@ export interface ReplToolDeps {
|
|
|
137
181
|
readonly registry: ModelRegistry;
|
|
138
182
|
/** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
|
|
139
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;
|
|
140
188
|
readonly signal?: AbortSignal;
|
|
141
189
|
readonly onUsage?: (usage: Usage, role: "sub") => void;
|
|
142
190
|
readonly ensureContext?: () => Promise<void>;
|
|
@@ -145,61 +193,48 @@ export interface ReplToolDeps {
|
|
|
145
193
|
}
|
|
146
194
|
|
|
147
195
|
export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
|
|
148
|
-
const { sandboxManager, workerModel, registry, getConfig, signal, onUsage } = deps;
|
|
149
|
-
const bridgeState = new NativeBridgeState();
|
|
196
|
+
const { sandboxManager, workerModel, registry, getConfig, signal, onUsage, background } = deps;
|
|
197
|
+
const bridgeState = new NativeBridgeState(background);
|
|
150
198
|
|
|
151
199
|
// Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
|
|
152
200
|
let sessionCwd = process.cwd();
|
|
153
201
|
|
|
154
|
-
const
|
|
202
|
+
const getWorkerModel = (): Model<Api> => deps.getWorkerModel?.() ?? workerModel;
|
|
203
|
+
const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
|
|
155
204
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
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(),
|
|
160
211
|
registry,
|
|
161
|
-
config: getConfig,
|
|
212
|
+
config: getConfig(),
|
|
162
213
|
signal,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
emitter:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
},
|
|
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,
|
|
230
|
+
registry,
|
|
231
|
+
getWorkerModel,
|
|
232
|
+
getModel,
|
|
233
|
+
getConfig,
|
|
234
|
+
signal,
|
|
235
|
+
onUsage,
|
|
236
|
+
runChild,
|
|
237
|
+
trackDetached: (task) => background.track(task),
|
|
203
238
|
});
|
|
204
239
|
|
|
205
240
|
const libraryBundle = getConfig().libraryLoader
|
|
@@ -233,7 +268,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
233
268
|
],
|
|
234
269
|
parameters: ReplToolParams,
|
|
235
270
|
|
|
236
|
-
async execute(_toolCallId, rawParams,
|
|
271
|
+
async execute(_toolCallId, rawParams, execSignal, onUpdate, ctx) {
|
|
237
272
|
const validation = validateToolParams(ReplToolParams, rawParams, "REPL", (errors): ReplDetails => ({
|
|
238
273
|
status: "error",
|
|
239
274
|
output: "",
|
|
@@ -253,17 +288,29 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
253
288
|
const startedAt = Date.now();
|
|
254
289
|
const limits = new LimitGuard(limitsFromConfig(getConfig()));
|
|
255
290
|
|
|
291
|
+
const detachTracers = traceEnabled
|
|
292
|
+
? [attachTracer(emitter, "turn"), attachTracer(background.emitter, "background")]
|
|
293
|
+
: [];
|
|
294
|
+
|
|
256
295
|
// ── Progressive rendering: spinner + live sub-call tree ──
|
|
257
296
|
const progress = createProgressNotifier<ReplDetails>({
|
|
258
297
|
onUpdate,
|
|
259
|
-
getDetails: () =>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
},
|
|
267
314
|
isRunning: (details) => details.status === "running",
|
|
268
315
|
renderText: (details) => details.output.slice(0, 500) || (details.status === "running" ? `${spinnerFrame()} Running…` : "(no output)"),
|
|
269
316
|
});
|
|
@@ -295,8 +342,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
295
342
|
|
|
296
343
|
await deps.ensureContext?.();
|
|
297
344
|
await sandboxManager.getOrCreate({
|
|
298
|
-
...
|
|
299
|
-
...rlmHandlers,
|
|
345
|
+
...subcallHandlers,
|
|
300
346
|
askUserQuestion: interactiveHandlers.askUserQuestion,
|
|
301
347
|
todo: interactiveHandlers.todo,
|
|
302
348
|
...(libraryBundle?.handlers ?? {}),
|
|
@@ -311,19 +357,46 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
311
357
|
});
|
|
312
358
|
}
|
|
313
359
|
|
|
360
|
+
if (traceEnabled) {
|
|
361
|
+
trace("repl.exec.start", { chars: params.code.length, code: params.code.slice(0, 400) });
|
|
362
|
+
}
|
|
363
|
+
|
|
314
364
|
const start = Date.now();
|
|
315
365
|
const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
|
|
316
366
|
// Wire per-invocation mutable state only after the serialized exec slot
|
|
317
367
|
// is active. Swapping earlier would let queued repl() calls overwrite
|
|
318
368
|
// emitter/limits for the currently running REPL execution.
|
|
319
|
-
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits, interactive
|
|
320
|
-
});
|
|
369
|
+
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits }, interactive);
|
|
370
|
+
}, execSignal);
|
|
321
371
|
const elapsed = Date.now() - start;
|
|
322
372
|
capturedStdout = result.stdout;
|
|
323
373
|
capturedStderr = result.stderr;
|
|
324
374
|
progressStatus = "done";
|
|
325
375
|
|
|
326
|
-
|
|
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
|
+
};
|
|
327
400
|
const subUsage: Usage = {
|
|
328
401
|
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: totals.tokens,
|
|
329
402
|
cost: { total: totals.costUsd, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
@@ -336,7 +409,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
336
409
|
const { text: resultText } = buildReplResultText(
|
|
337
410
|
result.stdout,
|
|
338
411
|
finalAnswer,
|
|
339
|
-
|
|
412
|
+
subcalls,
|
|
413
|
+
background.pending,
|
|
414
|
+
result.varNames,
|
|
340
415
|
);
|
|
341
416
|
|
|
342
417
|
const details: ReplDetails = {
|
|
@@ -344,10 +419,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
344
419
|
output: result.stdout,
|
|
345
420
|
stderr: result.stderr,
|
|
346
421
|
executionTimeMs: elapsed,
|
|
347
|
-
subcalls
|
|
348
|
-
totals
|
|
422
|
+
subcalls,
|
|
423
|
+
totals,
|
|
349
424
|
finalAnswer,
|
|
350
|
-
|
|
425
|
+
backgroundPending: background.pending > 0 ? background.pending : undefined,
|
|
426
|
+
warnings: collectReplWarnings(subcalls),
|
|
351
427
|
};
|
|
352
428
|
const progressText = finalAnswer !== undefined
|
|
353
429
|
? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
|
|
@@ -358,13 +434,20 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
358
434
|
} catch (e) {
|
|
359
435
|
progressStatus = "error";
|
|
360
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();
|
|
361
440
|
const details: ReplDetails = {
|
|
362
441
|
status: "error",
|
|
363
442
|
output: "",
|
|
364
443
|
stderr: msg,
|
|
365
444
|
executionTimeMs: 0,
|
|
366
|
-
subcalls: store.getSubcalls(),
|
|
367
|
-
totals:
|
|
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,
|
|
368
451
|
};
|
|
369
452
|
onUpdate?.({ content: [{ type: "text", text: `REPL error: ${msg}` }], details });
|
|
370
453
|
return {
|
|
@@ -373,6 +456,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
373
456
|
};
|
|
374
457
|
} finally {
|
|
375
458
|
progress.stop();
|
|
459
|
+
for (const off of detachTracers) off();
|
|
376
460
|
store.dispose();
|
|
377
461
|
emitter.shutdown();
|
|
378
462
|
}
|
|
@@ -401,7 +485,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
401
485
|
|
|
402
486
|
function replStats(details: ReplDetails, theme: Theme): string {
|
|
403
487
|
const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
|
|
404
|
-
return cardStatsLine(details.totals, theme, elapsed);
|
|
488
|
+
return cardStatsLine(details.totals, theme, elapsed, details.backgroundPending);
|
|
405
489
|
}
|
|
406
490
|
|
|
407
491
|
function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
|
package/src/tool/rlm-events.ts
CHANGED
|
@@ -76,18 +76,26 @@ export interface RootPromptEvent {
|
|
|
76
76
|
/**
|
|
77
77
|
* Typed wrapper around a Node.js EventEmitter for RLM lifecycle events.
|
|
78
78
|
*
|
|
79
|
-
* Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()
|
|
79
|
+
* Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`, with a
|
|
80
|
+
* configurable prefix so a second emitter's IDs cannot collide with the default ones.
|
|
80
81
|
* Provides typed `on*` methods that return unsubscribe functions.
|
|
81
82
|
*/
|
|
82
83
|
export class RlmEmitter {
|
|
83
84
|
private readonly ee = new EventEmitter();
|
|
84
85
|
private seq = 0;
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* `idPrefix` namespaces generated IDs. The counter is per-instance, so two emitters
|
|
89
|
+
* would both start at `s1`; a distinct prefix is what lets one emitter's subcalls be
|
|
90
|
+
* merged into another's tree without colliding IDs or corrupting parentId links.
|
|
91
|
+
*/
|
|
92
|
+
constructor(private readonly idPrefix = "s") {}
|
|
93
|
+
|
|
86
94
|
// ── Emit ──
|
|
87
95
|
|
|
88
96
|
/** Create a new sub-call entry. Returns the auto-generated ID. */
|
|
89
97
|
emitSubcallCreated(init: Omit<SubcallCreatedEvent, "id">): string {
|
|
90
|
-
const id =
|
|
98
|
+
const id = `${this.idPrefix}${++this.seq}`;
|
|
91
99
|
const event: SubcallCreatedEvent = { id, ...init };
|
|
92
100
|
this.ee.emit("subcall:created", event);
|
|
93
101
|
return id;
|
|
@@ -52,11 +52,21 @@ export function cardStatsLine(
|
|
|
52
52
|
totals: { readonly costUsd: number; readonly tokens: number },
|
|
53
53
|
theme: Theme,
|
|
54
54
|
extra?: string,
|
|
55
|
+
backgroundPending?: number,
|
|
55
56
|
): string {
|
|
56
57
|
const parts: string[] = [formatCost(totals.costUsd)];
|
|
57
58
|
if (totals.tokens > 0) parts.push(`${formatTokens(totals.tokens)} tok`);
|
|
58
59
|
if (extra) parts.push(extra);
|
|
59
|
-
|
|
60
|
+
const line = theme.fg("dim", parts.join(" · "));
|
|
61
|
+
// The one thing no tree can show: spawned work that may outlive this block.
|
|
62
|
+
return backgroundPending !== undefined && backgroundPending > 0
|
|
63
|
+
? `${line} ${theme.fg("warning", `↯${backgroundPending} bg`)}`
|
|
64
|
+
: line;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Detached nodes carry BackgroundTasks' "bg" id prefix (RlmEmitter("bg")). */
|
|
68
|
+
function backgroundTag(sc: RlmSubcall, theme: Theme): string {
|
|
69
|
+
return sc.id.startsWith("bg") ? ` ${theme.fg("warning", "↯bg")}` : "";
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** `<glyph> <TITLE> <stats>` — the first line of both tools' collapsed and expanded views. */
|
|
@@ -128,7 +138,8 @@ export function renderCollapsedSubcallTree(
|
|
|
128
138
|
const branch = isLast ? "└─" : "├─";
|
|
129
139
|
const gGlyph = subcallStatusGlyph(sc, theme);
|
|
130
140
|
const gStats = subcallStatsLine(sc);
|
|
131
|
-
|
|
141
|
+
const gBg = backgroundTag(sc, theme);
|
|
142
|
+
lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}${gBg}`);
|
|
132
143
|
const childPrefix = prefix + (isLast ? " " : "│ ");
|
|
133
144
|
lines.push(...walk(sc.id, childPrefix));
|
|
134
145
|
}
|
|
@@ -155,7 +166,8 @@ export function renderExpandedSubcallTree(
|
|
|
155
166
|
const sKind = theme.fg("muted", sc.label);
|
|
156
167
|
const sModel = sc.model ? theme.fg("dim", ` ${sc.model}`) : "";
|
|
157
168
|
const sStats = sc.endedAt ? ` ${theme.fg("dim", subcallStatsLine(sc))}` : "";
|
|
158
|
-
|
|
169
|
+
const sBg = backgroundTag(sc, theme);
|
|
170
|
+
let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}${sBg}`;
|
|
159
171
|
|
|
160
172
|
if (sc.args) {
|
|
161
173
|
line += `\n${pad} ${theme.fg("dim", previewText(sc.args, ARGS_PREVIEW_CHARS))}`;
|