@hicaru/pi-rlm 0.3.15 → 0.3.16

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,6 +1,6 @@
1
- <p align="center">
2
- <img src="https://github.com/openzebra/rlm.pi/blob/master/assets/plugin-cover.png?raw=true" width="100%" alt="pi-rlm — Recursive Language Model plugin for Pi">
3
- </p>
1
+ # rlm.pi PI plugin
2
+
3
+ > pi-rlm — Large contexts on cheap models: Recursive Language Model (RLM) for Pi
4
4
 
5
5
  <p align="center">
6
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>
@@ -14,45 +14,78 @@
14
14
  <a href="https://www.npmjs.com/package/@hicaru/pi-rlm">📦 npm</a>
15
15
  </p>
16
16
 
17
- ---
17
+ ## Install
18
+
19
+ ```bash
20
+ pi install npm:@hicaru/pi-rlm
21
+ ```
22
+
23
+ Run `/reload` in Pi — `/rlm`, `/rlm-config`, `/rlm-stop` appear under **[Extensions]**.
24
+ Toggle with `Ctrl+Shift+R` or `/rlm`.
25
+
26
+ To remove it later:
27
+
28
+ ```bash
29
+ pi uninstall npm:@hicaru/pi-rlm
30
+ ```
31
+
32
+ <p align="center">
33
+ <img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" width="100%" alt="rlm.pi — OOLONG benchmark results">
34
+ </p>
35
+
36
+ ## What is pi-rlm?
18
37
 
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 engineyour best model orchestrates, cheap worker models
22
- do the reading.
38
+ A Pi plugin that turns your session into a **Recursive Language Model (RLM)**: instead of
39
+ stuffing a huge document into the prompt, the context lives in a Python REPL and your best
40
+ model orchestrates itsearching, decomposing, and delegating leaf reads to cheap worker
41
+ models, recursively. Same Pi session, same tools, same keys: `/rlm` and go. Reads
42
+ `.pdf` `.docx` `.xlsx` `.epub` and more, works with any OpenRouter model, 100% local.
23
43
 
24
- > **One install. One toggle. Infinite context.**
44
+ ## Benchmarks
25
45
 
26
- ## Why pi-rlm?
46
+ <p align="center">
47
+ <img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" width="100%" alt="OOLONG benchmark — latest results">
48
+ </p>
27
49
 
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. |
50
+ **OOLONG (oolong-synth)** paper-tier long-context suite; latest journal per model,
51
+ cost per task from real `costUsd` (older journals estimated at OpenRouter list prices):
36
52
 
37
- ## See it in action
53
+ | Model | Score | Avg. cost/task |
54
+ |-------|-------|----------------|
55
+ | `qwen/qwen3.8-27b` | **100%** | $0.0127 |
56
+ | `google/gemma-3-27b-it` | 83.3% | $0.0013 |
57
+ | `qwen/qwen3-30b-a3b-instruct-2507` | 66.7% | $0.0009 |
58
+ | `mistralai/mistral-small-3.2-24b-instruct` | 66.7% | $0.0025 |
38
59
 
39
- <div align="center">
60
+ Lite suite — `needle` multi-needle recall, `codeqa` repo-QA, `coding` fix task
61
+ (7 tasks × 2 passes per model, deterministic graders, no LLM-as-judge):
40
62
 
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/master/assets/hero.png"></video>
63
+ | Model | Score | Accuracy |
64
+ |-------|-------|----------|
65
+ | `qwen/qwen3-30b-a3b-instruct-2507` | **14/14** | **100%** |
66
+ | `google/gemma-3-27b-it` | 12/14 | 86% |
67
+ | `mistralai/mistral-small-3.2-24b-instruct` | 12/14 | 86% |
42
68
 
43
- </div>
69
+ Raw per-task rows (correct, recall, latency, tokens, cost) live in
70
+ `bench/runs/*.jsonl` — one JSONL row per task, committed as history.
44
71
 
45
- ## Install (30 seconds)
72
+ ### Run the benchmarks
46
73
 
47
74
  ```bash
48
- pi install npm:@hicaru/pi-rlm
49
- ```
75
+ export OPENROUTER_API_KEY=sk-or-... # required — env vars are the only key transport
50
76
 
51
- Run `/reload` in Pi. Done. `/rlm`, `/rlm-config`, `/rlm-stop` appear under **[Extensions]**.
77
+ bun run bench # lite suite: needle + codeqa + coding
78
+ bun run bench --suite needle --limit 1 # one suite, first task only
79
+ bun run bench --model openrouter/qwen/qwen3-30b-a3b-instruct-2507
80
+ bun run bench --list # print tasks, no engine / no key
81
+ bun run bench --suite paper # paper tier: s_niah, oolong, browsecomp, codeqa_lb (downloads datasets)
82
+ ```
52
83
 
53
- Toggle with `Ctrl+Shift+R` or `/rlm` plain prompts now route through the RLM engine.
84
+ Suites: `all` (lite, default) · `needle` · `codeqa` · `coding` · `paper` · `s_niah` ·
85
+ `oolong` · `browsecomp` · `codeqa_lb`. Regenerate the hero chart:
86
+ `python3 bench/hero.py` (needs `matplotlib`).
54
87
 
55
- ## What you get
88
+ ## How it works
56
89
 
57
90
  ```
