@hicaru/pi-rlm 0.1.1 → 0.1.2

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 CHANGED
@@ -1,25 +1,40 @@
1
+ # pi-rlm — Save 99% tokens, Recursive Language Model (RLM) for the Pi
2
+
1
3
  <div align="center">
2
4
 
3
- <img src="../../assets/hero.png" alt="pi-rlm">
5
+ **Recursive Language Models (RLMs)**, implemented natively as a Pi extension —
6
+ FULLY LOCAL.
4
7
 
5
8
  </div>
6
9
 
7
- <div align="center">
10
+ ## Install
8
11
 
9
- <sub>
10
- **English** &nbsp;·&nbsp; <a href="README.zh-CN.md">中文</a> &nbsp;·&nbsp; <a href="README.ru.md">Русский</a>
11
- </sub>
12
+ ```bash
13
+ pi install npm:@hicaru/pi-rlm
14
+ ```
12
15
 
13
- </div>
16
+ To remove it later:
14
17
 
15
- ---
18
+ ```bash
19
+ pi uninstall npm:@hicaru/pi-rlm
20
+ ```
21
+
22
+ Then run `/reload` or restart Pi. Verify with `pi list` that the package appears in
23
+ `settings.packages`, and check that `/rlm`, `/rlm-config`, and `/rlm-stop` appear under **[Extensions]**.
24
+
25
+ <div align="center">
26
+
27
+ <a href="https://arxiv.org/abs/2512.24601"><img src="../../assets/hero.png" alt="pi-rlm"></a>
16
28
 
17
- # pi-rlm Recursive Language Models for the [Pi](https://github.com/earendil-works) Coding Agent
29
+ <sub>Modeled on the method in the RLM paper, reimplemented natively for Pi.</sub>
30
+
31
+ </div>
18
32
 
19
33
  <div align="center">
20
34
 
21
- **Recursive Language Models (RLMs)**, implemented natively as a Pi extension —
22
- no extra servers, no Docker, no sockets.
35
+ <sub>
36
+ **English** &nbsp;·&nbsp; <a href="README.zh-CN.md">中文</a> &nbsp;·&nbsp; <a href="README.ru.md">Русский</a>
37
+ </sub>
23
38
 
24
39
  </div>
25
40
 
@@ -46,59 +61,31 @@ sub-LLM calls, hence the name.
46
61
  - Hard sub-problems **recurse** into child RLMs via `rlm_query` (depth-capped).
47
62
  - Everything runs **in-process** — the only external process is one local `python3` worker.
48
63
 
