@hicaru/pi-rlm 0.1.2 → 0.1.5
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 +2 -1
- package/README.ru.md +2 -1
- package/README.zh-CN.md +2 -1
- package/package.json +4 -4
- package/src/bridge/llm-query.ts +8 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/settings.ts +2 -0
- package/src/context/repomix-context.ts +4 -2
- package/src/core/answer.ts +16 -5
- package/src/core/engine.ts +21 -16
- package/src/core/iteration.ts +7 -1
- package/src/core/pipeline.ts +1 -1
- package/src/core/types.ts +2 -0
- package/src/index.ts +41 -11
- package/src/mode/native-guards.ts +92 -0
- package/src/prompts/system.ts +149 -62
- package/src/sandbox/sandbox-manager.ts +2 -0
- package/src/sandbox/sandbox.ts +11 -1
- package/src/sandbox/worker.py +106 -12
- package/src/text/parsing.ts +2 -2
- package/src/tool/repl-tool.ts +54 -9
- package/src/ui/config-panel.ts +3 -0
package/src/prompts/system.ts
CHANGED
|
@@ -18,8 +18,56 @@ export interface SystemPromptOptions {
|
|
|
18
18
|
readonly recursion?: boolean;
|
|
19
19
|
readonly askUserQuestion?: boolean;
|
|
20
20
|
readonly todo?: boolean;
|
|
21
|
+
readonly pipeline?: boolean;
|
|
22
|
+
readonly maxPromptChars?: number;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
export type ContextKind = "files" | "text";
|
|
26
|
+
|
|
27
|
+
/** "str" (raw string context, e.g. rlm_query children) → text; everything else → files. */
|
|
28
|
+
export function contextKindOf(contextType: string): ContextKind {
|
|
29
|
+
return contextType === "str" ? "text" : "files";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_PROMPT_CAP = 400_000;
|
|
33
|
+
|
|
34
|
+
function promptCapTokensK(maxPromptChars: number): number {
|
|
35
|
+
return Math.round(maxPromptChars / 4_000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Shared glossary entry for the chunked-query helper (headless + native). */
|
|
39
|
+
const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
|
|
40
|
+
"- `llm_query_chunked(text: str, prompt: str, model=None) -> list[str]`: auto-splits `text` into",
|
|
41
|
+
" chunks that fit the sub-LLM prompt cap, fans them out concurrently (order preserved), and",
|
|
42
|
+
" returns one answer per chunk. Use it for ANY text too large for a single `llm_query` — a file",
|
|
43
|
+
" you open()ed, an oversized sub-result, or several concatenated context files.",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Why a file the user mentioned may be missing from `context`. */
|
|
47
|
+
const CONTEXT_EXCLUSION_NOTE =
|
|
48
|
+
" NOTE: files larger than 1MB and gitignored files are NOT in `context` — they exist only on disk.";
|
|
49
|
+
|
|
50
|
+
/** The large-on-disk-file protocol (headless + native). */
|
|
51
|
+
const LARGE_FILE_RULE_LINES: readonly string[] = Object.freeze([
|
|
52
|
+
"**Large on-disk files (profiles, logs, dumps, generated JSON):** files >1MB or gitignored are",
|
|
53
|
+
"absent from `context`. Protocol:",
|
|
54
|
+
'1. Load in Python: `raw = open("dhat-heap.json").read()` — loading into a variable is fine.',
|
|
55
|
+
"2. Deterministic processing in Python (`json.load`, `re`, counting, aggregation) is fine and preferred.",
|
|
56
|
+
"3. The moment you need MEANING from raw text (summarize, explain, find anomalies), do NOT read it",
|
|
57
|
+
" yourself — call `llm_query_chunked(raw, question)`, or slice + `llm_query_batched`.",
|
|
58
|
+
"4. Never print more than a small probe (~2K chars) of raw content.",
|
|
59
|
+
'Example: `parts = llm_query_chunked(raw, "Extract top allocation sites with byte totals")`, then',
|
|
60
|
+
"aggregate `parts` in Python or with one final `llm_query`.",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** Concise native-mode glossary line for the chunked helper (native prompt has a 6K budget). */
|
|
64
|
+
const CHUNKED_GLOSSARY_LINE_NATIVE =
|
|
65
|
+
"- `llm_query_chunked(text, prompt, model=None) -> list[str]` — auto-splits oversized text into cap-sized chunks, fans out concurrently; one answer per chunk.";
|
|
66
|
+
|
|
67
|
+
/** Concise native-mode large-file rule (folds in the context-exclusion note; native 6K budget). */
|
|
68
|
+
const LARGE_FILE_RULE_NATIVE =
|
|
69
|
+
"- 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.";
|
|
70
|
+
|
|
23
71
|
function howToRunCode(): string {
|
|
24
72
|
return [
|
|
25
73
|
"To run Python, write a fenced ```repl``` block. The REPL **persists** across turns. Only",
|
|
@@ -28,29 +76,41 @@ function howToRunCode(): string {
|
|
|
28
76
|
].join(" ");
|
|
29
77
|
}
|
|
30
78
|
|
|
31
|
-
function replGlossary(recursion: boolean, askUserQuestion: boolean, todo: boolean): string {
|
|
32
|
-
const lines = [
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
79
|
+
function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: boolean, todo: boolean, pipeline: boolean): string {
|
|
80
|
+
const lines = ["Available in the REPL:"];
|
|
81
|
+
if (kind === "text") {
|
|
82
|
+
lines.push(
|
|
83
|
+
"- `context`: str — the raw text you must analyze. Probe it with slices",
|
|
84
|
+
" (`print(context[:2000])`), split it programmatically, and delegate large chunks",
|
|
85
|
+
" to sub-LLMs — never dump the whole string into your own output.",
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
lines.push(
|
|
89
|
+
"- `context`: list[dict] — a pre-packed JSON array of every file in the repository. Each dict has",
|
|
90
|
+
" keys: `path` (relative file path, str), `content` (file text, str), `tokens` (estimated count, int).",
|
|
91
|
+
" For large repos, chunk `context` into batches and delegate to sub-LLMs — never dump raw file",
|
|
92
|
+
" bodies into your own output.",
|
|
93
|
+
CONTEXT_EXCLUSION_NOTE,
|
|
94
|
+
"",
|
|
95
|
+
" Chunking example:",
|
|
96
|
+
" ```python",
|
|
97
|
+
" chunk_size = 5",
|
|
98
|
+
" for i in range(0, len(context), chunk_size):",
|
|
99
|
+
" batch = context[i:i+chunk_size]",
|
|
100
|
+
" results = llm_query_batched([",
|
|
101
|
+
" f\"Analyze {f['path']} ({f['tokens']} tok):\\n{f['content']}\"",
|
|
102
|
+
" for f in batch",
|
|
103
|
+
" ])",
|
|
104
|
+
" ```",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
lines.push(
|
|
49
108
|
"- `llm_query(prompt: str, model=None) -> str`: a single sub-LLM completion. Use for extraction,",
|
|
50
109
|
" summarization, or Q&A over a chunk of text.",
|
|
51
110
|
"- `llm_query_batched(prompts: list[str], model=None) -> list[str]`: run several sub-LLM calls",
|
|
52
111
|
" concurrently; output order matches input order.",
|
|
53
|
-
|
|
112
|
+
...CHUNKED_GLOSSARY_LINES,
|
|
113
|
+
);
|
|
54
114
|
if (askUserQuestion) {
|
|
55
115
|
lines.push(
|
|
56
116
|
"- `ask_user_question(questions: list[dict]) -> list[dict]`: pause and present the user",
|
|
@@ -86,11 +146,13 @@ function replGlossary(recursion: boolean, askUserQuestion: boolean, todo: boolea
|
|
|
86
146
|
" handle. Avoid excessive recursive sub-calls when a batched one-shot would suffice.",
|
|
87
147
|
);
|
|
88
148
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
149
|
+
if (pipeline) {
|
|
150
|
+
lines.push(
|
|
151
|
+
"- `advance_phase(phase: str, summary=None) -> str`: transition the root RLM pipeline to the next phase.",
|
|
152
|
+
" Valid phases in order: 'research' → 'blueprint' → 'implement' → 'validate'. You must advance forward",
|
|
153
|
+
" one phase at a time. Only callable at the root depth; returns an error in sub-RLM contexts.",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
94
156
|
lines.push(
|
|
95
157
|
"- `SHOW_VARS() -> str`: list every variable currently in the REPL.",
|
|
96
158
|
'- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
|
|
@@ -99,24 +161,26 @@ function replGlossary(recursion: boolean, askUserQuestion: boolean, todo: boolea
|
|
|
99
161
|
return lines.join("\n");
|
|
100
162
|
}
|
|
101
163
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
164
|
+
function orchestratorAddendum(maxPromptChars: number): string {
|
|
165
|
+
return [
|
|
166
|
+
"As an RLM you are an **orchestrator, not a solver**. After you probe `context` and understand the",
|
|
167
|
+
"task, pause and plan: state how the task decomposes into sub-LLM / REPL steps, then execute one step",
|
|
168
|
+
"at a time, printing a small sample of each result to verify before moving on.",
|
|
169
|
+
"",
|
|
170
|
+
"Your own context window is small. Push every long-context operation — reading, summarizing,",
|
|
171
|
+
"classifying, answering sub-questions — into `llm_query` / `llm_query_batched` instead of pulling raw",
|
|
172
|
+
"text into your own message stream. Conversely, if a Python keyword/regex search over `context` would",
|
|
173
|
+
"already pin the answer, just read it directly. Aggregate the small results back in Python.",
|
|
174
|
+
"",
|
|
175
|
+
`Sub-call budget is finite on two axes: (1) per-prompt capacity — each sub-prompt must stay under ${maxPromptChars.toLocaleString()} characters`,
|
|
176
|
+
`(hard cap; ≈${promptCapTokensK(maxPromptChars)}K tokens), packing a chunk of many items per call; (2) batch fan-out —`,
|
|
177
|
+
"keep batches to roughly ~20 prompts. Fat prompts in small batches beat thousands of tiny prompts.",
|
|
178
|
+
"If the workload exceeds both at once, filter aggressively in Python first, then batch the survivors.",
|
|
179
|
+
"",
|
|
180
|
+
"Reserve your own tokens for high-level decisions: what to ask next, how to combine sub-LM outputs,",
|
|
181
|
+
"when to finalize. Delegate everything else. Do not submit a final answer before inspecting `context`.",
|
|
182
|
+
].join("\n");
|
|
183
|
+
}
|
|
120
184
|
|
|
121
185
|
const INTRO = [
|
|
122
186
|
"You are a Recursive Language Model (RLM): a language model with a prompt and a very important",
|
|
@@ -126,12 +190,14 @@ const INTRO = [
|
|
|
126
190
|
/** Build the full RLM system prompt. */
|
|
127
191
|
export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions = {}): string {
|
|
128
192
|
const recursion = opts.recursion ?? false;
|
|
193
|
+
const kind = contextKindOf(meta.contextType);
|
|
194
|
+
const maxPromptChars = opts.maxPromptChars ?? DEFAULT_PROMPT_CAP;
|
|
129
195
|
const parts = [
|
|
130
196
|
INTRO,
|
|
131
197
|
"",
|
|
132
198
|
howToRunCode(),
|
|
133
199
|
"",
|
|
134
|
-
replGlossary(recursion, opts.askUserQuestion ?? false, opts.todo ?? false),
|
|
200
|
+
replGlossary(kind, recursion, opts.askUserQuestion ?? false, opts.todo ?? false, opts.pipeline ?? false),
|
|
135
201
|
"",
|
|
136
202
|
"REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
|
|
137
203
|
"REPL variables as buffers. Re-print only the slice you need (e.g. `print(result[:500])`); never",
|
|
@@ -140,9 +206,12 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
|
|
|
140
206
|
"Start by probing `context` (print a few lines, count items). Then build up an answer to the query.",
|
|
141
207
|
];
|
|
142
208
|
if (opts.orchestrator ?? true) {
|
|
143
|
-
parts.push("",
|
|
209
|
+
parts.push("", orchestratorAddendum(maxPromptChars));
|
|
144
210
|
}
|
|
145
|
-
|
|
211
|
+
if (kind === "files") {
|
|
212
|
+
parts.push("", LARGE_FILE_RULE_LINES.join("\n"));
|
|
213
|
+
}
|
|
214
|
+
parts.push("", buildMetadataLine(meta, maxPromptChars));
|
|
146
215
|
return parts.join("\n");
|
|
147
216
|
}
|
|
148
217
|
|
|
@@ -152,23 +221,19 @@ function nativeReplGlossary(): string {
|
|
|
152
221
|
"## RLM Native Mode — Persistent Python REPL",
|
|
153
222
|
"",
|
|
154
223
|
"Call `repl({code: \"...\"})` to execute Python in a **persistent** sandbox. Variables, imports,",
|
|
155
|
-
"
|
|
156
|
-
"returned, so always wrap inspections in `print(...)`.",
|
|
224
|
+
"State persists; only `print()` output is returned, so wrap inspections in `print(...)`.",
|
|
157
225
|
"",
|
|
158
226
|
"### REPL Environment",
|
|
159
227
|
"- `context`: list[dict] — every file in the repository. Each dict: `path` (str), `content` (str), `tokens` (int).",
|
|
160
228
|
"- `llm_query(prompt, model=None) -> str` — one-shot sub-LLM. Use for extraction, summarization, Q&A over a chunk.",
|
|
161
229
|
"- `llm_query_batched(prompts, model=None) -> list[str]` — concurrent sub-LLM calls; output order matches input order.",
|
|
162
|
-
|
|
230
|
+
CHUNKED_GLOSSARY_LINE_NATIVE,
|
|
231
|
+
"- `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.",
|
|
163
232
|
"- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
|
|
164
233
|
"",
|
|
165
|
-
"**Choosing between `llm_query` and `rlm_query`:** default to `llm_query`/batched; use `rlm_query` only for iterative sub-tasks.",
|
|
166
234
|
"- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
|
|
167
235
|
"- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
|
|
168
|
-
"- `stage_edit(path, old_text, new_text)
|
|
169
|
-
" Read the file from `context`, compute the exact change in Python, then call",
|
|
170
|
-
" stage_edit once per file. The repl() result will include a STAGED_EDITS JSON block.",
|
|
171
|
-
" The main agent must then call `edit` for each entry verbatim — zero analysis needed.",
|
|
236
|
+
"- `stage_edit(path, old_text, new_text)`: stage exact edits from `context`; apply returned STAGED_EDITS verbatim with edit().",
|
|
172
237
|
"- `answer`: dict `{\"content\": \"\", \"ready\": False}`. To submit: `answer[\"content\"] = \"...\"; answer[\"ready\"] = True`.",
|
|
173
238
|
"",
|
|
174
239
|
"### Orchestrator Pattern",
|
|
@@ -190,15 +255,12 @@ function nativeReplGlossary(): string {
|
|
|
190
255
|
" ])",
|
|
191
256
|
" # aggregate results into a buffer",
|
|
192
257
|
"```",
|
|
193
|
-
|
|
194
|
-
"- If your `context` is small enough (<20 files), you CAN read files directly via `read` / `grep` / `zebra-mcp`.",
|
|
195
|
-
"- For medium/large repos, delegate to sub-LLMs via the REPL.",
|
|
258
|
+
`- Keep sub-prompts under ${DEFAULT_PROMPT_CAP.toLocaleString()} characters (≈${promptCapTokensK(DEFAULT_PROMPT_CAP)}K tokens); batch ~20 prompts per call. Fat prompts in small batches > thousands of tiny prompts.`,
|
|
196
259
|
"",
|
|
197
260
|
"### Choosing Between Tools",
|
|
198
261
|
"| Tool | When |",
|
|
199
262
|
"|------|------|",
|
|
200
263
|
"| `repl({code})` | Need to chunk/delegate `context` to sub-LLMs; need Python scripting; need REPL state across calls |",
|
|
201
|
-
"| `read` / `grep` | Inspect a few specific files directly; small codebase |",
|
|
202
264
|
"| `zebra-mcp` | Semantic search over the codebase |",
|
|
203
265
|
"| `edit` | Modify an existing file with exact text replacement (native Pi flow, visible to all plugins) |",
|
|
204
266
|
"| `write` | Create a new file (native Pi flow, visible to all plugins) |",
|
|
@@ -214,6 +276,7 @@ function nativeReplGlossary(): string {
|
|
|
214
276
|
"4. **Finalize**: For file changes, stage them inside repl() via stage_edit(path, old, new), then relay the STAGED_EDITS from the result to `edit`. For analysis tasks, write a normal message.",
|
|
215
277
|
"",
|
|
216
278
|
"### Task-Specific Patterns",
|
|
279
|
+
LARGE_FILE_RULE_NATIVE,
|
|
217
280
|
"- Architecture/code review: chunk relevant files and delegate summaries or review to `llm_query_batched`.",
|
|
218
281
|
"- Bug investigation: use Python string/regex search over `context`; delegate matching files for analysis.",
|
|
219
282
|
"- If sub-LLM credits are exhausted, report partial results and stop — do not bypass REPL restrictions.",
|
|
@@ -230,7 +293,15 @@ export function buildNativeSystemPrompt(): string {
|
|
|
230
293
|
"║ NATIVE RLM MODE — YOU ARE AN ORCHESTRATOR, NOT A READER ║",
|
|
231
294
|
"╚══════════════════════════════════════════════════════════════════╝",
|
|
232
295
|
"",
|
|
233
|
-
"
|
|
296
|
+
"ENFORCED BY THE RUNTIME (not advisory):",
|
|
297
|
+
"- `read`/`grep` are blocked; bash readers (cat/sed/head/tail/awk/rg) are blocked; bash output is hard-capped at 4K chars.",
|
|
298
|
+
"- repl() stdout returned to you is hard-capped at 4K chars — printing file bodies is USELESS; the text will not reach you.",
|
|
299
|
+
"",
|
|
300
|
+
"DELEGATION RULE: if a step needs MEANING from more than ~4K chars of text, that reading MUST",
|
|
301
|
+
"be an llm_query / llm_query_batched / llm_query_chunked call (rlm_query for iterative",
|
|
302
|
+
"sub-tasks). Deterministic Python (search, count, slice, json, re) over `context` is free and",
|
|
303
|
+
"preferred for lookups. Semantic reading is always delegated.",
|
|
304
|
+
"",
|
|
234
305
|
"All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
|
|
235
306
|
"If sub-LLM credits are exhausted → report the error to the user and stop.",
|
|
236
307
|
"",
|
|
@@ -245,14 +316,30 @@ export function buildNativeSystemPrompt(): string {
|
|
|
245
316
|
].join("\n");
|
|
246
317
|
}
|
|
247
318
|
|
|
319
|
+
/** Soft cap on the static native prompt. Leaves headroom for per-turn context injection
|
|
320
|
+
* without bloating the root model's system prompt. Exceeded → phase-guards.ts fails. */
|
|
321
|
+
export const NATIVE_PROMPT_BUDGET = 6_000;
|
|
322
|
+
|
|
248
323
|
/** Exported for tests — prompt length without context metadata (which is injected separately). */
|
|
249
324
|
export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
|
|
250
325
|
|
|
326
|
+
/** Per-turn last-position reminder for native mode — appended to every context build. */
|
|
327
|
+
export const NATIVE_TURN_REMINDER = [
|
|
328
|
+
"[RLM orchestrator contract — enforced by the runtime, not optional:",
|
|
329
|
+
"repl() stdout to you is hard-capped at 4K chars; read/grep and bash readers are blocked.",
|
|
330
|
+
"Any SEMANTIC reading of file/text content MUST go through llm_query / llm_query_batched /",
|
|
331
|
+
"llm_query_chunked (rlm_query for iterative sub-tasks). Deterministic Python (search, count,",
|
|
332
|
+
"slice, json) is free. Keep your own output to decisions and aggregation.]",
|
|
333
|
+
].join("\n");
|
|
334
|
+
|
|
251
335
|
/** The one-line context metadata, also reused by the per-turn prompt in headless mode. */
|
|
252
|
-
export function buildMetadataLine(meta: PromptMeta): string {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
336
|
+
export function buildMetadataLine(meta: PromptMeta, maxPromptChars = DEFAULT_PROMPT_CAP): string {
|
|
337
|
+
const kind = contextKindOf(meta.contextType);
|
|
338
|
+
const contextDesc = kind === "text"
|
|
339
|
+
? `Your context is a plain string of ${meta.contextChars.toLocaleString()} characters. Use Python slicing to chunk it for sub-LLM delegation.`
|
|
340
|
+
: `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.`;
|
|
341
|
+
const tail = `Each sub-LLM call accepts up to ${maxPromptChars.toLocaleString()} characters (≈${promptCapTokensK(maxPromptChars)}K tokens).`;
|
|
342
|
+
const dist = kind === "files" && meta.contextStats
|
|
256
343
|
? ` 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.`
|
|
257
344
|
: "";
|
|
258
345
|
const body = `${contextDesc} ${tail}${dist}`;
|
|
@@ -13,6 +13,7 @@ export interface SandboxManagerConfig {
|
|
|
13
13
|
readonly requestTimeoutMs: number;
|
|
14
14
|
readonly python: string;
|
|
15
15
|
readonly sandboxInitTimeoutMs: number;
|
|
16
|
+
readonly maxPromptChars: number;
|
|
16
17
|
readonly signal?: AbortSignal;
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -58,6 +59,7 @@ export class SandboxManager {
|
|
|
58
59
|
python: this.config.python,
|
|
59
60
|
signal: this.config.signal,
|
|
60
61
|
initTimeoutMs: this.config.sandboxInitTimeoutMs,
|
|
62
|
+
maxPromptChars: this.config.maxPromptChars,
|
|
61
63
|
handlers,
|
|
62
64
|
}).then(async (s) => {
|
|
63
65
|
// Load context on first creation if available.
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface SandboxOptions {
|
|
|
52
52
|
readonly signal?: AbortSignal;
|
|
53
53
|
/** Worker startup wait before init failure (ms). */
|
|
54
54
|
readonly initTimeoutMs?: number;
|
|
55
|
+
/** Sub-LLM prompt cap (chars) — sizes llm_query_chunked chunks inside the worker. */
|
|
56
|
+
readonly maxPromptChars?: number;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
|
|
@@ -110,9 +112,17 @@ export class PythonSandbox {
|
|
|
110
112
|
this.requestTimeoutMs = opts.requestTimeoutMs ?? 20 * 60_000;
|
|
111
113
|
this.initTimeoutMs = opts.initTimeoutMs ?? 30_000;
|
|
112
114
|
const python = opts.python ?? "python3";
|
|
115
|
+
const workerArgs = [
|
|
116
|
+
"-u", WORKER_PATH,
|
|
117
|
+
"--depth", String(opts.depth ?? 1),
|
|
118
|
+
"--timeout", String(opts.execTimeoutS ?? 600),
|
|
119
|
+
];
|
|
120
|
+
if (opts.maxPromptChars !== undefined) {
|
|
121
|
+
workerArgs.push("--max-prompt-chars", String(opts.maxPromptChars));
|
|
122
|
+
}
|
|
113
123
|
this.proc = spawn(
|
|
114
124
|
python,
|
|
115
|
-
|
|
125
|
+
workerArgs,
|
|
116
126
|
{ stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv() },
|
|
117
127
|
) as ChildProcessWithoutNullStreams;
|
|
118
128
|
|
package/src/sandbox/worker.py
CHANGED
|
@@ -21,6 +21,7 @@ import io
|
|
|
21
21
|
import json
|
|
22
22
|
import os
|
|
23
23
|
import pickle
|
|
24
|
+
import re
|
|
24
25
|
import signal
|
|
25
26
|
import sys
|
|
26
27
|
import time
|
|
@@ -28,10 +29,11 @@ import traceback
|
|
|
28
29
|
from contextlib import contextmanager
|
|
29
30
|
from typing import Any
|
|
30
31
|
|
|
31
|
-
# Capture the REAL
|
|
32
|
+
# Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
|
|
32
33
|
# All protocol writes must go to the real stdout even while user code's prints are captured.
|
|
33
34
|
_REAL_STDOUT = sys.stdout
|
|
34
35
|
_REAL_STDIN = sys.stdin
|
|
36
|
+
_REAL_STDERR = sys.stderr
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
def _builtin(name: str):
|
|
@@ -62,13 +64,37 @@ for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
|
|
|
62
64
|
|
|
63
65
|
RESERVED = frozenset(
|
|
64
66
|
{
|
|
65
|
-
"llm_query", "llm_query_batched", "
|
|
67
|
+
"llm_query", "llm_query_batched", "llm_query_chunked",
|
|
68
|
+
"rlm_query", "rlm_query_batched",
|
|
66
69
|
"advance_phase",
|
|
67
70
|
"ask_user_question", "todo",
|
|
68
71
|
"stage_edit",
|
|
69
72
|
"SHOW_VARS", "answer", "context",
|
|
70
73
|
}
|
|
71
74
|
)
|
|
75
|
+
_CONTEXT_SLOT = re.compile(r"context(_\d+)?\Z")
|
|
76
|
+
|
|
77
|
+
# Sizing for llm_query_chunked: leave room for the instruction and the chunk header.
|
|
78
|
+
_CHUNK_HEADER_OVERHEAD = 64
|
|
79
|
+
_MAX_CHUNK_BATCH = 20 # fan-out per llm_query_batched call (matches prompt guidance)
|
|
80
|
+
_MAX_CHUNKS = 500 # ceiling: above this, force pre-filtering in Python
|
|
81
|
+
_NUDGE_CHARS = 500_000 # str/bytes vars above this trigger a one-time stdout hint
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
85
|
+
"""Split text into <=chunk_chars pieces, preferring newline boundaries."""
|
|
86
|
+
chunks: list[str] = []
|
|
87
|
+
n = len(text)
|
|
88
|
+
start = 0
|
|
89
|
+
while start < n:
|
|
90
|
+
end = min(start + chunk_chars, n)
|
|
91
|
+
if end < n:
|
|
92
|
+
nl = text.rfind("\n", start, end)
|
|
93
|
+
if nl > start:
|
|
94
|
+
end = nl + 1
|
|
95
|
+
chunks.append(text[start:end])
|
|
96
|
+
start = end
|
|
97
|
+
return chunks
|
|
72
98
|
|
|
73
99
|
|
|
74
100
|
class _AnswerDict(dict):
|
|
@@ -92,9 +118,10 @@ def _send(obj: dict[str, Any]) -> None:
|
|
|
92
118
|
|
|
93
119
|
|
|
94
120
|
class Worker:
|
|
95
|
-
def __init__(self, depth: int, exec_timeout_s: float):
|
|
121
|
+
def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int):
|
|
96
122
|
self.depth = depth
|
|
97
123
|
self.exec_timeout_s = exec_timeout_s
|
|
124
|
+
self.max_prompt_chars = max_prompt_chars
|
|
98
125
|
self._rid = 0
|
|
99
126
|
self._final_answer: str | None = None
|
|
100
127
|
self._context_count = 0
|
|
@@ -105,6 +132,7 @@ class Worker:
|
|
|
105
132
|
self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
|
|
106
133
|
self._ctx_payloads: dict[int, Any] = {}
|
|
107
134
|
self._staged_edits: list[dict[str, str]] = []
|
|
135
|
+
self._nudged: set[str] = set()
|
|
108
136
|
self._restore_scaffold()
|
|
109
137
|
|
|
110
138
|
def _capture_answer(self, content: Any) -> None:
|
|
@@ -115,6 +143,7 @@ class Worker:
|
|
|
115
143
|
ns = self.ns
|
|
116
144
|
ns["llm_query"] = self._llm_query
|
|
117
145
|
ns["llm_query_batched"] = self._llm_query_batched
|
|
146
|
+
ns["llm_query_chunked"] = self._llm_query_chunked
|
|
118
147
|
ns["rlm_query"] = self._rlm_query
|
|
119
148
|
ns["rlm_query_batched"] = self._rlm_query_batched
|
|
120
149
|
ns["advance_phase"] = self._advance_phase
|
|
@@ -131,11 +160,14 @@ class Worker:
|
|
|
131
160
|
if cur.get("ready") and self._final_answer is None:
|
|
132
161
|
self._final_answer = str(cur.get("content", ""))
|
|
133
162
|
ns["answer"] = ans
|
|
134
|
-
#
|
|
163
|
+
# Context slots are ordinary variables (RLM paper: the context lives in the
|
|
164
|
+
# environment and the model may transform it in place). Re-inject only if the
|
|
165
|
+
# model deleted the name entirely; mutations and re-binds persist within the run.
|
|
166
|
+
# Resume reloads pristine context; keep derived resume-critical values in user vars.
|
|
135
167
|
for idx, payload in self._ctx_payloads.items():
|
|
136
|
-
ns
|
|
168
|
+
ns.setdefault(f"context_{idx}", payload)
|
|
137
169
|
if 0 in self._ctx_payloads:
|
|
138
|
-
ns
|
|
170
|
+
ns.setdefault("context", self._ctx_payloads[0])
|
|
139
171
|
|
|
140
172
|
def _user_var_names(self) -> list[str]:
|
|
141
173
|
"""User-created variable names — filters builtins, scaffold, and context slots.
|
|
@@ -146,7 +178,7 @@ class Worker:
|
|
|
146
178
|
return [
|
|
147
179
|
k for k in self.ns
|
|
148
180
|
if not k.startswith("_")
|
|
149
|
-
and not
|
|
181
|
+
and not _CONTEXT_SLOT.match(k)
|
|
150
182
|
and k not in RESERVED
|
|
151
183
|
]
|
|
152
184
|
|
|
@@ -174,8 +206,11 @@ class Worker:
|
|
|
174
206
|
msg = json.loads(line)
|
|
175
207
|
if msg.get("type") == "llm_reply" and msg.get("rid") == rid:
|
|
176
208
|
return msg
|
|
177
|
-
#
|
|
178
|
-
|
|
209
|
+
# Stray/late message (e.g. a reply to an earlier timed-out request): skip it.
|
|
210
|
+
print(
|
|
211
|
+
f"[rlm-sandbox] ignoring unexpected message during sub-LLM request: {str(msg)[:200]}",
|
|
212
|
+
file=_REAL_STDERR,
|
|
213
|
+
)
|
|
179
214
|
finally:
|
|
180
215
|
if pause and remaining > 0:
|
|
181
216
|
signal.setitimer(signal.ITIMER_REAL, remaining)
|
|
@@ -196,6 +231,35 @@ class Worker:
|
|
|
196
231
|
return ["Error: malformed batched response"] * len(prompts)
|
|
197
232
|
return [s if isinstance(s, str) else f"Error: {s}" for s in out]
|
|
198
233
|
|
|
234
|
+
def _llm_query_chunked(self, text, prompt: str, model: str | None = None) -> list[str]:
|
|
235
|
+
"""Split oversized text into cap-sized chunks and fan out via llm_query_batched.
|
|
236
|
+
|
|
237
|
+
Returns one answer per chunk, order preserved. No exceptions escape: errors come
|
|
238
|
+
back as "Error: ..." strings per chunk (same contract as llm_query_batched).
|
|
239
|
+
|
|
240
|
+
NOTE: budget uses Python code-point length (len) while the parent-side cap check counts
|
|
241
|
+
UTF-16 units (JS string.length); astral/emoji-heavy text may be marginally larger on the
|
|
242
|
+
parent and get per-chunk rejected. Acceptable trade-off for typical code/log/profile text.
|
|
243
|
+
"""
|
|
244
|
+
text, prompt = str(text), str(prompt)
|
|
245
|
+
if not text:
|
|
246
|
+
return []
|
|
247
|
+
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD
|
|
248
|
+
if budget < 1_000:
|
|
249
|
+
return [f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) — shorten the instruction"]
|
|
250
|
+
chunks = _chunk_text(text, budget)
|
|
251
|
+
total = len(chunks)
|
|
252
|
+
if total > _MAX_CHUNKS:
|
|
253
|
+
return [f"Error: {total} chunks would be needed — filter/slice the text in Python first"]
|
|
254
|
+
results: list[str] = []
|
|
255
|
+
for i in range(0, total, _MAX_CHUNK_BATCH):
|
|
256
|
+
batch = [
|
|
257
|
+
f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
|
|
258
|
+
for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
|
|
259
|
+
]
|
|
260
|
+
results.extend(self._llm_query_batched(batch, model))
|
|
261
|
+
return results
|
|
262
|
+
|
|
199
263
|
def _rlm_query(self, prompt: str, model: str | None = None) -> str:
|
|
200
264
|
r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
|
|
201
265
|
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
@@ -334,6 +398,24 @@ class Worker:
|
|
|
334
398
|
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
335
399
|
signal.signal(signal.SIGALRM, old)
|
|
336
400
|
|
|
401
|
+
def _nudge_lines(self) -> list[str]:
|
|
402
|
+
"""One-time hint for newly created huge raw-text variables (single line).
|
|
403
|
+
|
|
404
|
+
Collapses to one line so it survives headless stdout elision (head 200 + tail 200).
|
|
405
|
+
"""
|
|
406
|
+
names: list[str] = []
|
|
407
|
+
for k in self._user_var_names():
|
|
408
|
+
v = self.ns.get(k)
|
|
409
|
+
if isinstance(v, (str, bytes)) and len(v) > _NUDGE_CHARS and k not in self._nudged:
|
|
410
|
+
self._nudged.add(k)
|
|
411
|
+
names.append(f"{k} ({len(v):,} chars)")
|
|
412
|
+
if not names:
|
|
413
|
+
return []
|
|
414
|
+
return [
|
|
415
|
+
f"[rlm] huge raw-text variable(s): {', '.join(names)} — do NOT analyze them yourself; "
|
|
416
|
+
'delegate with llm_query_chunked(name, "your question") or slice + llm_query_batched.'
|
|
417
|
+
]
|
|
418
|
+
|
|
337
419
|
def execute(self, code: str) -> dict[str, Any]:
|
|
338
420
|
start = time.perf_counter()
|
|
339
421
|
raised = False
|
|
@@ -352,6 +434,15 @@ class Worker:
|
|
|
352
434
|
edits, self._staged_edits = self._staged_edits, []
|
|
353
435
|
answer = self.ns.get("answer")
|
|
354
436
|
answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
|
|
437
|
+
# ready may have been flipped with empty content before content was assigned later
|
|
438
|
+
# in the same block; the dict's current content is the real submission.
|
|
439
|
+
if final is not None and not final.strip() and str(answer_content).strip():
|
|
440
|
+
final = str(answer_content)
|
|
441
|
+
nudges = self._nudge_lines()
|
|
442
|
+
if nudges:
|
|
443
|
+
parts = [stdout] if stdout else []
|
|
444
|
+
parts.extend(nudges)
|
|
445
|
+
stdout = "\n".join(parts) + "\n"
|
|
355
446
|
return {
|
|
356
447
|
"stdout": stdout,
|
|
357
448
|
"stderr": stderr,
|
|
@@ -381,7 +472,7 @@ class Worker:
|
|
|
381
472
|
out, skipped = {}, []
|
|
382
473
|
MAX_VAR_BYTES = 50 * 1024 * 1024
|
|
383
474
|
for k, v in self.ns.items():
|
|
384
|
-
if k.startswith("_") or
|
|
475
|
+
if k.startswith("_") or _CONTEXT_SLOT.match(k) or k in RESERVED or k == "__builtins__":
|
|
385
476
|
continue
|
|
386
477
|
try:
|
|
387
478
|
blob = s.dumps(v)
|
|
@@ -392,7 +483,7 @@ class Worker:
|
|
|
392
483
|
except Exception:
|
|
393
484
|
skipped.append(k)
|
|
394
485
|
if skipped:
|
|
395
|
-
print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=
|
|
486
|
+
print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
|
|
396
487
|
tmp = path + ".tmp"
|
|
397
488
|
with open(tmp, "wb") as f:
|
|
398
489
|
s.dump({"nonce": nonce, "vars": out}, f)
|
|
@@ -420,9 +511,12 @@ def main() -> None:
|
|
|
420
511
|
ap = argparse.ArgumentParser()
|
|
421
512
|
ap.add_argument("--depth", type=int, default=int(os.environ.get("RLM_DEPTH", "1")))
|
|
422
513
|
ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
|
|
514
|
+
ap.add_argument("--max-prompt-chars", type=int,
|
|
515
|
+
default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
|
|
423
516
|
args = ap.parse_args()
|
|
424
517
|
|
|
425
|
-
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout
|
|
518
|
+
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
|
|
519
|
+
max_prompt_chars=args.max_prompt_chars)
|
|
426
520
|
_send({"id": "_init", "ok": True})
|
|
427
521
|
|
|
428
522
|
for raw in _REAL_STDIN:
|
package/src/text/parsing.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* blocks in order; everything else is prose the model uses to think out loud.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const FENCE =
|
|
8
|
+
const FENCE = /(`{3,})[ \t]*repl[ \t]*\r?\n([\s\S]*?)\1/g;
|
|
9
9
|
|
|
10
10
|
/** Return every ```repl``` block body, in document order. */
|
|
11
11
|
export function findReplBlocks(text: string): string[] {
|
|
@@ -13,7 +13,7 @@ export function findReplBlocks(text: string): string[] {
|
|
|
13
13
|
let m: RegExpExecArray | null;
|
|
14
14
|
FENCE.lastIndex = 0;
|
|
15
15
|
while ((m = FENCE.exec(text)) !== null) {
|
|
16
|
-
const code = m[
|
|
16
|
+
const code = m[2] ?? "";
|
|
17
17
|
if (code.trim()) blocks.push(code.replace(/\s+$/, ""));
|
|
18
18
|
}
|
|
19
19
|
return blocks;
|