58
91
  ┌─────────────────────────┐
@@ -106,10 +139,6 @@ A **Recursive Language Model (RLM)** replaces `llm.completion(prompt)` with
106
139
  launch sub-LLM and sub-RLM calls as ordinary Python functions — decomposing,
107
140
  delegating, and synthesizing across a tree of models, not a single context window.
108
141
 
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.
112
-
113
142
  ## Commands
114
143
 
115
144
  | Command | Shortcut | What it does |
@@ -129,6 +158,45 @@ inside Pi — same session, same tools, same everything.
129
158
  | REPL timeout | `120`s | Bump for slow computations |
130
159
  | Max concurrent subs | `16` | More parallelism (costs RAM) |
131
160
 
161
+ ## Sampling & reproducibility
162
+
163
+ The r3 bench showed the biggest capability lever is not the model — it is the sampling:
164
+ temperature 0 took OOLONG from 71% pooled / 40% flips to 91.7–100% all-stable for $1.74
165
+ total. Those knobs are first-class in `rlm.json` (`~/.pi/agent/rlm.json`) and on the
166
+ `/rlm-config` panel:
167
+
168
+ | Field | Where | Default | What it governs |
169
+ |-------|-------|---------|-----------------|
170
+ | `rootSampling.maxTokens` | rlm.json, panel | `16384` | Output cap per root-model turn (finalize included) |
171
+ | `rootSampling.temperature` | rlm.json, panel | provider default | Root + finalize sampling temperature; `0` = deterministic |
172
+ | `smartReasoning` | rlm.json, panel | none | Thinking effort for the root model |
173
+ | `subSampling.maxTokens` | rlm.json, panel | `8192` | Output cap per leaf sub-call (`llm_query`, `llm_batch`, `map_files`) |
174
+ | `subSampling.temperature` | rlm.json, panel | provider default | Leaf sampling temperature |
175
+ | `enableVerificationNudge` | rlm.json | off | One coached redo when the root finalizes early with a bare number / short label |
176
+
177
+ **Reproducibility recipe (validated by r3):**
178
+
179
+ ```json
180
+ {
181
+ "config": {
182
+ "rootSampling": { "maxTokens": 8192, "temperature": 0, "reasoning": "high" }
183
+ }
184
+ }
185
+ ```
186
+
187
+ Reasoning tokens share the completion budget with the answer — with thinking on, keep
188
+ `maxTokens` generous (the bench doubles it to 8192; the engine warns once on turn 0 when it
189
+ is tight).
190
+
191
+ **Scope boundary:** `rlm.json` sampling applies to RLM-mode runs, `rlm()` delegation, and
192
+ child recursion at any depth (same engine function). The native Pi agent loop follows Pi's
193
+ own session settings — rlm.json never touches it.
194
+
195
+ **Model capability:** reasoning requires a model whose registry entry has `reasoning: true`.
196
+ Anything else has the level dropped before it reaches the provider (pi-ai clamps unsupported
197
+ levels to off); capability comes from the registry, so OpenRouter hybrids like
198
+ `qwen/qwen3.8-27b` just work.
199
+
132
200
  ## Prompt Architecture
133
201
 
134
202
  The system prompt follows a **contract / routing / examples / rules** pattern
@@ -142,38 +210,6 @@ The system prompt follows a **contract / routing / examples / rules** pattern
142
210
  **Key insight:** children see `Recursion depth: N` and calibrate ambition —
143
211
  delegating only when their task genuinely decomposes further.
144
212
 
