@hicaru/pi-rlm 0.1.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/LICENSE +21 -0
- package/README.md +237 -0
- package/README.ru.md +200 -0
- package/README.zh-CN.md +224 -0
- package/package.json +54 -0
- package/src/bridge/fallback-todo.ts +137 -0
- package/src/bridge/interactive.ts +65 -0
- package/src/bridge/llm-query.ts +124 -0
- package/src/bridge/model.ts +97 -0
- package/src/bridge/pi-interactive.ts +86 -0
- package/src/bridge/rlm-query.ts +78 -0
- package/src/commands/rlm-config.ts +42 -0
- package/src/commands/rlm.ts +165 -0
- package/src/config/defaults.ts +38 -0
- package/src/config/settings.ts +185 -0
- package/src/context/repomix-context.ts +253 -0
- package/src/core/answer.ts +97 -0
- package/src/core/compaction.ts +64 -0
- package/src/core/engine.ts +408 -0
- package/src/core/history.ts +13 -0
- package/src/core/iteration.ts +45 -0
- package/src/core/limits.ts +90 -0
- package/src/core/pipeline.ts +100 -0
- package/src/core/resource-limits.ts +14 -0
- package/src/core/types.ts +131 -0
- package/src/index.ts +165 -0
- package/src/mode/input-router.ts +23 -0
- package/src/mode/rlm-mode.ts +149 -0
- package/src/patch/apply.ts +148 -0
- package/src/patch/index.ts +37 -0
- package/src/prompts/system.ts +278 -0
- package/src/prompts/user.ts +21 -0
- package/src/sandbox/protocol.ts +191 -0
- package/src/sandbox/sandbox-manager.ts +143 -0
- package/src/sandbox/sandbox.ts +362 -0
- package/src/sandbox/worker.py +457 -0
- package/src/state/events.ts +22 -0
- package/src/state/index.ts +23 -0
- package/src/state/internal.ts +46 -0
- package/src/state/paths.ts +42 -0
- package/src/state/reads.ts +96 -0
- package/src/state/resume.ts +154 -0
- package/src/state/rows.ts +117 -0
- package/src/state/writes.ts +56 -0
- package/src/telemetry/dispatcher.ts +116 -0
- package/src/telemetry/index.ts +14 -0
- package/src/telemetry/mlflow-config.ts +15 -0
- package/src/telemetry/mlflow-sink.ts +136 -0
- package/src/telemetry/mlflow.ts +99 -0
- package/src/telemetry/sink.ts +8 -0
- package/src/text/edits.ts +16 -0
- package/src/text/parsing.ts +35 -0
- package/src/text/preview.ts +18 -0
- package/src/text/tokens.ts +64 -0
- package/src/tool/apply-diff-tool.ts +125 -0
- package/src/tool/emitter-listener.ts +24 -0
- package/src/tool/repl-details.ts +23 -0
- package/src/tool/repl-tool.ts +528 -0
- package/src/tool/rlm-aggregator.ts +115 -0
- package/src/tool/rlm-details.ts +53 -0
- package/src/tool/rlm-events.ts +215 -0
- package/src/tool/rlm-tool.ts +199 -0
- package/src/tool/subcall-render.ts +129 -0
- package/src/tool/subcall-store.ts +90 -0
- package/src/tool/tool-utils.ts +73 -0
- package/src/ui/config-panel.ts +92 -0
- package/src/ui/intro.ts +23 -0
- package/src/ui/model-picker.ts +139 -0
- package/src/ui/status.ts +26 -0
- package/src/ui/theme.ts +47 -0
- package/src/util/concurrency.ts +15 -0
- package/src/util/errors.ts +27 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RLM system prompt (ported from rlm/utils/prompts.py).
|
|
3
|
+
*
|
|
4
|
+
* The root model runs Python by writing fenced ```repl``` blocks (headless engine). The REPL
|
|
5
|
+
* exposes `context`, the sub-LLM functions, and the `answer` dict the model flips to submit.
|
|
6
|
+
*/
|
|
7
|
+
import type { ContextSizeStats } from "../text/tokens.ts";
|
|
8
|
+
|
|
9
|
+
export interface PromptMeta {
|
|
10
|
+
readonly contextType: string;
|
|
11
|
+
readonly contextChars: number;
|
|
12
|
+
readonly contextStats?: ContextSizeStats;
|
|
13
|
+
readonly rootPrompt?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SystemPromptOptions {
|
|
17
|
+
readonly orchestrator?: boolean;
|
|
18
|
+
readonly recursion?: boolean;
|
|
19
|
+
readonly askUserQuestion?: boolean;
|
|
20
|
+
readonly todo?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function howToRunCode(): string {
|
|
24
|
+
return [
|
|
25
|
+
"To run Python, write a fenced ```repl``` block. The REPL **persists** across turns. Only",
|
|
26
|
+
"`print(...)` output (stdout) is returned; a bare expression on the last line is discarded, so",
|
|
27
|
+
"always wrap inspections in `print(...)`.",
|
|
28
|
+
].join(" ");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function replGlossary(recursion: boolean, askUserQuestion: boolean, todo: boolean): string {
|
|
32
|
+
const lines = [
|
|
33
|
+
"Available in the REPL:",
|
|
34
|
+
"- `context`: list[dict] — a pre-packed JSON array of every file in the repository. Each dict has",
|
|
35
|
+
" keys: `path` (relative file path, str), `content` (file text, str), `tokens` (estimated count, int).",
|
|
36
|
+
" For large repos, chunk `context` into batches and delegate to sub-LLMs — never dump raw file",
|
|
37
|
+
" bodies into your own output.",
|
|
38
|
+
"",
|
|
39
|
+
" Chunking example:",
|
|
40
|
+
" ```python",
|
|
41
|
+
" chunk_size = 5",
|
|
42
|
+
" for i in range(0, len(context), chunk_size):",
|
|
43
|
+
" batch = context[i:i+chunk_size]",
|
|
44
|
+
" results = llm_query_batched([",
|
|
45
|
+
" f\"Analyze {f['path']} ({f['tokens']} tok):\\n{f['content']}\"",
|
|
46
|
+
" for f in batch",
|
|
47
|
+
" ])",
|
|
48
|
+
" ```",
|
|
49
|
+
"- `llm_query(prompt: str, model=None) -> str`: a single sub-LLM completion. Use for extraction,",
|
|
50
|
+
" summarization, or Q&A over a chunk of text.",
|
|
51
|
+
"- `llm_query_batched(prompts: list[str], model=None) -> list[str]`: run several sub-LLM calls",
|
|
52
|
+
" concurrently; output order matches input order.",
|
|
53
|
+
];
|
|
54
|
+
if (askUserQuestion) {
|
|
55
|
+
lines.push(
|
|
56
|
+
"- `ask_user_question(questions: list[dict]) -> list[dict]`: pause and present the user",
|
|
57
|
+
" with 1-4 structured questions. Each question: {question, header, options: [{label, description}],",
|
|
58
|
+
" multiSelect?}. Returns list of {question, selected: [label], custom?}.",
|
|
59
|
+
" Use when you have 2-4 concrete options from your analysis and need a decision before proceeding.",
|
|
60
|
+
" DO NOT ask open-ended chat questions — use concrete options grounded in code/data.",
|
|
61
|
+
" Only valid at root depth; returns an error inside rlm_query sub-calls.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (todo) {
|
|
65
|
+
lines.push(
|
|
66
|
+
"- `todo(action, **kwargs) -> str`: manage a task list visible to the user.",
|
|
67
|
+
" Actions: create(subject, description?, status='pending'), update(id, status?, activeForm?),",
|
|
68
|
+
" list(filterStatus?), get(id), delete(id), clear().",
|
|
69
|
+
" Status flow: pending → in_progress → completed.",
|
|
70
|
+
" Use to plan multi-step work before starting, then mark tasks as you complete them.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (recursion) {
|
|
74
|
+
lines.push(
|
|
75
|
+
"- `rlm_query(prompt, model=None)` / `rlm_query_batched(prompts, model=None)`: recursive RLM",
|
|
76
|
+
" sub-calls. Each child runs a full REPL loop internally — its entire conversation is PRIVATE",
|
|
77
|
+
" and never enters your history. Only the final answer (a short string) is returned.",
|
|
78
|
+
"",
|
|
79
|
+
" **Choosing between `llm_query` and `rlm_query`:**",
|
|
80
|
+
" - `llm_query` for simple one-shot tasks — summarize a chunk, extract a fact, answer a direct",
|
|
81
|
+
" question. It is a single LLM call: fast and cheap. Prefer it by default, and fan out with",
|
|
82
|
+
" `llm_query_batched` for parallel one-shots.",
|
|
83
|
+
" - `rlm_query` only when a sub-task genuinely needs iterative reasoning with its own code",
|
|
84
|
+
" execution (e.g. a sub-context large enough to need its own chunking, or a multi-step",
|
|
85
|
+
" reasoning chain). It is slower and more expensive — reserve it for cases `llm_query` cannot",
|
|
86
|
+
" handle. Avoid excessive recursive sub-calls when a batched one-shot would suffice.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
lines.push(
|
|
90
|
+
"- `advance_phase(phase: str, summary=None) -> str`: transition the root RLM pipeline to the next phase.",
|
|
91
|
+
" Valid phases in order: 'research' → 'blueprint' → 'implement' → 'validate'. You must advance forward",
|
|
92
|
+
" one phase at a time. Only callable at the root depth; returns an error in sub-RLM contexts.",
|
|
93
|
+
);
|
|
94
|
+
lines.push(
|
|
95
|
+
"- `SHOW_VARS() -> str`: list every variable currently in the REPL.",
|
|
96
|
+
'- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
|
|
97
|
+
' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
|
|
98
|
+
);
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ORCHESTRATOR_ADDENDUM = [
|
|
103
|
+
"As an RLM you are an **orchestrator, not a solver**. After you probe `context` and understand the",
|
|
104
|
+
"task, pause and plan: state how the task decomposes into sub-LLM / REPL steps, then execute one step",
|
|
105
|
+
"at a time, printing a small sample of each result to verify before moving on.",
|
|
106
|
+
"",
|
|
107
|
+
"Your own context window is small. Push every long-context operation — reading, summarizing,",
|
|
108
|
+
"classifying, answering sub-questions — into `llm_query` / `llm_query_batched` instead of pulling raw",
|
|
109
|
+
"text into your own message stream. Conversely, if a Python keyword/regex search over `context` would",
|
|
110
|
+
"already pin the answer, just read it directly. Aggregate the small results back in Python.",
|
|
111
|
+
"",
|
|
112
|
+
"Sub-call budget is finite on two axes: (1) per-prompt capacity — keep each sub-prompt modestly sized",
|
|
113
|
+
"(a useful ceiling is ~100K characters), packing a chunk of many items per call; (2) batch fan-out —",
|
|
114
|
+
"keep batches to roughly ~20 prompts. Fat prompts in small batches beat thousands of tiny prompts.",
|
|
115
|
+
"If the workload exceeds both at once, filter aggressively in Python first, then batch the survivors.",
|
|
116
|
+
"",
|
|
117
|
+
"Reserve your own tokens for high-level decisions: what to ask next, how to combine sub-LM outputs,",
|
|
118
|
+
"when to finalize. Delegate everything else. Do not submit a final answer before inspecting `context`.",
|
|
119
|
+
].join("\n");
|
|
120
|
+
|
|
121
|
+
const INTRO = [
|
|
122
|
+
"You are a Recursive Language Model (RLM): a language model with a prompt and a very important",
|
|
123
|
+
"context stored in a Python REPL. You interact with the REPL turn-by-turn until you have an answer.",
|
|
124
|
+
].join(" ");
|
|
125
|
+
|
|
126
|
+
/** Build the full RLM system prompt. */
|
|
127
|
+
export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions = {}): string {
|
|
128
|
+
const recursion = opts.recursion ?? false;
|
|
129
|
+
const parts = [
|
|
130
|
+
INTRO,
|
|
131
|
+
"",
|
|
132
|
+
howToRunCode(),
|
|
133
|
+
"",
|
|
134
|
+
replGlossary(recursion, opts.askUserQuestion ?? false, opts.todo ?? false),
|
|
135
|
+
"",
|
|
136
|
+
"REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
|
|
137
|
+
"REPL variables as buffers. Re-print only the slice you need (e.g. `print(result[:500])`); never",
|
|
138
|
+
"dump a whole sub-LLM result. The full content persists across turns in REPL variables (call `SHOW_VARS()`).",
|
|
139
|
+
"",
|
|
140
|
+
"Start by probing `context` (print a few lines, count items). Then build up an answer to the query.",
|
|
141
|
+
];
|
|
142
|
+
if (opts.orchestrator ?? true) {
|
|
143
|
+
parts.push("", ORCHESTRATOR_ADDENDUM);
|
|
144
|
+
}
|
|
145
|
+
parts.push("", buildMetadataLine(meta));
|
|
146
|
+
return parts.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Adapts the REPL glossary for native mode — agent calls `repl({code})` instead of writing ```repl``` blocks. */
|
|
150
|
+
function nativeReplGlossary(): string {
|
|
151
|
+
return [
|
|
152
|
+
"## RLM Native Mode — Persistent Python REPL",
|
|
153
|
+
"",
|
|
154
|
+
"Call `repl({code: \"...\"})` to execute Python in a **persistent** sandbox. Variables, imports,",
|
|
155
|
+
"and state survive across calls — you build up results incrementally. Only `print()` output is",
|
|
156
|
+
"returned, so always wrap inspections in `print(...)`.",
|
|
157
|
+
"",
|
|
158
|
+
"### REPL Environment",
|
|
159
|
+
"- `context`: list[dict] — every file in the repository. Each dict: `path` (str), `content` (str), `tokens` (int).",
|
|
160
|
+
"- `llm_query(prompt, model=None) -> str` — one-shot sub-LLM. Use for extraction, summarization, Q&A over a chunk.",
|
|
161
|
+
"- `llm_query_batched(prompts, model=None) -> list[str]` — concurrent sub-LLM calls; output order matches input order.",
|
|
162
|
+
"- `rlm_query(prompt, model=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning.",
|
|
163
|
+
"- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
|
|
164
|
+
"",
|
|
165
|
+
"**Choosing between `llm_query` and `rlm_query`:** default to `llm_query` (fast/cheap) for one-shot tasks",
|
|
166
|
+
"and fan out with `llm_query_batched`; reach for `rlm_query` only when a sub-task needs its own iterative",
|
|
167
|
+
"reasoning. Avoid excessive recursive sub-calls when a batched one-shot suffices.",
|
|
168
|
+
"- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
|
|
169
|
+
"- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
|
|
170
|
+
"- `answer`: dict `{\"content\": \"\", \"ready\": False}`. To submit: `answer[\"content\"] = \"...\"; answer[\"ready\"] = True`.",
|
|
171
|
+
"",
|
|
172
|
+
"### Orchestrator Pattern",
|
|
173
|
+
"You are an **orchestrator, not a solver**. After probing `context`, decompose the task into sub-LLM / REPL steps,",
|
|
174
|
+
"then execute one step at a time, printing samples of each result to verify before moving on.",
|
|
175
|
+
"",
|
|
176
|
+
"Push every long-context operation (reading, summarizing, classifying, answering sub-questions) into",
|
|
177
|
+
"`llm_query` / `llm_query_batched` — never dump raw file bodies into your own output. Aggregate small",
|
|
178
|
+
"results back in Python. Use Python string operations (`in`, `re.search`) over `context` for quick lookups.",
|
|
179
|
+
"",
|
|
180
|
+
"### Chunking Strategy",
|
|
181
|
+
"```python",
|
|
182
|
+
"chunk_size = 10",
|
|
183
|
+
"for i in range(0, len(context), chunk_size):",
|
|
184
|
+
" batch = context[i:i+chunk_size]",
|
|
185
|
+
" results = llm_query_batched([",
|
|
186
|
+
" f\"Analyze {f['path']} ({f['tokens']} tok):\\n{f['content']}\"",
|
|
187
|
+
" for f in batch",
|
|
188
|
+
" ])",
|
|
189
|
+
" # aggregate results into a buffer",
|
|
190
|
+
"```",
|
|
191
|
+
"- Keep sub-prompts ~100K characters; batch ~20 prompts per call. Fat prompts in small batches > thousands of tiny prompts.",
|
|
192
|
+
"- If your `context` is small enough (<20 files), you CAN read files directly via `read` / `grep` / `zebra-mcp`.",
|
|
193
|
+
"- For medium/large repos, delegate to sub-LLMs via the REPL.",
|
|
194
|
+
"",
|
|
195
|
+
"### Choosing Between Tools",
|
|
196
|
+
"| Tool | When |",
|
|
197
|
+
"|------|------|",
|
|
198
|
+
"| `repl({code})` | Need to chunk/delegate `context` to sub-LLMs; need Python scripting; need REPL state across calls |",
|
|
199
|
+
"| `read` / `grep` | Inspect a few specific files directly; small codebase |",
|
|
200
|
+
"| `zebra-mcp` | Semantic search over the codebase |",
|
|
201
|
+
"| `apply_diff({diff})` | Apply a unified diff to any file — shows patch preview before writing |",
|
|
202
|
+
"| `llm_query` (inside repl) | Extract, summarize, or classify a chunk of text |",
|
|
203
|
+
"| `rlm_query` (inside repl) | Complex sub-task needing iterative reasoning with its own REPL |",
|
|
204
|
+
"| `todo` (inside repl) | Track multi-step progress visibly to the user |",
|
|
205
|
+
"",
|
|
206
|
+
"### Workflow",
|
|
207
|
+
"1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
|
|
208
|
+
"2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
|
|
209
|
+
"3. **Aggregate**: Collect results in Python, pass aggregated results to a final `llm_query` or produce the answer directly.",
|
|
210
|
+
"4. **Finalize**: Set `answer[\"content\"]` and `answer[\"ready\"] = True`, or just write your final answer as a normal message.",
|
|
211
|
+
"",
|
|
212
|
+
"### Task-Specific Patterns",
|
|
213
|
+
"",
|
|
214
|
+
"**Architecture analysis / \"learn this project\" / diagram**:",
|
|
215
|
+
"1. Probe → `repl()` to list all files grouped by directory",
|
|
216
|
+
"2. Chunk → split files into module batches (~10-15 files each)",
|
|
217
|
+
"3. **DELEGATE ALL** → `llm_query_batched` on EVERY module: \"Summarize each file's role, what it exports, and how it connects\". Send ALL batches.",
|
|
218
|
+
"4. Aggregate → collect all sub-LLM summaries, synthesize diagram from them.",
|
|
219
|
+
"5. If sub-LLM credits exhausted → report to user: \"Credits exhausted after N batches. Results so far: ...\"",
|
|
220
|
+
"",
|
|
221
|
+
"**Bug investigation / \"find the issue\"**:",
|
|
222
|
+
"1. `repl()` → grep context for keywords (use Python re/in operators)",
|
|
223
|
+
"2. `llm_query` on matching files: \"Is there a bug here? What could cause X?\"",
|
|
224
|
+
"",
|
|
225
|
+
"**Full code review / audit**:",
|
|
226
|
+
"1. `repl()` → chunk all files, delegate ALL to `llm_query_batched` with review criteria",
|
|
227
|
+
"2. Aggregate findings, report to user",
|
|
228
|
+
"",
|
|
229
|
+
"CRITICAL: Never read files directly. If sub-LLMs fail → report, don't fall back to read.",
|
|
230
|
+
"",
|
|
231
|
+
"### Handling Sub-LLM Failures",
|
|
232
|
+
"Sub-LLM calls can fail (credit limits, rate limits, timeouts). Handle gracefully:",
|
|
233
|
+
"",
|
|
234
|
+
"| Failure | Action |",
|
|
235
|
+
"|---------|--------|",
|
|
236
|
+
"| `llm_query_batched` all fail | Reduce batch size (try 3-5 instead of 10+). If still failing, use individual `llm_query` calls. |",
|
|
237
|
+
"| Individual `llm_query` fails | Check error message. If credit/rate-limit, wait and retry once. If still failing, read files directly with `read`/`grep`. |",
|
|
238
|
+
"| `rlm_query` fails | Fall back to `llm_query` — it's a one-shot call that uses fewer resources. |",
|
|
239
|
+
"| All sub-LLMs exhausted | Read key files directly. For small repos (<20 files), direct reading is fine. For large repos, prioritize the most important files. |",
|
|
240
|
+
"",
|
|
241
|
+
"Reserve your own tokens for high-level decisions: what to ask next, how to combine sub-LLM outputs, when to finalize.",
|
|
242
|
+
"Delegate everything else. Do not submit a final answer before inspecting `context`.",
|
|
243
|
+
].join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Build the native-mode system prompt for the main Pi agent. */
|
|
247
|
+
export function buildNativeSystemPrompt(): string {
|
|
248
|
+
return [
|
|
249
|
+
"╔══════════════════════════════════════════════════════════════════╗",
|
|
250
|
+
"║ NATIVE RLM MODE — YOU ARE AN ORCHESTRATOR, NOT A READER ║",
|
|
251
|
+
"╚══════════════════════════════════════════════════════════════════╝",
|
|
252
|
+
"",
|
|
253
|
+
"ABSOLUTE RESTRICTION: Do NOT use `read`, `grep`, or `bash` to access files.",
|
|
254
|
+
"All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
|
|
255
|
+
"You may read at most 2 hub files directly (README.md, package.json) for quick orientation.",
|
|
256
|
+
"If sub-LLM credits are exhausted → report the error to the user and stop.",
|
|
257
|
+
"",
|
|
258
|
+
"ABSOLUTE RESTRICTION: Do NOT use `write` or `edit` to modify files directly.",
|
|
259
|
+
"All file modifications MUST go through apply_diff({diff}).",
|
|
260
|
+
"The diff MUST be a complete unified diff with --- a/<path> / +++ b/<path> header and @@ hunk markers.",
|
|
261
|
+
"",
|
|
262
|
+
nativeReplGlossary(),
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Exported for tests — prompt length without context metadata (which is injected separately). */
|
|
267
|
+
export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
|
|
268
|
+
|
|
269
|
+
/** The one-line context metadata, also reused by the per-turn prompt in headless mode. */
|
|
270
|
+
export function buildMetadataLine(meta: PromptMeta): string {
|
|
271
|
+
const contextDesc = `Your context is a JSON array of ${meta.contextChars.toLocaleString()} total characters — list[dict] where each dict has keys "path" (str), "content" (str), and "tokens" (int). Use Python list slicing to chunk it into batches for sub-LLM delegation.`;
|
|
272
|
+
const tail = "Each sub-LLM call can handle roughly ~100k tokens at once.";
|
|
273
|
+
const dist = meta.contextStats
|
|
274
|
+
? ` 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.`
|
|
275
|
+
: "";
|
|
276
|
+
const body = `${contextDesc} ${tail}${dist}`;
|
|
277
|
+
return meta.rootPrompt ? `Answer the following: ${meta.rootPrompt}\n\n${body}` : body;
|
|
278
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn user prompts for the headless engine (ported from prompts.py `build_user_prompt`).
|
|
3
|
+
* Native mode does not use these — pi's own loop supplies the turns.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function buildTurnPrompt(iteration: number, maxIterations: number, gateMessage?: string): string {
|
|
7
|
+
const body = `Turn ${iteration + 1}/${maxIterations}:`;
|
|
8
|
+
const gate = gateMessage ? `\n${gateMessage}\n\n` : "";
|
|
9
|
+
if (iteration === 0) {
|
|
10
|
+
return (
|
|
11
|
+
"You have not interacted with the REPL or seen your context yet. Look at the context first; " +
|
|
12
|
+
`do not provide a final answer yet.\n\n${gate}${body}`
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return `${gate}${body}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Asked once when the engine runs out of turns without a submitted answer. */
|
|
19
|
+
export const FINALIZE_PROMPT =
|
|
20
|
+
"You are out of turns. Provide your best final answer now based on everything you have gathered, " +
|
|
21
|
+
'by setting `answer["content"]` and `answer["ready"] = True` (fenced ```repl```), or as plain text.';
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol for the RLM Python sandbox.
|
|
3
|
+
*
|
|
4
|
+
* Newline-delimited JSON over the worker's stdin/stdout — no sockets, no HTTP.
|
|
5
|
+
* Parent -> worker: requests (exec/load_context/shutdown) and llm replies.
|
|
6
|
+
* Worker -> parent: request responses and mid-exec sub-LLM interrupts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Requests the parent sends to the worker. */
|
|
10
|
+
export type WorkerRequest =
|
|
11
|
+
| { readonly id: string; readonly type: "exec"; readonly code: string }
|
|
12
|
+
| { readonly id: string; readonly type: "load_context"; readonly path: string; readonly index?: number; readonly json: boolean }
|
|
13
|
+
| { readonly id: string; readonly type: "snapshot"; readonly path: string; readonly nonce: string }
|
|
14
|
+
| { readonly id: string; readonly type: "restore"; readonly path: string; readonly nonce: string }
|
|
15
|
+
| { readonly id: string; readonly type: "shutdown" };
|
|
16
|
+
|
|
17
|
+
/** Reply the parent sends to satisfy a sub-LLM interrupt. */
|
|
18
|
+
export interface LlmReply {
|
|
19
|
+
readonly type: "llm_reply";
|
|
20
|
+
readonly rid: string;
|
|
21
|
+
readonly response?: string;
|
|
22
|
+
readonly responses?: readonly string[];
|
|
23
|
+
readonly answers?: readonly AskAnswer[];
|
|
24
|
+
readonly error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ParentMessage = WorkerRequest | LlmReply;
|
|
28
|
+
|
|
29
|
+
export interface ProposedEdit {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly oldText: string;
|
|
32
|
+
readonly newText: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProposedDiffEdit {
|
|
36
|
+
readonly diff: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A normal response to a request (keyed by the request `id`). */
|
|
40
|
+
export interface WorkerResponse {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly ok: boolean;
|
|
43
|
+
readonly error?: string;
|
|
44
|
+
// exec result fields:
|
|
45
|
+
readonly stdout?: string;
|
|
46
|
+
readonly stderr?: string;
|
|
47
|
+
readonly final_answer?: string | null;
|
|
48
|
+
readonly answer_content?: string;
|
|
49
|
+
readonly edits?: readonly ProposedEdit[];
|
|
50
|
+
readonly diffs?: readonly ProposedDiffEdit[];
|
|
51
|
+
readonly raised?: boolean;
|
|
52
|
+
readonly execution_time?: number;
|
|
53
|
+
// user-created variable names after this exec (filters builtins/context) — Metadata(stdout) for history orientation
|
|
54
|
+
readonly var_names?: readonly string[];
|
|
55
|
+
// load_context:
|
|
56
|
+
readonly index?: number;
|
|
57
|
+
// snapshot/restore:
|
|
58
|
+
readonly skipped?: readonly string[];
|
|
59
|
+
readonly restored?: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Kinds of sub-LLM interrupt the worker can raise mid-exec. */
|
|
63
|
+
export type InterruptKind =
|
|
64
|
+
| "llm_query"
|
|
65
|
+
| "llm_query_batched"
|
|
66
|
+
| "rlm_query"
|
|
67
|
+
| "rlm_query_batched"
|
|
68
|
+
| "advance_phase"
|
|
69
|
+
| "ask_user_question"
|
|
70
|
+
| "todo";
|
|
71
|
+
|
|
72
|
+
export interface AskOption {
|
|
73
|
+
readonly label: string;
|
|
74
|
+
readonly description?: string;
|
|
75
|
+
readonly preview?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AskQuestion {
|
|
79
|
+
readonly question: string;
|
|
80
|
+
readonly header: string;
|
|
81
|
+
readonly multiSelect?: boolean;
|
|
82
|
+
readonly options: readonly AskOption[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AskAnswer {
|
|
86
|
+
readonly question: string;
|
|
87
|
+
readonly selected: readonly string[];
|
|
88
|
+
readonly custom?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AskUserQuestionReply {
|
|
92
|
+
readonly answers: readonly AskAnswer[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface InterruptBase {
|
|
96
|
+
readonly rid: string;
|
|
97
|
+
readonly depth: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface PromptInterrupt extends InterruptBase {
|
|
101
|
+
readonly type: "llm_query" | "rlm_query";
|
|
102
|
+
readonly prompt?: string;
|
|
103
|
+
readonly model?: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface BatchedPromptInterrupt extends InterruptBase {
|
|
107
|
+
readonly type: "llm_query_batched" | "rlm_query_batched";
|
|
108
|
+
readonly prompts?: readonly string[];
|
|
109
|
+
readonly model?: string | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface AdvancePhaseInterrupt extends InterruptBase {
|
|
113
|
+
readonly type: "advance_phase";
|
|
114
|
+
readonly phase?: string;
|
|
115
|
+
readonly summary?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface AskUserQuestionInterrupt extends InterruptBase {
|
|
119
|
+
readonly type: "ask_user_question";
|
|
120
|
+
readonly questions: readonly AskQuestion[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface TodoInterrupt extends InterruptBase {
|
|
124
|
+
readonly type: "todo";
|
|
125
|
+
readonly action: "create" | "update" | "list" | "get" | "delete" | "clear";
|
|
126
|
+
readonly id?: number;
|
|
127
|
+
readonly subject?: string;
|
|
128
|
+
readonly description?: string;
|
|
129
|
+
readonly status?: "pending" | "in_progress" | "completed" | "deleted";
|
|
130
|
+
readonly activeForm?: string;
|
|
131
|
+
readonly blockedBy?: readonly number[];
|
|
132
|
+
readonly addBlockedBy?: readonly number[];
|
|
133
|
+
readonly removeBlockedBy?: readonly number[];
|
|
134
|
+
readonly owner?: string;
|
|
135
|
+
readonly filterStatus?: string;
|
|
136
|
+
readonly includeDeleted?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A mid-exec sub-LLM/tool request from the worker. */
|
|
140
|
+
export type WorkerInterrupt =
|
|
141
|
+
| PromptInterrupt
|
|
142
|
+
| BatchedPromptInterrupt
|
|
143
|
+
| AdvancePhaseInterrupt
|
|
144
|
+
| AskUserQuestionInterrupt
|
|
145
|
+
| TodoInterrupt;
|
|
146
|
+
|
|
147
|
+
export type WorkerMessage = WorkerResponse | WorkerInterrupt;
|
|
148
|
+
|
|
149
|
+
export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
|
|
150
|
+
"llm_query",
|
|
151
|
+
"llm_query_batched",
|
|
152
|
+
"rlm_query",
|
|
153
|
+
"rlm_query_batched",
|
|
154
|
+
"advance_phase",
|
|
155
|
+
"ask_user_question",
|
|
156
|
+
"todo",
|
|
157
|
+
]));
|
|
158
|
+
|
|
159
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
160
|
+
return typeof value === "object" && value !== null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isWorkerResponse(value: unknown): value is WorkerResponse {
|
|
164
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.ok === "boolean";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function isInterrupt(msg: unknown): msg is WorkerInterrupt {
|
|
168
|
+
return isRecord(msg)
|
|
169
|
+
&& typeof msg.type === "string"
|
|
170
|
+
&& INTERRUPT_KINDS.has(msg.type as InterruptKind)
|
|
171
|
+
&& typeof msg.rid === "string"
|
|
172
|
+
&& typeof msg.depth === "number";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isWorkerMessage(msg: unknown): msg is WorkerMessage {
|
|
176
|
+
return isWorkerResponse(msg) || isInterrupt(msg);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Result of a single `repl` block execution, surfaced to the engine/tool. */
|
|
180
|
+
export interface ReplResult {
|
|
181
|
+
readonly stdout: string;
|
|
182
|
+
readonly stderr: string;
|
|
183
|
+
readonly finalAnswer: string | null;
|
|
184
|
+
readonly answerContent: string;
|
|
185
|
+
readonly edits: readonly ProposedEdit[];
|
|
186
|
+
readonly diffs: readonly ProposedDiffEdit[];
|
|
187
|
+
readonly raised: boolean;
|
|
188
|
+
readonly executionTimeMs: number;
|
|
189
|
+
/** User-created variable names after this exec (builtins/context filtered out). */
|
|
190
|
+
readonly varNames: readonly string[];
|
|
191
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SandboxManager — persistent singleton owning one PythonSandbox across
|
|
3
|
+
* multiple repl() calls. Handles lazy creation, death-recreate, serialized
|
|
4
|
+
* execution via a promise queue, and idempotent disposal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PythonSandbox, type SubLlmHandlers } from "./sandbox.ts";
|
|
8
|
+
import type { ReplResult } from "./protocol.ts";
|
|
9
|
+
|
|
10
|
+
/** Static configuration for sandbox creation — set once, reused across getOrCreate calls. */
|
|
11
|
+
export interface SandboxManagerConfig {
|
|
12
|
+
readonly execTimeoutS: number;
|
|
13
|
+
readonly requestTimeoutMs: number;
|
|
14
|
+
readonly python: string;
|
|
15
|
+
readonly sandboxInitTimeoutMs: number;
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SandboxManager {
|
|
20
|
+
private sandbox: PythonSandbox | null = null;
|
|
21
|
+
private disposed = false;
|
|
22
|
+
private initPromise: Promise<PythonSandbox> | null = null;
|
|
23
|
+
/** Serialized execution queue — concurrent repl() calls wait for predecessor. */
|
|
24
|
+
private execQueue: Promise<void> = Promise.resolve();
|
|
25
|
+
private pendingExecCount = 0;
|
|
26
|
+
/** Context payload to load on first sandbox creation. Set externally before getOrCreate. */
|
|
27
|
+
contextPayload: unknown = null;
|
|
28
|
+
/** True once contextPayload has been loaded into the sandbox (prevents reload + race fix). */
|
|
29
|
+
private contextLoaded = false;
|
|
30
|
+
|
|
31
|
+
constructor(private readonly config: SandboxManagerConfig) {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lazy get-or-create the sandbox. On first call, spawns PythonSandbox with the
|
|
35
|
+
* given handlers. Subsequent calls return the existing sandbox immediately.
|
|
36
|
+
* Deduplicates concurrent calls via initPromise.
|
|
37
|
+
*
|
|
38
|
+
* The caller is responsible for calling loadContext() on the returned sandbox
|
|
39
|
+
* before first use.
|
|
40
|
+
*/
|
|
41
|
+
async getOrCreate(handlers: Partial<SubLlmHandlers>): Promise<PythonSandbox> {
|
|
42
|
+
if (this.disposed) throw new Error("SandboxManager disposed");
|
|
43
|
+
if (this.sandbox) {
|
|
44
|
+
// RACE FIX: contextPayload may arrive after the sandbox was created (the
|
|
45
|
+
// "context" event's async packRepository resolves after the first repl() call).
|
|
46
|
+
// Load it into the live sandbox now if still pending.
|
|
47
|
+
if (this.contextPayload !== null && !this.contextLoaded) {
|
|
48
|
+
this.contextLoaded = true;
|
|
49
|
+
try { await this.sandbox.loadContext(this.contextPayload); } catch { /* best-effort */ }
|
|
50
|
+
}
|
|
51
|
+
return this.sandbox;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.initPromise) return this.initPromise;
|
|
55
|
+
|
|
56
|
+
this.initPromise = PythonSandbox.spawn({
|
|
57
|
+
execTimeoutS: this.config.execTimeoutS,
|
|
58
|
+
requestTimeoutMs: this.config.requestTimeoutMs,
|
|
59
|
+
python: this.config.python,
|
|
60
|
+
signal: this.config.signal,
|
|
61
|
+
initTimeoutMs: this.config.sandboxInitTimeoutMs,
|
|
62
|
+
handlers,
|
|
63
|
+
}).then(async (s) => {
|
|
64
|
+
// Load context on first creation if available
|
|
65
|
+
if (this.contextPayload !== null) {
|
|
66
|
+
this.contextLoaded = true;
|
|
67
|
+
try { await s.loadContext(this.contextPayload); } catch { /* best-effort */ }
|
|
68
|
+
}
|
|
69
|
+
this.sandbox = s;
|
|
70
|
+
this.initPromise = null;
|
|
71
|
+
return s;
|
|
72
|
+
}).catch((err) => {
|
|
73
|
+
this.initPromise = null;
|
|
74
|
+
throw err;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return this.initPromise;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Execute code in the sandbox. Serializes concurrent calls via a promise queue
|
|
82
|
+
* (second call waits for first to complete, no interleaving). On failure,
|
|
83
|
+
* nullifies the sandbox so the next call recreates it (death-recreate).
|
|
84
|
+
*/
|
|
85
|
+
async exec(code: string): Promise<ReplResult> {
|
|
86
|
+
return this.execQueued(code);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Execute code after running setup inside the serialized execution slot.
|
|
91
|
+
* Use this for per-invocation handler state that must match the active REPL run.
|
|
92
|
+
*/
|
|
93
|
+
async execWithSetup(code: string, setup: () => void): Promise<ReplResult> {
|
|
94
|
+
return this.execQueued(code, setup);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async execQueued(code: string, setup?: () => void): Promise<ReplResult> {
|
|
98
|
+
if (!this.sandbox) throw new Error("Sandbox not initialized — call getOrCreate first");
|
|
99
|
+
|
|
100
|
+
// Serialize: queue behind any in-flight execution
|
|
101
|
+
const prev = this.execQueue;
|
|
102
|
+
let resolveNext: () => void = () => {};
|
|
103
|
+
this.execQueue = new Promise<void>((r) => { resolveNext = r; });
|
|
104
|
+
this.pendingExecCount++;
|
|
105
|
+
await prev;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const sandbox = this.sandbox;
|
|
109
|
+
if (!sandbox) throw new Error("Sandbox not initialized — previous execution disposed it");
|
|
110
|
+
setup?.();
|
|
111
|
+
return await sandbox.exec(code);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Death-recreate: worker died — nullify so next repl() recreates
|
|
114
|
+
if (this.sandbox) {
|
|
115
|
+
// Best-effort dispose of the dead sandbox
|
|
116
|
+
try { await this.sandbox.dispose(); } catch { /* already dead */ }
|
|
117
|
+
this.sandbox = null;
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
} finally {
|
|
121
|
+
this.pendingExecCount--;
|
|
122
|
+
resolveNext();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** True if the sandbox is alive and not disposed. */
|
|
127
|
+
get isAlive(): boolean {
|
|
128
|
+
return this.sandbox !== null && !this.disposed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** True if a repl() execution is currently in-flight or queued. */
|
|
132
|
+
get isExecuting(): boolean {
|
|
133
|
+
return this.pendingExecCount > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Idempotent dispose. Safe to call multiple times. */
|
|
137
|
+
async dispose(): Promise<void> {
|
|
138
|
+
if (this.disposed) return;
|
|
139
|
+
this.disposed = true;
|
|
140
|
+
await this.sandbox?.dispose();
|
|
141
|
+
this.sandbox = null;
|
|
142
|
+
}
|
|
143
|
+
}
|