@hicaru/pi-rlm 0.1.8 → 0.2.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.
Files changed (52) hide show
  1. package/README.md +22 -19
  2. package/package.json +2 -1
  3. package/src/bridge/library.ts +93 -15
  4. package/src/bridge/llm-query.ts +60 -36
  5. package/src/bridge/rlm-query.ts +63 -79
  6. package/src/commands/rlm-config.ts +8 -8
  7. package/src/commands/rlm.ts +48 -12
  8. package/src/config/settings.ts +33 -3
  9. package/src/context/library-context.ts +209 -22
  10. package/src/context/repomix-context.ts +7 -58
  11. package/src/core/answer.ts +5 -13
  12. package/src/core/artifacts.ts +4 -3
  13. package/src/core/critique.ts +92 -0
  14. package/src/core/engine.ts +94 -299
  15. package/src/core/gates.ts +33 -4
  16. package/src/core/limits.ts +19 -1
  17. package/src/core/pipeline-handlers.ts +319 -0
  18. package/src/core/pipeline.ts +40 -15
  19. package/src/core/types.ts +26 -30
  20. package/src/index.ts +36 -26
  21. package/src/mode/native-guards.ts +2 -2
  22. package/src/mode/rlm-mode.ts +8 -11
  23. package/src/prompts/phases.ts +18 -39
  24. package/src/prompts/system.ts +167 -64
  25. package/src/prompts/user.ts +1 -5
  26. package/src/sandbox/protocol.ts +5 -17
  27. package/src/sandbox/sandbox-manager.ts +5 -5
  28. package/src/sandbox/sandbox.ts +67 -27
  29. package/src/sandbox/worker.py +534 -48
  30. package/src/state/paths.ts +1 -1
  31. package/src/state/reads.ts +12 -4
  32. package/src/state/resume.ts +26 -25
  33. package/src/state/rows.ts +2 -2
  34. package/src/text/parsing.ts +0 -6
  35. package/src/text/tokens.ts +7 -1
  36. package/src/tool/repl-details.ts +2 -3
  37. package/src/tool/repl-tool.ts +132 -337
  38. package/src/tool/rlm-aggregator.ts +7 -7
  39. package/src/tool/rlm-details.ts +6 -13
  40. package/src/tool/rlm-events.ts +14 -11
  41. package/src/tool/rlm-tool.ts +20 -38
  42. package/src/tool/subcall-render.ts +61 -9
  43. package/src/tool/subcall-store.ts +4 -2
  44. package/src/ui/config-panel.ts +43 -23
  45. package/src/ui/intro.ts +2 -1
  46. package/src/ui/status.ts +8 -5
  47. package/src/ui/theme-adapter.ts +36 -0
  48. package/src/ui/theme.ts +0 -25
  49. package/src/mode/input-router.ts +0 -23
  50. package/src/registry/edit-registry.ts +0 -22
  51. package/src/text/edits.ts +0 -164
  52. package/src/tool/apply-edits-tool.ts +0 -295
package/README.md CHANGED
@@ -100,8 +100,8 @@ sub-LLM calls, hence the name.
100
100
 
101
101
  While a run is active, a **live tree** shows the root orchestrator and every sub-LLM /
102
102
  recursive child with status, model, cost, tokens, and duration. The final answer is posted
103
- to the chat as markdown; any code edits are collected as diffs and reviewed via a popup
104
- (unless `yolo` is on).
103
+ to the chat as markdown. File changes use Pi's native `edit` / `write` tools (with their
104
+ built-in diff preview).
105
105
 
106
106
  ## Sandbox API
107
107
 