145
- ## Benchmarks
146
-
147
- E2E runs of the real engine against OpenRouter chat models — the lite suite
148
- (`needle` multi-needle recall, `codeqa` repo-QA, `coding` fix task; 7 tasks × 2 passes
149
- per model). Deterministic graders (recall / gold containment / regex), no LLM-as-judge.
150
-
151
- Latest results — small models (≤32B parameters, paid tier):
152
-
153
- | Model | Params | Score | Accuracy | Latency/task |
154
- |-------|--------|-------|----------|--------------|
155
- | `qwen/qwen3-30b-a3b-instruct-2507` | MoE 30B / 3B active | **14/14** | **100%** | ~15s |
156
- | `google/gemma-3-27b-it` | dense 27B | 12/14 | 86% | ~26s |
157
- | `mistralai/mistral-small-3.2-24b-instruct` | dense 24B | 12/14 | 86% | ~28s |
158
-
159
- Raw per-task rows (correct, recall, latency, tokens, cost) live in
160
- `bench/runs/bench-<ts>.jsonl` — one JSONL row per task, committed as history.
161
-
162
- ### Run the benchmarks
163
-
164
- ```bash
165
- export OPENROUTER_API_KEY=sk-or-... # required — env vars are the only key transport
166
-
167
- bun run bench # lite suite: needle + codeqa + coding
168
- bun run bench --suite needle --limit 1 # one suite, first task only
169
- bun run bench --model openrouter/qwen/qwen3-30b-a3b-instruct-2507
170
- bun run bench --list # print tasks, no engine / no key
171
- bun run bench --suite paper # paper tier: s_niah, oolong, browsecomp, codeqa_lb (downloads datasets)
172
- ```
173
-
174
- Suites: `all` (lite, default) · `needle` · `codeqa` · `coding` · `paper` · `s_niah` ·
175
- `oolong` · `browsecomp` · `codeqa_lb`.
176
-
177
213
  ## Security
178
214
 
179
215
  - **Key isolation** — provider keys live in TypeScript only; sandbox receives prompts, returns text.
@@ -182,12 +218,6 @@ Suites: `all` (lite, default) · `needle` · `codeqa` · `coding` · `paper` ·
182
218
  - **Per-block timeout** — SIGALRM + parent watchdog (SIGKILL on hang).
183
219
  - **Trust** — project-local install requires Pi project trust.
184
220
 
185
- ## Uninstall
186
-
187
- ```bash
188
- pi uninstall npm:@hicaru/pi-rlm
189
- ```
190
-
191
221
  ## License
192
222
 
193
223
  MIT — see [LICENSE](./LICENSE).
package/README.ru.md CHANGED
@@ -132,6 +132,43 @@ rm -rf ~/.pi/agent/extensions/rlm
132
132
 
133
133
  > **Примечание по параллелизму:** каждый дочерний `rlm_query` запускает собственного worker `python3` (~50–150 мс «холодного старта»). В худшем случае количество параллельных интерпретаторов ≈ `maxConcurrentSubcalls`^(depth−1); при настройках по умолчанию (глубина 4, параллелизм 4) это 4³ = 64 в патологическом случае. Лимиты бюджета и ошибок (см. выше) ограничивают общие затраты независимо от степени разветвления.
134
134
 
135
+ ## Сэмплинг и воспроизводимость
136
+
137
+ Бенчмарк r3 показал: самый большой рычаг качества — не модель, а сэмплинг. Temperature 0
138
+ перевела OOLONG с 71% (pooled) / 40% (flips) на 91.7–100% all-stable за $1.74 суммарно.
139
+ Эти ручки — полноправные поля `rlm.json` (`~/.pi/agent/rlm.json`) и панели `/rlm-config`:
140
+
141
+ | Поле | Где | По умолчанию | За что отвечает |
142
+ |---|---|---|---|
143
+ | `rootSampling.maxTokens` | rlm.json, панель | `16384` | Лимит вывода на ход корневой модели (включая finalize) |
144
+ | `rootSampling.temperature` | rlm.json, панель | провайдер | Температура корневых ходов и finalize; `0` = детерминированно |
145
+ | `smartReasoning` | rlm.json, панель | нет | Усилие «размышления» корневой модели |
146
+ | `subSampling.maxTokens` | rlm.json, панель | `8192` | Лимит вывода одного листового вызова (`llm_query`, `llm_batch`, `map_files`) |
147
+ | `subSampling.temperature` | rlm.json, панель | провайдер | Температура листовых вызовов |
148
+ | `enableVerificationNudge` | rlm.json | выкл | Один повтор с подсказкой, если корень рано финализирует «голым числом» |
149
+
150
+ **Рецепт воспроизводимости (проверен r3):**
151
+
152
+ ```json
153
+ {
154
+ "config": {
155
+ "rootSampling": { "maxTokens": 8192, "temperature": 0, "reasoning": "high" }
156
+ }
157
+ }
158
+ ```
159
+
160
+ Токены размышлений делят бюджет завершения с ответом — при включённом thinking держите
161
+ `maxTokens` щедрым (бенчмарк удваивает его до 8192; движок один раз предупредит на ходе 0,
162
+ если лимит тесный).
163
+
164
+ **Граница действия:** сэмплинг из `rlm.json` управляет запусками RLM-режима, делегированием
165
+ `rlm()` и рекурсией на любой глубине (тот же движок). Собственный цикл агента Pi следует
166
+ настройкам сессии Pi — rlm.json его не касается.
167
+
168
+ **Возможности модели:** reasoning требует модель с `reasoning: true` в записи реестра.
169
+ Для остальных уровень отбрасывается до отправки провайдеру (pi-ai приводит его к off);
170
+ возможность берётся из реестра, а не из конфига.
171
+
135
172
  ## Логи запусков
