@hicaru/pi-rlm 0.3.8 → 0.3.9
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 +2 -2
- package/package.json +1 -1
- package/src/bridge/handlers/emitting.ts +5 -23
- package/src/bridge/handlers/index.ts +1 -1
- package/src/bridge/handlers/llm-query.ts +21 -22
- package/src/bridge/handlers/rlm-query.ts +9 -31
- package/src/commands/pins.ts +51 -0
- package/src/commands/rlm-config.ts +4 -88
- package/src/commands/rlm-llm.ts +59 -0
- package/src/commands/rlm-rlm.ts +58 -0
- package/src/commands/rlm.ts +2 -2
- package/src/config/settings.ts +13 -3
- package/src/core/engine.ts +16 -2
- package/src/core/iteration.ts +5 -0
- package/src/core/ledger.ts +14 -122
- package/src/core/memory.ts +12 -0
- package/src/index.ts +47 -3
- package/src/mode/rlm-mode.ts +11 -1
- package/src/prompts/glossary.ts +11 -3
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/tool/repl-render.ts +4 -10
- package/src/tool/repl-tool.ts +30 -17
- package/src/tool/rlm-aggregator.ts +16 -3
- package/src/tool/rlm-details.ts +7 -0
- package/src/tool/rlm-events.ts +17 -1
- package/src/tool/rlm-tool.ts +25 -14
- package/src/tool/subcall-render.ts +14 -129
- package/src/tool/subcall-store.ts +11 -1
- package/src/ui/intro.ts +13 -4
- package/src/ui/modal/agent-modal.ts +104 -0
- package/src/ui/modal/modal-view.ts +132 -0
- package/src/ui/modal/timeline-store.ts +85 -0
- package/src/ui/model-picker/drilldown.ts +173 -0
- package/src/ui/model-picker/grouping.ts +81 -0
- package/src/ui/model-picker/levels.ts +63 -0
- package/src/ui/model-picker.ts +7 -197
- package/src/ui/panel/run-registry.ts +135 -0
- package/src/ui/panel/tree-panel.ts +46 -0
- package/src/ui/status.ts +26 -13
- package/src/ui/theme.ts +0 -4
- package/src/ui/tree/tree-model.ts +221 -0
- package/src/ui/tree/tree-rows.ts +73 -0
- package/src/ui/tree/tree-widget.ts +186 -0
package/README.md
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
**The ONLY Recursive Language Model plugin for Pi.** No new agent to learn, no
|
|
20
20
|
separate CLI, no YAML workflows — just `/rlm` and your existing Pi session becomes a
|
|
21
|
-
recursive orchestration engine
|
|
22
|
-
|
|
21
|
+
recursive orchestration engine — your best model orchestrates, cheap worker models
|
|
22
|
+
do the reading.
|
|
23
23
|
|
|
24
24
|
> **One install. One toggle. Infinite context.**
|
|
25
25
|
|
package/package.json
CHANGED
|
@@ -42,6 +42,8 @@ export async function emitting<T>(
|
|
|
42
42
|
args: opts.args,
|
|
43
43
|
depth: inv.depth,
|
|
44
44
|
});
|
|
45
|
+
// Leaf nodes spend their whole lifetime waiting on the model — say so from birth.
|
|
46
|
+
inv.emitter.emitSubcallUpdated({ id, phase: "waiting" });
|
|
45
47
|
|
|
46
48
|
let costUsd = 0;
|
|
47
49
|
let tokens = 0;
|
|
@@ -78,27 +80,7 @@ export async function emitting<T>(
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
/** Summarize a
|
|
82
|
-
export function
|
|
83
|
-
|
|
84
|
-
let firstError: string | undefined;
|
|
85
|
-
for (const s of out) {
|
|
86
|
-
if (isErrorText(s)) {
|
|
87
|
-
failed += 1;
|
|
88
|
-
firstError ??= s;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const first = previewText(out[0] ?? "");
|
|
92
|
-
const error =
|
|
93
|
-
failed === 0
|
|
94
|
-
? undefined
|
|
95
|
-
: failed === out.length
|
|
96
|
-
? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
|
|
97
|
-
: `${failed}/${out.length} sub-calls failed`;
|
|
98
|
-
return {
|
|
99
|
-
preview: out.length > 1 ? `${first} (+${out.length - 1} more)` : first,
|
|
100
|
-
error: error ?? firstError,
|
|
101
|
-
failed,
|
|
102
|
-
total: out.length,
|
|
103
|
-
};
|
|
83
|
+
/** Summarize a single leaf answer for the emitter — shared by llm_query and every llm_batch item. */
|
|
84
|
+
export function summarizeLeaf(out: string): EmitSummary {
|
|
85
|
+
return { preview: previewText(out), error: isErrorText(out) ? out : undefined };
|
|
104
86
|
}
|
|
@@ -43,6 +43,6 @@ export type {
|
|
|
43
43
|
} from "./types.ts";
|
|
44
44
|
|
|
45
45
|
export { limitsFromRemaining } from "./types.ts";
|
|
46
|
-
export {
|
|
46
|
+
export { summarizeLeaf } from "./emitting.ts";
|
|
47
47
|
export { createTaskRegistry, SPAWN_HINT } from "./task-registry.ts";
|
|
48
48
|
export type { TaskRegistry, SpawnDeps, AwaitDeps } from "./task-registry.ts";
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
6
6
|
import { modelRef } from "../../config/settings.ts";
|
|
7
7
|
import { complete1, type Complete1Deps } from "./completion.ts";
|
|
8
|
-
import { emitting,
|
|
9
|
-
import { formatError,
|
|
8
|
+
import { emitting, summarizeLeaf } from "./emitting.ts";
|
|
9
|
+
import { formatError, errorMessage } from "../../util/errors.ts";
|
|
10
10
|
import { previewText } from "../../text/preview.ts";
|
|
11
11
|
import type { SpawnResult, SubcallHandlerDeps } from "./types.ts";
|
|
12
12
|
import type { SubcallOpts } from "../../sandbox/interrupts.ts";
|
|
@@ -107,10 +107,7 @@ export function createLlmQueryHandler(
|
|
|
107
107
|
model: displayModel(deps),
|
|
108
108
|
},
|
|
109
109
|
(track: (u: Usage) => void) => complete1(inv, prompt, track, cdeps),
|
|
110
|
-
|
|
111
|
-
preview: previewText(out),
|
|
112
|
-
error: isErrorText(out) ? out : undefined,
|
|
113
|
-
}),
|
|
110
|
+
summarizeLeaf,
|
|
114
111
|
);
|
|
115
112
|
// v5 TaskLedger for leaves: identical prompts coalesce onto one completion (key has no
|
|
116
113
|
// context — a leaf's entire world is the prompt text itself).
|
|
@@ -153,21 +150,23 @@ export function createLlmBatchHandler(
|
|
|
153
150
|
sd,
|
|
154
151
|
"llm_batch",
|
|
155
152
|
prompts.length,
|
|
153
|
+
// One visible node per prompt — no collapsed "×N" row, no hidden failures: each item
|
|
154
|
+
// reports its own status/tokens/error (UI parity with pi: every concurrent call renders).
|
|
156
155
|
() =>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
156
|
+
Promise.all(
|
|
157
|
+
prompts.map((p) =>
|
|
158
|
+
emitting(
|
|
159
|
+
inv,
|
|
160
|
+
{
|
|
161
|
+
kind: "llm",
|
|
162
|
+
label: "llm_query",
|
|
163
|
+
args: `prompt: ${previewText(p)}`,
|
|
164
|
+
model: displayModel(deps),
|
|
165
|
+
},
|
|
166
|
+
// NO outer gate — complete1 takes the single leaf slot per prompt.
|
|
167
|
+
// v5 (audit H3): every item routes through the ledger — duplicate prompts inside
|
|
168
|
+
// one batch (or twins of other in-flight leaves) coalesce instead of paying N times.
|
|
169
|
+
(track: (u: Usage) => void) =>
|
|
171
170
|
runClaimedLeaf(
|
|
172
171
|
ledger,
|
|
173
172
|
ledger === undefined ? undefined : leafClaimKey(deps, p),
|
|
@@ -175,9 +174,9 @@ export function createLlmBatchHandler(
|
|
|
175
174
|
inv.depth,
|
|
176
175
|
() => complete1(inv, p, track, cdeps),
|
|
177
176
|
),
|
|
178
|
-
|
|
177
|
+
summarizeLeaf,
|
|
179
178
|
),
|
|
180
|
-
|
|
179
|
+
),
|
|
181
180
|
),
|
|
182
181
|
deps.trackDetached,
|
|
183
182
|
opts.detached,
|
|
@@ -15,8 +15,7 @@ import type { Invocation, SpawnResult, SubcallHandlerDeps } from "./types.ts";
|
|
|
15
15
|
import type { SubcallOpts } from "../../sandbox/interrupts.ts";
|
|
16
16
|
import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
|
|
17
17
|
import { complete1, type Complete1Deps } from "./completion.ts";
|
|
18
|
-
import { emitting } from "./emitting.ts";
|
|
19
|
-
import { isErrorText } from "../../util/errors.ts";
|
|
18
|
+
import { emitting, summarizeLeaf } from "./emitting.ts";
|
|
20
19
|
import { leafClaimKey, runClaimedLeaf } from "./llm-query.ts";
|
|
21
20
|
|
|
22
21
|
const UNWIRED = formatError("RLM bridge not wired for this invocation");
|
|
@@ -159,6 +158,7 @@ async function childRun(
|
|
|
159
158
|
detail: prompt.slice(0, 60),
|
|
160
159
|
depth: childDepth,
|
|
161
160
|
});
|
|
161
|
+
inv.emitter.emitSubcallUpdated({ id: subId, phase: "spawning" });
|
|
162
162
|
|
|
163
163
|
if (decision?.type === "echo") {
|
|
164
164
|
inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: ECHO_STUB.slice(0, 80) });
|
|
@@ -266,10 +266,7 @@ export function createRlmQueryHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
|
|
|
266
266
|
args: previewText(task),
|
|
267
267
|
},
|
|
268
268
|
(track) => complete1(inv, task, track, completeDeps(deps)),
|
|
269
|
-
|
|
270
|
-
preview: previewText(out),
|
|
271
|
-
error: isErrorText(out) ? out : undefined,
|
|
272
|
-
}),
|
|
269
|
+
summarizeLeaf,
|
|
273
270
|
),
|
|
274
271
|
),
|
|
275
272
|
deps.trackDetached,
|
|
@@ -311,36 +308,17 @@ export function createRlmBatchHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
|
|
|
311
308
|
}
|
|
312
309
|
|
|
313
310
|
const pathArg = opts.paths;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
parentId: inv.parentId,
|
|
317
|
-
label: `rlm_batch ×${tasks.length}`,
|
|
318
|
-
args: previewText(tasks[0] ?? ""),
|
|
319
|
-
depth: inv.depth,
|
|
320
|
-
});
|
|
321
|
-
|
|
311
|
+
// No wrapper "rlm_batch ×N" node: every task already gets its own rlm_query node from
|
|
312
|
+
// childRun (DRY #2), parented to the caller — the batch is spawn fan-out, not a UI row.
|
|
322
313
|
return spawnAndRun(
|
|
323
314
|
sd,
|
|
324
315
|
"rlm_batch",
|
|
325
316
|
tasks.length,
|
|
326
317
|
async () => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const answers = results.map((r) => r.answer);
|
|
332
|
-
inv.emitter.emitSubcallUpdated({
|
|
333
|
-
id,
|
|
334
|
-
status: "done",
|
|
335
|
-
resultPreview: previewText(answers[0] ?? ""),
|
|
336
|
-
totalCount: answers.length,
|
|
337
|
-
});
|
|
338
|
-
return answers;
|
|
339
|
-
} catch (err: unknown) {
|
|
340
|
-
const msg = errorMessage(err);
|
|
341
|
-
inv.emitter.emitSubcallUpdated({ id, status: "error", detail: msg });
|
|
342
|
-
throw err;
|
|
343
|
-
}
|
|
318
|
+
const results = await Promise.all(
|
|
319
|
+
tasks.map((t) => childRun(deps, inv, t, pathArg)),
|
|
320
|
+
);
|
|
321
|
+
return results.map((r) => r.answer);
|
|
344
322
|
},
|
|
345
323
|
deps.trackDetached,
|
|
346
324
|
opts.detached,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pins — apply model-picker selections to controller pin state.
|
|
3
|
+
*
|
|
4
|
+
* One function per role; both follow the same contract:
|
|
5
|
+
* `undefined` → ESC, no change · `null` → the role's top option (unpin) ·
|
|
6
|
+
* `ModelSelection` → pin model + its thinking level into the matching sampling slot.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { modelRef } from "../config/settings.ts";
|
|
10
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
11
|
+
import type { ModelSelection } from "../ui/model-picker.ts";
|
|
12
|
+
|
|
13
|
+
export function applyLlmSelection(controller: RlmController, llm: ModelSelection | null | undefined): void {
|
|
14
|
+
if (llm === undefined) return;
|
|
15
|
+
if (llm === null) {
|
|
16
|
+
controller.llmModel = undefined;
|
|
17
|
+
controller.savedLlmRef = undefined;
|
|
18
|
+
controller.explicitClearPin = true;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
controller.llmModel = llm.model;
|
|
22
|
+
controller.savedLlmRef = modelRef(llm.model);
|
|
23
|
+
controller.explicitClearPin = false;
|
|
24
|
+
controller.setConfig(Object.freeze({
|
|
25
|
+
...controller.config,
|
|
26
|
+
subSampling: Object.freeze({
|
|
27
|
+
...controller.config.subSampling,
|
|
28
|
+
reasoning: llm.thinkingLevel,
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function applyRlmSelection(controller: RlmController, rlm: ModelSelection | null | undefined): void {
|
|
34
|
+
if (rlm === undefined) return;
|
|
35
|
+
if (rlm === null) {
|
|
36
|
+
controller.rlmModel = undefined;
|
|
37
|
+
controller.savedRlmRef = undefined;
|
|
38
|
+
controller.explicitClearRlmPin = true;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
controller.rlmModel = rlm.model;
|
|
42
|
+
controller.savedRlmRef = modelRef(rlm.model);
|
|
43
|
+
controller.explicitClearRlmPin = false;
|
|
44
|
+
controller.setConfig(Object.freeze({
|
|
45
|
+
...controller.config,
|
|
46
|
+
rootSampling: Object.freeze({
|
|
47
|
+
...(controller.config.rootSampling ?? {}),
|
|
48
|
+
reasoning: rlm.thinkingLevel,
|
|
49
|
+
}),
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
@@ -1,104 +1,20 @@
|
|
|
1
|
-
/** `/rlm-config` —
|
|
2
|
-
* The root model is always pi's active model; only the sub-LLM is configurable here. */
|
|
1
|
+
/** `/rlm-config` — run settings only. Model pins live in `/rlm-llm` and `/rlm-rlm`. */
|
|
3
2
|
|
|
4
|
-
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { modelRef } from "../config/settings.ts";
|
|
7
4
|
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
8
|
-
import { cheapestModel } from "../mode/llm-model.ts";
|
|
9
5
|
import { setRlmModeStatus } from "../ui/status.ts";
|
|
10
6
|
import { showConfigPanel } from "../ui/config-panel.ts";
|
|
11
|
-
import { pickableModels, selectModel, type ModelSelection } from "../ui/model-picker.ts";
|
|
12
|
-
|
|
13
|
-
/** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely. */
|
|
14
|
-
function sessionScopedModels(
|
|
15
|
-
ctx: ExtensionContext,
|
|
16
|
-
): readonly { readonly model: Model<Api> }[] | undefined {
|
|
17
|
-
const scoped: unknown = Reflect.get(ctx, "scopedModels");
|
|
18
|
-
return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Apply a model-picker result to controller pin state.
|
|
23
|
-
*
|
|
24
|
-
* - `null` → explicit "cheapest (auto)" (clear pin; leave reasoning alone)
|
|
25
|
-
* - `ModelSelection` → pin that model (and its thinking level, which may be undefined)
|
|
26
|
-
* - `undefined` → ESC / no change
|
|
27
|
-
*/
|
|
28
|
-
export function applyLlmSelection(
|
|
29
|
-
controller: RlmController,
|
|
30
|
-
llm: ModelSelection | null | undefined,
|
|
31
|
-
): void {
|
|
32
|
-
if (llm === undefined) return;
|
|
33
|
-
if (llm === null) {
|
|
34
|
-
controller.llmModel = undefined;
|
|
35
|
-
controller.savedLlmRef = undefined;
|
|
36
|
-
controller.explicitClearPin = true;
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
controller.llmModel = llm.model;
|
|
40
|
-
controller.savedLlmRef = modelRef(llm.model);
|
|
41
|
-
controller.explicitClearPin = false;
|
|
42
|
-
controller.setConfig(Object.freeze({
|
|
43
|
-
...controller.config,
|
|
44
|
-
subSampling: Object.freeze({
|
|
45
|
-
...controller.config.subSampling,
|
|
46
|
-
reasoning: llm.thinkingLevel,
|
|
47
|
-
}),
|
|
48
|
-
}));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
|
|
52
|
-
// Match Pi's native list: refresh so a just-added key appears, then use scoped models when
|
|
53
|
-
// the session narrowed them, else every available (auth-configured) model. Never getAll().
|
|
54
|
-
try {
|
|
55
|
-
await ctx.modelRegistry.refresh();
|
|
56
|
-
} catch {
|
|
57
|
-
// Fail-soft: show the cached available snapshot rather than aborting config.
|
|
58
|
-
}
|
|
59
|
-
const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
|
|
60
|
-
|
|
61
|
-
const llm = await selectModel(
|
|
62
|
-
ctx,
|
|
63
|
-
"LLM model (sub-calls: llm_query / map_files / rlm_query)",
|
|
64
|
-
models,
|
|
65
|
-
controller.llmModel,
|
|
66
|
-
controller.config.subSampling.reasoning,
|
|
67
|
-
controller.savedLlmRef,
|
|
68
|
-
);
|
|
69
|
-
// Only an explicit choice touches the pin. ESC leaves model + reasoning alone.
|
|
70
|
-
// Choosing cheapest must NOT wipe subSampling.reasoning (null !== undefined used to).
|
|
71
|
-
applyLlmSelection(controller, llm);
|
|
72
|
-
|
|
73
|
-
// Persist model choice immediately — if showConfigPanel throws or process exits before it
|
|
74
|
-
// returns, the pin survives (Root Cause #2, v0.3.2).
|
|
75
|
-
if (llm !== undefined) {
|
|
76
|
-
const saved = await controller.persist();
|
|
77
|
-
if (!saved) ctx.ui.notify("RLM: failed to save llm setting", "error");
|
|
78
|
-
}
|
|
79
7
|
|
|
8
|
+
async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<void> {
|
|
80
9
|
controller.setConfig(await showConfigPanel(ctx, controller.config));
|
|
81
|
-
|
|
82
10
|
const persisted = await controller.persist();
|
|
83
11
|
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
84
|
-
setRlmModeStatus(ctx
|
|
85
|
-
|
|
86
|
-
// Name the model that actually resolved, not "(cheapest)" — otherwise there is no way to
|
|
87
|
-
// tell whether the free model in the catalog was the one picked.
|
|
88
|
-
const pinned = controller.llmModel;
|
|
89
|
-
const effective = pinned ?? cheapestModel(ctx.modelRegistry);
|
|
90
|
-
const reasoning = controller.config.subSampling.reasoning;
|
|
91
|
-
ctx.ui.notify(
|
|
92
|
-
`RLM: llm=${modelRef(effective) ?? "(none available)"}`
|
|
93
|
-
+ `${pinned ? "" : " (cheapest, auto)"}${reasoning ? `/${reasoning}` : ""}`,
|
|
94
|
-
"info",
|
|
95
|
-
);
|
|
96
|
-
return llm !== undefined;
|
|
12
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
97
13
|
}
|
|
98
14
|
|
|
99
15
|
export function registerRlmConfigCommand(pi: ExtensionAPI, controller: RlmController): void {
|
|
100
16
|
pi.registerCommand("rlm-config", {
|
|
101
|
-
description: "Configure
|
|
17
|
+
description: "Configure RLM run settings (models: /rlm-llm, /rlm-rlm).",
|
|
102
18
|
handler: async (_args, ctx) => {
|
|
103
19
|
await runRlmConfig(controller, ctx);
|
|
104
20
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** `/rlm-llm` — pin the leaf-LLM model (llm_query / llm_batch / map_files). */
|
|
2
|
+
|
|
3
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { modelRef } from "../config/settings.ts";
|
|
6
|
+
import { cheapestModel } from "../mode/llm-model.ts";
|
|
7
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
8
|
+
import { pickableModels, selectModel } from "../ui/model-picker.ts";
|
|
9
|
+
import { setRlmModeStatus } from "../ui/status.ts";
|
|
10
|
+
import { applyLlmSelection } from "./pins.ts";
|
|
11
|
+
|
|
12
|
+
/** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely. */
|
|
13
|
+
function sessionScopedModels(
|
|
14
|
+
ctx: ExtensionContext,
|
|
15
|
+
): readonly { readonly model: Model<Api> }[] | undefined {
|
|
16
|
+
const scoped: unknown = Reflect.get(ctx, "scopedModels");
|
|
17
|
+
return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function runRlmLlm(controller: RlmController, ctx: ExtensionContext): Promise<void> {
|
|
21
|
+
try {
|
|
22
|
+
await ctx.modelRegistry.refresh();
|
|
23
|
+
} catch {
|
|
24
|
+
// Fail-soft: show the cached available snapshot rather than aborting config.
|
|
25
|
+
}
|
|
26
|
+
const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
|
|
27
|
+
const llm = await selectModel(
|
|
28
|
+
ctx,
|
|
29
|
+
"llm",
|
|
30
|
+
models,
|
|
31
|
+
controller.llmModel,
|
|
32
|
+
controller.config.subSampling.reasoning,
|
|
33
|
+
controller.savedLlmRef,
|
|
34
|
+
);
|
|
35
|
+
applyLlmSelection(controller, llm);
|
|
36
|
+
const persisted = await controller.persist();
|
|
37
|
+
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
38
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
39
|
+
|
|
40
|
+
// Name the model that actually resolved, not "(cheapest)" — otherwise there is no way to
|
|
41
|
+
// tell whether the free model in the catalog was the one picked.
|
|
42
|
+
const pinned = controller.llmModel;
|
|
43
|
+
const effective = pinned ?? cheapestModel(ctx.modelRegistry);
|
|
44
|
+
const reasoning = controller.config.subSampling.reasoning;
|
|
45
|
+
ctx.ui.notify(
|
|
46
|
+
`RLM: llm=${modelRef(effective) ?? "(none available)"}`
|
|
47
|
+
+ `${pinned ? "" : " (cheapest, auto)"}${reasoning ? `/${reasoning}` : ""}`,
|
|
48
|
+
"info",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function registerRlmLlmCommand(pi: ExtensionAPI, controller: RlmController): void {
|
|
53
|
+
pi.registerCommand("rlm-llm", {
|
|
54
|
+
description: "Pin the LLM model used by llm_query / llm_batch / map_files sub-calls.",
|
|
55
|
+
handler: async (_args, ctx) => {
|
|
56
|
+
await runRlmLlm(controller, ctx);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** `/rlm-rlm` — pin the root/worker model for rlm_query / rlm_batch child engines.
|
|
2
|
+
*
|
|
3
|
+
* Unpinned (default), child engines follow pi's active session model — exactly
|
|
4
|
+
* the pre-pin behavior, now an explicit picker row.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { modelRef } from "../config/settings.ts";
|
|
10
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
11
|
+
import { pickableModels, selectModel } from "../ui/model-picker.ts";
|
|
12
|
+
import { setRlmModeStatus } from "../ui/status.ts";
|
|
13
|
+
import { applyRlmSelection } from "./pins.ts";
|
|
14
|
+
|
|
15
|
+
function sessionScopedModels(
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
): readonly { readonly model: Model<Api> }[] | undefined {
|
|
18
|
+
const scoped: unknown = Reflect.get(ctx, "scopedModels");
|
|
19
|
+
return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runRlmRlm(controller: RlmController, ctx: ExtensionContext): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
await ctx.modelRegistry.refresh();
|
|
25
|
+
} catch {
|
|
26
|
+
// Fail-soft: show the cached available snapshot rather than aborting config.
|
|
27
|
+
}
|
|
28
|
+
const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
|
|
29
|
+
const rlm = await selectModel(
|
|
30
|
+
ctx,
|
|
31
|
+
"rlm",
|
|
32
|
+
models,
|
|
33
|
+
controller.rlmModel,
|
|
34
|
+
controller.config.rootSampling?.reasoning,
|
|
35
|
+
controller.savedRlmRef,
|
|
36
|
+
);
|
|
37
|
+
applyRlmSelection(controller, rlm);
|
|
38
|
+
const persisted = await controller.persist();
|
|
39
|
+
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
40
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
41
|
+
|
|
42
|
+
const reasoning = controller.config.rootSampling?.reasoning;
|
|
43
|
+
ctx.ui.notify(
|
|
44
|
+
controller.rlmModel
|
|
45
|
+
? `RLM: rlm=${modelRef(controller.rlmModel) ?? "(none)"}${reasoning ? `/${reasoning}` : ""}`
|
|
46
|
+
: "RLM: rlm follows session model",
|
|
47
|
+
"info",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerRlmRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
|
|
52
|
+
pi.registerCommand("rlm-rlm", {
|
|
53
|
+
description: "Pin the model used by rlm_query / rlm_batch child engines (default: session model).",
|
|
54
|
+
handler: async (_args, ctx) => {
|
|
55
|
+
await runRlmRlm(controller, ctx);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/commands/rlm.ts
CHANGED
|
@@ -9,7 +9,7 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
9
9
|
description: "Toggle persistent RLM mode (route plain prompts through the RLM engine).",
|
|
10
10
|
handler: async (_args, ctx) => {
|
|
11
11
|
const enabled = controller.toggle();
|
|
12
|
-
setRlmModeStatus(ctx
|
|
12
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
13
13
|
ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
|
|
14
14
|
},
|
|
15
15
|
});
|
|
@@ -30,7 +30,7 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
30
30
|
description: "Toggle RLM mode (off also stops a running query)",
|
|
31
31
|
handler: async (ctx) => {
|
|
32
32
|
const enabled = controller.toggle();
|
|
33
|
-
setRlmModeStatus(ctx
|
|
33
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
34
34
|
ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
|
|
35
35
|
},
|
|
36
36
|
});
|
package/src/config/settings.ts
CHANGED
|
@@ -12,6 +12,9 @@ export interface PersistedSettings {
|
|
|
12
12
|
/** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
|
|
13
13
|
* `null` = explicit "cheapest" clear (omit key on disk). */
|
|
14
14
|
readonly llm?: string | null;
|
|
15
|
+
/** "provider/id" of the pinned rlm root/worker model, or undefined for "follow session".
|
|
16
|
+
* `null` = explicit "follow session model" clear (omit key on disk). */
|
|
17
|
+
readonly rlm?: string | null;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
|
|
@@ -167,6 +170,7 @@ export async function loadSettings(): Promise<PersistedSettings> {
|
|
|
167
170
|
config: validateConfig(r.config),
|
|
168
171
|
// `worker` is the pre-rename key — still read so an existing pin survives the upgrade.
|
|
169
172
|
llm: validateString(r.llm) ?? validateString(r.worker),
|
|
173
|
+
rlm: validateString(r.rlm),
|
|
170
174
|
};
|
|
171
175
|
} catch {
|
|
172
176
|
return { config: {} };
|
|
@@ -178,13 +182,19 @@ export async function saveSettings(s: PersistedSettings): Promise<boolean> {
|
|
|
178
182
|
const p = settingsPath();
|
|
179
183
|
await mkdir(dirname(p), { recursive: true });
|
|
180
184
|
const body: Record<string, unknown> = { config: s.config };
|
|
185
|
+
const mergeDisk = s.llm === undefined || s.rlm === undefined;
|
|
186
|
+
const existing = mergeDisk ? await loadSettings() : undefined;
|
|
181
187
|
if (s.llm !== undefined) {
|
|
182
188
|
// Explicit: string → write pin, null → omit key (cheapest).
|
|
183
189
|
if (s.llm !== null) body.llm = s.llm;
|
|
184
|
-
} else {
|
|
190
|
+
} else if (existing?.llm) {
|
|
185
191
|
// Merge: preserve existing disk pin so config-only saves never strip it.
|
|
186
|
-
|
|
187
|
-
|
|
192
|
+
body.llm = existing.llm;
|
|
193
|
+
}
|
|
194
|
+
if (s.rlm !== undefined) {
|
|
195
|
+
if (s.rlm !== null) body.rlm = s.rlm;
|
|
196
|
+
} else if (existing?.rlm) {
|
|
197
|
+
body.rlm = existing.rlm;
|
|
188
198
|
}
|
|
189
199
|
await writeFile(p, `${JSON.stringify(body, null, 2)}\n`);
|
|
190
200
|
return true;
|
package/src/core/engine.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
|
22
22
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
23
23
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
24
24
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
25
|
+
import type { SubcallPhase } from "../tool/rlm-details.ts";
|
|
25
26
|
import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
|
|
26
27
|
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
27
28
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
@@ -119,6 +120,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
119
120
|
},
|
|
120
121
|
},
|
|
121
122
|
};
|
|
123
|
+
// Live activity phase for the tree UI: child engines report on their own subcall
|
|
124
|
+
// node; the root engine has no node, so it reports via the root-phase channel.
|
|
125
|
+
const reportPhase = (phase: SubcallPhase): void => {
|
|
126
|
+
if (selfReportId !== undefined) emitter.emitSubcallUpdated({ id: selfReportId, phase });
|
|
127
|
+
else emitter.emitRootPhase(phase);
|
|
128
|
+
};
|
|
122
129
|
// Detached work must not outlive the sandbox we dispose in `finally`: track it so the
|
|
123
130
|
// run can settle or abort it first (a child engine left running would keep spending).
|
|
124
131
|
let detachedInFlight = 0;
|
|
@@ -276,9 +283,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
276
283
|
}).handlers
|
|
277
284
|
: {};
|
|
278
285
|
|
|
286
|
+
// v5 doctrine: one condition feeds BOTH the python surface and the memory scope —
|
|
287
|
+
// delegation children keep llm/memory-read/ledger, never repo retrieval or memory.add.
|
|
288
|
+
const surface = input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root";
|
|
279
289
|
sandbox = await PythonSandbox.spawn({
|
|
280
290
|
depth: input.depth,
|
|
281
|
-
surface
|
|
291
|
+
surface,
|
|
282
292
|
execTimeoutS: deps.config.execTimeoutS,
|
|
283
293
|
requestTimeoutMs: deps.config.requestTimeoutMs,
|
|
284
294
|
python: deps.config.python,
|
|
@@ -290,7 +300,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
290
300
|
...subcalls,
|
|
291
301
|
...contextHandlers,
|
|
292
302
|
ledgerClaims: () => Promise.resolve(runLedger.listClaims()),
|
|
293
|
-
memoryOp: (op, args) =>
|
|
303
|
+
memoryOp: (op, args) =>
|
|
304
|
+
Promise.resolve(
|
|
305
|
+
rootMemory === undefined ? "memory off" : rootMemory.serviceOp(op, args, surface === "child" ? "child" : "root"),
|
|
306
|
+
),
|
|
294
307
|
},
|
|
295
308
|
});
|
|
296
309
|
|
|
@@ -361,6 +374,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
361
374
|
sampling: rootSampling,
|
|
362
375
|
signal: deps.signal,
|
|
363
376
|
complete: deps.complete,
|
|
377
|
+
onPhase: reportPhase,
|
|
364
378
|
});
|
|
365
379
|
const allBlocks = turn.blocks.length > 0
|
|
366
380
|
? turn.blocks.map((b) => previewText(b, 400)).join("\n")
|
package/src/core/iteration.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type ChatMsg, type CompleteOptions, type CompleteResult, modelComplete
|
|
|
10
10
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
11
11
|
import type { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
12
12
|
import { findReplBlocks } from "../text/parsing.ts";
|
|
13
|
+
import type { SubcallPhase } from "../tool/rlm-details.ts";
|
|
13
14
|
import type { Sampling } from "./types.ts";
|
|
14
15
|
|
|
15
16
|
export interface Turn {
|
|
@@ -30,10 +31,13 @@ export interface TurnDeps {
|
|
|
30
31
|
readonly signal?: AbortSignal;
|
|
31
32
|
/** Test-only override for model completion (scripted responses). */
|
|
32
33
|
readonly complete?: CompleteFn;
|
|
34
|
+
/** Live activity reporting for the tree UI (thinking → repl/texting per turn). */
|
|
35
|
+
readonly onPhase?: (phase: SubcallPhase) => void;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbox, deps: TurnDeps): Promise<Turn> {
|
|
36
39
|
const complete = deps.complete ?? modelComplete;
|
|
40
|
+
deps.onPhase?.("thinking");
|
|
37
41
|
const { text, usage } = await complete(history, {
|
|
38
42
|
model: deps.model,
|
|
39
43
|
registry: deps.registry,
|
|
@@ -44,6 +48,7 @@ export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbo
|
|
|
44
48
|
});
|
|
45
49
|
|
|
46
50
|
const blocks = findReplBlocks(text);
|
|
51
|
+
deps.onPhase?.(blocks.length > 0 ? "repl" : "texting");
|
|
47
52
|
const results = new Array<ReplResult>(blocks.length);
|
|
48
53
|
let executed = 0;
|
|
49
54
|
for (let i = 0; i < blocks.length; i++) {
|