@hicaru/pi-rlm 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +52 -5
  2. package/README.ru.md +5 -5
  3. package/README.zh-CN.md +5 -5
  4. package/package.json +1 -1
  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 +33 -14
  15. package/src/context/listing.ts +2 -2
  16. package/src/context/refresh.ts +141 -0
  17. package/src/core/engine.ts +16 -18
  18. package/src/core/types.ts +1 -3
  19. package/src/index.ts +95 -38
  20. package/src/mode/native-guards.ts +4 -4
  21. package/src/mode/subagent.ts +68 -0
  22. package/src/prompts/glossary.ts +71 -74
  23. package/src/prompts/native.ts +127 -85
  24. package/src/prompts/system.ts +29 -15
  25. package/src/sandbox/interrupts.ts +258 -68
  26. package/src/sandbox/protocol.ts +53 -30
  27. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  28. package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
  29. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  31. package/src/sandbox/py/guards.py +15 -5
  32. package/src/sandbox/py/hostio.py +57 -0
  33. package/src/sandbox/py/retrieval.py +17 -8
  34. package/src/sandbox/py/tasks.py +1 -1
  35. package/src/sandbox/py/worker.py +109 -83
  36. package/src/sandbox/sandbox-manager.ts +26 -1
  37. package/src/sandbox/sandbox.ts +9 -2
  38. package/src/tool/background-tasks.ts +1 -1
  39. package/src/tool/repl-result.ts +2 -2
  40. package/src/tool/repl-tool.ts +13 -14
  41. package/src/ui/config-panel.ts +1 -1
  42. package/src/ui/intro.ts +1 -4
  43. package/src/ui/model-picker.ts +28 -2
  44. package/src/util/concurrency.ts +1 -1
  45. package/src/bridge/subcall-handlers.ts +0 -382
@@ -29,10 +29,10 @@ export function promptCapTokensK(maxPromptChars: number): number {
29
29
  */
30
30
  export const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
31
31
  "- `search(query: str, k=10, path_glob=None)`: BM25 ranking over `context`. Returns",
32
- " [{path, line, score, snippet}] — POINTERS, not bodies. **Start here.** It is free:",
33
- " no sub-LLM call, no tokens. Use it before you guess at filenames or write regex.",
32
+ " [{path, line, score, snippet, text}] — POINTERS, not bodies (`text` aliases `snippet`).",
33
+ " **Start here.** Free: no sub-LLM call. Use before guessing filenames.",
34
34
  "- `grep_context(pattern, k=50, path_glob=None, before=0, after=0) -> dict`: regex over",
35
- " `context`. Returns {hits: [{path, line, text}], counts: {path: n}, total, truncated} —",
35
+ " `context`. Returns {hits: [{path, line, text, snippet}], counts, total, truncated} —",
36
36
  " `counts` is complete even when `hits` is capped, so a wide pattern reports its shape",
37
37
  " instead of flooding you. Use for exact lexical needles; use `search` for meaning.",
38
38
  "- `outline(path) -> str`: definition/heading skeleton of one file with line numbers.",
@@ -41,37 +41,38 @@ export const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
41
41
 
42
42
  /** One-line delegation helpers — orchestrating must be cheaper than solving. */
43
43
  export const DELEGATION_GLOSSARY_LINES: readonly string[] = Object.freeze([
44
- "- `map_files(files, prompt, model=None) -> dict[path, str]`: ask `prompt` of every file and",
45
- " get back {path: answer}. Accepts context entries or paths, packs them into cap-sized",
46
- " batched sub-calls, and splits oversized files automatically. **This is the default way to",
47
- " read many files** prefer it over hand-rolling a chunk loop.",
48
- "- `llm_map_reduce(items, map_prompt, reduce_prompt, model=None) -> str`: map over items in",
49
- " one batch, then reduce the partial answers with a single call. The paper's canonical",
50
- " strategy (query per chunk → aggregate the buffers) as one call.",
44
+ "- `map_files(files, prompt) -> Task`: always spawn. `await_task(t)` dict[path, answer].",
45
+ " Accepts context entries or paths; packs into cap-sized batches; splits oversized files.",
46
+ " **Default way to read many files** fire independent `map_files` Tasks, free work, then await.",
47
+ "- `llm_map_reduce(items, map_prompt, reduce_prompt) -> str`: **blocks** (map then reduce).",
48
+ " Prefer separate `map_files` / `llm_batch` Tasks when you can do free work between fan-out and collect.",
51
49
  ]);
52
50
 
53
51
  /** Shared glossary entry for the chunked-query helper (headless + native). */
54
52
  export const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
55
- "- `llm_query_chunked(text: str, prompt: str, model=None) -> list[str]`: auto-splits `text` into",
56
- " chunks that fit the sub-LLM prompt cap, fans them out concurrently (order preserved), and",
57
- " returns one answer per chunk. Use it for ANY text too large for a single `llm_query` — a file",
58
- " you open()ed, an oversized sub-result, or several concatenated context files.",
53
+ "- `llm_query_chunked(text: str, prompt: str) -> Task`: always spawn. `await_task(t)` → list[str]",
54
+ " (one answer per chunk, order preserved). Auto-splits text to the sub-LLM prompt cap.",
55
+ " Use for ANY text too large for a single `llm_query` — open()ed files, oversized sub-results.",
59
56
  ]);
