@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.
- package/README.md +12 -35
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +1 -1
- package/src/bridge/library.ts +61 -26
- package/src/bridge/subcall-handlers.ts +382 -0
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +7 -15
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +115 -360
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +3 -36
- package/src/index.ts +49 -10
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +26 -57
- package/src/prompts/glossary.ts +287 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +14 -386
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +14 -69
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +116 -0
- package/src/sandbox/py/worker.py +836 -0
- package/src/sandbox/sandbox-manager.ts +33 -6
- package/src/sandbox/sandbox.ts +153 -182
- package/src/text/tokens.ts +29 -3
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +4 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +178 -216
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +10 -16
- package/src/tool/rlm-tool.ts +1 -12
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +4 -16
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +91 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/fallback-todo.ts +0 -137
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/bridge/rlm-query.ts +0 -108
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/sandbox/worker.py +0 -1078
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
package/src/tool/repl-tool.ts
CHANGED
|
@@ -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
|
-
*
|
|
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";
|
|
16
|
-
import type {
|
|
17
|
-
import {
|
|
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 {
|
|
24
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
|
46
|
+
/** Chars of code shown on the tool call line. */
|
|
49
47
|
const CALL_PREVIEW_CHARS = 80;
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
60
|
-
export interface ReplResultText {
|
|
61
|
-
readonly text: string;
|
|
62
|
-
}
|
|
65
|
+
// ── Mutable bridge state (handler indirection) ──
|
|
63
66
|
|
|
64
67
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
+
swap(inv: Invocation): void {
|
|
85
|
+
this.current = Object.freeze({ ...inv });
|
|
86
|
+
}
|
|
100
87
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
105
|
+
readonly llmModel: Model<Api>;
|
|
135
106
|
readonly getModel?: () => Model<Api> | undefined;
|
|
136
|
-
readonly
|
|
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,
|
|
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
|
|
129
|
+
const getLlmModel = (): Model<Api> => deps.getLlmModel?.() ?? llmModel;
|
|
130
|
+
const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
|
|
155
131
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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,
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
348
|
-
totals
|
|
348
|
+
subcalls,
|
|
349
|
+
totals,
|
|
349
350
|
finalAnswer,
|
|
350
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
package/src/tool/rlm-details.ts
CHANGED
|
@@ -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
|
|