136
173
 
137
174
 
package/README.zh-CN.md CHANGED
@@ -141,6 +141,40 @@ rm -rf ~/.pi/agent/extensions/rlm
141
141
  > 默认设置下 (深度 4, 并发 4),极端情况下为 4³ = 64。预算和错误
142
142
  > 上限 (见上文) 无论扇出 (fan-out) 如何都会限制总支出。
143
143
 
144
+ ## 采样与可复现性
145
+
146
+ r3 基准测试表明:最大的能力杠杆不是模型,而是采样。仅 temperature 0 就让 OOLONG 从
147
+ 71% (pooled) / 40% (flips) 提升到 91.7–100% all-stable,总计仅花费 $1.74。这些旋钮在
148
+ `rlm.json` (`~/.pi/agent/rlm.json`) 和 `/rlm-config` 面板中都是一等公民:
149
+
150
+ | 字段 | 位置 | 默认值 | 作用范围 |
151
+ |---|---|---|---|
152
+ | `rootSampling.maxTokens` | rlm.json, 面板 | `16384` | 根模型每轮的输出上限 (包括 finalize) |
153
+ | `rootSampling.temperature` | rlm.json, 面板 | 提供商默认 | 根轮次与 finalize 的采样温度;`0` = 确定性 |
154
+ | `smartReasoning` | rlm.json, 面板 | 无 | 根模型的思考强度 |
155
+ | `subSampling.maxTokens` | rlm.json, 面板 | `8192` | 每个叶子子调用 (`llm_query`, `llm_batch`, `map_files`) 的输出上限 |
156
+ | `subSampling.temperature` | rlm.json, 面板 | 提供商默认 | 叶子调用的采样温度 |
157
+ | `enableVerificationNudge` | rlm.json | 关 | 当根节点过早以“裸数字”定稿时,给予一次带提示的重做 |
158
+
159
+ **可复现性配方 (经 r3 验证):**
160
+
161
+ ```json
162
+ {
163
+ "config": {
164
+ "rootSampling": { "maxTokens": 8192, "temperature": 0, "reasoning": "high" }
165
+ }
166
+ }
167
+ ```
168
+
169
+ 思考 token 与答案共享补全预算——开启 thinking 时请保持充裕的 `maxTokens`
170
+ (基准测试将其翻倍至 8192;若过紧,引擎会在第 0 轮警告一次)。
171
+
172
+ **作用边界:** `rlm.json` 的采样适用于 RLM 模式运行、`rlm()` 委托以及任意深度的子递归
173
+ (同一个引擎函数)。Pi 原生代理循环遵循 Pi 自身的会话设置——rlm.json 不会触及它。
174
+
175
+ **模型能力:** reasoning 要求模型的注册表条目为 `reasoning: true`。其他情况会在发送给
176
+ 提供商之前丢弃该级别 (pi-ai 会将不支持的级别钳制为 off);能力来自注册表,而非配置。
177
+
144
178
  ## 运行日志
145
179
 
146
180
  - **密钥隔离**:供应商密钥仅存在于 TypeScript (`AuthStorage`) 中;沙箱
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,6 +9,7 @@
9
9
  * RLM root (full history).
10
10
  */
11
11
 
12
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
12
13
  import { type Api, completeSimple, type Message, type Model, type ThinkingLevel, type Usage } from "@earendil-works/pi-ai/compat";
13
14
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
14
15
  import { completeWithRetry, DEFAULT_RETRY_POLICY, type RetryPolicy } from "../util/retry.ts";
@@ -68,6 +69,19 @@ function toPiMessages(messages: readonly ChatMsg[], model: Model<Api>): { readon
68
69
  return { systemPrompt, messages: out };
69
70
  }
70
71
 
72
+ /**
73
+ * Explicit reasoning gate. pi-ai clamps unsupported levels too (a model with `reasoning:
74
+ * false` supports only "off", so any requested level clamps to it and nothing goes out on
75
+ * the wire) — owning the gate here makes the behavior engine policy, testable without a
76
+ * provider, and drops the option outright instead of forwarding a level the model cannot
77
+ * honor. Capability comes from the registry entry, never from config.
78
+ */
79
+ export function effectiveReasoning(model: Model<Api>, level: ThinkingLevel | undefined): ThinkingLevel | undefined {
80
+ if (level === undefined) return undefined;
81
+ const clamped = clampThinkingLevel(model, level);
82
+ return clamped === "off" ? undefined : clamped;
83
+ }
84
+
71
85
  /** Extract the assistant's plain text from a completion. */
