@hicaru/pi-rlm 0.1.0
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/LICENSE +21 -0
- package/README.md +237 -0
- package/README.ru.md +200 -0
- package/README.zh-CN.md +224 -0
- package/package.json +54 -0
- package/src/bridge/fallback-todo.ts +137 -0
- package/src/bridge/interactive.ts +65 -0
- package/src/bridge/llm-query.ts +124 -0
- package/src/bridge/model.ts +97 -0
- package/src/bridge/pi-interactive.ts +86 -0
- package/src/bridge/rlm-query.ts +78 -0
- package/src/commands/rlm-config.ts +42 -0
- package/src/commands/rlm.ts +165 -0
- package/src/config/defaults.ts +38 -0
- package/src/config/settings.ts +185 -0
- package/src/context/repomix-context.ts +253 -0
- package/src/core/answer.ts +97 -0
- package/src/core/compaction.ts +64 -0
- package/src/core/engine.ts +408 -0
- package/src/core/history.ts +13 -0
- package/src/core/iteration.ts +45 -0
- package/src/core/limits.ts +90 -0
- package/src/core/pipeline.ts +100 -0
- package/src/core/resource-limits.ts +14 -0
- package/src/core/types.ts +131 -0
- package/src/index.ts +165 -0
- package/src/mode/input-router.ts +23 -0
- package/src/mode/rlm-mode.ts +149 -0
- package/src/patch/apply.ts +148 -0
- package/src/patch/index.ts +37 -0
- package/src/prompts/system.ts +278 -0
- package/src/prompts/user.ts +21 -0
- package/src/sandbox/protocol.ts +191 -0
- package/src/sandbox/sandbox-manager.ts +143 -0
- package/src/sandbox/sandbox.ts +362 -0
- package/src/sandbox/worker.py +457 -0
- package/src/state/events.ts +22 -0
- package/src/state/index.ts +23 -0
- package/src/state/internal.ts +46 -0
- package/src/state/paths.ts +42 -0
- package/src/state/reads.ts +96 -0
- package/src/state/resume.ts +154 -0
- package/src/state/rows.ts +117 -0
- package/src/state/writes.ts +56 -0
- package/src/telemetry/dispatcher.ts +116 -0
- package/src/telemetry/index.ts +14 -0
- package/src/telemetry/mlflow-config.ts +15 -0
- package/src/telemetry/mlflow-sink.ts +136 -0
- package/src/telemetry/mlflow.ts +99 -0
- package/src/telemetry/sink.ts +8 -0
- package/src/text/edits.ts +16 -0
- package/src/text/parsing.ts +35 -0
- package/src/text/preview.ts +18 -0
- package/src/text/tokens.ts +64 -0
- package/src/tool/apply-diff-tool.ts +125 -0
- package/src/tool/emitter-listener.ts +24 -0
- package/src/tool/repl-details.ts +23 -0
- package/src/tool/repl-tool.ts +528 -0
- package/src/tool/rlm-aggregator.ts +115 -0
- package/src/tool/rlm-details.ts +53 -0
- package/src/tool/rlm-events.ts +215 -0
- package/src/tool/rlm-tool.ts +199 -0
- package/src/tool/subcall-render.ts +129 -0
- package/src/tool/subcall-store.ts +90 -0
- package/src/tool/tool-utils.ts +73 -0
- package/src/ui/config-panel.ts +92 -0
- package/src/ui/intro.ts +23 -0
- package/src/ui/model-picker.ts +139 -0
- package/src/ui/status.ts +26 -0
- package/src/ui/theme.ts +47 -0
- package/src/util/concurrency.ts +15 -0
- package/src/util/errors.ts +27 -0
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="../../assets/hero.png" alt="pi-rlm">
|
|
4
|
+
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div align="center">
|
|
8
|
+
|
|
9
|
+
<sub>
|
|
10
|
+
<a href="README.md">English</a> · **中文** · <a href="README.ru.md">Русский</a>
|
|
11
|
+
</sub>
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# pi-rlm — 为 [Pi](https://github.com/earendil-works) 编程代理提供的递归语言模型 (Recursive Language Models)
|
|
18
|
+
|
|
19
|
+
<div align="center">
|
|
20
|
+
|
|
21
|
+
**递归语言模型 (RLMs)** 作为 Pi 扩展原生实现 ——
|
|
22
|
+
无需额外服务器,无需 Docker,无需 socket。
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
**递归语言模型 (RLM)** 是一种与任务无关的推理范式,其中根语言模型通过对输入进行*编程式*的检查、分解并**递归调用自身**,从而在近乎无限的上下文中进行编排。RLM 将典型的 `llm.completion(prompt, model)` 调用替换为 `rlm.completion(prompt, model)` 调用:提示词/上下文作为 REPL 环境中的一个变量进行卸载,模型与其进行交互,并且模型可以将子 LLM 和子 RLM 调用作为代码中的普通函数启动。
|
|
29
|
+
|
|
30
|
+
这是对 [CodeAct](https://arxiv.org/abs/2402.01030) 风格框架的一种尝试 —— 每个语言模型都能访问代码环境,子 (R)LM 调用是函数,而上下文/提示词是代码中的对象 —— 从而脱离了 JSON 工具调用 (tool-calling) 标准。以此方式构建的系统*本身*就是一个依赖于递归子 LLM 调用的语言模型,因此得名。
|
|
31
|
+
|
|
32
|
+
`pi-rlm` 将该范式**原生引入 Pi**:
|
|
33
|
+
|
|
34
|
+
- **根编排器**模型逐轮驱动一个**持久化的 Python REPL**。
|
|
35
|
+
- 长上下文工作通过 `llm_query` / `llm_query_batched` **委派**给廉价的工作模型。
|
|
36
|
+
- 困难的子问题通过 `rlm_query` **递归**到子 RLM 中(设有深度限制)。
|
|
37
|
+
- 所有内容均**在进程内**运行 —— 唯一的外部进程是一个本地的 `python3` worker。
|
|
38
|
+
|
|
39
|
+
> 这是 RLM 方法的 Pi 插件重新实现(参见 [RLM 论文](https://arxiv.org/abs/2512.24601)
|
|
40
|
+
> 和 [Python `rlm` 库](https://github.com/alexzhang13/rlm-minimal))。它**不是**那个 Python 库。
|
|
41
|
+
|
|
42
|
+
## 工作原理
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pi 进程 (TypeScript)
|
|
46
|
+
├─ /rlm ──► 引擎逐轮驱动 SMART (根) 模型 (编写 ```repl``` Python)
|
|
47
|
+
│ │ 每轮:解析 repl 块 ──► 在沙箱中运行 ──► 将 stdout 反馈回去
|
|
48
|
+
│ ▼
|
|
49
|
+
├─ bridge ── llm_query / llm_query_batched ──► WORKER 模型 (serverless, 进程内)
|
|
50
|
+
│ rlm_query ──► 递归子 RLM (自有沙箱), 设有深度限制
|
|
51
|
+
├─ AgentTree ──► 编辑器上方的实时 agent/subagent 树 (角色, 深度, 成本, token)
|
|
52
|
+
└─ PythonSandbox ── `python3 worker.py` ──[基于 stdio 的 JSONL, 双向]── 持久化 REPL
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- **无需服务器,无需 socket,无需 Docker。** 唯一的外部进程是一个本地 `python3` 沙箱。
|
|
56
|
+
当沙箱代码调用 `llm_query` 时,worker 在 stdout 上写入请求并在 stdin 上阻塞;
|
|
57
|
+
Pi 在进程内提供服务并将回复写回。**供应商 API 密钥绝不会进入沙箱。**
|
|
58
|
+
- 沙箱公开了 `context`, `llm_query`, `llm_query_batched`, `rlm_query`,
|
|
59
|
+
`rlm_query_batched`, `SHOW_VARS()`, `todo()`, `ask_user_question()` 以及一个 `answer` 字典。
|
|
60
|
+
模型通过设置 `answer["ready"] = True` 来提交最终结果。
|
|
61
|
+
|
|
62
|
+
## 安装
|
|
63
|
+
|
|
64
|
+
`pi-rlm` 是一个 Pi 包。Pi 提供了 `@earendil-works/pi-*` 和 `typebox` peer
|
|
65
|
+
依赖;请**不要**在该包中安装它们的独立副本。要求 `PATH` 中有 `python3` (仅限标准库)。
|
|
66
|
+
|
|
67
|
+
开发时的推荐本地安装方式:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pi install /path/to/this-repo/pi-plugin/rlm
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
已发布的 npm 包安装方式:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm publish # 例如 as @<you>/pi-rlm
|
|
77
|
+
pi install npm:@<you>/pi-rlm
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
> **Git 安装**要求包清单位于安装的仓库根目录下。
|
|
81
|
+
> 对于像这样一个 monorepo 子目录,请优先使用上述的本地路径或 npm 流程。
|
|
82
|
+
|
|
83
|
+
如果您之前直接复制了扩展文件夹,请将其删除,以免遮蔽 (shadow) 该包:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
rm -rf ~/.pi/agent/extensions/rlm
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
然后运行 `/reload` 或重启 Pi。使用 `pi list` 验证该包是否出现在
|
|
90
|
+
`settings.packages` 中,并检查 `/rlm`, `/rlm-config` 和 `/rlm-stop` 是否出现在 **[Extensions]** 下。
|
|
91
|
+
|
|
92
|
+
## 命令
|
|
93
|
+
|
|
94
|
+
| 命令 | 快捷键 | 描述 |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `/rlm` | `Ctrl+Shift+R` | 切换持久化 RLM 模式 (通过 RLM 引擎路由普通提示词) |
|
|
97
|
+
| `/rlm-stop` | | 终止正在运行的任务 |
|
|
98
|
+
| `/rlm-config` | | 选择 smart + worker 模型并调整运行设置 |
|
|
99
|
+
| `/rlm-resume` | | 恢复被中断的任务 (默认 `@latest`) |
|
|
100
|
+
| `/rlm-runs` | | 列出最近的任务 |
|
|
101
|
+
| `/rlm-help` | | 显示启动指南和速查表 |
|
|
102
|
+
|
|
103
|
+
在任务激活期间,一个**实时树**会显示根编排器和每个子 LLM /
|
|
104
|
+
递归子节点的状态、模型、成本、token 和持续时间。最终答案将以 markdown 形式发布
|
|
105
|
+
到聊天中;任何代码修改将作为 diff 收集并通过弹出窗口进行审核 (除非开启了 `yolo`)。
|
|
106
|
+
|
|
107
|
+
## 沙箱 API
|
|
108
|
+
|
|
109
|
+
这些函数被注入到 REPL 内部模型的 Python 命名空间中:
|
|
110
|
+
|
|
111
|
+
| 函数 | 签名 | 描述 |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `context` | `list[dict]` | 打包为 `[{"path","content","tokens"}, ...]` 的仓库 —— 完整的代码库 |
|
|
114
|
+
| `llm_query` | `(prompt, model=None) -> str` | 单次子 LLM 调用 (worker 模型) |
|
|
115
|
+
| `llm_query_batched` | `(prompts, model=None) -> list[str]` | 并发子 LLM 调用 (池上限) |
|
|
116
|
+
| `rlm_query` | `(prompt, model=None) -> str` | 具有自有沙箱的递归子 RLM (设有深度限制) |
|
|
117
|
+
| `rlm_query_batched` | `(prompts, model=None) -> list[str]` | 并发递归子 RLM |
|
|
118
|
+
| `todo` | `(action, **kwargs) -> str` | 任务列表:`create`/`update`/`list`/`get`/`delete`/`clear` |
|
|
119
|
+
| `ask_user_question` | `(questions) -> list[dict]` | 向用户提出结构化问题 (仅限深度 0) |
|
|
120
|
+
| `SHOW_VARS` | `() -> str` | 列出当前定义的变量及其类型 |
|
|
121
|
+
| `answer` | `dict` | 设置 `answer["content"]=...; answer["ready"]=True` 以结束 |
|
|
122
|
+
|
|
123
|
+
## 设置 (`/rlm-config`)
|
|
124
|
+
|
|
125
|
+
| 设置 | 默认值 | 含义 |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| Smart model | Pi 的当前活动模型 | 根编排器 |
|
|
128
|
+
| Worker model | 最便宜的可用模型 | 响应 `llm_query` |
|
|
129
|
+
| Max recursion depth | `4` | 超过此深度的 `rlm_query` 将回退到 `llm_query` |
|
|
130
|
+
| Max iterations | `30` | 引擎完成前的最大轮数 |
|
|
131
|
+
| Budget ceiling | none | 当美元支出超过此值时停止整个树 |
|
|
132
|
+
| Max consecutive errors | `5` | 在 N 轮连续错误后停止 |
|
|
133
|
+
| REPL block timeout | `120s` | 每个 `repl` 块的墙上时钟时间 (worker 中的 SIGALRM) |
|
|
134
|
+
| Max concurrent sub-calls | `4` | `*_batched` 的池大小 |
|
|
135
|
+
| Orchestrator addendum | on | “委派,而非自行解决”的引导 |
|
|
136
|
+
| Trajectory compaction | on (0.85) | 当历史记录接近上下文窗口时进行总结 |
|
|
137
|
+
| `yolo` | off | 立即应用建议的修改,跳过审核弹出窗 |
|
|
138
|
+
| `askUserQuestion` | on | 向模型公开 `ask_user_question()` |
|
|
139
|
+
| `todo` | on | 向模型公开 `todo()` |
|
|
140
|
+
|
|
141
|
+
> **并发注意:** 每个 `rlm_query` 子节点都会启动自己的 `python3` worker (冷启动约 50–150 毫秒)。
|
|
142
|
+
> 最坏情况下的并发解释器数量 ≈ `maxConcurrentSubcalls`^(depth−1);在
|
|
143
|
+
> 默认设置下 (深度 4, 并发 4),极端情况下为 4³ = 64。预算和错误
|
|
144
|
+
> 上限 (见上文) 无论扇出 (fan-out) 如何都会限制总支出。
|
|
145
|
+
|
|
146
|
+
## 遥测与运行日志
|
|
147
|
+
|
|
148
|
+
- **运行日志** (`runLog`):默认始终开启。每次运行将 JSONL 轨迹写入 `.rlm/runs/`
|
|
149
|
+
(默认),上限为 `maxRuns` (50)。支持通过 `/rlm-resume` 进行**快照** (`sandbox.pkl`) 和**恢复**
|
|
150
|
+
被中断的任务。快照受每个会话的 `nonce` 保护,以防止跨会话重放。
|
|
151
|
+
- **MLflow 追踪** (`telemetry`):可选。设置 `MLFLOW_TRACKING_URI` 或在
|
|
152
|
+
`/rlm-config` 中配置 `trackingUri` / `experimentId`。根运行被标记为 MLflow span
|
|
153
|
+
以便在恢复时进行追踪关联。Bearer 令牌来自 `MLFLOW_TRACKING_TOKEN`
|
|
154
|
+
环境变量,且**绝不会**持久化到 `rlm.json`。
|
|
155
|
+
|
|
156
|
+
## 安全性
|
|
157
|
+
|
|
158
|
+
- **密钥隔离**:供应商密钥仅存在于 TypeScript (`AuthStorage`) 中;沙箱
|
|
159
|
+
接收提示词并返回文本 —— 绝不接触密钥。
|
|
160
|
+
- **环境清理**:在 worker 启动前会剥离敏感环境变量 (API 密钥, token)。
|
|
161
|
+
worker 无法从 `os.environ` 读取供应商凭据。
|
|
162
|
+
- **并非安全沙箱**:Python worker 公开了 `__import__` 和 `open`。模型编写的
|
|
163
|
+
代码可以导入网络模块、读写本地文件,并向 stdout 写入符合协议格式的 JSON。
|
|
164
|
+
此层级信任根模型的代码;stdio 协议隔离的是供应商密钥和
|
|
165
|
+
进程生命周期,**而非**对抗性代码的隔离。以后可以在不改变协议的情况下,
|
|
166
|
+
通过设置添加更强的沙箱 (Docker, seccomp)。
|
|
167
|
+
- **限制内置函数**:禁用 `eval`/`exec`/`compile`/`input`/`globals`/`locals`;每块
|
|
168
|
+
SIGALRM 超时 + 父进程监视器 (挂起时 SIGKILL);预算 / token / 超时 /
|
|
169
|
+
连续错误上限。
|
|
170
|
+
- **信任**:本地安装需要 Pi 项目信任。
|
|
171
|
+
|
|
172
|
+
## 项目布局
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
src/
|
|
176
|
+
sandbox/ worker.py + JSONL stdio driver (PythonSandbox) · protocol.ts · sandbox-manager.ts
|
|
177
|
+
bridge/ model.ts (one-shot completion) · llm-query.ts · rlm-query.ts (recursion)
|
|
178
|
+
core/ engine.ts (the loop) · iteration · limits · answer · compaction · pipeline · types
|
|
179
|
+
prompts/ system + per-turn prompts (ported from the Python reference)
|
|
180
|
+
text/ parsing (repl blocks) · tokens · preview · edits
|
|
181
|
+
state/ agent-tree · events · reads/writes · resume · paths · rows
|
|
182
|
+
tool/ repl-tool · rlm-events · aggregator · propose-edits · emitter-listener
|
|
183
|
+
config/ defaults · settings (rlm.json persistence + validation)
|
|
184
|
+
context/ repomix-based repository packing + caching
|
|
185
|
+
telemetry/ MLflow sink · dispatcher · mlflow-config
|
|
186
|
+
ui/ tree-widget · status · model-picker · config-panel · intro · theme
|
|
187
|
+
commands/ rlm · rlm-config
|
|
188
|
+
mode/ rlm-mode (controller) · input-router
|
|
189
|
+
patch/ apply · popup · index
|
|
190
|
+
util/ errors · concurrency
|
|
191
|
+
test/ phase1–phase9 · native-smoke · native-mode · helpers
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## 测试
|
|
195
|
+
|
|
196
|
+
运行时为 **Bun** (`bun install`, `bun run …` —— 绝不要使用 npm/pnpm/yarn)。
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
bun run test/phase1.ts # 沙箱:执行, 持久化, 密钥隔离, 超时终止
|
|
200
|
+
bun run test/phase4.ts # 递归深度限制逻辑 (不消耗 token)
|
|
201
|
+
bun run test/phase5.ts # 实时 agent 树渲染 (不消耗 token)
|
|
202
|
+
RLM_TEST_LIVE=1 bun run test/phase2.ts # 通过沙箱进行真实的 llm_query
|
|
203
|
+
RLM_TEST_LIVE=1 bun run test/phase3.ts # 在文件上下文中进行真实的端到端 /rlm 运行
|
|
204
|
+
RLM_TEST_LIVE=1 bun run test/phase4.ts # 引擎解决 20 个文档的“大海捞针”测试
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## 背景
|
|
208
|
+
|
|
209
|
+
基于 Python 参考实现 [`rlm`](https://github.com/alexzhang13/rlm-minimal) 和
|
|
210
|
+
[RLM 论文](https://arxiv.org/abs/2512.24601) 中的方法,为 Pi 原生重新实现。
|
|
211
|
+
|
|
212
|
+
如果您在研究中使用此项目,请引用原始 RLM 工作:
|
|
213
|
+
|
|
214
|
+
```bibtex
|
|
215
|
+
@misc{zhang2026recursivelanguagemodels,
|
|
216
|
+
title={Recursive Language Models},
|
|
217
|
+
author={Alex L. Zhang and Tim Kraska and Omar Khattab},
|
|
218
|
+
year={2026},
|
|
219
|
+
eprint={2512.24601},
|
|
220
|
+
archivePrefix={arXiv},
|
|
221
|
+
primaryClass={cs.AI},
|
|
222
|
+
url={https://arxiv.org/abs/2512.24601},
|
|
223
|
+
}
|
|
224
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hicaru/pi-rlm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Recursive Language Model (RLM) for the Pi coding agent — native, server-less.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "hicaru",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/hicaru/rlm.pi.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/hicaru/rlm.pi",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/hicaru/rlm.pi/issues"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"pi-package",
|
|
26
|
+
"pi-extension"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"check": "tsc --noEmit",
|
|
30
|
+
"prepublishOnly": "npm run check"
|
|
31
|
+
},
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./src/index.ts"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-ai": ">=0.79.0",
|
|
42
|
+
"@earendil-works/pi-coding-agent": ">=0.79.0",
|
|
43
|
+
"@earendil-works/pi-tui": ">=0.79.0",
|
|
44
|
+
"typebox": "*"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@mlflow/core": "~0.2.0",
|
|
48
|
+
"diff": "^9.0.0",
|
|
49
|
+
"repomix": "^1.15.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"typescript": "^5.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { formatError } from "../util/errors.ts";
|
|
2
|
+
|
|
3
|
+
type TaskStatus = "pending" | "in_progress" | "completed" | "deleted";
|
|
4
|
+
|
|
5
|
+
interface Task {
|
|
6
|
+
readonly id: number;
|
|
7
|
+
readonly subject: string;
|
|
8
|
+
readonly description?: string;
|
|
9
|
+
readonly status: TaskStatus;
|
|
10
|
+
readonly activeForm?: string;
|
|
11
|
+
readonly blockedBy?: readonly number[];
|
|
12
|
+
readonly owner?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface TodoParams {
|
|
16
|
+
readonly id?: number;
|
|
17
|
+
readonly subject?: string;
|
|
18
|
+
readonly description?: string;
|
|
19
|
+
readonly status?: TaskStatus;
|
|
20
|
+
readonly activeForm?: string;
|
|
21
|
+
readonly blockedBy?: readonly number[];
|
|
22
|
+
readonly addBlockedBy?: readonly number[];
|
|
23
|
+
readonly removeBlockedBy?: readonly number[];
|
|
24
|
+
readonly owner?: string;
|
|
25
|
+
readonly filterStatus?: string;
|
|
26
|
+
readonly includeDeleted?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TODO_STATUSES = Object.freeze(new Set<unknown>(["pending", "in_progress", "completed", "deleted"]));
|
|
30
|
+
|
|
31
|
+
function isTaskStatus(value: unknown): value is TaskStatus {
|
|
32
|
+
return TODO_STATUSES.has(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function numericArray(value: unknown): readonly number[] | undefined {
|
|
36
|
+
return Array.isArray(value) ? Object.freeze(value.filter((n): n is number => typeof n === "number")) : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toTodoParams(raw: Record<string, unknown>): TodoParams {
|
|
40
|
+
const blockedBy = numericArray(raw.blockedBy);
|
|
41
|
+
const addBlockedBy = numericArray(raw.addBlockedBy);
|
|
42
|
+
const removeBlockedBy = numericArray(raw.removeBlockedBy);
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
...(typeof raw.id === "number" ? { id: raw.id } : {}),
|
|
45
|
+
...(typeof raw.subject === "string" ? { subject: raw.subject } : {}),
|
|
46
|
+
...(typeof raw.description === "string" ? { description: raw.description } : {}),
|
|
47
|
+
...(isTaskStatus(raw.status) ? { status: raw.status } : {}),
|
|
48
|
+
...(typeof raw.activeForm === "string" ? { activeForm: raw.activeForm } : {}),
|
|
49
|
+
...(blockedBy ? { blockedBy } : {}),
|
|
50
|
+
...(addBlockedBy ? { addBlockedBy } : {}),
|
|
51
|
+
...(removeBlockedBy ? { removeBlockedBy } : {}),
|
|
52
|
+
...(typeof raw.owner === "string" ? { owner: raw.owner } : {}),
|
|
53
|
+
...(typeof raw.filterStatus === "string" ? { filterStatus: raw.filterStatus } : {}),
|
|
54
|
+
...(raw.includeDeleted === true ? { includeDeleted: true } : {}),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function patchedBlockedBy(task: Task, params: TodoParams): readonly number[] | undefined {
|
|
59
|
+
if (params.blockedBy) return params.blockedBy;
|
|
60
|
+
|
|
61
|
+
let next = task.blockedBy ?? Object.freeze([] as readonly number[]);
|
|
62
|
+
if (params.addBlockedBy) next = Object.freeze([...next, ...params.addBlockedBy]);
|
|
63
|
+
|
|
64
|
+
if (params.removeBlockedBy) {
|
|
65
|
+
const removeSet = new Set(params.removeBlockedBy);
|
|
66
|
+
next = Object.freeze(next.filter((n) => !removeSet.has(n)));
|
|
67
|
+
}
|
|
68
|
+
return next.length ? next : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function taskLines(task: Task): readonly string[] {
|
|
72
|
+
const lines: string[] = [`#${task.id} [${task.status}] ${task.subject}`];
|
|
73
|
+
if (task.description) lines.push(` description: ${task.description}`);
|
|
74
|
+
if (task.activeForm) lines.push(` activeForm: ${task.activeForm}`);
|
|
75
|
+
if (task.blockedBy?.length) lines.push(` blockedBy: ${task.blockedBy.map((n) => `#${n}`).join(", ")}`);
|
|
76
|
+
if (task.owner) lines.push(` owner: ${task.owner}`);
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function withPatch(task: Task, params: TodoParams): Task {
|
|
81
|
+
const blockedBy = patchedBlockedBy(task, params);
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
...task,
|
|
84
|
+
...(params.subject !== undefined ? { subject: params.subject } : {}),
|
|
85
|
+
...(params.description !== undefined ? { description: params.description } : {}),
|
|
86
|
+
...(params.status !== undefined ? { status: params.status } : {}),
|
|
87
|
+
...(params.activeForm !== undefined ? { activeForm: params.activeForm } : {}),
|
|
88
|
+
...(blockedBy ? { blockedBy } : {}),
|
|
89
|
+
...(params.owner !== undefined ? { owner: params.owner } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createTodoFallback(): (action: string, params: Record<string, unknown>) => Promise<string> {
|
|
94
|
+
let nextId = 1;
|
|
95
|
+
let tasks: readonly Task[] = Object.freeze([]);
|
|
96
|
+
const fmt = (task: Task): string => taskLines(task)[0] ?? `#${task.id}`;
|
|
97
|
+
|
|
98
|
+
const apply = (action: string, rawParams: Record<string, unknown>): string => {
|
|
99
|
+
const params = toTodoParams(rawParams);
|
|
100
|
+
if (action === "clear") {
|
|
101
|
+
const count = tasks.length;
|
|
102
|
+
tasks = Object.freeze([]);
|
|
103
|
+
nextId = 1;
|
|
104
|
+
return `Cleared ${count} task(s).`;
|
|
105
|
+
}
|
|
106
|
+
if (action === "create") {
|
|
107
|
+
const subject = typeof params.subject === "string" && params.subject.trim() ? params.subject.trim() : undefined;
|
|
108
|
+
if (!subject) return formatError("create requires subject");
|
|
109
|
+
const task = withPatch(Object.freeze({ id: nextId, subject, status: "pending" }), params);
|
|
110
|
+
nextId += 1;
|
|
111
|
+
tasks = Object.freeze([...tasks, task]);
|
|
112
|
+
return `Created ${fmt(task)}`;
|
|
113
|
+
}
|
|
114
|
+
if (action === "list") {
|
|
115
|
+
const filter = params.filterStatus ?? params.status;
|
|
116
|
+
const includeDeleted = params.includeDeleted === true;
|
|
117
|
+
const rows = tasks.filter((task) => (includeDeleted || task.status !== "deleted") && (!filter || task.status === filter)).map(fmt);
|
|
118
|
+
return rows.length ? rows.join("\n") : "No tasks.";
|
|
119
|
+
}
|
|
120
|
+
const id = params.id;
|
|
121
|
+
const task = id !== undefined ? tasks.find((item) => item.id === id) : undefined;
|
|
122
|
+
if (!task) return formatError(`task #${id ?? "?"} not found`);
|
|
123
|
+
if (action === "get") return taskLines(task).join("\n");
|
|
124
|
+
if (action === "delete") {
|
|
125
|
+
const deleted = Object.freeze({ ...task, status: "deleted" as const });
|
|
126
|
+
tasks = Object.freeze(tasks.map((item) => item.id === task.id ? deleted : item));
|
|
127
|
+
return `Deleted ${fmt(deleted)}`;
|
|
128
|
+
}
|
|
129
|
+
if (action === "update") {
|
|
130
|
+
const updated = withPatch(task, params);
|
|
131
|
+
tasks = Object.freeze(tasks.map((item) => item.id === task.id ? updated : item));
|
|
132
|
+
return `Updated ${fmt(updated)}`;
|
|
133
|
+
}
|
|
134
|
+
return formatError(`unknown todo action '${action}'`);
|
|
135
|
+
};
|
|
136
|
+
return async (action, params) => apply(action, params);
|
|
137
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
|
|
2
|
+
import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
3
|
+
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
4
|
+
import { formatError } from "../util/errors.ts";
|
|
5
|
+
|
|
6
|
+
export interface InteractiveBridgeOpts {
|
|
7
|
+
readonly onAskUserQuestion?: (questions: readonly AskQuestion[]) => Promise<AskAnswer[]>;
|
|
8
|
+
readonly onTodo?: (action: string, params: Record<string, unknown>) => Promise<string>;
|
|
9
|
+
readonly onTodoRow?: (action: string, params: Record<string, unknown>, result: string) => void | Promise<void>;
|
|
10
|
+
readonly emitter?: RlmEmitter;
|
|
11
|
+
readonly depth: number;
|
|
12
|
+
readonly parentId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildInteractiveHandlers(opts: InteractiveBridgeOpts): {
|
|
16
|
+
askUserQuestion: SubLlmHandlers["askUserQuestion"];
|
|
17
|
+
todo: SubLlmHandlers["todo"];
|
|
18
|
+
} {
|
|
19
|
+
return {
|
|
20
|
+
async askUserQuestion(questions, depth) {
|
|
21
|
+
if (depth > 0) return questions.map((q) => ({
|
|
22
|
+
question: q.question,
|
|
23
|
+
selected: [],
|
|
24
|
+
custom: formatError("ask_user_question not available inside rlm_query sub-calls"),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const id = opts.emitter?.emitSubcallCreated({
|
|
28
|
+
kind: "tool", parentId: opts.parentId,
|
|
29
|
+
label: "ask_user_question",
|
|
30
|
+
args: `${questions.length} question(s)`,
|
|
31
|
+
depth,
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
const cb = opts.onAskUserQuestion;
|
|
35
|
+
if (!cb) throw new Error("ask_user_question not configured (no onAskUserQuestion callback)");
|
|
36
|
+
const answers = await cb(questions);
|
|
37
|
+
if (id) opts.emitter?.emitSubcallUpdated({ id, status: "done" });
|
|
38
|
+
return answers;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (id) opts.emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async todo(action, params, depth) {
|
|
46
|
+
const id = opts.emitter?.emitSubcallCreated({
|
|
47
|
+
kind: "tool", parentId: opts.parentId,
|
|
48
|
+
label: `todo:${action}`,
|
|
49
|
+
args: params.subject ? String(params.subject) : String(params.id ?? ""),
|
|
50
|
+
depth,
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
const cb = opts.onTodo;
|
|
54
|
+
if (!cb) throw new Error("todo not configured (no onTodo callback)");
|
|
55
|
+
const result = await cb(action, params);
|
|
56
|
+
await opts.onTodoRow?.(action, params, result);
|
|
57
|
+
if (id) opts.emitter?.emitSubcallUpdated({ id, status: "done", resultPreview: result.slice(0, 80) });
|
|
58
|
+
return result;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (id) opts.emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `llm_query` / `llm_query_batched` bridge: turns sandbox sub-LLM interrupts into
|
|
3
|
+
* real (serverless) completions on the configured *worker* model, reporting each call to the
|
|
4
|
+
* RlmEmitter for progressive TUI re-rendering.
|
|
5
|
+
*
|
|
6
|
+
* Caps enforce the divide-and-conquer budget from the RLM method: per-prompt size and batch
|
|
7
|
+
* fan-out are bounded, and batches run through a fixed-size concurrency pool.
|
|
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 { RlmEmitter } from "../tool/rlm-events.ts";
|
|
13
|
+
import { modelRef, resolveModelId } from "../config/settings.ts";
|
|
14
|
+
import type { Sampling } from "../core/types.ts";
|
|
15
|
+
import { type ChatMsg, modelComplete } from "./model.ts";
|
|
16
|
+
import { previewText } from "../text/preview.ts";
|
|
17
|
+
import { formatError, isErrorText } from "../util/errors.ts";
|
|
18
|
+
import { mapPool } from "../util/concurrency.ts";
|
|
19
|
+
|
|
20
|
+
export interface LlmBridgeOptions {
|
|
21
|
+
readonly workerModel: Model<Api>;
|
|
22
|
+
readonly registry: ModelRegistry;
|
|
23
|
+
readonly subSystem?: string;
|
|
24
|
+
readonly maxPromptChars?: number;
|
|
25
|
+
readonly maxConcurrent?: number;
|
|
26
|
+
readonly sampling?: Sampling;
|
|
27
|
+
readonly signal?: AbortSignal;
|
|
28
|
+
readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
|
|
29
|
+
/** Live RlmDetails reporting via onUpdate. */
|
|
30
|
+
readonly emitter?: RlmEmitter;
|
|
31
|
+
readonly parentId?: string;
|
|
32
|
+
readonly depth?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_MAX_PROMPT_CHARS = 400_000;
|
|
36
|
+
const DEFAULT_MAX_CONCURRENT = 4;
|
|
37
|
+
|
|
38
|
+
export interface LlmBridge {
|
|
39
|
+
llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
40
|
+
llmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
|
|
44
|
+
const maxPromptChars = opts.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS;
|
|
45
|
+
const maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
46
|
+
const { emitter } = opts;
|
|
47
|
+
const displayModel = (model: string | null): string =>
|
|
48
|
+
modelRef(model ? (resolveModelId(opts.registry, model) ?? opts.workerModel) : opts.workerModel) ?? opts.workerModel.id;
|
|
49
|
+
|
|
50
|
+
// Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
|
|
51
|
+
async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
|
|
52
|
+
if (prompt.length > maxPromptChars) {
|
|
53
|
+
return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
|
|
54
|
+
}
|
|
55
|
+
const resolved = model ? resolveModelId(opts.registry, model) : undefined;
|
|
56
|
+
if (model && !resolved) return formatError(`unknown model override '${model}'`);
|
|
57
|
+
try {
|
|
58
|
+
const messages: ChatMsg[] = [{ role: "user", content: prompt }];
|
|
59
|
+
const res = await modelComplete(messages, {
|
|
60
|
+
model: resolved ?? opts.workerModel,
|
|
61
|
+
registry: opts.registry,
|
|
62
|
+
system: opts.subSystem,
|
|
63
|
+
maxTokens: opts.sampling?.maxTokens,
|
|
64
|
+
temperature: opts.sampling?.temperature,
|
|
65
|
+
reasoning: opts.sampling?.reasoning,
|
|
66
|
+
signal: opts.signal,
|
|
67
|
+
});
|
|
68
|
+
opts.onUsage?.(res.usage, resolved ?? opts.workerModel);
|
|
69
|
+
track(res.usage);
|
|
70
|
+
return res.text;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return formatError(err instanceof Error ? err.message : String(err));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async llmQuery(prompt, model) {
|
|
78
|
+
const id = emitter?.emitSubcallCreated({
|
|
79
|
+
kind: "llm", parentId: opts.parentId, label: "llm_query",
|
|
80
|
+
model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
|
|
81
|
+
depth: opts.depth ?? 0,
|
|
82
|
+
});
|
|
83
|
+
let cost = 0;
|
|
84
|
+
let tokens = 0;
|
|
85
|
+
const out = await complete1(prompt, model, (u) => {
|
|
86
|
+
cost += u.cost.total;
|
|
87
|
+
tokens += u.totalTokens;
|
|
88
|
+
});
|
|
89
|
+
if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
|
|
90
|
+
status: isErrorText(out) ? "error" : "done",
|
|
91
|
+
costUsd: cost, tokens, resultPreview: previewText(out),
|
|
92
|
+
detail: isErrorText(out) ? out : undefined,
|
|
93
|
+
});
|
|
94
|
+
return out;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async llmQueryBatched(prompts, model) {
|
|
98
|
+
const id = emitter?.emitSubcallCreated({
|
|
99
|
+
kind: "batch", parentId: opts.parentId, label: `llm_query ×${prompts.length}`,
|
|
100
|
+
model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
|
|
101
|
+
depth: opts.depth ?? 0,
|
|
102
|
+
});
|
|
103
|
+
let cost = 0;
|
|
104
|
+
let tokens = 0;
|
|
105
|
+
const out = await mapPool(prompts, maxConcurrent, (p) =>
|
|
106
|
+
complete1(p, model, (u) => {
|
|
107
|
+
cost += u.cost.total;
|
|
108
|
+
tokens += u.totalTokens;
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const failed = out.filter(isErrorText).length;
|
|
112
|
+
const error = failed === out.length && out.length > 0
|
|
113
|
+
? `all ${out.length} sub-calls failed`
|
|
114
|
+
: failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
|
|
115
|
+
const firstPreview = previewText(out[0] ?? "");
|
|
116
|
+
const resultPreview = out.length > 1 ? `${firstPreview} (+${out.length - 1} more)` : firstPreview;
|
|
117
|
+
if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
|
|
118
|
+
status: error ? "error" : "done", costUsd: cost, tokens,
|
|
119
|
+
resultPreview, detail: error,
|
|
120
|
+
});
|
|
121
|
+
return out;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|