@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/src/core/types.ts
CHANGED
|
@@ -15,9 +15,10 @@ export interface RlmConfig {
|
|
|
15
15
|
readonly maxDepth: number;
|
|
16
16
|
/** Max turns before the engine must finalize. */
|
|
17
17
|
readonly maxIterations: number;
|
|
18
|
-
/** Per-`repl`-block wall-clock timeout inside the worker (seconds).
|
|
18
|
+
/** Per-`repl`-block wall-clock timeout inside the worker (seconds).
|
|
19
|
+
* v5 doctrine: content limits are the token budget's job — this is a HANG backstop only. */
|
|
19
20
|
readonly execTimeoutS: number;
|
|
20
|
-
/** Parent-side watchdog per sandbox request (ms). */
|
|
21
|
+
/** Parent-side watchdog per sandbox request (ms). Hang backstop (see execTimeoutS). */
|
|
21
22
|
readonly requestTimeoutMs: number;
|
|
22
23
|
/** Concurrency pool for *_batched sub-calls. */
|
|
23
24
|
readonly maxConcurrentSubcalls: number;
|
|
@@ -28,7 +29,10 @@ export interface RlmConfig {
|
|
|
28
29
|
readonly maxPromptChars: number;
|
|
29
30
|
/** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
|
|
30
31
|
readonly maxTimeoutMs?: number;
|
|
31
|
-
/** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap).
|
|
32
|
+
/** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap).
|
|
33
|
+
* ⚠ HARD ABORT (audit H8): when set, exceeding this throws a LimitError mid-run and the run
|
|
34
|
+
* ends with its best partial — NO wrap-up, NO continuation. The graceful path is the v5
|
|
35
|
+
* token budget (`enableTokenBudget`); leave this unset unless a hard tree-wide stop is wanted. */
|
|
32
36
|
readonly maxTokens?: number;
|
|
33
37
|
/** Max consecutive error turns before the engine stops (undefined = no cap). */
|
|
34
38
|
readonly maxErrors?: number;
|
|
@@ -61,6 +65,37 @@ export interface RlmConfig {
|
|
|
61
65
|
readonly subSystemPrompt?: string;
|
|
62
66
|
/** Sampling for sub-LLM (worker) calls. */
|
|
63
67
|
readonly subSampling: Readonly<Sampling>;
|
|
68
|
+
/** v5 token budget: cap = budgetShare × contextWindow, clamped by budgetTaskCap. When on,
|
|
69
|
+
* the budget is the PRIMARY run-length control (soft wrap-up → continuation chain). */
|
|
70
|
+
readonly enableTokenBudget: boolean;
|
|
71
|
+
/** Fraction of the model's context window that forms one run's token cap. */
|
|
72
|
+
readonly budgetShare: number;
|
|
73
|
+
/** Soft wrap-up fires at this fraction of the cap (one wrap-up turn). */
|
|
74
|
+
readonly budgetSoftFrac: number;
|
|
75
|
+
/** Absolute single-run ceiling; 0 = no clamp beyond the share. */
|
|
76
|
+
readonly budgetTaskCap: number;
|
|
77
|
+
/** Max continuation runs after a hard stop (chain ≤ 1 + this). */
|
|
78
|
+
readonly budgetMaxContinuations: number;
|
|
79
|
+
/** Char budget for the deterministic continuation handoff. */
|
|
80
|
+
readonly budgetHandoffChars: number;
|
|
81
|
+
/** v5 TaskLedger blackboard: claim coalescing + ancestor-echo reject + `[ledger]` injection. */
|
|
82
|
+
readonly enableLedger: boolean;
|
|
83
|
+
/** Real rlm spawns allowed before extra rlm_query demotes to llm_query (0 = never). */
|
|
84
|
+
readonly rlmBudget: number;
|
|
85
|
+
/** v5 durable memory: L1 episode replay + L2 BM25 notes under `<root>/.rlm/memory`. */
|
|
86
|
+
readonly enableMemory: boolean;
|
|
87
|
+
/** Char budget for the `[memory]` injection = tokens × 4. */
|
|
88
|
+
readonly injectNoteTokens: number;
|
|
89
|
+
/** Pending episodes per L2 consolidation batch (0 = never auto-consolidate). */
|
|
90
|
+
readonly evolveEvery: number;
|
|
91
|
+
/** Override the memory dir. `null` (default) = `<root>/.rlm/memory` — this field only
|
|
92
|
+
* RELOCATES the store; the on/off switch is `enableMemory` (audit M3). */
|
|
93
|
+
readonly memoryDir: string | null;
|
|
94
|
+
/** v5: per-provider concurrent-request caps (e.g. `{ zai: 4 }`). Caps only lower limits. */
|
|
95
|
+
readonly providerMaxConcurrent?: Readonly<Record<string, number>>;
|
|
96
|
+
/** v5 doctrine: "delegation" = child engines get llm/memory/ledger only (no repo retrieval);
|
|
97
|
+
* "legacy" keeps today's full child surface as a one-flip rollback. */
|
|
98
|
+
readonly childSurface: "delegation" | "legacy";
|
|
64
99
|
}
|
|
65
100
|
|
|
66
101
|
/** Input to a (headless) RLM run. */
|
|
@@ -75,6 +110,12 @@ export interface RlmInput {
|
|
|
75
110
|
readonly parentNodeId?: string;
|
|
76
111
|
/** Remaining timeout for this subtree (set by parent from its LimitGuard). */
|
|
77
112
|
readonly remainingTimeoutMs?: number;
|
|
113
|
+
/** v5: budget for this run. Set only by the engine itself when chaining a continuation —
|
|
114
|
+
* a fresh budget is resolved from config when omitted. */
|
|
115
|
+
readonly budget?: import("./budget.ts").TokenBudget;
|
|
116
|
+
/** v5: the shared TaskLedger blackboard. Children inherit the parent's instance —
|
|
117
|
+
* set by childRun (the one child-RlmInput construction path); a fresh run gets a new one. */
|
|
118
|
+
readonly ledger?: import("./ledger.ts").TaskLedger;
|
|
78
119
|
}
|
|
79
120
|
|
|
80
121
|
/** Result of a completed RLM run. */
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { Markdown } from "@earendil-works/pi-tui";
|
|
5
5
|
import { registerRlmCommand } from "./commands/rlm.ts";
|
|
6
6
|
import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
|
|
7
|
+
import { registerRlmLlmCommand } from "./commands/rlm-llm.ts";
|
|
8
|
+
import { registerRlmRlmCommand } from "./commands/rlm-rlm.ts";
|
|
9
|
+
import type { RlmConfig } from "./core/types.ts";
|
|
7
10
|
import { createRlmTool } from "./tool/rlm-tool.ts";
|
|
8
11
|
import { createReplTool } from "./tool/repl-tool.ts";
|
|
9
12
|
import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
|
|
@@ -11,10 +14,15 @@ import { RlmController } from "./mode/rlm-mode.ts";
|
|
|
11
14
|
import { cheapestModel } from "./mode/llm-model.ts";
|
|
12
15
|
import { postRlmGuide } from "./ui/intro.ts";
|
|
13
16
|
import { setRlmModeStatus } from "./ui/status.ts";
|
|
17
|
+
import { RunRegistry } from "./ui/panel/run-registry.ts";
|
|
18
|
+
import { installTreePanel } from "./ui/panel/tree-panel.ts";
|
|
14
19
|
import { markdownTheme } from "./ui/theme-adapter.ts";
|
|
20
|
+
import { SANDBOX_WATCHDOG_HEARTBEAT_MS } from "./sandbox/sandbox.ts";
|
|
15
21
|
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
16
|
-
import {
|
|
22
|
+
import { buildSessionGates, type SubcallGates } from "./util/concurrency.ts";
|
|
17
23
|
import { BackgroundTasks } from "./tool/background-tasks.ts";
|
|
24
|
+
import { MemoryStore } from "./core/memory.ts";
|
|
25
|
+
import { modelComplete } from "./bridge/model.ts";
|
|
18
26
|
import { resolve } from "node:path";
|
|
19
27
|
import { resolveSource } from "./context/resolve.ts";
|
|
20
28
|
import { formatContextListing } from "./context/listing.ts";
|
|
@@ -38,8 +46,6 @@ export {
|
|
|
38
46
|
processRlmDepth,
|
|
39
47
|
} from "./mode/subagent.ts";
|
|
40
48
|
|
|
41
|
-
/** How often to keep the parent sandbox's request watchdog alive during detached work. */
|
|
42
|
-
const WATCHDOG_HEARTBEAT_MS = 30_000;
|
|
43
49
|
/** Soft token guard — cap bulk tool stdout; do NOT hard-block read/grep/bash readers. */
|
|
44
50
|
const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls", "read", "grep"]));
|
|
45
51
|
|
|
@@ -62,7 +68,21 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
62
68
|
|
|
63
69
|
// Init synchronously with defaults — ensures commands/tools/handlers register before session_start
|
|
64
70
|
const config = mergeConfig({});
|
|
65
|
-
|
|
71
|
+
// v5 durable memory: one store per session under <cwd>/.rlm/memory (L1 replay + L2 notes).
|
|
72
|
+
// NOTE (audit M4): this store IS shared by both composition roots, but the TaskLedger is
|
|
73
|
+
// NOT — the native repl() session and each headless rlm run each keep their own blackboard
|
|
74
|
+
// (v5 parity: per-run ledger). Claims/coalescing reset at that boundary, by design.
|
|
75
|
+
// The consolidation LLM + real workspace root are attached in session_start (setLlm/setRoot).
|
|
76
|
+
const memory = new MemoryStore(
|
|
77
|
+
process.cwd(),
|
|
78
|
+
{
|
|
79
|
+
dir: config.memoryDir ?? undefined,
|
|
80
|
+
injectNoteTokens: config.injectNoteTokens,
|
|
81
|
+
evolveEvery: config.evolveEvery,
|
|
82
|
+
},
|
|
83
|
+
config.enableMemory,
|
|
84
|
+
);
|
|
85
|
+
const controller = new RlmController(config, memory);
|
|
66
86
|
let onSandboxDiscardExtra: (() => void) | undefined;
|
|
67
87
|
const sandboxManager = new SandboxManager({
|
|
68
88
|
execTimeoutS: config.execTimeoutS,
|
|
@@ -75,20 +95,32 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
75
95
|
awaitTimeoutS: Math.round(config.requestTimeoutMs / 1000),
|
|
76
96
|
onSandboxDiscarded: () => { onSandboxDiscardExtra?.(); },
|
|
77
97
|
});
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const gates = createSubcallGates(config.maxConcurrentSubcalls, config.maxConcurrentChildren);
|
|
98
|
+
// v5: sub-call admission is built per session (see session_start) so provider concurrency
|
|
99
|
+
// caps resolve against the models actually in use.
|
|
81
100
|
const background = new BackgroundTasks({
|
|
82
101
|
maxTimeoutMs: config.maxTimeoutMs,
|
|
83
102
|
maxTokens: config.maxTokens,
|
|
84
103
|
maxErrors: config.maxErrors,
|
|
85
104
|
});
|
|
105
|
+
// Session tree panel: every repl cell / rlm run / detached bg task registers here;
|
|
106
|
+
// the below-editor widget + agent modal read from it. Background is persistent
|
|
107
|
+
// and hides itself while idle.
|
|
108
|
+
const runRegistry = new RunRegistry();
|
|
109
|
+
runRegistry.register({
|
|
110
|
+
runId: "background",
|
|
111
|
+
label: "background tasks",
|
|
112
|
+
emitter: background.emitter,
|
|
113
|
+
subcalls: () => background.liveSubcalls(),
|
|
114
|
+
totals: () => background.liveTotals(),
|
|
115
|
+
hideWhenEmpty: true,
|
|
116
|
+
});
|
|
117
|
+
let treePanelInstalled = false;
|
|
86
118
|
// A detached child works in its OWN sandbox, so this one sees no frames and its request
|
|
87
119
|
// watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
|
|
88
120
|
// with it. Keep it alive while detached work is genuinely in flight.
|
|
89
121
|
const watchdogHeartbeat = setInterval(() => {
|
|
90
122
|
if (background.pending > 0) sandboxManager.refreshWatchdog();
|
|
91
|
-
},
|
|
123
|
+
}, SANDBOX_WATCHDOG_HEARTBEAT_MS);
|
|
92
124
|
watchdogHeartbeat.unref();
|
|
93
125
|
|
|
94
126
|
/** Memoised cwd seed — one resolveSource(pathPrefix:"") per session. */
|
|
@@ -146,9 +178,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
146
178
|
// ── Commands ──
|
|
147
179
|
registerRlmCommand(pi, controller);
|
|
148
180
|
registerRlmConfigCommand(pi, controller);
|
|
181
|
+
registerRlmLlmCommand(pi, controller);
|
|
182
|
+
registerRlmRlmCommand(pi, controller);
|
|
149
183
|
|
|
150
184
|
// ── Tool registration ──
|
|
151
|
-
pi.registerTool(createRlmTool(controller));
|
|
185
|
+
pi.registerTool(createRlmTool(controller, runRegistry));
|
|
152
186
|
let guidePosted = false;
|
|
153
187
|
|
|
154
188
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -157,6 +191,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
157
191
|
const persisted = await loadSettings();
|
|
158
192
|
controller.config = mergeConfig(persisted.config);
|
|
159
193
|
controller.savedLlmRef = persisted.llm ?? undefined;
|
|
194
|
+
controller.savedRlmRef = persisted.rlm ?? undefined;
|
|
160
195
|
|
|
161
196
|
// An explicit --rlm flag wins over the persisted setting for this session.
|
|
162
197
|
const flag = pi.getFlag("rlm");
|
|
@@ -173,6 +208,25 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
173
208
|
console.warn(`[rlm] model registry refresh failed: ${errorMessage(err)}`);
|
|
174
209
|
}
|
|
175
210
|
|
|
211
|
+
if (controller.savedRlmRef) {
|
|
212
|
+
const resolvedRlm = resolveModelId(ctx.modelRegistry, controller.savedRlmRef);
|
|
213
|
+
if (resolvedRlm) {
|
|
214
|
+
controller.rlmModel = resolvedRlm;
|
|
215
|
+
} else {
|
|
216
|
+
console.warn(
|
|
217
|
+
`[rlm] pinned rlm model ${controller.savedRlmRef} not in registry; following session model until it reappears`,
|
|
218
|
+
);
|
|
219
|
+
try {
|
|
220
|
+
ctx.ui.notify(
|
|
221
|
+
`RLM: pinned rlm=${controller.savedRlmRef} unavailable — following session model until it is`,
|
|
222
|
+
"warning",
|
|
223
|
+
);
|
|
224
|
+
} catch {
|
|
225
|
+
// Some hosts have no UI at session_start.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
176
230
|
if (controller.savedLlmRef) {
|
|
177
231
|
const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
|
|
178
232
|
if (resolved) {
|
|
@@ -198,6 +252,40 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
198
252
|
const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
|
|
199
253
|
const model = ctx.model;
|
|
200
254
|
if (llmModel && model) {
|
|
255
|
+
// Consolidation runs on the cheap worker model through the single completion entry point;
|
|
256
|
+
// the workspace root is only known once the session starts.
|
|
257
|
+
const consolidateModel = llmModel;
|
|
258
|
+
memory.setLlm((prompt) =>
|
|
259
|
+
modelComplete([{ role: "user", content: prompt }], { model: consolidateModel, registry: ctx.modelRegistry })
|
|
260
|
+
.then((r) => r.text));
|
|
261
|
+
memory.setRoot(ctx.cwd ?? process.cwd());
|
|
262
|
+
// v5 provider caps (audit C1/C6): ONE resolver shared by both composition roots — the
|
|
263
|
+
// repl() tool and RlmController.start admit through the same pool, each gate capped
|
|
264
|
+
// against the model that actually runs on it (leaves = worker, children = smart).
|
|
265
|
+
// Memoized on (config, providers) so /rlm-config changes apply without a restart.
|
|
266
|
+
let gatesMemo:
|
|
267
|
+
| { readonly config: RlmConfig; readonly smart: string; readonly worker: string; readonly gates: SubcallGates }
|
|
268
|
+
| undefined;
|
|
269
|
+
const resolveSessionGates = (): SubcallGates => {
|
|
270
|
+
const smart = model;
|
|
271
|
+
const worker = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
|
|
272
|
+
const workerProvider = worker.provider;
|
|
273
|
+
if (
|
|
274
|
+
gatesMemo === undefined ||
|
|
275
|
+
gatesMemo.config !== controller.config ||
|
|
276
|
+
gatesMemo.smart !== smart.provider ||
|
|
277
|
+
gatesMemo.worker !== workerProvider
|
|
278
|
+
) {
|
|
279
|
+
gatesMemo = {
|
|
280
|
+
config: controller.config,
|
|
281
|
+
smart: smart.provider,
|
|
282
|
+
worker: workerProvider,
|
|
283
|
+
gates: buildSessionGates(controller.config, smart.provider, workerProvider),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return gatesMemo.gates;
|
|
287
|
+
};
|
|
288
|
+
controller.setSessionGates(resolveSessionGates);
|
|
201
289
|
try {
|
|
202
290
|
pi.registerTool(createReplTool({
|
|
203
291
|
sandboxManager,
|
|
@@ -207,8 +295,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
207
295
|
getLlmModel: () => controller.resolveModels(ctx)?.llm,
|
|
208
296
|
registry: ctx.modelRegistry,
|
|
209
297
|
getConfig: () => controller.config,
|
|
210
|
-
gates,
|
|
298
|
+
gates: resolveSessionGates(),
|
|
299
|
+
resolveGates: resolveSessionGates,
|
|
211
300
|
background,
|
|
301
|
+
runRegistry,
|
|
302
|
+
memory,
|
|
212
303
|
registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
|
|
213
304
|
registerContextBundle: (bundle) => {
|
|
214
305
|
contextBundleRef = bundle;
|
|
@@ -231,7 +322,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
231
322
|
}
|
|
232
323
|
}
|
|
233
324
|
|
|
234
|
-
setRlmModeStatus(ctx
|
|
325
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
326
|
+
if (!treePanelInstalled) {
|
|
327
|
+
treePanelInstalled = true;
|
|
328
|
+
installTreePanel(ctx, runRegistry);
|
|
329
|
+
}
|
|
235
330
|
if (!guidePosted && controller.enabled) {
|
|
236
331
|
guidePosted = true;
|
|
237
332
|
postRlmGuide(pi, controller);
|
|
@@ -240,7 +335,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
240
335
|
|
|
241
336
|
// ── Keep the footer's context reading live (RLM exists to shrink this number) ──
|
|
242
337
|
pi.on("turn_end", async (_event, ctx) => {
|
|
243
|
-
setRlmModeStatus(ctx
|
|
338
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
244
339
|
});
|
|
245
340
|
|
|
246
341
|
/** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
|
package/src/mode/rlm-mode.ts
CHANGED
|
@@ -9,13 +9,16 @@
|
|
|
9
9
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
10
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
|
|
12
|
-
import { createEngine } from "../core/engine.ts";
|
|
12
|
+
import { createEngine, type EngineDeps } from "../core/engine.ts";
|
|
13
13
|
import { limitsFromConfig } from "../core/limits.ts";
|
|
14
14
|
import type { RlmConfig, RlmResult } from "../core/types.ts";
|
|
15
15
|
import { resolveSource } from "../context/resolve.ts";
|
|
16
16
|
import { RlmEmitter } from "../tool/rlm-events.ts";
|
|
17
17
|
import { formatError } from "../util/errors.ts";
|
|
18
18
|
import { cheapestModel } from "./llm-model.ts";
|
|
19
|
+
import type { MemoryStore } from "../core/memory.ts";
|
|
20
|
+
import type { RunRlm } from "../core/types.ts";
|
|
21
|
+
import type { SubcallGates } from "../util/concurrency.ts";
|
|
19
22
|
|
|
20
23
|
export interface RunHandle {
|
|
21
24
|
readonly abort: () => void;
|
|
@@ -32,9 +35,25 @@ export class RlmController {
|
|
|
32
35
|
savedLlmRef: string | undefined;
|
|
33
36
|
/** Set by applyLlmSelection when the user explicitly picks "cheapest (auto)". */
|
|
34
37
|
explicitClearPin = false;
|
|
38
|
+
/** Pinned rlm root/worker model — when unset, child engines follow pi's session model. */
|
|
39
|
+
rlmModel: Model<Api> | undefined;
|
|
40
|
+
savedRlmRef: string | undefined;
|
|
41
|
+
/** Set by applyRlmSelection when the user explicitly picks "(follow session model)". */
|
|
42
|
+
explicitClearRlmPin = false;
|
|
35
43
|
private active: AbortController | null = null;
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
/** v5: session admission gates (provider-capped), shared with the repl() tool — set at
|
|
45
|
+
* session_start so BOTH composition roots admit through one pool (audit C1). */
|
|
46
|
+
private sessionGates: (() => SubcallGates) | undefined;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
public config: RlmConfig,
|
|
50
|
+
/** v5 durable memory — shared with the repl tool so child runs replay/persist too. */
|
|
51
|
+
public readonly memory?: MemoryStore,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
setSessionGates(getGates: () => SubcallGates): void {
|
|
55
|
+
this.sessionGates = getGates;
|
|
56
|
+
}
|
|
38
57
|
|
|
39
58
|
get enabled(): boolean {
|
|
40
59
|
return this.config.enabled;
|
|
@@ -64,6 +83,9 @@ export class RlmController {
|
|
|
64
83
|
llm: this.explicitClearPin
|
|
65
84
|
? null
|
|
66
85
|
: (modelRef(this.llmModel) ?? this.savedLlmRef),
|
|
86
|
+
rlm: this.explicitClearRlmPin
|
|
87
|
+
? null
|
|
88
|
+
: (modelRef(this.rlmModel) ?? this.savedRlmRef),
|
|
67
89
|
});
|
|
68
90
|
}
|
|
69
91
|
|
|
@@ -77,12 +99,41 @@ export class RlmController {
|
|
|
77
99
|
|
|
78
100
|
resolveModels(ctx: ExtensionContext): { model: Model<Api>; llm: Model<Api> } | undefined {
|
|
79
101
|
if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
|
|
80
|
-
|
|
102
|
+
if (!this.rlmModel && this.savedRlmRef) this.rlmModel = resolveModelId(ctx.modelRegistry, this.savedRlmRef);
|
|
103
|
+
// The rlm pin wins over the session model; unset → follow pi's active model.
|
|
104
|
+
const model = this.rlmModel ?? ctx.model ?? cheapestModel(ctx.modelRegistry);
|
|
81
105
|
if (!model) return undefined;
|
|
82
106
|
const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
|
|
83
107
|
return { model, llm };
|
|
84
108
|
}
|
|
85
109
|
|
|
110
|
+
/** Test seam (audit R7): intercept the exact object `createEngine` receives. */
|
|
111
|
+
protected spawnEngine(deps: EngineDeps): RunRlm {
|
|
112
|
+
return createEngine(deps);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The ONE engine construction path for this controller (DRY #6 — a second path that
|
|
116
|
+
* forgets to grow is exactly how issue #4 and audit C1 happened). Protected so tests can
|
|
117
|
+
* subclass and assert the wiring without touching the network. */
|
|
118
|
+
protected buildEngine(args: {
|
|
119
|
+
readonly ctx: ExtensionContext;
|
|
120
|
+
readonly models: { readonly model: Model<Api>; readonly llm: Model<Api> };
|
|
121
|
+
readonly signal: AbortSignal;
|
|
122
|
+
readonly emitter: RlmEmitter;
|
|
123
|
+
}): RunRlm {
|
|
124
|
+
return this.spawnEngine({
|
|
125
|
+
model: args.models.model,
|
|
126
|
+
llmModel: args.models.llm,
|
|
127
|
+
registry: args.ctx.modelRegistry,
|
|
128
|
+
config: this.config,
|
|
129
|
+
signal: args.signal,
|
|
130
|
+
emitter: args.emitter,
|
|
131
|
+
limits: limitsFromConfig(this.config),
|
|
132
|
+
memory: this.memory,
|
|
133
|
+
gates: this.sessionGates?.(),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
86
137
|
start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter): RunHandle {
|
|
87
138
|
const models = this.resolveModels(ctx);
|
|
88
139
|
if (!models) throw new Error("no model with configured auth is available");
|
|
@@ -102,14 +153,11 @@ export class RlmController {
|
|
|
102
153
|
? result.value.payload
|
|
103
154
|
: formatError(`failed to pack repository — ${result.error}`);
|
|
104
155
|
}
|
|
105
|
-
const engine =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
registry: ctx.modelRegistry,
|
|
109
|
-
config: this.config,
|
|
156
|
+
const engine = this.buildEngine({
|
|
157
|
+
ctx,
|
|
158
|
+
models,
|
|
110
159
|
signal: abortController.signal,
|
|
111
160
|
emitter: emitter ?? new RlmEmitter(),
|
|
112
|
-
limits: limitsFromConfig(this.config),
|
|
113
161
|
});
|
|
114
162
|
return await engine({ rootPrompt: input.rootPrompt, context: contextValue, depth: 0 });
|
|
115
163
|
})().finally(() => {
|