72
86
  function extractText(content: readonly { readonly type: string; readonly text?: string }[]): string {
73
87
  return content
@@ -97,7 +111,7 @@ export async function modelComplete(messages: readonly ChatMsg[], opts: Complete
97
111
  headers: auth.headers,
98
112
  maxTokens: opts.maxTokens,
99
113
  temperature: opts.temperature,
100
- reasoning: opts.reasoning,
114
+ reasoning: effectiveReasoning(opts.model, opts.reasoning),
101
115
  signal: opts.signal,
102
116
  onResponse: (res) => { note(res.status, res.headers); },
103
117
  },
@@ -58,4 +58,7 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
58
58
  memoryDir: null,
59
59
  // v5 role separation: children delegate (llm + memory/ledger); "legacy" = full child surface.
60
60
  childSurface: "delegation",
61
+ // Verification-discipline nudge — deliberately OFF (plan guardrail): when on, an early
62
+ // bare-number finalize gets one coached redo instead of being accepted. Opt-in via rlm.json.
63
+ enableVerificationNudge: false,
61
64
  });
@@ -38,9 +38,10 @@ function validateString(v: unknown): string | undefined {
38
38
  /**
39
39
  * Every value pi-ai accepts for `reasoning`. Keyed by the union so a new level added upstream
40
40
  * is a compile error here rather than a silently-rejected setting. Note `off` is NOT a
41
- * ThinkingLevel — a hand-edited rlm.json carrying one is dropped, not forwarded.
41
+ * ThinkingLevel — a hand-edited rlm.json carrying one is dropped, not forwarded. Exported so
42
+ * the config panel derives its choices from the same source of truth.
42
43
  */
43
- const THINKING_LEVELS: Readonly<Record<ThinkingLevel, true>> = Object.freeze({
44
+ export const THINKING_LEVELS: Readonly<Record<ThinkingLevel, true>> = Object.freeze({
44
45
  minimal: true, low: true, medium: true, high: true, xhigh: true, max: true,
45
46
  });
46
47
 
@@ -149,6 +150,9 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
149
150
  }
150
151
  // v5 child surface doctrine
151
152
  if (r.childSurface === "delegation" || r.childSurface === "legacy") out.childSurface = r.childSurface;
153
+ // Verification-discipline nudge (default OFF).
154
+ const enableVerificationNudge = validateBoolean(r.enableVerificationNudge);
155
+ if (enableVerificationNudge !== undefined) out.enableVerificationNudge = enableVerificationNudge;
152
156
  if (typeof r.subSampling === "object" && r.subSampling !== null) {
153
157
  const ss = r.subSampling as Record<string, unknown>;
154
158
  const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
@@ -20,7 +20,7 @@ import { TaskLedger, contextSig, taskKey } from "./ledger.ts";
20
20
  import { type MemoryStore, rootContextPaths } from "./memory.ts";
21
21
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
22
22
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
23
- import { buildTurnPrompt, FINALIZE_PROMPT, RETRIEVAL_NUDGE } from "../prompts/user.ts";
23
+ import { buildTurnPrompt, FINALIZE_PROMPT, RETRIEVAL_NUDGE, REASONING_BUDGET_HINT, VERIFICATION_NUDGE } from "../prompts/user.ts";
24
24
  import type { RlmEmitter } from "../tool/rlm-events.ts";
25
25
  import type { SubcallPhase } from "../tool/rlm-details.ts";
26
26
  import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
@@ -46,6 +46,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
46
46
  * hold a finished run open on work whose result nobody can receive.
47
47
  */
48
48
  const DETACHED_SETTLE_MS = 5_000;
49
+ /** Verification nudge (enableVerificationNudge): only an EARLY finalize is suspicious — from
50
+ * turn 4 on, a bare answer is just... an answer. "Before iteration ~4", per the bench data. */
51
+ const VERIFICATION_NUDGE_TURN_CAP = 4;
49
52
  /** H6 (audit): root episodes snapshot at most this many real files — replay invalidation for
50
53
  * the disk-backed slice of the context without hashing an unbounded repository. */
51
54
  const ROOT_HASH_MAX = 64;
@@ -253,6 +256,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
253
256
  // H3: retrieval-discipline coach — one-shot per run; children inherit it via the same loop.
254
257
  let sawRetrieval = false;
255
258
  let retrievalNudged = false;
259
+ // Verification-discipline coach (enableVerificationNudge, default OFF): one coached redo
260
+ // when an early finalize looks like the confident-wrong bench shape.
261
+ let verificationNudged = false;
262
+ let verificationNudgePending = false;
256
263
 
257
264
  try {
258
265
  const meta = {
@@ -320,6 +327,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
320
327
  liveContext = input.context ?? [];
321
328
  contextPin = await pinContext(liveContext);
322
329
  await sandbox.loadContextPinned(contextPin);
330
+
331
+ // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
332
+ // Loop-invariant — built once here; finalize() applies the same merge to its own turn.
333
+ const rootSampling: Sampling = {
334
+ reasoning: deps.config.smartReasoning,
335
+ ...deps.config.rootSampling,
336
+ };
323
337
  for (let i = 0; i < deps.config.maxIterations; i++) {
324
338
  limits.checkTimeout();
325
339
  if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
@@ -369,16 +383,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
369
383
  ledgerBlock === "" ? undefined : ledgerBlock,
370
384
  memoryBlock === "" ? undefined : memoryBlock,
371
385
  nudgeNow ? RETRIEVAL_NUDGE : undefined,
386
+ verificationNudgePending ? VERIFICATION_NUDGE : undefined,
387
+ // One-shot (turn 0 only): thinking tokens share the completion budget — mirror of
388
+ // the bench's doubling rule. Advisory; never fatal, never repeated.
389
+ i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) < 8_192
390
+ ? REASONING_BUDGET_HINT
391
+ : undefined,
372
392
  ]
373
393
  .filter((s): s is string => s !== undefined)
374
394
  .join("\n\n") || undefined;
395
+ verificationNudgePending = false;
375
396
  appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
376
397
 
377
- // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
378
- const rootSampling: Sampling = {
379
- reasoning: deps.config.smartReasoning,
380
- ...deps.config.rootSampling,
381
- };
382
398
  const turn = await runTurn(history, sandbox, {
383
399
  model: model,
384
400
  registry: deps.registry,
@@ -414,10 +430,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
414
430
  completedTurns = i + 1;
415
431
  const final = finalAnswerOf(turn.results);
416
432
  if (final != null) {
417
- const done = result(final, i + 1, limits);
418
- persistRoot(done.answer);
419
- lastAnswer = done.answer;
420
- return done;
433
+ // Verification-discipline nudge (enableVerificationNudge, default OFF): an early
434
+ // finalize whose answer is a bare number / short label is the confident-wrong shape
435
+ // that dominated bench failures. ONE coached redo, then the answer is accepted.
436
+ if (deps.config.enableVerificationNudge === true && !verificationNudged
437
+ && completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final)) {
438
+ verificationNudged = true;
439
+ verificationNudgePending = true;
440
+ } else {
441
+ const done = result(final, i + 1, limits);
442
+ persistRoot(done.answer);
443
+ lastAnswer = done.answer;
444
+ return done;
445
+ }
421
446
  }
422
447
 
423
448
  limits.observe(turnHadError(turn.results));
@@ -535,6 +560,13 @@ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegist
535
560
  return registry.limitFor(`${model.provider}/${model.id}`);
536
561
  }
537
562
 
563
+ /** Bare number / short label — the early-confident answer shape the verification nudge
564
+ * targets (28/33 bench failures were early confident wrong answers). */
565
+ function isBareAnswer(answer: string): boolean {
566
+ const t = answer.trim();
567
+ return t.length <= 12 || /^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t);
568
+ }
569
+
538
570
  /** Out of turns: ask the model for its best final answer. FINALIZE_PROMPT asks for a fenced
539
571
  * ```repl``` block, so execute it like any turn and prefer the captured answer (H2) — a raw
540
572
  * fence echoed verbatim must never become the run answer. Plain text stays the fallback. */
@@ -548,10 +580,19 @@ async function finalize(
548
580
  const finalHistory = [...history];
549
581
  appendUserMessage(finalHistory, FINALIZE_PROMPT);
550
582
  const complete = deps.complete ?? modelComplete;
583
+ // Same merge rule as the main loop (the `rootSampling` construction in run()): rootSampling
584
+ // wins, smartReasoning is the reasoning default. Finalize is a root turn — it must obey the
585
+ // user's sampling too, or the last turn of every run silently reverts to provider defaults.
586
+ const rootSampling: Sampling = {
587
+ reasoning: deps.config.smartReasoning,
588
+ ...deps.config.rootSampling,
589
+ };
551
590
  const { text, usage } = await complete(finalHistory, {
552
591
  model,
553
592
  registry: deps.registry,
554
- reasoning: deps.config.smartReasoning,
593
+ maxTokens: rootSampling.maxTokens,
594
+ temperature: rootSampling.temperature,
595
+ reasoning: rootSampling.reasoning,
555
596
  signal: deps.signal,
556
597
  });
557
598
  limits.addUsage(usage);
package/src/core/types.ts CHANGED
@@ -110,6 +110,11 @@ export interface RlmConfig {
110
110
  /** v5 doctrine: "delegation" = child engines get llm/memory/ledger only (no repo retrieval);
111
111
  * "legacy" keeps today's full child surface as a one-flip rollback. */
112
112
  readonly childSurface: "delegation" | "legacy";
113
+ /** Verification-discipline nudge (default OFF — it changes interactive behavior): when the
114
+ * root finalizes before turn 4 with a bare number / short label, it gets ONE coached redo
115
+ * ("recompute and sanity-check in Python") instead of accepting the answer. Opt-in via
116
+ * rlm.json; evidence: 28/33 bench failures were early confident wrong answers. */
117
+ readonly enableVerificationNudge?: boolean;
113
118
  }
114
119
 
115
120
  /** Input to a (headless) RLM run. */
@@ -34,3 +34,20 @@ export const RETRIEVAL_NUDGE =
34
34
  "[coach] You have not inspected the external context yet — it is NOT included in this " +
35
35
  "chat, and guessing is useless: the text is padding. On THIS turn, call search(\"...\") " +
36
36
  "or grep_context(\"...\") inside a ```repl block before answering.";
37
+
38
+ /** One-shot budget hint (engine-owned, turn 0 only): reasoning tokens share the completion
39
+ * budget with the answer, mirroring the bench's doubling rule — a reasoning root with a
40
+ * small output cap risks truncated thought. Advisory only; never fatal. */
41
+ export const REASONING_BUDGET_HINT =
42
+ "[budget] Reasoning is on while the root output cap is below 8192 tokens: thinking shares " +
43
+ "the completion budget with the answer, so long thought may be cut off mid-reasoning. " +
44
+ "Keep thought concise, or raise rootSampling.maxTokens.";
45
+
46
+ /** One-shot verification-discipline nudge (default OFF — enableVerificationNudge): fired when
47
+ * the root finalizes suspiciously early with a bare number / short label. The model gets ONE
48
+ * coached redo instead of having the answer accepted. */
49
+ export const VERIFICATION_NUDGE =
50
+ "[coach] That answer was submitted suspiciously early and looks under-verified. Before " +
51
+ "finalizing: recompute the key quantity inside a ```repl block (show the actual computation, " +
52
+ "not a restatement), sanity-check it against the source material, and only then set " +
53
+ "answer[\"content\"] again.";
@@ -235,7 +235,10 @@ def grep_context(
235
235
  `counts` is complete even when `hits` is capped.
236
236
  """
237
237
  try:
238
- rx = re.compile(pattern)
238
+ # MULTILINE: the doc-level gate below must not veto line-anchored patterns (^/$)
239
+ # without it, `^foo` on a multi-line doc never matches outside position 0 and every
240
+ # line hit is silently filtered out (grep is line-oriented; match that).
241
+ rx = re.compile(pattern, re.MULTILINE)
239
242
  except re.error as e:
240
243
  return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
241
244
  try:
@@ -344,6 +344,8 @@ export class PythonSandbox {
344
344
  frame: msg.type,
345
345
  id: "id" in msg ? msg.id : undefined,
346
346
  rid: "rid" in msg ? msg.rid : undefined,
347
+ // Post-mortem needs the payload, not just the shape (mirrors repl-tool's 400-char cap).
348
+ ...(msg.type === "exec" ? { chars: msg.code.length, code: msg.code.slice(0, 400) } : {}),
347
349
  });
348
350
  }
349
351
  // Never write to a corpse. The write would fail asynchronously and, historically, take the
@@ -423,7 +425,18 @@ export class PythonSandbox {
423
425
  prompts: "prompts" in msg ? msg.prompts?.length : 1,
424
426
  });
425
427
  } else {
426
- trace("frame.in", { frame: "response", id: msg.id, ok: msg.ok });
428
+ trace("frame.in", {
429
+ frame: "response",
430
+ id: msg.id,
431
+ ok: msg.ok,
432
+ ...(msg.ok
433
+ ? {
434
+ stdout: msg.stdout?.slice(0, 300),
435
+ finalAnswer: msg.final_answer ?? undefined,
436
+ vars: msg.var_names?.length,
437
+ }
438
+ : { error: msg.error?.slice(0, 200) }),
439
+ });
427
440
  }
428
441
  }
429
442
  if (isInterrupt(msg)) {
@@ -1,9 +1,11 @@
1
1
  /** Config panel TUI — toggle RLM run parameters with descriptions. */
2
2
 
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
4
5
  import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
5
6
  import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
6
7
  import type { RlmConfig } from "../core/types.ts";
8
+ import { THINKING_LEVELS } from "../config/settings.ts";
7
9
 
8
10
  const CHOICES = Object.freeze({
9
11
  maxDepth: Object.freeze(["1", "2", "3", "4"]),
@@ -18,6 +20,10 @@ const CHOICES = Object.freeze({
18
20
  compaction: Object.freeze(["on", "off"]),
19
21
  compactionThresholdPct: Object.freeze(["50", "65", "80", "90"]),
20
22
  rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
23
+ rootSamplingTemperature: Object.freeze(["0", "0.3", "0.7", "1.0", "default"]),
24
+ smartReasoning: Object.freeze(["default", ...Object.keys(THINKING_LEVELS)]),
25
+ subSamplingMaxTokens: Object.freeze(["1024", "2048", "4096", "8192"]),
26
+ subSamplingTemperature: Object.freeze(["0", "0.3", "0.7", "1.0", "default"]),
21
27
  sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
22
28
  requestTimeoutMs: Object.freeze(["2", "5", "10", "15", "20"]),
23
29
  contextLoader: Object.freeze(["on", "off"]),
@@ -48,6 +54,14 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
48
54
  item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
49
55
  item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "Compact once estimated history tokens reach this share of the root model's context window."),
50
56
  item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
57
+ item("rootSamplingTemperature", "Root sampling temperature", config.rootSampling?.temperature === undefined ? "default" : String(config.rootSampling?.temperature), CHOICES.rootSamplingTemperature,
58
+ "Sampling temperature for RLM root turns, finalize included — 0 = deterministic (the r3 reproducibility setting); 'default' = provider default. Applies to RLM-mode runs, rlm() delegation and child recursion; the native Pi agent loop follows Pi's own session settings."),
59
+ item("smartReasoning", "Root reasoning effort", config.smartReasoning ?? "default", CHOICES.smartReasoning,
60
+ "Thinking effort for the root model ('default' = none). Only models whose registry entry supports reasoning will think; others silently run without it. Reasoning tokens share the output cap — raise the root output cap when thinking is on."),
61
+ item("subSamplingMaxTokens", "Worker output cap (tok)", String(config.subSampling?.maxTokens ?? 8192), CHOICES.subSamplingMaxTokens,
62
+ "Max output tokens per leaf sub-call (llm_query / llm_batch / map_files)."),
63
+ item("subSamplingTemperature", "Worker sampling temperature", config.subSampling?.temperature === undefined ? "default" : String(config.subSampling?.temperature), CHOICES.subSamplingTemperature,
64
+ "Sampling temperature for leaf sub-calls; 'default' = provider default. Deterministic extraction (temp 0) is what made the r3 bench stable."),
51
65
  item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
52
66
  item("requestTimeoutMs", "Sandbox request timeout (min)", String(Math.round(config.requestTimeoutMs / 60_000)), CHOICES.requestTimeoutMs, "Parent-side watchdog per sandbox request; on breach the Python worker is killed."),
53
67
  item("contextLoader", "Context loader", config.contextLoader ? "on" : "off", CHOICES.contextLoader,
@@ -89,6 +103,13 @@ function optionalNumber(value: string, scale = 1): number | undefined {
89
103
  return value === "none" ? undefined : Number(value) * scale;
90
104
  }
91
105
 
106
+ /** Optional temperature: the literal "default" clears it (provider default); else [0, 2]. */
107
+ function optionalTemperature(value: string): number | undefined {
108
+ if (value === "default") return undefined;
109
+ const n = Number(value);
110
+ return Number.isFinite(n) && n >= 0 && n <= 2 ? n : undefined;
111
+ }
112
+
92
113
  /** Pure: returns a new frozen config with `id` set to `value`; unknown ids pass through. */
93
114
  export function applySetting(config: RlmConfig, id: string, value: string): RlmConfig {
94
115
  switch (id) {
@@ -105,6 +126,24 @@ export function applySetting(config: RlmConfig, id: string, value: string): RlmC
105
126
  case "compactionThresholdPct": return Object.freeze({ ...config, compactionThresholdPct: Number(value) / 100 });
106
127
  case "rootSamplingMaxTokens":
107
128
  return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }) });
129
+ case "rootSamplingTemperature": {
130
+ const t = optionalTemperature(value);
131
+ // Reject invalid values (NaN / out of range) — keep the current setting.
132
+ if (t === undefined && value !== "default") return config;
133
+ return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, temperature: t }) });
134
+ }
135
+ case "smartReasoning":
136
+ if (value === "default") return Object.freeze({ ...config, smartReasoning: undefined });
137
+ return Object.hasOwn(THINKING_LEVELS, value)
138
+ ? Object.freeze({ ...config, smartReasoning: value as ThinkingLevel })
139
+ : config;
140
+ case "subSamplingMaxTokens":
141
+ return Object.freeze({ ...config, subSampling: Object.freeze({ ...config.subSampling, maxTokens: Number(value) }) });
142
+ case "subSamplingTemperature": {
143
+ const st = optionalTemperature(value);
144
+ if (st === undefined && value !== "default") return config;
145
+ return Object.freeze({ ...config, subSampling: Object.freeze({ ...config.subSampling, temperature: st }) });
146
+ }
108
147
  case "sandboxInitTimeoutMs": return Object.freeze({ ...config, sandboxInitTimeoutMs: Number(value) });
109
148
  case "requestTimeoutMs": return Object.freeze({ ...config, requestTimeoutMs: Number(value) * 60_000 });
110
149
  case "contextLoader": return Object.freeze({ ...config, contextLoader: value === "on" });