60
57
 
61
58
  /** Non-blocking fan-out: spawn now, collect later (headless glossary). */
62
59
  export const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
63
- "- `spawn(fn, *args) -> Task`: start `llm_query`, `llm_query_batched`, `llm_query_chunked`,",
64
- " `map_files`, `rlm_query` or `rlm_query_batched` WITHOUT waiting. Returns immediately.",
65
- " (Not `llm_map_reduce` its reduce step depends on its own map results.)",
66
- "- `rlm_await(task)` / `rlm_await_all(tasks) -> list`: collect results; order matches input.",
67
- " Tasks survive across turns, so spawn the slow work first, keep doing useful things, and",
68
- " await only when you actually need the results. `task.done` tells you if it has landed.",
60
+ "- **ALWAYS SPAWN (Task + ↯bg):** `llm_query` / `llm_batch` / `rlm_query` / `rlm_batch` /",
61
+ " `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.",
63
+ " Do NOT await after every independent spawn (serializes wall time). `task.done` when settled.",
64
+ "- `spawn(fn, *args) -> Task`: same as calling the always-spawn tools (not `llm_map_reduce`).",
65
+ "- Only `llm_map_reduce` still blocks until done.",
69
66
  "",
70
67
  " ```python",
71
- " # start the slow sub-agents, then keep working while they run",
72
- " tasks = [spawn(rlm_query, f\"Audit {area} end to end\") for area in areas]",
73
- " hits = [f for f in context if \"TODO\" in f[\"content\"]] # overlaps with the sub-agents",
74
- " reports = rlm_await_all(tasks)",
68
+ " # Multi-area study: one rlm_batch (parallel workers), free locate, then await",
69
+ " t = rlm_batch([",
70
+ " \"Study module A NO edits. Paths + symbols for X.\",",
71
+ " \"Study module B — NO edits. Report how Y is configured.\",",
72
+ " ])",
73
+ " hits = search(\"X OR Y\", k=10)",
74
+ " reports = await_task(t)",
75
+ " # One-shot extracts: map_files / llm_batch also return Task → await_task",
75
76
  " ```",
76
77
  ]);
77
78
 
@@ -89,7 +90,7 @@ export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
89
90
  " Narrow its world with `rlm_query(prompt, paths=['src/auth/', 'ctx/x-9f3a/'])` — path PREFIXES,",
90
91
  " not globs. Omit `paths` to hand over everything.",
91
92
  " Inheritance is one-way: sources the child loads, and its whole REPL, die with it — only its",
92
- " final answer string returns.",
93
+ " final answer string returns. The child cannot write to your `answers` or `plan`.",
93
94
  " At the depth cap `rlm_query` degrades to a plain sub-LLM call with NO context, which is why",
94
95
  " this section disappears at the last recursive depth.",
95
96
  ]);
@@ -118,15 +119,15 @@ export const LARGE_FILE_RULE_LINES: readonly string[] = Object.freeze([
118
119
  '1. Load in Python: `raw = open("dhat-heap.json").read()` — loading into a variable is fine.',
119
120
  "2. Deterministic processing in Python (`json.load`, `re`, counting, aggregation) is fine and preferred.",
120
121
  "3. The moment you need MEANING from raw text (summarize, explain, find anomalies), do NOT read it",
121
- " yourself — call `llm_query_chunked(raw, question)`, or slice + `llm_query_batched`.",
122
+ " yourself — call `llm_query_chunked(raw, question)` (Task → await_task), or slice + `llm_batch`.",
122
123
  "4. Never print more than a small probe (~2K chars) of raw content.",
123
- 'Example: `parts = llm_query_chunked(raw, "Extract top allocation sites with byte totals")`, then',
124
- "aggregate `parts` in Python or with one final `llm_query`.",
124
+ 'Example: `t = llm_query_chunked(raw, "Extract top allocation sites with byte totals"); parts = await_task(t)`, then',
125
+ "aggregate `parts` in Python or with one final `llm_query` + await_task.",
125
126
  ]);
126
127
 
127
128
  /** Concise native-mode glossary line for the chunked helper (native prompt has a 6K budget). */
128
129
  export const CHUNKED_GLOSSARY_LINE_NATIVE =
129
- "- `llm_query_chunked(text, prompt, model=None) -> list[str]` — auto-splits oversized text into cap-sized chunks, fans out concurrently; one answer per chunk.";
130
+ "- `llm_query_chunked(text, prompt) -> Task` — always spawn; await_task list[str] (one answer per chunk). Auto-splits oversized text.";
130
131
 
131
132
  /** Concise native-mode large-file rule (folds in the context-exclusion note; native 6K budget). */
132
133
  export const LARGE_FILE_RULE_NATIVE =