@@ -117,10 +117,9 @@ These functions are injected into the model's Python namespace inside the REPL:
117
117
  | `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent recursive child RLMs |
118
118
  | `todo` | `(action, **kwargs) -> str` | Task list: `create`/`update`/`list`/`get`/`delete`/`clear` |
119
119
  | `ask_user_question` | `(questions) -> list[dict]` | Ask the user structured questions (depth 0 only) |
120
- | `load_library` | `(source) -> dict \| str` | Load an external dir, file, or git URL into a new `context_N` slot |
121
- | `stage_edit` | `(path, old_text, new_text) -> str` | Stage a file edit; relayed to the host's native edit flow |
122
- | `save_artifact` | `(kind, content) -> str` | Persist a stage artifact (`clarification` / `research` / `plan` / `validation`) under `.rlm/artifacts/` (root depth only) |
123
- | `advance_phase` | `(phase, summary=None) -> str` | Advance one step in order `clarify → research → blueprint → implement → validate` (clarify skipped when `askUserQuestion` is off). **Engine-gated** on the latest artifact + interview rounds. Rejected transitions return the gate error. |
120
+ | `load_library` | `(source) -> dict \| str` | Append an external dir, file, or git URL into `context` under `lib/<id>/` |
121
+ | `save_artifact` | `(kind, content) -> str` | Persist a stage artifact (`clarification` / `research` / `plan` / `validation`) under `.rlm/artifacts/` (root depth only). Returns preflight gate critique. |
122
+ | `advance_phase` | `(phase, summary=None) -> str` | Advance one step in order `clarify research → blueprint → validate` (clarify skipped when `askUserQuestion` is off). **Engine-gated** on the latest artifact + interview rounds. Rejected transitions return the gate error. |
124
123
  | `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
125
124
  | `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
126
125
 
@@ -130,27 +129,31 @@ When the task needs an **external library, another source tree, or standalone do
130
129
  not in the packed repo `context`, the model calls `load_library(source)` mid-run:
131
130
 
132
131
  ```python
