@hicaru/pi-rlm 0.2.1 → 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 +63 -17
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +6 -17
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +55 -335
- 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 +23 -12
- 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 -407
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +8 -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/{worker.py → py/worker.py} +76 -696
- package/src/sandbox/sandbox-manager.ts +13 -0
- package/src/sandbox/sandbox.ts +99 -193
- package/src/text/tokens.ts +29 -3
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +37 -159
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +0 -14
- package/src/tool/rlm-tool.ts +1 -12
- 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 +9 -5
- package/src/bridge/fallback-todo.ts +0 -148
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/pi-interactive.ts +0 -41
- 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/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
|
@@ -16,16 +16,15 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { Type } from "typebox";
|
|
19
|
-
import type {
|
|
20
|
-
import {
|
|
19
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
21
21
|
import type { Model, Usage, Api } from "@earendil-works/pi-ai";
|
|
22
22
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
24
23
|
import { buildLibraryHandler } from "../bridge/library.ts";
|
|
25
|
-
import {
|
|
24
|
+
import { libraryPrefixesIn } from "../context/library-context.ts";
|
|
26
25
|
import type { SubcallGates } from "../util/concurrency.ts";
|
|
27
26
|
import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
|
|
28
|
-
import type {
|
|
27
|
+
import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
|
|
29
28
|
import { SandboxManager } from "../sandbox/sandbox-manager.ts";
|
|
30
29
|
import type { SubcallOpts } from "../sandbox/sandbox.ts";
|
|
31
30
|
import { createSubcallHandlers, type Invocation } from "../bridge/subcall-handlers.ts";
|
|
@@ -39,26 +38,13 @@ import { createEngine } from "../core/engine.ts";
|
|
|
39
38
|
import { spinnerFrame } from "../ui/theme.ts";
|
|
40
39
|
import { previewText } from "../text/preview.ts";
|
|
41
40
|
import { errorMessage } from "../util/errors.ts";
|
|
42
|
-
import {
|
|
43
|
-
cardHeader,
|
|
44
|
-
cardStatsLine,
|
|
45
|
-
renderCollapsedCard,
|
|
46
|
-
renderExpandedSubcallTree,
|
|
47
|
-
} from "./subcall-render.ts";
|
|
48
41
|
import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
|
|
49
|
-
import {
|
|
42
|
+
import { buildReplResultText, collectReplWarnings } from "./repl-result.ts";
|
|
43
|
+
import { renderReplCollapsed, renderReplExpanded } from "./repl-render.ts";
|
|
50
44
|
import { attachTracer, trace, traceEnabled } from "../util/trace.ts";
|
|
51
45
|
|
|
52
|
-
/** Chars of code shown on the tool call line
|
|
46
|
+
/** Chars of code shown on the tool call line. */
|
|
53
47
|
const CALL_PREVIEW_CHARS = 80;
|
|
54
|
-
const EXPANDED_STDOUT_CHARS = 2_000;
|
|
55
|
-
const EXPANDED_STDERR_CHARS = 500;
|
|
56
|
-
|
|
57
|
-
// ── Parameter schema ──
|
|
58
|
-
|
|
59
|
-
export const ReplToolParams = Object.freeze(Type.Object({
|
|
60
|
-
code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
|
|
61
|
-
}));
|
|
62
48
|
|
|
63
49
|
/** Last non-empty line of a Python traceback — the `TypeError: …` line, not the frames. */
|
|
64
50
|
function lastLine(text: string): string {
|
|
@@ -70,67 +56,11 @@ function lastLine(text: string): string {
|
|
|
70
56
|
return "";
|
|
71
57
|
}
|
|
72
58
|
|
|
73
|
-
|
|
74
|
-
export interface ReplResultText {
|
|
75
|
-
readonly text: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
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.
|
|
89
|
-
*/
|
|
90
|
-
export function buildReplResultText(
|
|
91
|
-
stdout: string,
|
|
92
|
-
finalAnswer: string | undefined,
|
|
93
|
-
subcalls: readonly RlmSubcall[],
|
|
94
|
-
backgroundPending = 0,
|
|
95
|
-
varNames: readonly string[] = [],
|
|
96
|
-
): ReplResultText {
|
|
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
|
-
: "";
|
|
103
|
-
const rawText = answerSubmitted
|
|
104
|
-
? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
|
|
105
|
-
: stdout || `(no output)${varsHint}`;
|
|
106
|
-
// Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
|
|
107
|
-
const cappedText = capReplResultText(rawText) ?? rawText;
|
|
108
|
-
const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
|
|
109
|
-
const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
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 };
|
|
118
|
-
}
|
|
59
|
+
// ── Parameter schema ──
|
|
119
60
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
let total = 0;
|
|
124
|
-
for (let i = 0; i < subcalls.length; i++) {
|
|
125
|
-
const call = subcalls[i];
|
|
126
|
-
if (call.status !== "error") continue;
|
|
127
|
-
// A batch subcall stands for many prompts; a single call stands for one.
|
|
128
|
-
failed += call.failedCount ?? 1;
|
|
129
|
-
total += call.totalCount ?? 1;
|
|
130
|
-
}
|
|
131
|
-
if (failed === 0) return undefined;
|
|
132
|
-
return Object.freeze([`${failed}/${total} sub-call(s) failed — results may be incomplete`]);
|
|
133
|
-
}
|
|
61
|
+
export const ReplToolParams = Object.freeze(Type.Object({
|
|
62
|
+
code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
|
|
63
|
+
}));
|
|
134
64
|
|
|
135
65
|
// ── Mutable bridge state (handler indirection) ──
|
|
136
66
|
|
|
@@ -141,21 +71,18 @@ export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly s
|
|
|
141
71
|
* calls rather than rebuilding handlers (which would lose REPL variable state). Handlers
|
|
142
72
|
* capture the Invocation synchronously at interrupt entry and never re-read it — with
|
|
143
73
|
* 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
|
|
74
|
+
* turn happened to be current when it settled.
|
|
145
75
|
*
|
|
146
76
|
* Detached work resolves to the session-scoped background Invocation instead, whose emitter
|
|
147
77
|
* and LimitGuard are not torn down at the end of a turn.
|
|
148
78
|
*/
|
|
149
79
|
class NativeBridgeState {
|
|
150
80
|
private current: Invocation | null = null;
|
|
151
|
-
/** Interactive callbacks for the turn in progress; child engines inherit them. */
|
|
152
|
-
interactive: InteractiveDeps | null = null;
|
|
153
81
|
|
|
154
82
|
constructor(private readonly background: BackgroundTasks) {}
|
|
155
83
|
|
|
156
|
-
swap(inv: Invocation
|
|
84
|
+
swap(inv: Invocation): void {
|
|
157
85
|
this.current = Object.freeze({ ...inv });
|
|
158
|
-
this.interactive = interactive;
|
|
159
86
|
}
|
|
160
87
|
|
|
161
88
|
/** Detached ⇒ session registry; otherwise the turn that is currently executing. */
|
|
@@ -175,9 +102,9 @@ class NativeBridgeState {
|
|
|
175
102
|
export interface ReplToolDeps {
|
|
176
103
|
readonly sandboxManager: SandboxManager;
|
|
177
104
|
readonly model: Model<Api>;
|
|
178
|
-
readonly
|
|
105
|
+
readonly llmModel: Model<Api>;
|
|
179
106
|
readonly getModel?: () => Model<Api> | undefined;
|
|
180
|
-
readonly
|
|
107
|
+
readonly getLlmModel?: () => Model<Api> | undefined;
|
|
181
108
|
readonly registry: ModelRegistry;
|
|
182
109
|
/** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
|
|
183
110
|
readonly getConfig: () => RlmConfig;
|
|
@@ -193,13 +120,13 @@ export interface ReplToolDeps {
|
|
|
193
120
|
}
|
|
194
121
|
|
|
195
122
|
export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
|
|
196
|
-
const { sandboxManager,
|
|
123
|
+
const { sandboxManager, llmModel, registry, getConfig, signal, onUsage, background } = deps;
|
|
197
124
|
const bridgeState = new NativeBridgeState(background);
|
|
198
125
|
|
|
199
126
|
// Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
|
|
200
127
|
let sessionCwd = process.cwd();
|
|
201
128
|
|
|
202
|
-
const
|
|
129
|
+
const getLlmModel = (): Model<Api> => deps.getLlmModel?.() ?? llmModel;
|
|
203
130
|
const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
|
|
204
131
|
|
|
205
132
|
// Each rlm_query spawns a child RLM with its own sandbox and turn loop, not a flat
|
|
@@ -207,7 +134,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
207
134
|
// progress and cost deltas land on the emitter the parent invocation is using.
|
|
208
135
|
const runChild = (input: RlmInput, inv: Invocation): Promise<RlmResult> => createEngine({
|
|
209
136
|
model: getModel(),
|
|
210
|
-
|
|
137
|
+
llmModel: getLlmModel(),
|
|
211
138
|
registry,
|
|
212
139
|
config: getConfig(),
|
|
213
140
|
signal,
|
|
@@ -218,8 +145,6 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
218
145
|
// the child's own root turns — so fold both roles into "sub" rather than casting.
|
|
219
146
|
onUsage: onUsage === undefined ? undefined : (usage: Usage) => onUsage(usage, "sub"),
|
|
220
147
|
limits: limitsFromConfig(getConfig()),
|
|
221
|
-
onTodo: bridgeState.interactive?.onTodo,
|
|
222
|
-
onAskUserQuestion: bridgeState.interactive?.onAskUserQuestion,
|
|
223
148
|
})(input);
|
|
224
149
|
|
|
225
150
|
// Built once: the same closures stay correct across repl() calls because everything
|
|
@@ -228,12 +153,16 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
228
153
|
resolve: (opts) => bridgeState.resolve(opts),
|
|
229
154
|
gates: deps.gates,
|
|
230
155
|
registry,
|
|
231
|
-
|
|
156
|
+
getLlmModel,
|
|
232
157
|
getModel,
|
|
233
158
|
getConfig,
|
|
234
159
|
signal,
|
|
235
160
|
onUsage,
|
|
236
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,
|
|
237
166
|
trackDetached: (task) => background.track(task),
|
|
238
167
|
});
|
|
239
168
|
|
|
@@ -241,12 +170,22 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
241
170
|
? buildLibraryHandler({
|
|
242
171
|
getCwd: () => sessionCwd,
|
|
243
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,
|
|
244
176
|
parentId: undefined,
|
|
245
177
|
signal,
|
|
246
|
-
|
|
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); },
|
|
247
181
|
})
|
|
248
182
|
: undefined;
|
|
249
|
-
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
|
+
}
|
|
250
189
|
|
|
251
190
|
return {
|
|
252
191
|
name: "repl",
|
|
@@ -258,7 +197,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
258
197
|
"semantic reading to map_files / llm_query / llm_query_batched / llm_query_chunked " +
|
|
259
198
|
"(rlm_query for iterative sub-tasks) — stdout returned to you is hard-capped at 4K chars, " +
|
|
260
199
|
"so printing file bodies is useless. Variables, imports, and the `answers`/`plan` memo " +
|
|
261
|
-
"persist across calls. Also supports
|
|
200
|
+
"persist across calls. Also supports load_library.",
|
|
262
201
|
promptSnippet:
|
|
263
202
|
"repl: run Python in a persistent sandbox holding the whole repository in `context`; " +
|
|
264
203
|
"search/grep_context/outline to locate, map_files/llm_query* to read.",
|
|
@@ -327,24 +266,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
327
266
|
}
|
|
328
267
|
|
|
329
268
|
try {
|
|
330
|
-
// Build interactive handlers (session-stable callbacks)
|
|
331
|
-
const interactive = createPiInteractiveDeps(ctx);
|
|
332
|
-
const interactiveHandlers = buildInteractiveHandlers({
|
|
333
|
-
onAskUserQuestion: getConfig().askUserQuestion ? interactive.onAskUserQuestion : undefined,
|
|
334
|
-
onTodo: interactive.onTodo,
|
|
335
|
-
onTodoRow: undefined,
|
|
336
|
-
emitter,
|
|
337
|
-
depth: 0,
|
|
338
|
-
parentId: undefined,
|
|
339
|
-
});
|
|
340
|
-
|
|
341
269
|
sessionCwd = ctx.cwd ?? process.cwd();
|
|
342
270
|
|
|
343
271
|
await deps.ensureContext?.();
|
|
344
272
|
await sandboxManager.getOrCreate({
|
|
345
273
|
...subcallHandlers,
|
|
346
|
-
askUserQuestion: interactiveHandlers.askUserQuestion,
|
|
347
|
-
todo: interactiveHandlers.todo,
|
|
348
274
|
...(libraryBundle?.handlers ?? {}),
|
|
349
275
|
});
|
|
350
276
|
|
|
@@ -366,7 +292,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
366
292
|
// Wire per-invocation mutable state only after the serialized exec slot
|
|
367
293
|
// is active. Swapping earlier would let queued repl() calls overwrite
|
|
368
294
|
// emitter/limits for the currently running REPL execution.
|
|
369
|
-
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits }
|
|
295
|
+
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
|
|
370
296
|
}, execSignal);
|
|
371
297
|
const elapsed = Date.now() - start;
|
|
372
298
|
capturedStdout = result.stdout;
|
|
@@ -480,51 +406,3 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
480
406
|
},
|
|
481
407
|
};
|
|
482
408
|
}
|
|
483
|
-
|
|
484
|
-
// ── Collapsed view ──
|
|
485
|
-
|
|
486
|
-
function replStats(details: ReplDetails, theme: Theme): string {
|
|
487
|
-
const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
|
|
488
|
-
return cardStatsLine(details.totals, theme, elapsed, details.backgroundPending);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
|
|
492
|
-
return renderCollapsedCard("REPL", details.status, replStats(details, theme), details.subcalls, theme);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// ── Expanded view ──
|
|
496
|
-
|
|
497
|
-
function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
|
|
498
|
-
const container = new Container();
|
|
499
|
-
|
|
500
|
-
container.addChild(new Text(cardHeader("REPL", details.status, replStats(details, theme), theme), 0, 0));
|
|
501
|
-
|
|
502
|
-
// Output
|
|
503
|
-
if (details.output) {
|
|
504
|
-
container.addChild(new Spacer(1));
|
|
505
|
-
const out = details.output.length > EXPANDED_STDOUT_CHARS
|
|
506
|
-
? `${details.output.slice(0, EXPANDED_STDOUT_CHARS)}…`
|
|
507
|
-
: details.output;
|
|
508
|
-
container.addChild(new Text(out, 0, 0));
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (details.warnings && details.warnings.length > 0) {
|
|
512
|
-
container.addChild(new Spacer(1));
|
|
513
|
-
container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Stderr
|
|
517
|
-
if (details.stderr) {
|
|
518
|
-
container.addChild(new Spacer(1));
|
|
519
|
-
container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Sub-call tree
|
|
523
|
-
if (details.subcalls.length > 0) {
|
|
524
|
-
container.addChild(new Spacer(1));
|
|
525
|
-
container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
|
|
526
|
-
container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return container;
|
|
530
|
-
}
|
|
@@ -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
|
|
package/src/tool/rlm-events.ts
CHANGED
|
@@ -45,10 +45,6 @@ export interface SubcallUpdatedEvent {
|
|
|
45
45
|
readonly totalCount?: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
export interface WarningsEvent {
|
|
49
|
-
readonly warnings: readonly string[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
48
|
export interface TurnEvent {
|
|
53
49
|
readonly current: number;
|
|
54
50
|
readonly max: number;
|
|
@@ -121,11 +117,6 @@ export class RlmEmitter {
|
|
|
121
117
|
this.ee.emit("answer", { text } satisfies AnswerEvent);
|
|
122
118
|
}
|
|
123
119
|
|
|
124
|
-
/** Set advisory warnings (root-only; never a failure). */
|
|
125
|
-
emitWarnings(warnings: readonly string[]): void {
|
|
126
|
-
this.ee.emit("warnings", { warnings } satisfies WarningsEvent);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
120
|
/** Set the root run status (done/error/aborted). */
|
|
130
121
|
emitStatus(status: RlmRunStatus): void {
|
|
131
122
|
this.ee.emit("status", { status } satisfies StatusEvent);
|
|
@@ -163,11 +154,6 @@ export class RlmEmitter {
|
|
|
163
154
|
return () => { this.ee.off("answer", handler); };
|
|
164
155
|
}
|
|
165
156
|
|
|
166
|
-
onWarnings(handler: (event: WarningsEvent) => void): () => void {
|
|
167
|
-
this.ee.on("warnings", handler);
|
|
168
|
-
return () => { this.ee.off("warnings", handler); };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
157
|
onStatus(handler: (event: StatusEvent) => void): () => void {
|
|
172
158
|
this.ee.on("status", handler);
|
|
173
159
|
return () => { this.ee.off("status", handler); };
|
package/src/tool/rlm-tool.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import { type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
|
|
10
10
|
import { Type } from "typebox";
|
|
11
|
-
import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
|
|
12
11
|
import type { RlmController, StartInput } from "../mode/rlm-mode.ts";
|
|
13
12
|
import { spinnerFrame } from "../ui/theme.ts";
|
|
14
13
|
import { markdownTheme } from "../ui/theme-adapter.ts";
|
|
@@ -82,15 +81,10 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
|
|
|
82
81
|
|
|
83
82
|
try {
|
|
84
83
|
const input: StartInput = {
|
|
85
|
-
kind: "fresh",
|
|
86
84
|
rootPrompt: params.prompt,
|
|
87
85
|
context: params.context ?? undefined,
|
|
88
86
|
};
|
|
89
|
-
const
|
|
90
|
-
const { done } = controller.start(ctx, input, emitter, {
|
|
91
|
-
onAskUserQuestion: controller.config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
|
|
92
|
-
onTodo: controller.config.todo ? interactive.onTodo : undefined,
|
|
93
|
-
});
|
|
87
|
+
const { done } = controller.start(ctx, input, emitter);
|
|
94
88
|
const result = await done;
|
|
95
89
|
|
|
96
90
|
emitter.emitAnswer(result.answer);
|
|
@@ -152,11 +146,6 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
|
|
|
152
146
|
container.addChild(new Markdown(details.answer, 0, 0, markdownTheme(theme)));
|
|
153
147
|
}
|
|
154
148
|
|
|
155
|
-
if (details.warnings && details.warnings.length > 0) {
|
|
156
|
-
container.addChild(new Spacer(1));
|
|
157
|
-
container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
149
|
return container;
|
|
161
150
|
}
|
|
162
151
|
|
package/src/ui/config-panel.ts
CHANGED
|
@@ -9,21 +9,17 @@ const CHOICES = Object.freeze({
|
|
|
9
9
|
maxDepth: Object.freeze(["1", "2", "3", "4"]),
|
|
10
10
|
maxIterations: Object.freeze(["10", "20", "30", "50"]),
|
|
11
11
|
execTimeoutS: Object.freeze(["30", "60", "120", "300"]),
|
|
12
|
-
maxConcurrentSubcalls: Object.freeze(["2", "4", "8", "16"]),
|
|
13
|
-
|
|
12
|
+
maxConcurrentSubcalls: Object.freeze(["2", "4", "8", "16", "32"]),
|
|
13
|
+
maxConcurrentChildren: Object.freeze(["1", "2", "3", "4", "6", "8"]),
|
|
14
14
|
maxTimeoutMs: Object.freeze(["none", "60", "120", "300"]),
|
|
15
15
|
maxTokens: Object.freeze(["none", "10000", "50000", "100000"]),
|
|
16
16
|
maxErrors: Object.freeze(["3", "5", "10", "none"]),
|
|
17
17
|
orchestrator: Object.freeze(["on", "off"]),
|
|
18
|
-
pipeline: Object.freeze(["on", "off"]),
|
|
19
|
-
maxBackwardJumps: Object.freeze(["0", "1", "2", "3"]),
|
|
20
18
|
compaction: Object.freeze(["on", "off"]),
|
|
21
19
|
compactionThresholdPct: Object.freeze(["50", "65", "80", "90"]),
|
|
22
20
|
rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
|
|
23
21
|
sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
|
|
24
22
|
requestTimeoutMs: Object.freeze(["2", "5", "10", "20"]),
|
|
25
|
-
askUserQuestion: Object.freeze(["on", "off"]),
|
|
26
|
-
todo: Object.freeze(["on", "off"]),
|
|
27
23
|
libraryLoader: Object.freeze(["on", "off"]),
|
|
28
24
|
});
|
|
29
25
|
|
|
@@ -43,20 +39,16 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
|
|
|
43
39
|
item("maxIterations", "Max iterations", String(config.maxIterations), CHOICES.maxIterations, "Maximum root REPL turns before RLM asks the model for a final answer."),
|
|
44
40
|
item("execTimeoutS", "REPL block timeout (s)", String(config.execTimeoutS), CHOICES.execTimeoutS, "Wall-clock limit for one model-authored Python REPL block."),
|
|
45
41
|
item("maxConcurrentSubcalls", "Max concurrent sub-calls", String(config.maxConcurrentSubcalls), CHOICES.maxConcurrentSubcalls, "Concurrency pool size for llm_query_batched and rlm_query_batched."),
|
|
46
|
-
item("
|
|
42
|
+
item("maxConcurrentChildren", "Max concurrent children", String(config.maxConcurrentChildren), CHOICES.maxConcurrentChildren, "Concurrent rlm_query child engines per depth. Each is a Python process holding its own copy of the inherited context."),
|
|
47
43
|
item("maxTimeoutMs", "Wall-clock ceiling (min)", config.maxTimeoutMs != null ? String(Math.round(config.maxTimeoutMs / 60_000)) : "none", CHOICES.maxTimeoutMs, "Total runtime cap for the whole recursive tree; none disables the cap."),
|
|
48
44
|
item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
|
|
49
45
|
item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
|
|
50
46
|
item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
|
|
51
|
-
item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable artifact-gated phases: clarify→research→blueprint→validate (read-only plan pipeline; clarify needs Ask user on)."),
|
|
52
|
-
item("maxBackwardJumps", "Max validate→blueprint loops", String(config.maxBackwardJumps), CHOICES.maxBackwardJumps, "Bounded corrective re-entries when validation reports blockers_count > 0."),
|
|
53
47
|
item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
|
|
54
48
|
item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "Compact once estimated history tokens reach this share of the root model's context window."),
|
|
55
49
|
item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
|
|
56
50
|
item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
|
|
57
51
|
item("requestTimeoutMs", "Sandbox request timeout (min)", String(Math.round(config.requestTimeoutMs / 60_000)), CHOICES.requestTimeoutMs, "Parent-side watchdog per sandbox request; on breach the Python worker is killed."),
|
|
58
|
-
item("askUserQuestion", "[Interactive] Ask user", config.askUserQuestion ? "on" : "off", CHOICES.askUserQuestion, "Allow root REPL code to present structured ask_user_question dialogs."),
|
|
59
|
-
item("todo", "[Interactive] Todo", config.todo ? "on" : "off", CHOICES.todo, "Allow REPL code to manage a visible todo task list."),
|
|
60
52
|
item("libraryLoader", "Library loader", config.libraryLoader ? "on" : "off", CHOICES.libraryLoader,
|
|
61
53
|
"Allow load_library() to pull an external dir, file, or git repo into the shared context list."),
|
|
62
54
|
item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
|
|
@@ -101,21 +93,17 @@ export function applySetting(config: RlmConfig, id: string, value: string): RlmC
|
|
|
101
93
|
case "maxIterations": return Object.freeze({ ...config, maxIterations: Number(value) });
|
|
102
94
|
case "execTimeoutS": return Object.freeze({ ...config, execTimeoutS: Number(value) });
|
|
103
95
|
case "maxConcurrentSubcalls": return Object.freeze({ ...config, maxConcurrentSubcalls: Number(value) });
|
|
104
|
-
case "
|
|
96
|
+
case "maxConcurrentChildren": return Object.freeze({ ...config, maxConcurrentChildren: Number(value) });
|
|
105
97
|
case "maxTimeoutMs": return Object.freeze({ ...config, maxTimeoutMs: optionalNumber(value, 60_000) });
|
|
106
98
|
case "maxTokens": return Object.freeze({ ...config, maxTokens: optionalNumber(value) });
|
|
107
99
|
case "maxErrors": return Object.freeze({ ...config, maxErrors: optionalNumber(value) });
|
|
108
100
|
case "orchestrator": return Object.freeze({ ...config, orchestrator: value === "on" });
|
|
109
|
-
case "pipeline": return Object.freeze({ ...config, pipeline: value === "on" });
|
|
110
|
-
case "maxBackwardJumps": return Object.freeze({ ...config, maxBackwardJumps: Number(value) });
|
|
111
101
|
case "compaction": return Object.freeze({ ...config, compaction: value === "on" });
|
|
112
102
|
case "compactionThresholdPct": return Object.freeze({ ...config, compactionThresholdPct: Number(value) / 100 });
|
|
113
103
|
case "rootSamplingMaxTokens":
|
|
114
104
|
return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }) });
|
|
115
105
|
case "sandboxInitTimeoutMs": return Object.freeze({ ...config, sandboxInitTimeoutMs: Number(value) });
|
|
116
106
|
case "requestTimeoutMs": return Object.freeze({ ...config, requestTimeoutMs: Number(value) * 60_000 });
|
|
117
|
-
case "askUserQuestion": return Object.freeze({ ...config, askUserQuestion: value === "on" });
|
|
118
|
-
case "todo": return Object.freeze({ ...config, todo: value === "on" });
|
|
119
107
|
case "libraryLoader": return Object.freeze({ ...config, libraryLoader: value === "on" });
|
|
120
108
|
default: return config;
|
|
121
109
|
}
|
package/src/ui/intro.ts
CHANGED
|
@@ -11,9 +11,8 @@ export const RLM_GUIDE = `# RLM mode
|
|
|
11
11
|
## Commands
|
|
12
12
|
|
|
13
13
|
- \`/rlm\` — toggle RLM mode (shortcut: Ctrl+Shift+R). Turning it OFF also stops a running query.
|
|
14
|
-
- \`/rlm-config\` — choose models, reasoning, and
|
|
14
|
+
- \`/rlm-config\` — choose models, reasoning, and run limits
|
|
15
15
|
- \`/rlm-stop\` — cancel the current run but stay in RLM mode (use /rlm or Ctrl+Shift+R to leave)
|
|
16
|
-
- \`/rlm-help\` — show this guide again
|
|
17
16
|
|
|
18
17
|
When RLM mode is ON, \`read\`/\`grep\` are disabled and the agent reads the repository through the
|
|
19
18
|
\`repl\` tool, delegating bulk analysis to sub-LLMs. The footer/status line shows the current state.`;
|
package/src/ui/model-picker.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Model picker TUI — choose a model and, when supported, a thinking level. */
|
|
2
2
|
|
|
3
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
6
|
import { Container, type Component, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
import { formatCost } from "./theme.ts";
|
|
8
|
+
import { compareLlm } from "../mode/llm-model.ts";
|
|
8
9
|
|
|
9
10
|
export interface ModelSelection {
|
|
10
11
|
readonly model: Model<Api>;
|
|
@@ -16,15 +17,38 @@ type SelectableThinkingLevel = (typeof LEVELS)[number];
|
|
|
16
17
|
|
|
17
18
|
const CHEAPEST_VALUE = "__rlm_cheapest__";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Models Pi itself would offer for this session, cheapest-first.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors the built-in model switcher:
|
|
24
|
+
* - if the session has scoped models (`--models` / enabledModels) → those only
|
|
25
|
+
* - else → `getAvailable()` (providers with configured auth)
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT `getAll()`: the full catalog dumps every provider's catalog entry and is
|
|
28
|
+
* not what the user sees in Pi natively. See Pi extension docs on `ctx.scopedModels`.
|
|
29
|
+
*/
|
|
30
|
+
export function pickableModels(
|
|
31
|
+
registry: ModelRegistry,
|
|
32
|
+
scoped?: readonly { readonly model: Model<Api> }[],
|
|
33
|
+
): readonly Model<Api>[] {
|
|
34
|
+
const source = scoped !== undefined && scoped.length > 0
|
|
35
|
+
? scoped.map((s) => s.model)
|
|
36
|
+
: registry.getAvailable();
|
|
37
|
+
return [...source].sort(compareLlm);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function items(models: readonly Model<Api>[], includeCheapest: boolean): SelectItem[] {
|
|
41
|
+
const modelItems = models.map((m) => {
|
|
42
|
+
const price = `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok`;
|
|
43
|
+
return {
|
|
44
|
+
value: `${m.provider}/${m.id}`,
|
|
45
|
+
label: `${m.provider}/${m.id}`,
|
|
46
|
+
description: `${price}${m.reasoning ? " · reasoning" : ""}`,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
25
49
|
if (!includeCheapest) return modelItems;
|
|
26
50
|
return [
|
|
27
|
-
{ value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest
|
|
51
|
+
{ value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest model with a configured key" },
|
|
28
52
|
...modelItems,
|
|
29
53
|
];
|
|
30
54
|
}
|
|
@@ -79,12 +103,12 @@ async function selectThinkingLevel(
|
|
|
79
103
|
export async function selectModel(
|
|
80
104
|
ctx: ExtensionContext,
|
|
81
105
|
title: string,
|
|
82
|
-
models: Model<Api>[],
|
|
106
|
+
models: readonly Model<Api>[],
|
|
83
107
|
current?: Model<Api>,
|
|
84
108
|
currentThinking?: ThinkingLevel,
|
|
85
109
|
): Promise<ModelSelection | null | undefined> {
|
|
86
110
|
if (models.length === 0) {
|
|
87
|
-
ctx.ui.notify("RLM: no models
|
|
111
|
+
ctx.ui.notify("RLM: no models available (add a provider key in Pi, or widen --models / enabledModels)", "warning");
|
|
88
112
|
return undefined;
|
|
89
113
|
}
|
|
90
114
|
if (ctx.mode !== "tui") {
|
package/src/ui/status.ts
CHANGED
|
@@ -12,18 +12,14 @@ export function modelLabel(model: Model<Api> | undefined, fallback: string): str
|
|
|
12
12
|
|
|
13
13
|
export function formatRlmStateLine(controller: RlmController, contextUsage?: ContextUsage): string {
|
|
14
14
|
if (!controller.enabled) return "○ RLM OFF";
|
|
15
|
-
const
|
|
16
|
-
const
|
|
15
|
+
const llm = modelLabel(controller.llmModel, controller.savedLlmRef ?? "cheapest");
|
|
16
|
+
const llmSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
|
|
17
17
|
// `percent` is null right after a compaction, before the next assistant response reports usage.
|
|
18
18
|
const percent = contextUsage?.percent;
|
|
19
19
|
const ctxSuffix = percent === null || percent === undefined ? "" : ` · ctx ${Math.round(percent)}%`;
|
|
20
|
-
return `● RLM ON ·
|
|
20
|
+
return `● RLM ON · llm=${llm}${llmSuffix}${ctxSuffix}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function setRlmModeStatus(ui: ExtensionUIContext, controller: RlmController, contextUsage?: ContextUsage): void {
|
|
24
24
|
ui.setStatus(KEY, formatRlmStateLine(controller, contextUsage));
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
export function clearRlmStatus(ui: ExtensionUIContext): void {
|
|
28
|
-
ui.setStatus(KEY, undefined);
|
|
29
|
-
}
|