@hicaru/pi-rlm 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/types.ts CHANGED
@@ -15,9 +15,10 @@ export interface RlmConfig {
15
15
  readonly maxDepth: number;
16
16
  /** Max turns before the engine must finalize. */
17
17
  readonly maxIterations: number;
18
- /** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
18
+ /** Per-`repl`-block wall-clock timeout inside the worker (seconds).
19
+ * v5 doctrine: content limits are the token budget's job — this is a HANG backstop only. */
19
20
  readonly execTimeoutS: number;
20
- /** Parent-side watchdog per sandbox request (ms). */
21
+ /** Parent-side watchdog per sandbox request (ms). Hang backstop (see execTimeoutS). */
21
22
  readonly requestTimeoutMs: number;
22
23
  /** Concurrency pool for *_batched sub-calls. */
23
24
  readonly maxConcurrentSubcalls: number;
@@ -28,7 +29,10 @@ export interface RlmConfig {
28
29
  readonly maxPromptChars: number;
29
30
  /** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
30
31
  readonly maxTimeoutMs?: number;
31
- /** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap). */
32
+ /** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap).
33
+ * ⚠ HARD ABORT (audit H8): when set, exceeding this throws a LimitError mid-run and the run
34
+ * ends with its best partial — NO wrap-up, NO continuation. The graceful path is the v5
35
+ * token budget (`enableTokenBudget`); leave this unset unless a hard tree-wide stop is wanted. */
32
36
  readonly maxTokens?: number;
33
37
  /** Max consecutive error turns before the engine stops (undefined = no cap). */
34
38
  readonly maxErrors?: number;
@@ -61,6 +65,37 @@ export interface RlmConfig {
61
65
  readonly subSystemPrompt?: string;
62
66
  /** Sampling for sub-LLM (worker) calls. */
63
67
  readonly subSampling: Readonly<Sampling>;
68
+ /** v5 token budget: cap = budgetShare × contextWindow, clamped by budgetTaskCap. When on,
69
+ * the budget is the PRIMARY run-length control (soft wrap-up → continuation chain). */
70
+ readonly enableTokenBudget: boolean;
71
+ /** Fraction of the model's context window that forms one run's token cap. */
72
+ readonly budgetShare: number;
73
+ /** Soft wrap-up fires at this fraction of the cap (one wrap-up turn). */
74
+ readonly budgetSoftFrac: number;
75
+ /** Absolute single-run ceiling; 0 = no clamp beyond the share. */
76
+ readonly budgetTaskCap: number;
77
+ /** Max continuation runs after a hard stop (chain ≤ 1 + this). */
78
+ readonly budgetMaxContinuations: number;
79
+ /** Char budget for the deterministic continuation handoff. */
80
+ readonly budgetHandoffChars: number;
81
+ /** v5 TaskLedger blackboard: claim coalescing + ancestor-echo reject + `[ledger]` injection. */
82
+ readonly enableLedger: boolean;
83
+ /** Real rlm spawns allowed before extra rlm_query demotes to llm_query (0 = never). */
84
+ readonly rlmBudget: number;
85
+ /** v5 durable memory: L1 episode replay + L2 BM25 notes under `<root>/.rlm/memory`. */
86
+ readonly enableMemory: boolean;
87
+ /** Char budget for the `[memory]` injection = tokens × 4. */
88
+ readonly injectNoteTokens: number;
89
+ /** Pending episodes per L2 consolidation batch (0 = never auto-consolidate). */
90
+ readonly evolveEvery: number;
91
+ /** Override the memory dir. `null` (default) = `<root>/.rlm/memory` — this field only
92
+ * RELOCATES the store; the on/off switch is `enableMemory` (audit M3). */
93
+ readonly memoryDir: string | null;
94
+ /** v5: per-provider concurrent-request caps (e.g. `{ zai: 4 }`). Caps only lower limits. */
95
+ readonly providerMaxConcurrent?: Readonly<Record<string, number>>;
96
+ /** v5 doctrine: "delegation" = child engines get llm/memory/ledger only (no repo retrieval);
97
+ * "legacy" keeps today's full child surface as a one-flip rollback. */
98
+ readonly childSurface: "delegation" | "legacy";
64
99
  }
65
100
 
66
101
  /** Input to a (headless) RLM run. */
@@ -75,6 +110,12 @@ export interface RlmInput {
75
110
  readonly parentNodeId?: string;
76
111
  /** Remaining timeout for this subtree (set by parent from its LimitGuard). */
77
112
  readonly remainingTimeoutMs?: number;
113
+ /** v5: budget for this run. Set only by the engine itself when chaining a continuation —
114
+ * a fresh budget is resolved from config when omitted. */
115
+ readonly budget?: import("./budget.ts").TokenBudget;
116
+ /** v5: the shared TaskLedger blackboard. Children inherit the parent's instance —
117
+ * set by childRun (the one child-RlmInput construction path); a fresh run gets a new one. */
118
+ readonly ledger?: import("./ledger.ts").TaskLedger;
78
119
  }
79
120
 
80
121
  /** Result of a completed RLM run. */
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { Markdown } from "@earendil-works/pi-tui";
5
5
  import { registerRlmCommand } from "./commands/rlm.ts";
6
6
  import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
7
+ import type { RlmConfig } from "./core/types.ts";
7
8
  import { createRlmTool } from "./tool/rlm-tool.ts";
8
9
  import { createReplTool } from "./tool/repl-tool.ts";
9
10
  import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
@@ -12,9 +13,12 @@ import { cheapestModel } from "./mode/llm-model.ts";
12
13
  import { postRlmGuide } from "./ui/intro.ts";
13
14
  import { setRlmModeStatus } from "./ui/status.ts";
14
15
  import { markdownTheme } from "./ui/theme-adapter.ts";
16
+ import { SANDBOX_WATCHDOG_HEARTBEAT_MS } from "./sandbox/sandbox.ts";
15
17
  import { SandboxManager } from "./sandbox/sandbox-manager.ts";
16
- import { createSubcallGates } from "./util/concurrency.ts";
18
+ import { buildSessionGates, type SubcallGates } from "./util/concurrency.ts";
17
19
  import { BackgroundTasks } from "./tool/background-tasks.ts";
20
+ import { MemoryStore } from "./core/memory.ts";
21
+ import { modelComplete } from "./bridge/model.ts";
18
22
  import { resolve } from "node:path";
19
23
  import { resolveSource } from "./context/resolve.ts";
20
24
  import { formatContextListing } from "./context/listing.ts";
@@ -38,8 +42,6 @@ export {
38
42
  processRlmDepth,
39
43
  } from "./mode/subagent.ts";
40
44
 
41
- /** How often to keep the parent sandbox's request watchdog alive during detached work. */
42
- const WATCHDOG_HEARTBEAT_MS = 30_000;
43
45
  /** Soft token guard — cap bulk tool stdout; do NOT hard-block read/grep/bash readers. */
44
46
  const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls", "read", "grep"]));
45
47
 
@@ -62,7 +64,21 @@ export default function rlmExtension(pi: ExtensionAPI): void {
62
64
 
63
65
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
64
66
  const config = mergeConfig({});
65
- const controller = new RlmController(config);
67
+ // v5 durable memory: one store per session under <cwd>/.rlm/memory (L1 replay + L2 notes).
68
+ // NOTE (audit M4): this store IS shared by both composition roots, but the TaskLedger is
69
+ // NOT — the native repl() session and each headless rlm run each keep their own blackboard
70
+ // (v5 parity: per-run ledger). Claims/coalescing reset at that boundary, by design.
71
+ // The consolidation LLM + real workspace root are attached in session_start (setLlm/setRoot).
72
+ const memory = new MemoryStore(
73
+ process.cwd(),
74
+ {
75
+ dir: config.memoryDir ?? undefined,
76
+ injectNoteTokens: config.injectNoteTokens,
77
+ evolveEvery: config.evolveEvery,
78
+ },
79
+ config.enableMemory,
80
+ );
81
+ const controller = new RlmController(config, memory);
66
82
  let onSandboxDiscardExtra: (() => void) | undefined;
67
83
  const sandboxManager = new SandboxManager({
68
84
  execTimeoutS: config.execTimeoutS,
@@ -75,9 +91,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
75
91
  awaitTimeoutS: Math.round(config.requestTimeoutMs / 1000),
76
92
  onSandboxDiscarded: () => { onSandboxDiscardExtra?.(); },
77
93
  });
78
- // One admission gate for the whole session: spawn() lets the sandbox put many requests on
79
- // the wire at once, so nothing smaller than session scope actually bounds fan-out.
80
- const gates = createSubcallGates(config.maxConcurrentSubcalls, config.maxConcurrentChildren);
94
+ // v5: sub-call admission is built per session (see session_start) so provider concurrency
95
+ // caps resolve against the models actually in use.
81
96
  const background = new BackgroundTasks({
82
97
  maxTimeoutMs: config.maxTimeoutMs,
83
98
  maxTokens: config.maxTokens,
@@ -88,7 +103,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
88
103
  // with it. Keep it alive while detached work is genuinely in flight.
89
104
  const watchdogHeartbeat = setInterval(() => {
90
105
  if (background.pending > 0) sandboxManager.refreshWatchdog();
91
- }, WATCHDOG_HEARTBEAT_MS);
106
+ }, SANDBOX_WATCHDOG_HEARTBEAT_MS);
92
107
  watchdogHeartbeat.unref();
93
108
 
94
109
  /** Memoised cwd seed — one resolveSource(pathPrefix:"") per session. */
@@ -198,6 +213,40 @@ export default function rlmExtension(pi: ExtensionAPI): void {
198
213
  const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
199
214
  const model = ctx.model;
200
215
  if (llmModel && model) {
216
+ // Consolidation runs on the cheap worker model through the single completion entry point;
217
+ // the workspace root is only known once the session starts.
218
+ const consolidateModel = llmModel;
219
+ memory.setLlm((prompt) =>
220
+ modelComplete([{ role: "user", content: prompt }], { model: consolidateModel, registry: ctx.modelRegistry })
221
+ .then((r) => r.text));
222
+ memory.setRoot(ctx.cwd ?? process.cwd());
223
+ // v5 provider caps (audit C1/C6): ONE resolver shared by both composition roots — the
224
+ // repl() tool and RlmController.start admit through the same pool, each gate capped
225
+ // against the model that actually runs on it (leaves = worker, children = smart).
226
+ // Memoized on (config, providers) so /rlm-config changes apply without a restart.
227
+ let gatesMemo:
228
+ | { readonly config: RlmConfig; readonly smart: string; readonly worker: string; readonly gates: SubcallGates }
229
+ | undefined;
230
+ const resolveSessionGates = (): SubcallGates => {
231
+ const smart = model;
232
+ const worker = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
233
+ const workerProvider = worker.provider;
234
+ if (
235
+ gatesMemo === undefined ||
236
+ gatesMemo.config !== controller.config ||
237
+ gatesMemo.smart !== smart.provider ||
238
+ gatesMemo.worker !== workerProvider
239
+ ) {
240
+ gatesMemo = {
241
+ config: controller.config,
242
+ smart: smart.provider,
243
+ worker: workerProvider,
244
+ gates: buildSessionGates(controller.config, smart.provider, workerProvider),
245
+ };
246
+ }
247
+ return gatesMemo.gates;
248
+ };
249
+ controller.setSessionGates(resolveSessionGates);
201
250
  try {
202
251
  pi.registerTool(createReplTool({
203
252
  sandboxManager,
@@ -207,8 +256,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
207
256
  getLlmModel: () => controller.resolveModels(ctx)?.llm,
208
257
  registry: ctx.modelRegistry,
209
258
  getConfig: () => controller.config,
210
- gates,
259
+ gates: resolveSessionGates(),
260
+ resolveGates: resolveSessionGates,
211
261
  background,
262
+ memory,
212
263
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
213
264
  registerContextBundle: (bundle) => {
214
265
  contextBundleRef = bundle;
@@ -9,13 +9,16 @@
9
9
  import type { Api, Model } from "@earendil-works/pi-ai";
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
12
- import { createEngine } from "../core/engine.ts";
12
+ import { createEngine, type EngineDeps } from "../core/engine.ts";
13
13
  import { limitsFromConfig } from "../core/limits.ts";
14
14
  import type { RlmConfig, RlmResult } from "../core/types.ts";
15
15
  import { resolveSource } from "../context/resolve.ts";
16
16
  import { RlmEmitter } from "../tool/rlm-events.ts";
17
17
  import { formatError } from "../util/errors.ts";
18
18
  import { cheapestModel } from "./llm-model.ts";
19
+ import type { MemoryStore } from "../core/memory.ts";
20
+ import type { RunRlm } from "../core/types.ts";
21
+ import type { SubcallGates } from "../util/concurrency.ts";
19
22
 
20
23
  export interface RunHandle {
21
24
  readonly abort: () => void;
@@ -33,8 +36,19 @@ export class RlmController {
33
36
  /** Set by applyLlmSelection when the user explicitly picks "cheapest (auto)". */
34
37
  explicitClearPin = false;
35
38
  private active: AbortController | null = null;
36
-
37
- constructor(public config: RlmConfig) {}
39
+ /** v5: session admission gates (provider-capped), shared with the repl() tool — set at
40
+ * session_start so BOTH composition roots admit through one pool (audit C1). */
41
+ private sessionGates: (() => SubcallGates) | undefined;
42
+
43
+ constructor(
44
+ public config: RlmConfig,
45
+ /** v5 durable memory — shared with the repl tool so child runs replay/persist too. */
46
+ public readonly memory?: MemoryStore,
47
+ ) {}
48
+
49
+ setSessionGates(getGates: () => SubcallGates): void {
50
+ this.sessionGates = getGates;
51
+ }
38
52
 
39
53
  get enabled(): boolean {
40
54
  return this.config.enabled;
@@ -83,6 +97,33 @@ export class RlmController {
83
97
  return { model, llm };
84
98
  }
85
99
 
100
+ /** Test seam (audit R7): intercept the exact object `createEngine` receives. */
101
+ protected spawnEngine(deps: EngineDeps): RunRlm {
102
+ return createEngine(deps);
103
+ }
104
+
105
+ /** The ONE engine construction path for this controller (DRY #6 — a second path that
106
+ * forgets to grow is exactly how issue #4 and audit C1 happened). Protected so tests can
107
+ * subclass and assert the wiring without touching the network. */
108
+ protected buildEngine(args: {
109
+ readonly ctx: ExtensionContext;
110
+ readonly models: { readonly model: Model<Api>; readonly llm: Model<Api> };
111
+ readonly signal: AbortSignal;
112
+ readonly emitter: RlmEmitter;
113
+ }): RunRlm {
114
+ return this.spawnEngine({
115
+ model: args.models.model,
116
+ llmModel: args.models.llm,
117
+ registry: args.ctx.modelRegistry,
118
+ config: this.config,
119
+ signal: args.signal,
120
+ emitter: args.emitter,
121
+ limits: limitsFromConfig(this.config),
122
+ memory: this.memory,
123
+ gates: this.sessionGates?.(),
124
+ });
125
+ }
126
+
86
127
  start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter): RunHandle {
87
128
  const models = this.resolveModels(ctx);
88
129
  if (!models) throw new Error("no model with configured auth is available");
@@ -102,14 +143,11 @@ export class RlmController {
102
143
  ? result.value.payload
103
144
  : formatError(`failed to pack repository — ${result.error}`);
104
145
  }
105
- const engine = createEngine({
106
- model: models.model,
107
- llmModel: models.llm,
108
- registry: ctx.modelRegistry,
109
- config: this.config,
146
+ const engine = this.buildEngine({
147
+ ctx,
148
+ models,
110
149
  signal: abortController.signal,
111
150
  emitter: emitter ?? new RlmEmitter(),
112
- limits: limitsFromConfig(this.config),
113
151
  });
114
152
  return await engine({ rootPrompt: input.rootPrompt, context: contextValue, depth: 0 });
115
153
  })().finally(() => {
@@ -39,6 +39,16 @@ export const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
39
39
  " Orient in ~200 chars instead of printing 20K. Matches exact path, then suffix, then glob.",
40
40
  ]);
41
41
 
42
+ /** v5 delegation doctrine (audit C5): children have NO retrieval tools — their world is the
43
+ * sliced `context` they were handed. This REPLACES the retrieval lines in child prompts so
44
+ * the prompt and the runtime sandbox agree (a child taught to `search` burns turns on NameError). */
45
+ export const DELEGATION_SURFACE_LINES: readonly string[] = Object.freeze([
46
+ "- **No `search` / `grep_context` / `outline` / `add_context` in this REPL** (delegation",
47
+ " surface, v5 doctrine): your task arrived WITH its world in `context`. Explore it with",
48
+ " Python (list comprehensions, string matching, slicing) and delegate slices to sub-LLMs —",
49
+ " never re-ask the parent for retrieval.",
50
+ ]);
51
+
42
52
  /** One-line delegation helpers — orchestrating must be cheaper than solving. */
43
53
  export const DELEGATION_GLOSSARY_LINES: readonly string[] = Object.freeze([
44
54
  "- `map_files(files, prompt) -> Task`: always spawn. `await_task(t)` → dict[path, answer].",
@@ -57,12 +67,21 @@ export const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
57
67
 
58
68
  /** Non-blocking fan-out: spawn now, collect later (headless glossary). */
59
69
  export const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
60
- "- **ALWAYS SPAWN (Task + bg):** `llm_query` / `llm_batch` / `rlm_query` / `rlm_batch` /",
70
+ "- **ALWAYS SPAWN (Task + bg):** `llm_query` / `llm_batch` / `rlm_query` / `rlm_batch` /",
61
71
  " `map_files` / `llm_query_chunked`. Never treat the return as the answer.",
62
- " Collect with `await_task(t)` or `await_task([t1,t2,…])`. Fire independent Tasks first, free work, then await.",
72
+ " Collect with `await_task(t)`, `await_task([t1,t2,…])`, or `await_task()` (every still-running Task).",
73
+ " If `await_task` returns `Error: sub-call still running`, call it again — do not respawn.",
74
+ " `list_tasks()` → [{kind, label, done, var}]. Fire independent Tasks first, free work, then await.",
63
75
  " Do NOT await after every independent spawn (serializes wall time). `task.done` when settled.",
76
+ "- `[ledger]` global state: the blackboard in your prompt lists inflight/done agent claims.",
77
+ " NEVER `rlm_query` a task already on `[ledger]` (await it / reuse the result); ancestor",
78
+ " echo is rejected with a stub. `list_claims()` shows the live table anytime.",
64
79
  "- `spawn(fn, *args) -> Task`: same as calling the always-spawn tools (not `llm_map_reduce`).",
65
80
  "- Only `llm_map_reduce` still blocks until done.",
81
+ ]);
82
+
83
+ /** v5 (audit C5): the spawn worked example, retrieval flavor — root surface only. */
84
+ export const SPAWN_EXAMPLE_RETRIEVAL: readonly string[] = Object.freeze([
66
85
  "",
67
86
  " ```python",
68
87
  " # Multi-area study: one rlm_batch (parallel workers), free locate, then await",
@@ -76,11 +95,20 @@ export const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
76
95
  " ```",
77
96
  ]);
78
97
 
79
- /**
80
- * What a parent must know about the child it is about to spawn. Without this the model writes
81
- * referential prompts ("read lib/x/src/…") on the assumption the child can go fetch them, which
82
- * is what made a missing child context degrade silently instead of failing (issue #4).
83
- */
98
+ /** v5 (audit C5): the spawn worked example, delegation flavor — no retrieval, slice instead. */
99
+ export const SPAWN_EXAMPLE_DELEGATION: readonly string[] = Object.freeze([
100
+ "",
101
+ " ```python",
102
+ " # Multi-area study: one rlm_batch (parallel workers), slice your world while they run",
103
+ " t = rlm_batch([",
104
+ " \"Answer from the FIRST half of the context only: paths + symbols for X.\",",
105
+ " \"Answer from the SECOND half only: report how Y is configured.\",",
106
+ " ])",
107
+ " half = [f['path'] for f in context[:len(context)//2]] # free work while Tasks run",
108
+ " reports = await_task(t)",
109
+ " # One-shot extracts: map_files / llm_batch also return Task → await_task",
110
+ " ```",
111
+ ]);
84
112
  export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
85
113
  "",
86
114
  " **What a child sees:** it inherits YOUR `context` — every file you have loaded, including",
@@ -95,6 +123,19 @@ export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
95
123
  " this section disappears at the last recursive depth.",
96
124
  ]);
97
125
 
126
+ /** v5 recursion section, delegation variant (audit C5): describes what a delegation child
127
+ * receives — the narrowed pack as text, no retrieval of its own. */
128
+ export const RECURSION_DELEGATION_LINES: readonly string[] = Object.freeze([
129
+ "",
130
+ " **What a child sees:** it inherits YOUR `context` (narrowed by `paths=` when given) and works",
131
+ " on it as text — it has NO retrieval tools, so put what matters in your prompt and `paths`,",
132
+ " never file bodies you already share (that costs tokens twice and buys nothing).",
133
+ " Inheritance is one-way: sources the child loads, and its whole REPL, die with it — only its",
134
+ " final answer string returns. The child cannot write to your `answers` or `plan`.",
135
+ " At the depth cap `rlm_query` degrades to a plain sub-LLM call with NO context, which is why",
136
+ " this section disappears at the last recursive depth.",
137
+ ]);
138
+
98
139
  /**
99
140
  * Sub-RLM orientation. Emitted only at depth > 0, where `context` is the parent's world rather
100
141
  * than a repository the run packed for itself.
@@ -146,38 +187,52 @@ export const LARGE_FILE_RULE_NATIVE =
146
187
  * The paper is explicit (App. B) that one prompt does not port across models and that both
147
188
  * guardrails are needed; keep them both.
148
189
  */
149
- export const ENV_TIPS = [
150
- "## Decomposition doctrine",
151
- "",
152
- "**Orchestrate; don't solve.** A single chain of thought over a large repository drifts —",
153
- "you lose partials and compound mistakes. Sub-workers are competent: trust them; don't read for them.",
154
- "",
155
- "Your job: (1) free locate with `search` / `grep_context` / `outline`,",
156
- "(2) fan out: **multi-step areas `rlm_batch` / `rlm_query`**; one-shot extracts ",
157
- " `map_files` / `llm_batch` (all return Task `await_task` for content),",
158
- "(3) memoize into `answers`, (4) sanity-check before dependents, (5) assemble from `answers`.",
159
- "Your own compute is: pointers, dict lookups, string formatting, and decisions.",
160
- "",
161
- "### The only state that matters",
162
- "`answers` and `plan` are dicts that persist across every turn.",
163
- "**If a value isn't in `answers`, it doesn't exist.** Do not trust truncated stdout. Memoize.",
164
- "",
165
- "### Shape of a run",
166
- "1. Probe: `print(len(context))`, `search(<question>)`. Do not print file bodies.",
167
- "2. Plan: sub-questions into `plan` (each from a named slice / module).",
168
- "3. Fan out **in parallel**: one `rlm_batch` for independent multi-step studies, or",
169
- " `map_files` / `llm_batch` for one-shot reads — not one serial call per file.",
170
- "4. Assemble from `answers`.",
171
- "",
172
- "### Red flags you are off track",
173
- "- Printing file bodies / native bulk read stop; use map_files or rlm_*.",
174
- "- `llm_query(\"Read src/foo.ts…\")` with only a path — sub-LLM has **no disk**; use map_files/rlm_*.",
175
- "- Multi-module task with zero `rlm_batch`/`rlm_query`/`map_files` → under-delegating.",
176
- "- Await after every independent spawn → serializes wall time; fire-all-then-await.",
177
- "- Treating Task as the answer without `await_task`.",
178
- "- Regex used to *infer meaning* sub-LLM job. Regex is for exact needles only.",
179
- "- Two turns with zero sub-LLM calls on analysis solving it yourself.",
180
- ].join("\n");
190
+ /** Decomposition doctrine. `delegation` drops retrieval names (audit R3) — children
191
+ * have no `search` and must not be told to locate with it. */
192
+ export function envTips(delegation = false): string {
193
+ const locate = delegation
194
+ ? "Your job: (1) slice `context` with Python (indexing, string matching, comprehensions),"
195
+ : "Your job: (1) free locate with `search` / `grep_context` / `outline`,";
196
+ const probe = delegation
197
+ ? "1. Probe: `print(len(context))`; locate targets with Python slicing / string matching. Do not print file bodies."
198
+ : "1. Probe: `print(len(context))`; locate targets with `search` when your surface has it, else\n Python slicing / string matching. Do not print file bodies.";
199
+ return [
200
+ "## Decomposition doctrine",
201
+ "",
202
+ "**Orchestrate; don't solve.** A single chain of thought over a large repository drifts —",
203
+ "you lose partials and compound mistakes. Sub-workers are competent: trust them; don't read for them.",
204
+ "",
205
+ locate,
206
+ "(2) fan out: **multi-step areas → `rlm_batch` / `rlm_query`**; one-shot extracts →",
207
+ " `map_files` / `llm_batch` (all return Task `await_task` for content),",
208
+ "(3) memoize into `answers`, (4) sanity-check before dependents, (5) assemble from `answers`.",
209
+ "Your own compute is: pointers, dict lookups, string formatting, and decisions.",
210
+ "",
211
+ "### The only state that matters",
212
+ "`answers` and `plan` are dicts that persist across every turn.",
213
+ "**If a result isn't in `answers`, you have not memoized it.** Task handles are REPL vars —",
214
+ "`list_tasks()` / `SHOW_VARS()` find them. Do not trust truncated stdout. Memoize after await_task.",
215
+ "",
216
+ "### Shape of a run",
217
+ probe,
218
+ "2. Plan: sub-questions into `plan` (each from a named slice / module).",
219
+ "3. Fan out **in parallel**: one `rlm_batch` for independent multi-step studies, or",
220
+ " `map_files` / `llm_batch` for one-shot reads not one serial call per file.",
221
+ "4. Assemble from `answers`.",
222
+ "",
223
+ "### Red flags — you are off track",
224
+ "- Printing file bodies / native bulk read → stop; use map_files or rlm_*.",
225
+ "- `llm_query(\"Read src/foo.ts…\")` with only a path — sub-LLM has **no disk**; use map_files/rlm_*.",
226
+ "- Multi-module task with zero `rlm_batch`/`rlm_query`/`map_files` → under-delegating.",
227
+ "- Await after every independent spawn → serializes wall time; fire-all-then-await.",
228
+ "- Treating Task as the answer without `await_task`.",
229
+ "- Regex used to *infer meaning* → sub-LLM job. Regex is for exact needles only.",
230
+ "- Two turns with zero sub-LLM calls on analysis → solving it yourself.",
231
+ ].join("\n");
232
+ }
233
+
234
+ /** Root-surface doctrine (back-compat alias of `envTips(false)`). */
235
+ export const ENV_TIPS = envTips(false);
181
236
 
182
237
  /** Native-mode variant of the doctrine — same rules, sized for the native prompt budget. */
183
238
  export const ENV_TIPS_CONDENSED = [
@@ -185,7 +240,7 @@ export const ENV_TIPS_CONDENSED = [
185
240
  "Orchestrate; don't solve. Free locate → fan-out Tasks → await_task → memoize in `answers`.",
186
241
  "Multi-module / multi-step areas: **`rlm_batch` (or rlm_query)** — not serial native read.",
187
242
  "One-shot extracts: `map_files` / `llm_batch`. Always Task → await_task; fire-all then await.",
188
- "`answers`/`plan` persist: **if it isn't in `answers`, it doesn't exist.**",
243
+ "`answers`/`plan` persist collected results. Task handles are REPL vars (`list_tasks()` / `SHOW_VARS()`).",
189
244
  "Red flags: bulk file dumps; llm_query with path-only (no content — no disk!); zero rlm_*/map_files",
190
245
  "on multi-area tasks; await after each spawn; Task treated as answer.",
191
246
  "AUTHORING: you write every edit body yourself.",
@@ -204,6 +259,7 @@ export function replGlossary(
204
259
  recursion: boolean,
205
260
  contextLoader: boolean,
206
261
  child: boolean,
262
+ delegation = false,
207
263
  ): string {
208
264
  const lines = ["Available in the REPL:"];
209
265
  if (kind === "text") {
@@ -221,19 +277,36 @@ export function replGlossary(
221
277
  CONTEXT_EXCLUSION_NOTE,
222
278
  );
223
279
  if (child) lines.push(...CHILD_CONTEXT_LINES);
224
- lines.push(
225
- "",
226
- " Worked example — find the slice, then delegate it (Task + await):",
227
- " ```python",
228
- ' hits = search("where is the retry/backoff policy configured?", k=8)',
229
- " paths = sorted({h['path'] for h in hits})",
230
- ' t = map_files(paths, "Describe any retry/backoff policy in this file, with line numbers. Say NONE if absent.")',
231
- " answers.update(await_task(t))",
232
- " print({p: a[:80] for p, a in answers.items()})",
233
- " ```",
234
- );
280
+ if (delegation) {
281
+ lines.push(
282
+ "",
283
+ " Worked example — slice the world you were handed, then delegate it (Task + await):",
284
+ " ```python",
285
+ " slice = [f for f in context if f['path'].startswith('src/auth/')][:6]",
286
+ " prompts = [f\"Answer from this file only.\\n\\n{f['content'][:4000]}\" for f in slice]",
287
+ " t = llm_batch(prompts)",
288
+ " answers.update(dict(zip([f['path'] for f in slice], await_task(t))))",
289
+ " ```",
290
+ );
291
+ } else {
292
+ lines.push(
293
+ "",
294
+ " Worked example — find the slice, then delegate it (Task + await):",
295
+ " ```python",
296
+ ' hits = search("where is the retry/backoff policy configured?", k=8)',
297
+ " paths = sorted({h['path'] for h in hits})",
298
+ ' t = map_files(paths, "Describe any retry/backoff policy in this file, with line numbers. Say NONE if absent.")',
299
+ " answers.update(await_task(t))",
300
+ " print({p: a[:80] for p, a in answers.items()})",
301
+ " ```",
302
+ );
303
+ }
304
+ }
305
+ if (delegation) {
306
+ lines.push(...DELEGATION_SURFACE_LINES);
307
+ } else {
308
+ lines.push(...RETRIEVAL_GLOSSARY_LINES);
235
309
  }
236
- lines.push(...RETRIEVAL_GLOSSARY_LINES);
237
310
  lines.push(
238
311
  "- `llm_query(prompt: str) -> Task`: spawn one sub-LLM (await_task for str). The prompt must",
239
312
  " **contain the text** to analyze — this call has no filesystem and no `context`.",
@@ -241,9 +314,10 @@ export function replGlossary(
241
314
  " await_task → ordered list[str]. NEVER pass bare file paths as if the worker can open them.",
242
315
  ...CHUNKED_GLOSSARY_LINES,
243
316
  ...SPAWN_GLOSSARY_LINES,
317
+ ...(delegation ? SPAWN_EXAMPLE_DELEGATION : SPAWN_EXAMPLE_RETRIEVAL),
244
318
  ...DELEGATION_GLOSSARY_LINES,
245
319
  );
246
- if (contextLoader) {
320
+ if (contextLoader && !delegation) {
247
321
  lines.push(
248
322
  "- `add_context(source: str) -> dict`: load a dir, file, document, or git URL and **APPEND its",
249
323
  " files into `context`** (same shape: path/content/tokens). Documents (PDF, DOCX, XLSX, PPTX,",
@@ -264,21 +338,29 @@ export function replGlossary(
264
338
  }
265
339
  if (recursion) {
266
340
  lines.push(
267
- "- `rlm_query(task, paths=None) -> Task` / `rlm_batch(tasks, paths=None) -> Task`:",
341
+ "- `rlm_query(task|prompt, paths=None) -> Task` / `rlm_batch(tasks|prompts, paths=None) -> Task`:",
268
342
  " always spawn + ↯bg. await_task for the report string(s). Child REPL is private.",
343
+ " Both spellings accepted; prefer `task`/`tasks`.",
269
344
  "",
270
345
  " **Routing (api_v5):**",
271
346
  " - `llm_query` / `llm_batch` / `map_files` — one-shot facts/extracts (fast).",
272
- " - `rlm_query` — one multi-step study (own search/outline loop).",
347
+ delegation
348
+ ? " - `rlm_query` — one multi-step study (its own delegation loop; it cannot search either)."
349
+ : " - `rlm_query` — one multi-step study (own search/outline loop).",
273
350
  " - `rlm_batch` — ≥2 independent multi-step studies in **parallel** (prefer over N× rlm_query).",
274
351
  " Always Task → await_task. Fire independent work first; never serial-await between peers.",
275
- ...RECURSION_CONTEXT_LINES,
352
+ ...(delegation ? RECURSION_DELEGATION_LINES : RECURSION_CONTEXT_LINES),
276
353
  );
277
354
  }
278
355
  lines.push(
279
356
  "- `answers` / `plan`: two dicts that persist across turns. Memoize every",
280
357
  " verified result in `answers` — see the decomposition doctrine below.",
281
- "- `SHOW_VARS() -> str`: list every variable currently in the REPL.",
358
+ "- `SHOW_VARS() -> str`: list every variable currently in the REPL (Task handles show as `<Task …>`).",
359
+ "- `list_tasks()`: every Task this REPL created — [{kind, label, done, var}].",
360
+ "- `memory.query(q) -> str` / `memory.add(text, paths=…, tags=…)`: durable notes under `.rlm/`",
361
+ " that survive across sessions. Query before re-studying a known area; add concise findings",
362
+ " (facts, locations, decisions) — never secrets or API keys (notes persist on disk).",
363
+ "- `list_claims()`: the live `[ledger]` table of inflight/done agent work.",
282
364
  '- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
283
365
  ' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
284
366
  ' **You MUST flip `answer["ready"] = True` — runs that never finalize are discarded.**',
@@ -32,19 +32,23 @@ function nativeReplGlossary(): string {
32
32
  "| `llm_batch(prompts)` | list[str] | many cheap facts in **parallel** | multi-step research |",
33
33
  "| `map_files(files, prompt)` | dict[path,str] | same question over many files | multi-step per file |",
34
34
  "| `llm_query_chunked(text, prompt)` | list[str] | one huge text, auto-chunked | structured data |",
35
- "| `rlm_query(task, paths=None)` | str | **one** multi-step study | ≥2 independent studies |",
36
- "| `rlm_batch(tasks, paths=None)` | list[str] | **≥2 independent studies in parallel** | trivia / one-shots |",
35
+ "| `rlm_query(task|prompt, paths=None)` | str | **one** multi-step study | ≥2 independent studies |",
36
+ "| `rlm_batch(tasks|prompts, paths=None)` | list[str] | **≥2 independent studies in parallel** | trivia / one-shots |",
37
37
  "",
38
- "Collect: `await_task(t)` or `await_task([t1,t2,…])`. `task.done` when settled.",
38
+ "Collect: `await_task(t)` / `await_task([])` / `await_task()` (all running). `list_tasks()` finds lost handles. `task.done` when settled.",
39
+ "If a spawn call raises, the assignment did NOT run — the variable is undefined in later cells. Re-spawn with the corrected signature from the error message.",
39
40
  "The child inherits your `context`; send instructions + optional `paths=['src/auth/']` prefixes — never paste file bodies. **Children are sandboxed — they cannot mutate your `answers`, `plan`, or REPL variables.**",
40
41
  CHUNKED_GLOSSARY_LINE_NATIVE,
41
42
  "- `llm_map_reduce(...)` **blocks** (map then reduce) — prefer Tasks when you can interleave free work.",
42
43
  "- `spawn(fn, *args) -> Task` optional alias for always-spawn tools (not llm_map_reduce).",
43
44
  "",
44
45
  "### Memo / finalize",
45
- "- `answers` / `plan` — persistent dicts. **If a value isn't in `answers`, it doesn't exist.**",
46
+ "- `answers` / `plan` — persistent dicts for **collected** results. Task handles are REPL vars (`t`), not `answers` keys.",
46
47
  "- `add_context(source) -> dict` — append external dir/file/doc/git under `ctx/<id>/…` (metadata only).",
47
- "- `SHOW_VARS()` list REPL vars. `answer[\"ready\"]=True` only for headless finalize (native: write a normal message).",
48
+ "- `memory.query(q)` / `memory.add(text, paths=…, tags=…)` durable notes under `.rlm/` that survive",
49
+ " sessions. Query before re-studying a known area; add concise findings — never secrets or API",
50
+ " keys (notes persist on disk). `list_claims()` — the live `[ledger]` table of agent work.",
51
+ "- `SHOW_VARS()` — list REPL vars (Tasks as `<Task …>`). `list_tasks()` finds Task handles. `answer[\"ready\"]=True` only for headless finalize (native: write a normal message).",
48
52
  "",
49
53
  ENV_TIPS_CONDENSED,
50
54
  "",
@@ -87,8 +91,9 @@ export function buildNativeSystemPrompt(): string {
87
91
  "<contract>",
88
92
  "Inside `repl({code})`, EVERY heavy call returns a Task immediately (not the answer):",
89
93
  " llm_query | llm_batch | map_files | llm_query_chunked | rlm_query | rlm_batch → Task",
90
- "ONLY `await_task(t)` / `await_task([…])` returns content. Fan-out runs detached (↯bg) and outlives the cell.",
91
- "If you printed a Task and did not await_task, you do **not** know the answer yet.",
94
+ "ONLY `await_task(t)` / `await_task([…])` / `await_task()` returns content. Fan-out runs detached (↯bg) and outlives the cell.",
95
+ "If `await_task` returns `Error: sub-call still running`, call it again do not respawn.",
96
+ "If you printed a Task and did not await_task, you do **not** know the answer yet. A Task handle is a REPL variable, not an `answers[]` key.",
92
97
  "Fire independent Tasks first → free `search`/`grep_context`/`outline` → then await_task.",
93
98
  "Do NOT await after every independent spawn (that serializes wall time).",
94
99
  "</contract>",