133
- info = load_library("../some-lib") # local directory → repomix-packed list[dict]
134
- info = load_library("docs/api.md") # single file → plain str
135
- info = load_library("https://github.com/x/y.git") # shallow clone, then pack
136
- # info == {"index": 1, "var": "context_1", "files": …, "chars": …}
137
- # then chunk context_1 exactly like context
132
+ info = load_library("../some-lib") # local directory → packed + appended
133
+ info = load_library("docs/api.md") # single file → one entry in context
134
+ info = load_library("https://github.com/x/y.git") # shallow clone, then pack + append
135
+ # Files land in the SAME `context` list under lib/<source_id>/…
136
+ # info == {"source_id", "path_prefix", "files", "chars", "context_len", "already_loaded", …}
137
+ lib = [f for f in context if f["path"].startswith(info["path_prefix"])]
138
138
  ```
139
139
 
140
- Slots start at `context_1` (`context` / `context_0` remains the repo). Toggle via
141
- `/rlm-config` → **Library loader** (`libraryLoader`, default on). On headless runs with
142
- persistence, each loaded slot is written as a resume sidecar (`context.<N>.json`).
140
+ There is no `context_1` / `context_2` only `context`. Paths are namespaced so multiple
141
+ libraries do not collide. Toggle via `/rlm-config` → **Library loader** (`libraryLoader`,
142
+ default on). On headless runs with persistence, each load writes a resume sidecar
143
+ (`context.<N>.json`) that is **merged back into `context`** on resume.
143
144
 
144
145
  ### Artifact-gated pipeline (opt-in via `pipeline: true`)
145
146
 
146
147
  When enabled at root depth:
147
148
 
148
149
  1. **Goal capture** — the brief is written verbatim to `.rlm/artifacts/goal/goal-<ts>.md` with a pre-run dirty-tree baseline.
149
- 2. **Stages** — `clarify → research → blueprint → implement validate`. Each produces a durable markdown artifact with frontmatter contracts; chat history is **reset** at every phase boundary (artifacts are the only channel; REPL vars persist).
150
+ 2. **Stages** — `clarify → research → blueprint → validate` (**read-only** produces a validated plan; does not write code). Each produces a durable markdown artifact with frontmatter contracts; chat history is **reset** at every phase boundary (artifacts are the only channel; REPL vars persist).
150
151
  3. **Clarify (intake)** — interviews the user via `ask_user_question` (intent first, then evidence-confirmed decisions). Writes `.rlm/artifacts/clarifications/*` with `decisions_count` / `open_questions_count`. Engine gate: **≥1 serviced ask round** + artifact contract. When **`askUserQuestion` is off**, clarify is skipped and the run starts at research.
151
- 4. **Gates (TypeScript, never LLM judgment)** — `status: ready`; clarify structure; plan `phases:` ≡ fence-aware `## Phase N:` headings; every `file:line` citation resolves; validate carries `blockers_count` + `verdict`.
152
- 5. **Implement fanout** — on `advance_phase("implement")` the engine runs one **serial** child RLM per plan phase and applies that child's edits before the next phase starts.
153
- 6. **Corrective loop** — `blockers_count > 0` re-enters blueprint, bounded by `maxBackwardJumps` (default 2).
152
+ 4. **Gates (TypeScript, never LLM judgment)** — `status: ready`; clarify structure; plan `phases:` ≡ fence-aware `## Phase N:` headings; every `file:line` citation resolves; validate carries `blockers_count` + `verdict`. Preflight critique runs on every `save_artifact`.
153
+ 5. **Validate** — adversarial plan review against the tree (not a post-implementation diff check). Final answer is the validated plan.
154
+ 6. **Corrective loop** — `blockers_count > 0` re-enters blueprint (superseded plan kept for context), bounded by `maxBackwardJumps` (default 2).
155
+
156
+ Native RLM mode authors file changes with Pi's native `edit` / `write` tools. Sub-LLMs extract and locate; they never ship code.
154
157
 
155
158
  ## Settings (`/rlm-config`)
156
159
 
@@ -167,7 +170,7 @@ When enabled at root depth:
167
170
  | Token ceiling | none | total input+output token cap for the whole recursive tree |
168
171
  | Max consecutive errors | `5` | stop after N consecutive failing turns (none = off) |
169
172
  | Orchestrator addendum | on | divide-and-conquer guidance in the root system prompt |
170
- | Phase pipeline | off | artifact-gated clarify→research→blueprint→implement fanout→validate |
173
+ | Phase pipeline | off | artifact-gated clarify→research→blueprint→validate (read-only plan pipeline) |
171
174
  | Max validate→blueprint loops | `2` | bounded corrective re-entries when validation reports blockers |
172
175
  | Ask user question | on | when pipeline is on, enables clarify intake; when off, pipeline starts at research |
173
176
  | Trajectory compaction | on (0.65) | summarize old turns when history nears the context window |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
6
6
  "license": "MIT",
@@ -27,6 +27,7 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "check": "tsc --noEmit",
30
+ "test": "bun run test/smoke.ts",
30
31
  "prepublishOnly": "npm run check"
31
32
  },
32
33
  "pi": {
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * Shared load_library handler for headless engine and native repl() mode.
3
3
  *
4
- * Host assigns context slot indices (slot 0 = repo); packs the source via
5
- * resolveLibrarySource; optional onLoaded writes resume sidecars.
4
+ * Host packs the source via resolveLibrarySource (namespaced under lib/<id>/),
5
+ * assigns a resume-sidecar index, and returns the payload for the worker to
6
+ * append into the single `context` list.
7
+ *
8
+ * Idempotency is host-side: re-loading a source that was already packed does
9
+ * not consume an index, write a sidecar, or re-clone/pack. That keeps resume
10
+ * trails free of duplicate library slots.
6
11
  *
7
12
  * Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
8
13
  * across native repl() calls — getOrCreate only installs handlers at spawn.
@@ -10,7 +15,10 @@
10
15
 
11
16
  import type { RlmEmitter } from "../tool/rlm-events.ts";
12
17
  import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
13
- import { resolveLibrarySource } from "../context/library-context.ts";
18
+ import {
19
+ libraryNamespace,
20
+ resolveLibrarySource,
21
+ } from "../context/library-context.ts";
14
22
  import { previewText } from "../text/preview.ts";
15
23
 
16
24
  export interface LibraryBridgeOpts {
@@ -23,22 +31,35 @@ export interface LibraryBridgeOpts {
23
31
  readonly getEmitter?: () => RlmEmitter | null | undefined;
24
32
  readonly parentId?: string;
25
33
  readonly signal?: AbortSignal;
26
- /** First slot to assign (slot 0 is the repo context). Resume passes 1 + restored slots. */
34
+ /** First resume-sidecar index (slot 0 = repo). Resume passes 1 + max restored. */
27
35
  readonly startIndex: number;
36
+ /**
37
+ * Prefixes already present in context (e.g. restored from sidecars).
38
+ * Seeded so re-load after resume is still a no-op without re-packing.
39
+ */
40
+ readonly loadedPrefixes?: readonly string[];
28
41
  /** Post-load hook — the engine writes the resume sidecar here; native mode omits it. */
29
42
  readonly onLoaded?: (index: number, payload: unknown) => void | Promise<void>;
30
43
  }
31
44
 
32
45
  export interface LibraryHandlerBundle {
33
46
  readonly handlers: Pick<SubLlmHandlers, "loadLibrary">;
34
- /** Reset the slot counter (call when the sandbox is discarded and will re-spawn). */
47
+ /** Reset the sidecar index counter (call when the sandbox is discarded and will re-spawn). */
35
48
  readonly reset: () => void;
49
+ /** Prefixes loaded in this sandbox lifetime (for tests). */
50
+ readonly loadedPrefixes: () => ReadonlySet<string>;
36
51
  }
