@hicaru/pi-rlm 0.1.3 → 0.1.6

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,24 +221,20 @@ 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.",
172
- "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. To submit: `answer[\"content\"] = \"...\"; answer[\"ready\"] = True`.",
236
+ "- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
237
+ "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
173
238
  "",
174
239
  "### Orchestrator Pattern",
175
240
  "You are an **orchestrator, not a solver**. After probing `context`, decompose the task into sub-LLM / REPL steps,",
@@ -190,30 +255,28 @@ 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) |",
205
267
  "| `llm_query` (inside repl) | Extract, summarize, or classify a chunk of text |",
206
268
  "| `rlm_query` (inside repl) | Complex sub-task needing iterative reasoning with its own REPL |",
207
269
  "| `todo` (inside repl) | Track multi-step progress visibly to the user |",
208
- "| `stage_edit(path, old, new)` (inside repl) | Sub-agent stages exact edit params; relay STAGED_EDITS to `edit` |",
270
+ "| `stage_edit(path, old, new)` (inside repl) | Stage exact edit params; apply returned IDs with `apply_edits` |",
209
271
  "",
210
272
  "### Workflow",
211
273
  "1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
212
274
  "2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
213
275
  "3. **Aggregate**: Collect results in Python, pass aggregated results to a final `llm_query` or produce the answer directly.",
214
- "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.",
276
+ "4. **Finalize**: For file changes, stage them inside repl() via `stage_edit(path, old, new)`, then apply the returned IDs with `apply_edits({ ids })`. 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,29 +293,50 @@ 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
  "",
237
- "For file changes, use `edit` (modify existing) or `write` (create new) these route through",
238
- "Pi's native tool flow, visible to all plugins with a `+/-` diff preview.",
239
- "",
240
- "When repl() returns a STAGED_EDITS block, apply each entry by calling `edit` verbatim:",
241
- " edit({ path: entry.path, edits: [{ oldText: entry.oldText, newText: entry.newText }] })",
242
- "Do not analyze or modify the parameters — relay them exactly as provided by the sub-agent.",
308
+ "For file changes, prefer `stage_edit()` inside repl(); it returns edit IDs and keeps edit bodies out of your output.",
309
+ "When repl() returns STAGED_EDITS, apply them with `apply_edits({ ids: [\"e1\", ...] })`.",
310
+ "Never re-type file paths, oldText, newText, file bodies, or `answer[\"content\"]` in your own output.",
243
311
  "",
244
312
  nativeReplGlossary(),
245
313
  ].join("\n");
246
314
  }
247
315
 
316
+ /** Soft cap on the static native prompt. Leaves headroom for per-turn context injection
317
+ * without bloating the root model's system prompt. Exceeded → phase-guards.ts fails. */
318
+ export const NATIVE_PROMPT_BUDGET = 6_000;
319
+
248
320
  /** Exported for tests — prompt length without context metadata (which is injected separately). */
249
321
  export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
250
322
 
323
+ /** Per-turn last-position reminder for native mode — appended to every context build. */
324
+ export const NATIVE_TURN_REMINDER = [
325
+ "[RLM orchestrator contract — enforced by the runtime, not optional:",
326
+ "repl() stdout to you is hard-capped at 4K chars; read/grep and bash readers are blocked.",
327
+ "Any SEMANTIC reading of file/text content MUST go through llm_query / llm_query_batched /",
328
+ "llm_query_chunked (rlm_query for iterative sub-tasks). Deterministic Python (search, count,",
329
+ "slice, json) is free. Keep your own output to decisions and aggregation.]",
330
+ ].join("\n");
331
+
251
332
  /** 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
333
+ export function buildMetadataLine(meta: PromptMeta, maxPromptChars = DEFAULT_PROMPT_CAP): string {
334
+ const kind = contextKindOf(meta.contextType);
335
+ const contextDesc = kind === "text"
336
+ ? `Your context is a plain string of ${meta.contextChars.toLocaleString()} characters. Use Python slicing to chunk it for sub-LLM delegation.`
337
+ : `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.`;
338
+ const tail = `Each sub-LLM call accepts up to ${maxPromptChars.toLocaleString()} characters (≈${promptCapTokensK(maxPromptChars)}K tokens).`;
339
+ const dist = kind === "files" && meta.contextStats
256
340
  ? ` 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
341
  : "";
258
342
  const body = `${contextDesc} ${tail}${dist}`;
@@ -0,0 +1,22 @@
1
+ import type { ProposedEdit } from "../sandbox/protocol.ts";
2
+
3
+ export class EditRegistry {
4
+ private readonly edits = new Map<string, ProposedEdit>();
5
+
6
+ registerAll(edits: readonly ProposedEdit[] | undefined): void {
7
+ if (edits === undefined) return;
8
+ for (const edit of edits) this.edits.set(edit.id, edit);
9
+ }
10
+
11
+ get(id: string): ProposedEdit | undefined {
12
+ return this.edits.get(id);
13
+ }
14
+
15
+ delete(id: string): boolean {
16
+ return this.edits.delete(id);
17
+ }
18
+
19
+ clear(): void {
20
+ this.edits.clear();
21
+ }
22
+ }
@@ -27,6 +27,7 @@ export interface LlmReply {
27
27
  export type ParentMessage = WorkerRequest | LlmReply;
28
28
 
29
29
  export interface ProposedEdit {
30
+ readonly id: string;
30
31
  readonly path: string;
31
32
  readonly oldText: string;
32
33
  readonly newText: string;
@@ -13,7 +13,9 @@ 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;
18
+ readonly onSandboxDiscarded?: () => void;
17
19
  }
18
20
 
19
21
  export class SandboxManager {
@@ -58,6 +60,7 @@ export class SandboxManager {
58
60
  python: this.config.python,
59
61
  signal: this.config.signal,
60
62
  initTimeoutMs: this.config.sandboxInitTimeoutMs,
63
+ maxPromptChars: this.config.maxPromptChars,
61
64
  handlers,
62
65
  }).then(async (s) => {
63
66
  // Load context on first creation if available.
@@ -116,6 +119,7 @@ export class SandboxManager {
116
119
  try { await this.sandbox.dispose(); } catch { /* already dead */ }
117
120
  this.sandbox = null;
118
121
  this.contextLoaded = false;
122
+ this.config.onSandboxDiscarded?.();
119
123
  }
120
124
  throw err;
121
125
  } finally {
@@ -139,7 +143,10 @@ export class SandboxManager {
139
143
  if (this.disposed) return;
140
144
  this.disposed = true;
141
145
  await this.sandbox?.dispose();
142
- this.sandbox = null;
143
- this.contextLoaded = false;
146
+ if (this.sandbox !== null) {
147
+ this.sandbox = null;
148
+ this.contextLoaded = false;
149
+ this.config.onSandboxDiscarded?.();
150
+ }
144
151
  }
145
152
  }
@@ -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