@hicaru/pi-rlm 0.3.1 → 0.3.3

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.
Files changed (46) hide show
  1. package/README.md +131 -154
  2. package/README.ru.md +5 -5
  3. package/README.zh-CN.md +5 -5
  4. package/package.json +3 -2
  5. package/src/bridge/handlers/await.ts +148 -0
  6. package/src/bridge/handlers/completion.ts +72 -0
  7. package/src/bridge/handlers/emitting.ts +104 -0
  8. package/src/bridge/handlers/finish.ts +45 -0
  9. package/src/bridge/handlers/index.ts +48 -0
  10. package/src/bridge/handlers/llm-query.ts +130 -0
  11. package/src/bridge/handlers/rlm-query.ts +227 -0
  12. package/src/bridge/handlers/task-registry.ts +202 -0
  13. package/src/bridge/handlers/types.ts +136 -0
  14. package/src/commands/rlm-config.ts +40 -12
  15. package/src/config/settings.ts +13 -3
  16. package/src/context/listing.ts +2 -2
  17. package/src/context/refresh.ts +141 -0
  18. package/src/core/engine.ts +16 -18
  19. package/src/core/types.ts +1 -3
  20. package/src/index.ts +59 -54
  21. package/src/mode/native-guards.ts +4 -4
  22. package/src/mode/rlm-mode.ts +6 -1
  23. package/src/mode/subagent.ts +1 -1
  24. package/src/prompts/glossary.ts +71 -74
  25. package/src/prompts/native.ts +127 -85
  26. package/src/prompts/system.ts +29 -15
  27. package/src/sandbox/interrupts.ts +258 -68
  28. package/src/sandbox/protocol.ts +53 -30
  29. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
  31. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  32. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/guards.py +8 -5
  34. package/src/sandbox/py/retrieval.py +17 -8
  35. package/src/sandbox/py/tasks.py +1 -1
  36. package/src/sandbox/py/worker.py +106 -79
  37. package/src/sandbox/sandbox-manager.ts +26 -1
  38. package/src/sandbox/sandbox.ts +1 -1
  39. package/src/tool/background-tasks.ts +1 -1
  40. package/src/tool/repl-result.ts +2 -2
  41. package/src/tool/repl-tool.ts +13 -14
  42. package/src/ui/config-panel.ts +1 -1
  43. package/src/ui/intro.ts +1 -4
  44. package/src/ui/model-picker.ts +32 -2
  45. package/src/util/concurrency.ts +1 -1
  46. package/src/bridge/subcall-handlers.ts +0 -382
package/README.md CHANGED
@@ -1,73 +1,58 @@
1
- # pi-rlm — Save 99% tokens, Recursive Language Model (RLM) for the Pi
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/openzebra/rlm.pi/main/assets/plugin-cover.png" width="100%" alt="pi-rlm — Recursive Language Model plugin for Pi">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@hicaru/pi-rlm"><img src="https://img.shields.io/npm/v/@hicaru/pi-rlm?color=cb3837&logo=npm" alt="npm version"></a>
7
+ <a href="https://github.com/openzebra/rlm.pi/blob/master/pi-plugin/rlm/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
8
+ <a href="https://github.com/earendil-works/pi"><img src="https://img.shields.io/badge/for-Pi-7c3aed" alt="Built for Pi"></a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://arxiv.org/abs/2512.24601">📄 RLM Paper</a> ·
13
+ <a href="https://github.com/openzebra/rlm.pi">💻 Source</a> ·
14
+ <a href="https://www.npmjs.com/package/@hicaru/pi-rlm">📦 npm</a>
15
+ </p>
2
16
 
3
- <div align="center">
4
-
5
- **Recursive Language Models (RLMs)**, implemented natively as a Pi extension —
6
- FULLY LOCAL.
7
-
8
- </div>
9
-
10
- ## Install
11
-
12
- ```bash
13
- pi install npm:@hicaru/pi-rlm
14
- ```
15
-
16
- To remove it later:
17
-
18
- ```bash
19
- pi uninstall npm:@hicaru/pi-rlm
20
- ```
17
+ ---
21
18
 
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]**.
19
+ **The ONLY Recursive Language Model plugin for Pi.** No new agent to learn, no
20
+ separate CLI, no YAML workflows just `/rlm` and your existing Pi session becomes a
21
+ recursive orchestration engine that saves **99% tokens** by delegating work to cheap
22
+ worker models.
24
23
 
25
- <div align="center">
24
+ > **One install. One toggle. Infinite context.**
26
25
 