49
- > This is a Pi-plugin reimplementation of the RLM method (see the [RLM paper](https://arxiv.org/abs/2512.24601)
50
- > and the [Python `rlm` library](https://github.com/alexzhang13/rlm-minimal)). It is **not** the Python library.
64
+ > This is a Pi-plugin reimplementation of the RLM method (see the [RLM paper](https://arxiv.org/abs/2512.24601)).
65
+ > It is **not** the Python library.
51
66
 
52
67
  ## How it works
53
68
 
54
69
  ```
55
- pi process (TypeScript)
56
- ├─ /rlm ──► engine drives the SMART (root) model turn-by-turn (writes ```repl``` Python)
57
- │ │ each turn: parse repl blocks ──► run in sandbox ──► feed stdout back
58
-
59
- ├─ bridge ── llm_query / llm_query_batched ──► WORKER model (serverless, in-process)
60
- │ rlm_query ──► recursive child RLM (own sandbox), depth-capped
61
- ├─ AgentTree ──► live agent/subagent tree above the editor (roles, depth, cost, tokens)
62
- └─ PythonSandbox ── `python3 worker.py` ──[JSONL over stdio, bidirectional]── persistent REPL
63
- ```
64
-
65
- - **No servers, no sockets, no Docker.** The only external process is one local `python3` sandbox.
66
- When sandbox code calls `llm_query`, the worker writes a request on stdout and blocks on stdin;
67
- Pi services it in-process and writes the reply back. **Provider API keys never enter the sandbox.**
68
- - The sandbox exposes `context`, `llm_query`, `llm_query_batched`, `rlm_query`,
69
- `rlm_query_batched`, `SHOW_VARS()`, `todo()`, `ask_user_question()`, and an `answer` dict.
70
- The model submits its final result by setting `answer["ready"] = True`.
71
-
72
- ## Install
73
-
74
- `pi-rlm` is a Pi package. Pi provides the `@earendil-works/pi-*` and `typebox` peer
75
- dependencies; do **not** install a separate copy of them into this package. Requires
76
- `python3` on `PATH` (standard library only).
77
-
78
- Recommended local install while developing:
79
-
80
- ```bash
81
- pi install /path/to/this-repo/pi-plugin/rlm
82
- ```
83
-
84
- Published npm package install:
85
-
86
- ```bash
87
- npm publish # e.g. as @<you>/pi-rlm
88
- pi install npm:@<you>/pi-rlm
70
+ ┌─────────────────────────┐
71
+ │ Pi coding agent │
72
+ └────────────┬────────────┘
73
+ /rlm
74
+
75
+ ┌─────────────────────────┐ spawns ┌────────────────────┐
76
+ │ Smart model (root) │ ────────► │ Worker models │
77
+ │ drives a Python REPL │ ◄──────── │ (cheap, fast) │
78
+ └────────────┬────────────┘ results └────────────────────┘
79
+ │ recursion (depth-capped)
80
+ └────► child RLMs ────► (same loop)
81
+
82
+ All local · one python3 process · no servers
89
83
  ```
90
84
 
91
- > **Git installs** require the package manifest to live at the installed repository root.
92
- > For monorepo subdirectories like this one, prefer the local-path or npm flow above.
93
-
94
- If you previously copied the extension folder directly, remove it so it does not shadow the package:
95
-
96
- ```bash
97
- rm -rf ~/.pi/agent/extensions/rlm
98
- ```
99
-
100
- Then run `/reload` or restart Pi. Verify with `pi list` that the package appears in
101
- `settings.packages`, and check that `/rlm`, `/rlm-config`, and `/rlm-stop` appear under **[Extensions]**.
85
+ - The **smart model** thinks and writes Python in a REPL.
86
+ - The **worker models** do the heavy lifting (read, summarize, classify).
87
+ - Hard sub-problems **recurse** into child RLMs.
88
+ - Everything runs **fully local** your API keys never leave Pi.
102
89
 
103
90
  ## Commands
104
91
 
@@ -129,6 +116,8 @@ These functions are injected into the model's Python namespace inside the REPL:
129
116
  | `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent recursive child RLMs |
130
117
  | `todo` | `(action, **kwargs) -> str` | Task list: `create`/`update`/`list`/`get`/`delete`/`clear` |
131
118
  | `ask_user_question` | `(questions) -> list[dict]` | Ask the user structured questions (depth 0 only) |
119
+ | `stage_edit` | `(path, old_text, new_text) -> str` | Stage a file edit; relayed to the host's native edit flow |
120
+ | `advance_phase` | `(phase, summary=None) -> str` | Move the root pipeline to a new phase |
132
121
  | `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
133
122
  | `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
134
123
 
@@ -138,15 +127,18 @@ These functions are injected into the model's Python namespace inside the REPL:
138
127
  |---|---|---|
139
128
  | Smart model | Pi's active model | the root orchestrator |
140
129
  | Worker model | cheapest available | answers `llm_query` |
141
- | Max recursion depth | `4` | `rlm_query` past this falls back to `llm_query` |
142
- | Max iterations | `30` | turns before the engine finalizes |
143
- | Budget ceiling | none | stops the whole tree when USD spend exceeds this |
144
- | Max consecutive errors | `5` | stops after N consecutive error turns |
145
- | REPL block timeout | `120s` | per-`repl`-block wall-clock (SIGALRM in the worker) |
146
- | Max concurrent sub-calls | `4` | pool size for `*_batched` |
147
- | Orchestrator addendum | on | "delegate, don't solve" guidance |
148
- | Trajectory compaction | on (0.85) | summarize history when it nears the context window |
149
- | `yolo` | off | apply proposed edits immediately, skipping the review popup |
130
+ | Max recursion depth | `4` | `rlm_query` past this degrades to plain `llm_query` |
131
+ | Max iterations | `30` | root REPL turns before RLM asks for a final answer |
132
+ | REPL block timeout (s) | `120` | wall-clock limit for one Python REPL block (SIGALRM) |
133
+ | Max concurrent sub-calls | `4` | concurrency pool size for `*_batched` |
134
+ | Budget ceiling (USD) | none | total spend cap for the whole recursive tree |
135
+ | Wall-clock ceiling (min) | none | total runtime cap for the whole recursive tree |
136
+ | Token ceiling | none | total input+output token cap for the whole recursive tree |
137
+ | Max consecutive errors | `5` | stop after N consecutive failing turns (none = off) |
138
+ | Orchestrator addendum | on | divide-and-conquer guidance in the root system prompt |
139
+ | Trajectory compaction | on (0.65) | summarize old turns when history nears the context window |
140
+ | Root model output cap (tok) | `16384` | max output tokens per root-model turn |
141
+ | Sandbox init timeout | `30000` ms | how long to wait for the Python worker to start |
150
142
  | `askUserQuestion` | on | expose `ask_user_question()` to the model |
151
143
  | `todo` | on | expose `todo()` to the model |
152
144
 
@@ -155,13 +147,6 @@ These functions are injected into the model's Python namespace inside the REPL:
155
147
  > defaults (depth 4, conc 4) that's 4³ = 64 in the pathological case. Budget and error
156
148
  > caps (above) bound total spend regardless of fan-out.
157
149
 
158
- ## Run logs
159
-
160
- - **Run logs** (`runLog`): always-on by default. Each run writes a JSONL trail to `.rlm/runs/`
161
- (default), capped at `maxRuns` (50). Supports **snapshots** (`sandbox.pkl`) and **resume**
162
- of interrupted runs via `/rlm-resume`. Snapshots are protected by a per-session `nonce`
163
- to prevent cross-session replay.
164
-
165
150
  ## Security
166
151
 
167
152
  - **Key isolation**: provider keys live only in TypeScript (`AuthStorage`); the sandbox
@@ -177,56 +162,3 @@ These functions are injected into the model's Python namespace inside the REPL:
177
162
  SIGALRM timeout + parent watchdog (SIGKILL on hang); budget / token / timeout /
178
163
  consecutive-error caps.
179
164
  - **Trust**: project-local install requires Pi project trust.
180
-
181
- ## Project layout
182
-
183
- ```
184
- src/
185
- sandbox/ worker.py + JSONL stdio driver (PythonSandbox) · protocol.ts · sandbox-manager.ts
186
- bridge/ model.ts (one-shot completion) · llm-query.ts · rlm-query.ts (recursion)
187
- core/ engine.ts (the loop) · iteration · limits · answer · compaction · pipeline · types
188
- prompts/ system + per-turn prompts (ported from the Python reference)
189
- text/ parsing (repl blocks) · tokens · preview · edits
190
- state/ reads/writes · resume · paths · rows
191
- tool/ repl-tool · rlm-events · aggregator · propose-edits · emitter-listener
192
- config/ defaults · settings (rlm.json persistence + validation)
193
- context/ repomix-based repository packing + caching
194
- ui/ tree-widget · status · model-picker · config-panel · intro · theme
195
- commands/ rlm · rlm-config
196
- mode/ rlm-mode (controller) · input-router
197
- patch/ apply · popup · index
198
- util/ errors · concurrency
199
- test/ phase1–phase9 · native-smoke · native-mode · helpers
200
- ```
201
-
202
- ## Tests
203
-
204
- Runtime is **Bun** (`bun install`, `bun run …` — never npm/pnpm/yarn).
205
-
206
- ```bash
207
- bun run test/phase1.ts # sandbox: exec, persistence, key isolation, timeout kill
208
- bun run test/phase4.ts # recursion depth-cap logic (no tokens)
209
- bun run test/phase5.ts # live agent tree rendering (no tokens)
210
- RLM_TEST_LIVE=1 bun run test/phase2.ts # real llm_query through the sandbox
211
- RLM_TEST_LIVE=1 bun run test/phase3.ts # real end-to-end /rlm over a file context
212
- RLM_TEST_LIVE=1 bun run test/phase4.ts # engine solves a 20-doc needle-in-haystack
213
- ```
214
-
215
- ## Background
216
-
217
- Modeled on the Python reference [`rlm`](https://github.com/alexzhang13/rlm-minimal) and the
218
- method in the [RLM paper](https://arxiv.org/abs/2512.24601), reimplemented natively for Pi.
219
-
220
- If you use this in your research, please cite the original RLM work:
221
-
222
- ```bibtex
223
- @misc{zhang2026recursivelanguagemodels,
224
- title={Recursive Language Models},
225
- author={Alex L. Zhang and Tim Kraska and Omar Khattab},
226
- year={2026},
227
- eprint={2512.24601},
228
- archivePrefix={arXiv},
229
- primaryClass={cs.AI},
230
- url={https://arxiv.org/abs/2512.24601},
231
- }
232
- ```
package/README.ru.md CHANGED
@@ -19,7 +19,7 @@
19
19
  <div align="center">
20
20
 
21
21
  **Рекурсивные языковые модели (RLMs)**, реализованные нативно как расширение Pi —
22
- без дополнительных серверов, Docker или сокетов.
22
+ ПОЛНОСТЬЮ ЛОКАЛЬНО.
23
23
 
24
24
  </div>
25
25
 
@@ -36,8 +36,8 @@
36
36
  - Сложные подзадачи **рекурсивно** передаются в дочерние RLM через `rlm_query` (с ограничением глубины).
37
37
  - Все работает **in-process** — единственным внешним процессом является локальный worker `python3`.
38
38
 
39
- > This is a Pi-plugin reimplementation of the RLM method (see the [RLM paper](https://arxiv.org/abs/2512.24601)
40
- > and the [Python `rlm` library](https://github.com/alexzhang13/rlm-minimal)). It is **not** the Python library.
39
+ > This is a Pi-plugin reimplementation of the RLM method (see the [RLM paper](https://arxiv.org/abs/2512.24601)).
40
+ > It is **not** the Python library.
41
41
 
42
42
  ## Как это работает
43
43
 
@@ -181,7 +181,7 @@ RLM_TEST_LIVE=1 bun run test/phase4.ts # engine solves a 20-doc needle-in-hays
181
181
 
182
182
  ## Общая информация
183
183
 
184
- Реализовано на основе эталонного проекта [`rlm`](https://github.com/alexzhang13/rlm-minimal) на Python и метода из [статьи RLM](https://arxiv.org/abs/2512.24601), с нативной переработкой для Pi.
184
+ Реализовано на основе метода из [статьи RLM](https://arxiv.org/abs/2512.24601), с нативной переработкой для Pi.
185
185
 
186
186
  Если вы используете этот проект в своих исследованиях, пожалуйста, сошлитесь на оригинальную работу RLM:
187
187
 
package/README.zh-CN.md CHANGED
@@ -19,7 +19,7 @@
19
19
  <div align="center">
20
20
 
21
21
  **递归语言模型 (RLMs)** 作为 Pi 扩展原生实现 ——
22
- 无需额外服务器,无需 Docker,无需 socket。
22
+ 完全本地。
23
23
 
24
24
  </div>
25
25
 
@@ -36,8 +36,8 @@
36
36
  - 困难的子问题通过 `rlm_query` **递归**到子 RLM 中(设有深度限制)。
37
37
  - 所有内容均**在进程内**运行 —— 唯一的外部进程是一个本地的 `python3` worker。
38
38
 
39
- > 这是 RLM 方法的 Pi 插件重新实现(参见 [RLM 论文](https://arxiv.org/abs/2512.24601)
40
- > [Python `rlm` 库](https://github.com/alexzhang13/rlm-minimal))。它**不是**那个 Python 库。
39
+ > 这是 RLM 方法的 Pi 插件重新实现(参见 [RLM 论文](https://arxiv.org/abs/2512.24601))。
40
+ > 它**不是**那个 Python 库。
41
41
 
42
42
  ## 工作原理
43
43
 
@@ -201,8 +201,7 @@ RLM_TEST_LIVE=1 bun run test/phase4.ts # 引擎解决 20 个文档的“大海
201
201
 
202
202
  ## 背景
203
203
 
204
- 基于 Python 参考实现 [`rlm`](https://github.com/alexzhang13/rlm-minimal)
205
- [RLM 论文](https://arxiv.org/abs/2512.24601) 中的方法,为 Pi 原生重新实现。
204
+ 基于 [RLM 论文](https://arxiv.org/abs/2512.24601) 中的方法,为 Pi 原生重新实现。
206
205
 
207
206
  如果您在研究中使用此项目,请引用原始 RLM 工作:
208
207
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
- "description": "Recursive Language Model (RLM) for the Pi coding agent — native, server-less.",
5
+ "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
6
6
  "license": "MIT",
7
7
  "author": "hicaru",
8
8
  "repository": {
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ import { packRepository, formatForLLM, serializeForSandbox } from "./context/rep
16
16
  import { buildNativeSystemPrompt } from "./prompts/system.ts";
17
17
  import { errorMessage } from "./util/errors.ts";
18
18
 
19
- const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep", "bash"]));
19
+ const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
20
20
 
21
21
  export default function rlmExtension(pi: ExtensionAPI): void {
22
22
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
@@ -28,6 +28,23 @@ export default function rlmExtension(pi: ExtensionAPI): void {
28
28
  python: config.python,
29
29
  sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
30
30
  });
31
+ let packedContextText: string | undefined;
32
+ let contextPackPromise: Promise<string | undefined> | undefined;
33
+ const ensureRepositoryContext = async (cwd: string): Promise<string | undefined> => {
34
+ if (packedContextText !== undefined && sandboxManager.contextPayload !== null) return packedContextText;
35
+ contextPackPromise ??= packRepository(cwd)
36
+ .then((result) => {
37
+ if (!result.ok) {
38
+ console.warn(`[rlm] repository context pack failed: ${result.error}`);
39
+ return undefined;
40
+ }
41
+ sandboxManager.contextPayload = serializeForSandbox(result.value);
42
+ packedContextText = formatForLLM(result.value);
43
+ return packedContextText;
44
+ })
45
+ .finally(() => { contextPackPromise = undefined; });
46
+ return contextPackPromise;
47
+ };
31
48
 
32
49
  // Load persisted settings async — applied before session_start handler reads controller state
33
50
  const settingsReady = loadSettings()
@@ -81,6 +98,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
81
98
  getWorkerModel: () => controller.resolveModels(ctx)?.worker,
82
99
  registry: ctx.modelRegistry,
83
100
  config: controller.config,
101
+ ensureContext: async () => {
102
+ const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
103
+ if (contextText === undefined) throw new Error("repository context could not be loaded into RLM sandbox");
104
+ },
84
105
  }));
85
106
  } catch { /* re-registration on provider change — ignore if already registered */ }
86
107
  }
@@ -107,14 +128,13 @@ export default function rlmExtension(pi: ExtensionAPI): void {
107
128
 
108
129
  // Inject repository context as a compact listing (once per session, only when RLM is enabled)
109
130
  if (controller.enabled && !contextInjected) {
110
- contextInjected = true;
111
131
  const cwd = ctx.cwd ?? process.cwd();
112
- const result = await packRepository(cwd);
113
- if (result.ok) {
114
- const contextText = formatForLLM(result.value);
132
+ const contextText = await ensureRepositoryContext(cwd);
133
+ if (contextText !== undefined) {
134
+ contextInjected = true;
115
135
  const instruction = [
116
- "ANALYZE THIS REPOSITORY using repl({code}) — read/grep/bash are DISABLED.",
117
- `Total: ${result.value.totalFiles} files, ${result.value.totalChars.toLocaleString()} chars must use repl().`,
136
+ "ANALYZE THIS REPOSITORY using repl({code}) — read/grep are DISABLED.",
137
+ "Repository contents are pre-loaded in the Python REPL `context` variable.",
118
138
  "Chunk context via Python, delegate to llm_query. If credits exhausted → report and stop.",
119
139
  "",
120
140
  ].join("\n");
@@ -124,9 +144,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
124
144
  timestamp: 0,
125
145
  } as (typeof filtered)[number];
126
146
 
127
- // Store context for sandbox loading on first repl() call
128
- sandboxManager.contextPayload = serializeForSandbox(result.value);
129
-
130
147
  return { messages: [contextMsg, ...filtered] };
131
148
  }
132
149
  }
@@ -141,7 +158,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
141
158
 
142
159
  // ── Tool restriction: block analysis tools when RLM is ON ──
143
160
  // `edit`/`write` stay unblocked so the agent modifies files through Pi's native
144
- // tool flow (visible to all plugins, +/- diff preview). Only read/grep/bash are
161
+ // tool flow (visible to all plugins, +/- diff preview). Only read/grep are
145
162
  // blocked — the repository is pre-loaded in the REPL `context` variable.
146
163
  pi.on("tool_call", async (event) => {
147
164
  if (!controller.enabled) return;
@@ -158,5 +175,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
158
175
  controller.abort();
159
176
  await sandboxManager.dispose();
160
177
  contextInjected = false;
178
+ packedContextText = undefined;
179
+ contextPackPromise = undefined;
180
+ sandboxManager.contextPayload = null;
161
181
  });
162
182
  }
@@ -230,7 +230,7 @@ export function buildNativeSystemPrompt(): string {
230
230
  "║ NATIVE RLM MODE — YOU ARE AN ORCHESTRATOR, NOT A READER ║",
231
231
  "╚══════════════════════════════════════════════════════════════════╝",
232
232
  "",
233
- "ABSOLUTE RESTRICTION: Do NOT use `read`, `grep`, or `bash` to access files.",
233
+ "ABSOLUTE RESTRICTION: Do NOT use `read` or `grep` to access files.",
234
234
  "All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
235
235
  "If sub-LLM credits are exhausted → report the error to the user and stop.",
236
236
  "",
@@ -35,8 +35,7 @@ export class SandboxManager {
35
35
  * given handlers. Subsequent calls return the existing sandbox immediately.
36
36
  * Deduplicates concurrent calls via initPromise.
37
37
  *
38
- * The caller is responsible for calling loadContext() on the returned sandbox
39
- * before first use.
38
+ * If contextPayload is set, it is loaded before the sandbox is returned.
40
39
  */
41
40
  async getOrCreate(handlers: Partial<SubLlmHandlers>): Promise<PythonSandbox> {
42
41
  if (this.disposed) throw new Error("SandboxManager disposed");
@@ -45,8 +44,8 @@ export class SandboxManager {
45
44
  // "context" event's async packRepository resolves after the first repl() call).
46
45
  // Load it into the live sandbox now if still pending.
47
46
  if (this.contextPayload !== null && !this.contextLoaded) {
47
+ await this.sandbox.loadContext(this.contextPayload);
48
48
  this.contextLoaded = true;
49
- try { await this.sandbox.loadContext(this.contextPayload); } catch { /* best-effort */ }
50
49
  }
51
50
  return this.sandbox;
52
51
  }
@@ -61,15 +60,16 @@ export class SandboxManager {
61
60
  initTimeoutMs: this.config.sandboxInitTimeoutMs,
62
61
  handlers,
63
62
  }).then(async (s) => {
64
- // Load context on first creation if available
63
+ // Load context on first creation if available.
65
64
  if (this.contextPayload !== null) {
65
+ await s.loadContext(this.contextPayload);
66
66
  this.contextLoaded = true;
67
- try { await s.loadContext(this.contextPayload); } catch { /* best-effort */ }
68
67
  }
69
68
  this.sandbox = s;
70
69
  this.initPromise = null;
71
70
  return s;
72
71
  }).catch((err) => {
72
+ this.contextLoaded = false;
73
73
  this.initPromise = null;
74
74
  throw err;
75
75
  });
@@ -115,6 +115,7 @@ export class SandboxManager {
115
115
  // Best-effort dispose of the dead sandbox
116
116
  try { await this.sandbox.dispose(); } catch { /* already dead */ }
117
117
  this.sandbox = null;
118
+ this.contextLoaded = false;
118
119
  }
119
120
  throw err;
120
121
  } finally {
@@ -139,5 +140,6 @@ export class SandboxManager {
139
140
  this.disposed = true;
140
141
  await this.sandbox?.dispose();
141
142
  this.sandbox = null;
143
+ this.contextLoaded = false;
142
144
  }
143
145
  }
@@ -161,6 +161,7 @@ export class PythonSandbox {
161
161
  try {
162
162
  path = await this.writeContextFile(payload, isJson);
163
163
  const res = await this.request({ type: "load_context", path, index, json: isJson });
164
+ if (!res.ok) throw new Error(res.error ?? "load_context failed");
164
165
  return res.index ?? 0;
165
166
  } finally {
166
167
  if (path) await unlink(path).catch(() => {});
@@ -183,6 +184,7 @@ export class PythonSandbox {
183
184
 
184
185
  async exec(code: string): Promise<ReplResult> {
185
186
  const res = await this.request({ type: "exec", code });
187
+ if (!res.ok) throw new Error(res.error ?? "exec failed");
186
188
  return {
187
189
  stdout: res.stdout ?? "",
188
190
  stderr: res.stderr ?? "",
@@ -212,6 +214,7 @@ export class PythonSandbox {
212
214
  async snapshot(path: string, nonce: string): Promise<boolean> {
213
215
  try {
214
216
  const res = await this.request({ type: "snapshot", path, nonce });
217
+ if (!res.ok) return false;
215
218
  return res.ok;
216
219
  } catch {
217
220
  return false;
@@ -222,6 +225,7 @@ export class PythonSandbox {
222
225
  async restore(path: string, nonce: string): Promise<boolean> {
223
226
  try {
224
227
  const res = await this.request({ type: "restore", path, nonce });
228
+ if (!res.ok) return false;
225
229
  return res.ok;
226
230
  } catch {
227
231
  return false;
@@ -24,7 +24,7 @@ import { previewText } from "../text/preview.ts";
24
24
  import { mapPool } from "../util/concurrency.ts";
25
25
  import { LimitGuard } from "../core/limits.ts";
26
26
  import { checkResourceLimits } from "../core/resource-limits.ts";
27
- import type { RlmConfig, Sampling } from "../core/types.ts";
27
+ import type { InteractiveDeps, RlmConfig, Sampling } from "../core/types.ts";
28
28
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
29
29
  import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
30
30
  import type { ProposedEdit, ReplResult } from "../sandbox/protocol.ts";
@@ -64,12 +64,14 @@ class NativeBridgeState {
64
64
  currentParentId: string | undefined;
65
65
  currentDepth = 0;
66
66
  currentLimits: LimitGuard | null = null;
67
+ currentInteractive: InteractiveDeps | null = null;
67
68
 
68
- swap(inv: { emitter: RlmEmitter; parentId?: string; depth: number; limits: LimitGuard }): void {
69
+ swap(inv: { emitter: RlmEmitter; parentId?: string; depth: number; limits: LimitGuard; interactive: InteractiveDeps }): void {
69
70
  this.currentEmitter = inv.emitter;
70
71
  this.currentParentId = inv.parentId;
71
72
  this.currentDepth = inv.depth;
72
73
  this.currentLimits = inv.limits;
74
+ this.currentInteractive = inv.interactive;
73
75
  }
74
76
 
75
77
  buildLlmHandlers(deps: {
@@ -224,6 +226,8 @@ class NativeBridgeState {
224
226
  maxTokens: deps.config.maxTokens,
225
227
  maxErrors: deps.config.maxErrors,
226
228
  },
229
+ onTodo: state.currentInteractive?.onTodo,
230
+ onAskUserQuestion: state.currentInteractive?.onAskUserQuestion,
227
231
  });
228
232
 
229
233
  try {
@@ -277,6 +281,7 @@ export interface ReplToolDeps {
277
281
  readonly config: RlmConfig;
278
282
  readonly signal?: AbortSignal;
279
283
  readonly onUsage?: (usage: Usage, role: "sub") => void;
284
+ readonly ensureContext?: () => Promise<void>;
280
285
  }
281
286
 
282
287
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
@@ -378,6 +383,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
378
383
  parentId: undefined,
379
384
  });
380
385
 
386
+ await deps.ensureContext?.();
381
387
  await sandboxManager.getOrCreate({
382
388
  ...llmHandlers,
383
389
  ...rlmHandlers,
@@ -399,7 +405,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
399
405
  // Wire per-invocation mutable state only after the serialized exec slot
400
406
  // is active. Swapping earlier would let queued repl() calls overwrite
401
407
  // emitter/limits for the currently running REPL execution.
402
- bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
408
+ bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits, interactive });
403
409
  });
404
410
  const elapsed = Date.now() - start;
405
411
  capturedStdout = result.stdout;