@hicaru/pi-rlm 0.3.6 → 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 +84 -29
- package/src/bridge/handlers/rlm-query.ts +133 -33
- package/src/bridge/handlers/types.ts +12 -0
- 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/defaults.ts +17 -0
- package/src/config/settings.ts +58 -5
- package/src/core/answer.ts +7 -10
- package/src/core/budget.ts +182 -0
- package/src/core/compaction.ts +46 -0
- package/src/core/engine.ts +185 -5
- package/src/core/iteration.ts +5 -0
- package/src/core/ledger.ts +343 -0
- package/src/core/memory.ts +589 -0
- package/src/core/model-registry.ts +88 -0
- package/src/core/types.ts +44 -3
- package/src/index.ts +107 -12
- package/src/mode/rlm-mode.ts +58 -10
- package/src/prompts/glossary.ts +147 -57
- package/src/prompts/native.ts +12 -7
- package/src/prompts/system.ts +22 -7
- package/src/prompts/user.ts +6 -3
- package/src/sandbox/interrupts.ts +24 -0
- package/src/sandbox/protocol.ts +69 -5
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +11 -6
- package/src/sandbox/py/scaffold.py +615 -0
- package/src/sandbox/py/worker.py +53 -506
- package/src/sandbox/sandbox.ts +21 -3
- package/src/text/repl-output.ts +15 -0
- package/src/tool/repl-render.ts +4 -10
- package/src/tool/repl-result.ts +54 -10
- package/src/tool/repl-tool.ts +50 -3
- 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 -10
- 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/src/util/concurrency.ts +47 -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,12 +5,13 @@
|
|
|
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";
|
|
13
13
|
import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
|
|
14
|
+
import { ECHO_STUB, taskKey, type TaskLedger } from "../../core/ledger.ts";
|
|
14
15
|
|
|
15
16
|
const UNWIRED = formatError("RLM bridge not wired for this invocation");
|
|
16
17
|
|
|
@@ -34,6 +35,45 @@ function displayModel(deps: SubcallHandlerDeps): string | undefined {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** The ledger active for leaf calls — undefined when disabled by config or not threaded in. */
|
|
39
|
+
function activeLedger(deps: SubcallHandlerDeps): TaskLedger | undefined {
|
|
40
|
+
return deps.getConfig().enableLedger ? deps.ledger : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** v5 TaskLedger routing for ONE leaf prompt (audit H3 — shared by llm_query and every
|
|
44
|
+
* llm_batch item, which v5 routed through `_spawn_single` too): echo → stub string,
|
|
45
|
+
* coalesce → the twin's result (bounded wait), run → caller executes then finish/fail.
|
|
46
|
+
* Exported for tests. */
|
|
47
|
+
export async function runClaimedLeaf(
|
|
48
|
+
ledger: TaskLedger | undefined,
|
|
49
|
+
key: string | undefined,
|
|
50
|
+
prompt: string,
|
|
51
|
+
depth: number,
|
|
52
|
+
exec: () => Promise<string>,
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
if (ledger === undefined || key === undefined) return exec();
|
|
55
|
+
const decision = ledger.tryClaim({ kind: "llm", prompt, paths: [], depth }, key);
|
|
56
|
+
if (decision.type === "echo") return ECHO_STUB;
|
|
57
|
+
if (decision.type === "coalesce") {
|
|
58
|
+
return ledger.waitFor(decision.key).catch((err: unknown): string => formatError(errorMessage(err)));
|
|
59
|
+
}
|
|
60
|
+
ledger.markRunning(key);
|
|
61
|
+
try {
|
|
62
|
+
const out = await exec();
|
|
63
|
+
ledger.finish(key, out);
|
|
64
|
+
return out;
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
ledger.fail(key, errorMessage(err));
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function leafClaimKey(deps: SubcallHandlerDeps, prompt: string): string | undefined {
|
|
72
|
+
const ledger = activeLedger(deps);
|
|
73
|
+
if (ledger === undefined) return undefined;
|
|
74
|
+
return taskKey("llm", prompt, [], displayModel(deps) ?? "", "");
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
export function createLlmQueryHandler(
|
|
38
78
|
deps: SubcallHandlerDeps,
|
|
39
79
|
sd: SpawnDeps,
|
|
@@ -57,25 +97,25 @@ export function createLlmQueryHandler(
|
|
|
57
97
|
}
|
|
58
98
|
|
|
59
99
|
const cdeps = completeDeps(deps);
|
|
100
|
+
const runLeaf = (): Promise<string> =>
|
|
101
|
+
emitting(
|
|
102
|
+
inv,
|
|
103
|
+
{
|
|
104
|
+
kind: "llm",
|
|
105
|
+
label: "llm_query",
|
|
106
|
+
args: `prompt: ${previewText(prompt)}`,
|
|
107
|
+
model: displayModel(deps),
|
|
108
|
+
},
|
|
109
|
+
(track: (u: Usage) => void) => complete1(inv, prompt, track, cdeps),
|
|
110
|
+
summarizeLeaf,
|
|
111
|
+
);
|
|
112
|
+
// v5 TaskLedger for leaves: identical prompts coalesce onto one completion (key has no
|
|
113
|
+
// context — a leaf's entire world is the prompt text itself).
|
|
60
114
|
return spawnAndRun(
|
|
61
115
|
sd,
|
|
62
116
|
"llm",
|
|
63
117
|
1,
|
|
64
|
-
() =>
|
|
65
|
-
emitting(
|
|
66
|
-
inv,
|
|
67
|
-
{
|
|
68
|
-
kind: "llm",
|
|
69
|
-
label: "llm_query",
|
|
70
|
-
args: `prompt: ${previewText(prompt)}`,
|
|
71
|
-
model: displayModel(deps),
|
|
72
|
-
},
|
|
73
|
-
(track: (u: Usage) => void) => complete1(inv, prompt, track, cdeps),
|
|
74
|
-
(out) => ({
|
|
75
|
-
preview: previewText(out),
|
|
76
|
-
error: isErrorText(out) ? out : undefined,
|
|
77
|
-
}),
|
|
78
|
-
),
|
|
118
|
+
() => runClaimedLeaf(activeLedger(deps), leafClaimKey(deps, prompt), prompt, inv.depth, runLeaf),
|
|
79
119
|
deps.trackDetached,
|
|
80
120
|
opts.detached,
|
|
81
121
|
);
|
|
@@ -105,23 +145,38 @@ export function createLlmBatchHandler(
|
|
|
105
145
|
}
|
|
106
146
|
|
|
107
147
|
const cdeps = completeDeps(deps);
|
|
148
|
+
const ledger = activeLedger(deps);
|
|
108
149
|
return spawnAndRun(
|
|
109
150
|
sd,
|
|
110
151
|
"llm_batch",
|
|
111
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).
|
|
112
155
|
() =>
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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) =>
|
|
170
|
+
runClaimedLeaf(
|
|
171
|
+
ledger,
|
|
172
|
+
ledger === undefined ? undefined : leafClaimKey(deps, p),
|
|
173
|
+
p,
|
|
174
|
+
inv.depth,
|
|
175
|
+
() => complete1(inv, p, track, cdeps),
|
|
176
|
+
),
|
|
177
|
+
summarizeLeaf,
|
|
178
|
+
),
|
|
179
|
+
),
|
|
125
180
|
),
|
|
126
181
|
deps.trackDetached,
|
|
127
182
|
opts.detached,
|
|
@@ -10,10 +10,13 @@ import { filterContextByPaths } from "../../context/merge.ts";
|
|
|
10
10
|
import { previewText } from "../../text/preview.ts";
|
|
11
11
|
import type { RlmInput, RlmResult } from "../../core/types.ts";
|
|
12
12
|
import { checkResourceLimits } from "../../core/resource-limits.ts";
|
|
13
|
+
import { contextSig, ECHO_STUB, taskKey } from "../../core/ledger.ts";
|
|
13
14
|
import type { Invocation, SpawnResult, SubcallHandlerDeps } from "./types.ts";
|
|
14
15
|
import type { SubcallOpts } from "../../sandbox/interrupts.ts";
|
|
15
16
|
import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
|
|
16
17
|
import { complete1, type Complete1Deps } from "./completion.ts";
|
|
18
|
+
import { emitting, summarizeLeaf } from "./emitting.ts";
|
|
19
|
+
import { leafClaimKey, runClaimedLeaf } from "./llm-query.ts";
|
|
17
20
|
|
|
18
21
|
const UNWIRED = formatError("RLM bridge not wired for this invocation");
|
|
19
22
|
const NO_UNMATCHED: readonly string[] = Object.freeze([]);
|
|
@@ -64,9 +67,20 @@ function completeDeps(deps: SubcallHandlerDeps): Complete1Deps {
|
|
|
64
67
|
};
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/** Ledger active for this call — undefined when disabled by config or not threaded in. */
|
|
71
|
+
function activeLedger(deps: SubcallHandlerDeps) {
|
|
72
|
+
return deps.getConfig().enableLedger ? deps.ledger : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function claimKeyFor(deps: SubcallHandlerDeps, kind: "llm" | "rlm", prompt: string, paths: readonly string[], ctx: string): string {
|
|
76
|
+
const rootModel = deps.getModel?.();
|
|
77
|
+
const modelId = rootModel === undefined ? "" : (modelRef(rootModel) ?? rootModel.id);
|
|
78
|
+
return taskKey(kind, prompt, paths, modelId, ctx);
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
/**
|
|
68
|
-
* One child RLM run: depth cap → resource guard → depth gate → spawn engine →
|
|
69
|
-
* Emits its own subcall node (do not wrap in emitting()).
|
|
82
|
+
* One child RLM run: depth cap → resource guard → ledger gate → depth gate → spawn engine →
|
|
83
|
+
* debit parent. Emits its own subcall node (do not wrap in emitting()).
|
|
70
84
|
*/
|
|
71
85
|
async function childRun(
|
|
72
86
|
deps: SubcallHandlerDeps,
|
|
@@ -91,23 +105,75 @@ async function childRun(
|
|
|
91
105
|
const limitError = checkResourceLimits({ timeoutMs: remTimeout });
|
|
92
106
|
if (limitError !== undefined) return emptyResult(limitError);
|
|
93
107
|
|
|
108
|
+
const child = childContextFor(deps, prompt, paths);
|
|
109
|
+
const rootPrompt =
|
|
110
|
+
child.unmatched.length === 0
|
|
111
|
+
? prompt
|
|
112
|
+
: `${prompt}\n\n[rlm] paths=${child.unmatched.join(", ")} matched no files; you received the full context.`;
|
|
113
|
+
|
|
114
|
+
// ── v5 memory replay: an identical, still-fresh child answer replays for zero API calls ──
|
|
115
|
+
const memory = deps.memory;
|
|
116
|
+
const sig = contextSig(child.context);
|
|
117
|
+
const key = claimKeyFor(deps, "rlm", prompt, paths ?? [], sig);
|
|
118
|
+
if (memory !== undefined && deps.getConfig().enableMemory !== false) {
|
|
119
|
+
const hit = memory.replay(key);
|
|
120
|
+
if (hit !== undefined) {
|
|
121
|
+
const replayId = inv.emitter.emitSubcallCreated({
|
|
122
|
+
kind: "rlm",
|
|
123
|
+
parentId: inv.parentId,
|
|
124
|
+
label: "rlm_query (replay)",
|
|
125
|
+
detail: prompt.slice(0, 60),
|
|
126
|
+
depth: childDepth,
|
|
127
|
+
});
|
|
128
|
+
inv.emitter.emitSubcallUpdated({ id: replayId, status: "done", resultPreview: hit.result.slice(0, 200) });
|
|
129
|
+
return {
|
|
130
|
+
answer: hit.result,
|
|
131
|
+
iterations: 0,
|
|
132
|
+
costUsd: 0,
|
|
133
|
+
inputTokens: 0,
|
|
134
|
+
outputTokens: 0,
|
|
135
|
+
durationMs: 0,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── v5 TaskLedger: echo → stub; duplicate → coalesce onto the existing runner ──────
|
|
141
|
+
const ledger = activeLedger(deps);
|
|
142
|
+
const claimKey = ledger === undefined ? undefined : key;
|
|
143
|
+
const decision =
|
|
144
|
+
ledger !== undefined && claimKey !== undefined
|
|
145
|
+
? ledger.tryClaim({ kind: "rlm", prompt, paths: paths ?? [], depth: childDepth }, claimKey)
|
|
146
|
+
: undefined;
|
|
147
|
+
|
|
148
|
+
// ONE subcall node per childRun (audit C2 / DRY #5): the decision branch reuses it, the
|
|
149
|
+
// run branch reports the engine's turns/cost on it. Never a second emit below.
|
|
94
150
|
const rootModel = deps.getModel?.();
|
|
95
151
|
const modelLabel =
|
|
96
152
|
rootModel === undefined ? undefined : (modelRef(rootModel) ?? rootModel.id);
|
|
97
153
|
const subId = inv.emitter.emitSubcallCreated({
|
|
98
154
|
kind: "rlm",
|
|
99
155
|
parentId: inv.parentId,
|
|
100
|
-
label: "rlm_query"
|
|
156
|
+
label: decision === undefined || decision.type === "run" ? "rlm_query" : `rlm_query (${decision.type})`,
|
|
101
157
|
model: modelLabel,
|
|
102
158
|
detail: prompt.slice(0, 60),
|
|
103
159
|
depth: childDepth,
|
|
104
160
|
});
|
|
161
|
+
inv.emitter.emitSubcallUpdated({ id: subId, phase: "spawning" });
|
|
105
162
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
163
|
+
if (decision?.type === "echo") {
|
|
164
|
+
inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: ECHO_STUB.slice(0, 80) });
|
|
165
|
+
return emptyResult(ECHO_STUB);
|
|
166
|
+
}
|
|
167
|
+
if (decision?.type === "coalesce" && ledger !== undefined) {
|
|
168
|
+
const twin = await ledger
|
|
169
|
+
.waitFor(decision.key)
|
|
170
|
+
.catch((err: unknown) => errorMessage(err));
|
|
171
|
+
inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: previewText(String(twin).slice(0, 80)) });
|
|
172
|
+
return emptyResult(String(twin));
|
|
173
|
+
}
|
|
174
|
+
if (ledger !== undefined && claimKey !== undefined) {
|
|
175
|
+
ledger.markRunning(claimKey);
|
|
176
|
+
}
|
|
111
177
|
|
|
112
178
|
const input: RlmInput = {
|
|
113
179
|
rootPrompt,
|
|
@@ -115,12 +181,27 @@ async function childRun(
|
|
|
115
181
|
depth: childDepth,
|
|
116
182
|
parentNodeId: subId,
|
|
117
183
|
remainingTimeoutMs: remTimeout,
|
|
184
|
+
ledger, // DRY #6: the one seam — children share the parent's blackboard
|
|
118
185
|
};
|
|
119
186
|
|
|
120
187
|
try {
|
|
121
188
|
const res = await deps.gates.rlm.at(childDepth).run(() => run(input, inv));
|
|
122
189
|
inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
|
|
123
190
|
deps.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
|
|
191
|
+
if (ledger !== undefined && claimKey !== undefined) ledger.finish(claimKey, res.answer);
|
|
192
|
+
// v5: child answers persist unconditionally — this is what later identical runs replay.
|
|
193
|
+
if (memory !== undefined && deps.getConfig().enableMemory !== false) {
|
|
194
|
+
memory.recordEpisode({
|
|
195
|
+
key,
|
|
196
|
+
kind: "rlm",
|
|
197
|
+
model: modelLabel ?? "",
|
|
198
|
+
prompt,
|
|
199
|
+
paths: paths ?? [],
|
|
200
|
+
result: res.answer,
|
|
201
|
+
tokensIn: res.inputTokens,
|
|
202
|
+
tokensOut: res.outputTokens,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
124
205
|
inv.emitter.emitSubcallUpdated({
|
|
125
206
|
id: subId,
|
|
126
207
|
status: "done",
|
|
@@ -129,6 +210,7 @@ async function childRun(
|
|
|
129
210
|
return res;
|
|
130
211
|
} catch (err: unknown) {
|
|
131
212
|
const msg = errorMessage(err);
|
|
213
|
+
if (ledger !== undefined && claimKey !== undefined) ledger.fail(claimKey, msg);
|
|
132
214
|
inv.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
|
|
133
215
|
return emptyResult(formatError(`child RLM failed - ${msg}`));
|
|
134
216
|
}
|
|
@@ -155,6 +237,43 @@ export function createRlmQueryHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
|
|
|
155
237
|
|
|
156
238
|
const pathArg = opts.paths;
|
|
157
239
|
|
|
240
|
+
// v5 rlmBudget demotion: once the ledger has started `rlmBudget` real rlm runs, extra
|
|
241
|
+
// rlm_query spawns demote to the leaf path (batch spawns are exempt — v5 `_spawn_single`).
|
|
242
|
+
const ledger = activeLedger(deps);
|
|
243
|
+
const rlmBudget = deps.getConfig().rlmBudget;
|
|
244
|
+
if (
|
|
245
|
+
ledger !== undefined &&
|
|
246
|
+
rlmBudget !== undefined &&
|
|
247
|
+
rlmBudget > 0 &&
|
|
248
|
+
ledger.rlmCount() >= rlmBudget
|
|
249
|
+
) {
|
|
250
|
+
return spawnAndRun(
|
|
251
|
+
sd,
|
|
252
|
+
"llm",
|
|
253
|
+
1,
|
|
254
|
+
() =>
|
|
255
|
+
runClaimedLeaf(
|
|
256
|
+
ledger,
|
|
257
|
+
leafClaimKey(deps, task),
|
|
258
|
+
task,
|
|
259
|
+
inv.depth,
|
|
260
|
+
() =>
|
|
261
|
+
emitting(
|
|
262
|
+
inv,
|
|
263
|
+
{
|
|
264
|
+
kind: "llm",
|
|
265
|
+
label: "rlm_query→llm (demoted)",
|
|
266
|
+
args: previewText(task),
|
|
267
|
+
},
|
|
268
|
+
(track) => complete1(inv, task, track, completeDeps(deps)),
|
|
269
|
+
summarizeLeaf,
|
|
270
|
+
),
|
|
271
|
+
),
|
|
272
|
+
deps.trackDetached,
|
|
273
|
+
opts.detached,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
158
277
|
return spawnAndRun(
|
|
159
278
|
sd,
|
|
160
279
|
"rlm",
|
|
@@ -189,36 +308,17 @@ export function createRlmBatchHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
|
|
|
189
308
|
}
|
|
190
309
|
|
|
191
310
|
const pathArg = opts.paths;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
parentId: inv.parentId,
|
|
195
|
-
label: `rlm_batch ×${tasks.length}`,
|
|
196
|
-
args: previewText(tasks[0] ?? ""),
|
|
197
|
-
depth: inv.depth,
|
|
198
|
-
});
|
|
199
|
-
|
|
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.
|
|
200
313
|
return spawnAndRun(
|
|
201
314
|
sd,
|
|
202
315
|
"rlm_batch",
|
|
203
316
|
tasks.length,
|
|
204
317
|
async () => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const answers = results.map((r) => r.answer);
|
|
210
|
-
inv.emitter.emitSubcallUpdated({
|
|
211
|
-
id,
|
|
212
|
-
status: "done",
|
|
213
|
-
resultPreview: previewText(answers[0] ?? ""),
|
|
214
|
-
totalCount: answers.length,
|
|
215
|
-
});
|
|
216
|
-
return answers;
|
|
217
|
-
} catch (err: unknown) {
|
|
218
|
-
const msg = errorMessage(err);
|
|
219
|
-
inv.emitter.emitSubcallUpdated({ id, status: "error", detail: msg });
|
|
220
|
-
throw err;
|
|
221
|
-
}
|
|
318
|
+
const results = await Promise.all(
|
|
319
|
+
tasks.map((t) => childRun(deps, inv, t, pathArg)),
|
|
320
|
+
);
|
|
321
|
+
return results.map((r) => r.answer);
|
|
222
322
|
},
|
|
223
323
|
deps.trackDetached,
|
|
224
324
|
opts.detached,
|
|
@@ -12,6 +12,8 @@ import type { RlmInput, RlmResult, Sampling } from "../../core/types.ts";
|
|
|
12
12
|
import type { SubcallGates } from "../../util/concurrency.ts";
|
|
13
13
|
import type { SubcallOpts } from "../../sandbox/interrupts.ts";
|
|
14
14
|
import type { RlmEmitter } from "../../tool/rlm-events.ts";
|
|
15
|
+
import type { TaskLedger } from "../../core/ledger.ts";
|
|
16
|
+
import type { MemoryStore } from "../../core/memory.ts";
|
|
15
17
|
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// Spawn / Await / Finish — the three shapes the model sees
|
|
@@ -85,6 +87,12 @@ export interface SubcallConfig {
|
|
|
85
87
|
readonly maxDepth: number;
|
|
86
88
|
readonly subSampling?: Sampling;
|
|
87
89
|
readonly subSystemPrompt?: string;
|
|
90
|
+
/** v5 TaskLedger: claim/coalesce/echo gates (optional — unwired callers keep ledger off). */
|
|
91
|
+
readonly enableLedger?: boolean;
|
|
92
|
+
/** v5: real rlm spawns before demotion to llm (0 = never demote). */
|
|
93
|
+
readonly rlmBudget?: number;
|
|
94
|
+
/** v5 durable memory gates (optional; omitted → memory off). */
|
|
95
|
+
readonly enableMemory?: boolean;
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
export interface SubcallHandlerDeps {
|
|
@@ -104,6 +112,10 @@ export interface SubcallHandlerDeps {
|
|
|
104
112
|
readonly degrade?: (prompt: string, depth: number) => Promise<string>;
|
|
105
113
|
readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
|
|
106
114
|
readonly trackDetached?: <T>(run: () => Promise<T>) => Promise<T>;
|
|
115
|
+
/** v5 TaskLedger blackboard shared across the whole run tree (claim/coalesce/echo/demote). */
|
|
116
|
+
readonly ledger?: TaskLedger;
|
|
117
|
+
/** v5 durable memory (session-wide store) for child replay + episode persistence. */
|
|
118
|
+
readonly memory?: MemoryStore;
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
},
|