@hicaru/pi-rlm 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -19
- package/package.json +2 -1
- package/src/bridge/library.ts +93 -15
- package/src/bridge/llm-query.ts +60 -36
- package/src/bridge/rlm-query.ts +63 -79
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- package/src/config/settings.ts +33 -3
- package/src/context/library-context.ts +209 -22
- package/src/context/repomix-context.ts +7 -58
- package/src/core/answer.ts +5 -13
- package/src/core/artifacts.ts +4 -3
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +94 -299
- package/src/core/gates.ts +33 -4
- package/src/core/limits.ts +19 -1
- package/src/core/pipeline-handlers.ts +319 -0
- package/src/core/pipeline.ts +40 -15
- package/src/core/types.ts +26 -30
- package/src/index.ts +36 -26
- package/src/mode/native-guards.ts +2 -2
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/phases.ts +18 -39
- package/src/prompts/system.ts +167 -64
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +5 -17
- package/src/sandbox/sandbox-manager.ts +5 -5
- package/src/sandbox/sandbox.ts +67 -27
- package/src/sandbox/worker.py +534 -48
- package/src/state/paths.ts +1 -1
- package/src/state/reads.ts +12 -4
- package/src/state/resume.ts +26 -25
- package/src/state/rows.ts +2 -2
- package/src/text/parsing.ts +0 -6
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +132 -337
- package/src/tool/rlm-aggregator.ts +7 -7
- package/src/tool/rlm-details.ts +6 -13
- package/src/tool/rlm-events.ts +14 -11
- package/src/tool/rlm-tool.ts +20 -38
- package/src/tool/subcall-render.ts +61 -9
- package/src/tool/subcall-store.ts +4 -2
- package/src/ui/config-panel.ts +43 -23
- package/src/ui/intro.ts +2 -1
- package/src/ui/status.ts +8 -5
- package/src/ui/theme-adapter.ts +36 -0
- package/src/ui/theme.ts +0 -25
- package/src/mode/input-router.ts +0 -23
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -164
- package/src/tool/apply-edits-tool.ts +0 -295
package/src/prompts/system.ts
CHANGED
|
@@ -36,6 +36,36 @@ function promptCapTokensK(maxPromptChars: number): number {
|
|
|
36
36
|
return Math.round(maxPromptChars / 4_000);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Deterministic retrieval over `context` (headless + native).
|
|
41
|
+
*
|
|
42
|
+
* The paper's trajectories retrieve with hand-written regex (App. E.1); frontier models do that
|
|
43
|
+
* well, small ones guess keywords badly, and the first decomposition disproportionately decides
|
|
44
|
+
* the outcome (§5, Fig. 4a). These cost no tokens and no sub-calls.
|
|
45
|
+
*/
|
|
46
|
+
const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
|
|
47
|
+
"- `search(query: str, k=10, path_glob=None)`: BM25 ranking over `context`. Returns",
|
|
48
|
+
" [{path, line, score, snippet}] — POINTERS, not bodies. **Start here.** It is free:",
|
|
49
|
+
" no sub-LLM call, no tokens. Use it before you guess at filenames or write regex.",
|
|
50
|
+
"- `grep_context(pattern, k=50, path_glob=None, before=0, after=0) -> dict`: regex over",
|
|
51
|
+
" `context`. Returns {hits: [{path, line, text}], counts: {path: n}, total, truncated} —",
|
|
52
|
+
" `counts` is complete even when `hits` is capped, so a wide pattern reports its shape",
|
|
53
|
+
" instead of flooding you. Use for exact lexical needles; use `search` for meaning.",
|
|
54
|
+
"- `outline(path) -> str`: definition/heading skeleton of one file with line numbers.",
|
|
55
|
+
" Orient in ~200 chars instead of printing 20K. Matches exact path, then suffix, then glob.",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** One-line delegation helpers — orchestrating must be cheaper than solving. */
|
|
59
|
+
const DELEGATION_GLOSSARY_LINES: readonly string[] = Object.freeze([
|
|
60
|
+
"- `map_files(files, prompt, model=None) -> dict[path, str]`: ask `prompt` of every file and",
|
|
61
|
+
" get back {path: answer}. Accepts context entries or paths, packs them into cap-sized",
|
|
62
|
+
" batched sub-calls, and splits oversized files automatically. **This is the default way to",
|
|
63
|
+
" read many files** — prefer it over hand-rolling a chunk loop.",
|
|
64
|
+
"- `llm_map_reduce(items, map_prompt, reduce_prompt, model=None) -> str`: map over items in",
|
|
65
|
+
" one batch, then reduce the partial answers with a single call. The paper's canonical",
|
|
66
|
+
" strategy (query per chunk → aggregate the buffers) as one call.",
|
|
67
|
+
]);
|
|
68
|
+
|
|
39
69
|
/** Shared glossary entry for the chunked-query helper (headless + native). */
|
|
40
70
|
const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
|
|
41
71
|
"- `llm_query_chunked(text: str, prompt: str, model=None) -> list[str]`: auto-splits `text` into",
|
|
@@ -69,6 +99,66 @@ const CHUNKED_GLOSSARY_LINE_NATIVE =
|
|
|
69
99
|
const LARGE_FILE_RULE_NATIVE =
|
|
70
100
|
"- Files >1MB or gitignored are NOT in `context`: open() + parse deterministically in Python is fine; ANY semantic reading of the raw text goes through llm_query_chunked. Never print >2K chars raw.";
|
|
71
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The decomposition doctrine, ported from the RLM paper's Appendix C.3 `<env_tips>` and
|
|
104
|
+
* retargeted from competition math to repository analysis.
|
|
105
|
+
*
|
|
106
|
+
* This block is the single highest-leverage prompt intervention the paper reports: +69.5% on
|
|
107
|
+
* LongCoT-mini over the same RLM without it (Table 2). Plain RLM prompting alone actually
|
|
108
|
+
* *regressed* two of the five categories; the doctrine is what fixed them. Its purpose is to
|
|
109
|
+
* counter under-delegation — the model doing the work itself in the REPL instead of fanning out.
|
|
110
|
+
*
|
|
111
|
+
* Note the counterweight: `orchestratorAddendum` carries the anti-OVER-recursion batching rule.
|
|
112
|
+
* The paper is explicit (App. B) that one prompt does not port across models and that both
|
|
113
|
+
* guardrails are needed; keep them both.
|
|
114
|
+
*/
|
|
115
|
+
const ENV_TIPS = [
|
|
116
|
+
"## Decomposition doctrine",
|
|
117
|
+
"",
|
|
118
|
+
"**Orchestrate; don't solve.** A single chain of thought over a large repository drifts —",
|
|
119
|
+
"you lose partials and compound mistakes. Your sub-LLMs are competent readers: given a",
|
|
120
|
+
"self-contained prompt and the text, they will extract, locate, classify, and summarize",
|
|
121
|
+
"reliably. Trust them; don't do their reading yourself.",
|
|
122
|
+
"",
|
|
123
|
+
"Your job: (1) find the relevant slice with `search` / `grep_context` / `outline`,",
|
|
124
|
+
"(2) delegate all semantic reading to `map_files` / `llm_query_batched` / `llm_map_reduce`,",
|
|
125
|
+
"(3) memoize every result you will reuse in `answers`, (4) sanity-check an answer before",
|
|
126
|
+
"another step depends on it, (5) assemble the final answer from `answers` by lookup.",
|
|
127
|
+
"Your own compute is: pointers, dict lookups, string formatting, and decisions.",
|
|
128
|
+
"",
|
|
129
|
+
"### The only state that matters",
|
|
130
|
+
"`answers` and `plan` are dicts that persist across every turn and survive snapshots.",
|
|
131
|
+
"**If a value isn't in `answers`, it doesn't exist.** Do not trust a number from your own",
|
|
132
|
+
"earlier reasoning or from truncated stdout — context drifts. Memoize everything you reuse.",
|
|
133
|
+
"",
|
|
134
|
+
"### Shape of a run",
|
|
135
|
+
"1. Probe: `print(len(context))`, `search(<the user's question>)`. Do not print file bodies.",
|
|
136
|
+
"2. Plan: write the sub-questions into `plan`; each must be answerable from a named slice.",
|
|
137
|
+
"3. Fan out: one `map_files` / `llm_query_batched` per independent group, not one call per",
|
|
138
|
+
" file. Store results into `answers` keyed by path or sub-question.",
|
|
139
|
+
"4. Assemble: build the answer from `answers`. Delegate the aggregation too if it is large.",
|
|
140
|
+
"",
|
|
141
|
+
"### Red flags — you are off track",
|
|
142
|
+
"- Printing file bodies to read them yourself → stop, delegate to `map_files`.",
|
|
143
|
+
"- Writing regex to *infer meaning* (naming conventions, intent, correctness) → that is a",
|
|
144
|
+
" sub-LLM job. Regex is for exact lexical needles only.",
|
|
145
|
+
"- Two turns in with zero sub-LLM calls on an analysis task → you are solving it yourself.",
|
|
146
|
+
"- About to reuse a value that is not in `answers` → re-derive it and store it.",
|
|
147
|
+
"- One sub-call per file over dozens of files → batch them; fat prompts in small batches win.",
|
|
148
|
+
].join("\n");
|
|
149
|
+
|
|
150
|
+
/** Native-mode variant of the doctrine — same rules, sized for the native prompt budget. */
|
|
151
|
+
const ENV_TIPS_CONDENSED = [
|
|
152
|
+
"### Decomposition doctrine (paper App. C.3 — worth +69.5% there)",
|
|
153
|
+
"Orchestrate; don't solve. Loop: `search`/`grep_context`/`outline` to find the slice →",
|
|
154
|
+
"`map_files` / `llm_query_batched` to read it → memoize into `answers` → assemble by lookup.",
|
|
155
|
+
"`answers` and `plan` persist across turns and snapshots: **if a value isn't in `answers`, it",
|
|
156
|
+
"doesn't exist** — never reuse a number from your own earlier reasoning or truncated stdout.",
|
|
157
|
+
"Red flags: printing file bodies to read them; regex used to infer meaning rather than match",
|
|
158
|
+
"a literal; two turns into an analysis with zero sub-LLM calls; one sub-call per file instead",
|
|
159
|
+
"of one batch. Exception — AUTHORING is not reading: you write every edit body yourself.",
|
|
160
|
+
].join("\n");
|
|
161
|
+
|
|
72
162
|
function howToRunCode(): string {
|
|
73
163
|
return [
|
|
74
164
|
"To run Python, write a fenced ```repl``` block. The REPL **persists** across turns. Only",
|
|
@@ -100,24 +190,23 @@ function replGlossary(
|
|
|
100
190
|
" bodies into your own output.",
|
|
101
191
|
CONTEXT_EXCLUSION_NOTE,
|
|
102
192
|
"",
|
|
103
|
-
"
|
|
193
|
+
" Worked example — find the slice, then delegate it:",
|
|
104
194
|
" ```python",
|
|
105
|
-
|
|
106
|
-
" for
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
" f\"Analyze {f['path']} ({f['tokens']} tok):\\n{f['content']}\"",
|
|
110
|
-
" for f in batch",
|
|
111
|
-
" ])",
|
|
195
|
+
' hits = search("where is the retry/backoff policy configured?", k=8)',
|
|
196
|
+
" paths = sorted({h['path'] for h in hits})",
|
|
197
|
+
' answers.update(map_files(paths, "Describe any retry/backoff policy in this file, with line numbers. Say NONE if absent."))',
|
|
198
|
+
" print({p: a[:80] for p, a in answers.items()})",
|
|
112
199
|
" ```",
|
|
113
200
|
);
|
|
114
201
|
}
|
|
202
|
+
lines.push(...RETRIEVAL_GLOSSARY_LINES);
|
|
115
203
|
lines.push(
|
|
116
204
|
"- `llm_query(prompt: str, model=None) -> str`: a single sub-LLM completion. Use for extraction,",
|
|
117
205
|
" summarization, or Q&A over a chunk of text.",
|
|
118
206
|
"- `llm_query_batched(prompts: list[str], model=None) -> list[str]`: run several sub-LLM calls",
|
|
119
207
|
" concurrently; output order matches input order.",
|
|
120
208
|
...CHUNKED_GLOSSARY_LINES,
|
|
209
|
+
...DELEGATION_GLOSSARY_LINES,
|
|
121
210
|
);
|
|
122
211
|
if (askUserQuestion) {
|
|
123
212
|
lines.push(
|
|
@@ -141,13 +230,21 @@ function replGlossary(
|
|
|
141
230
|
}
|
|
142
231
|
if (libraryLoader) {
|
|
143
232
|
lines.push(
|
|
144
|
-
"- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
" (shallow-cloned, then packed).
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
233
|
+
"- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document and",
|
|
234
|
+
" **APPEND its files into the existing `context` list** (same shape: path/content/tokens).",
|
|
235
|
+
" `source` may be a local directory (repomix-packed), a single file path, or an https/git@ URL",
|
|
236
|
+
" (shallow-cloned, then packed). Paths are namespaced under `lib/<source_id>/…` so you can filter",
|
|
237
|
+
" by prefix. Returns metadata only:",
|
|
238
|
+
" {\"source\", \"source_id\", \"path_prefix\", \"files\", \"chars\", \"context_len\", \"already_loaded\"}",
|
|
239
|
+
" or an \"Error: ...\" string. **Never treat the return value as the file list** — always search",
|
|
240
|
+
" and chunk the single variable `context`. Do not invent `context_1` / aliases; do not call",
|
|
241
|
+
" globals()/locals(). Idempotent: re-loading the same source is a no-op.",
|
|
242
|
+
"",
|
|
243
|
+
" ```python",
|
|
244
|
+
" info = load_library(\"/path/to/other-project\")",
|
|
245
|
+
" # info is metadata; files are already in context under info[\"path_prefix\"]",
|
|
246
|
+
" lib_files = [f for f in context if f[\"path\"].startswith(info[\"path_prefix\"])]",
|
|
247
|
+
" ```",
|
|
151
248
|
);
|
|
152
249
|
}
|
|
153
250
|
if (recursion) {
|
|
@@ -172,8 +269,8 @@ function replGlossary(
|
|
|
172
269
|
" Kinds: `'clarification'` | `'research'` | `'plan'` | `'validation'`. Must match the current phase.",
|
|
173
270
|
" Frontmatter must eventually include `status: ready` before `advance_phase` will accept the transition.",
|
|
174
271
|
"- `advance_phase(phase: str, summary=None) -> str`: transition to the next pipeline phase.",
|
|
175
|
-
" Order: 'clarify' → 'research' → 'blueprint' → '
|
|
176
|
-
" clarify is skipped when ask_user_question is disabled).",
|
|
272
|
+
" Order: 'clarify' → 'research' → 'blueprint' → 'validate' (one step at a time;",
|
|
273
|
+
" clarify is skipped when ask_user_question is disabled). The pipeline is READ-ONLY.",
|
|
177
274
|
" **advance_phase is validated by the engine** — it measures the latest saved artifact",
|
|
178
275
|
" (status, structure, citations, blockers_count; clarify also requires ≥1 ask_user_question round).",
|
|
179
276
|
" A rejected transition returns the gate error for you to fix; the phase does NOT advance.",
|
|
@@ -181,6 +278,8 @@ function replGlossary(
|
|
|
181
278
|
);
|
|
182
279
|
}
|
|
183
280
|
lines.push(
|
|
281
|
+
"- `answers` / `plan`: two dicts that persist across turns and snapshots. Memoize every",
|
|
282
|
+
" verified result in `answers` — see the decomposition doctrine below.",
|
|
184
283
|
"- `SHOW_VARS() -> str`: list every variable currently in the REPL.",
|
|
185
284
|
'- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
|
|
186
285
|
' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
|
|
@@ -236,7 +335,9 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
|
|
|
236
335
|
"Start by probing `context` (print a few lines, count items). Then build up an answer to the query.",
|
|
237
336
|
];
|
|
238
337
|
if (opts.orchestrator ?? true) {
|
|
239
|
-
|
|
338
|
+
// Two counterweights, both required (paper App. B): the addendum bounds OVER-recursion
|
|
339
|
+
// (batching/cost), ENV_TIPS bounds UNDER-recursion (solving it yourself).
|
|
340
|
+
parts.push("", orchestratorAddendum(maxPromptChars), "", ENV_TIPS);
|
|
240
341
|
}
|
|
241
342
|
if (kind === "files") {
|
|
242
343
|
parts.push("", LARGE_FILE_RULE_LINES.join("\n"));
|
|
@@ -255,61 +356,55 @@ function nativeReplGlossary(): string {
|
|
|
255
356
|
"",
|
|
256
357
|
"### REPL Environment",
|
|
257
358
|
"- `context`: list[dict] — every file in the repository. Each dict: `path` (str), `content` (str), `tokens` (int).",
|
|
359
|
+
"",
|
|
360
|
+
"Retrieval — free (no sub-LLM call, no tokens). **Start here, before guessing filenames:**",
|
|
361
|
+
"- `search(query, k=10, path_glob=None) -> [{path, line, score, snippet}]` — BM25 over `context`. Returns pointers, not bodies.",
|
|
362
|
+
"- `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.",
|
|
363
|
+
"- `outline(path) -> str` — definition/heading skeleton with line numbers. Orient in ~200 chars instead of printing 20K.",
|
|
364
|
+
"",
|
|
365
|
+
"Delegation — everything semantic goes through these:",
|
|
366
|
+
"- `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.**",
|
|
367
|
+
"- `llm_map_reduce(items, map_prompt, reduce_prompt, model=None) -> str` — map in one batch, then reduce with one call.",
|
|
258
368
|
"- `llm_query(prompt, model=None) -> str` — one-shot sub-LLM. Use for extraction, summarization, Q&A over a chunk.",
|
|
259
369
|
"- `llm_query_batched(prompts, model=None) -> list[str]` — concurrent sub-LLM calls; output order matches input order.",
|
|
260
370
|
CHUNKED_GLOSSARY_LINE_NATIVE,
|
|
261
371
|
"- `rlm_query(prompt, model=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning. Prefer llm_query — rlm_query is slower and costlier.",
|
|
262
372
|
"- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
|
|
263
373
|
"",
|
|
374
|
+
"",
|
|
375
|
+
"- `answers` / `plan` — dicts persisted across every repl() call and snapshot. Your memo.",
|
|
264
376
|
"- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
|
|
265
|
-
"- `load_library(source)`
|
|
377
|
+
"- `load_library(source) -> dict`: append external dir/file/git tree into `context` under `lib/<id>/…`. Return is metadata only — always use `context`.",
|
|
266
378
|
"- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
|
|
267
|
-
"- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
|
|
268
379
|
"- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
|
|
269
380
|
"",
|
|
270
|
-
|
|
271
|
-
"You are an **orchestrator, not a solver**. After probing `context`, decompose the task into sub-LLM / REPL steps,",
|
|
272
|
-
"then execute one step at a time, printing samples of each result to verify before moving on.",
|
|
381
|
+
ENV_TIPS_CONDENSED,
|
|
273
382
|
"",
|
|
274
|
-
"
|
|
275
|
-
"`llm_query` / `llm_query_batched` — never dump raw file bodies into your own output. Aggregate small",
|
|
276
|
-
"results back in Python. Use Python string operations (`in`, `re.search`) over `context` for quick lookups.",
|
|
277
|
-
"",
|
|
278
|
-
"### Chunking Strategy",
|
|
383
|
+
"### Worked pattern",
|
|
279
384
|
"```python",
|
|
280
|
-
|
|
281
|
-
"for
|
|
282
|
-
"
|
|
283
|
-
"
|
|
284
|
-
" f\"Analyze {f['path']} ({f['tokens']} tok):\\n{f['content']}\"",
|
|
285
|
-
" for f in batch",
|
|
286
|
-
" ])",
|
|
287
|
-
" # aggregate results into a buffer",
|
|
385
|
+
'hits = search("where is retry/backoff configured?", k=8)',
|
|
386
|
+
"paths = sorted({h['path'] for h in hits})",
|
|
387
|
+
'answers.update(map_files(paths, "Describe any retry/backoff policy here, with line numbers. Say NONE if absent."))',
|
|
388
|
+
"print({p: a[:80] for p, a in answers.items()})",
|
|
288
389
|
"```",
|
|
289
|
-
`-
|
|
390
|
+
`- 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.`,
|
|
290
391
|
"",
|
|
291
392
|
"### Choosing Between Tools",
|
|
292
393
|
"| Tool | When |",
|
|
293
394
|
"|------|------|",
|
|
294
|
-
"| `repl({code})` |
|
|
295
|
-
"| `
|
|
296
|
-
"| `
|
|
297
|
-
"| `
|
|
298
|
-
"| `
|
|
299
|
-
"| `rlm_query` (
|
|
300
|
-
"| `todo` (
|
|
301
|
-
"| `stage_edit(path, old, new)` (inside repl) | Stage exact edit params; apply returned IDs with `apply_edits` |",
|
|
302
|
-
"",
|
|
303
|
-
"### Workflow",
|
|
304
|
-
"1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
|
|
305
|
-
"2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
|
|
306
|
-
"3. **Aggregate**: Collect results in Python, pass aggregated results to a final `llm_query` or produce the answer directly.",
|
|
307
|
-
"4. **Finalize**: For file changes, stage them inside repl() via `stage_edit(path, old, new)`, then apply the returned IDs with `apply_edits({ ids })`. Do not use the native `edit` tool directly for native RLM file changes unless explicitly asked. For analysis tasks, write a normal message.",
|
|
395
|
+
"| `repl({code})` | ALL repository reading, search, and analysis; Python scripting; state across calls |",
|
|
396
|
+
"| `edit` / `write` | Change or create a file. Compose oldText/newText yourself; exact match required |",
|
|
397
|
+
"| `search` / `grep_context` / `outline` (in repl) | Locate the relevant slice — free, do this first |",
|
|
398
|
+
"| `zebra-mcp` | Semantic/embedding search when lexical `search` misses the concept |",
|
|
399
|
+
"| `map_files` / `llm_query_batched` (in repl) | Read/extract/classify that slice |",
|
|
400
|
+
"| `rlm_query` (in repl) | Sub-task needing its own iterative reasoning and REPL |",
|
|
401
|
+
"| `todo` (in repl) | Track multi-step progress visibly to the user |",
|
|
308
402
|
"",
|
|
309
403
|
"### Task-Specific Patterns",
|
|
310
404
|
LARGE_FILE_RULE_NATIVE,
|
|
311
|
-
"- Architecture/code review:
|
|
312
|
-
"- Bug investigation:
|
|
405
|
+
"- Architecture/code review: `search` for the subsystem, then `map_files` the hits.",
|
|
406
|
+
"- Bug investigation: `grep_context` for the literal symbol/message, then `map_files` the matching files.",
|
|
407
|
+
"- Finalizing: for file changes call `edit`/`write` directly so Pi validates the anchor and renders the diff; for analysis, write a normal message.",
|
|
313
408
|
"- If sub-LLM credits are exhausted, report partial results and stop — do not bypass REPL restrictions.",
|
|
314
409
|
"",
|
|
315
410
|
"Reserve your own tokens for high-level decisions: what to ask next, how to combine sub-LLM outputs, when to finalize.",
|
|
@@ -328,25 +423,30 @@ export function buildNativeSystemPrompt(): string {
|
|
|
328
423
|
"- `read`/`grep` are blocked; bash readers (cat/sed/head/tail/awk/rg) are blocked; bash output is hard-capped at 4K chars.",
|
|
329
424
|
"- repl() stdout returned to you is hard-capped at 4K chars — printing file bodies is USELESS; the text will not reach you.",
|
|
330
425
|
"",
|
|
331
|
-
"
|
|
332
|
-
"
|
|
333
|
-
"
|
|
426
|
+
"LOCATE-THEN-DELEGATE: `search(query)` / `grep_context(pattern)` / `outline(path)` cost nothing",
|
|
427
|
+
"— run them FIRST to find the relevant slice. Then, if a step needs MEANING from more than ~4K",
|
|
428
|
+
"chars, that reading MUST be a map_files / llm_query / llm_query_batched / llm_query_chunked",
|
|
429
|
+
"call (rlm_query for iterative sub-tasks). Deterministic Python over `context` is free and",
|
|
334
430
|
"preferred for lookups. Semantic reading is always delegated.",
|
|
335
431
|
"",
|
|
336
432
|
"All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
|
|
337
433
|
"If sub-LLM credits are exhausted → report the error to the user and stop.",
|
|
338
434
|
"",
|
|
339
|
-
"
|
|
340
|
-
"
|
|
341
|
-
"
|
|
435
|
+
"AUTHORING RULE: sub-LLMs (`llm_query` family) READ — they extract, locate, and summarize.",
|
|
436
|
+
"They never author code you will ship. Once you know WHAT to change, compose the exact",
|
|
437
|
+
"oldText / newText yourself and apply it with `edit`. Delegated code is written by a small",
|
|
438
|
+
"model with no view of the codebase; it is a research aid, never a patch.",
|
|
439
|
+
"Never re-type file bodies or `answer[\"content\"]` in your own output.",
|
|
342
440
|
"",
|
|
343
441
|
nativeReplGlossary(),
|
|
344
442
|
].join("\n");
|
|
345
443
|
}
|
|
346
444
|
|
|
347
445
|
/** Soft cap on the static native prompt. Leaves headroom for per-turn context injection
|
|
348
|
-
* without bloating the root model's system prompt. Exceeded → phase-guards.ts fails.
|
|
349
|
-
|
|
446
|
+
* without bloating the root model's system prompt. Exceeded → phase-guards.ts fails.
|
|
447
|
+
* Raised from 6K when the retrieval glossary and the condensed decomposition doctrine
|
|
448
|
+
* landed; both buy far more than they cost (paper Table 2, Fig. 4a). */
|
|
449
|
+
export const NATIVE_PROMPT_BUDGET = 7_500;
|
|
350
450
|
|
|
351
451
|
/** Exported for tests — prompt length without context metadata (which is injected separately). */
|
|
352
452
|
export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
|
|
@@ -355,9 +455,12 @@ export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
|
|
|
355
455
|
export const NATIVE_TURN_REMINDER = [
|
|
356
456
|
"[RLM orchestrator contract — enforced by the runtime, not optional:",
|
|
357
457
|
"repl() stdout to you is hard-capped at 4K chars; read/grep and bash readers are blocked.",
|
|
358
|
-
"
|
|
359
|
-
"
|
|
360
|
-
"
|
|
458
|
+
"LOCATE FIRST with search() / grep_context() / outline() — they cost nothing. Any SEMANTIC",
|
|
459
|
+
"reading MUST then go through map_files / llm_query / llm_query_batched / llm_query_chunked",
|
|
460
|
+
"(rlm_query for iterative sub-tasks). Memoize what you reuse in `answers`; a value not in",
|
|
461
|
+
"`answers` does not exist. AUTHORING IS NOT READING: you write every edit body yourself and",
|
|
462
|
+
"apply it with the native `edit` / `write` tools — never delegate code you will ship.",
|
|
463
|
+
"Keep your own output to decisions, authored edits, and aggregation.]",
|
|
361
464
|
].join("\n");
|
|
362
465
|
|
|
363
466
|
/** The one-line context metadata, also reused by the per-turn prompt in headless mode. */
|
package/src/prompts/user.ts
CHANGED
|
@@ -7,12 +7,8 @@ export function buildTurnPrompt(
|
|
|
7
7
|
iteration: number,
|
|
8
8
|
maxIterations: number,
|
|
9
9
|
gateMessage?: string,
|
|
10
|
-
phaseGuidanceText?: string,
|
|
11
10
|
): string {
|
|
12
|
-
const
|
|
13
|
-
if (phaseGuidanceText) parts.push(phaseGuidanceText);
|
|
14
|
-
if (gateMessage) parts.push(gateMessage);
|
|
15
|
-
const prefix = parts.length > 0 ? `${parts.join("\n\n")}\n\n` : "";
|
|
11
|
+
const prefix = gateMessage ? `${gateMessage}\n\n` : "";
|
|
16
12
|
const body = `Turn ${iteration + 1}/${maxIterations}:`;
|
|
17
13
|
if (iteration === 0) {
|
|
18
14
|
return (
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -21,24 +21,21 @@ export interface LlmReply {
|
|
|
21
21
|
readonly response?: string;
|
|
22
22
|
readonly responses?: readonly string[];
|
|
23
23
|
readonly answers?: readonly AskAnswer[];
|
|
24
|
-
/** load_library reply: temp file with the packed payload +
|
|
24
|
+
/** load_library reply: temp file with the packed payload (+ resume index / namespace). */
|
|
25
25
|
readonly path?: string;
|
|
26
26
|
readonly json?: boolean;
|
|
27
27
|
readonly index?: number;
|
|
28
28
|
readonly files?: number;
|
|
29
29
|
readonly chars?: number;
|
|
30
|
+
readonly source_id?: string;
|
|
31
|
+
readonly path_prefix?: string;
|
|
32
|
+
/** Host-side idempotency: library already loaded — no path payload. */
|
|
33
|
+
readonly already_loaded?: boolean;
|
|
30
34
|
readonly error?: string;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export type ParentMessage = WorkerRequest | LlmReply;
|
|
34
38
|
|
|
35
|
-
export interface ProposedEdit {
|
|
36
|
-
readonly id: string;
|
|
37
|
-
readonly path: string;
|
|
38
|
-
readonly oldText: string;
|
|
39
|
-
readonly newText: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
39
|
/** A normal response to a request (keyed by the request `id`). */
|
|
43
40
|
export interface WorkerResponse {
|
|
44
41
|
readonly id: string;
|
|
@@ -49,16 +46,12 @@ export interface WorkerResponse {
|
|
|
49
46
|
readonly stderr?: string;
|
|
50
47
|
readonly final_answer?: string | null;
|
|
51
48
|
readonly answer_content?: string;
|
|
52
|
-
readonly edits?: readonly ProposedEdit[];
|
|
53
49
|
readonly raised?: boolean;
|
|
54
50
|
readonly execution_time?: number;
|
|
55
51
|
// user-created variable names after this exec (filters builtins/context) — Metadata(stdout) for history orientation
|
|
56
52
|
readonly var_names?: readonly string[];
|
|
57
53
|
// load_context:
|
|
58
54
|
readonly index?: number;
|
|
59
|
-
// snapshot/restore:
|
|
60
|
-
readonly skipped?: readonly string[];
|
|
61
|
-
readonly restored?: readonly string[];
|
|
62
55
|
}
|
|
63
56
|
|
|
64
57
|
/** Kinds of sub-LLM interrupt the worker can raise mid-exec. */
|
|
@@ -92,10 +85,6 @@ export interface AskAnswer {
|
|
|
92
85
|
readonly custom?: string;
|
|
93
86
|
}
|
|
94
87
|
|
|
95
|
-
export interface AskUserQuestionReply {
|
|
96
|
-
readonly answers: readonly AskAnswer[];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
88
|
interface InterruptBase {
|
|
100
89
|
readonly rid: string;
|
|
101
90
|
readonly depth: number;
|
|
@@ -201,7 +190,6 @@ export interface ReplResult {
|
|
|
201
190
|
readonly stderr: string;
|
|
202
191
|
readonly finalAnswer: string | null;
|
|
203
192
|
readonly answerContent: string;
|
|
204
|
-
readonly edits: readonly ProposedEdit[];
|
|
205
193
|
readonly raised: boolean;
|
|
206
194
|
readonly executionTimeMs: number;
|
|
207
195
|
/** User-created variable names after this exec (builtins/context filtered out). */
|
|
@@ -81,17 +81,17 @@ export class SandboxManager {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Execute code in the sandbox. Serializes concurrent calls via
|
|
85
|
-
* (second call waits for first
|
|
86
|
-
*
|
|
84
|
+
* Execute code in the sandbox with no per-invocation setup. Serializes concurrent calls via
|
|
85
|
+
* a promise queue (second call waits for the first, no interleaving). On failure the sandbox
|
|
86
|
+
* is nullified so the next call recreates it (death-recreate).
|
|
87
87
|
*/
|
|
88
88
|
async exec(code: string): Promise<ReplResult> {
|
|
89
89
|
return this.execQueued(code);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Execute code after running setup inside the serialized execution slot
|
|
94
|
-
*
|
|
93
|
+
* Execute code after running `setup` inside the serialized execution slot, so per-invocation
|
|
94
|
+
* handler state (emitter, limits, depth) always matches the active REPL run.
|
|
95
95
|
*/
|
|
96
96
|
async execWithSetup(code: string, setup: () => void): Promise<ReplResult> {
|
|
97
97
|
return this.execQueued(code, setup);
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -24,14 +24,18 @@ import {
|
|
|
24
24
|
type WorkerRequest,
|
|
25
25
|
type WorkerResponse,
|
|
26
26
|
} from "./protocol.ts";
|
|
27
|
-
import { formatError } from "../util/errors.ts";
|
|
27
|
+
import { errorMessage, formatError } from "../util/errors.ts";
|
|
28
28
|
|
|
29
29
|
/** Result of a host-side library pack requested by `load_library`. */
|
|
30
30
|
export interface LibraryLoadResult {
|
|
31
|
-
readonly payload: unknown; //
|
|
32
|
-
readonly index: number; //
|
|
31
|
+
readonly payload: unknown; // always ContextFile[] under lib/<id>/
|
|
32
|
+
readonly index: number; // resume-sidecar index (not a REPL var name); -1 if alreadyLoaded
|
|
33
33
|
readonly files?: number;
|
|
34
34
|
readonly chars: number;
|
|
35
|
+
readonly sourceId: string;
|
|
36
|
+
readonly pathPrefix: string;
|
|
37
|
+
/** Host already has this library — no pack, no sidecar, empty payload. */
|
|
38
|
+
readonly alreadyLoaded?: boolean;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
|
|
@@ -64,9 +68,15 @@ export interface SandboxOptions {
|
|
|
64
68
|
readonly initTimeoutMs?: number;
|
|
65
69
|
/** Sub-LLM prompt cap (chars) — sizes llm_query_chunked chunks inside the worker. */
|
|
66
70
|
readonly maxPromptChars?: number;
|
|
71
|
+
/**
|
|
72
|
+
* When true, the worker rejects open() write modes (pipeline read-only runs).
|
|
73
|
+
* Native repl() data work leaves this false so scratch-file writes still work.
|
|
74
|
+
*/
|
|
75
|
+
readonly readOnly?: boolean;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
|
|
79
|
+
const STDERR_TAIL_CHARS = 8_192;
|
|
70
80
|
const TODO_PROTO_KEYS = new Set(["type", "rid", "depth", "action"]);
|
|
71
81
|
|
|
72
82
|
// The sandbox runs untrusted model-authored code; it must never inherit provider secrets.
|
|
@@ -115,7 +125,23 @@ export class PythonSandbox {
|
|
|
115
125
|
private readonly handlers: SubLlmHandlers;
|
|
116
126
|
private readonly requestTimeoutMs: number;
|
|
117
127
|
private readonly initTimeoutMs: number;
|
|
118
|
-
|
|
128
|
+
/** Bounded stderr tail (chunks, newest last) — avoids rebuilding the buffer per chunk. */
|
|
129
|
+
private readonly stderrTail: string[] = [];
|
|
130
|
+
private stderrLen = 0;
|
|
131
|
+
|
|
132
|
+
/** Bounded tail of everything written to stderr, oldest chunks already dropped. */
|
|
133
|
+
private get stderr(): string {
|
|
134
|
+
return this.stderrTail.join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Record a diagnostic on the same bounded tail as real worker stderr. */
|
|
138
|
+
private appendStderr(text: string): void {
|
|
139
|
+
this.stderrTail.push(text);
|
|
140
|
+
this.stderrLen += text.length;
|
|
141
|
+
while (this.stderrLen > STDERR_TAIL_CHARS && this.stderrTail.length > 1) {
|
|
142
|
+
this.stderrLen -= (this.stderrTail.shift() ?? "").length;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
119
145
|
private disposed = false;
|
|
120
146
|
private ready: Promise<void>;
|
|
121
147
|
|
|
@@ -132,6 +158,9 @@ export class PythonSandbox {
|
|
|
132
158
|
if (opts.maxPromptChars !== undefined) {
|
|
133
159
|
workerArgs.push("--max-prompt-chars", String(opts.maxPromptChars));
|
|
134
160
|
}
|
|
161
|
+
if (opts.readOnly) {
|
|
162
|
+
workerArgs.push("--read-only");
|
|
163
|
+
}
|
|
135
164
|
this.proc = spawn(
|
|
136
165
|
python,
|
|
137
166
|
workerArgs,
|
|
@@ -141,9 +170,7 @@ export class PythonSandbox {
|
|
|
141
170
|
this.proc.stdout.setEncoding("utf8");
|
|
142
171
|
this.proc.stdout.on("data", (chunk: string) => this.onData(chunk));
|
|
143
172
|
this.proc.stderr.setEncoding("utf8");
|
|
144
|
-
this.proc.stderr.on("data", (chunk: string) =>
|
|
145
|
-
this.stderr = (this.stderr + chunk).slice(-8192);
|
|
146
|
-
});
|
|
173
|
+
this.proc.stderr.on("data", (chunk: string) => this.appendStderr(chunk));
|
|
147
174
|
this.proc.on("error", (err: NodeJS.ErrnoException) => {
|
|
148
175
|
const hint = err.code === "ENOENT" ? ` ('${python}' not found — is Python installed and on PATH?)` : "";
|
|
149
176
|
this.failAll(new Error(`failed to start sandbox${hint}: ${err.message}`));
|
|
@@ -177,12 +204,12 @@ export class PythonSandbox {
|
|
|
177
204
|
return sandbox;
|
|
178
205
|
}
|
|
179
206
|
|
|
180
|
-
async loadContext(payload: unknown
|
|
207
|
+
async loadContext(payload: unknown): Promise<number> {
|
|
181
208
|
const isJson = typeof payload !== "string";
|
|
182
209
|
let path: string | undefined;
|
|
183
210
|
try {
|
|
184
211
|
path = await this.writeContextFile(payload, isJson);
|
|
185
|
-
const res = await this.request({ type: "load_context", path,
|
|
212
|
+
const res = await this.request({ type: "load_context", path, json: isJson });
|
|
186
213
|
if (!res.ok) throw new Error(res.error ?? "load_context failed");
|
|
187
214
|
return res.index ?? 0;
|
|
188
215
|
} finally {
|
|
@@ -212,7 +239,6 @@ export class PythonSandbox {
|
|
|
212
239
|
stderr: res.stderr ?? "",
|
|
213
240
|
finalAnswer: res.final_answer ?? null,
|
|
214
241
|
answerContent: res.answer_content ?? "",
|
|
215
|
-
edits: res.edits ?? [],
|
|
216
242
|
raised: res.raised ?? false,
|
|
217
243
|
executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
|
|
218
244
|
varNames: res.var_names ?? [],
|
|
@@ -297,15 +323,6 @@ export class PythonSandbox {
|
|
|
297
323
|
}
|
|
298
324
|
}
|
|
299
325
|
|
|
300
|
-
/**
|
|
301
|
-
* Refresh the parent-side request watchdog for every pending request.
|
|
302
|
-
* Used during long mid-exec work (e.g. serial implement fanout) that does not
|
|
303
|
-
* produce additional worker interrupts on this sandbox.
|
|
304
|
-
*/
|
|
305
|
-
refreshWatchdog(): void {
|
|
306
|
-
this.touchPending();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
326
|
private send(msg: ParentMessage): void {
|
|
310
327
|
this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
311
328
|
}
|
|
@@ -320,11 +337,11 @@ export class PythonSandbox {
|
|
|
320
337
|
try {
|
|
321
338
|
const message = JSON.parse(line) as unknown;
|
|
322
339
|
if (isWorkerMessage(message)) this.dispatch(message);
|
|
323
|
-
else this.
|
|
340
|
+
else this.appendStderr(`\n[protocol] skipped invalid stdout message: ${line.slice(0, 200)}`);
|
|
324
341
|
} catch {
|
|
325
342
|
// Non-JSON line on the protocol stream — likely a subprocess writing to fd 1.
|
|
326
343
|
// Skip it so a rogue write doesn't kill the pump, but retain a breadcrumb for watchdog errors.
|
|
327
|
-
this.
|
|
344
|
+
this.appendStderr(`\n[protocol] skipped non-JSON stdout line: ${line.slice(0, 200)}`);
|
|
328
345
|
}
|
|
329
346
|
}
|
|
330
347
|
}
|
|
@@ -381,14 +398,34 @@ export class PythonSandbox {
|
|
|
381
398
|
this.reply(msg.rid, { response });
|
|
382
399
|
} else if (msg.type === "load_library") {
|
|
383
400
|
const lib = await h.loadLibrary(msg.source ?? "", d);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
401
|
+
if (lib.alreadyLoaded) {
|
|
402
|
+
// No temp file — worker short-circuits on already_loaded.
|
|
403
|
+
this.reply(msg.rid, {
|
|
404
|
+
already_loaded: true,
|
|
405
|
+
index: lib.index,
|
|
406
|
+
files: 0,
|
|
407
|
+
chars: lib.chars,
|
|
408
|
+
source_id: lib.sourceId,
|
|
409
|
+
path_prefix: lib.pathPrefix,
|
|
410
|
+
});
|
|
411
|
+
} else {
|
|
412
|
+
const isJson = typeof lib.payload !== "string";
|
|
413
|
+
const path = await this.writeContextFile(lib.payload, isJson);
|
|
414
|
+
// Worker reads then unlinks (worker._load_library). Host must not unlink here —
|
|
415
|
+
// if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
|
|
416
|
+
this.reply(msg.rid, {
|
|
417
|
+
path,
|
|
418
|
+
json: isJson,
|
|
419
|
+
index: lib.index,
|
|
420
|
+
files: lib.files,
|
|
421
|
+
chars: lib.chars,
|
|
422
|
+
source_id: lib.sourceId,
|
|
423
|
+
path_prefix: lib.pathPrefix,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
389
426
|
}
|
|
390
427
|
} catch (err) {
|
|
391
|
-
this.reply(msg.rid, { error:
|
|
428
|
+
this.reply(msg.rid, { error: errorMessage(err) });
|
|
392
429
|
}
|
|
393
430
|
}
|
|
394
431
|
|
|
@@ -401,6 +438,9 @@ export class PythonSandbox {
|
|
|
401
438
|
index?: number;
|
|
402
439
|
files?: number;
|
|
403
440
|
chars?: number;
|
|
441
|
+
source_id?: string;
|
|
442
|
+
path_prefix?: string;
|
|
443
|
+
already_loaded?: boolean;
|
|
404
444
|
error?: string;
|
|
405
445
|
}): void {
|
|
406
446
|
if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
|