@@ -149,47 +150,45 @@ export const ENV_TIPS = [
149
150
  "## Decomposition doctrine",
150
151
  "",
151
152
  "**Orchestrate; don't solve.** A single chain of thought over a large repository drifts —",
152
- "you lose partials and compound mistakes. Your sub-LLMs are competent readers: given a",
153
- "self-contained prompt and the text, they will extract, locate, classify, and summarize",
154
- "reliably. Trust them; don't do their reading yourself.",
153
+ "you lose partials and compound mistakes. Sub-workers are competent: trust them; don't read for them.",
155
154
  "",
156
- "Your job: (1) find the relevant slice with `search` / `grep_context` / `outline`,",
157
- "(2) delegate all semantic reading to `map_files` / `llm_query_batched` / `llm_map_reduce`,",
158
- "(3) memoize every result you will reuse in `answers`, (4) sanity-check an answer before",
159
- "another step depends on it, (5) assemble the final answer from `answers` by lookup.",
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`.",
160
159
  "Your own compute is: pointers, dict lookups, string formatting, and decisions.",
161
160
  "",
162
161
  "### The only state that matters",
163
162
  "`answers` and `plan` are dicts that persist across every turn.",
164
- "**If a value isn't in `answers`, it doesn't exist.** Do not trust a number from your own",
165
- "earlier reasoning or from truncated stdout — context drifts. Memoize everything you reuse.",
163
+ "**If a value isn't in `answers`, it doesn't exist.** Do not trust truncated stdout. Memoize.",
166
164
  "",
167
165
  "### Shape of a run",
168
- "1. Probe: `print(len(context))`, `search(<the user's question>)`. Do not print file bodies.",
169
- "2. Plan: write the sub-questions into `plan`; each must be answerable from a named slice.",
170
- "3. Fan out: one `map_files` / `llm_query_batched` per independent group, not one call per",
171
- " file. Store results into `answers` keyed by path or sub-question.",
172
- "4. Assemble: build the answer from `answers`. Delegate the aggregation too if it is large.",
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`.",
173
171
  "",
174
172
  "### Red flags — you are off track",
175
- "- Printing file bodies to read them yourself → stop, delegate to `map_files`.",
176
- "- Writing regex to *infer meaning* (naming conventions, intent, correctness) that is a",
177
- " sub-LLM job. Regex is for exact lexical needles only.",
178
- "- Two turns in with zero sub-LLM calls on an analysis task you are solving it yourself.",
179
- "- About to reuse a value that is not in `answers` → re-derive it and store it.",
180
- "- One sub-call per file over dozens of files batch them; fat prompts in small batches win.",
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 spawnserializes 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.",
181
180
  ].join("\n");
182
181
 
183
182
  /** Native-mode variant of the doctrine — same rules, sized for the native prompt budget. */
184
183
  export const ENV_TIPS_CONDENSED = [
185
- "### Decomposition doctrine (paper App. C.3 — worth +69.5% there)",
186
- "Orchestrate; don't solve. Loop: `search`/`grep_context`/`outline` to find the slice →",
187
- "`map_files` / `llm_query_batched` to read it memoize into `answers` assemble by lookup.",
188
- "`answers` and `plan` persist across every turn: **if a value isn't in `answers`, it",
189
- "doesn't exist** never reuse a number from your own earlier reasoning or truncated stdout.",
190
- "Red flags: printing file bodies to read them; regex used to infer meaning rather than match",
191
- "a literal; two turns into an analysis with zero sub-LLM calls; one sub-call per file instead",
192
- "of one batch. Exception — AUTHORING is not reading: you write every edit body yourself.",
184
+ "### Decomposition doctrine",
185
+ "Orchestrate; don't solve. Free locate fan-out Tasks await_task memoize in `answers`.",
186
+ "Multi-module / multi-step areas: **`rlm_batch` (or rlm_query)** not serial native read.",
187
+ "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.**",
189
+ "Red flags: bulk file dumps; llm_query with path-only (no content no disk!); zero rlm_*/map_files",
190
+ "on multi-area tasks; await after each spawn; Task treated as answer.",
191
+ "AUTHORING: you write every edit body yourself.",
193
192
  ].join("\n");
194
193
 
195
194
  export function howToRunCode(): string {
@@ -224,21 +223,22 @@ export function replGlossary(
224
223
  if (child) lines.push(...CHILD_CONTEXT_LINES);
225
224
  lines.push(
226
225
  "",
227
- " Worked example — find the slice, then delegate it:",
226
+ " Worked example — find the slice, then delegate it (Task + await):",
228
227
  " ```python",
229
228
  ' hits = search("where is the retry/backoff policy configured?", k=8)',
230
229
  " paths = sorted({h['path'] for h in hits})",
231
- ' answers.update(map_files(paths, "Describe any retry/backoff policy in this file, with line numbers. Say NONE if absent."))',
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
232
  " print({p: a[:80] for p, a in answers.items()})",
233
233
  " ```",
234
234
  );
235
235
  }
236
236
  lines.push(...RETRIEVAL_GLOSSARY_LINES);
