@hicaru/pi-rlm 0.3.1 → 0.3.3

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 (46) hide show
  1. package/README.md +131 -154
  2. package/README.ru.md +5 -5
  3. package/README.zh-CN.md +5 -5
  4. package/package.json +3 -2
  5. package/src/bridge/handlers/await.ts +148 -0
  6. package/src/bridge/handlers/completion.ts +72 -0
  7. package/src/bridge/handlers/emitting.ts +104 -0
  8. package/src/bridge/handlers/finish.ts +45 -0
  9. package/src/bridge/handlers/index.ts +48 -0
  10. package/src/bridge/handlers/llm-query.ts +130 -0
  11. package/src/bridge/handlers/rlm-query.ts +227 -0
  12. package/src/bridge/handlers/task-registry.ts +202 -0
  13. package/src/bridge/handlers/types.ts +136 -0
  14. package/src/commands/rlm-config.ts +40 -12
  15. package/src/config/settings.ts +13 -3
  16. package/src/context/listing.ts +2 -2
  17. package/src/context/refresh.ts +141 -0
  18. package/src/core/engine.ts +16 -18
  19. package/src/core/types.ts +1 -3
  20. package/src/index.ts +59 -54
  21. package/src/mode/native-guards.ts +4 -4
  22. package/src/mode/rlm-mode.ts +6 -1
  23. package/src/mode/subagent.ts +1 -1
  24. package/src/prompts/glossary.ts +71 -74
  25. package/src/prompts/native.ts +127 -85
  26. package/src/prompts/system.ts +29 -15
  27. package/src/sandbox/interrupts.ts +258 -68
  28. package/src/sandbox/protocol.ts +53 -30
  29. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
  31. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  32. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/guards.py +8 -5
  34. package/src/sandbox/py/retrieval.py +17 -8
  35. package/src/sandbox/py/tasks.py +1 -1
  36. package/src/sandbox/py/worker.py +106 -79
  37. package/src/sandbox/sandbox-manager.ts +26 -1
  38. package/src/sandbox/sandbox.ts +1 -1
  39. package/src/tool/background-tasks.ts +1 -1
  40. package/src/tool/repl-result.ts +2 -2
  41. package/src/tool/repl-tool.ts +13 -14
  42. package/src/ui/config-panel.ts +1 -1
  43. package/src/ui/intro.ts +1 -4
  44. package/src/ui/model-picker.ts +32 -2
  45. package/src/util/concurrency.ts +1 -1
  46. package/src/bridge/subcall-handlers.ts +0 -382
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Single in-memory task registry for api_v5 async-by-default spawns.
3
+ *
4
+ * DRY: this is the ONLY place that assigns task_ids, parks await waiters, and
5
+ * resolves/rejects entries. Handlers and session UI both consume this type —
6
+ * never re-implement a Map of TaskEntry elsewhere.
7
+ */
8
+
9
+ import type { AwaitResult, SpawnResult, TaskEntry } from "./types.ts";
10
+
11
+ export const SPAWN_HINT =
12
+ "Call await_task(task_id=...) to get the result — this is NOT the answer.";
13
+
14
+ export interface SpawnDeps {
15
+ nextId(): number;
16
+ register(kind: SpawnResult["kind"], n: number, taskId: string): SpawnResult;
17
+ resolve(taskId: string, result: string | readonly string[]): void;
18
+ reject(taskId: string, error: string): void;
19
+ }
20
+
21
+ export interface AwaitDeps {
22
+ get(taskId: string): TaskEntry | undefined;
23
+ wait(taskId: string, timeoutMs?: number): Promise<TaskEntry>;
24
+ unawaitedIds(): readonly string[];
25
+ }
26
+
27
+ export interface TaskRegistry {
28
+ readonly spawnDeps: SpawnDeps;
29
+ readonly awaitDeps: AwaitDeps;
30
+ entries(): ReadonlyMap<string, TaskEntry>;
31
+ toAwaitResult(taskId: string): AwaitResult;
32
+ }
33
+
34
+ interface Waiter {
35
+ resolve: (entry: TaskEntry) => void;
36
+ reject: (err: Error) => void;
37
+ timer?: ReturnType<typeof setTimeout>;
38
+ }
39
+
40
+ function notify(waiters: Map<string, Waiter>, taskId: string, entry: TaskEntry): void {
41
+ const w = waiters.get(taskId);
42
+ if (w === undefined) return;
43
+ if (w.timer !== undefined) clearTimeout(w.timer);
44
+ w.resolve(entry);
45
+ waiters.delete(taskId);
46
+ }
47
+
48
+ export function createTaskRegistry(): TaskRegistry {
49
+ const tasks = new Map<string, TaskEntry>();
50
+ const waiters = new Map<string, Waiter>();
51
+ let counter = 0;
52
+
53
+ const spawnDeps: SpawnDeps = {
54
+ nextId: () => {
55
+ counter += 1;
56
+ return counter;
57
+ },
58
+ register(kind, n, taskId) {
59
+ const entry: TaskEntry = {
60
+ taskId,
61
+ kind,
62
+ n,
63
+ status: "pending",
64
+ createdAt: Date.now(),
65
+ };
66
+ tasks.set(taskId, entry);
67
+ return Object.freeze({
68
+ ok: true,
69
+ task_id: taskId,
70
+ kind,
71
+ n,
72
+ status: "pending" as const,
73
+ hint: SPAWN_HINT,
74
+ });
75
+ },
76
+ resolve(taskId, result) {
77
+ const entry = tasks.get(taskId);
78
+ if (entry === undefined) return;
79
+ entry.status = "done";
80
+ if (typeof result === "string") {
81
+ entry.result = result;
82
+ } else {
83
+ entry.results = Object.freeze([...result]);
84
+ }
85
+ notify(waiters, taskId, entry);
86
+ },
87
+ reject(taskId, error) {
88
+ const entry = tasks.get(taskId);
89
+ if (entry === undefined) return;
90
+ entry.status = "error";
91
+ entry.error = error;
92
+ const w = waiters.get(taskId);
93
+ if (w !== undefined) {
94
+ if (w.timer !== undefined) clearTimeout(w.timer);
95
+ w.reject(new Error(error));
96
+ waiters.delete(taskId);
97
+ }
98
+ },
99
+ };
100
+
101
+ const awaitDeps: AwaitDeps = {
102
+ get: (taskId) => tasks.get(taskId),
103
+ wait(taskId, timeoutMs) {
104
+ return new Promise<TaskEntry>((resolve, reject) => {
105
+ const entry = tasks.get(taskId);
106
+ if (entry !== undefined && entry.status !== "pending") {
107
+ resolve(entry);
108
+ return;
109
+ }
110
+ const timer =
111
+ timeoutMs !== undefined
112
+ ? setTimeout(() => {
113
+ waiters.delete(taskId);
114
+ const e = tasks.get(taskId);
115
+ if (e !== undefined && e.status === "pending") {
116
+ e.status = "timeout";
117
+ e.error = `Timeout after ${timeoutMs}ms`;
118
+ }
119
+ reject(new Error(`Timeout waiting for task ${taskId}`));
120
+ }, timeoutMs)
121
+ : undefined;
122
+ waiters.set(taskId, { resolve, reject, timer });
123
+ });
124
+ },
125
+ unawaitedIds: () => {
126
+ const ids: string[] = [];
127
+ for (const [id, e] of tasks) {
128
+ if (e.status === "pending") ids.push(id);
129
+ }
130
+ return ids;
131
+ },
132
+ };
133
+
134
+ return {
135
+ spawnDeps,
136
+ awaitDeps,
137
+ entries: () => tasks,
138
+ toAwaitResult(taskId) {
139
+ const entry = tasks.get(taskId);
140
+ if (entry === undefined) {
141
+ return {
142
+ ok: false,
143
+ task_id: taskId,
144
+ kind: "unknown",
145
+ status: "error",
146
+ error: `Task ${taskId} not found`,
147
+ };
148
+ }
149
+ const status =
150
+ entry.status === "pending" ? "error" : entry.status;
151
+ return {
152
+ ok: entry.status === "done",
153
+ task_id: entry.taskId,
154
+ kind: entry.kind,
155
+ status,
156
+ result: entry.result,
157
+ results: entry.results,
158
+ error:
159
+ entry.error ??
160
+ (entry.status === "pending" ? "Task still pending" : undefined),
161
+ };
162
+ },
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Register a spawn and start background work once.
168
+ * Returns SpawnResult immediately; never uses non-null assertions on task_id.
169
+ */
170
+ export function spawnAndRun(
171
+ sd: SpawnDeps,
172
+ kind: SpawnResult["kind"],
173
+ n: number,
174
+ work: () => Promise<string | readonly string[]>,
175
+ trackDetached: (<T>(run: () => Promise<T>) => Promise<T>) | undefined,
176
+ detached: boolean,
177
+ ): SpawnResult {
178
+ const id = sd.nextId();
179
+ const taskId = `task_${id}`;
180
+ const spawned = sd.register(kind, n, taskId);
181
+
182
+ const run = async (): Promise<void> => {
183
+ try {
184
+ const result = await work();
185
+ sd.resolve(taskId, result);
186
+ } catch (err: unknown) {
187
+ const message = err instanceof Error ? err.message : String(err);
188
+ sd.reject(taskId, message.startsWith("Error:") ? message : `Error: ${message}`);
189
+ }
190
+ };
191
+
192
+ if (trackDetached !== undefined && detached) {
193
+ void trackDetached(run).catch((err: unknown) => {
194
+ const message = err instanceof Error ? err.message : String(err);
195
+ sd.reject(taskId, message.startsWith("Error:") ? message : `Error: ${message}`);
196
+ });
197
+ } else {
198
+ void run();
199
+ }
200
+
201
+ return spawned;
202
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Shared types for the async-by-default subcall handler set.
3
+ *
4
+ * Every subcall (llm_query, llm_batch, rlm_query, rlm_batch) returns a SpawnResult
5
+ * immediately with a task_id. The model must call await(task_id) to collect the
6
+ * real answer. This contract is proven in rlm_test (api_v5 + batch, scores 0.89–1.0).
7
+ */
8
+
9
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
10
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
11
+ import type { RlmInput, RlmResult, Sampling } from "../../core/types.ts";
12
+ import type { SubcallGates } from "../../util/concurrency.ts";
13
+ import type { SubcallOpts } from "../../sandbox/interrupts.ts";
14
+ import type { RlmEmitter } from "../../tool/rlm-events.ts";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Spawn / Await / Finish — the three shapes the model sees
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Returned by every spawn: llm_query, llm_batch, rlm_query, rlm_batch. */
21
+ export interface SpawnResult {
22
+ readonly ok: boolean;
23
+ readonly task_id: string | null;
24
+ readonly kind: "llm" | "rlm" | "llm_batch" | "rlm_batch";
25
+ /** Number of sub-tasks in this batch (1 for singles). */
26
+ readonly n: number;
27
+ readonly status: "pending";
28
+ /** Hint text reminding the model to await. */
29
+ readonly hint: string;
30
+ readonly error?: string;
31
+ }
32
+
33
+ /** Returned by await(task_id) or await(task_ids=[...]). */
34
+ export interface AwaitResult {
35
+ readonly ok: boolean;
36
+ readonly task_id: string;
37
+ readonly kind: string;
38
+ readonly status: "done" | "error" | "timeout";
39
+ /** Single result (llm_query, rlm_query). */
40
+ readonly result?: string;
41
+ /** Ordered batch results (llm_batch, rlm_batch). */
42
+ readonly results?: readonly string[];
43
+ readonly error?: string;
44
+ }
45
+
46
+ /** Returned by finish(summary) — the contract boundary. */
47
+ export interface FinishResult {
48
+ readonly ok: true;
49
+ readonly finished: true;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Invocation — per-call context (emitter, limits, depth)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface InvocationLimits {
57
+ remainingTimeoutMs(): number | undefined;
58
+ addUsage(usage: Usage): void;
59
+ addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
60
+ }
61
+
62
+ export function limitsFromRemaining(
63
+ remaining?: () => { readonly timeoutMs?: number },
64
+ ): InvocationLimits {
65
+ return Object.freeze({
66
+ remainingTimeoutMs: () => remaining?.().timeoutMs,
67
+ addUsage: (_usage: Usage) => {},
68
+ addRaw: (_costUsd: number, _inputTokens: number, _outputTokens: number) => {},
69
+ });
70
+ }
71
+
72
+ export interface Invocation {
73
+ readonly emitter: RlmEmitter;
74
+ readonly parentId: string | undefined;
75
+ readonly depth: number;
76
+ readonly limits: InvocationLimits;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Handler dependencies — supplied by the engine or repl() tool
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export interface SubcallConfig {
84
+ readonly maxPromptChars: number;
85
+ readonly maxDepth: number;
86
+ readonly subSampling?: Sampling;
87
+ readonly subSystemPrompt?: string;
88
+ }
89
+
90
+ export interface SubcallHandlerDeps {
91
+ /** Resolve the invocation for a given interrupt (emitter + limits). */
92
+ readonly resolve: (opts: SubcallOpts, depth: number) => Invocation | null;
93
+ /** Session-wide concurrency gates. */
94
+ readonly gates: SubcallGates;
95
+ readonly registry: ModelRegistry;
96
+ readonly getLlmModel: () => Model<Api>;
97
+ readonly getConfig: () => SubcallConfig;
98
+ readonly signal?: AbortSignal;
99
+ readonly onUsage?: (usage: Usage, role: "sub") => void;
100
+ // Recursion
101
+ readonly runChild?: (input: RlmInput, inv: Invocation) => Promise<RlmResult>;
102
+ readonly getChildContext?: () => unknown;
103
+ readonly getModel?: () => Model<Api>;
104
+ readonly degrade?: (prompt: string, depth: number) => Promise<string>;
105
+ readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
106
+ readonly trackDetached?: <T>(run: () => Promise<T>) => Promise<T>;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Handler set returned to the sandbox (canonical names only)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /** Canonical api_v5 handler set — no legacy aliases. */
114
+ export interface SubcallHandlers {
115
+ readonly llmQuery: (prompt: string, depth: number, opts: SubcallOpts) => Promise<SpawnResult>;
116
+ readonly llmBatch: (prompts: readonly string[], depth: number, opts: SubcallOpts) => Promise<SpawnResult>;
117
+ readonly rlmQuery: (task: string, depth: number, opts: SubcallOpts) => Promise<SpawnResult>;
118
+ readonly rlmBatch: (tasks: readonly string[], depth: number, opts: SubcallOpts) => Promise<SpawnResult>;
119
+ readonly awaitTask: (taskId: string | undefined, taskIds: readonly string[] | undefined, timeoutS: number | undefined, depth: number, opts: SubcallOpts) => Promise<AwaitResult>;
120
+ readonly finishTask: (summary: string, depth: number, opts: SubcallOpts) => Promise<FinishResult>;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Task registry entry — tracks in-flight spawns
125
+ // ---------------------------------------------------------------------------
126
+
127
+ export interface TaskEntry {
128
+ readonly taskId: string;
129
+ readonly kind: "llm" | "rlm" | "llm_batch" | "rlm_batch";
130
+ readonly n: number;
131
+ status: "pending" | "done" | "error" | "timeout";
132
+ result?: string;
133
+ results?: readonly string[];
134
+ error?: string;
135
+ readonly createdAt: number;
136
+ }
@@ -8,7 +8,7 @@ import type { RlmController } from "../mode/rlm-mode.ts";
8
8
  import { cheapestModel } from "../mode/llm-model.ts";
9
9
  import { setRlmModeStatus } from "../ui/status.ts";
10
10
  import { showConfigPanel } from "../ui/config-panel.ts";
11
- import { pickableModels, selectModel } from "../ui/model-picker.ts";
11
+ import { pickableModels, selectModel, type ModelSelection } from "../ui/model-picker.ts";
12
12
 
13
13
  /** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely. */
14
14
  function sessionScopedModels(
@@ -18,6 +18,36 @@ function sessionScopedModels(
18
18
  return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
19
19
  }
20
20
 
21
+ /**
22
+ * Apply a model-picker result to controller pin state.
23
+ *
24
+ * - `null` → explicit "cheapest (auto)" (clear pin; leave reasoning alone)
25
+ * - `ModelSelection` → pin that model (and its thinking level, which may be undefined)
26
+ * - `undefined` → ESC / no change
27
+ */
28
+ export function applyLlmSelection(
29
+ controller: RlmController,
30
+ llm: ModelSelection | null | undefined,
31
+ ): void {
32
+ if (llm === undefined) return;
33
+ if (llm === null) {
34
+ controller.llmModel = undefined;
35
+ controller.savedLlmRef = undefined;
36
+ controller.explicitClearPin = true;
37
+ return;
38
+ }
39
+ controller.llmModel = llm.model;
40
+ controller.savedLlmRef = modelRef(llm.model);
41
+ controller.explicitClearPin = false;
42
+ controller.setConfig(Object.freeze({
43
+ ...controller.config,
44
+ subSampling: Object.freeze({
45
+ ...controller.config.subSampling,
46
+ reasoning: llm.thinkingLevel,
47
+ }),
48
+ }));
49
+ }
50
+
21
51
  export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
22
52
  // Match Pi's native list: refresh so a just-added key appears, then use scoped models when
23
53
  // the session narrowed them, else every available (auth-configured) model. Never getAll().
@@ -34,23 +64,21 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
34
64
  models,
35
65
  controller.llmModel,
36
66
  controller.config.subSampling.reasoning,
67
+ controller.savedLlmRef,
37
68
  );
69
+ // Only an explicit choice touches the pin. ESC leaves model + reasoning alone.
70
+ // Choosing cheapest must NOT wipe subSampling.reasoning (null !== undefined used to).
71
+ applyLlmSelection(controller, llm);
72
+
73
+ // Persist model choice immediately — if showConfigPanel throws or process exits before it
74
+ // returns, the pin survives (Root Cause #2, v0.3.2).
38
75
  if (llm !== undefined) {
39
- controller.llmModel = llm?.model;
40
- controller.setConfig(Object.freeze({
41
- ...controller.config,
42
- subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: llm?.thinkingLevel }),
43
- }));
76
+ const saved = await controller.persist();
77
+ if (!saved) ctx.ui.notify("RLM: failed to save llm setting", "error");
44
78
  }
45
79
 
46
80
  controller.setConfig(await showConfigPanel(ctx, controller.config));
47
81
 
48
- // Only an explicit choice touches the persisted pin. ESC (`undefined`) used to fall through
49
- // here and freeze whatever cheapest resolved to at that moment, which silently ended
50
- // "cheapest (auto)" for every later session — including once a cheaper model appeared.
51
- if (llm === null) controller.savedLlmRef = undefined; // "⟳ cheapest (auto)"
52
- else if (llm !== undefined) controller.savedLlmRef = modelRef(llm.model);
53
-
54
82
  const persisted = await controller.persist();
55
83
  if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
56
84
  setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
@@ -9,8 +9,9 @@ import { DEFAULT_CONFIG } from "./defaults.ts";
9
9
 
10
10
  export interface PersistedSettings {
11
11
  readonly config: Partial<RlmConfig>;
12
- /** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)". */
13
- readonly llm?: string;
12
+ /** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
13
+ * `null` = explicit "cheapest" clear (omit key on disk). */
14
+ readonly llm?: string | null;
14
15
  }
15
16
 
16
17
  type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
@@ -133,7 +134,16 @@ export async function saveSettings(s: PersistedSettings): Promise<boolean> {
133
134
  try {
134
135
  const p = settingsPath();
135
136
  await mkdir(dirname(p), { recursive: true });
136
- await writeFile(p, `${JSON.stringify(s, null, 2)}\n`);
137
+ const body: Record<string, unknown> = { config: s.config };
138
+ if (s.llm !== undefined) {
139
+ // Explicit: string → write pin, null → omit key (cheapest).
140
+ if (s.llm !== null) body.llm = s.llm;
141
+ } else {
142
+ // Merge: preserve existing disk pin so config-only saves never strip it.
143
+ const existing = await loadSettings();
144
+ if (existing.llm) body.llm = existing.llm;
145
+ }
146
+ await writeFile(p, `${JSON.stringify(body, null, 2)}\n`);
137
147
  return true;
138
148
  } catch {
139
149
  return false;
@@ -23,7 +23,7 @@ export function formatContextListing(context: unknown): string {
23
23
  'Use `add_context("/path/to/dir")` / `add_context("docs.pdf")` / `add_context("https://…")` for external sources.',
24
24
  "Documents (PDF, DOCX, XLSX, PPTX, CSV, …) are converted to Markdown on the way in.",
25
25
  "",
26
- "Use repl({code}) and delegate semantic reading to llm_query / llm_query_batched / llm_query_chunked.",
26
+ "Use repl({code}) and delegate semantic reading to llm_query / llm_batch / llm_query_chunked.",
27
27
  ].join("\n");
28
28
  }
29
29
 
@@ -53,7 +53,7 @@ export function formatContextListing(context: unknown): string {
53
53
  truncated,
54
54
  "",
55
55
  "File contents are loaded in the REPL `context` variable — file-reading tools are disabled.",
56
- "Use repl({code}) and delegate semantic reading to llm_query / llm_query_batched / llm_query_chunked.",
56
+ "Use repl({code}) and delegate semantic reading to llm_query / llm_batch / llm_query_chunked.",
57
57
  ].join("\n");
58
58
  }
59
59
 
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Keep RLM `context` in sync with the disk after native edit/write.
3
+ *
4
+ * Seed packs file bodies once; without this, search/map_files/llm still see pre-edit text.
5
+ */
6
+
7
+ import { readFile } from "node:fs/promises";
8
+ import { isAbsolute, relative, resolve } from "node:path";
9
+ import { estimateTokens } from "../text/tokens.ts";
10
+ import type { ContextFile } from "./types.ts";
11
+
12
+ /** Paths that look like tool file targets. */
13
+ export function extractEditPaths(input: unknown): readonly string[] {
14
+ if (typeof input !== "object" || input === null) return Object.freeze([]);
15
+ const o = input as Record<string, unknown>;
16
+ const keys = ["path", "file_path", "filePath", "filename", "file"] as const;
17
+ const out: string[] = [];
18
+ for (const k of keys) {
19
+ const v = o[k];
20
+ if (typeof v === "string" && v.trim() !== "") out.push(v.trim());
21
+ }
22
+ // Some tools pass { path, oldText, newText } only — already covered.
23
+ return Object.freeze(out);
24
+ }
25
+
26
+ /**
27
+ * Normalize disk path to how cwd-seed entries usually appear (relative to cwd when under cwd).
28
+ */
29
+ export function normalizeContextPath(filePath: string, cwd: string): string {
30
+ const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath);
31
+ const rel = relative(cwd, abs);
32
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return abs;
33
+ return rel.split("\\").join("/");
34
+ }
35
+
36
+ function pathMatches(entryPath: string, target: string, cwd: string): boolean {
37
+ if (entryPath === target) return true;
38
+ const a = normalizeContextPath(entryPath, cwd);
39
+ const b = normalizeContextPath(target, cwd);
40
+ if (a === b) return true;
41
+ // suffix match for namespaced entries
42
+ return entryPath.endsWith("/" + target) || entryPath.endsWith(target);
43
+ }
44
+
45
+ /**
46
+ * Upsert one file into a context payload list. Returns a **new** array (identity change
47
+ * so BM25 stamp invalidates when the worker rebinds `context`).
48
+ */
49
+ export function upsertContextFile(
50
+ payload: unknown,
51
+ filePath: string,
52
+ content: string,
53
+ cwd: string,
54
+ ): ContextFile[] {
55
+ const path = normalizeContextPath(filePath, cwd);
56
+ const tokens = estimateTokens(content.length);
57
+ const entry: ContextFile = Object.freeze({ path, content, tokens });
58
+
59
+ if (!Array.isArray(payload)) {
60
+ return [entry];
61
+ }
62
+
63
+ const next = new Array<ContextFile>(payload.length + 1);
64
+ let n = 0;
65
+ let replaced = false;
66
+ for (let i = 0; i < payload.length; i++) {
67
+ const item: unknown = payload[i];
68
+ if (
69
+ item !== null &&
70
+ typeof item === "object" &&
71
+ "path" in item &&
72
+ typeof (item as { path: unknown }).path === "string" &&
73
+ pathMatches((item as { path: string }).path, path, cwd)
74
+ ) {
75
+ next[n++] = entry;
76
+ replaced = true;
77
+ } else if (
78
+ item !== null &&
79
+ typeof item === "object" &&
80
+ "path" in item &&
81
+ "content" in item &&
82
+ typeof (item as { path: unknown }).path === "string" &&
83
+ typeof (item as { content: unknown }).content === "string"
84
+ ) {
85
+ const e = item as { path: string; content: string; tokens?: number };
86
+ next[n++] = Object.freeze({
87
+ path: e.path,
88
+ content: e.content,
89
+ tokens: typeof e.tokens === "number" ? e.tokens : estimateTokens(e.content.length),
90
+ });
91
+ }
92
+ // drop non-file entries silently (shouldn't appear in file bundles)
93
+ }
94
+ if (!replaced) next[n++] = entry;
95
+ next.length = n;
96
+ return next;
97
+ }
98
+
99
+ /** Read file from disk; return null if missing/unreadable. */
100
+ export async function readDiskFile(filePath: string, cwd: string): Promise<string | null> {
101
+ const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath);
102
+ try {
103
+ return await readFile(abs, "utf8");
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Python snippet that rebinds `context` with updated path content and forces a new list id
111
+ * so BM25 rebuilds on next search.
112
+ */
113
+ export function patchContextExecCode(filePath: string, content: string, cwd: string): string {
114
+ const path = normalizeContextPath(filePath, cwd);
115
+ // JSON for safe embedding in Python string literals
116
+ const pathLit = JSON.stringify(path);
117
+ const contentLit = JSON.stringify(content);
118
+ const tokens = estimateTokens(content.length);
119
+ return `
120
+ _path = ${pathLit}
121
+ _content = ${contentLit}
122
+ _tokens = ${tokens}
123
+ _old = context if isinstance(context, list) else []
124
+ _next = []
125
+ _found = False
126
+ for _e in _old:
127
+ if isinstance(_e, dict) and str(_e.get("path", "")) in (_path, _path.replace("\\\\", "/")):
128
+ _next.append({"path": _path, "content": _content, "tokens": _tokens})
129
+ _found = True
130
+ elif isinstance(_e, dict) and (
131
+ str(_e.get("path", "")).endswith("/" + _path) or str(_e.get("path", "")).endswith(_path)
132
+ ):
133
+ _next.append({"path": str(_e.get("path")), "content": _content, "tokens": _tokens})
134
+ _found = True
135
+ else:
136
+ _next.append(_e)
137
+ if not _found:
138
+ _next.append({"path": _path, "content": _content, "tokens": _tokens})
139
+ context = _next
140
+ `.trim();
141
+ }
@@ -13,10 +13,10 @@ import { buildAddContextHandler } from "../bridge/add-context.ts";
13
13
  import { mergeIntoContext } from "../context/merge.ts";
14
14
  import {
15
15
  createSubcallHandlers,
16
+ createTaskRegistry,
16
17
  type Invocation,
17
- } from "../bridge/subcall-handlers.ts";
18
+ } from "../bridge/handlers/index.ts";
18
19
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
19
- import { resolveModelId } from "../config/settings.ts";
20
20
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
21
21
  import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
22
22
  import type { RlmEmitter } from "../tool/rlm-events.ts";
@@ -30,7 +30,6 @@ import { appendUserMessage } from "./history.ts";
30
30
  import { runTurn } from "./iteration.ts";
31
31
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
32
32
  import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
33
- import { formatError } from "../util/errors.ts";
34
33
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
35
34
 
36
35
  /**
@@ -71,20 +70,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
71
70
  emitter.emitTurn(0, deps.config.maxIterations);
72
71
  }
73
72
 
74
- const overrideModel = input.modelOverride ? resolveModelId(deps.registry, input.modelOverride) : undefined;
75
- if (input.modelOverride && !overrideModel) {
76
- if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, status: "error", detail: "unknown model override" });
77
- else emitter.emitStatus("error");
78
- return {
79
- answer: formatError(`unknown model override '${input.modelOverride}'`),
80
- iterations: 0,
81
- costUsd: 0,
82
- inputTokens: 0,
83
- outputTokens: 0,
84
- durationMs: 0,
85
- };
86
- }
87
- const model = overrideModel ?? deps.model;
73
+ const model = deps.model;
88
74
 
89
75
  // Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
90
76
  // Children inherit the parent's remaining timeout (propagated as remaining amount, not
@@ -117,6 +103,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
117
103
  // run can settle or abort it first (a child engine left running would keep spending).
118
104
  let detachedInFlight = 0;
119
105
  let detachedIdle: (() => void) | undefined;
106
+ // One registry per run — unawaited task reminders share the same map as await handlers.
107
+ const taskRegistry = createTaskRegistry();
120
108
  const subcalls = createSubcallHandlers({
121
109
  resolve: () => invocation,
122
110
  gates: deps.gates
@@ -140,7 +128,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
140
128
  if (detachedInFlight === 0) detachedIdle?.();
141
129
  }
142
130
  },
143
- });
131
+ }, taskRegistry);
144
132
  /** Wait (bounded) for detached work before the sandbox goes away. */
145
133
  const settleDetached = async (): Promise<void> => {
146
134
  if (detachedInFlight === 0) return;
@@ -189,6 +177,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
189
177
  maxPromptChars: deps.config.maxPromptChars,
190
178
  contextLoader: deps.config.contextLoader,
191
179
  child: input.depth > 0,
180
+ depth: input.depth,
192
181
  });
193
182
 
194
183
  const contextHandlers = deps.config.contextLoader
@@ -251,6 +240,15 @@ export function createEngine(deps: EngineDeps): RunRlm {
251
240
  pendingReplOutputs = undefined;
252
241
  }
253
242
 
243
+ // Soft runtime nudge (rlm_test parity): remind the model to await pending host tasks.
244
+ const pendingIds = taskRegistry.awaitDeps.unawaitedIds();
245
+ if (pendingIds.length > 0) {
246
+ appendUserMessage(
247
+ history,
248
+ `[runtime] Unawaited task_ids: ${pendingIds.join(", ")} — call await before finish.`,
249
+ );
250
+ }
251
+
254
252
  appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
255
253
 
256
254
  // rootSampling fields win; smartReasoning is the default reasoning when not overridden.