27
- <a href="https://arxiv.org/abs/2512.24601"><img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" alt="pi-rlm"></a>
26
+ ## Why pi-rlm?
28
27
 
29
- <sub>Modeled on the method in the RLM paper, reimplemented natively for Pi.</sub>
28
+ | Advantage | What it means |
29
+ |-----------|---------------|
30
+ | 🔌 **Plugin, not a new agent** | Stays inside Pi. You keep your keybindings, your theme, your tools, your muscle memory. |
31
+ | 📄 **Reads ANY document** | `.pdf` `.docx` `.pptx` `.xlsx` `.epub` `.rtf` `.odt` `.csv` `.html` `.xml` — drop them in, they become Markdown in `context`. |
32
+ | 🪶 **Unix-style — tiny & composable** | Does ONE thing (RLM orchestration). Pair it with any other Pi plugin. No lock-in. |
33
+ | 🧠 **Smartest model orchestrates, cheapest model researches** | Root uses your best model; workers auto-pick the cheapest. Recursive children inherit the full `context` for free. |
34
+ | ⏳ **Long-running with goals** | Toggle `/rlm` on, set a goal, let it loop. Runs survive across chat turns — go make coffee. |
35
+ | 🔒 **100% local, 100% private** | No servers. Your API keys never leave your machine. One `python3` subprocess — that's it. |
30
36
 
31
- </div>
37
+ ## See it in action
32
38
 
33
39
  <div align="center">
34
40
 
35
- <sub>
36
- **English** &nbsp;·&nbsp; <a href="README.zh-CN.md">中文</a> &nbsp;·&nbsp; <a href="README.ru.md">Русский</a>
37
- </sub>
41
+ <video src="https://github.com/openzebra/rlm.pi/raw/refs/heads/master/animation/rlm_pi_explainer.mp4" controls width="854" poster="https://raw.githubusercontent.com/openzebra/rlm.pi/main/assets/hero.png"></video>
38
42
 
39
43
  </div>
40
44
 
