@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
package/src/tool/rlm-events.ts
CHANGED
|
@@ -45,10 +45,6 @@ export interface SubcallUpdatedEvent {
|
|
|
45
45
|
readonly totalCount?: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
export interface WarningsEvent {
|
|
49
|
-
readonly warnings: readonly string[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
48
|
export interface TurnEvent {
|
|
53
49
|
readonly current: number;
|
|
54
50
|
readonly max: number;
|
|
@@ -76,18 +72,26 @@ export interface RootPromptEvent {
|
|
|
76
72
|
/**
|
|
77
73
|
* Typed wrapper around a Node.js EventEmitter for RLM lifecycle events.
|
|
78
74
|
*
|
|
79
|
-
* Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()
|
|
75
|
+
* Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`, with a
|
|
76
|
+
* configurable prefix so a second emitter's IDs cannot collide with the default ones.
|
|
80
77
|
* Provides typed `on*` methods that return unsubscribe functions.
|
|
81
78
|
*/
|
|
82
79
|
export class RlmEmitter {
|
|
83
80
|
private readonly ee = new EventEmitter();
|
|
84
81
|
private seq = 0;
|
|
85
82
|
|
|
83
|
+
/**
|
|
84
|
+
* `idPrefix` namespaces generated IDs. The counter is per-instance, so two emitters
|
|
85
|
+
* would both start at `s1`; a distinct prefix is what lets one emitter's subcalls be
|
|
86
|
+
* merged into another's tree without colliding IDs or corrupting parentId links.
|
|
87
|
+
*/
|
|
88
|
+
constructor(private readonly idPrefix = "s") {}
|
|
89
|
+
|
|
86
90
|
// ── Emit ──
|
|
87
91
|
|
|
88
92
|
/** Create a new sub-call entry. Returns the auto-generated ID. */
|
|
89
93
|
emitSubcallCreated(init: Omit<SubcallCreatedEvent, "id">): string {
|
|
90
|
-
const id =
|
|
94
|
+
const id = `${this.idPrefix}${++this.seq}`;
|
|
91
95
|
const event: SubcallCreatedEvent = { id, ...init };
|
|
92
96
|
this.ee.emit("subcall:created", event);
|
|
93
97
|
return id;
|
|
@@ -113,11 +117,6 @@ export class RlmEmitter {
|
|
|
113
117
|
this.ee.emit("answer", { text } satisfies AnswerEvent);
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
/** Set advisory warnings (root-only; never a failure). */
|
|
117
|
-
emitWarnings(warnings: readonly string[]): void {
|
|
118
|
-
this.ee.emit("warnings", { warnings } satisfies WarningsEvent);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
120
|
/** Set the root run status (done/error/aborted). */
|
|
122
121
|
emitStatus(status: RlmRunStatus): void {
|
|
123
122
|
this.ee.emit("status", { status } satisfies StatusEvent);
|
|
@@ -155,11 +154,6 @@ export class RlmEmitter {
|
|
|
155
154
|
return () => { this.ee.off("answer", handler); };
|
|
156
155
|
}
|
|
157
156
|
|
|
158
|
-
onWarnings(handler: (event: WarningsEvent) => void): () => void {
|
|
159
|
-
this.ee.on("warnings", handler);
|
|
160
|
-
return () => { this.ee.off("warnings", handler); };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
157
|
onStatus(handler: (event: StatusEvent) => void): () => void {
|
|
164
158
|
this.ee.on("status", handler);
|
|
165
159
|
return () => { this.ee.off("status", handler); };
|
package/src/tool/rlm-tool.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import { type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
|
|
10
10
|
import { Type } from "typebox";
|
|
11
|
-
import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
|
|
12
11
|
import type { RlmController, StartInput } from "../mode/rlm-mode.ts";
|
|
13
12
|
import { spinnerFrame } from "../ui/theme.ts";
|
|
14
13
|
import { markdownTheme } from "../ui/theme-adapter.ts";
|
|
@@ -82,15 +81,10 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
|
|
|
82
81
|
|
|
83
82
|
try {
|
|
84
83
|
const input: StartInput = {
|
|
85
|
-
kind: "fresh",
|
|
86
84
|
rootPrompt: params.prompt,
|
|
87
85
|
context: params.context ?? undefined,
|
|
88
86
|
};
|
|
89
|
-
const
|
|
90
|
-
const { done } = controller.start(ctx, input, emitter, {
|
|
91
|
-
onAskUserQuestion: controller.config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
|
|
92
|
-
onTodo: controller.config.todo ? interactive.onTodo : undefined,
|
|
93
|
-
});
|
|
87
|
+
const { done } = controller.start(ctx, input, emitter);
|
|
94
88
|
const result = await done;
|
|
95
89
|
|
|
96
90
|
emitter.emitAnswer(result.answer);
|
|
@@ -152,11 +146,6 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
|
|
|
152
146
|
container.addChild(new Markdown(details.answer, 0, 0, markdownTheme(theme)));
|
|
153
147
|
}
|
|
154
148
|
|
|
155
|
-
if (details.warnings && details.warnings.length > 0) {
|
|
156
|
-
container.addChild(new Spacer(1));
|
|
157
|
-
container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
149
|
return container;
|
|
161
150
|
}
|
|
162
151
|
|
|
@@ -52,11 +52,21 @@ export function cardStatsLine(
|
|
|
52
52
|
totals: { readonly costUsd: number; readonly tokens: number },
|
|
53
53
|
theme: Theme,
|
|
54
54
|
extra?: string,
|
|
55
|
+
backgroundPending?: number,
|
|
55
56
|
): string {
|
|
56
57
|
const parts: string[] = [formatCost(totals.costUsd)];
|
|
57
58
|
if (totals.tokens > 0) parts.push(`${formatTokens(totals.tokens)} tok`);
|
|
58
59
|
if (extra) parts.push(extra);
|
|
59
|
-
|
|
60
|
+
const line = theme.fg("dim", parts.join(" · "));
|
|
61
|
+
// The one thing no tree can show: spawned work that may outlive this block.
|
|
62
|
+
return backgroundPending !== undefined && backgroundPending > 0
|
|
63
|
+
? `${line} ${theme.fg("warning", `↯${backgroundPending} bg`)}`
|
|
64
|
+
: line;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Detached nodes carry BackgroundTasks' "bg" id prefix (RlmEmitter("bg")). */
|
|
68
|
+
function backgroundTag(sc: RlmSubcall, theme: Theme): string {
|
|
69
|
+
return sc.id.startsWith("bg") ? ` ${theme.fg("warning", "↯bg")}` : "";
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** `<glyph> <TITLE> <stats>` — the first line of both tools' collapsed and expanded views. */
|
|
@@ -128,7 +138,8 @@ export function renderCollapsedSubcallTree(
|
|
|
128
138
|
const branch = isLast ? "└─" : "├─";
|
|
129
139
|
const gGlyph = subcallStatusGlyph(sc, theme);
|
|
130
140
|
const gStats = subcallStatsLine(sc);
|
|
131
|
-
|
|
141
|
+
const gBg = backgroundTag(sc, theme);
|
|
142
|
+
lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}${gBg}`);
|
|
132
143
|
const childPrefix = prefix + (isLast ? " " : "│ ");
|
|
133
144
|
lines.push(...walk(sc.id, childPrefix));
|
|
134
145
|
}
|
|
@@ -155,7 +166,8 @@ export function renderExpandedSubcallTree(
|
|
|
155
166
|
const sKind = theme.fg("muted", sc.label);
|
|
156
167
|
const sModel = sc.model ? theme.fg("dim", ` ${sc.model}`) : "";
|
|
157
168
|
const sStats = sc.endedAt ? ` ${theme.fg("dim", subcallStatsLine(sc))}` : "";
|
|
158
|
-
|
|
169
|
+
const sBg = backgroundTag(sc, theme);
|
|
170
|
+
let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}${sBg}`;
|
|
159
171
|
|
|
160
172
|
if (sc.args) {
|
|
161
173
|
line += `\n${pad} ${theme.fg("dim", previewText(sc.args, ARGS_PREVIEW_CHARS))}`;
|
|
@@ -7,13 +7,19 @@
|
|
|
7
7
|
* subcall accumulation logic.
|
|
8
8
|
*/
|
|
9
9
|
import type { RlmEmitter, SubcallCreatedEvent, SubcallUpdatedEvent } from "./rlm-events.ts";
|
|
10
|
-
import type { RlmSubcall } from "./rlm-details.ts";
|
|
10
|
+
import type { RlmSubcall, SubcallStatus } from "./rlm-details.ts";
|
|
11
11
|
import { EmitterListener } from "./emitter-listener.ts";
|
|
12
12
|
|
|
13
13
|
type MutableSubcall = {
|
|
14
14
|
-readonly [Key in keyof RlmSubcall]: RlmSubcall[Key];
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
/** Accumulated cost/tokens, shared by getTotals() and takeSettledSubtrees(). */
|
|
18
|
+
export interface SubcallTotals {
|
|
19
|
+
readonly costUsd: number;
|
|
20
|
+
readonly tokens: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export class SubcallStore extends EmitterListener {
|
|
18
24
|
private readonly subcalls = new Map<string, MutableSubcall>();
|
|
19
25
|
|
|
@@ -82,6 +88,56 @@ export class SubcallStore extends EmitterListener {
|
|
|
82
88
|
return { costUsd: this.totalCostUsd, tokens: this.totalTokens };
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Remove and return every fully-settled root subtree, with its cost/tokens subtracted
|
|
93
|
+
* from the running totals so the caller can add them without double-counting.
|
|
94
|
+
*
|
|
95
|
+
* A root whose subtree still has a running node stays put. That matters because
|
|
96
|
+
* `renderCollapsedSubcallTree` walks down from `parentId === undefined`: a subcall handed
|
|
97
|
+
* over without its parent has no path from a root and is silently dropped from the tree.
|
|
98
|
+
* Handing over whole subtrees is what keeps adopted nodes renderable.
|
|
99
|
+
*/
|
|
100
|
+
takeSettledSubtrees(): { readonly subcalls: readonly RlmSubcall[]; readonly totals: SubcallTotals } {
|
|
101
|
+
const children = new Map<string | undefined, MutableSubcall[]>();
|
|
102
|
+
for (const sc of this.subcalls.values()) {
|
|
103
|
+
const siblings = children.get(sc.parentId);
|
|
104
|
+
if (siblings === undefined) children.set(sc.parentId, [sc]);
|
|
105
|
+
else siblings.push(sc);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Collect a root's subtree, or undefined when any node in it is still running.
|
|
109
|
+
const settledSubtree = (root: MutableSubcall): MutableSubcall[] | undefined => {
|
|
110
|
+
const collected: MutableSubcall[] = [];
|
|
111
|
+
const stack: MutableSubcall[] = [root];
|
|
112
|
+
while (stack.length > 0) {
|
|
113
|
+
const node = stack.pop();
|
|
114
|
+
if (node === undefined) continue;
|
|
115
|
+
if (node.status === "running") return undefined;
|
|
116
|
+
collected.push(node);
|
|
117
|
+
const kids = children.get(node.id);
|
|
118
|
+
if (kids !== undefined) stack.push(...kids);
|
|
119
|
+
}
|
|
120
|
+
return collected;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const taken: RlmSubcall[] = [];
|
|
124
|
+
let costUsd = 0;
|
|
125
|
+
let tokens = 0;
|
|
126
|
+
for (const root of children.get(undefined) ?? []) {
|
|
127
|
+
const subtree = settledSubtree(root);
|
|
128
|
+
if (subtree === undefined) continue;
|
|
129
|
+
for (const node of subtree) {
|
|
130
|
+
costUsd += node.costUsd;
|
|
131
|
+
tokens += node.tokens;
|
|
132
|
+
taken.push(Object.freeze({ ...node, status: node.status as SubcallStatus }));
|
|
133
|
+
this.subcalls.delete(node.id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
this.totalCostUsd -= costUsd;
|
|
137
|
+
this.totalTokens -= tokens;
|
|
138
|
+
return { subcalls: taken, totals: { costUsd, tokens } };
|
|
139
|
+
}
|
|
140
|
+
|
|
85
141
|
// ── Root usage (delegated from RlmEventAggregator) ──
|
|
86
142
|
|
|
87
143
|
/** Accumulate root-level usage into shared totals. Called by aggregator. */
|
package/src/ui/config-panel.ts
CHANGED
|
@@ -9,21 +9,17 @@ const CHOICES = Object.freeze({
|
|
|
9
9
|
maxDepth: Object.freeze(["1", "2", "3", "4"]),
|
|
10
10
|
maxIterations: Object.freeze(["10", "20", "30", "50"]),
|
|
11
11
|
execTimeoutS: Object.freeze(["30", "60", "120", "300"]),
|
|
12
|
-
maxConcurrentSubcalls: Object.freeze(["2", "4", "8", "16"]),
|
|
13
|
-
|
|
12
|
+
maxConcurrentSubcalls: Object.freeze(["2", "4", "8", "16", "32"]),
|
|
13
|
+
maxConcurrentChildren: Object.freeze(["1", "2", "3", "4", "6", "8"]),
|
|
14
14
|
maxTimeoutMs: Object.freeze(["none", "60", "120", "300"]),
|
|
15
15
|
maxTokens: Object.freeze(["none", "10000", "50000", "100000"]),
|
|
16
16
|
maxErrors: Object.freeze(["3", "5", "10", "none"]),
|
|
17
17
|
orchestrator: Object.freeze(["on", "off"]),
|
|
18
|
-
pipeline: Object.freeze(["on", "off"]),
|
|
19
|
-
maxBackwardJumps: Object.freeze(["0", "1", "2", "3"]),
|
|
20
18
|
compaction: Object.freeze(["on", "off"]),
|
|
21
19
|
compactionThresholdPct: Object.freeze(["50", "65", "80", "90"]),
|
|
22
20
|
rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
|
|
23
21
|
sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
|
|
24
22
|
requestTimeoutMs: Object.freeze(["2", "5", "10", "20"]),
|
|
25
|
-
askUserQuestion: Object.freeze(["on", "off"]),
|
|
26
|
-
todo: Object.freeze(["on", "off"]),
|
|
27
23
|
libraryLoader: Object.freeze(["on", "off"]),
|
|
28
24
|
});
|
|
29
25
|
|
|
@@ -43,20 +39,16 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
|
|
|
43
39
|
item("maxIterations", "Max iterations", String(config.maxIterations), CHOICES.maxIterations, "Maximum root REPL turns before RLM asks the model for a final answer."),
|
|
44
40
|
item("execTimeoutS", "REPL block timeout (s)", String(config.execTimeoutS), CHOICES.execTimeoutS, "Wall-clock limit for one model-authored Python REPL block."),
|
|
45
41
|
item("maxConcurrentSubcalls", "Max concurrent sub-calls", String(config.maxConcurrentSubcalls), CHOICES.maxConcurrentSubcalls, "Concurrency pool size for llm_query_batched and rlm_query_batched."),
|
|
46
|
-
item("
|
|
42
|
+
item("maxConcurrentChildren", "Max concurrent children", String(config.maxConcurrentChildren), CHOICES.maxConcurrentChildren, "Concurrent rlm_query child engines per depth. Each is a Python process holding its own copy of the inherited context."),
|
|
47
43
|
item("maxTimeoutMs", "Wall-clock ceiling (min)", config.maxTimeoutMs != null ? String(Math.round(config.maxTimeoutMs / 60_000)) : "none", CHOICES.maxTimeoutMs, "Total runtime cap for the whole recursive tree; none disables the cap."),
|
|
48
44
|
item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
|
|
49
45
|
item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
|
|
50
46
|
item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
|
|
51
|
-
item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable artifact-gated phases: clarify→research→blueprint→validate (read-only plan pipeline; clarify needs Ask user on)."),
|
|
52
|
-
item("maxBackwardJumps", "Max validate→blueprint loops", String(config.maxBackwardJumps), CHOICES.maxBackwardJumps, "Bounded corrective re-entries when validation reports blockers_count > 0."),
|
|
53
47
|
item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
|
|
54
48
|
item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "Compact once estimated history tokens reach this share of the root model's context window."),
|
|
55
49
|
item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
|
|
56
50
|
item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
|
|
57
51
|
item("requestTimeoutMs", "Sandbox request timeout (min)", String(Math.round(config.requestTimeoutMs / 60_000)), CHOICES.requestTimeoutMs, "Parent-side watchdog per sandbox request; on breach the Python worker is killed."),
|
|
58
|
-
item("askUserQuestion", "[Interactive] Ask user", config.askUserQuestion ? "on" : "off", CHOICES.askUserQuestion, "Allow root REPL code to present structured ask_user_question dialogs."),
|
|
59
|
-
item("todo", "[Interactive] Todo", config.todo ? "on" : "off", CHOICES.todo, "Allow REPL code to manage a visible todo task list."),
|
|
60
52
|
item("libraryLoader", "Library loader", config.libraryLoader ? "on" : "off", CHOICES.libraryLoader,
|
|
61
53
|
"Allow load_library() to pull an external dir, file, or git repo into the shared context list."),
|
|
62
54
|
item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
|
|
@@ -101,21 +93,17 @@ export function applySetting(config: RlmConfig, id: string, value: string): RlmC
|
|
|
101
93
|
case "maxIterations": return Object.freeze({ ...config, maxIterations: Number(value) });
|
|
102
94
|
case "execTimeoutS": return Object.freeze({ ...config, execTimeoutS: Number(value) });
|
|
103
95
|
case "maxConcurrentSubcalls": return Object.freeze({ ...config, maxConcurrentSubcalls: Number(value) });
|
|
104
|
-
case "
|
|
96
|
+
case "maxConcurrentChildren": return Object.freeze({ ...config, maxConcurrentChildren: Number(value) });
|
|
105
97
|
case "maxTimeoutMs": return Object.freeze({ ...config, maxTimeoutMs: optionalNumber(value, 60_000) });
|
|
106
98
|
case "maxTokens": return Object.freeze({ ...config, maxTokens: optionalNumber(value) });
|
|
107
99
|
case "maxErrors": return Object.freeze({ ...config, maxErrors: optionalNumber(value) });
|
|
108
100
|
case "orchestrator": return Object.freeze({ ...config, orchestrator: value === "on" });
|
|
109
|
-
case "pipeline": return Object.freeze({ ...config, pipeline: value === "on" });
|
|
110
|
-
case "maxBackwardJumps": return Object.freeze({ ...config, maxBackwardJumps: Number(value) });
|
|
111
101
|
case "compaction": return Object.freeze({ ...config, compaction: value === "on" });
|
|
112
102
|
case "compactionThresholdPct": return Object.freeze({ ...config, compactionThresholdPct: Number(value) / 100 });
|
|
113
103
|
case "rootSamplingMaxTokens":
|
|
114
104
|
return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }) });
|
|
115
105
|
case "sandboxInitTimeoutMs": return Object.freeze({ ...config, sandboxInitTimeoutMs: Number(value) });
|
|
116
106
|
case "requestTimeoutMs": return Object.freeze({ ...config, requestTimeoutMs: Number(value) * 60_000 });
|
|
117
|
-
case "askUserQuestion": return Object.freeze({ ...config, askUserQuestion: value === "on" });
|
|
118
|
-
case "todo": return Object.freeze({ ...config, todo: value === "on" });
|
|
119
107
|
case "libraryLoader": return Object.freeze({ ...config, libraryLoader: value === "on" });
|
|
120
108
|
default: return config;
|
|
121
109
|
}
|
package/src/ui/intro.ts
CHANGED
|
@@ -11,9 +11,8 @@ export const RLM_GUIDE = `# RLM mode
|
|
|
11
11
|
## Commands
|
|
12
12
|
|
|
13
13
|
- \`/rlm\` — toggle RLM mode (shortcut: Ctrl+Shift+R). Turning it OFF also stops a running query.
|
|
14
|
-
- \`/rlm-config\` — choose models, reasoning, and
|
|
14
|
+
- \`/rlm-config\` — choose models, reasoning, and run limits
|
|
15
15
|
- \`/rlm-stop\` — cancel the current run but stay in RLM mode (use /rlm or Ctrl+Shift+R to leave)
|
|
16
|
-
- \`/rlm-help\` — show this guide again
|
|
17
16
|
|
|
18
17
|
When RLM mode is ON, \`read\`/\`grep\` are disabled and the agent reads the repository through the
|
|
19
18
|
\`repl\` tool, delegating bulk analysis to sub-LLMs. The footer/status line shows the current state.`;
|
package/src/ui/model-picker.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Model picker TUI — choose a model and, when supported, a thinking level. */
|
|
2
2
|
|
|
3
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
6
|
import { Container, type Component, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
import { formatCost } from "./theme.ts";
|
|
8
|
+
import { compareLlm } from "../mode/llm-model.ts";
|
|
8
9
|
|
|
9
10
|
export interface ModelSelection {
|
|
10
11
|
readonly model: Model<Api>;
|
|
@@ -16,15 +17,38 @@ type SelectableThinkingLevel = (typeof LEVELS)[number];
|
|
|
16
17
|
|
|
17
18
|
const CHEAPEST_VALUE = "__rlm_cheapest__";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Models Pi itself would offer for this session, cheapest-first.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors the built-in model switcher:
|
|
24
|
+
* - if the session has scoped models (`--models` / enabledModels) → those only
|
|
25
|
+
* - else → `getAvailable()` (providers with configured auth)
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT `getAll()`: the full catalog dumps every provider's catalog entry and is
|
|
28
|
+
* not what the user sees in Pi natively. See Pi extension docs on `ctx.scopedModels`.
|
|
29
|
+
*/
|
|
30
|
+
export function pickableModels(
|
|
31
|
+
registry: ModelRegistry,
|
|
32
|
+
scoped?: readonly { readonly model: Model<Api> }[],
|
|
33
|
+
): readonly Model<Api>[] {
|
|
34
|
+
const source = scoped !== undefined && scoped.length > 0
|
|
35
|
+
? scoped.map((s) => s.model)
|
|
36
|
+
: registry.getAvailable();
|
|
37
|
+
return [...source].sort(compareLlm);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function items(models: readonly Model<Api>[], includeCheapest: boolean): SelectItem[] {
|
|
41
|
+
const modelItems = models.map((m) => {
|
|
42
|
+
const price = `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok`;
|
|
43
|
+
return {
|
|
44
|
+
value: `${m.provider}/${m.id}`,
|
|
45
|
+
label: `${m.provider}/${m.id}`,
|
|
46
|
+
description: `${price}${m.reasoning ? " · reasoning" : ""}`,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
25
49
|
if (!includeCheapest) return modelItems;
|
|
26
50
|
return [
|
|
27
|
-
{ value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest
|
|
51
|
+
{ value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest model with a configured key" },
|
|
28
52
|
...modelItems,
|
|
29
53
|
];
|
|
30
54
|
}
|
|
@@ -79,12 +103,12 @@ async function selectThinkingLevel(
|
|
|
79
103
|
export async function selectModel(
|
|
80
104
|
ctx: ExtensionContext,
|
|
81
105
|
title: string,
|
|
82
|
-
models: Model<Api>[],
|
|
106
|
+
models: readonly Model<Api>[],
|
|
83
107
|
current?: Model<Api>,
|
|
84
108
|
currentThinking?: ThinkingLevel,
|
|
85
109
|
): Promise<ModelSelection | null | undefined> {
|
|
86
110
|
if (models.length === 0) {
|
|
87
|
-
ctx.ui.notify("RLM: no models
|
|
111
|
+
ctx.ui.notify("RLM: no models available (add a provider key in Pi, or widen --models / enabledModels)", "warning");
|
|
88
112
|
return undefined;
|
|
89
113
|
}
|
|
90
114
|
if (ctx.mode !== "tui") {
|
package/src/ui/status.ts
CHANGED
|
@@ -12,18 +12,14 @@ export function modelLabel(model: Model<Api> | undefined, fallback: string): str
|
|
|
12
12
|
|
|
13
13
|
export function formatRlmStateLine(controller: RlmController, contextUsage?: ContextUsage): string {
|
|
14
14
|
if (!controller.enabled) return "○ RLM OFF";
|
|
15
|
-
const
|
|
16
|
-
const
|
|
15
|
+
const llm = modelLabel(controller.llmModel, controller.savedLlmRef ?? "cheapest");
|
|
16
|
+
const llmSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
|
|
17
17
|
// `percent` is null right after a compaction, before the next assistant response reports usage.
|
|
18
18
|
const percent = contextUsage?.percent;
|
|
19
19
|
const ctxSuffix = percent === null || percent === undefined ? "" : ` · ctx ${Math.round(percent)}%`;
|
|
20
|
-
return `● RLM ON ·
|
|
20
|
+
return `● RLM ON · llm=${llm}${llmSuffix}${ctxSuffix}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function setRlmModeStatus(ui: ExtensionUIContext, controller: RlmController, contextUsage?: ContextUsage): void {
|
|
24
24
|
ui.setStatus(KEY, formatRlmStateLine(controller, contextUsage));
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
export function clearRlmStatus(ui: ExtensionUIContext): void {
|
|
28
|
-
ui.setStatus(KEY, undefined);
|
|
29
|
-
}
|
package/src/util/concurrency.ts
CHANGED
|
@@ -1,15 +1,93 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Sub-call admission control.
|
|
3
|
+
*
|
|
4
|
+
* `spawn()` lets the sandbox put many requests on the wire at once, so a per-call pool no
|
|
5
|
+
* longer bounds anything: one `llm_query_chunked` over a large file posts every batch
|
|
6
|
+
* simultaneously, and each batch fans out again. The bound has to be session-wide, which is
|
|
7
|
+
* what `SubcallGates` is — constructed once at the composition root and shared by every
|
|
8
|
+
* handler.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Counting semaphore: at most `limit` holders at once, FIFO.
|
|
13
|
+
*
|
|
14
|
+
* NOT re-entrant. A leaf completion takes exactly ONE slot, in `complete1`; wrapping a batch in
|
|
15
|
+
* a second acquisition of this same gate deadlocks as soon as the batch reaches `limit`
|
|
16
|
+
* prompts — the outer holders fill the gate and each waits for an inner slot nothing can free.
|
|
17
|
+
*
|
|
18
|
+
* FIFO means a large fan-out is not starved, but also that it is not preempted: a
|
|
19
|
+
* 500-prompt `llm_query_chunked` holds the queue until it drains, so an interactive
|
|
20
|
+
* `llm_query` issued behind it waits for the whole thing. Acceptable while sub-calls are
|
|
21
|
+
* uniform in priority; revisit if an interactive tier is ever added.
|
|
22
|
+
*/
|
|
23
|
+
export class Semaphore {
|
|
24
|
+
private active = 0;
|
|
25
|
+
private readonly waiters: Array<() => void> = [];
|
|
26
|
+
|
|
27
|
+
constructor(private readonly limit: number) {}
|
|
28
|
+
|
|
29
|
+
/** Hold a slot for the duration of `fn`. */
|
|
30
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
31
|
+
// `while`, not `if`: a caller arriving between the decrement below and a woken waiter's
|
|
32
|
+
// resumption would otherwise slip past the limit.
|
|
33
|
+
while (this.active >= this.limit) {
|
|
34
|
+
await new Promise<void>((resolve) => { this.waiters.push(resolve); });
|
|
11
35
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
36
|
+
this.active += 1;
|
|
37
|
+
try {
|
|
38
|
+
return await fn();
|
|
39
|
+
} finally {
|
|
40
|
+
this.active -= 1;
|
|
41
|
+
this.waiters.shift()?.();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** In-flight holders. Exposed for tests asserting the bound. */
|
|
46
|
+
get inFlight(): number {
|
|
47
|
+
return this.active;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One semaphore per recursion depth.
|
|
53
|
+
*
|
|
54
|
+
* A single shared gate would deadlock: `limit` rlm_query children holding every slot, each
|
|
55
|
+
* blocked waiting for a grandchild that can never be admitted. Splitting by depth breaks the
|
|
56
|
+
* cycle — a holder at depth k only ever waits on depth k+1. Leaf LLM calls are terminal and
|
|
57
|
+
* never re-enter, so they safely share one process-wide gate.
|
|
58
|
+
*/
|
|
59
|
+
export class DepthGates {
|
|
60
|
+
private readonly gates = new Map<number, Semaphore>();
|
|
61
|
+
|
|
62
|
+
constructor(private readonly limit: number) {}
|
|
63
|
+
|
|
64
|
+
at(depth: number): Semaphore {
|
|
65
|
+
let gate = this.gates.get(depth);
|
|
66
|
+
if (gate === undefined) {
|
|
67
|
+
gate = new Semaphore(this.limit);
|
|
68
|
+
this.gates.set(depth, gate);
|
|
69
|
+
}
|
|
70
|
+
return gate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Session-wide sub-call admission. Construct once; pass explicitly — never default one in. */
|
|
75
|
+
export interface SubcallGates {
|
|
76
|
+
/** llm_query / llm_query_batched completions — terminal, so one shared gate. */
|
|
77
|
+
readonly leaf: Semaphore;
|
|
78
|
+
/** Recursive child engines — one gate per depth, see DepthGates. */
|
|
79
|
+
readonly rlm: DepthGates;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Worst case is `(maxDepth - 1) × childLimit` concurrent child engines — the cap short-circuits
|
|
84
|
+
* at `childDepth >= maxDepth`, so engines exist at depths 1..maxDepth-1 — plus `leafLimit` leaf
|
|
85
|
+
* completions.
|
|
86
|
+
*
|
|
87
|
+
* Children get their own, smaller bound because they are far heavier than leaves: each owns a
|
|
88
|
+
* Python subprocess AND its own copy of the context it inherited from its parent, where a leaf
|
|
89
|
+
* is one HTTP request. See DEFAULT_CONFIG.maxConcurrentChildren.
|
|
90
|
+
*/
|
|
91
|
+
export function createSubcallGates(leafLimit: number, childLimit: number = leafLimit): SubcallGates {
|
|
92
|
+
return Object.freeze({ leaf: new Semaphore(leafLimit), rlm: new DepthGates(childLimit) });
|
|
15
93
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RLM trace — one JSONL line per interesting event, for the E2E harness and for post-mortem of
|
|
3
|
+
* a hung run. Off unless RLM_TRACE_FILE is set; when off, every call costs one boolean check.
|
|
4
|
+
*
|
|
5
|
+
* Purely observational: it subscribes to the RlmEmitter that already exists and to the sandbox
|
|
6
|
+
* frames that already cross the pipe. No behaviour is duplicated here.
|
|
7
|
+
*/
|
|
8
|
+
import { appendFileSync } from "node:fs";
|
|
9
|
+
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
10
|
+
|
|
11
|
+
const FILE = process.env.RLM_TRACE_FILE;
|
|
12
|
+
|
|
13
|
+
/** Check this before building payloads on hot paths. */
|
|
14
|
+
export const traceEnabled: boolean = typeof FILE === "string" && FILE.length > 0;
|
|
15
|
+
|
|
16
|
+
const START = Date.now();
|
|
17
|
+
|
|
18
|
+
/** Append one event. Fail-soft: tracing must never break a run (state/writes.ts convention). */
|
|
19
|
+
export function trace(kind: string, data: Record<string, unknown> = {}): void {
|
|
20
|
+
if (!traceEnabled || FILE === undefined) return;
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
try {
|
|
23
|
+
appendFileSync(FILE, `${JSON.stringify({ t: now, rel: now - START, pid: process.pid, kind, ...data })}\n`);
|
|
24
|
+
} catch {
|
|
25
|
+
/* ignore */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Mirror one emitter's sub-call lifecycle into the trace. Returns an unsubscribe fn. */
|
|
30
|
+
export function attachTracer(emitter: RlmEmitter, scope: "turn" | "background"): () => void {
|
|
31
|
+
if (!traceEnabled) return () => {};
|
|
32
|
+
// Rename e.kind → subcallKind so it does not overwrite the outer event kind
|
|
33
|
+
// (`subcall.created` / `subcall.updated`) when `trace` spreads `data` after `kind`.
|
|
34
|
+
const offs = [
|
|
35
|
+
emitter.onSubcallCreated((e) => {
|
|
36
|
+
const { kind: subcallKind, ...rest } = e;
|
|
37
|
+
trace("subcall.created", { scope, subcallKind, ...rest });
|
|
38
|
+
}),
|
|
39
|
+
emitter.onSubcallUpdated((e) => trace("subcall.updated", { scope, ...e })),
|
|
40
|
+
];
|
|
41
|
+
return () => { for (const off of offs) off(); };
|
|
42
|
+
}
|