@hicaru/pi-rlm 0.2.0 → 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 +382 -0
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +7 -15
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +115 -360
- 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 +49 -10
- 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 -386
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +14 -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/py/worker.py +836 -0
- package/src/sandbox/sandbox-manager.ts +33 -6
- package/src/sandbox/sandbox.ts +153 -182
- package/src/text/tokens.ts +29 -3
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +4 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +178 -216
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +10 -16
- package/src/tool/rlm-tool.ts +1 -12
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- 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 +91 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/fallback-todo.ts +0 -137
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/bridge/rlm-query.ts +0 -108
- 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/sandbox/worker.py +0 -1078
- 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
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single implementation of the sub-LLM handler set (llm_query, llm_query_batched,
|
|
3
|
+
* rlm_query, rlm_query_batched).
|
|
4
|
+
*
|
|
5
|
+
* Resolves AGENTS.md DRY #1–#5, which previously lived twice: once in `createLlmBridge` /
|
|
6
|
+
* `createRlmHandlers` for the headless engine, once in `NativeBridgeState` for the repl()
|
|
7
|
+
* tool. The two callers only ever differed in how they answer one question — "which emitter,
|
|
8
|
+
* limits and parent node does THIS interrupt belong to?" — so that is the only thing they
|
|
9
|
+
* still supply, as `resolve`. The engine binds one Invocation for a whole run; the repl()
|
|
10
|
+
* tool swaps one per turn and routes `spawn()`ed work to its session registry.
|
|
11
|
+
*
|
|
12
|
+
* Concurrency: every leaf completion passes through `gates.leaf`, every child engine through
|
|
13
|
+
* `gates.rlm.at(depth)`, so a sandbox that puts 25 batches on the wire at once is still
|
|
14
|
+
* bounded session-wide. See util/concurrency.ts for why the rlm gate is per-depth.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { displayModelRef, modelRef, resolveModelId } from "../config/settings.ts";
|
|
20
|
+
import { type ChatMsg, modelComplete } from "./model.ts";
|
|
21
|
+
import { previewText } from "../text/preview.ts";
|
|
22
|
+
import { checkResourceLimits } from "../core/resource-limits.ts";
|
|
23
|
+
import { filterContextByPaths } from "../context/library-context.ts";
|
|
24
|
+
import type { RlmInput, RlmResult, Sampling } from "../core/types.ts";
|
|
25
|
+
import type { SubcallGates } from "../util/concurrency.ts";
|
|
26
|
+
import type { SubcallOpts, SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
27
|
+
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
28
|
+
import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The slice of LimitGuard these handlers need. Narrow on purpose: the headless bridge is
|
|
32
|
+
* constructed from a remaining-timeout callback rather than owning a guard, and both
|
|
33
|
+
* shapes satisfy this.
|
|
34
|
+
*/
|
|
35
|
+
export interface InvocationLimits {
|
|
36
|
+
remainingTimeoutMs(): number | undefined;
|
|
37
|
+
addUsage(usage: Usage): void;
|
|
38
|
+
addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Adapt a "how much is left?" callback to InvocationLimits.
|
|
43
|
+
*
|
|
44
|
+
* The headless engine owns the real LimitGuard and folds usage in through `onUsage` /
|
|
45
|
+
* `onChildUsage`, so the accounting methods here are deliberately inert.
|
|
46
|
+
*/
|
|
47
|
+
export function limitsFromRemaining(
|
|
48
|
+
remaining?: () => { readonly timeoutMs?: number },
|
|
49
|
+
): InvocationLimits {
|
|
50
|
+
return {
|
|
51
|
+
remainingTimeoutMs: () => remaining?.().timeoutMs,
|
|
52
|
+
addUsage: () => {},
|
|
53
|
+
addRaw: () => {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where one interrupt's reporting and accounting go.
|
|
59
|
+
*
|
|
60
|
+
* Captured at interrupt entry and threaded down, never re-read: once handlers can outlive
|
|
61
|
+
* their exec, re-reading mutable tool state after an await would attribute a sub-call to
|
|
62
|
+
* whichever turn happens to be current when it settles.
|
|
63
|
+
*/
|
|
64
|
+
export interface Invocation {
|
|
65
|
+
readonly emitter: RlmEmitter;
|
|
66
|
+
readonly parentId: string | undefined;
|
|
67
|
+
readonly depth: number;
|
|
68
|
+
readonly limits: InvocationLimits;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The config slice these handlers read. Structurally satisfied by `RlmConfig`, and re-read on
|
|
73
|
+
* every call so `/rlm-config` changes take effect without rebuilding the sandbox.
|
|
74
|
+
*/
|
|
75
|
+
export interface SubcallConfig {
|
|
76
|
+
readonly maxPromptChars: number;
|
|
77
|
+
readonly maxDepth: number;
|
|
78
|
+
readonly subSampling?: Sampling;
|
|
79
|
+
readonly subSystemPrompt?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SubcallHandlerDeps {
|
|
83
|
+
/**
|
|
84
|
+
* Pick the Invocation for this interrupt. `null` ⇒ the bridge is not wired yet.
|
|
85
|
+
*
|
|
86
|
+
* `depth` is the depth the sandbox reported. The headless engine trusts it (its handlers
|
|
87
|
+
* serve one sandbox per depth); the repl() tool overrides it with its own turn depth.
|
|
88
|
+
*/
|
|
89
|
+
readonly resolve: (opts: SubcallOpts, depth: number) => Invocation | null;
|
|
90
|
+
/** Session-wide admission control. Required — a per-caller default would silently unbound it. */
|
|
91
|
+
readonly gates: SubcallGates;
|
|
92
|
+
readonly registry: ModelRegistry;
|
|
93
|
+
readonly getLlmModel: () => Model<Api>;
|
|
94
|
+
/** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
|
|
95
|
+
readonly getConfig: () => SubcallConfig;
|
|
96
|
+
readonly signal?: AbortSignal;
|
|
97
|
+
readonly onUsage?: (usage: Usage, role: "sub") => void;
|
|
98
|
+
|
|
99
|
+
// ── recursion (omit all three to get llm-only handlers) ──
|
|
100
|
+
/**
|
|
101
|
+
* Spawns a child RLM for rlm_query.
|
|
102
|
+
*
|
|
103
|
+
* Receives the parent's Invocation so the child can report on the same emitter its
|
|
104
|
+
* parent subcall node lives on — a child of detached work must not emit to a turn
|
|
105
|
+
* emitter that will be shut down before it finishes, and keeping parent and child on
|
|
106
|
+
* one emitter is what lets the session registry drain the subtree intact.
|
|
107
|
+
*/
|
|
108
|
+
readonly runChild?: (input: RlmInput, inv: Invocation) => Promise<RlmResult>;
|
|
109
|
+
/**
|
|
110
|
+
* The parent's live context, read at spawn time and never captured: a library loaded on turn 3
|
|
111
|
+
* must reach a child spawned on turn 4. `undefined`/`null` ⇒ no inheritance, and the child falls
|
|
112
|
+
* back to prompt-as-context.
|
|
113
|
+
*
|
|
114
|
+
* This is the ONLY inheritance seam. Adding a second construction path for a child's world
|
|
115
|
+
* would re-open issue #4 on whichever path forgets to grow.
|
|
116
|
+
*/
|
|
117
|
+
readonly getChildContext?: () => unknown;
|
|
118
|
+
readonly getModel?: () => Model<Api>;
|
|
119
|
+
/**
|
|
120
|
+
* What rlm_query degrades to at the depth cap. A child RLM there would just be an LM, so
|
|
121
|
+
* both callers hand in their own one-shot path rather than re-deriving one here.
|
|
122
|
+
*/
|
|
123
|
+
readonly degrade?: (prompt: string, model: string | null, depth: number) => Promise<string>;
|
|
124
|
+
/** Called with a child run's totals so a caller-side guard can debit them too. */
|
|
125
|
+
readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
|
|
126
|
+
/** Wraps detached work so a session registry can count what is still in flight. */
|
|
127
|
+
readonly trackDetached?: <T>(run: () => Promise<T>) => Promise<T>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** DRY #4 — the batch failure summary, previously written out in both copies. */
|
|
131
|
+
export function summarizeBatch(out: readonly string[]): { readonly failed: number; readonly error?: string } {
|
|
132
|
+
let failed = 0;
|
|
133
|
+
for (const item of out) if (isErrorText(item)) failed += 1;
|
|
134
|
+
if (failed === 0) return { failed: 0 };
|
|
135
|
+
const error = failed === out.length
|
|
136
|
+
? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
|
|
137
|
+
: `${failed}/${out.length} sub-calls failed`;
|
|
138
|
+
return { failed, error };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type SubcallHandlers = Pick<
|
|
142
|
+
SubLlmHandlers,
|
|
143
|
+
"llmQuery" | "llmQueryBatched" | "rlmQuery" | "rlmQueryBatched"
|
|
144
|
+
>;
|
|
145
|
+
|
|
146
|
+
const UNWIRED = formatError("RLM bridge not wired for this invocation");
|
|
147
|
+
|
|
148
|
+
function emptyResult(answer: string): RlmResult {
|
|
149
|
+
return { answer, iterations: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What a child RLM will see, plus any `paths=` prefix that selected nothing. */
|
|
153
|
+
interface ChildContext {
|
|
154
|
+
readonly context: unknown;
|
|
155
|
+
readonly unmatched: readonly string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const NO_UNMATCHED: readonly string[] = Object.freeze([]);
|
|
159
|
+
|
|
160
|
+
export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers {
|
|
161
|
+
/** DRY #3 — the one display-model resolution. */
|
|
162
|
+
const displayModel = (model: string | null): string =>
|
|
163
|
+
displayModelRef(deps.registry, model, deps.getLlmModel());
|
|
164
|
+
|
|
165
|
+
/** Detached work is counted by the session registry; attached work runs as-is. */
|
|
166
|
+
const detachable = <T>(opts: SubcallOpts, run: () => Promise<T>): Promise<T> =>
|
|
167
|
+
opts.detached && deps.trackDetached !== undefined ? deps.trackDetached(run) : run();
|
|
168
|
+
|
|
169
|
+
/** One leaf completion. Reports cost/tokens via `track`; never throws. */
|
|
170
|
+
async function complete1(
|
|
171
|
+
inv: Invocation,
|
|
172
|
+
prompt: string,
|
|
173
|
+
model: string | null,
|
|
174
|
+
track: (usage: Usage) => void,
|
|
175
|
+
): Promise<string> {
|
|
176
|
+
const config = deps.getConfig();
|
|
177
|
+
const limitError = checkResourceLimits({
|
|
178
|
+
timeoutMs: inv.limits.remainingTimeoutMs(),
|
|
179
|
+
});
|
|
180
|
+
if (limitError !== undefined) return limitError;
|
|
181
|
+
if (prompt.length > config.maxPromptChars) {
|
|
182
|
+
return formatError(
|
|
183
|
+
`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ` +
|
|
184
|
+
`${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const resolved = model ? resolveModelId(deps.registry, model) : undefined;
|
|
188
|
+
if (model && !resolved) return formatError(`unknown model override '${model}'`);
|
|
189
|
+
try {
|
|
190
|
+
const messages: ChatMsg[] = [{ role: "user", content: prompt }];
|
|
191
|
+
const res = await deps.gates.leaf.run(() => modelComplete(messages, {
|
|
192
|
+
model: resolved ?? deps.getLlmModel(),
|
|
193
|
+
registry: deps.registry,
|
|
194
|
+
system: config.subSystemPrompt,
|
|
195
|
+
maxTokens: config.subSampling?.maxTokens,
|
|
196
|
+
temperature: config.subSampling?.temperature,
|
|
197
|
+
reasoning: config.subSampling?.reasoning,
|
|
198
|
+
signal: deps.signal,
|
|
199
|
+
}));
|
|
200
|
+
inv.limits.addUsage(res.usage);
|
|
201
|
+
deps.onUsage?.(res.usage, "sub");
|
|
202
|
+
track(res.usage);
|
|
203
|
+
return res.text;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const msg = errorMessage(err);
|
|
206
|
+
const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
|
|
207
|
+
? " — try smaller batches or individual llm_query calls"
|
|
208
|
+
: "";
|
|
209
|
+
return formatError(`${msg}${hint}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* DRY #5 — the create → execute → update emit pattern for leaf sub-calls, in one place.
|
|
215
|
+
* rlm_query does NOT use this: its node is created inside `childRun`, and a wrapper here
|
|
216
|
+
* would double-report it.
|
|
217
|
+
*/
|
|
218
|
+
async function emitting<T>(
|
|
219
|
+
opts: SubcallOpts,
|
|
220
|
+
depth: number,
|
|
221
|
+
init: { kind: "llm" | "batch"; label: string; model: string | null; args: string },
|
|
222
|
+
run: (inv: Invocation, track: (usage: Usage) => void) => Promise<T>,
|
|
223
|
+
summarize: (out: T) => {
|
|
224
|
+
readonly preview: string;
|
|
225
|
+
readonly error?: string;
|
|
226
|
+
readonly failed?: number;
|
|
227
|
+
readonly total?: number;
|
|
228
|
+
},
|
|
229
|
+
unwired: () => T,
|
|
230
|
+
): Promise<T> {
|
|
231
|
+
const inv = deps.resolve(opts, depth);
|
|
232
|
+
if (inv === null) return unwired();
|
|
233
|
+
const id = inv.emitter.emitSubcallCreated({
|
|
234
|
+
kind: init.kind, parentId: inv.parentId, label: init.label,
|
|
235
|
+
model: displayModel(init.model), args: init.args, depth: inv.depth,
|
|
236
|
+
});
|
|
237
|
+
let costUsd = 0;
|
|
238
|
+
let tokens = 0;
|
|
239
|
+
const track = (usage: Usage): void => { costUsd += usage.cost.total; tokens += usage.totalTokens; };
|
|
240
|
+
const out = await detachable(opts, () => run(inv, track));
|
|
241
|
+
const summary = summarize(out);
|
|
242
|
+
inv.emitter.emitSubcallUpdated({
|
|
243
|
+
id,
|
|
244
|
+
status: summary.error !== undefined ? "error" : "done",
|
|
245
|
+
costUsd, tokens,
|
|
246
|
+
resultPreview: summary.preview,
|
|
247
|
+
detail: summary.error,
|
|
248
|
+
failedCount: summary.failed,
|
|
249
|
+
totalCount: summary.total,
|
|
250
|
+
});
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Resolve the child's world: the parent's live context, optionally narrowed by path prefixes.
|
|
256
|
+
*
|
|
257
|
+
* Falls back to prompt-as-context when nothing is wired, and to the FULL context when `paths`
|
|
258
|
+
* matched nothing — a silently blind child is exactly the bug this fixes, so a bad prefix
|
|
259
|
+
* degrades loudly (see the note childRun folds into rootPrompt) rather than quietly.
|
|
260
|
+
*/
|
|
261
|
+
function childContextFor(prompt: string, paths: readonly string[] | undefined): ChildContext {
|
|
262
|
+
const inherited = deps.getChildContext?.();
|
|
263
|
+
if (inherited === undefined || inherited === null) {
|
|
264
|
+
return Object.freeze({ context: prompt, unmatched: NO_UNMATCHED });
|
|
265
|
+
}
|
|
266
|
+
if (paths === undefined || paths.length === 0) {
|
|
267
|
+
return Object.freeze({ context: inherited, unmatched: NO_UNMATCHED });
|
|
268
|
+
}
|
|
269
|
+
const filtered = filterContextByPaths(inherited, paths);
|
|
270
|
+
return Object.freeze({
|
|
271
|
+
context: filtered.files.length > 0 ? filtered.files : inherited,
|
|
272
|
+
unmatched: filtered.unmatched,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* One child RLM run: depth cap → resource guard → spawn engine → debit parent.
|
|
278
|
+
* Emits its own subcall node, so callers must not wrap it in another.
|
|
279
|
+
*/
|
|
280
|
+
async function childRun(
|
|
281
|
+
inv: Invocation,
|
|
282
|
+
prompt: string,
|
|
283
|
+
model: string | null,
|
|
284
|
+
paths: readonly string[] | undefined,
|
|
285
|
+
): Promise<RlmResult> {
|
|
286
|
+
const childDepth = inv.depth + 1;
|
|
287
|
+
const run = deps.runChild;
|
|
288
|
+
const maxDepth = deps.getConfig().maxDepth;
|
|
289
|
+
|
|
290
|
+
// At the cap a child RLM would just be an LM — short-circuit to the caller's one-shot path.
|
|
291
|
+
if (run === undefined || childDepth >= maxDepth) {
|
|
292
|
+
const degrade = deps.degrade;
|
|
293
|
+
const answer = degrade !== undefined
|
|
294
|
+
? await degrade(prompt, model, inv.depth)
|
|
295
|
+
: await complete1(inv, prompt, model, () => {});
|
|
296
|
+
return emptyResult(answer);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const remTimeout = inv.limits.remainingTimeoutMs();
|
|
300
|
+
const limitError = checkResourceLimits({ timeoutMs: remTimeout });
|
|
301
|
+
if (limitError) return emptyResult(limitError);
|
|
302
|
+
|
|
303
|
+
const rootModel = deps.getModel?.();
|
|
304
|
+
const resolvedOverride = model ? resolveModelId(deps.registry, model) : undefined;
|
|
305
|
+
const modelLabel = model
|
|
306
|
+
? (modelRef(resolvedOverride) ?? `unknown/${model}`)
|
|
307
|
+
: (rootModel === undefined ? undefined : (modelRef(rootModel) ?? rootModel.id));
|
|
308
|
+
const subId = inv.emitter.emitSubcallCreated({
|
|
309
|
+
kind: "rlm", parentId: inv.parentId, label: "rlm_query",
|
|
310
|
+
model: modelLabel, detail: prompt.slice(0, 60), depth: childDepth,
|
|
311
|
+
});
|
|
312
|
+
// The child's context is the parent's world, not the prompt text. The prompt becomes the
|
|
313
|
+
// child's rootPrompt, exactly as a depth-0 run takes the user's question.
|
|
314
|
+
const child = childContextFor(prompt, paths);
|
|
315
|
+
const rootPrompt = child.unmatched.length === 0
|
|
316
|
+
? prompt
|
|
317
|
+
: `${prompt}\n\n[rlm] paths=${child.unmatched.join(", ")} matched no files; you received the full context.`;
|
|
318
|
+
try {
|
|
319
|
+
const res = await deps.gates.rlm.at(childDepth).run(() => run({
|
|
320
|
+
rootPrompt,
|
|
321
|
+
context: child.context,
|
|
322
|
+
depth: childDepth,
|
|
323
|
+
parentNodeId: subId,
|
|
324
|
+
modelOverride: model ?? undefined,
|
|
325
|
+
remainingTimeoutMs: remTimeout,
|
|
326
|
+
}, inv));
|
|
327
|
+
inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
|
|
328
|
+
deps.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
|
|
329
|
+
// The child emits live usage deltas on the shared emitter, so no aggregate cost here
|
|
330
|
+
// — adding it would double-count against SubcallStore's running totals.
|
|
331
|
+
inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: res.answer.slice(0, 200) });
|
|
332
|
+
return res;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
const msg = errorMessage(err);
|
|
335
|
+
inv.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
|
|
336
|
+
return emptyResult(formatError(`child RLM failed - ${msg}`));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
llmQuery: (prompt, model, depth, opts) => emitting(
|
|
342
|
+
opts, depth,
|
|
343
|
+
{ kind: "llm", label: "llm_query", model, args: `prompt: ${previewText(prompt)}` },
|
|
344
|
+
(inv, track) => complete1(inv, prompt, model, track),
|
|
345
|
+
(out) => ({ preview: previewText(out), error: isErrorText(out) ? out : undefined }),
|
|
346
|
+
() => UNWIRED,
|
|
347
|
+
),
|
|
348
|
+
|
|
349
|
+
llmQueryBatched: (prompts, model, depth, opts) => emitting(
|
|
350
|
+
opts, depth,
|
|
351
|
+
{ kind: "batch", label: `llm_query ×${prompts.length}`, model, args: `prompt: ${previewText(prompts[0] ?? "")}` },
|
|
352
|
+
// NO outer gate. `complete1` already takes the single `leaf` slot each prompt needs;
|
|
353
|
+
// an outer `gates.leaf.map` here deadlocked every batch of >= limit prompts.
|
|
354
|
+
(inv, track) => Promise.all(prompts.map((p) => complete1(inv, p, model, track))),
|
|
355
|
+
(out) => {
|
|
356
|
+
const { failed, error } = summarizeBatch(out);
|
|
357
|
+
const first = previewText(out[0] ?? "");
|
|
358
|
+
return {
|
|
359
|
+
preview: out.length > 1 ? `${first} (+${out.length - 1} more)` : first,
|
|
360
|
+
error, failed, total: out.length,
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
() => prompts.map(() => UNWIRED),
|
|
364
|
+
),
|
|
365
|
+
|
|
366
|
+
async rlmQuery(prompt, model, depth, opts) {
|
|
367
|
+
const inv = deps.resolve(opts, depth);
|
|
368
|
+
if (inv === null) return UNWIRED;
|
|
369
|
+
return detachable(opts, async () => (await childRun(inv, prompt, model, opts.paths)).answer);
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
async rlmQueryBatched(prompts, model, depth, opts) {
|
|
373
|
+
const inv = deps.resolve(opts, depth);
|
|
374
|
+
if (inv === null) return prompts.map(() => UNWIRED);
|
|
375
|
+
// Bounded by the per-depth rlm gate inside childRun, not by an outer pool.
|
|
376
|
+
return detachable(opts, async () => {
|
|
377
|
+
const results = await Promise.all(prompts.map((p) => childRun(inv, p, model, opts.paths)));
|
|
378
|
+
return results.map((r) => r.answer);
|
|
379
|
+
});
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -1,47 +1,76 @@
|
|
|
1
|
-
/** `/rlm-config` — choose
|
|
1
|
+
/** `/rlm-config` — choose the sub-LLM model, reasoning level, and run settings.
|
|
2
|
+
* The root model is always pi's active model; only the sub-LLM is configurable here. */
|
|
2
3
|
|
|
4
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
6
|
import { modelRef } from "../config/settings.ts";
|
|
5
|
-
import {
|
|
7
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
8
|
+
import { cheapestModel } from "../mode/llm-model.ts";
|
|
6
9
|
import { setRlmModeStatus } from "../ui/status.ts";
|
|
7
10
|
import { showConfigPanel } from "../ui/config-panel.ts";
|
|
8
|
-
import { selectModel } from "../ui/model-picker.ts";
|
|
11
|
+
import { pickableModels, selectModel } 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
|
+
}
|
|
9
20
|
|
|
10
21
|
export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
|
|
11
|
-
|
|
22
|
+
// Match Pi's native list: refresh so a just-added key appears, then use scoped models when
|
|
23
|
+
// the session narrowed them, else every available (auth-configured) model. Never getAll().
|
|
24
|
+
try {
|
|
25
|
+
await ctx.modelRegistry.refresh();
|
|
26
|
+
} catch {
|
|
27
|
+
// Fail-soft: show the cached available snapshot rather than aborting config.
|
|
28
|
+
}
|
|
29
|
+
const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
|
|
12
30
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
31
|
+
const llm = await selectModel(
|
|
32
|
+
ctx,
|
|
33
|
+
"LLM model (sub-calls: llm_query / map_files / rlm_query)",
|
|
34
|
+
models,
|
|
35
|
+
controller.llmModel,
|
|
36
|
+
controller.config.subSampling.reasoning,
|
|
37
|
+
);
|
|
38
|
+
if (llm !== undefined) {
|
|
39
|
+
controller.llmModel = llm?.model;
|
|
16
40
|
controller.setConfig(Object.freeze({
|
|
17
41
|
...controller.config,
|
|
18
|
-
subSampling: Object.freeze({ ...controller.config.subSampling, reasoning:
|
|
42
|
+
subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: llm?.thinkingLevel }),
|
|
19
43
|
}));
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
controller.setConfig(await showConfigPanel(ctx, controller.config));
|
|
23
47
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
48
|
+
// Only an explicit choice touches the persisted pin. ESC (`undefined`) used to fall through
|
|
49
|
+
// here and freeze whatever cheapest resolved to at that moment, which silently ended
|
|
50
|
+
// "cheapest (auto)" for every later session — including once a cheaper model appeared.
|
|
51
|
+
if (llm === null) controller.savedLlmRef = undefined; // "⟳ cheapest (auto)"
|
|
52
|
+
else if (llm !== undefined) controller.savedLlmRef = modelRef(llm.model);
|
|
53
|
+
|
|
30
54
|
const persisted = await controller.persist();
|
|
31
55
|
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
32
56
|
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
33
57
|
|
|
34
|
-
|
|
58
|
+
// Name the model that actually resolved, not "(cheapest)" — otherwise there is no way to
|
|
59
|
+
// tell whether the free model in the catalog was the one picked.
|
|
60
|
+
const pinned = controller.llmModel;
|
|
61
|
+
const effective = pinned ?? cheapestModel(ctx.modelRegistry);
|
|
62
|
+
const reasoning = controller.config.subSampling.reasoning;
|
|
35
63
|
ctx.ui.notify(
|
|
36
|
-
`RLM:
|
|
64
|
+
`RLM: llm=${modelRef(effective) ?? "(none available)"}`
|
|
65
|
+
+ `${pinned ? "" : " (cheapest, auto)"}${reasoning ? `/${reasoning}` : ""}`,
|
|
37
66
|
"info",
|
|
38
67
|
);
|
|
39
|
-
return
|
|
68
|
+
return llm !== undefined;
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
export function registerRlmConfigCommand(pi: ExtensionAPI, controller: RlmController): void {
|
|
43
72
|
pi.registerCommand("rlm-config", {
|
|
44
|
-
description: "Configure RLM
|
|
73
|
+
description: "Configure the RLM sub-LLM model and run settings.",
|
|
45
74
|
handler: async (_args, ctx) => {
|
|
46
75
|
await runRlmConfig(controller, ctx);
|
|
47
76
|
},
|
package/src/commands/rlm.ts
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
1
|
/** `/rlm` — toggle persistent Recursive Language Model mode. */
|
|
2
2
|
|
|
3
|
-
import type { ExtensionAPI
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type { RlmController, RunHandle } from "../mode/rlm-mode.ts";
|
|
7
|
-
import { postRlmGuide } from "../ui/intro.ts";
|
|
8
|
-
import { clearRlmStatus, setRlmModeStatus } from "../ui/status.ts";
|
|
9
|
-
import { listRunIds, readContextSidecar, readHeader, resolveRunId } from "../state/index.ts";
|
|
10
|
-
import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
|
|
11
|
-
import { reconstructRlmState } from "../state/resume.ts";
|
|
12
|
-
import type { ReconstructResult } from "../state/resume.ts";
|
|
13
|
-
import type { RunHeader } from "../state/rows.ts";
|
|
14
|
-
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
15
|
-
import { RlmEmitter } from "../tool/rlm-events.ts";
|
|
16
|
-
import { RlmEventAggregator } from "../tool/rlm-aggregator.ts";
|
|
17
|
-
import type { RlmDetails } from "../tool/rlm-details.ts";
|
|
18
|
-
import { cardHeader, cardStatsLine, renderCollapsedSubcallTree } from "../tool/subcall-render.ts";
|
|
19
|
-
import { errorMessage } from "../util/errors.ts";
|
|
20
|
-
|
|
21
|
-
/** Run ids offered for `/rlm-resume <TAB>`. */
|
|
22
|
-
const MAX_COMPLETIONS = 20;
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
5
|
+
import { setRlmModeStatus } from "../ui/status.ts";
|
|
23
6
|
|
|
24
7
|
export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
|
|
25
8
|
pi.registerCommand("rlm", {
|
|
@@ -43,69 +26,6 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
43
26
|
},
|
|
44
27
|
});
|
|
45
28
|
|
|
46
|
-
pi.registerCommand("rlm-help", {
|
|
47
|
-
description: "Show the RLM startup guide and command cheatsheet.",
|
|
48
|
-
handler: async () => {
|
|
49
|
-
postRlmGuide(pi, controller);
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
pi.registerCommand("rlm-resume", {
|
|
54
|
-
description: "Resume an interrupted RLM run (default @latest).",
|
|
55
|
-
getArgumentCompletions: async (prefix) => {
|
|
56
|
-
const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
|
|
57
|
-
const ids = await listRunIds(process.cwd(), dir);
|
|
58
|
-
const candidates = ["@latest", ...ids];
|
|
59
|
-
return candidates
|
|
60
|
-
.filter((value) => value.startsWith(prefix))
|
|
61
|
-
.slice(0, MAX_COMPLETIONS)
|
|
62
|
-
.map((value) => ({ value, label: value }));
|
|
63
|
-
},
|
|
64
|
-
handler: async (args, ctx) => {
|
|
65
|
-
if (controller.isBusy()) {
|
|
66
|
-
ctx.ui.notify("RLM is busy (use /rlm-stop to cancel).", "warning");
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const ref = args.trim() || "@latest";
|
|
70
|
-
const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
|
|
71
|
-
const cwd = ctx.cwd ?? process.cwd();
|
|
72
|
-
const runId = await resolveRunId(cwd, dir, ref);
|
|
73
|
-
if (!runId) { ctx.ui.notify(`No resumable RLM run for '${ref}'.`, "error"); return; }
|
|
74
|
-
const header = await readHeader(cwd, dir, runId);
|
|
75
|
-
if (!header) { ctx.ui.notify(`Run ${runId} has no header.`, "error"); return; }
|
|
76
|
-
const systemPrompt = buildRlmSystemPrompt(
|
|
77
|
-
{ contextType: header.context.type, contextChars: header.context.chars, rootPrompt: header.rootPrompt },
|
|
78
|
-
{
|
|
79
|
-
orchestrator: header.meta.orchestrator,
|
|
80
|
-
recursion: 1 < header.meta.maxDepth,
|
|
81
|
-
askUserQuestion: controller.config.askUserQuestion,
|
|
82
|
-
todo: controller.config.todo,
|
|
83
|
-
},
|
|
84
|
-
);
|
|
85
|
-
let recon: ReconstructResult;
|
|
86
|
-
try { recon = await reconstructRlmState(cwd, dir, runId, systemPrompt); }
|
|
87
|
-
catch (e) {
|
|
88
|
-
ctx.ui.notify(`RLM resume failed: corrupt run state — ${errorMessage(e)}`, "error");
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (!recon.ok) { ctx.ui.notify(`Cannot resume ${runId}: ${recon.reason}.`, "error"); return; }
|
|
92
|
-
if (recon.terminated) { ctx.ui.notify(`Run ${runId} already finished.`, "info"); return; }
|
|
93
|
-
const context = await readContextSidecar(cwd, dir, runId, header.context.json);
|
|
94
|
-
if (context === undefined) // R-C2: warn instead of silently resuming on empty context
|
|
95
|
-
ctx.ui.notify(`Warning: context sidecar missing for ${runId} — resuming without original context.`, "warning");
|
|
96
|
-
await executeRlmRunWithResume(pi, controller, ctx, recon, header, context ?? "");
|
|
97
|
-
},
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
pi.registerCommand("rlm-runs", {
|
|
101
|
-
description: "List recent RLM runs.",
|
|
102
|
-
handler: async (_args, ctx) => {
|
|
103
|
-
const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
|
|
104
|
-
const ids = (await listRunIds(ctx.cwd ?? process.cwd(), dir)).slice(0, 20);
|
|
105
|
-
ctx.ui.notify(ids.length ? ids.join("\n") : "No RLM runs recorded.", "info");
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
|
|
109
29
|
pi.registerShortcut?.("ctrl+shift+r", {
|
|
110
30
|
description: "Toggle RLM mode (off also stops a running query)",
|
|
111
31
|
handler: async (ctx) => {
|
|
@@ -115,72 +35,3 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
115
35
|
},
|
|
116
36
|
});
|
|
117
37
|
}
|
|
118
|
-
|
|
119
|
-
/** Above-editor progress card for a `/rlm-resume` run: header + the live sub-call tree. */
|
|
120
|
-
function renderResumeWidget(details: RlmDetails | undefined, theme: Theme): Component {
|
|
121
|
-
const container = new Container();
|
|
122
|
-
if (!details) return container;
|
|
123
|
-
const turns = details.turns;
|
|
124
|
-
const stats = cardStatsLine(
|
|
125
|
-
details.totals,
|
|
126
|
-
theme,
|
|
127
|
-
turns.max > 0 ? `turn ${turns.current}/${turns.max}` : undefined,
|
|
128
|
-
);
|
|
129
|
-
container.addChild(new Text(cardHeader("RLM resume", details.status, stats, theme), 0, 0));
|
|
130
|
-
if (details.subcalls.length > 0) {
|
|
131
|
-
container.addChild(new Text(renderCollapsedSubcallTree(details.subcalls, theme), 0, 0));
|
|
132
|
-
}
|
|
133
|
-
return container;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function executeRlmRunWithResume(
|
|
137
|
-
pi: ExtensionAPI,
|
|
138
|
-
controller: RlmController,
|
|
139
|
-
ctx: ExtensionContext,
|
|
140
|
-
recon: ReconstructResult & { ok: true },
|
|
141
|
-
header: RunHeader,
|
|
142
|
-
context: unknown,
|
|
143
|
-
): Promise<void> {
|
|
144
|
-
let handle: RunHandle | undefined;
|
|
145
|
-
let emitter: RlmEmitter | undefined;
|
|
146
|
-
let aggregator: RlmEventAggregator | undefined;
|
|
147
|
-
try {
|
|
148
|
-
emitter = new RlmEmitter();
|
|
149
|
-
// Component factory rather than the string[] form: the array form is hard-capped at 10
|
|
150
|
-
// lines by pi, which the live sub-call tree exceeds as soon as a run fans out. The factory
|
|
151
|
-
// also receives the live theme, so the widget follows /theme switches.
|
|
152
|
-
let latest: RlmDetails | undefined;
|
|
153
|
-
aggregator = new RlmEventAggregator(emitter, (partial) => {
|
|
154
|
-
latest = partial.details;
|
|
155
|
-
if (!latest) return;
|
|
156
|
-
ctx.ui.setWidget?.("rlm-status", (_tui, theme) => renderResumeWidget(latest, theme), {
|
|
157
|
-
placement: "aboveEditor",
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
emitter.emitRootPrompt(header.rootPrompt);
|
|
161
|
-
const interactive = createPiInteractiveDeps(ctx);
|
|
162
|
-
if (controller.config.todo) {
|
|
163
|
-
for (const row of recon.todoRows) await interactive.onTodo?.(row.action, row.params);
|
|
164
|
-
}
|
|
165
|
-
handle = controller.start(ctx, { kind: "resume", resume: recon, context }, emitter, {
|
|
166
|
-
onAskUserQuestion: controller.config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
|
|
167
|
-
onTodo: controller.config.todo ? interactive.onTodo : undefined,
|
|
168
|
-
});
|
|
169
|
-
} catch (e) {
|
|
170
|
-
ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
pi.sendMessage({ customType: "rlm-question", content: `[resume] ${header.rootPrompt}`, display: true });
|
|
174
|
-
const { done } = handle;
|
|
175
|
-
try {
|
|
176
|
-
const result = await done;
|
|
177
|
-
pi.sendMessage({ customType: "rlm-answer", content: result.answer, display: true });
|
|
178
|
-
} catch (e) {
|
|
179
|
-
ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
|
|
180
|
-
} finally {
|
|
181
|
-
clearRlmStatus(ctx.ui);
|
|
182
|
-
ctx.ui.setWidget?.("rlm-status", undefined);
|
|
183
|
-
aggregator?.dispose();
|
|
184
|
-
emitter?.shutdown();
|
|
185
|
-
}
|
|
186
|
-
}
|