41
- ---
42
-
43
- A **Recursive Language Model (RLM)** is a task-agnostic inference paradigm where a
44
- root language model orchestrates over near-infinite context by *programmatically*
45
- examining, decomposing, and **recursively calling itself** over its input. RLMs
46
- replace the canonical `llm.completion(prompt, model)` call with an
47
- `rlm.completion(prompt, model)` call: the prompt/context is offloaded as a variable
48
- in a REPL environment that the model interacts with, and the model can launch
49
- sub-LLM and sub-RLM calls as ordinary functions in code.
50
-
51
- This is a bet on a [CodeAct](https://arxiv.org/abs/2402.01030)-style harness — every
52
- language model gets access to a code environment, sub-(R)LM calls are functions, and
53
- context/prompts are objects in code — moving away from the JSON tool-calling standard.
54
- A system built this way is *itself* a language model that relies on recursive
55
- sub-LLM calls, hence the name.
45
+ ## Install (30 seconds)
56
46
 
57
- `pi-rlm` brings that paradigm **natively into Pi**:
47
+ ```bash
48
+ pi install npm:@hicaru/pi-rlm
49
+ ```
58
50
 
59
- - A **root orchestrator** model drives a **persistent Python REPL** turn-by-turn.
60
- - Long-context work is **delegated** to cheap worker models via `llm_query` / `llm_query_batched`.
61
- - Hard sub-problems **recurse** into child RLMs via `rlm_query` (depth-capped). A child inherits
62
- its parent's `context` — every file loaded so far, including sources added with `add_context()` —
63
- so it runs the same retrieval primitives over the same paths. Inheritance costs no extra tokens:
64
- the content lives in the sandbox, and only a size line reaches the model.
65
- - Everything runs **in-process** — the only external process is one local `python3` worker.
51
+ Run `/reload` in Pi. Done. `/rlm`, `/rlm-config`, `/rlm-stop` appear under **[Extensions]**.
66
52
 
67
- > This is a Pi-plugin reimplementation of the RLM method (see the [RLM paper](https://arxiv.org/abs/2512.24601)).
68
- > It is **not** the Python library.
53
+ Toggle with `Ctrl+Shift+R` or `/rlm` plain prompts now route through the RLM engine.
69
54
 
70
- ## How it works
55
+ ## What you get
71
56
 
72
57
  ```
73
58
  ┌─────────────────────────┐
@@ -76,8 +61,8 @@ sub-LLM calls, hence the name.
76
61
  │ /rlm
77
62
 
78
63
  ┌─────────────────────────┐ spawns ┌────────────────────┐
79
- │ Smart model (root) │ ────────► │ Worker models │
80
- │ drives a Python REPL │ ◄──────── │ (cheap, fast) │
64
+ │ Smart model (root) │ ────────► │ Worker models │
65
+ │ drives a Python REPL │ ◄──────── │ (cheap, fast) │
81
66
  └────────────┬────────────┘ results └────────────────────┘
82
67
  │ recursion (depth-capped)
83
68
  └────► child RLMs ────► (same loop)
@@ -85,117 +70,109 @@ sub-LLM calls, hence the name.
85
70
  All local · one python3 process · no servers
86
71
  ```
87
72
 
88
- - The **smart model** thinks and writes Python in a REPL.
89
- - The **worker models** do the heavy lifting (read, summarize, classify).
90
- - Hard sub-problems **recurse** into child RLMs.
91
- - Everything runs **fully local** your API keys never leave Pi.
73
+ - **Smart model** thinks and writes Python in a persistent REPL.
74
+ - **Worker models** do the heavy lifting read, summarize, classify, search.
75
+ - **Child RLMs** recurse into hard sub-problems, inheriting the full `context` for free.
76
+ - **Live tree** shows every sub-call with model, cost, tokens, and duration.
77
+
78
+ ## Document format support
79
+
80
+ Drop ANY of these into `add_context()` — they auto-convert to Markdown and land in `context`:
81
+
82
+ | Category | Formats |
83
+ |----------|---------|
84
+ | **Word** | `.docx` |
85
+ | **PDF** | `.pdf` |
86
+ | **PowerPoint** | `.pptx` |
87
+ | **Excel** | `.xlsx` |
88
+ | **EPUB** | `.epub` |
89
+ | **Rich Text** | `.rtf` |
90
+ | **OpenDocument** | `.odt` |
91
+ | **CSV / TSV** | `.csv` `.tsv` |
92
+ | **HTML / XML** | `.html` `.htm` `.xml` `.rss` `.atom` |
93
+ | **+ Pandoc fallback** | `.doc` `.ppt` `.xls` `.pptm` `.xlsm` `.xlsb` `.ppsm` `.docm` `.odp` `.ods` |
94
+
95
+ ```python
96
+ add_context("report.pdf") # → Markdown in context
97
+ add_context("data.xlsx") # → Markdown in context
98
+ add_context("../some-lib") # → entire directory packed
99
+ add_context("https://github.com/x/y.git") # → shallow clone + pack
100
+ ```
101
+
102
+ ## RECURSION — the core idea
103
+
104
+ A **Recursive Language Model (RLM)** replaces `llm.completion(prompt)` with
105
+ `rlm.completion(prompt)`. The prompt becomes a variable in a REPL. The model can
106
+ launch sub-LLM and sub-RLM calls as ordinary Python functions — decomposing,
107
+ delegating, and synthesizing across a tree of models, not a single context window.
108
+
109
+ **This is the only plugin that brings true RLM recursion to Pi.** Prime Agent and
110
+ the reference Python library are separate agents you must switch to. pi-rlm lives
111
+ inside Pi — same session, same tools, same everything.
92
112
 
93
113
  ## Commands
94
114
 
95
- | Command | Shortcut | Description |
96
- |---|---|---|
97
- | `/rlm` | `Ctrl+Shift+R` | Toggle persistent RLM mode (route plain prompts through the RLM engine) |
98
- | `/rlm-stop` | | Abort an in-progress run |
99
- | `/rlm-config` | | Pick smart + worker models and tune run settings |
115
+ | Command | Shortcut | What it does |
116
+ |---------|----------|--------------|
117
+ | `/rlm` | `Ctrl+Shift+R` | Toggle RLM mode on/off |
118
+ | `/rlm-stop` | | Abort current run |
119
+ | `/rlm-config` | | Pick models, tune limits |
100
120
 
101
- While a run is active, a **live tree** shows the root orchestrator and every sub-LLM /
102
- recursive child with status, model, cost, tokens, and duration. The final answer is posted
103
- to the chat as markdown. File changes use Pi's native `edit` / `write` tools (with their
104
- built-in diff preview).
121
+ ## Settings (`/rlm-config`)
105
122
 
106
- ## Sandbox API
123
+ | Setting | Default | Why you'd change it |
124
+ |---------|---------|---------------------|
125
+ | Smart model | Pi's active | Use your best model as orchestrator |
126
+ | Worker model | cheapest available | Free/cheap model for leaf `llm_query` calls |
127
+ | Max recursion depth | `4` | Deeper trees for harder problems |
128
+ | Max iterations | `30` | Longer runs for complex tasks |
129
+ | REPL timeout | `120`s | Bump for slow computations |
130
+ | Max concurrent subs | `16` | More parallelism (costs RAM) |
107
131
 
108
- These functions are injected into the model's Python namespace inside the REPL:
132
+ ## Prompt Architecture
109
133
 
110
- | Function | Signature | Description |
111
- |---|---|---|
112
- | `context` | `list[dict]` | Loaded files as `[{"path","content","tokens"}, …]` — starts empty; cwd seeds on first `repl()` |
113
- | `llm_query` | `(prompt, model=None) -> str` | One-shot sub-LLM call (worker model) |
114
- | `llm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent sub-LLM calls (pool-bounded) |
115
- | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | Split large text into cap-sized chunks and fan out via sub-LLMs |
116
- | `rlm_query` | `(prompt, model=None, paths=None) -> str` | Recursive child RLM with its own sandbox (depth-capped). Inherits your `context`; `paths` narrows it by prefix |
117
- | `rlm_query_batched` | `(prompts, model=None, paths=None) -> list[str]` | Concurrent recursive child RLMs, sharing one `paths` slice |
118
- | `add_context` | `(source) -> dict \| str` | Append a dir, file, document, or git URL into `context` under `ctx/<id>/` |
119
- | `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
120
- | `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
134
+ The system prompt follows a **contract / routing / examples / rules** pattern
135
+ (api_v5), modeled on the best-performing arm from the RLM paper bake-off:
121
136
 
122
- ### Adding context
137
+ - `<contract>` — every heavy call returns a `Task`, only `await_task` returns content
138
+ - `<routing>` — decision tree: which tool for which job
139
+ - `<examples>` — concrete E1–E7 patterns with anti-patterns
140
+ - `<rules>` — locate-then-delegate, memoize, cap workers, author edits yourself
123
141
 
124
- `context` starts empty. The working directory seeds automatically on the first `repl()` call
125
- (un-prefixed paths so `search()` hits remain real paths for `edit`/`write`). For an **external
126
- tree, document, or git URL**, call `add_context(source)`:
142
+ **Key insight:** children see `Recursion depth: N` and calibrate ambition
143
+ delegating only when their task genuinely decomposes further.
127
144
 
128
- ```python
129
- info = add_context("../some-lib") # local directory → packed + appended
130
- info = add_context("docs/api.md") # single file → one entry in context
131
- info = add_context("report.pdf") # document → Markdown, then appended
132
- info = add_context("https://github.com/x/y.git") # shallow clone, then pack + append
133
- # Files land in the SAME `context` list under ctx/<source_id>/…
134
- # info == {"source_id", "path_prefix", "files", "chars", "context_len", "already_loaded", "converted", "skipped", …}
135
- lib = [f for f in context if f["path"].startswith(info["path_prefix"])]
136
- ```
145
+ ## Benchmarks
137
146
 
138
- There is no `context_1` / `context_2` only `context`. Paths are namespaced so multiple
139
- sources do not collide. Toggle via `/rlm-config` **Context loader** (`contextLoader`,
140
- default on) and **Auto-seed cwd** (`autoSeedCwd`, default on). A source loaded at any point is
141
- inherited by every child spawned afterwards.
147
+ Tested against `rlm-lab` prompt bake-off and full dual-mode RLM runtime benchmarks
148
+ on `poolside/laguna-xs-2.1:free` (a free ~32B model):
142
149
 
143
- ## Settings (`/rlm-config`)
150
+ | Benchmark | Mode | Result |
151
+ |-----------|------|--------|
152
+ | Main orchestrator (7 scenarios) | prompt bake-off | **0.958** mean score (v3 fewshot arm) |
153
+ | RLM worker (4 scenarios) | prompt bake-off | **0.94** mean score (v2 contract arm) |
154
+ | Needle-in-haystack (3 needles) | classic RLM | **recall 1.0** |
155
+ | CodeQA timeout | classic RLM | **correct** (~3.7k tokens) |
156
+ | Coding (retry fix) | orchestrator | **correct** (file edited) |
157
+ | Live smoke needle | classic RLM | **hit** (~5k tokens) |
144
158
 
145
- | Setting | Default | Meaning |
146
- |---|---|---|
147
- | Smart model | Pi's active model | the root orchestrator |
148
- | Worker model | cheapest available | answers `llm_query` |
149
- | Max recursion depth | `4` | `rlm_query` past this degrades to plain `llm_query` |
150
- | Max iterations | `30` | root REPL turns before RLM asks for a final answer |
151
- | REPL block timeout (s) | `120` | wall-clock limit for one Python REPL block (SIGALRM) |
152
- | Max concurrent sub-calls | `16` | concurrency pool size for `*_batched` |
153
- | Max concurrent children | `6` | concurrent `rlm_query` child engines per depth |
154
- | Wall-clock ceiling (min) | none | total runtime cap for the whole recursive tree |
155
- | Token ceiling | none | total input+output token cap for the whole recursive tree |
156
- | Max consecutive errors | `5` | stop after N consecutive failing turns (none = off) |
157
- | Orchestrator addendum | on | divide-and-conquer guidance in the root system prompt |
158
- | Trajectory compaction | on (0.65) | summarize old turns when history nears the context window |
159
- | Root model output cap (tok) | `16384` | max output tokens per root-model turn |
160
- | Sandbox init timeout | `30000` ms | how long to wait for the Python worker to start |
161
- | Context loader | on | expose `add_context()` for external dirs/files/documents/git repos |
162
- | Auto-seed cwd | on | seed the working directory into `context` on the first `repl()` |
163
-
164
- > **Concurrency note:** each `rlm_query` child spawns its own `python3` worker (~50–150 ms
165
- > cold start). Children are bounded separately (`maxConcurrentChildren`, default 6) because
166
- > each holds a full Python process and its own copy of the inherited context. Error and
167
- > wall-clock caps (above) still bound a runaway tree.
168
-
169
- ## Subagents and environment
170
-
171
- RLM never confiscates native file tools (`read` / `grep` / bash readers) unless `repl` is in
172
- the **active** tool set — the paper's trade is all-or-nothing. Process-boundary subagents that
173
- spawn pi with a `--tools` allowlist without `repl` therefore keep ordinary file access.
174
-
175
- Optional env conventions (for packages that want an explicit full bypass):
176
-
177
- | Env | Meaning |
178
- |---|---|
179
- | `PI_SUBAGENT_CHILD=1` | Full RLM bypass in this process (no tools / hooks / flags). |
180
- | `PI_RLM_FORCE_IN_SUBAGENT=1` | Experimental: opt a child back into RLM. **Consumed on activate** (not inherited after). Refused when `PI_RLM_DEPTH >= maxDepth`. |
181
- | `PI_RLM_DEPTH` | Cross-process depth counter (default `0`). Bumped when force-in activates. |
182
-
183
- In-process recursion (`rlm_query`) still uses `maxDepth` from `/rlm-config` and is unrelated to
184
- these env vars. Set `RLM_TRACE_FILE` to a path for JSONL traces of bypass / force / block-skip
185
- decisions.
159
+ > On a *free* model. Frontier models do even better. See `rlm_test/RESULTS_AGENT.md`
160
+ > and `rlm_test/RESULTS.md` for full methodology.
186
161
 
187
162
  ## Security
188
163
 
189
- - **Key isolation**: provider keys live only in TypeScript (`AuthStorage`); the sandbox
190
- receives prompts and returns text never keys.
191
- - **Environment sanitization**: sensitive env vars (API keys, tokens) are stripped before the
192
- worker spawns. The worker cannot read provider credentials from `os.environ`.
193
- - **NOT a security sandbox**: the Python worker exposes `__import__` and `open`. Model-authored
194
- code can import networking modules, read/write local files, and write protocol-shaped JSON to
195
- stdout. This tier trusts the root model's code; the stdio protocol isolates provider keys and
196
- process lifecycle, **not** adversarial code containment. A stronger sandbox (Docker, seccomp)
197
- can be added later behind a setting without protocol changes.
198
- - **Restricted builtins**: no `eval`/`exec`/`compile`/`input`/`globals`/`locals`; per-block
199
- SIGALRM timeout + parent watchdog (SIGKILL on hang); budget / token / timeout /
200
- consecutive-error caps.
201
- - **Trust**: project-local install requires Pi project trust.
164
+ - **Key isolation** provider keys live in TypeScript only; sandbox receives prompts, returns text.
165
+ - **Environment sanitization** sensitive env vars stripped before worker spawns.
166
+ - **Restricted builtins** no `eval`/`exec`/`compile`/`input` in the sandbox.
167
+ - **Per-block timeout** SIGALRM + parent watchdog (SIGKILL on hang).
168
+ - **Trust** project-local install requires Pi project trust.
169
+
170
+ ## Uninstall
171
+
172
+ ```bash
173
+ pi uninstall npm:@hicaru/pi-rlm
174
+ ```
175
+
176
+ ## License
177
+
178
+ MIT — see [LICENSE](./LICENSE).
package/README.ru.md CHANGED
@@ -104,11 +104,11 @@ rm -rf ~/.pi/agent/extensions/rlm
104
104
  | Функция | Сигнатура | Описание |
105
105
  |---|---|---|
106
106
  | `context` | `list[dict]` | Репозиторий, упакованный как `[{"path","content","tokens"}, ...]` — вся кодовая база |
107
- | `llm_query` | `(prompt, model=None) -> str` | Одноразовый вызов sub-LLM (worker-модель) |
108
- | `llm_query_batched` | `(prompts, model=None) -> list[str]` | Параллельные вызовы sub-LLM (с ограничением пула) |
109
- | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | Дробит большой текст на части по лимиту и обрабатывает через sub-LLM |
110
- | `rlm_query` | `(prompt, model=None, paths=None) -> str` | Рекурсивный дочерний RLM со своей песочницей (с ограничением глубины). Наследует ваш `context`; `paths` сужает его по префиксу |
111
- | `rlm_query_batched` | `(prompts, model=None, paths=None) -> list[str]` | Параллельные рекурсивные дочерние RLM с общим срезом `paths` |
107
+ | `llm_query` | `(prompt) -> str` | Одноразовый вызов sub-LLM (настроенная RLM LLM) |
108
+ | `llm_query_batched` | `(prompts) -> list[str]` | Параллельные вызовы sub-LLM (с ограничением пула) |
109
+ | `llm_query_chunked` | `(text, prompt) -> list[str]` | Дробит большой текст на части по лимиту и обрабатывает через sub-LLM |
110
+ | `rlm_query` | `(prompt, paths=None) -> str` | Рекурсивный дочерний RLM со своей песочницей (с ограничением глубины). Наследует ваш `context`; `paths` сужает его по префиксу |
111
+ | `rlm_query_batched` | `(prompts, paths=None) -> list[str]` | Параллельные рекурсивные дочерние RLM с общим срезом `paths` |
112
112
  | `ask_user_question` | `(questions) -> list[dict]` | Задать пользователю структурированные вопросы (только на глубине 0) |
113
113
  | `SHOW_VARS` | `() -> str` | Список текущих переменных и их типов |
114
114
  | `answer` | `dict` | Установите `answer["content"]=...; answer["ready"]=True` для завершения |
package/README.zh-CN.md CHANGED
@@ -110,11 +110,11 @@ rm -rf ~/.pi/agent/extensions/rlm
110
110
  | 函数 | 签名 | 描述 |
111
111
  |---|---|---|
112
112
  | `context` | `list[dict]` | 打包为 `[{"path","content","tokens"}, ...]` 的仓库 —— 完整的代码库 |
113
- | `llm_query` | `(prompt, model=None) -> str` | 单次子 LLM 调用 (worker 模型) |
114
- | `llm_query_batched` | `(prompts, model=None) -> list[str]` | 并发子 LLM 调用 (池上限) |
115
- | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | 将大文本拆分为不超过上限的块并通过子 LLM 处理 |
116
- | `rlm_query` | `(prompt, model=None, paths=None) -> str` | 具有自有沙箱的递归子 RLM (设有深度限制)。继承父级的 `context`;`paths` 按前缀缩小范围 |
117
- | `rlm_query_batched` | `(prompts, model=None, paths=None) -> list[str]` | 并发递归子 RLM,共享同一个 `paths` 切片 |
113
+ | `llm_query` | `(prompt) -> str` | 单次子 LLM 调用(配置的 RLM LLM) |
114
+ | `llm_query_batched` | `(prompts) -> list[str]` | 并发子 LLM 调用 (池上限) |
115
+ | `llm_query_chunked` | `(text, prompt) -> list[str]` | 将大文本拆分为不超过上限的块并通过子 LLM 处理 |
116
+ | `rlm_query` | `(prompt, paths=None) -> str` | 具有自有沙箱的递归子 RLM (设有深度限制)。继承父级的 `context`;`paths` 按前缀缩小范围 |
117
+ | `rlm_query_batched` | `(prompts, paths=None) -> list[str]` | 并发递归子 RLM,共享同一个 `paths` 切片 |
118
118
  | `ask_user_question` | `(questions) -> list[dict]` | 向用户提出结构化问题 (仅限深度 0) |
119
119
  | `SHOW_VARS` | `() -> str` | 列出当前定义的变量及其类型 |
120
120
  | `answer` | `dict` | 设置 `answer["content"]=...; answer["ready"]=True` 以结束 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,7 +36,8 @@
36
36
  "pi": {
37
37
  "extensions": [
38
38
  "./src/index.ts"
39
- ]
39
+ ],
40
+ "image": "https://raw.githubusercontent.com/openzebra/rlm.pi/main/assets/plugin-cover.png"
40
41
  },
41
42
  "publishConfig": {
42
43
  "access": "public"
@@ -0,0 +1,148 @@
1
+ /**
2
+ * await handler — collect results from background tasks.
3
+ *
4
+ * Supports single-task await (await_task(task_id="...")) and multi-task await
5
+ * (await_task(task_ids=[...])). Returns AwaitResult with the collected result(s)
6
+ * in input order for batches.
7
+ */
8
+
9
+ import { errorMessage, formatError } from "../../util/errors.ts";
10
+ import type { AwaitResult, SubcallHandlerDeps, TaskEntry } from "./types.ts";
11
+ import type { SubcallOpts } from "../../sandbox/interrupts.ts";
12
+ import type { AwaitDeps } from "./task-registry.ts";
13
+
14
+ export function createAwaitHandler(_deps: SubcallHandlerDeps, ad: AwaitDeps) {
15
+ return async (
16
+ taskId: string | undefined,
17
+ taskIds: readonly string[] | undefined,
18
+ timeoutS: number | undefined,
19
+ _depth: number,
20
+ _opts: SubcallOpts,
21
+ ): Promise<AwaitResult> => {
22
+ const timeoutMs = timeoutS !== undefined ? timeoutS * 1000 : undefined;
23
+
24
+ // Single task
25
+ if (taskId !== undefined && (taskIds === undefined || taskIds.length === 0)) {
26
+ const entry = ad.get(taskId);
27
+ if (entry === undefined) {
28
+ return {
29
+ ok: false,
30
+ task_id: taskId,
31
+ kind: "unknown",
32
+ status: "error",
33
+ error: `Task ${taskId} not found — was it already awaited or never spawned?`,
34
+ };
35
+ }
36
+
37
+ if (entry.status === "pending") {
38
+ try {
39
+ const resolved = await ad.wait(taskId, timeoutMs);
40
+ return toAwaitResult(resolved);
41
+ } catch (err: unknown) {
42
+ return {
43
+ ok: false,
44
+ task_id: taskId,
45
+ kind: entry.kind,
46
+ status: "error",
47
+ error: formatError(errorMessage(err)),
48
+ };
49
+ }
50
+ }
51
+
52
+ return toAwaitResult(entry);
53
+ }
54
+
55
+ // Multiple tasks
56
+ const ids = taskIds ?? (taskId !== undefined ? [taskId] : []);
57
+ if (ids.length === 0) {
58
+ return {
59
+ ok: false,
60
+ task_id: "",
61
+ kind: "unknown",
62
+ status: "error",
63
+ error: "No task_id or task_ids provided to await",
64
+ };
65
+ }
66
+
67
+ const entries = ids
68
+ .map((id) => ad.get(id))
69
+ .filter((e): e is TaskEntry => e !== undefined);
70
+
71
+ if (entries.length === 0) {
72
+ return {
73
+ ok: false,
74
+ task_id: ids[0] ?? "",
75
+ kind: "unknown",
76
+ status: "error",
77
+ error: "None of the requested task IDs were found",
78
+ };
79
+ }
80
+
81
+ const resolved = await Promise.all(
82
+ entries.map(async (e) => {
83
+ if (e.status === "pending") {
84
+ try {
85
+ return await ad.wait(e.taskId, timeoutMs);
86
+ } catch {
87
+ return e;
88
+ }
89
+ }
90
+ return e;
91
+ }),
92
+ );
93
+
94
+ const awaited = resolved.map(toAwaitResult);
95
+ const allDone = awaited.every((a) => a.status === "done");
96
+ const hasResults = awaited.some((a) => a.results !== undefined);
97
+ const firstError = awaited.find((a) => a.error)?.error;
98
+ const first = awaited[0];
99
+ const kind = first?.kind ?? "unknown";
100
+
101
+ if (hasResults) {
102
+ const allResults: string[] = [];
103
+ for (const a of awaited) {
104
+ if (a.results !== undefined) {
105
+ for (const r of a.results) allResults.push(r);
106
+ } else if (a.result !== undefined) {
107
+ allResults.push(a.result);
108
+ }
109
+ }
110
+ return {
111
+ ok: allDone,
112
+ task_id: ids.join(","),
113
+ kind,
114
+ status: allDone ? "done" : "error",
115
+ results: Object.freeze(allResults),
116
+ error: firstError,
117
+ };
118
+ }
119
+
120
+ if (awaited.length === 1 && first !== undefined) {
121
+ return first;
122
+ }
123
+
124
+ return {
125
+ ok: allDone,
126
+ task_id: ids.join(","),
127
+ kind,
128
+ status: allDone ? "done" : "error",
129
+ results: Object.freeze(awaited.map((a) => a.result ?? a.error ?? "")),
130
+ error: firstError,
131
+ };
132
+ };
133
+ }
134
+
135
+ function toAwaitResult(entry: TaskEntry): AwaitResult {
136
+ const status = entry.status === "pending" ? "error" : entry.status;
137
+ return {
138
+ ok: entry.status === "done",
139
+ task_id: entry.taskId,
140
+ kind: entry.kind,
141
+ status,
142
+ result: entry.result,
143
+ results: entry.results,
144
+ error:
145
+ entry.error ??
146
+ (entry.status === "pending" ? "Task still pending" : undefined),
147
+ };
148
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Single LLM completion — the ONE place a sub-LLM call is made.
3
+ *
4
+ * Every leaf completion takes exactly ONE slot on `gates.leaf` here.
5
+ * Never wrap a whole batch in the leaf gate (deadlock); only complete1 acquires it.
6
+ *
7
+ * AGENTS.md DRY #1: complete1 exists once. Never inline another one.
8
+ */
9
+
10
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
11
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
+ import { type ChatMsg, modelComplete } from "../model.ts";
13
+ import { checkResourceLimits } from "../../core/resource-limits.ts";
14
+ import { errorMessage, formatError } from "../../util/errors.ts";
15
+ import type { Semaphore } from "../../util/concurrency.ts";
16
+ import type { Invocation, SubcallConfig } from "./types.ts";
17
+
18
+ export interface Complete1Deps {
19
+ readonly leafGate: Semaphore;
20
+ readonly registry: ModelRegistry;
21
+ readonly getLlmModel: () => Model<Api>;
22
+ readonly getConfig: () => SubcallConfig;
23
+ readonly signal?: AbortSignal;
24
+ readonly onUsage?: (usage: Usage, role: "sub") => void;
25
+ }
26
+
27
+ /**
28
+ * Run one LLM completion inside the leaf gate.
29
+ * Never throws — returns formatError(...) strings on failure.
30
+ */
31
+ export async function complete1(
32
+ inv: Invocation,
33
+ prompt: string,
34
+ track: (usage: Usage) => void,
35
+ deps: Complete1Deps,
36
+ ): Promise<string> {
37
+ const config = deps.getConfig();
38
+ const limitError = checkResourceLimits({
39
+ timeoutMs: inv.limits.remainingTimeoutMs(),
40
+ });
41
+ if (limitError !== undefined) return limitError;
42
+ if (prompt.length > config.maxPromptChars) {
43
+ return formatError(
44
+ `sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ` +
45
+ `${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`,
46
+ );
47
+ }
48
+ try {
49
+ const messages: ChatMsg[] = [{ role: "user", content: prompt }];
50
+ const res = await deps.leafGate.run(() =>
51
+ modelComplete(messages, {
52
+ model: deps.getLlmModel(),
53
+ registry: deps.registry,
54
+ system: config.subSystemPrompt,
55
+ maxTokens: config.subSampling?.maxTokens,
56
+ temperature: config.subSampling?.temperature,
57
+ reasoning: config.subSampling?.reasoning,
58
+ signal: deps.signal,
59
+ }),
60
+ );
61
+ inv.limits.addUsage(res.usage);
62
+ deps.onUsage?.(res.usage, "sub");
63
+ track(res.usage);
64
+ return res.text;
65
+ } catch (err: unknown) {
66
+ const msg = errorMessage(err);
67
+ const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
68
+ ? " — try smaller batches or individual llm_query calls"
69
+ : "";
70
+ return formatError(`${msg}${hint}`);
71
+ }
72
+ }