@hicaru/pi-rlm 0.2.1 → 0.3.0
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 +28 -47
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +22 -19
- package/src/bridge/add-context.ts +322 -0
- package/src/bridge/subcall-handlers.ts +63 -17
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +8 -18
- package/src/config/settings.ts +13 -34
- package/src/context/anydoc.ts +67 -0
- package/src/context/listing.ts +70 -0
- package/src/context/md-cache.ts +112 -0
- package/src/context/merge.ts +97 -0
- package/src/context/namespace.ts +180 -0
- package/src/context/resolve.ts +122 -0
- package/src/context/source-dir.ts +166 -0
- package/src/context/source-doc.ts +71 -0
- package/src/context/source-git.ts +51 -0
- package/src/context/source-text.ts +45 -0
- package/src/context/types.ts +88 -0
- package/src/context/walk.ts +250 -0
- package/src/core/engine.ts +61 -345
- 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 +10 -38
- package/src/index.ts +92 -54
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +28 -58
- package/src/prompts/glossary.ts +290 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +15 -408
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +160 -0
- package/src/sandbox/protocol.ts +20 -75
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +129 -0
- package/src/sandbox/py/worker.py +856 -0
- package/src/sandbox/sandbox-manager.ts +24 -9
- package/src/sandbox/sandbox.ts +99 -193
- package/src/text/tokens.ts +31 -5
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +60 -170
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +0 -14
- package/src/tool/rlm-tool.ts +2 -13
- package/src/ui/config-panel.ts +12 -20
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +9 -5
- package/src/bridge/fallback-todo.ts +0 -148
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/library.ts +0 -155
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/context/library-context.ts +0 -266
- package/src/context/repomix-context.ts +0 -204
- 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 -1456
- 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,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared add_context handler for headless engine and native repl() mode.
|
|
3
|
+
*
|
|
4
|
+
* Host packs the source via resolveSource (namespaced under ctx/<id>/) and returns the
|
|
5
|
+
* payload for the worker to append into the single `context` list.
|
|
6
|
+
*
|
|
7
|
+
* Idempotency is host-side:
|
|
8
|
+
* - prefix set (ctx/<id>/) for external sources
|
|
9
|
+
* - cwd seed: markSeededCwd + exact-path short-circuit for add_context(".")
|
|
10
|
+
* - subpath of seed: short-circuit only when the live context already holds un-prefixed
|
|
11
|
+
* entries under that relative path (gitignored subtrees still pack for real)
|
|
12
|
+
*
|
|
13
|
+
* Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
|
|
14
|
+
* across native repl() calls — getOrCreate only installs handlers at spawn.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
18
|
+
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
19
|
+
import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
20
|
+
import {
|
|
21
|
+
contextNamespace,
|
|
22
|
+
isContextFile,
|
|
23
|
+
} from "../context/namespace.ts";
|
|
24
|
+
import { resolveSource } from "../context/resolve.ts";
|
|
25
|
+
import { previewText } from "../text/preview.ts";
|
|
26
|
+
|
|
27
|
+
export interface AddContextBridgeOpts {
|
|
28
|
+
/** Fixed cwd (headless). Prefer getCwd when the sandbox outlives a single invocation. */
|
|
29
|
+
readonly cwd?: string;
|
|
30
|
+
/** Late-bound cwd (native mode — sandbox handlers outlive a single repl()). */
|
|
31
|
+
readonly getCwd?: () => string;
|
|
32
|
+
readonly emitter?: RlmEmitter;
|
|
33
|
+
/** Native mode: read the live emitter each call. */
|
|
34
|
+
readonly getEmitter?: () => RlmEmitter | null | undefined;
|
|
35
|
+
readonly parentId?: string;
|
|
36
|
+
readonly signal?: AbortSignal;
|
|
37
|
+
/** Prefixes already present in context — seeds host-side idempotency after a sandbox restart. */
|
|
38
|
+
readonly loadedPrefixes?: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* The live context this sandbox holds. Read to refuse pre-flight exactly what the worker's
|
|
41
|
+
* `_append_context` would reject, before any prefix is committed.
|
|
42
|
+
*/
|
|
43
|
+
readonly getContext?: () => unknown;
|
|
44
|
+
/**
|
|
45
|
+
* Post-load hook. The engine grows its live context here; native mode grows
|
|
46
|
+
* SandboxManager.contextPayload.
|
|
47
|
+
*/
|
|
48
|
+
readonly onLoaded?: (payload: unknown) => void | Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AddContextHandlerBundle {
|
|
52
|
+
readonly handlers: Pick<SubLlmHandlers, "addContext">;
|
|
53
|
+
/**
|
|
54
|
+
* Reset the loaded-prefix cache (call when the sandbox is
|
|
55
|
+
* discarded and will re-spawn).
|
|
56
|
+
*
|
|
57
|
+
* `keep` re-seeds the cache from the payload that will be replayed into the fresh worker.
|
|
58
|
+
* `loaded` is a CACHE of `contextPrefixesIn(context)` plus the cwd sentinel `""`, never
|
|
59
|
+
* independent state, so it may only be cleared by re-deriving it — clearing it outright
|
|
60
|
+
* would make the host re-clone a source the recreated worker already holds.
|
|
61
|
+
*/
|
|
62
|
+
readonly reset: (keep?: readonly string[]) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Register a prefix as already loaded without packing. Used by the cwd seed to plant the
|
|
65
|
+
* `""` sentinel so add_context of the same tree is a no-op.
|
|
66
|
+
*/
|
|
67
|
+
readonly markLoaded: (prefix: string) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Record the absolute path of the cwd seed. add_context(".") resolves to a ctx/<id>/
|
|
70
|
+
* namespace, not "", so the absolute-path check is the only reliable short-circuit.
|
|
71
|
+
*/
|
|
72
|
+
readonly markSeededCwd: (absPath: string) => void;
|
|
73
|
+
/** Prefixes loaded in this sandbox lifetime (for tests). */
|
|
74
|
+
readonly loadedPrefixes: () => ReadonlySet<string>;
|
|
75
|
+
/** Absolute seeded cwd, if any (for tests). */
|
|
76
|
+
readonly seededCwd: () => string | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* JS runtime kind → the Python type name worker.py reports, so both sides emit exactly one
|
|
81
|
+
* message for the same refusal. Covers every shape a context payload can take after JSON
|
|
82
|
+
* transport; anything else is a plain object, which `json.load` materializes as a dict.
|
|
83
|
+
*/
|
|
84
|
+
const PY_TYPE_NAME: Readonly<Record<string, string>> = Object.freeze({
|
|
85
|
+
string: "str", boolean: "bool", number: "int", bigint: "int", undefined: "None",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function pythonKindOf(value: unknown): string {
|
|
89
|
+
if (value === null) return "None"; // matches worker.py's `if ctx is not None else "None"`
|
|
90
|
+
return PY_TYPE_NAME[typeof value] ?? "dict";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Refusal messages shared with worker.py `_append_context`. The worker is the backstop; the host
|
|
95
|
+
* pre-flights the same two conditions so it never commits a prefix for an append that
|
|
96
|
+
* will be rejected. Keep the wording identical — a comment in worker.py points back here.
|
|
97
|
+
*/
|
|
98
|
+
const LIST_CONTEXT_REQUIRED = (kind: string): string =>
|
|
99
|
+
`add_context requires list context (file bundle); got ${kind}`;
|
|
100
|
+
const NO_FILES_PRODUCED = "add_context produced no files";
|
|
101
|
+
|
|
102
|
+
/** Absolute path with no trailing slash (except root). */
|
|
103
|
+
function absKey(path: string): string {
|
|
104
|
+
const resolved = resolve(path);
|
|
105
|
+
return resolved.length > 1 && (resolved.endsWith("/") || resolved.endsWith("\\"))
|
|
106
|
+
? resolved.slice(0, -1)
|
|
107
|
+
: resolved;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* True when the live context already holds un-prefixed (cwd-seed) entries under `relPrefix`.
|
|
112
|
+
* Used so add_context("./src/context") does not double-load a subpath that the seed already
|
|
113
|
+
* has — without blocking genuinely-absent (gitignored) subtrees.
|
|
114
|
+
*/
|
|
115
|
+
function contextHasUnprefixedUnder(context: unknown, relPrefix: string): boolean {
|
|
116
|
+
if (!Array.isArray(context) || relPrefix === "") return false;
|
|
117
|
+
const clean = relPrefix.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
118
|
+
if (clean === "" || clean.startsWith("..")) return false;
|
|
119
|
+
const withSlash = `${clean}/`;
|
|
120
|
+
for (let i = 0; i < context.length; i++) {
|
|
121
|
+
const entry: unknown = context[i];
|
|
122
|
+
if (!isContextFile(entry)) continue;
|
|
123
|
+
// Only cwd-seed paths are un-prefixed; ctx/<id>/… is a different source.
|
|
124
|
+
if (entry.path.startsWith("ctx/")) continue;
|
|
125
|
+
if (entry.path === clean || entry.path.startsWith(withSlash)) return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function alreadyLoadedResult(
|
|
131
|
+
sourceId: string,
|
|
132
|
+
pathPrefix: string,
|
|
133
|
+
): Awaited<ReturnType<SubLlmHandlers["addContext"]>> {
|
|
134
|
+
return {
|
|
135
|
+
payload: Object.freeze([]),
|
|
136
|
+
files: 0,
|
|
137
|
+
chars: 0,
|
|
138
|
+
sourceId,
|
|
139
|
+
pathPrefix,
|
|
140
|
+
alreadyLoaded: true,
|
|
141
|
+
documents: 0,
|
|
142
|
+
converted: 0,
|
|
143
|
+
skipped: Object.freeze([]),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildAddContextHandler(opts: AddContextBridgeOpts): AddContextHandlerBundle {
|
|
148
|
+
/** Prefixes already loaded in this sandbox — mirrors the worker's context state. */
|
|
149
|
+
const loaded = new Set<string>(opts.loadedPrefixes ?? []);
|
|
150
|
+
/** Absolute path of the cwd seed, if autoSeedCwd planted one successfully. */
|
|
151
|
+
let seededCwdAbs: string | undefined;
|
|
152
|
+
return {
|
|
153
|
+
reset: (keep) => {
|
|
154
|
+
const seed = keep ?? opts.loadedPrefixes ?? [];
|
|
155
|
+
loaded.clear();
|
|
156
|
+
for (const prefix of seed) loaded.add(prefix);
|
|
157
|
+
// Do NOT clear seededCwdAbs — the payload is still on disk in the manager and will be
|
|
158
|
+
// replayed; the absolute-path short-circuit must keep working after a death-recreate.
|
|
159
|
+
},
|
|
160
|
+
markLoaded: (prefix) => { loaded.add(prefix); },
|
|
161
|
+
markSeededCwd: (absPath) => {
|
|
162
|
+
seededCwdAbs = absKey(absPath);
|
|
163
|
+
loaded.add(""); // cwd sentinel — payloadPrefix never sees un-prefixed files
|
|
164
|
+
},
|
|
165
|
+
loadedPrefixes: () => loaded,
|
|
166
|
+
seededCwd: () => seededCwdAbs,
|
|
167
|
+
handlers: {
|
|
168
|
+
async addContext(source, depth) {
|
|
169
|
+
const emitter = opts.getEmitter?.() ?? opts.emitter;
|
|
170
|
+
const cwd = opts.getCwd?.() ?? opts.cwd;
|
|
171
|
+
if (cwd === undefined || cwd === "") {
|
|
172
|
+
throw new Error("add_context: no cwd configured");
|
|
173
|
+
}
|
|
174
|
+
const id = emitter?.emitSubcallCreated({
|
|
175
|
+
kind: "tool", parentId: opts.parentId,
|
|
176
|
+
label: "add_context",
|
|
177
|
+
args: previewText(source, 80),
|
|
178
|
+
depth,
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
// Pre-flight the worker's own refusal: a non-list context cannot be appended to, and
|
|
182
|
+
// committing a prefix for it would make the NEXT load lie with already_loaded.
|
|
183
|
+
const current = opts.getContext?.();
|
|
184
|
+
if (current !== undefined && !Array.isArray(current)) {
|
|
185
|
+
throw new Error(LIST_CONTEXT_REQUIRED(pythonKindOf(current)));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const trimmed = source.trim();
|
|
189
|
+
const isLocal = trimmed !== "" && !/^(https:\/\/|git@)/.test(trimmed)
|
|
190
|
+
&& !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
|
|
191
|
+
const candidate = isLocal
|
|
192
|
+
? absKey(isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed))
|
|
193
|
+
: undefined;
|
|
194
|
+
const cwdAbs = absKey(cwd);
|
|
195
|
+
|
|
196
|
+
// ── Cwd seed short-circuit / recovery ──
|
|
197
|
+
// Exact cwd: if already seeded → no-op; if seed failed (sticky, no markSeededCwd)
|
|
198
|
+
// pack un-prefixed so paths stay edit/write-friendly.
|
|
199
|
+
if (candidate !== undefined && candidate === cwdAbs) {
|
|
200
|
+
if (seededCwdAbs !== undefined) {
|
|
201
|
+
if (id) {
|
|
202
|
+
emitter?.emitSubcallUpdated({
|
|
203
|
+
id, status: "done", resultPreview: "already loaded (cwd seed)",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return alreadyLoadedResult("cwd", "");
|
|
207
|
+
}
|
|
208
|
+
// Recovery after failed seed (or autoSeedCwd off): pack as primary, un-prefixed.
|
|
209
|
+
const recovered = await resolveSource(source, {
|
|
210
|
+
cwd, pathPrefix: "", signal: opts.signal,
|
|
211
|
+
});
|
|
212
|
+
if (!recovered.ok) throw new Error(recovered.error);
|
|
213
|
+
const r = recovered.value;
|
|
214
|
+
if (r.payload.length === 0) throw new Error(NO_FILES_PRODUCED);
|
|
215
|
+
if (opts.onLoaded) await opts.onLoaded(r.payload);
|
|
216
|
+
seededCwdAbs = cwdAbs;
|
|
217
|
+
loaded.add("");
|
|
218
|
+
if (id) {
|
|
219
|
+
emitter?.emitSubcallUpdated({
|
|
220
|
+
id, status: "done",
|
|
221
|
+
resultPreview: `+${r.files} file(s) → context (cwd seed recovery, ${r.chars.toLocaleString()} chars)`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
payload: r.payload,
|
|
226
|
+
files: r.files,
|
|
227
|
+
chars: r.chars,
|
|
228
|
+
sourceId: r.sourceId,
|
|
229
|
+
pathPrefix: "",
|
|
230
|
+
alreadyLoaded: false,
|
|
231
|
+
documents: r.documents,
|
|
232
|
+
converted: r.converted,
|
|
233
|
+
skipped: r.skipped,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Subpath of the seeded cwd: only short-circuit when those files are already in
|
|
238
|
+
// context (un-prefixed). A gitignored subtree that the seed never had still packs.
|
|
239
|
+
if (candidate !== undefined && seededCwdAbs !== undefined
|
|
240
|
+
&& candidate !== seededCwdAbs
|
|
241
|
+
&& (candidate.startsWith(seededCwdAbs + sep) || candidate.startsWith(seededCwdAbs + "/"))) {
|
|
242
|
+
const rel = relative(seededCwdAbs, candidate).split(sep).join("/");
|
|
243
|
+
if (rel !== "" && !rel.startsWith("..") && contextHasUnprefixedUnder(current, rel)) {
|
|
244
|
+
if (id) {
|
|
245
|
+
emitter?.emitSubcallUpdated({
|
|
246
|
+
id, status: "done",
|
|
247
|
+
resultPreview:
|
|
248
|
+
`already in cwd seed under '${rel}/' — filter context by path prefix`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return alreadyLoadedResult("cwd", "");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Cheap pre-check BEFORE cloning/packing: same namespace ⇒ nothing to do.
|
|
256
|
+
const { sourceId: preId, pathPrefix: prefix } = contextNamespace(source, cwd);
|
|
257
|
+
if (loaded.has(prefix)) {
|
|
258
|
+
if (id) {
|
|
259
|
+
emitter?.emitSubcallUpdated({
|
|
260
|
+
id,
|
|
261
|
+
status: "done",
|
|
262
|
+
resultPreview: `already loaded (${prefix}*)`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return alreadyLoadedResult(preId, prefix);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const resolved = await resolveSource(source, { cwd, signal: opts.signal });
|
|
269
|
+
if (!resolved.ok) throw new Error(resolved.error);
|
|
270
|
+
const { payload, files, chars, sourceId, pathPrefix, documents, converted, skipped } =
|
|
271
|
+
resolved.value;
|
|
272
|
+
// The worker's other refusal, pre-flighted for the same reason.
|
|
273
|
+
if (payload.length === 0) throw new Error(NO_FILES_PRODUCED);
|
|
274
|
+
|
|
275
|
+
// Race: another concurrent load of the same prefix finished while we packed.
|
|
276
|
+
if (loaded.has(pathPrefix)) {
|
|
277
|
+
if (id) {
|
|
278
|
+
emitter?.emitSubcallUpdated({
|
|
279
|
+
id,
|
|
280
|
+
status: "done",
|
|
281
|
+
resultPreview: `already loaded (${pathPrefix}*)`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return alreadyLoadedResult(sourceId, pathPrefix);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Mark loaded only after the host has grown its own copy of the context.
|
|
288
|
+
if (opts.onLoaded) {
|
|
289
|
+
await opts.onLoaded(payload);
|
|
290
|
+
}
|
|
291
|
+
loaded.add(pathPrefix);
|
|
292
|
+
|
|
293
|
+
if (id) {
|
|
294
|
+
const docNote = documents > 0
|
|
295
|
+
? `, ${documents} doc(s)${converted > 0 ? ` (${converted} fresh)` : " (cached)"}`
|
|
296
|
+
: "";
|
|
297
|
+
emitter?.emitSubcallUpdated({
|
|
298
|
+
id,
|
|
299
|
+
status: "done",
|
|
300
|
+
resultPreview:
|
|
301
|
+
`+${files} file(s) → context (${pathPrefix}*, ${chars.toLocaleString()} chars${docNote})`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
payload,
|
|
306
|
+
files,
|
|
307
|
+
chars,
|
|
308
|
+
sourceId,
|
|
309
|
+
pathPrefix,
|
|
310
|
+
alreadyLoaded: false,
|
|
311
|
+
documents,
|
|
312
|
+
converted,
|
|
313
|
+
skipped,
|
|
314
|
+
};
|
|
315
|
+
} catch (err) {
|
|
316
|
+
if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
|
|
317
|
+
throw err; // serviceInterrupt catch → {error} reply → "Error: …" in the REPL
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -20,6 +20,7 @@ import { displayModelRef, modelRef, resolveModelId } from "../config/settings.ts
|
|
|
20
20
|
import { type ChatMsg, modelComplete } from "./model.ts";
|
|
21
21
|
import { previewText } from "../text/preview.ts";
|
|
22
22
|
import { checkResourceLimits } from "../core/resource-limits.ts";
|
|
23
|
+
import { filterContextByPaths } from "../context/merge.ts";
|
|
23
24
|
import type { RlmInput, RlmResult, Sampling } from "../core/types.ts";
|
|
24
25
|
import type { SubcallGates } from "../util/concurrency.ts";
|
|
25
26
|
import type { SubcallOpts, SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
@@ -28,11 +29,10 @@ import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
|
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The slice of LimitGuard these handlers need. Narrow on purpose: the headless bridge is
|
|
31
|
-
* constructed from a
|
|
32
|
+
* constructed from a remaining-timeout callback rather than owning a guard, and both
|
|
32
33
|
* shapes satisfy this.
|
|
33
34
|
*/
|
|
34
35
|
export interface InvocationLimits {
|
|
35
|
-
remainingBudgetUsd(): number | undefined;
|
|
36
36
|
remainingTimeoutMs(): number | undefined;
|
|
37
37
|
addUsage(usage: Usage): void;
|
|
38
38
|
addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
|
|
@@ -45,10 +45,9 @@ export interface InvocationLimits {
|
|
|
45
45
|
* `onChildUsage`, so the accounting methods here are deliberately inert.
|
|
46
46
|
*/
|
|
47
47
|
export function limitsFromRemaining(
|
|
48
|
-
remaining?: () => { readonly
|
|
48
|
+
remaining?: () => { readonly timeoutMs?: number },
|
|
49
49
|
): InvocationLimits {
|
|
50
50
|
return {
|
|
51
|
-
remainingBudgetUsd: () => remaining?.().budgetUsd,
|
|
52
51
|
remainingTimeoutMs: () => remaining?.().timeoutMs,
|
|
53
52
|
addUsage: () => {},
|
|
54
53
|
addRaw: () => {},
|
|
@@ -60,7 +59,7 @@ export function limitsFromRemaining(
|
|
|
60
59
|
*
|
|
61
60
|
* Captured at interrupt entry and threaded down, never re-read: once handlers can outlive
|
|
62
61
|
* their exec, re-reading mutable tool state after an await would attribute a sub-call to
|
|
63
|
-
* whichever turn happens to be current when it
|
|
62
|
+
* whichever turn happens to be current when it settles.
|
|
64
63
|
*/
|
|
65
64
|
export interface Invocation {
|
|
66
65
|
readonly emitter: RlmEmitter;
|
|
@@ -91,7 +90,7 @@ export interface SubcallHandlerDeps {
|
|
|
91
90
|
/** Session-wide admission control. Required — a per-caller default would silently unbound it. */
|
|
92
91
|
readonly gates: SubcallGates;
|
|
93
92
|
readonly registry: ModelRegistry;
|
|
94
|
-
readonly
|
|
93
|
+
readonly getLlmModel: () => Model<Api>;
|
|
95
94
|
/** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
|
|
96
95
|
readonly getConfig: () => SubcallConfig;
|
|
97
96
|
readonly signal?: AbortSignal;
|
|
@@ -107,6 +106,15 @@ export interface SubcallHandlerDeps {
|
|
|
107
106
|
* one emitter is what lets the session registry drain the subtree intact.
|
|
108
107
|
*/
|
|
109
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;
|
|
110
118
|
readonly getModel?: () => Model<Api>;
|
|
111
119
|
/**
|
|
112
120
|
* What rlm_query degrades to at the depth cap. A child RLM there would just be an LM, so
|
|
@@ -141,10 +149,18 @@ function emptyResult(answer: string): RlmResult {
|
|
|
141
149
|
return { answer, iterations: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
|
|
142
150
|
}
|
|
143
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
|
+
|
|
144
160
|
export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers {
|
|
145
161
|
/** DRY #3 — the one display-model resolution. */
|
|
146
162
|
const displayModel = (model: string | null): string =>
|
|
147
|
-
displayModelRef(deps.registry, model, deps.
|
|
163
|
+
displayModelRef(deps.registry, model, deps.getLlmModel());
|
|
148
164
|
|
|
149
165
|
/** Detached work is counted by the session registry; attached work runs as-is. */
|
|
150
166
|
const detachable = <T>(opts: SubcallOpts, run: () => Promise<T>): Promise<T> =>
|
|
@@ -159,7 +175,6 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
|
|
|
159
175
|
): Promise<string> {
|
|
160
176
|
const config = deps.getConfig();
|
|
161
177
|
const limitError = checkResourceLimits({
|
|
162
|
-
budgetUsd: inv.limits.remainingBudgetUsd(),
|
|
163
178
|
timeoutMs: inv.limits.remainingTimeoutMs(),
|
|
164
179
|
});
|
|
165
180
|
if (limitError !== undefined) return limitError;
|
|
@@ -174,7 +189,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
|
|
|
174
189
|
try {
|
|
175
190
|
const messages: ChatMsg[] = [{ role: "user", content: prompt }];
|
|
176
191
|
const res = await deps.gates.leaf.run(() => modelComplete(messages, {
|
|
177
|
-
model: resolved ?? deps.
|
|
192
|
+
model: resolved ?? deps.getLlmModel(),
|
|
178
193
|
registry: deps.registry,
|
|
179
194
|
system: config.subSystemPrompt,
|
|
180
195
|
maxTokens: config.subSampling?.maxTokens,
|
|
@@ -236,11 +251,38 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
|
|
|
236
251
|
return out;
|
|
237
252
|
}
|
|
238
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
|
+
|
|
239
276
|
/**
|
|
240
277
|
* One child RLM run: depth cap → resource guard → spawn engine → debit parent.
|
|
241
278
|
* Emits its own subcall node, so callers must not wrap it in another.
|
|
242
279
|
*/
|
|
243
|
-
async function childRun(
|
|
280
|
+
async function childRun(
|
|
281
|
+
inv: Invocation,
|
|
282
|
+
prompt: string,
|
|
283
|
+
model: string | null,
|
|
284
|
+
paths: readonly string[] | undefined,
|
|
285
|
+
): Promise<RlmResult> {
|
|
244
286
|
const childDepth = inv.depth + 1;
|
|
245
287
|
const run = deps.runChild;
|
|
246
288
|
const maxDepth = deps.getConfig().maxDepth;
|
|
@@ -254,9 +296,8 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
|
|
|
254
296
|
return emptyResult(answer);
|
|
255
297
|
}
|
|
256
298
|
|
|
257
|
-
const remBudget = inv.limits.remainingBudgetUsd();
|
|
258
299
|
const remTimeout = inv.limits.remainingTimeoutMs();
|
|
259
|
-
const limitError = checkResourceLimits({
|
|
300
|
+
const limitError = checkResourceLimits({ timeoutMs: remTimeout });
|
|
260
301
|
if (limitError) return emptyResult(limitError);
|
|
261
302
|
|
|
262
303
|
const rootModel = deps.getModel?.();
|
|
@@ -268,14 +309,19 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
|
|
|
268
309
|
kind: "rlm", parentId: inv.parentId, label: "rlm_query",
|
|
269
310
|
model: modelLabel, detail: prompt.slice(0, 60), depth: childDepth,
|
|
270
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.`;
|
|
271
318
|
try {
|
|
272
319
|
const res = await deps.gates.rlm.at(childDepth).run(() => run({
|
|
273
|
-
rootPrompt
|
|
274
|
-
context:
|
|
320
|
+
rootPrompt,
|
|
321
|
+
context: child.context,
|
|
275
322
|
depth: childDepth,
|
|
276
323
|
parentNodeId: subId,
|
|
277
324
|
modelOverride: model ?? undefined,
|
|
278
|
-
remainingBudgetUsd: remBudget,
|
|
279
325
|
remainingTimeoutMs: remTimeout,
|
|
280
326
|
}, inv));
|
|
281
327
|
inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
|
|
@@ -320,7 +366,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
|
|
|
320
366
|
async rlmQuery(prompt, model, depth, opts) {
|
|
321
367
|
const inv = deps.resolve(opts, depth);
|
|
322
368
|
if (inv === null) return UNWIRED;
|
|
323
|
-
return detachable(opts, async () => (await childRun(inv, prompt, model)).answer);
|
|
369
|
+
return detachable(opts, async () => (await childRun(inv, prompt, model, opts.paths)).answer);
|
|
324
370
|
},
|
|
325
371
|
|
|
326
372
|
async rlmQueryBatched(prompts, model, depth, opts) {
|
|
@@ -328,7 +374,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
|
|
|
328
374
|
if (inv === null) return prompts.map(() => UNWIRED);
|
|
329
375
|
// Bounded by the per-depth rlm gate inside childRun, not by an outer pool.
|
|
330
376
|
return detachable(opts, async () => {
|
|
331
|
-
const results = await Promise.all(prompts.map((p) => childRun(inv, p, model)));
|
|
377
|
+
const results = await Promise.all(prompts.map((p) => childRun(inv, p, model, opts.paths)));
|
|
332
378
|
return results.map((r) => r.answer);
|
|
333
379
|
});
|
|
334
380
|
},
|
|
@@ -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
|
},
|