37
52
 
38
53
  export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBundle {
39
54
  let nextIndex = opts.startIndex;
55
+ /** Prefixes already loaded in this sandbox — mirrors the worker's context state. */
56
+ const loaded = new Set<string>(opts.loadedPrefixes ?? []);
40
57
  return {
41
- reset: () => { nextIndex = opts.startIndex; },
58
+ reset: () => {
59
+ nextIndex = opts.startIndex;
60
+ loaded.clear();
61
+ },
62
+ loadedPrefixes: () => loaded,
42
63
  handlers: {
43
64
  async loadLibrary(source, depth) {
44
65
  const emitter = opts.getEmitter?.() ?? opts.emitter;
@@ -53,19 +74,76 @@ export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBund
53
74
  depth,
54
75
  });
55
76
  try {
77
+ // Cheap pre-check BEFORE cloning/packing: same namespace ⇒ nothing to do.
78
+ const { sourceId: preId, pathPrefix: prefix } = libraryNamespace(source, cwd);
79
+ if (loaded.has(prefix)) {
80
+ if (id) {
81
+ emitter?.emitSubcallUpdated({
82
+ id,
83
+ status: "done",
84
+ resultPreview: `already loaded (${prefix}*)`,
85
+ });
86
+ }
87
+ // No index consumed, no sidecar written — resume stays consistent.
88
+ return {
89
+ payload: Object.freeze([]),
90
+ index: -1,
91
+ files: 0,
92
+ chars: 0,
93
+ sourceId: preId,
94
+ pathPrefix: prefix,
95
+ alreadyLoaded: true,
96
+ };
97
+ }
98
+
56
99
  const resolved = await resolveLibrarySource(source, cwd, opts.signal);
57
100
  if (!resolved.ok) throw new Error(resolved.error);
58
- const index = nextIndex++;
59
- await opts.onLoaded?.(index, resolved.value.payload);
60
- if (id) emitter?.emitSubcallUpdated({
61
- id, status: "done",
62
- resultPreview: `context_${index}: ${resolved.value.files ?? 1} file(s), ${resolved.value.chars.toLocaleString()} chars`,
63
- });
101
+ const { payload, files, chars, sourceId, pathPrefix } = resolved.value;
102
+
103
+ // Race: another concurrent load of the same prefix finished while we packed.
104
+ if (loaded.has(pathPrefix)) {
105
+ if (id) {
106
+ emitter?.emitSubcallUpdated({
107
+ id,
108
+ status: "done",
109
+ resultPreview: `already loaded (${pathPrefix}*)`,
110
+ });
111
+ }
112
+ return {
113
+ payload: Object.freeze([]),
114
+ index: -1,
115
+ files: 0,
116
+ chars: 0,
117
+ sourceId,
118
+ pathPrefix,
119
+ alreadyLoaded: true,
120
+ };
121
+ }
122
+
123
+ // Increment only after a successful sidecar write (or when no hook is set).
124
+ const index = nextIndex;
125
+ if (opts.onLoaded) {
126
+ await opts.onLoaded(index, payload);
127
+ }
128
+ nextIndex = index + 1;
129
+ loaded.add(pathPrefix);
130
+
131
+ if (id) {
132
+ emitter?.emitSubcallUpdated({
133
+ id,
134
+ status: "done",
135
+ resultPreview:
136
+ `+${files} file(s) → context (${pathPrefix}*, ${chars.toLocaleString()} chars)`,
137
+ });
138
+ }
64
139
  return {
65
- payload: resolved.value.payload,
140
+ payload,
66
141
  index,
67
- files: resolved.value.files,
68
- chars: resolved.value.chars,
142
+ files,
143
+ chars,
144
+ sourceId,
145
+ pathPrefix,
146
+ alreadyLoaded: false,
69
147
  };
70
148
  } catch (err) {
71
149
  if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
@@ -5,88 +5,112 @@
5
5
  *
6
6
  * Caps enforce the divide-and-conquer budget from the RLM method: per-prompt size and batch
7
7
  * fan-out are bounded, and batches run through a fixed-size concurrency pool.
8
+ *
9
+ * Every input that can change between calls (worker model, emitter, parent node, depth,
10
+ * remaining budget) is an accessor, so a single bridge instance serves both the headless
11
+ * engine — which binds them once per run — and the native `repl` tool, which swaps them per
12
+ * invocation without recreating the sandbox.
8
13
  */
9
14
 
10
15
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
11
16
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
17
  import type { RlmEmitter } from "../tool/rlm-events.ts";
13
- import { modelRef, resolveModelId } from "../config/settings.ts";
18
+ import { displayModelRef, resolveModelId } from "../config/settings.ts";
14
19
  import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
15
20
  import type { Sampling } from "../core/types.ts";
16
21
  import { type ChatMsg, modelComplete } from "./model.ts";
17
22
  import { previewText } from "../text/preview.ts";
18
- import { formatError, isErrorText } from "../util/errors.ts";
23
+ import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
19
24
  import { mapPool } from "../util/concurrency.ts";
20
25
 
26
+ /**
27
+ * The config slice this bridge reads. Structurally satisfied by `RlmConfig`, and re-read on
28
+ * every call so `/rlm-config` changes take effect without rebuilding the sandbox.
29
+ */
30
+ export interface LlmBridgeConfig {
31
+ readonly maxPromptChars: number;
32
+ readonly maxConcurrentSubcalls: number;
33
+ readonly subSystemPrompt?: string;
34
+ readonly subSampling?: Sampling;
35
+ }
36
+
21
37
  export interface LlmBridgeOptions {
22
- readonly workerModel: Model<Api>;
38
+ /** Resolved per call so provider/config changes between calls are picked up. */
39
+ readonly workerModel: () => Model<Api>;
23
40
  readonly registry: ModelRegistry;
24
- readonly subSystem?: string;
25
- readonly maxPromptChars?: number;
26
- readonly maxConcurrent?: number;
27
- readonly sampling?: Sampling;
41
+ readonly config: () => LlmBridgeConfig;
28
42
  readonly signal?: AbortSignal;
29
43
  readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
30
44
  /** Parent run's remaining budget/timeout; checked before every sub-call. */
31
- readonly remainingBudget?: () => RemainingResources;
32
- /** Live RlmDetails reporting via onUpdate. */
33
- readonly emitter?: RlmEmitter;
34
- readonly parentId?: string;
35
- readonly depth?: number;
45
+ readonly remainingBudget?: () => RemainingResources | undefined;
46
+ /** Live RlmDetails reporting target, resolved per call. */
47
+ readonly emitter?: () => RlmEmitter | undefined;
48
+ readonly parentId?: () => string | undefined;
49
+ readonly depth?: () => number;
36
50
  }
37
51
 
38
- const DEFAULT_MAX_PROMPT_CHARS = 400_000;
39
- const DEFAULT_MAX_CONCURRENT = 4;
52
+ /** Provider failures that a smaller batch or a cheaper model might get past. */
53
+ const RETRYABLE_HINT = /credit|402|payment|quota|rate.limit/i;
40
54
 
41
55
  export interface LlmBridge {
42
56
  llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
43
- llmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
57
+ llmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
58
+ }
59
+
60
+ /** Batch outcome summary, or undefined when every prompt succeeded. */
61
+ function batchError(failed: number, total: number): string | undefined {
62
+ if (failed === 0) return undefined;
63
+ return failed === total
64
+ ? `all ${total} sub-calls failed — reduce batch size or try llm_query individually`
65
+ : `${failed}/${total} sub-calls failed`;
44
66
  }
45
67
 
46
68
  export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
47
- const maxPromptChars = opts.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS;
48
- const maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
49
- const { emitter } = opts;
50
69
  const displayModel = (model: string | null): string =>
51
- modelRef(model ? (resolveModelId(opts.registry, model) ?? opts.workerModel) : opts.workerModel) ?? opts.workerModel.id;
70
+ displayModelRef(opts.registry, model, opts.workerModel());
52
71
 
53
72
  // Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
54
73
  async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
74
+ const config = opts.config();
55
75
  const rem = opts.remainingBudget?.();
56
76
  if (rem !== undefined) {
57
77
  const limitError = checkResourceLimits(rem);
58
78
  if (limitError !== undefined) return limitError;
59
79
  }
60
- if (prompt.length > maxPromptChars) {
61
- return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
80
+ if (prompt.length > config.maxPromptChars) {
81
+ return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
62
82
  }
63
83
  const resolved = model ? resolveModelId(opts.registry, model) : undefined;
64
84
  if (model && !resolved) return formatError(`unknown model override '${model}'`);
85
+ const target = resolved ?? opts.workerModel();
65
86
  try {
66
87
  const messages: ChatMsg[] = [{ role: "user", content: prompt }];
67
88
  const res = await modelComplete(messages, {
68
- model: resolved ?? opts.workerModel,
89
+ model: target,
69
90
  registry: opts.registry,
70
- system: opts.subSystem,
71
- maxTokens: opts.sampling?.maxTokens,
72
- temperature: opts.sampling?.temperature,
73
- reasoning: opts.sampling?.reasoning,
91
+ system: config.subSystemPrompt,
92
+ maxTokens: config.subSampling?.maxTokens,
93
+ temperature: config.subSampling?.temperature,
94
+ reasoning: config.subSampling?.reasoning,
74
95
  signal: opts.signal,
75
96
  });
76
- opts.onUsage?.(res.usage, resolved ?? opts.workerModel);
97
+ opts.onUsage?.(res.usage, target);
77
98
  track(res.usage);
78
99
  return res.text;
79
100
  } catch (err) {
80
- return formatError(err instanceof Error ? err.message : String(err));
101
+ const msg = errorMessage(err);
102
+ const hint = RETRYABLE_HINT.test(msg) ? " — try smaller batches or individual llm_query calls" : "";
103
+ return formatError(`${msg}${hint}`);
81
104
  }
82
105
  }
83
106
 
84
107
  return {
85
108
  async llmQuery(prompt, model) {
109
+ const emitter = opts.emitter?.();
86
110
  const id = emitter?.emitSubcallCreated({
87
- kind: "llm", parentId: opts.parentId, label: "llm_query",
111
+ kind: "llm", parentId: opts.parentId?.(), label: "llm_query",
88
112
  model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
89
- depth: opts.depth ?? 0,
113
+ depth: opts.depth?.() ?? 0,
90
114
  });
91
115
  let cost = 0;
92
116
  let tokens = 0;
@@ -103,28 +127,28 @@ export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
103
127
  },
104
128
 
105
129
  async llmQueryBatched(prompts, model) {
130
+ const emitter = opts.emitter?.();
106
131
  const id = emitter?.emitSubcallCreated({
107
- kind: "batch", parentId: opts.parentId, label: `llm_query ×${prompts.length}`,
132
+ kind: "batch", parentId: opts.parentId?.(), label: `llm_query ×${prompts.length}`,
108
133
  model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
109
- depth: opts.depth ?? 0,
134
+ depth: opts.depth?.() ?? 0,
110
135
  });
111
136
  let cost = 0;
112
137
  let tokens = 0;
113
- const out = await mapPool(prompts, maxConcurrent, (p) =>
138
+ const out = await mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) =>
114
139
  complete1(p, model, (u) => {
115
140
  cost += u.cost.total;
116
141
  tokens += u.totalTokens;
117
142
  }),
118
143
  );
119
144
  const failed = out.filter(isErrorText).length;
120
- const error = failed === out.length && out.length > 0
121
- ? `all ${out.length} sub-calls failed`
122
- : failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
145
+ const error = batchError(failed, out.length);
123
146
  const firstPreview = previewText(out[0] ?? "");
124
147
  const resultPreview = out.length > 1 ? `${firstPreview} (+${out.length - 1} more)` : firstPreview;
125
148
  if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
126
149
  status: error ? "error" : "done", costUsd: cost, tokens,
127
150
  resultPreview, detail: error,
151
+ failedCount: failed, totalCount: out.length,
128
152
  });
129
153
  return out;
130
154
  },
@@ -5,120 +5,104 @@
5
5
  * depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
6
6
  * concurrency pool bounds parallel children for `rlm_query_batched`.
7
7
  *
8
- * `childRun` is the single spawn path: rlmQuery returns `.answer`; implement fanout also
9
- * needs `.edits` both share this implementation (DRY).
8
+ * As with the llm bridge, everything that can change between calls is an accessor, so the
9
+ * headless engine (which binds them once per run) and the native `repl` tool (which swaps them
10
+ * per invocation) share one implementation.
10
11
  */
11
12
 
12
13
  import type { RlmResult, RunRlm } from "../core/types.ts";
13
14
  import type { LlmBridge } from "./llm-query.ts";
14
15
  import type { RlmEmitter } from "../tool/rlm-events.ts";
15
- import { checkResourceLimits } from "../core/resource-limits.ts";
16
- import { formatError } from "../util/errors.ts";
16
+ import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
17
+ import { errorMessage, formatError } from "../util/errors.ts";
17
18
  import { mapPool } from "../util/concurrency.ts";
19
+ import { previewText } from "../text/preview.ts";
18
20
 
19
- export interface ChildRunInput {
20
- readonly rootPrompt: string;
21
- readonly context: unknown;
22
- readonly depth: number;
23
- readonly label?: string;
24
- readonly model?: string | null;
21
+ /** The config slice this bridge reads; structurally satisfied by `RlmConfig`. */
22
+ export interface RlmBridgeConfig {
23
+ readonly maxDepth: number;
24
+ readonly maxConcurrentSubcalls: number;
25
25
  }
26
26
 
27
27
  export interface RlmHandlers {
28
28
  rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
29
- rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
30
- /** Full child-run result (answer + edits + usage) for engine-driven fanout. */
31
- childRun(input: ChildRunInput): Promise<RlmResult>;
29
+ rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
32
30
  }
33
31
 
34
32
  export interface RlmBridgeOptions {
33
+ /**
34
+ * Spawns one child run. The engine passes its own `run` (self-recursion); the native tool
35
+ * passes a closure that builds a child engine bound to the current invocation's emitter.
36
+ */
35
37
  readonly run: RunRlm;
36
38
  readonly llm: LlmBridge;
37
- /** Live RlmDetails reporting via onUpdate. Required — replaces SubcallObserver for recursive subcalls. */
38
- readonly emitter: RlmEmitter;
39
- readonly maxDepth: number;
40
- readonly maxConcurrent: number;
41
- /** Parent subcall ID that this run is attached under. */
42
- readonly parentNodeId?: string;
39
+ readonly config: () => RlmBridgeConfig;
40
+ /** "provider/id" shown on the sub-call node; see `displayModelRef` in config/settings.ts. */
41
+ readonly modelLabel?: (override: string | null) => string;
42
+ /** Live RlmDetails reporting target, resolved per call. */
43
+ readonly emitter: () => RlmEmitter | undefined;
44
+ /** Parent subcall ID that this run is attached under, resolved per call. */
45
+ readonly parentNodeId?: () => string | undefined;
43
46
  /** Returns the parent's remaining budget/timeout for seeding child runs. */
44
- readonly remainingBudget?: () => { readonly budgetUsd?: number; readonly timeoutMs?: number };
47
+ readonly remainingBudget?: () => RemainingResources | undefined;
45
48
  /** Called with a child run's total cost/tokens so the parent LimitGuard debits it. */
46
49
  readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
47
50
  }
48
51
 
49
- function emptyResult(answer: string): RlmResult {
50
- return {
51
- answer,
52
- edits: [],
53
- iterations: 0,
54
- costUsd: 0,
55
- inputTokens: 0,
56
- outputTokens: 0,
57
- durationMs: 0,
58
- };
59
- }
60
-
61
52
  export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
62
- async function childRun(input: ChildRunInput): Promise<RlmResult> {
63
- const childDepth = input.depth;
53
+ /**
54
+ * One child spawn. `childDepth` is the absolute depth the child will run at.
55
+ * Never throws: failures come back as "Error: ..." strings, matching the sandbox contract.
56
+ */
57
+ async function child(prompt: string, model: string | null, childDepth: number): Promise<string> {
64
58
  // At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
65
- // (Callers pass the absolute child depth; rlmQuery wraps with depth+1.)
66
- if (childDepth >= opts.maxDepth) {
67
- const answer = await opts.llm.llmQuery(
68
- input.rootPrompt || String(input.context),
69
- input.model ?? null,
70
- childDepth - 1,
71
- );
72
- return emptyResult(answer);
59
+ if (childDepth >= opts.config().maxDepth) {
60
+ return opts.llm.llmQuery(prompt, model, childDepth - 1);
73
61
  }
74
- let subId: string | undefined;
75
- try {
76
- const rem = opts.remainingBudget?.() ?? {};
62
+
63
+ const rem = opts.remainingBudget?.();
64
+ if (rem !== undefined) {
77
65
  // Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
78
66
  // (reference: _subcall checks remaining_budget/timeout before spawning).
79
67
  const limitError = checkResourceLimits(rem);
80
- if (limitError) return emptyResult(limitError);
81
- const label = input.label ?? "rlm_query";
82
- const detailSource = input.rootPrompt || String(input.context);
83
- subId = opts.emitter.emitSubcallCreated({
84
- kind: "rlm", parentId: opts.parentNodeId, label,
85
- model: input.model ?? undefined, detail: detailSource.slice(0, 60),
86
- depth: childDepth,
87
- });
88
- const res = await opts.run({
89
- rootPrompt: input.rootPrompt,
90
- context: input.context,
68
+ if (limitError !== undefined) return limitError;
69
+ }
70
+
71
+ const emitter = opts.emitter();
72
+ const subId = emitter?.emitSubcallCreated({
73
+ kind: "rlm", parentId: opts.parentNodeId?.(), label: "rlm_query",
74
+ model: opts.modelLabel?.(model) ?? model ?? undefined,
75
+ detail: prompt.slice(0, 60),
76
+ depth: childDepth,
77
+ });
78
+
79
+ try {
80
+ const res: RlmResult = await opts.run({
81
+ rootPrompt: "",
82
+ context: prompt,
91
83
  depth: childDepth,
92
84
  parentNodeId: subId,
93
- modelOverride: input.model ?? undefined,
94
- remainingBudgetUsd: rem.budgetUsd,
95
- remainingTimeoutMs: rem.timeoutMs,
85
+ modelOverride: model ?? undefined,
86
+ remainingBudgetUsd: rem?.budgetUsd,
87
+ remainingTimeoutMs: rem?.timeoutMs,
96
88
  });
97
89
  opts.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
98
- opts.emitter.emitSubcallUpdated({ id: subId,
99
- status: "done", resultPreview: res.answer.slice(0, 200),
100
- });
101
- return res;
90
+ // The child emits live cost/token deltas on the shared emitter as it runs, so the node
91
+ // must NOT also receive a final aggregate — that would double-count.
92
+ if (emitter && subId !== undefined) {
93
+ emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: previewText(res.answer) });
94
+ }
95
+ return res.answer;
102
96
  } catch (err) {
103
- const msg = err instanceof Error ? err.message : String(err);
104
- if (subId) opts.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
105
- return emptyResult(formatError(`child RLM failed - ${msg}`));
97
+ const msg = errorMessage(err);
98
+ if (emitter && subId !== undefined) emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
99
+ return formatError(`child RLM failed - ${msg}`);
106
100
  }
107
101
  }
108
102
 
109
- async function child(prompt: string, model: string | null, depth: number): Promise<string> {
110
- const res = await childRun({
111
- rootPrompt: "",
112
- context: prompt,
113
- depth: depth + 1,
114
- model,
115
- });
116
- return res.answer;
117
- }
118
-
119
103
  return {
120
- rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
121
- rlmQueryBatched: (prompts, model, depth) => mapPool(prompts, opts.maxConcurrent, (p) => child(p, model, depth)),
122
- childRun,
104
+ rlmQuery: (prompt, model, depth) => child(prompt, model, depth + 1),
105
+ rlmQueryBatched: (prompts, model, depth) =>
106
+ mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) => child(p, model, depth + 1)),
123
107
  };
124
108
  }
@@ -11,15 +11,15 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
11
11
  const models = ctx.modelRegistry.getAvailable();
12
12
 
13
13
  const worker = await selectModel(ctx, "Worker model (sub-LLM / llm_query)", models, controller.workerModel, controller.config.subSampling.reasoning);
14
- if (worker === null) {
15
- controller.workerModel = undefined;
16
- controller.config.subSampling.reasoning = undefined;
17
- } else if (worker) {
18
- controller.workerModel = worker.model;
19
- controller.config.subSampling.reasoning = worker.thinkingLevel;
14
+ if (worker !== undefined) {
15
+ controller.workerModel = worker?.model;
16
+ controller.setConfig(Object.freeze({
17
+ ...controller.config,
18
+ subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: worker?.thinkingLevel }),
19
+ }));
20
20
  }
21
21
 
22
- await showConfigPanel(ctx, controller.config);
22
+ controller.setConfig(await showConfigPanel(ctx, controller.config));
23
23
 
24
24
  if (worker === null) {
25
25
  controller.savedWorkerRef = undefined;
@@ -29,7 +29,7 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
29
29
  }
30
30
  const persisted = await controller.persist();
31
31
  if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
32
- setRlmModeStatus(ctx.ui, controller);
32
+ setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
33
33
 
34
34
  const w = controller.workerModel;
35
35
  ctx.ui.notify(