237
237
  lines.push(
238
- "- `llm_query(prompt: str, model=None) -> str`: a single sub-LLM completion. Use for extraction,",
239
- " summarization, or Q&A over a chunk of text.",
240
- "- `llm_query_batched(prompts: list[str], model=None) -> list[str]`: run several sub-LLM calls",
241
- " concurrently; output order matches input order.",
238
+ "- `llm_query(prompt: str) -> Task`: spawn one sub-LLM (await_task for str). The prompt must",
239
+ " **contain the text** to analyze this call has no filesystem and no `context`.",
240
+ "- `llm_batch(prompts: list[str]) -> Task`: many parallel one-shots (same rule: embed text).",
241
+ " await_task ordered list[str]. NEVER pass bare file paths as if the worker can open them.",
242
242
  ...CHUNKED_GLOSSARY_LINES,
243
243
  ...SPAWN_GLOSSARY_LINES,
244
244
  ...DELEGATION_GLOSSARY_LINES,
@@ -264,18 +264,14 @@ export function replGlossary(
264
264
  }
265
265
  if (recursion) {
266
266
  lines.push(
267
- "- `rlm_query(prompt, model=None)` / `rlm_query_batched(prompts, model=None)`: recursive RLM",
268
- " sub-calls. Each child runs a full REPL loop internally its entire conversation is PRIVATE",
269
- " and never enters your history. Only the final answer (a short string) is returned.",
267
+ "- `rlm_query(task, paths=None) -> Task` / `rlm_batch(tasks, paths=None) -> Task`:",
268
+ " always spawn + ↯bg. await_task for the report string(s). Child REPL is private.",
270
269
  "",
271
- " **Choosing between `llm_query` and `rlm_query`:**",
272
- " - `llm_query` for simple one-shot taskssummarize a chunk, extract a fact, answer a direct",
273
- " question. It is a single LLM call: fast and cheap. Prefer it by default, and fan out with",
274
- " `llm_query_batched` for parallel one-shots.",
275
- " - `rlm_query` only when a sub-task genuinely needs iterative reasoning with its own code",
276
- " execution (e.g. a sub-context large enough to need its own chunking, or a multi-step",
277
- " reasoning chain). It is slower and more expensive — reserve it for cases `llm_query` cannot",
278
- " handle. Avoid excessive recursive sub-calls when a batched one-shot would suffice.",
270
+ " **Routing (api_v5):**",
271
+ " - `llm_query` / `llm_batch` / `map_files`one-shot facts/extracts (fast).",
272
+ " - `rlm_query` one multi-step study (own search/outline loop).",
273
+ " - `rlm_batch` ≥2 independent multi-step studies in **parallel** (prefer over N× rlm_query).",
274
+ " Always Task await_task. Fire independent work first; never serial-await between peers.",
279
275
  ...RECURSION_CONTEXT_LINES,
280
276
  );
281
277
  }
@@ -285,6 +281,7 @@ export function replGlossary(
285
281
  "- `SHOW_VARS() -> str`: list every variable currently in the REPL.",
286
282
  '- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
287
283
  ' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
284
+ ' **You MUST flip `answer["ready"] = True` — runs that never finalize are discarded.**',
288
285
  );
289
286
  return lines.join("\n");
290
287
  }
@@ -1,4 +1,9 @@
1
- /** Native-mode prompts — the main Pi agent drives the sandbox through the `repl` tool. */
1
+ /** Native-mode prompts — the main Pi agent drives the sandbox through the `repl` tool.
2
+ *
3
+ * Structure mirrors rlm_test api_v5_anthropic (best bake-off arm): role → contract → routing →
4
+ * few-shots → anti-patterns → REPL surface. Goal: multi-area work fires rlm_batch / rlm_query
5
+ * as Task (BG), not serial repl+native read.
6
+ */
2
7
 
