@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.
@@ -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
- "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
- " ```",
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
- 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
- );
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
- 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");
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("", ORCHESTRATOR_ADDENDUM);
209
+ parts.push("", orchestratorAddendum(maxPromptChars));
144
210
  }
145
- parts.push("", buildMetadataLine(meta));
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
- "and state survive across calls you build up results incrementally. Only `print()` output is",
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
- "- `rlm_query(prompt, model=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning.",
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) -> str`: stage a file edit computed inside the REPL.",
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
- "- Keep sub-prompts ~100K characters; batch ~20 prompts per call. Fat prompts in small batches > thousands of tiny prompts.",
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
- "ABSOLUTE RESTRICTION: Do NOT use `read` or `grep` to access files.",
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 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.`;
254
- const tail = "Each sub-LLM call can handle roughly ~100k tokens at once.";
255
- const dist = meta.contextStats
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.
@@ -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
- ["-u", WORKER_PATH, "--depth", String(opts.depth ?? 1), "--timeout", String(opts.execTimeoutS ?? 600)],
125
+ workerArgs,
116
126
  { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv() },
117
127
  ) as ChildProcessWithoutNullStreams;
118
128
 
@@ -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 stdout/stdin before exec() redirects sys.stdout into a buffer.
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", "rlm_query", "rlm_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
- # Restore context slots from immutable originals so REPL mutations don't persist.
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[f"context_{idx}"] = payload
168
+ ns.setdefault(f"context_{idx}", payload)
137
169
  if 0 in self._ctx_payloads:
138
- ns["context"] = self._ctx_payloads[0]
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 k.startswith("context_")
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
- # The parent only ever sends our reply mid-exec; anything else is a protocol error.
178
- raise RuntimeError(f"unexpected parent message during sub-LLM request: {msg!r}")
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 k.startswith("context") or k in RESERVED or k == "__builtins__":
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=sys.stderr)
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:
@@ -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 = /```[ \t]*repl[ \t]*\r?\n([\s\S]*?)```/g;
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[1] ?? "";
16
+ const code = m[2] ?? "";
17
17
  if (code.trim()) blocks.push(code.replace(/\s+$/, ""));
18
18
  }
19
19
  return blocks;