@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.
Files changed (68) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +382 -0
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +7 -15
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +115 -360
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +49 -10
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -386
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +14 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/py/worker.py +836 -0
  30. package/src/sandbox/sandbox-manager.ts +33 -6
  31. package/src/sandbox/sandbox.ts +153 -182
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/background-tasks.ts +95 -0
  34. package/src/tool/repl-details.ts +4 -2
  35. package/src/tool/repl-render.ts +58 -0
  36. package/src/tool/repl-result.ts +70 -0
  37. package/src/tool/repl-tool.ts +178 -216
  38. package/src/tool/rlm-aggregator.ts +2 -10
  39. package/src/tool/rlm-details.ts +0 -2
  40. package/src/tool/rlm-events.ts +10 -16
  41. package/src/tool/rlm-tool.ts +1 -12
  42. package/src/tool/subcall-render.ts +15 -3
  43. package/src/tool/subcall-store.ts +57 -1
  44. package/src/ui/config-panel.ts +4 -16
  45. package/src/ui/intro.ts +1 -2
  46. package/src/ui/model-picker.ts +34 -10
  47. package/src/ui/status.ts +3 -7
  48. package/src/util/concurrency.ts +91 -13
  49. package/src/util/trace.ts +42 -0
  50. package/src/bridge/fallback-todo.ts +0 -137
  51. package/src/bridge/interactive.ts +0 -65
  52. package/src/bridge/llm-query.ts +0 -156
  53. package/src/bridge/pi-interactive.ts +0 -41
  54. package/src/bridge/rlm-query.ts +0 -108
  55. package/src/core/artifacts.ts +0 -89
  56. package/src/core/critique.ts +0 -92
  57. package/src/core/gates.ts +0 -301
  58. package/src/core/pipeline-handlers.ts +0 -319
  59. package/src/core/pipeline.ts +0 -268
  60. package/src/prompts/phases.ts +0 -104
  61. package/src/sandbox/worker.py +0 -1078
  62. package/src/state/index.ts +0 -24
  63. package/src/state/internal.ts +0 -46
  64. package/src/state/paths.ts +0 -44
  65. package/src/state/reads.ts +0 -133
  66. package/src/state/resume.ts +0 -173
  67. package/src/state/rows.ts +0 -123
  68. package/src/state/writes.ts +0 -58
@@ -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 = `s${++this.seq}`;
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); };
@@ -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 interactive = createPiInteractiveDeps(ctx);
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
- return theme.fg("dim", parts.join(" · "));
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
- lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}`);
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
- let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}`;
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. */
@@ -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
- maxBudgetUsd: Object.freeze(["none", "0.50", "1", "5"]),
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("maxBudgetUsd", "Budget ceiling (USD)", config.maxBudgetUsd != null ? String(config.maxBudgetUsd) : "none", CHOICES.maxBudgetUsd, "Total spend cap for the whole recursive tree; none disables the cap."),
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 "maxBudgetUsd": return Object.freeze({ ...config, maxBudgetUsd: optionalNumber(value) });
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 budget limits
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.`;
@@ -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
- function items(models: Model<Api>[], includeCheapest = false): SelectItem[] {
20
- const modelItems = models.map((m) => ({
21
- value: `${m.provider}/${m.id}`,
22
- label: `${m.provider}/${m.id}`,
23
- description: `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok${m.reasoning ? " · reasoning" : ""}`,
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 available model" },
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 with configured auth", "warning");
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 worker = modelLabel(controller.workerModel, controller.savedWorkerRef ?? "cheapest");
16
- const workerSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
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 · worker=${worker}${workerSuffix}${ctxSuffix}`;
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
- }
@@ -1,15 +1,93 @@
1
- /** Fixed-size concurrency pool: run `fn` over `items` with at most `limit` in flight, preserving order. */
2
- export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, idx: number) => Promise<R>): Promise<R[]> {
3
- const out = new Array<R>(items.length);
4
- let next = 0;
5
- const worker = async (): Promise<void> => {
6
- while (true) {
7
- const index = next;
8
- next += 1;
9
- if (index >= items.length) return;
10
- out[index] = await fn(items[index], index);
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
- await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
14
- return out;
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
+ }