3
8
  import {
4
9
  CHUNKED_GLOSSARY_LINE_NATIVE,
@@ -11,65 +16,52 @@ import {
11
16
  /** Adapts the REPL glossary for native mode — agent calls `repl({code})` instead of writing ```repl``` blocks. */
12
17
  function nativeReplGlossary(): string {
13
18
  return [
14
- "## RLM Native Mode Persistent Python REPL",
15
- "",
16
- "Call `repl({code: \"...\"})` to execute Python in a **persistent** sandbox. Variables, imports,",
17
- "State persists; only `print()` output is returned, so wrap inspections in `print(...)`.",
18
- "",
19
- "### REPL Environment",
20
- "- `context`: list[dict] — the files you have loaded (starts empty; cwd seeds on first repl()). Each dict: `path` (str), `content` (str), `tokens` (int).",
21
- "",
22
- "Retrieval — free (no sub-LLM call, no tokens). **Start here, before guessing filenames:**",
23
- "- `search(query, k=10, path_glob=None) -> [{path, line, score, snippet}]` BM25 over `context`. Returns pointers, not bodies.",
24
- "- `grep_context(pattern, k=50, path_glob=None, before=0, after=0) -> {hits, counts, total, truncated}` — regex; `counts` stays complete when `hits` is capped. Lexical needles only.",
25
- "- `outline(path) -> str` — definition/heading skeleton with line numbers. Orient in ~200 chars instead of printing 20K.",
26
- "",
27
- "Delegation everything semantic goes through these:",
28
- "- `map_files(files, prompt, model=None) -> {path: answer}` ask `prompt` of many files; batches and splits oversized files for you. **The default way to read many files.**",
29
- "- `llm_map_reduce(items, map_prompt, reduce_prompt, model=None) -> str` map in one batch, then reduce with one call.",
30
- "- `llm_query(prompt, model=None) -> str` one-shot sub-LLM. Use for extraction, summarization, Q&A over a chunk.",
31
- "- `llm_query_batched(prompts, model=None) -> list[str]` concurrent sub-LLM calls; output order matches input order.",
19
+ "## REPL surface (inside `repl({code})` only)",
20
+ "",
21
+ "Persistent Python sandbox. Only `print(...)` returns; variables/`answers`/`plan` persist across repl calls.",
22
+ "",
23
+ "### Free locate (no sub-LLM tokens)",
24
+ "- `search(query, k=10, path_glob=None) -> [{path, line, score, snippet, text}]` — BM25 pointers, not bodies",
25
+ "- `grep_context(pattern, k=50, ) -> {hits, counts, total, truncated}` regex / lexical needles",
26
+ "- `outline(path) -> str` — definition skeleton (~200 chars)",
27
+ "",
28
+ "### Always-spawn fan-out (return Task + run ↯bgNEVER the answer)",
29
+ "| Call | await_task | When | NOT for |",
30
+ "|------|--------------|------|---------|",
31
+ "| `llm_query(prompt)` | str | cheap fact **from text in prompt** | reading files (no disk) |",
32
+ "| `llm_batch(prompts)` | list[str] | many cheap facts in **parallel** | multi-step research |",
33
+ "| `map_files(files, prompt)` | dict[path,str] | same question over many files | multi-step per file |",
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 |",
37
+ "",
38
+ "Collect: `await_task(t)` or `await_task([t1,t2,…])`. `task.done` when settled.",
39
+ "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.**",
32
40
  CHUNKED_GLOSSARY_LINE_NATIVE,
33
- "- `rlm_query(prompt, model=None, paths=None) -> str` recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning. Prefer llm_query — rlm_query is slower and costlier. The child inherits your `context` (loaded files + ctx/ sources) and takes your prompt as its question, so describe the task; never paste file text. `paths=['src/auth/']` narrows its context by prefix.",
34
- "- `rlm_query_batched(prompts, model=None) -> list[str]` concurrent recursive RLM calls.",
35
- "- `spawn(fn, *args) -> Task` / `rlm_await(t)` / `rlm_await_all(ts)` — start `llm_query`, `llm_query_batched`, `llm_query_chunked`, `map_files`, `rlm_query` or `rlm_query_batched` without waiting (NOT `llm_map_reduce`); collect later, order preserved. Tasks outlive the repl() call, so spawn slow work early and await when you need it.",
36
- "",
41
+ "- `llm_map_reduce(...)` **blocks** (map then reduce) prefer Tasks when you can interleave free work.",
42
+ "- `spawn(fn, *args) -> Task` optional alias for always-spawn tools (not llm_map_reduce).",
37
43
  "",
38
- "- `answers` / `plan` — dicts persisted across every repl() call. Your memo.",
39
- "- `add_context(source) -> dict`: append dir/file/document/git URL into `context` under `ctx/<id>/…`. Documents converted to Markdown. Return is metadata only always use `context`.",
40
- "- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
41
- "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
44
+ "### Memo / finalize",
45
+ "- `answers` / `plan` persistent dicts. **If a value isn't in `answers`, it doesn't exist.**",
46
+ "- `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).",
42
48
  "",
43
49
  ENV_TIPS_CONDENSED,
44
50
  "",
45
- "### Worked pattern",
46
- "```python",
47
- 'hits = search("where is retry/backoff configured?", k=8)',
48
- "paths = sorted({h['path'] for h in hits})",
49
- 'answers.update(map_files(paths, "Describe any retry/backoff policy here, with line numbers. Say NONE if absent."))',
50
- "print({p: a[:80] for p, a in answers.items()})",
51
- "```",
52
- `- Sub-prompts cap at ${DEFAULT_PROMPT_CAP.toLocaleString()} chars (≈${promptCapTokensK(DEFAULT_PROMPT_CAP)}K tokens); ~20 prompts per batch. Fat prompts in small batches > thousands of tiny prompts.`,
51
+ `- Sub-prompts cap ${DEFAULT_PROMPT_CAP.toLocaleString()} chars (≈${promptCapTokensK(DEFAULT_PROMPT_CAP)}K tok); ~20 prompts/batch.`,
53
52
  "",
54
- "### Choosing Between Tools",
53
+ "### Outside REPL",
55
54
  "| Tool | When |",
56
55
  "|------|------|",
57
- "| `repl({code})` | ALL repository reading, search, and analysis; Python scripting; state across calls |",
58
- "| `edit` / `write` | Change or create a file. Compose oldText/newText yourself; exact match required |",
59
- "| `search` / `grep_context` / `outline` (in repl) | Locate the relevant slice free, do this first |",
60
- "| `zebra-mcp` | Semantic/embedding search when lexical `search` misses the concept |",
61
- "| `map_files` / `llm_query_batched` (in repl) | Read/extract/classify that slice |",
62
- "| `rlm_query` (in repl) | Sub-task needing its own iterative reasoning and REPL |",
63
- "",
64
- "### Task-Specific Patterns",
65
- LARGE_FILE_RULE_NATIVE,
66
- "- Architecture/code review: `search` for the subsystem, then `map_files` the hits.",
67
- "- Bug investigation: `grep_context` for the literal symbol/message, then `map_files` the matching files.",
68
- "- Finalizing: for file changes call `edit`/`write` directly so Pi validates the anchor and renders the diff; for analysis, write a normal message.",
69
- "- If sub-LLM credits are exhausted, report partial results and stop — do not bypass REPL restrictions.",
56
+ "| `repl({code})` | **All** bulk repo locate + fan-out analysis |",
57
+ "| `edit` / `write` | Apply a change **you** authored after research |",
58
+ "| `zebra-mcp` | Embedding search when lexical `search` misses |",
59
+ "| native `read`/`grep` | Only tiny pin-point files for an **edit anchor** — never bulk study |",
70
60
  "",
71
- "Reserve your own tokens for high-level decisions: what to ask next, how to combine sub-LLM outputs, when to finalize.",
72
- "Delegate everything else. Do not submit a final answer before inspecting `context`.",
61
+ LARGE_FILE_RULE_NATIVE,
62
+ "- Architecture/multi-module: `rlm_batch` per area (or map_files for one-shot extracts).",
63
+ "- Bug hunt: `grep_context` needle → `map_files` / `rlm_query` on hits — not serial native read.",
64
+ "- Credits exhausted → report partials and stop.",
73
65
  ].join("\n");
74
66
  }
75
67
 
@@ -80,48 +72,98 @@ export function buildNativeSystemPrompt(): string {
80
72
  "║ NATIVE RLM MODE — YOU ARE AN ORCHESTRATOR, NOT A READER ║",
81
73
  "╚══════════════════════════════════════════════════════════════════╝",
82
74
  "",
83
- "ENFORCED BY THE RUNTIME (not advisory):",
84
- "- `read`/`grep` are blocked; bash readers (cat/sed/head/tail/awk/rg) are blocked; bash output is hard-capped at 4K chars.",
85
- "- repl() stdout returned to you is hard-capped at 4K chars — printing file bodies is USELESS; the text will not reach you.",
75
+ "You never invent file contents. Multi-step / multi-area repo work goes through the REPL fan-out API.",
76
+ "Large tool/repl outputs are hard-capped (~4K chars).",
77
+ "",
78
+ "AUTHORING RULE: sub-LLMs (`llm_query` / `rlm_query` family) READ only — extract, locate, summarize.",
79
+ "They never author code you will ship. Once you know WHAT to change, compose exact oldText/newText",
80
+ "yourself and apply with `edit` / `write`. Never re-type file bodies or Task results into chat.",
81
+ "",
82
+ "THINKING RULE: complex decomposition (≥3 modules, architecture decisions, uncertainty",
83
+ "about which files hold the answer) → think out loud before the first repl() call.",
84
+ "Simple lookups, known paths, one-shot extracts → jump straight to repl().",
85
+ "Premature repl() calls on uncertain targets waste more tokens than 1–2 sentences of planning.",
86
+ "",
87
+ "<contract>",
88
+ "Inside `repl({code})`, EVERY heavy call returns a Task immediately (not the answer):",
89
+ " 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.",
92
+ "Fire independent Tasks first → free `search`/`grep_context`/`outline` → then await_task.",
93
+ "Do NOT await after every independent spawn (that serializes wall time).",
94
+ "</contract>",
95
+ "",
96
+ "<routing>",
97
+ "Pick the smallest tool that fits:",
98
+ "- One cheap pure-text fact **with the text already in the prompt** → llm_query",
99
+ "- Many similar facts/chunks **with text embedded** → llm_batch(prompts=[…])",
100
+ "- Same question over repo files in `context` → **map_files(files|paths, prompt)** // attaches content",
101
+ "- One multi-step study (own locate loop over context) → rlm_query(task=…, paths=…)",
102
+ "- ≥2 independent multi-step areas/modules → **rlm_batch(tasks=[…])** // PREFER over serial rlm_query",
103
+ "- Dependent edit: rlm_query(research NO edits) → await_task → then YOU write edit/write",
104
+ "CRITICAL: `llm_query` / `llm_batch` have **NO filesystem and NO `context`** — only the string you pass.",
105
+ "NEVER: llm_query(\"Read pi-plugin/…/foo.ts fully…\") — the worker will say it cannot access files.",
106
+ "To study real files: map_files / rlm_query / rlm_batch (they see `context`) OR put the slice in the prompt yourself.",
107
+ "NEVER use llm_batch for \"fix the whole codebase\". NEVER use rlm_batch for trivia.",
108
+ "NEVER bulk-read the repo with native read/grep or by printing file bodies in repl.",
109
+ "</routing>",
110
+ "",
111
+ "<examples>",
112
+ "E1 multi-area study (PREFER rlm_batch — parallel workers):",
113
+ "```python",
114
+ "t = rlm_batch([",
115
+ ' "Study pi-plugin/rlm/src/commands/ — how is RLM toggled ON/OFF? NO edits. Paths + symbols.",',
116
+ ' "Study pi-plugin/rlm/src/mode/rlm-mode.ts + settings — enabled default + persistence. NO edits.",',
117
+ ' "Study pi-plugin/rlm/src/ui/status.ts — status line format. NO edits. Exact string patterns.",',
118
+ "])",
119
+ "hits = search(\"setEnabled OR toggle OR enabled\", k=12) # free while workers run",
120
+ "print([h[\"path\"] for h in hits[:8]])",
121
+ "reports = await_task(t)",
122
+ "answers[\"toggle\"] = reports",
123
+ "print({i: r[:200] for i, r in enumerate(reports)})",
124
+ "```",
86
125
  "",
87
- "LOCATE-THEN-DELEGATE: `search(query)` / `grep_context(pattern)` / `outline(path)` cost nothing",
88
- "— run them FIRST to find the relevant slice. Then, if a step needs MEANING from more than ~4K",
89
- "chars, that reading MUST be a map_files / llm_query / llm_query_batched / llm_query_chunked",
90
- "call (rlm_query for iterative sub-tasks). Deterministic Python over `context` is free and",
91
- "preferred for lookups. Semantic reading is always delegated.",
126
+ "E2 locate then map_files (one-shot extracts, not multi-step):",
127
+ "```python",
128
+ 'hits = search("where is retry/backoff configured?", k=8)',
129
+ "paths = sorted({h['path'] for h in hits})",
130
+ 't = map_files(paths, "Describe any retry/backoff policy; line numbers. NONE if absent.")',
131
+ "answers.update(await_task(t))",
132
+ "print({p: a[:80] for p, a in answers.items()})",
133
+ "```",
92
134
  "",
93
- "Loaded file content lives in the REPL `context` variable (cwd seeds on first call). Use ONLY `repl({code})`.",
94
- "If sub-LLM credits are exhausted → report the error to the user and stop.",
135
+ "E3 parallel llm_batch:",
136
+ "```python",
137
+ 't = llm_batch(["one-line purpose of file A", "one-line purpose of file B"])',
138
+ "print(await_task(t))",
139
+ "```",
95
140
  "",
96
- "AUTHORING RULE: sub-LLMs (`llm_query` family) READ they extract, locate, and summarize.",
97
- "They never author code you will ship. Once you know WHAT to change, compose the exact",
98
- "oldText / newText yourself and apply it with `edit`. Delegated code is written by a small",
99
- "model with no view of the codebase; it is a research aid, never a patch.",
100
- "Never re-type file bodies or `answer[\"content\"]` in your own output.",
141
+ "E4 BAD: serial native read of many files, or await after every independent spawn",
142
+ " Wall-clock time scales linearly. Fire independent Tasks first, then await all.",
143
+ "E5 BAD: treat Task / map_files return as the answer without await_task",
144
+ " You'll read a Task repr, not the data. Silent garbage.",
145
+ "E6 BAD: one giant rlm_query that tries study+edit+verify for the whole tree",
146
+ " → Token limits + no parallelism. Split into rlm_batch tasks per module.",
147
+ "E7 BAD: llm_batch([\"Read path/to/a.ts…\", \"Read path/to/b.ts…\"]) — paths only, no content → useless",
148
+ " → llm_batch has no filesystem: it only sees the text string you paste.",
149
+ "E7 GOOD instead: map_files([\"path/to/a.ts\",\"path/to/b.ts\"], \"…question…\") or rlm_batch multi-step tasks",
150
+ "</examples>",
151
+ "",
152
+ "<rules>",
153
+ "1. LOCATE-THEN-DELEGATE: free search/grep/outline first; semantic reading only via Task tools.",
154
+ "2. Multi-module / multi-question tasks → rlm_batch (or several Tasks) in **one** repl when independent.",
155
+ "3. Self-contained task strings: goal + scope + success + \"NO edits\" for pure study.",
156
+ "4. AUTHORING: sub-LLMs READ only. You compose every edit oldText/newText yourself.",
157
+ "5. Memoize into `answers`. Never reuse facts only from truncated stdout.",
158
+ "6. Cap ~4–6 heavy concurrent rlm workers; prefer one rlm_batch over N serial rlm_query.",
159
+ "</rules>",
101
160
  "",
102
161
  nativeReplGlossary(),
103
162
  ].join("\n");
104
163
  }
105
164
 
106
- /** Soft cap on the static native prompt. Leaves headroom for per-turn context injection
107
- * without bloating the root model's system prompt. Exceeded → phase-guards.ts fails.
108
- * Raised from 6K when the retrieval glossary and the condensed decomposition doctrine
109
- * landed; both buy far more than they cost (paper Table 2, Fig. 4a), then again for
110
- * spawn/rlm_await: the async fan-out API is part of the model-visible contract, and
111
- * ~50 tokens is worth the model actually using it. */
112
- export const NATIVE_PROMPT_BUDGET = 7_700;
165
+ /** Soft cap on the static native prompt. Raised for v5-style contract/routing/examples. */
166
+ export const NATIVE_PROMPT_BUDGET = 9_500;
113
167
 
114
168
  /** Exported for tests — prompt length without context metadata (which is injected separately). */
115
169
  export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
116
-
117
- /** Per-turn last-position reminder for native mode — appended to every context build. */
118
- export const NATIVE_TURN_REMINDER = [
119
- "[RLM orchestrator contract — enforced by the runtime, not optional:",
120
- "repl() stdout to you is hard-capped at 4K chars; read/grep and bash readers are blocked.",
121
- "LOCATE FIRST with search() / grep_context() / outline() — they cost nothing. Any SEMANTIC",
122
- "reading MUST then go through map_files / llm_query / llm_query_batched / llm_query_chunked",
123
- "(rlm_query for iterative sub-tasks). Memoize what you reuse in `answers`; a value not in",
124
- "`answers` does not exist. AUTHORING IS NOT READING: you write every edit body yourself and",
125
- "apply it with the native `edit` / `write` tools — never delegate code you will ship.",
126
- "Keep your own output to decisions, authored edits, and aggregation.]",
127
- ].join("\n");
@@ -31,26 +31,31 @@ export interface SystemPromptOptions {
31
31
  readonly contextLoader?: boolean;
32
32
  /** depth > 0 — this run is an rlm_query child and its `context` is the parent's world. */
33
33
  readonly child?: boolean;
34
+ /** The recursion depth of this run (0 = root, 1 = first child, etc.). */
35
+ readonly depth?: number;
34
36
  }
35
37
 
36
38
  function orchestratorAddendum(maxPromptChars: number): string {
37
39
  return [
38
- "As an RLM you are an **orchestrator, not a solver**. After you probe `context` and understand the",
39
- "task, pause and plan: state how the task decomposes into sub-LLM / REPL steps, then execute one step",
40
- "at a time, printing a small sample of each result to verify before moving on.",
40
+ "As an RLM you are an **orchestrator, not a solver**. Probe `context`, plan decomposition, then",
41
+ "fan out do not solve multi-step module work yourself in a long chain of thought.",
41
42
  "",
42
- "Your own context window is small. Push every long-context operation reading, summarizing,",
43
- "classifying, answering sub-questions into `llm_query` / `llm_query_batched` instead of pulling raw",
44
- "text into your own message stream. Conversely, if a Python keyword/regex search over `context` would",
45
- "already pin the answer, just read it directly. Aggregate the small results back in Python.",
43
+ "<contract> llm_query / llm_batch / map_files / rlm_query / rlm_batch return Task (not the answer).",
44
+ "Only await_task returns content. Fire independent Tasks first, free search, then await_task.",
45
+ "Do not await after every independent spawn.</contract>",
46
46
  "",
47
- `Sub-call budget is finite on two axes: (1) per-prompt capacity each sub-prompt must stay under ${maxPromptChars.toLocaleString()} characters`,
48
- `(hard cap; ≈${promptCapTokensK(maxPromptChars)}K tokens), packing a chunk of many items per call; (2) batch fan-out —`,
49
- "keep batches to roughly ~20 prompts. Fat prompts in small batches beat thousands of tiny prompts.",
50
- "If the workload exceeds both at once, filter aggressively in Python first, then batch the survivors.",
47
+ "<routing> one-shot facts llm_query/llm_batch/map_files; one multi-step study rlm_query;",
48
+ "≥2 independent multi-step areas rlm_batch (prefer over serial rlm_query). NEVER print file",
49
+ "bodies into your own stream when a Task tool can read them.</routing>",
51
50
  "",
52
- "Reserve your own tokens for high-level decisions: what to ask next, how to combine sub-LM outputs,",
53
- "when to finalize. Delegate everything else. Do not submit a final answer before inspecting `context`.",
51
+ "Your own context window is small. Push long-context work into sub-calls. If free search/grep",
52
+ "already pins a tiny fact, use that. Aggregate small results in Python / `answers`.",
53
+ "",
54
+ `Sub-call budget: (1) per-prompt < ${maxPromptChars.toLocaleString()} chars (≈${promptCapTokensK(maxPromptChars)}K tok);`,
55
+ "(2) ~20 prompts per llm_batch. Fat prompts in small batches beat thousands of tiny prompts.",
56
+ "Filter in Python first when both axes overflow.",
57
+ "",
58
+ "Reserve your tokens for planning, combining, and finalizing. Do not finalize before inspecting `context`.",
54
59
  ].join("\n");
55
60
  }
56
61
 
@@ -66,6 +71,15 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
66
71
  const maxPromptChars = opts.maxPromptChars ?? DEFAULT_PROMPT_CAP;
67
72
  const parts = [
68
73
  INTRO,
74
+ ];
75
+ if ((opts.depth ?? 0) > 0) {
76
+ parts.push(
77
+ "",
78
+ `**Recursion depth: ${opts.depth}.** You are a sub-RLM — focus narrowly on your assigned`,
79
+ "task. Delegate (rlm_query/rlm_batch) only if the task itself must decompose further.",
80
+ );
81
+ }
82
+ parts.push(
69
83
  "",
70
84
  howToRunCode(),
71
85
  "",
@@ -78,7 +92,7 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
78
92
  "dump a whole sub-LLM result. The full content persists across turns in REPL variables (call `SHOW_VARS()`).",
79
93
  "",
80
94
  "Start by probing `context` (print a few lines, count items). Then build up an answer to the query.",
81
- ];
95
+ );
82
96
  if (opts.orchestrator ?? true) {
83
97
  // Two counterweights, both required (paper App. B): the addendum bounds OVER-recursion
84
98
  // (batching/cost), ENV_TIPS bounds UNDER-recursion (solving it yourself).
@@ -102,5 +116,5 @@ export function buildMetadataLine(meta: PromptMeta, maxPromptChars = DEFAULT_PRO
102
116
  ? ` Your context has ${meta.contextStats.files} files; per-file tokens run min ${meta.contextStats.min.toLocaleString()} / median ${meta.contextStats.median.toLocaleString()} / max ${meta.contextStats.max.toLocaleString()} — use this to gauge how many files fit per batch.`
103
117
  : "";
104
118
  const body = `${contextDesc} ${tail}${dist}`;
105
- return meta.rootPrompt ? `Answer the following: ${meta.rootPrompt}\n\n${body}` : body;
119
+ return meta.rootPrompt ? `<task>${meta.rootPrompt}</task>\n\n${body}` : body;
106
120
  }