@hicaru/pi-rlm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/README.ru.md +200 -0
- package/README.zh-CN.md +224 -0
- package/package.json +54 -0
- package/src/bridge/fallback-todo.ts +137 -0
- package/src/bridge/interactive.ts +65 -0
- package/src/bridge/llm-query.ts +124 -0
- package/src/bridge/model.ts +97 -0
- package/src/bridge/pi-interactive.ts +86 -0
- package/src/bridge/rlm-query.ts +78 -0
- package/src/commands/rlm-config.ts +42 -0
- package/src/commands/rlm.ts +165 -0
- package/src/config/defaults.ts +38 -0
- package/src/config/settings.ts +185 -0
- package/src/context/repomix-context.ts +253 -0
- package/src/core/answer.ts +97 -0
- package/src/core/compaction.ts +64 -0
- package/src/core/engine.ts +408 -0
- package/src/core/history.ts +13 -0
- package/src/core/iteration.ts +45 -0
- package/src/core/limits.ts +90 -0
- package/src/core/pipeline.ts +100 -0
- package/src/core/resource-limits.ts +14 -0
- package/src/core/types.ts +131 -0
- package/src/index.ts +165 -0
- package/src/mode/input-router.ts +23 -0
- package/src/mode/rlm-mode.ts +149 -0
- package/src/patch/apply.ts +148 -0
- package/src/patch/index.ts +37 -0
- package/src/prompts/system.ts +278 -0
- package/src/prompts/user.ts +21 -0
- package/src/sandbox/protocol.ts +191 -0
- package/src/sandbox/sandbox-manager.ts +143 -0
- package/src/sandbox/sandbox.ts +362 -0
- package/src/sandbox/worker.py +457 -0
- package/src/state/events.ts +22 -0
- package/src/state/index.ts +23 -0
- package/src/state/internal.ts +46 -0
- package/src/state/paths.ts +42 -0
- package/src/state/reads.ts +96 -0
- package/src/state/resume.ts +154 -0
- package/src/state/rows.ts +117 -0
- package/src/state/writes.ts +56 -0
- package/src/telemetry/dispatcher.ts +116 -0
- package/src/telemetry/index.ts +14 -0
- package/src/telemetry/mlflow-config.ts +15 -0
- package/src/telemetry/mlflow-sink.ts +136 -0
- package/src/telemetry/mlflow.ts +99 -0
- package/src/telemetry/sink.ts +8 -0
- package/src/text/edits.ts +16 -0
- package/src/text/parsing.ts +35 -0
- package/src/text/preview.ts +18 -0
- package/src/text/tokens.ts +64 -0
- package/src/tool/apply-diff-tool.ts +125 -0
- package/src/tool/emitter-listener.ts +24 -0
- package/src/tool/repl-details.ts +23 -0
- package/src/tool/repl-tool.ts +528 -0
- package/src/tool/rlm-aggregator.ts +115 -0
- package/src/tool/rlm-details.ts +53 -0
- package/src/tool/rlm-events.ts +215 -0
- package/src/tool/rlm-tool.ts +199 -0
- package/src/tool/subcall-render.ts +129 -0
- package/src/tool/subcall-store.ts +90 -0
- package/src/tool/tool-utils.ts +73 -0
- package/src/ui/config-panel.ts +92 -0
- package/src/ui/intro.ts +23 -0
- package/src/ui/model-picker.ts +139 -0
- package/src/ui/status.ts +26 -0
- package/src/ui/theme.ts +47 -0
- package/src/util/concurrency.ts +15 -0
- package/src/util/errors.ts +27 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* repl() tool — executes Python code in the persistent RLM sandbox.
|
|
3
|
+
*
|
|
4
|
+
* Registered as a Pi tool so the main agent can use `repl({code: "..."})` alongside
|
|
5
|
+
* its normal tool suite. Each call creates a fresh RlmEmitter for sub-call tracking
|
|
6
|
+
* and collects sub-calls manually from emitter events. No RlmEventAggregator is used
|
|
7
|
+
* (ReplDetails ≠ RlmDetails structural mismatch).
|
|
8
|
+
*
|
|
9
|
+
* Sandbox handlers (llm_query, rlm_query, todo, ask_user_question) use mutable refs
|
|
10
|
+
* so the tool can swap per-invocation state (emitter, depth, limits) without recreating
|
|
11
|
+
* the sandbox — preserving REPL variable state across calls.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { Model, Usage, Api } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { modelRef, resolveModelId } from "../config/settings.ts";
|
|
20
|
+
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
21
|
+
import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
|
|
22
|
+
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
23
|
+
import { previewText } from "../text/preview.ts";
|
|
24
|
+
import { mapPool } from "../util/concurrency.ts";
|
|
25
|
+
import { LimitGuard } from "../core/limits.ts";
|
|
26
|
+
import { checkResourceLimits } from "../core/resource-limits.ts";
|
|
27
|
+
import type { RlmConfig, Sampling } from "../core/types.ts";
|
|
28
|
+
import { SandboxManager } from "../sandbox/sandbox-manager.ts";
|
|
29
|
+
import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
30
|
+
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
31
|
+
import { RlmEmitter } from "./rlm-events.ts";
|
|
32
|
+
import { SubcallStore } from "./subcall-store.ts";
|
|
33
|
+
import type { ReplDetails } from "./repl-details.ts";
|
|
34
|
+
import { createEngine } from "../core/engine.ts";
|
|
35
|
+
import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
|
|
36
|
+
import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
|
|
37
|
+
import {
|
|
38
|
+
headlineStatusGlyph,
|
|
39
|
+
renderCollapsedSubcallTree,
|
|
40
|
+
renderExpandedSubcallTree,
|
|
41
|
+
} from "./subcall-render.ts";
|
|
42
|
+
import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
|
|
43
|
+
|
|
44
|
+
// ── Parameter schema ──
|
|
45
|
+
|
|
46
|
+
export const ReplToolParams = Object.freeze(Type.Object({
|
|
47
|
+
code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// ── Mutable bridge state (handler indirection) ──
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Holds per-invocation mutable state that sandbox handlers dereference.
|
|
54
|
+
* The sandbox is created once with handlers that read from this object's
|
|
55
|
+
* current fields, so the tool can swap emitter/depth/limits between calls
|
|
56
|
+
* without recreating the sandbox (preserving REPL variable state).
|
|
57
|
+
*/
|
|
58
|
+
class NativeBridgeState {
|
|
59
|
+
currentEmitter: RlmEmitter | null = null;
|
|
60
|
+
currentParentId: string | undefined;
|
|
61
|
+
currentDepth = 0;
|
|
62
|
+
currentLimits: LimitGuard | null = null;
|
|
63
|
+
|
|
64
|
+
swap(inv: { emitter: RlmEmitter; parentId?: string; depth: number; limits: LimitGuard }): void {
|
|
65
|
+
this.currentEmitter = inv.emitter;
|
|
66
|
+
this.currentParentId = inv.parentId;
|
|
67
|
+
this.currentDepth = inv.depth;
|
|
68
|
+
this.currentLimits = inv.limits;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
buildLlmHandlers(deps: {
|
|
72
|
+
workerModel: Model<Api>;
|
|
73
|
+
getWorkerModel?: () => Model<Api> | undefined;
|
|
74
|
+
registry: ModelRegistry;
|
|
75
|
+
maxPromptChars: number;
|
|
76
|
+
maxConcurrent: number;
|
|
77
|
+
sampling?: Sampling;
|
|
78
|
+
subSystem?: string;
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
}): Pick<SubLlmHandlers, "llmQuery" | "llmQueryBatched"> {
|
|
81
|
+
const state = this;
|
|
82
|
+
|
|
83
|
+
const workerModel = (): Model<Api> => deps.getWorkerModel?.() ?? deps.workerModel;
|
|
84
|
+
const displayModel = (model: string | null): string =>
|
|
85
|
+
modelRef(model ? (resolveModelId(deps.registry, model) ?? workerModel()) : workerModel()) ?? workerModel().id;
|
|
86
|
+
|
|
87
|
+
async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
|
|
88
|
+
if (prompt.length > deps.maxPromptChars) {
|
|
89
|
+
return formatError(`sub-LLM prompt exceeded size limit (${prompt.length.toLocaleString()} chars > ${deps.maxPromptChars.toLocaleString()})`);
|
|
90
|
+
}
|
|
91
|
+
const resolved = model ? resolveModelId(deps.registry, model) : undefined;
|
|
92
|
+
if (model && !resolved) return formatError(`unknown model override '${model}'`);
|
|
93
|
+
try {
|
|
94
|
+
const messages: ChatMsg[] = [{ role: "user", content: prompt }];
|
|
95
|
+
const res = await modelComplete(messages, {
|
|
96
|
+
model: resolved ?? workerModel(),
|
|
97
|
+
registry: deps.registry,
|
|
98
|
+
system: deps.subSystem,
|
|
99
|
+
maxTokens: deps.sampling?.maxTokens,
|
|
100
|
+
temperature: deps.sampling?.temperature,
|
|
101
|
+
reasoning: deps.sampling?.reasoning,
|
|
102
|
+
signal: deps.signal,
|
|
103
|
+
});
|
|
104
|
+
track(res.usage);
|
|
105
|
+
return res.text;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const msg = errorMessage(err);
|
|
108
|
+
const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
|
|
109
|
+
? " — try smaller batches or individual llm_query calls"
|
|
110
|
+
: "";
|
|
111
|
+
return formatError(`${msg}${hint}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
async llmQuery(prompt, model, _depth) {
|
|
117
|
+
const id = state.currentEmitter?.emitSubcallCreated({
|
|
118
|
+
kind: "llm", parentId: state.currentParentId, label: "llm_query",
|
|
119
|
+
model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
|
|
120
|
+
depth: state.currentDepth,
|
|
121
|
+
});
|
|
122
|
+
let cost = 0; let tokens = 0;
|
|
123
|
+
const out = await complete1(prompt, model, (u) => { cost += u.cost.total; tokens += u.totalTokens; });
|
|
124
|
+
if (id) state.currentEmitter?.emitSubcallUpdated({ id,
|
|
125
|
+
status: isErrorText(out) ? "error" : "done",
|
|
126
|
+
costUsd: cost, tokens, resultPreview: previewText(out),
|
|
127
|
+
detail: isErrorText(out) ? out : undefined,
|
|
128
|
+
});
|
|
129
|
+
state.currentLimits?.addRaw(cost, 0, tokens);
|
|
130
|
+
return out;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async llmQueryBatched(prompts: readonly string[], model, _depth): Promise<string[]> {
|
|
134
|
+
const id = state.currentEmitter?.emitSubcallCreated({
|
|
135
|
+
kind: "batch", parentId: state.currentParentId, label: `llm_query ×${prompts.length}`,
|
|
136
|
+
model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
|
|
137
|
+
depth: state.currentDepth,
|
|
138
|
+
});
|
|
139
|
+
let cost = 0; let tokens = 0;
|
|
140
|
+
const out: string[] = await mapPool(prompts, deps.maxConcurrent, (p) =>
|
|
141
|
+
complete1(p, model, (u) => { cost += u.cost.total; tokens += u.totalTokens; }),
|
|
142
|
+
);
|
|
143
|
+
const failed = out.filter(isErrorText).length;
|
|
144
|
+
const allFailed = failed === out.length;
|
|
145
|
+
const error = allFailed
|
|
146
|
+
? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
|
|
147
|
+
: failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
|
|
148
|
+
if (id) state.currentEmitter?.emitSubcallUpdated({ id,
|
|
149
|
+
status: error ? "error" : "done", costUsd: cost, tokens,
|
|
150
|
+
resultPreview: previewText(out[0] ?? ""), detail: error,
|
|
151
|
+
});
|
|
152
|
+
state.currentLimits?.addRaw(cost, 0, tokens);
|
|
153
|
+
return out;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build real recursive rlm_query / rlm_query_batched handlers that spawn
|
|
160
|
+
* child RLM engines (each with its own sandbox and turn loop) rather than
|
|
161
|
+
* falling back to a one-shot llm_query.
|
|
162
|
+
*
|
|
163
|
+
* At the maxDepth cap the handler degrades to a plain llm_query via the
|
|
164
|
+
* already-wired llmHandlers (which read from the same mutable state).
|
|
165
|
+
*/
|
|
166
|
+
buildRlmHandlers(deps: {
|
|
167
|
+
model: Model<Api>;
|
|
168
|
+
workerModel: Model<Api>;
|
|
169
|
+
getModel?: () => Model<Api> | undefined;
|
|
170
|
+
getWorkerModel?: () => Model<Api> | undefined;
|
|
171
|
+
registry: ModelRegistry;
|
|
172
|
+
config: RlmConfig;
|
|
173
|
+
signal?: AbortSignal;
|
|
174
|
+
onUsage?: (usage: Usage, role: "sub") => void;
|
|
175
|
+
llmHandlers: Pick<SubLlmHandlers, "llmQuery" | "llmQueryBatched">;
|
|
176
|
+
}): Pick<SubLlmHandlers, "rlmQuery" | "rlmQueryBatched"> {
|
|
177
|
+
const state = this;
|
|
178
|
+
|
|
179
|
+
async function rlmQueryImpl(prompt: string, model: string | null, depth: number): Promise<string> {
|
|
180
|
+
const emitter = state.currentEmitter;
|
|
181
|
+
const limits = state.currentLimits;
|
|
182
|
+
if (!emitter || !limits) return formatError("RLM bridge not wired for this invocation");
|
|
183
|
+
|
|
184
|
+
const childDepth = state.currentDepth + 1;
|
|
185
|
+
|
|
186
|
+
// Depth cap: degrade to a one-shot llm_query.
|
|
187
|
+
if (childDepth >= deps.config.maxDepth) {
|
|
188
|
+
return deps.llmHandlers.llmQuery(prompt, model, depth);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const remBudget = limits.remainingBudgetUsd();
|
|
192
|
+
const remTimeout = limits.remainingTimeoutMs();
|
|
193
|
+
const limitError = checkResourceLimits({ budgetUsd: remBudget, timeoutMs: remTimeout });
|
|
194
|
+
if (limitError) return limitError;
|
|
195
|
+
|
|
196
|
+
const rootModel = deps.getModel?.() ?? deps.model;
|
|
197
|
+
const workerModel = deps.getWorkerModel?.() ?? deps.workerModel;
|
|
198
|
+
const resolvedOverride = model ? resolveModelId(deps.registry, model) : undefined;
|
|
199
|
+
const subId = emitter.emitSubcallCreated({
|
|
200
|
+
kind: "rlm", parentId: state.currentParentId, label: "rlm_query",
|
|
201
|
+
model: model ? (modelRef(resolvedOverride) ?? `unknown/${model}`) : (modelRef(rootModel) ?? rootModel.id),
|
|
202
|
+
detail: prompt.slice(0, 60),
|
|
203
|
+
depth: childDepth,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Per-call engine creation with the visible emitter — child llm_query subcalls,
|
|
207
|
+
// turn progress, and cost deltas land on the per-invocation emitter, visible to
|
|
208
|
+
// SubcallStore and the live visual tree.
|
|
209
|
+
const runRlm = createEngine({
|
|
210
|
+
model: rootModel,
|
|
211
|
+
workerModel,
|
|
212
|
+
registry: deps.registry,
|
|
213
|
+
config: deps.config,
|
|
214
|
+
signal: deps.signal,
|
|
215
|
+
emitter: emitter,
|
|
216
|
+
onUsage: deps.onUsage as ((usage: Usage, role: "root" | "sub") => void) | undefined,
|
|
217
|
+
limits: {
|
|
218
|
+
maxBudgetUsd: deps.config.maxBudgetUsd,
|
|
219
|
+
maxTimeoutMs: deps.config.maxTimeoutMs,
|
|
220
|
+
maxTokens: deps.config.maxTokens,
|
|
221
|
+
maxErrors: deps.config.maxErrors,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const res = await runRlm({
|
|
227
|
+
rootPrompt: "",
|
|
228
|
+
context: prompt,
|
|
229
|
+
depth: childDepth,
|
|
230
|
+
parentNodeId: subId,
|
|
231
|
+
modelOverride: model ?? undefined,
|
|
232
|
+
remainingBudgetUsd: remBudget,
|
|
233
|
+
remainingTimeoutMs: remTimeout,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Debit parent limit guard for the entire child run.
|
|
237
|
+
limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
|
|
238
|
+
|
|
239
|
+
// Child engine emits live usage deltas via the shared emitter — SubcallStore
|
|
240
|
+
// accumulates them. No final aggregate costUsd/tokens to prevent double-counting
|
|
241
|
+
// (matches canonical rlm-query.ts:60-63).
|
|
242
|
+
emitter.emitSubcallUpdated({
|
|
243
|
+
id: subId,
|
|
244
|
+
status: "done",
|
|
245
|
+
resultPreview: res.answer.slice(0, 200),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return res.answer;
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const msg = errorMessage(err);
|
|
251
|
+
emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
|
|
252
|
+
return formatError(`child RLM failed - ${msg}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
rlmQuery: rlmQueryImpl,
|
|
258
|
+
rlmQueryBatched: (prompts, model, depth) =>
|
|
259
|
+
mapPool(prompts, deps.config.maxConcurrentSubcalls, (p) => rlmQueryImpl(p, model, depth)),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Tool factory ──
|
|
265
|
+
|
|
266
|
+
export interface ReplToolDeps {
|
|
267
|
+
readonly sandboxManager: SandboxManager;
|
|
268
|
+
readonly model: Model<Api>;
|
|
269
|
+
readonly workerModel: Model<Api>;
|
|
270
|
+
readonly getModel?: () => Model<Api> | undefined;
|
|
271
|
+
readonly getWorkerModel?: () => Model<Api> | undefined;
|
|
272
|
+
readonly registry: ModelRegistry;
|
|
273
|
+
readonly config: RlmConfig;
|
|
274
|
+
readonly signal?: AbortSignal;
|
|
275
|
+
readonly onUsage?: (usage: Usage, role: "sub") => void;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
|
|
279
|
+
const { sandboxManager, workerModel, registry, config, signal, onUsage } = deps;
|
|
280
|
+
const bridgeState = new NativeBridgeState();
|
|
281
|
+
|
|
282
|
+
// Build handlers once — llm/rlm use mutable refs, interactive is session-stable
|
|
283
|
+
const llmHandlers = bridgeState.buildLlmHandlers({
|
|
284
|
+
workerModel,
|
|
285
|
+
getWorkerModel: deps.getWorkerModel,
|
|
286
|
+
registry,
|
|
287
|
+
maxPromptChars: config.maxPromptChars,
|
|
288
|
+
maxConcurrent: config.maxConcurrentSubcalls,
|
|
289
|
+
sampling: config.subSampling,
|
|
290
|
+
subSystem: config.subSystemPrompt,
|
|
291
|
+
signal,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Real recursive rlm_query via createEngine — each call spawns a child RLM
|
|
295
|
+
// with its own sandbox and turn loop, not a flat one-shot llm_query.
|
|
296
|
+
const rlmHandlers = bridgeState.buildRlmHandlers({
|
|
297
|
+
model: deps.model,
|
|
298
|
+
workerModel,
|
|
299
|
+
getModel: deps.getModel,
|
|
300
|
+
getWorkerModel: deps.getWorkerModel,
|
|
301
|
+
registry,
|
|
302
|
+
config,
|
|
303
|
+
signal,
|
|
304
|
+
onUsage,
|
|
305
|
+
llmHandlers,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
name: "repl",
|
|
310
|
+
label: "REPL",
|
|
311
|
+
description: "Execute Python code in a persistent REPL sandbox with the full repository context pre-loaded. Variables, imports, and state persist across calls. Supports llm_query, rlm_query, todo, and ask_user_question inside the sandbox.",
|
|
312
|
+
parameters: ReplToolParams,
|
|
313
|
+
|
|
314
|
+
async execute(_toolCallId, rawParams, _execSignal, onUpdate, ctx) {
|
|
315
|
+
const validation = validateToolParams(ReplToolParams, rawParams, "REPL", (errors): ReplDetails => ({
|
|
316
|
+
status: "error",
|
|
317
|
+
output: "",
|
|
318
|
+
stderr: errors,
|
|
319
|
+
executionTimeMs: 0,
|
|
320
|
+
subcalls: [],
|
|
321
|
+
totals: { costUsd: 0, tokens: 0 },
|
|
322
|
+
}));
|
|
323
|
+
if (!validation.ok) return validation.error;
|
|
324
|
+
const params = validation.value;
|
|
325
|
+
|
|
326
|
+
const emitter = new RlmEmitter();
|
|
327
|
+
const store = new SubcallStore(emitter);
|
|
328
|
+
let capturedStdout = "";
|
|
329
|
+
let capturedStderr = "";
|
|
330
|
+
let progressStatus: ReplDetails["status"] = "running";
|
|
331
|
+
const startedAt = Date.now();
|
|
332
|
+
const limits = new LimitGuard({
|
|
333
|
+
maxBudgetUsd: config.maxBudgetUsd,
|
|
334
|
+
maxTimeoutMs: config.maxTimeoutMs,
|
|
335
|
+
maxTokens: config.maxTokens,
|
|
336
|
+
maxErrors: config.maxErrors,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Progressive rendering: spinner + live sub-call tree ──
|
|
340
|
+
const progress = createProgressNotifier<ReplDetails>({
|
|
341
|
+
onUpdate,
|
|
342
|
+
getDetails: () => ({
|
|
343
|
+
status: progressStatus,
|
|
344
|
+
output: capturedStdout,
|
|
345
|
+
stderr: capturedStderr,
|
|
346
|
+
executionTimeMs: Date.now() - startedAt,
|
|
347
|
+
subcalls: store.getSubcalls(),
|
|
348
|
+
totals: store.getTotals(),
|
|
349
|
+
}),
|
|
350
|
+
isRunning: (details) => details.status === "running",
|
|
351
|
+
renderText: (details) => details.output.slice(0, 500) || (details.status === "running" ? `${spinnerFrame()} Running…` : "(no output)"),
|
|
352
|
+
});
|
|
353
|
+
progress.start();
|
|
354
|
+
|
|
355
|
+
// Detect queue contention: notify if another repl() is already executing
|
|
356
|
+
let queuedId: string | undefined;
|
|
357
|
+
if (sandboxManager.isExecuting) {
|
|
358
|
+
queuedId = emitter.emitSubcallCreated({
|
|
359
|
+
kind: "tool", parentId: undefined, label: "repl:queued",
|
|
360
|
+
args: "waiting for previous repl() to finish",
|
|
361
|
+
depth: 0,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
// Build interactive handlers (session-stable callbacks)
|
|
367
|
+
const interactive = createPiInteractiveDeps(ctx);
|
|
368
|
+
const interactiveHandlers = buildInteractiveHandlers({
|
|
369
|
+
onAskUserQuestion: config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
|
|
370
|
+
onTodo: interactive.onTodo,
|
|
371
|
+
onTodoRow: undefined,
|
|
372
|
+
emitter,
|
|
373
|
+
depth: 0,
|
|
374
|
+
parentId: undefined,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
await sandboxManager.getOrCreate({
|
|
378
|
+
...llmHandlers,
|
|
379
|
+
...rlmHandlers,
|
|
380
|
+
askUserQuestion: interactiveHandlers.askUserQuestion,
|
|
381
|
+
todo: interactiveHandlers.todo,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Detect queue contention AFTER sandbox init (initPromise settled, isExecuting now accurate)
|
|
385
|
+
if (!queuedId && sandboxManager.isExecuting) {
|
|
386
|
+
queuedId = emitter.emitSubcallCreated({
|
|
387
|
+
kind: "tool", parentId: undefined, label: "repl:queued",
|
|
388
|
+
args: "waiting for previous repl() to finish",
|
|
389
|
+
depth: 0,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const start = Date.now();
|
|
394
|
+
const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
|
|
395
|
+
// Wire per-invocation mutable state only after the serialized exec slot
|
|
396
|
+
// is active. Swapping earlier would let queued repl() calls overwrite
|
|
397
|
+
// emitter/limits for the currently running REPL execution.
|
|
398
|
+
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
|
|
399
|
+
});
|
|
400
|
+
const elapsed = Date.now() - start;
|
|
401
|
+
capturedStdout = result.stdout;
|
|
402
|
+
capturedStderr = result.stderr;
|
|
403
|
+
progressStatus = "done";
|
|
404
|
+
|
|
405
|
+
const totals = store.getTotals();
|
|
406
|
+
const subUsage: Usage = {
|
|
407
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: totals.tokens,
|
|
408
|
+
cost: { total: totals.costUsd, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
409
|
+
};
|
|
410
|
+
onUsage?.(subUsage, "sub");
|
|
411
|
+
|
|
412
|
+
if (queuedId) emitter.emitSubcallUpdated({ id: queuedId, status: "done" });
|
|
413
|
+
|
|
414
|
+
const details: ReplDetails = {
|
|
415
|
+
status: "done",
|
|
416
|
+
output: result.stdout,
|
|
417
|
+
stderr: result.stderr,
|
|
418
|
+
executionTimeMs: elapsed,
|
|
419
|
+
subcalls: store.getSubcalls(),
|
|
420
|
+
totals: store.getTotals(),
|
|
421
|
+
};
|
|
422
|
+
// Final progressive update
|
|
423
|
+
onUpdate?.({ content: [{ type: "text", text: result.stdout.slice(0, 500) || "(no output)" }], details });
|
|
424
|
+
return { content: [{ type: "text", text: result.stdout || result.answerContent || "(no output)" }], details };
|
|
425
|
+
} catch (e) {
|
|
426
|
+
progressStatus = "error";
|
|
427
|
+
const msg = errorMessage(e);
|
|
428
|
+
const details: ReplDetails = {
|
|
429
|
+
status: "error",
|
|
430
|
+
output: "",
|
|
431
|
+
stderr: msg,
|
|
432
|
+
executionTimeMs: 0,
|
|
433
|
+
subcalls: store.getSubcalls(),
|
|
434
|
+
totals: store.getTotals(),
|
|
435
|
+
};
|
|
436
|
+
onUpdate?.({ content: [{ type: "text", text: `REPL error: ${msg}` }], details });
|
|
437
|
+
return {
|
|
438
|
+
content: [{ type: "text", text: `REPL error: ${msg}` }],
|
|
439
|
+
details,
|
|
440
|
+
};
|
|
441
|
+
} finally {
|
|
442
|
+
progress.stop();
|
|
443
|
+
store.dispose();
|
|
444
|
+
emitter.shutdown();
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
renderCall(args, theme) {
|
|
449
|
+
const preview = args.code.length > 80 ? `${args.code.slice(0, 80)}...` : args.code;
|
|
450
|
+
return new Text(
|
|
451
|
+
theme.fg("toolTitle", theme.bold("repl ")) +
|
|
452
|
+
theme.fg("dim", preview.replace(/\n/g, " ")),
|
|
453
|
+
0, 0,
|
|
454
|
+
);
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
renderResult(result, { expanded }, theme) {
|
|
458
|
+
const details = result.details as ReplDetails | undefined;
|
|
459
|
+
if (!details) return new Text("(no output)", 0, 0);
|
|
460
|
+
|
|
461
|
+
if (expanded) {
|
|
462
|
+
return renderReplExpanded(details, theme);
|
|
463
|
+
}
|
|
464
|
+
return renderReplCollapsed(details, theme);
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Collapsed view ──
|
|
470
|
+
|
|
471
|
+
function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
|
|
472
|
+
const glyph = details.status === "running"
|
|
473
|
+
? headlineStatusGlyph("running", theme)
|
|
474
|
+
: details.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
475
|
+
|
|
476
|
+
const parts: string[] = [];
|
|
477
|
+
parts.push(formatCost(details.totals.costUsd));
|
|
478
|
+
if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
|
|
479
|
+
if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
|
|
480
|
+
const stats = parts.length > 0 ? ` ${theme.fg("dim", parts.join(" · "))}` : "";
|
|
481
|
+
|
|
482
|
+
const header = `${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`;
|
|
483
|
+
|
|
484
|
+
let body = "";
|
|
485
|
+
if (details.subcalls.length > 0) {
|
|
486
|
+
body = `\n${renderCollapsedSubcallTree(details.subcalls, theme)}`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const expandHint = details.status === "running" ? "" : `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
490
|
+
return new Text(`${header}${body}${expandHint}`, 0, 0);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── Expanded view ──
|
|
494
|
+
|
|
495
|
+
function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
|
|
496
|
+
const container = new Container();
|
|
497
|
+
|
|
498
|
+
// Header: status + stats
|
|
499
|
+
const glyph = details.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
500
|
+
const parts: string[] = [];
|
|
501
|
+
parts.push(formatCost(details.totals.costUsd));
|
|
502
|
+
if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
|
|
503
|
+
if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
|
|
504
|
+
const stats = parts.length > 0 ? ` · ${theme.fg("dim", parts.join(" · "))}` : "";
|
|
505
|
+
container.addChild(new Text(`${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`, 0, 0));
|
|
506
|
+
|
|
507
|
+
// Output
|
|
508
|
+
if (details.output) {
|
|
509
|
+
container.addChild(new Spacer(1));
|
|
510
|
+
const out = details.output.length > 2000 ? `${details.output.slice(0, 2000)}...` : details.output;
|
|
511
|
+
container.addChild(new Text(out, 0, 0));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Stderr
|
|
515
|
+
if (details.stderr) {
|
|
516
|
+
container.addChild(new Spacer(1));
|
|
517
|
+
container.addChild(new Text(theme.fg("error", details.stderr.slice(0, 500)), 0, 0));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Sub-call tree
|
|
521
|
+
if (details.subcalls.length > 0) {
|
|
522
|
+
container.addChild(new Spacer(1));
|
|
523
|
+
container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
|
|
524
|
+
container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return container;
|
|
528
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RlmEventAggregator — builds RlmDetails from RlmEmitter events.
|
|
3
|
+
*
|
|
4
|
+
* Attaches as a listener to an RlmEmitter and accumulates sub-call lifecycle
|
|
5
|
+
* events into a flat RlmSubcall[] array with O(1) running totals. Exposes
|
|
6
|
+
* getState(): RlmDetails for direct access (spinner loop, final return).
|
|
7
|
+
*
|
|
8
|
+
* Subcall storage and totals are delegated to SubcallStore. Root-level state
|
|
9
|
+
* (status, prompt, turns, answer, edits) is kept in the aggregator.
|
|
10
|
+
*
|
|
11
|
+
* Replaces RlmToolBridge's internal state accumulation. The emitter is pure
|
|
12
|
+
* dispatch; the aggregator is pure state. Separated for independent testing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
|
|
16
|
+
import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, EditsEvent, StatusEvent, RootPromptEvent } from "./rlm-events.ts";
|
|
17
|
+
import type { RlmDetails, RlmRunStatus } from "./rlm-details.ts";
|
|
18
|
+
import { EmitterListener } from "./emitter-listener.ts";
|
|
19
|
+
import { SubcallStore } from "./subcall-store.ts";
|
|
20
|
+
|
|
21
|
+
export class RlmEventAggregator extends EmitterListener {
|
|
22
|
+
private readonly store: SubcallStore;
|
|
23
|
+
|
|
24
|
+
// Root-level state
|
|
25
|
+
private rootStatus: RlmRunStatus = "running";
|
|
26
|
+
private rootPrompt = "";
|
|
27
|
+
private turnCurrent = 0;
|
|
28
|
+
private turnMax = 0;
|
|
29
|
+
private answer?: string;
|
|
30
|
+
private edits: RlmDetails["edits"] = [];
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
emitter: RlmEmitter,
|
|
34
|
+
private readonly onChange?: AgentToolUpdateCallback<RlmDetails>,
|
|
35
|
+
) {
|
|
36
|
+
super();
|
|
37
|
+
this.store = new SubcallStore(emitter, () => this.notify());
|
|
38
|
+
|
|
39
|
+
this.trackAll([
|
|
40
|
+
emitter.onTurn((e) => this.handleTurn(e)),
|
|
41
|
+
emitter.onRootUsage((e) => this.handleRootUsage(e)),
|
|
42
|
+
emitter.onAnswer((e) => this.handleAnswer(e)),
|
|
43
|
+
emitter.onEdits((e) => this.handleEdits(e)),
|
|
44
|
+
emitter.onStatus((e) => this.handleStatus(e)),
|
|
45
|
+
emitter.onRootPrompt((e) => this.handleRootPrompt(e)),
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Event handlers ──
|
|
50
|
+
|
|
51
|
+
private handleTurn(event: TurnEvent): void {
|
|
52
|
+
this.turnCurrent = event.current;
|
|
53
|
+
this.turnMax = event.max;
|
|
54
|
+
this.notify();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private handleRootUsage(event: RootUsageEvent): void {
|
|
58
|
+
this.store.addRootUsage(event.costUsd, event.tokens);
|
|
59
|
+
this.notify();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private handleAnswer(event: AnswerEvent): void {
|
|
63
|
+
this.answer = event.text;
|
|
64
|
+
this.notify();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private handleEdits(event: EditsEvent): void {
|
|
68
|
+
this.edits = event.edits;
|
|
69
|
+
this.notify();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private handleStatus(event: StatusEvent): void {
|
|
73
|
+
this.rootStatus = event.status;
|
|
74
|
+
this.notify();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private handleRootPrompt(event: RootPromptEvent): void {
|
|
78
|
+
this.rootPrompt = event.text;
|
|
79
|
+
// No notify — root prompt is set before listeners exist; no TUI re-render needed
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Read ──
|
|
83
|
+
|
|
84
|
+
/** Snapshot the current accumulated state. O(1). */
|
|
85
|
+
getState(): RlmDetails {
|
|
86
|
+
return {
|
|
87
|
+
status: this.rootStatus,
|
|
88
|
+
rootPrompt: this.rootPrompt,
|
|
89
|
+
turns: { current: this.turnCurrent, max: this.turnMax },
|
|
90
|
+
subcalls: this.store.getSubcalls(),
|
|
91
|
+
totals: this.store.getTotals(),
|
|
92
|
+
answer: this.answer,
|
|
93
|
+
edits: this.edits,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Lifecycle ──
|
|
98
|
+
|
|
99
|
+
/** Detach all emitter listeners. Call after the run completes. */
|
|
100
|
+
override dispose(): void {
|
|
101
|
+
this.store.dispose();
|
|
102
|
+
super.dispose();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Internal ──
|
|
106
|
+
|
|
107
|
+
private notify(): void {
|
|
108
|
+
if (!this.onChange) return;
|
|
109
|
+
const state = this.getState();
|
|
110
|
+
this.onChange({
|
|
111
|
+
content: [{ type: "text", text: state.answer ?? "(running...)" }],
|
|
112
|
+
details: state,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RlmDetails — the structured payload for the RLM tool's AgentToolResult<T>.
|
|
3
|
+
*
|
|
4
|
+
* Replaces AgentTree + SubcallObserver. The RlmToolBridge accumulates sub-call
|
|
5
|
+
* lifecycle events into a flat RlmSubcall[] array and calls onUpdate(partialResult)
|
|
6
|
+
* after every mutation, enabling Pi's built-in progressive TUI re-render.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
10
|
+
|
|
11
|
+
export type SubcallKind = "root" | "rlm" | "llm" | "batch" | "tool";
|
|
12
|
+
export type SubcallStatus = "running" | "done" | "error";
|
|
13
|
+
export type RlmRunStatus = "running" | "done" | "error" | "aborted";
|
|
14
|
+
|
|
15
|
+
export interface RlmSubcall {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
/** Parent subcall ID for recursive grouping (undefined = direct child of root). */
|
|
18
|
+
readonly parentId?: string;
|
|
19
|
+
/** Recursion depth (0 = root tool call). */
|
|
20
|
+
readonly depth: number;
|
|
21
|
+
readonly kind: SubcallKind;
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly model?: string;
|
|
24
|
+
readonly status: SubcallStatus;
|
|
25
|
+
readonly detail?: string;
|
|
26
|
+
readonly args?: string;
|
|
27
|
+
readonly resultPreview?: string;
|
|
28
|
+
readonly startedAt: number;
|
|
29
|
+
readonly endedAt?: number;
|
|
30
|
+
readonly costUsd: number;
|
|
31
|
+
readonly tokens: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RlmDetails {
|
|
35
|
+
readonly status: RlmRunStatus;
|
|
36
|
+
readonly rootPrompt: string;
|
|
37
|
+
readonly turns: { readonly current: number; readonly max: number };
|
|
38
|
+
readonly subcalls: readonly RlmSubcall[];
|
|
39
|
+
readonly totals: { readonly costUsd: number; readonly tokens: number };
|
|
40
|
+
readonly answer?: string;
|
|
41
|
+
readonly edits?: readonly ProposedEdit[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SubcallInit {
|
|
45
|
+
readonly parentId?: string;
|
|
46
|
+
readonly kind: SubcallKind;
|
|
47
|
+
readonly label: string;
|
|
48
|
+
readonly model?: string;
|
|
49
|
+
readonly detail?: string;
|
|
50
|
+
readonly args?: string;
|
|
51
|
+
/** Recursion depth. Required — all call sites pass this. */
|
|
52
|
+
readonly depth: number;
|
|
53
|
+
}
|