@hicaru/pi-rlm 0.3.14 → 0.3.15
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 +29 -13
- package/README.ru.md +32 -0
- package/README.zh-CN.md +32 -0
- package/package.json +1 -1
- package/src/bridge/handlers/emitting.ts +8 -0
- package/src/bridge/handlers/rlm-query.ts +2 -0
- package/src/core/answer.ts +5 -2
- package/src/core/budget.ts +22 -0
- package/src/core/engine.ts +40 -6
- package/src/prompts/user.ts +8 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
- package/src/sandbox/py/retrieval.py +27 -1
- package/src/sandbox/py/worker.py +30 -4
- package/src/text/parsing.ts +28 -7
- package/src/tool/repl-tool.ts +2 -0
- package/src/tool/rlm-aggregator.ts +1 -1
- package/src/tool/rlm-details.ts +4 -1
- package/src/tool/rlm-events.ts +7 -2
- package/src/tool/rlm-tool.ts +3 -1
- package/src/tool/subcall-render.ts +10 -4
- package/src/tool/subcall-store.ts +32 -5
- package/src/ui/panel/run-registry.ts +8 -0
- package/src/ui/theme.ts +6 -0
- package/src/ui/tree/tree-model.ts +19 -1
- package/src/ui/tree/tree-rows.ts +8 -8
- package/src/util/retry.ts +6 -3
package/README.md
CHANGED
|
@@ -144,19 +144,35 @@ delegating only when their task genuinely decomposes further.
|
|
|
144
144
|
|
|
145
145
|
## Benchmarks
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
|
154
|
-
|
|
155
|
-
|
|
|
156
|
-
|
|
|
157
|
-
|
|
|
158
|
-
|
|
159
|
-
|
|
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`.
|
|
160
176
|
|
|
161
177
|
## Security
|
|
162
178
|
|
package/README.ru.md
CHANGED
|
@@ -135,6 +135,38 @@ rm -rf ~/.pi/agent/extensions/rlm
|
|
|
135
135
|
## Логи запусков
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
## Бенчмарки
|
|
139
|
+
|
|
140
|
+
E2E-прогоны реального движка против моделей OpenRouter — lite-сьют (`needle` поиск игл в
|
|
141
|
+
куче, `codeqa` вопросы по коду, `coding` задача-фикс; 7 задач × 2 прохода на модель).
|
|
142
|
+
Детерминированные грейдеры (recall / вхождение эталона / regex), без LLM-судьи.
|
|
143
|
+
|
|
144
|
+
Актуальные результаты — маленькие модели (≤32B параметров, платный тариф):
|
|
145
|
+
|
|
146
|
+
| Модель | Параметры | Счёт | Точность | Latency/задача |
|
|
147
|
+
|--------|-----------|------|----------|----------------|
|
|
148
|
+
| `qwen/qwen3-30b-a3b-instruct-2507` | MoE 30B / 3B активных | **14/14** | **100%** | ~15s |
|
|
149
|
+
| `google/gemma-3-27b-it` | dense 27B | 12/14 | 86% | ~26s |
|
|
150
|
+
| `mistralai/mistral-small-3.2-24b-instruct` | dense 24B | 12/14 | 86% | ~28s |
|
|
151
|
+
|
|
152
|
+
Построчные результаты (correct, recall, latency, токены, цена) — в
|
|
153
|
+
`bench/runs/bench-<ts>.jsonl`, одна JSONL-строка на задачу.
|
|
154
|
+
|
|
155
|
+
### Как запустить
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
export OPENROUTER_API_KEY=sk-or-... # обязателен — ключи ходят только через env
|
|
159
|
+
|
|
160
|
+
bun run bench # lite-сьют: needle + codeqa + coding
|
|
161
|
+
bun run bench --suite needle --limit 1 # один сьют, первая задача
|
|
162
|
+
bun run bench --model openrouter/qwen/qwen3-30b-a3b-instruct-2507
|
|
163
|
+
bun run bench --list # показать задачи, без движка и ключа
|
|
164
|
+
bun run bench --suite paper # paper-сьют: s_niah, oolong, browsecomp, codeqa_lb (скачивает датасеты)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Сьюты: `all` (lite, по умолчанию) · `needle` · `codeqa` · `coding` · `paper` · `s_niah` ·
|
|
168
|
+
`oolong` · `browsecomp` · `codeqa_lb`.
|
|
169
|
+
|
|
138
170
|
## Безопасность
|
|
139
171
|
|
|
140
172
|
- **Изоляция ключей**: ключи провайдеров хранятся только в TypeScript (`AuthStorage`); песочница получает промпты и возвращает текст, но никогда не получает ключи.
|
package/README.zh-CN.md
CHANGED
|
@@ -157,6 +157,38 @@ rm -rf ~/.pi/agent/extensions/rlm
|
|
|
157
157
|
连续错误上限。
|
|
158
158
|
- **信任**:本地安装需要 Pi 项目信任。
|
|
159
159
|
|
|
160
|
+
## 基准测试
|
|
161
|
+
|
|
162
|
+
针对 OpenRouter 聊天模型对真实引擎进行端到端测试 —— 精简套件(`needle` 多针召回、
|
|
163
|
+
`codeqa` 代码库问答、`coding` 修复任务;每个模型 7 个任务 × 2 轮)。确定性评分
|
|
164
|
+
(召回率 / 标准答案包含 / 正则),无 LLM 评审。
|
|
165
|
+
|
|
166
|
+
最新结果 —— 小型模型(≤32B 参数,付费额度):
|
|
167
|
+
|
|
168
|
+
| 模型 | 参数 | 得分 | 准确率 | 每任务延迟 |
|
|
169
|
+
|------|------|------|--------|------------|
|
|
170
|
+
| `qwen/qwen3-30b-a3b-instruct-2507` | MoE 30B / 3B 激活 | **14/14** | **100%** | ~15s |
|
|
171
|
+
| `google/gemma-3-27b-it` | dense 27B | 12/14 | 86% | ~26s |
|
|
172
|
+
| `mistralai/mistral-small-3.2-24b-instruct` | dense 24B | 12/14 | 86% | ~28s |
|
|
173
|
+
|
|
174
|
+
逐任务原始数据(正确性、召回率、延迟、token、成本)位于 `bench/runs/bench-<ts>.jsonl`
|
|
175
|
+
—— 每个任务一行 JSONL,作为历史记录提交。
|
|
176
|
+
|
|
177
|
+
### 运行基准测试
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
export OPENROUTER_API_KEY=sk-or-... # 必需 —— 密钥仅通过环境变量传递
|
|
181
|
+
|
|
182
|
+
bun run bench # 精简套件:needle + codeqa + coding
|
|
183
|
+
bun run bench --suite needle --limit 1 # 单个套件,仅第一个任务
|
|
184
|
+
bun run bench --model openrouter/qwen/qwen3-30b-a3b-instruct-2507
|
|
185
|
+
bun run bench --list # 仅列出任务,无需引擎和密钥
|
|
186
|
+
bun run bench --suite paper # paper 套件:s_niah, oolong, browsecomp, codeqa_lb(需下载数据集)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
套件:`all`(精简版,默认) · `needle` · `codeqa` · `coding` · `paper` · `s_niah` ·
|
|
190
|
+
`oolong` · `browsecomp` · `codeqa_lb`。
|
|
191
|
+
|
|
160
192
|
## 项目布局
|
|
161
193
|
|
|
162
194
|
```
|
package/package.json
CHANGED
|
@@ -56,9 +56,13 @@ export async function emitting<T>(
|
|
|
56
56
|
|
|
57
57
|
let costUsd = 0;
|
|
58
58
|
let tokens = 0;
|
|
59
|
+
let tokensIn = 0;
|
|
60
|
+
let tokensOut = 0;
|
|
59
61
|
const track = (u: Usage): void => {
|
|
60
62
|
costUsd += u.cost.total;
|
|
61
63
|
tokens += u.totalTokens;
|
|
64
|
+
tokensIn += u.input;
|
|
65
|
+
tokensOut += u.output;
|
|
62
66
|
};
|
|
63
67
|
|
|
64
68
|
try {
|
|
@@ -70,6 +74,8 @@ export async function emitting<T>(
|
|
|
70
74
|
resultPreview: summary.preview,
|
|
71
75
|
costUsd,
|
|
72
76
|
tokens,
|
|
77
|
+
tokensIn,
|
|
78
|
+
tokensOut,
|
|
73
79
|
detail: summary.error,
|
|
74
80
|
failedCount: summary.failed,
|
|
75
81
|
totalCount: summary.total,
|
|
@@ -83,6 +89,8 @@ export async function emitting<T>(
|
|
|
83
89
|
resultPreview: msg,
|
|
84
90
|
costUsd,
|
|
85
91
|
tokens,
|
|
92
|
+
tokensIn,
|
|
93
|
+
tokensOut,
|
|
86
94
|
detail: msg,
|
|
87
95
|
});
|
|
88
96
|
throw err;
|
package/src/core/answer.ts
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
4
4
|
import { formatReplStderr } from "../text/repl-output.ts";
|
|
5
5
|
|
|
6
|
-
/** First non-
|
|
6
|
+
/** First non-blank final answer across a turn's executed blocks, or null. A blank capture counts
|
|
7
|
+
* as absent (H2): an empty `answer.ready` flip must never terminate a run with "". */
|
|
7
8
|
export function finalAnswerOf(results: readonly ReplResult[]): string | null {
|
|
8
|
-
for (const r of results)
|
|
9
|
+
for (const r of results) {
|
|
10
|
+
if (r.finalAnswer != null && r.finalAnswer.trim() !== "") return r.finalAnswer;
|
|
11
|
+
}
|
|
9
12
|
return null;
|
|
10
13
|
}
|
|
11
14
|
|
package/src/core/budget.ts
CHANGED
|
@@ -107,8 +107,30 @@ export class TokenBudget {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/** Cap derivation (v5 `resolve_budget`): share × context window, clamped by the task cap. */
|
|
110
|
+
/**
|
|
111
|
+
* Minimum context window (tokens) for the token-budget cascade to engage at all.
|
|
112
|
+
*
|
|
113
|
+
* The formula (window × budgetShare) assumes the window is large enough that a fraction of it
|
|
114
|
+
* is a meaningful working budget. Below this floor the derived cap shrinks below a task's FIXED
|
|
115
|
+
* overhead (system prompt + per-turn history re-send + sub-LLM calls) and strangles the run —
|
|
116
|
+
* a 32k window would cap a task at 8k tokens, less than the protocol scaffolding alone.
|
|
117
|
+
* So for smaller windows the rule does not apply: the budget is effectively unbounded and runs
|
|
118
|
+
* stay bounded by maxIterations / maxErrors / wall-clock instead.
|
|
119
|
+
*/
|
|
120
|
+
export const BUDGET_WINDOW_FLOOR = 250_000;
|
|
121
|
+
|
|
122
|
+
/** An effective budget that can never trigger — the cascade "switched off" without changing
|
|
123
|
+
* any call-site types (budget: TokenBudget | undefined). */
|
|
124
|
+
function unboundedBudget(config: RlmConfig): TokenBudget {
|
|
125
|
+
return new TokenBudget(Number.MAX_SAFE_INTEGER, {
|
|
126
|
+
softFrac: config.budgetSoftFrac,
|
|
127
|
+
maxContinuations: config.budgetMaxContinuations,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
110
131
|
export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
|
|
111
132
|
const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
|
|
133
|
+
if (ctx < BUDGET_WINDOW_FLOOR) return unboundedBudget(config);
|
|
112
134
|
const shareCap = Math.floor(ctx * config.budgetShare);
|
|
113
135
|
const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
|
|
114
136
|
return new TokenBudget(Math.max(cap, 1), {
|
package/src/core/engine.ts
CHANGED
|
@@ -20,12 +20,14 @@ 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 } from "../prompts/user.ts";
|
|
23
|
+
import { buildTurnPrompt, FINALIZE_PROMPT, RETRIEVAL_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";
|
|
27
|
+
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
27
28
|
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
28
29
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
30
|
+
import { findReplBlocks } from "../text/parsing.ts";
|
|
29
31
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
30
32
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
31
33
|
import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
|
|
@@ -248,6 +250,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
248
250
|
// v5 budget cascade state: the wrap-up note fires for exactly ONE turn after crossing soft.
|
|
249
251
|
let softFired = false;
|
|
250
252
|
let softNoteTurn = -1;
|
|
253
|
+
// H3: retrieval-discipline coach — one-shot per run; children inherit it via the same loop.
|
|
254
|
+
let sawRetrieval = false;
|
|
255
|
+
let retrievalNudged = false;
|
|
251
256
|
|
|
252
257
|
try {
|
|
253
258
|
const meta = {
|
|
@@ -355,11 +360,15 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
355
360
|
// v5 [ledger] blackboard + [memory] notes — each silent ("") when it has nothing to say.
|
|
356
361
|
const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
|
|
357
362
|
const memoryBlock = rootMemory !== undefined ? rootMemory.injectBlock(input.rootPrompt) : "";
|
|
363
|
+
// H3: after two retrieval-free turns, inject the coach nudge exactly once, for one turn.
|
|
364
|
+
const nudgeNow = i >= 2 && !sawRetrieval && !retrievalNudged;
|
|
365
|
+
if (nudgeNow) retrievalNudged = true;
|
|
358
366
|
const notes =
|
|
359
367
|
[
|
|
360
368
|
i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
|
|
361
369
|
ledgerBlock === "" ? undefined : ledgerBlock,
|
|
362
370
|
memoryBlock === "" ? undefined : memoryBlock,
|
|
371
|
+
nudgeNow ? RETRIEVAL_NUDGE : undefined,
|
|
363
372
|
]
|
|
364
373
|
.filter((s): s is string => s !== undefined)
|
|
365
374
|
.join("\n\n") || undefined;
|
|
@@ -379,6 +388,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
379
388
|
complete: deps.complete,
|
|
380
389
|
onPhase: reportPhase,
|
|
381
390
|
});
|
|
391
|
+
if (turn.blocks.some((b) => /\b(?:search|grep_context)\s*\(/.test(b))) sawRetrieval = true;
|
|
382
392
|
const allBlocks = turn.blocks.length > 0
|
|
383
393
|
? turn.blocks.map((b) => previewText(b, 400)).join("\n")
|
|
384
394
|
: previewText(turn.response, 400);
|
|
@@ -386,8 +396,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
386
396
|
emitter.emitSubcallUpdated({ id: selfReportId, args: `▶ ${allBlocks}`, resultPreview: previewStdout(turn.results) });
|
|
387
397
|
}
|
|
388
398
|
limits.addUsage(turn.usage);
|
|
389
|
-
if (selfReportId)
|
|
390
|
-
|
|
399
|
+
if (selfReportId) {
|
|
400
|
+
emitter.emitSubcallUpdated({
|
|
401
|
+
id: selfReportId,
|
|
402
|
+
costUsd: turn.usage.cost.total,
|
|
403
|
+
tokens: turn.usage.totalTokens,
|
|
404
|
+
tokensIn: turn.usage.input,
|
|
405
|
+
tokensOut: turn.usage.output,
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
emitter.emitRootUsage(turn.usage.cost.total, turn.usage.totalTokens, turn.usage.input, turn.usage.output);
|
|
409
|
+
}
|
|
391
410
|
deps.onUsage?.(turn.usage, "root");
|
|
392
411
|
const answerContent = latestAnswerContentOf(turn.results);
|
|
393
412
|
if (answerContent) best = answerContent;
|
|
@@ -458,7 +477,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
458
477
|
}
|
|
459
478
|
}
|
|
460
479
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
461
|
-
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
|
|
480
|
+
const finalized = result(await finalize(history, model, deps, limits, sandbox), deps.config.maxIterations, limits);
|
|
462
481
|
persistRoot(finalized.answer);
|
|
463
482
|
lastAnswer = finalized.answer;
|
|
464
483
|
return finalized;
|
|
@@ -516,8 +535,16 @@ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegist
|
|
|
516
535
|
return registry.limitFor(`${model.provider}/${model.id}`);
|
|
517
536
|
}
|
|
518
537
|
|
|
519
|
-
/** Out of turns: ask the model for its best final answer
|
|
520
|
-
|
|
538
|
+
/** Out of turns: ask the model for its best final answer. FINALIZE_PROMPT asks for a fenced
|
|
539
|
+
* ```repl``` block, so execute it like any turn and prefer the captured answer (H2) — a raw
|
|
540
|
+
* fence echoed verbatim must never become the run answer. Plain text stays the fallback. */
|
|
541
|
+
async function finalize(
|
|
542
|
+
history: ChatMsg[],
|
|
543
|
+
model: Model<Api>,
|
|
544
|
+
deps: EngineDeps,
|
|
545
|
+
limits: LimitGuard,
|
|
546
|
+
sandbox: PythonSandbox,
|
|
547
|
+
): Promise<string> {
|
|
521
548
|
const finalHistory = [...history];
|
|
522
549
|
appendUserMessage(finalHistory, FINALIZE_PROMPT);
|
|
523
550
|
const complete = deps.complete ?? modelComplete;
|
|
@@ -528,5 +555,12 @@ async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps,
|
|
|
528
555
|
signal: deps.signal,
|
|
529
556
|
});
|
|
530
557
|
limits.addUsage(usage);
|
|
558
|
+
const blocks = findReplBlocks(text);
|
|
559
|
+
const results = new Array<ReplResult>(blocks.length);
|
|
560
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
561
|
+
results[i] = await sandbox.exec(blocks[i]);
|
|
562
|
+
}
|
|
563
|
+
const final = finalAnswerOf(results) ?? latestAnswerContentOf(results);
|
|
564
|
+
if (final !== null && final.trim() !== "") return final.trim();
|
|
531
565
|
return text.trim();
|
|
532
566
|
}
|
package/src/prompts/user.ts
CHANGED
|
@@ -26,3 +26,11 @@ export const FINALIZE_PROMPT =
|
|
|
26
26
|
"You are out of turns. Finalize NOW: set `answer[\"content\"]` and `answer[\"ready\"] = True` " +
|
|
27
27
|
"(fenced ```repl```) with your best final answer from everything you have gathered. " +
|
|
28
28
|
"Only if the REPL is unavailable, answer as plain text.";
|
|
29
|
+
|
|
30
|
+
/** One-shot retrieval-discipline nudge (the engine owns the when — see core/engine.ts). The
|
|
31
|
+
* context is external by design, so a model that never calls search()/grep_context() is
|
|
32
|
+
* guessing from padding vocabulary; after two retrieval-free turns it gets this once. */
|
|
33
|
+
export const RETRIEVAL_NUDGE =
|
|
34
|
+
"[coach] You have not inspected the external context yet — it is NOT included in this " +
|
|
35
|
+
"chat, and guessing is useless: the text is padding. On THIS turn, call search(\"...\") " +
|
|
36
|
+
"or grep_context(\"...\") inside a ```repl block before answering.";
|
|
Binary file
|
|
Binary file
|
|
@@ -36,6 +36,30 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
|
36
36
|
return chunks
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _snippet_window(text: str, terms: set[str]) -> str:
|
|
40
|
+
"""Slice `text` around the earliest occurrence of any query term, capped at _SNIPPET_CHARS.
|
|
41
|
+
|
|
42
|
+
BM25 finds the right window chunk; the snippet must show the match, not the chunk head.
|
|
43
|
+
Clipped edges get "..." markers, which count toward the cap (the body is trimmed to fit).
|
|
44
|
+
Falls back to the chunk head when no term occurs (tokenize/camelCase mismatches).
|
|
45
|
+
"""
|
|
46
|
+
lowered = text.lower()
|
|
47
|
+
hits = [p for p in (lowered.find(t) for t in terms) if p >= 0]
|
|
48
|
+
if not hits:
|
|
49
|
+
return text[:_SNIPPET_CHARS]
|
|
50
|
+
start = max(0, min(hits) - _SNIPPET_LEAD)
|
|
51
|
+
end = min(len(text), start + _SNIPPET_CHARS)
|
|
52
|
+
lead = "..." if start > 0 else ""
|
|
53
|
+
trail = "..." if end < len(text) else ""
|
|
54
|
+
body = text[start:end]
|
|
55
|
+
if len(body) > _SNIPPET_CHARS - len(lead) - len(trail):
|
|
56
|
+
body = body[:_SNIPPET_CHARS - len(lead) - len(trail)]
|
|
57
|
+
if start + len(body) < len(text):
|
|
58
|
+
trail = "..." # trimming pulled the window edge back inside the chunk
|
|
59
|
+
body = body[:_SNIPPET_CHARS - len(lead) - len(trail)]
|
|
60
|
+
return lead + body + trail
|
|
61
|
+
|
|
62
|
+
|
|
39
63
|
# ---- deterministic retrieval over `context` -----------------------------------------------
|
|
40
64
|
#
|
|
41
65
|
# The RLM paper's trajectories retrieve by having the root model hand-write regex over the
|
|
@@ -48,6 +72,7 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
|
48
72
|
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
49
73
|
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge add_context() cannot exhaust worker memory
|
|
50
74
|
_SNIPPET_CHARS = 400
|
|
75
|
+
_SNIPPET_LEAD = 100 # chars of lead-in kept before the earliest matched term
|
|
51
76
|
_GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
|
|
52
77
|
_BM25_K1 = 1.2
|
|
53
78
|
_BM25_B = 0.75
|
|
@@ -165,9 +190,10 @@ class _Bm25Index:
|
|
|
165
190
|
return []
|
|
166
191
|
top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
|
|
167
192
|
out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
|
|
193
|
+
term_set = set(terms)
|
|
168
194
|
for i, (idx, score) in enumerate(top):
|
|
169
195
|
text = self.texts[idx]
|
|
170
|
-
snip = text
|
|
196
|
+
snip = _snippet_window(text, term_set)
|
|
171
197
|
# Both `snippet` and `text` so agents never KeyError mixing search vs grep shapes.
|
|
172
198
|
out[i] = {
|
|
173
199
|
"path": self.paths[idx],
|
package/src/sandbox/py/worker.py
CHANGED
|
@@ -22,6 +22,7 @@ awaited in a LATER exec than the one that started it.
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
import argparse
|
|
25
|
+
import ast
|
|
25
26
|
import io
|
|
26
27
|
import json
|
|
27
28
|
import os
|
|
@@ -61,7 +62,15 @@ class _AnswerDict(dict):
|
|
|
61
62
|
def __setitem__(self, key, value):
|
|
62
63
|
super().__setitem__(key, value)
|
|
63
64
|
if key == "ready" and value:
|
|
64
|
-
self.
|
|
65
|
+
content = self.get("content", "")
|
|
66
|
+
# An empty ready-flip must not capture "": defer it — a later non-blank content
|
|
67
|
+
# assignment while ready stays True (branch below) is the real submission.
|
|
68
|
+
if str(content).strip():
|
|
69
|
+
self._on_ready(content)
|
|
70
|
+
elif key == "content" and self.get("ready"):
|
|
71
|
+
content = str(value)
|
|
72
|
+
if content.strip():
|
|
73
|
+
self._on_ready(content)
|
|
65
74
|
|
|
66
75
|
|
|
67
76
|
|
|
@@ -151,7 +160,7 @@ class Worker(WorkerScaffold):
|
|
|
151
160
|
if isinstance(cur, dict):
|
|
152
161
|
for k, v in cur.items():
|
|
153
162
|
dict.__setitem__(ans, k, v)
|
|
154
|
-
if cur.get("ready") and self._final_answer is None:
|
|
163
|
+
if cur.get("ready") and self._final_answer is None and str(cur.get("content", "")).strip():
|
|
155
164
|
self._final_answer = str(cur.get("content", ""))
|
|
156
165
|
ns["answer"] = ans
|
|
157
166
|
# Single context variable (RLM paper: the context lives in the environment and
|
|
@@ -295,8 +304,25 @@ class Worker(WorkerScaffold):
|
|
|
295
304
|
|
|
296
305
|
def _exec(self, code: str, ns: dict[str, Any]) -> None:
|
|
297
306
|
t = self.exec_timeout_s
|
|
307
|
+
# Jupyter-style auto-echo: plain exec() discards a trailing bare expression's value
|
|
308
|
+
# (models write `search("...")` and never see the hits), so split the block: exec the
|
|
309
|
+
# head, eval the tail, and repr() the value when it is not None (None stays silent).
|
|
310
|
+
tree = ast.parse(code)
|
|
311
|
+
tail = tree.body[-1] if tree.body else None
|
|
312
|
+
tail_is_expr = isinstance(tail, ast.Expr)
|
|
313
|
+
|
|
314
|
+
def _run() -> None:
|
|
315
|
+
if tail_is_expr and tail is not None:
|
|
316
|
+
head = ast.Module(body=tree.body[:-1], type_ignores=[])
|
|
317
|
+
exec(compile(head, "<repl>", "exec"), ns, ns) # noqa: S102
|
|
318
|
+
value = eval(compile(ast.Expression(tail.value), "<repl>", "eval"), ns, ns) # noqa: S102
|
|
319
|
+
if value is not None:
|
|
320
|
+
print(repr(value))
|
|
321
|
+
else:
|
|
322
|
+
exec(compile(tree, "<repl>", "exec"), ns, ns) # noqa: S102
|
|
323
|
+
|
|
298
324
|
if t <= 0 or not hasattr(signal, "SIGALRM"):
|
|
299
|
-
|
|
325
|
+
_run()
|
|
300
326
|
return
|
|
301
327
|
|
|
302
328
|
def _alarm(signum, frame): # noqa: ARG001
|
|
@@ -305,7 +331,7 @@ class Worker(WorkerScaffold):
|
|
|
305
331
|
old = signal.signal(signal.SIGALRM, _alarm)
|
|
306
332
|
signal.setitimer(signal.ITIMER_REAL, t)
|
|
307
333
|
try:
|
|
308
|
-
|
|
334
|
+
_run()
|
|
309
335
|
finally:
|
|
310
336
|
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
311
337
|
signal.signal(signal.SIGALRM, old)
|
package/src/text/parsing.ts
CHANGED
|
@@ -2,23 +2,44 @@
|
|
|
2
2
|
* Parsing helpers: extract ```repl``` code blocks from a model response.
|
|
3
3
|
*
|
|
4
4
|
* The RLM root model emits Python wrapped in fenced blocks tagged `repl`. We extract those
|
|
5
|
-
* blocks in order; everything else is prose the model uses to think out loud.
|
|
5
|
+
* blocks in order; everything else is prose the model uses to think out loud. Small instruct
|
|
6
|
+
* models often finalize inside ```python / ```py fences (or bare ones) instead; when a response
|
|
7
|
+
* contains no `repl` block at all, those code-ish fences are used as a fallback so the engine
|
|
8
|
+
* still executes their code. Other language tags (```text, ```json, ...) are never executed —
|
|
9
|
+
* the sandbox only runs Python.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
const FENCE = /(`{3,})[ \t]*repl[ \t]*\r?\n([\s\S]*?)\1/g;
|
|
13
|
+
// The info string is captured so a rejected tag (e.g. ```text) still consumes its whole fence —
|
|
14
|
+
// otherwise that fence's own closing ``` could later match as a bare opener and swallow code.
|
|
15
|
+
const FALLBACK_FENCE = /(`{3,})[ \t]*([^`\r\n]*)[ \t]*\r?\n([\s\S]*?)\1/g;
|
|
16
|
+
const PYTHON_TAG = /^py(thon)?$/i;
|
|
9
17
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
18
|
+
/** Shared fence scan: run `re` over `text`, keep bodies the selector accepts (same trimming). */
|
|
19
|
+
function collectFences(text: string, re: RegExp, select: (m: RegExpExecArray) => string | null): string[] {
|
|
12
20
|
const blocks: string[] = [];
|
|
13
21
|
let m: RegExpExecArray | null;
|
|
14
|
-
|
|
15
|
-
while ((m =
|
|
16
|
-
const code = m
|
|
17
|
-
if (code.trim()) blocks.push(code.replace(/\s+$/, ""));
|
|
22
|
+
re.lastIndex = 0;
|
|
23
|
+
while ((m = re.exec(text)) !== null) {
|
|
24
|
+
const code = select(m);
|
|
25
|
+
if (code !== null && code.trim()) blocks.push(code.replace(/\s+$/, ""));
|
|
18
26
|
}
|
|
19
27
|
return blocks;
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Return every ```repl``` block body, in document order. If the response has none, fall back to
|
|
32
|
+
* ```python / ```py / untagged fences — never other language tags, and never a mix of both kinds.
|
|
33
|
+
*/
|
|
34
|
+
export function findReplBlocks(text: string): string[] {
|
|
35
|
+
const repl = collectFences(text, FENCE, (m) => m[2] ?? "");
|
|
36
|
+
if (repl.length > 0) return repl;
|
|
37
|
+
return collectFences(text, FALLBACK_FENCE, (m) => {
|
|
38
|
+
const tag = m[2] ?? "";
|
|
39
|
+
return tag === "" || PYTHON_TAG.test(tag) ? (m[3] ?? "") : null;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
/** Truncate REPL stdout for the model's context window (head + tail, with an elision note). */
|
|
23
44
|
export function truncateOutput(text: string, limit = 20_000): string {
|
|
24
45
|
if (text.length <= limit) return text;
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -267,6 +267,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
267
267
|
return modelRef(m) ?? m.id;
|
|
268
268
|
},
|
|
269
269
|
rootTokens: () => store.getRootUsage().tokens,
|
|
270
|
+
rootTokensIn: () => store.getRootUsage().tokensIn,
|
|
271
|
+
rootTokensOut: () => store.getRootUsage().tokensOut,
|
|
270
272
|
});
|
|
271
273
|
let capturedStdout = "";
|
|
272
274
|
let capturedStderr = "";
|
|
@@ -55,7 +55,7 @@ export class RlmEventAggregator extends EmitterListener {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
private handleRootUsage(event: RootUsageEvent): void {
|
|
58
|
-
this.store.addRootUsage(event.costUsd, event.tokens);
|
|
58
|
+
this.store.addRootUsage(event.costUsd, event.tokens, event.tokensIn, event.tokensOut);
|
|
59
59
|
this.notify();
|
|
60
60
|
}
|
|
61
61
|
|
package/src/tool/rlm-details.ts
CHANGED
|
@@ -33,6 +33,9 @@ export interface RlmSubcall {
|
|
|
33
33
|
readonly endedAt?: number;
|
|
34
34
|
readonly costUsd: number;
|
|
35
35
|
readonly tokens: number;
|
|
36
|
+
/** In/out split (input / output) — mirrors tokens. */
|
|
37
|
+
readonly tokensIn: number;
|
|
38
|
+
readonly tokensOut: number;
|
|
36
39
|
/** For batch subcalls: failed prompt count (partial failure). */
|
|
37
40
|
readonly failedCount?: number;
|
|
38
41
|
/** For batch subcalls: total prompt count. */
|
|
@@ -46,7 +49,7 @@ export interface RlmDetails {
|
|
|
46
49
|
readonly rootPrompt: string;
|
|
47
50
|
readonly turns: { readonly current: number; readonly max: number };
|
|
48
51
|
readonly subcalls: readonly RlmSubcall[];
|
|
49
|
-
readonly totals: { readonly costUsd: number; readonly tokens: number };
|
|
52
|
+
readonly totals: { readonly costUsd: number; readonly tokens: number; readonly tokensIn: number; readonly tokensOut: number };
|
|
50
53
|
readonly answer?: string;
|
|
51
54
|
}
|
|
52
55
|
|
package/src/tool/rlm-events.ts
CHANGED
|
@@ -41,6 +41,9 @@ export interface SubcallUpdatedEvent {
|
|
|
41
41
|
readonly costUsd?: number;
|
|
42
42
|
/** Delta — additive on both the subcall and running totals. */
|
|
43
43
|
readonly tokens?: number;
|
|
44
|
+
/** Deltas for the in/out split shown in the tree (input / output). Additive like tokens. */
|
|
45
|
+
readonly tokensIn?: number;
|
|
46
|
+
readonly tokensOut?: number;
|
|
44
47
|
/** For batch subcalls: failed prompt count. */
|
|
45
48
|
readonly failedCount?: number;
|
|
46
49
|
/** For batch subcalls: total prompt count. */
|
|
@@ -55,6 +58,8 @@ export interface TurnEvent {
|
|
|
55
58
|
export interface RootUsageEvent {
|
|
56
59
|
readonly costUsd: number;
|
|
57
60
|
readonly tokens: number;
|
|
61
|
+
readonly tokensIn?: number;
|
|
62
|
+
readonly tokensOut?: number;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
export interface AnswerEvent {
|
|
@@ -114,8 +119,8 @@ export class RlmEmitter {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
/** Accumulate usage directly to root-level totals. */
|
|
117
|
-
emitRootUsage(costUsd: number, tokens: number): void {
|
|
118
|
-
this.ee.emit("root-usage", { costUsd, tokens } satisfies RootUsageEvent);
|
|
122
|
+
emitRootUsage(costUsd: number, tokens: number, tokensIn?: number, tokensOut?: number): void {
|
|
123
|
+
this.ee.emit("root-usage", { costUsd, tokens, tokensIn, tokensOut } satisfies RootUsageEvent);
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
/** Set the final answer text (root-only). */
|
package/src/tool/rlm-tool.ts
CHANGED
|
@@ -53,7 +53,7 @@ export function createRlmTool(controller: RlmController, runRegistry?: RunRegist
|
|
|
53
53
|
rootPrompt: "",
|
|
54
54
|
turns: { current: 0, max: 0 },
|
|
55
55
|
subcalls: [],
|
|
56
|
-
totals: { costUsd: 0, tokens: 0 },
|
|
56
|
+
totals: { costUsd: 0, tokens: 0, tokensIn: 0, tokensOut: 0 },
|
|
57
57
|
}));
|
|
58
58
|
if (!validation.ok) return validation.error;
|
|
59
59
|
const params = validation.value;
|
|
@@ -79,6 +79,8 @@ export function createRlmTool(controller: RlmController, runRegistry?: RunRegist
|
|
|
79
79
|
return m === undefined ? undefined : modelRef(m) ?? m.id;
|
|
80
80
|
},
|
|
81
81
|
rootTokens: () => aggregator.getRootUsage().tokens,
|
|
82
|
+
rootTokensIn: () => aggregator.getRootUsage().tokensIn,
|
|
83
|
+
rootTokensOut: () => aggregator.getRootUsage().tokensOut,
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
// Wire abort signal to controller
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { Text } from "@earendil-works/pi-tui";
|
|
10
10
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import type { SubcallStatus } from "./rlm-details.ts";
|
|
12
|
-
import { formatTokens, spinnerFrame } from "../ui/theme.ts";
|
|
12
|
+
import { formatTokens, formatTokensSplit, spinnerFrame } from "../ui/theme.ts";
|
|
13
13
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
|
|
15
15
|
// ── Glyphs ──
|
|
@@ -25,15 +25,21 @@ export function headlineStatusGlyph(status: SubcallStatus | "aborted" | "done",
|
|
|
25
25
|
|
|
26
26
|
// ── Stats formatting ──
|
|
27
27
|
|
|
28
|
-
/** The `4.2k tok · 812ms` run of a card header.
|
|
28
|
+
/** The `4.2k tok · 812ms` run of a card header. In/out split when known; omits zero parts. */
|
|
29
29
|
export function cardStatsLine(
|
|
30
|
-
totals: { readonly tokens: number },
|
|
30
|
+
totals: { readonly tokens: number; readonly tokensIn?: number; readonly tokensOut?: number },
|
|
31
31
|
theme: Theme,
|
|
32
32
|
extra?: string,
|
|
33
33
|
backgroundPending?: number,
|
|
34
34
|
): string {
|
|
35
35
|
const parts: string[] = [];
|
|
36
|
-
if (totals.tokens > 0)
|
|
36
|
+
if (totals.tokens > 0) {
|
|
37
|
+
parts.push(
|
|
38
|
+
totals.tokensOut !== undefined && totals.tokensOut > 0
|
|
39
|
+
? formatTokensSplit(totals.tokensIn ?? totals.tokens, totals.tokensOut)
|
|
40
|
+
: `${formatTokens(totals.tokens)} tok`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
37
43
|
if (extra) parts.push(extra);
|
|
38
44
|
const line = theme.fg("dim", parts.join(" · "));
|
|
39
45
|
// The one thing no single line can show: spawned work that may outlive this block.
|
|
@@ -18,6 +18,9 @@ type MutableSubcall = {
|
|
|
18
18
|
export interface SubcallTotals {
|
|
19
19
|
readonly costUsd: number;
|
|
20
20
|
readonly tokens: number;
|
|
21
|
+
/** In/out split (input / output) — mirrors tokens, shown separately in the tree. */
|
|
22
|
+
readonly tokensIn: number;
|
|
23
|
+
readonly tokensOut: number;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export class SubcallStore extends EmitterListener {
|
|
@@ -25,8 +28,12 @@ export class SubcallStore extends EmitterListener {
|
|
|
25
28
|
|
|
26
29
|
private totalCostUsd = 0;
|
|
27
30
|
private totalTokens = 0;
|
|
31
|
+
private totalTokensIn = 0;
|
|
32
|
+
private totalTokensOut = 0;
|
|
28
33
|
private rootCostUsd = 0;
|
|
29
34
|
private rootTokens = 0;
|
|
35
|
+
private rootTokensIn = 0;
|
|
36
|
+
private rootTokensOut = 0;
|
|
30
37
|
|
|
31
38
|
constructor(emitter: RlmEmitter, private readonly onChange?: () => void) {
|
|
32
39
|
super();
|
|
@@ -52,6 +59,8 @@ export class SubcallStore extends EmitterListener {
|
|
|
52
59
|
startedAt: Date.now(),
|
|
53
60
|
costUsd: 0,
|
|
54
61
|
tokens: 0,
|
|
62
|
+
tokensIn: 0,
|
|
63
|
+
tokensOut: 0,
|
|
55
64
|
});
|
|
56
65
|
}
|
|
57
66
|
|
|
@@ -75,6 +84,14 @@ export class SubcallStore extends EmitterListener {
|
|
|
75
84
|
sc.tokens += event.tokens;
|
|
76
85
|
this.totalTokens += event.tokens;
|
|
77
86
|
}
|
|
87
|
+
if (event.tokensIn !== undefined) {
|
|
88
|
+
sc.tokensIn += event.tokensIn;
|
|
89
|
+
this.totalTokensIn += event.tokensIn;
|
|
90
|
+
}
|
|
91
|
+
if (event.tokensOut !== undefined) {
|
|
92
|
+
sc.tokensOut += event.tokensOut;
|
|
93
|
+
this.totalTokensOut += event.tokensOut;
|
|
94
|
+
}
|
|
78
95
|
if (event.failedCount !== undefined) sc.failedCount = event.failedCount;
|
|
79
96
|
if (event.totalCount !== undefined) sc.totalCount = event.totalCount;
|
|
80
97
|
}
|
|
@@ -87,8 +104,8 @@ export class SubcallStore extends EmitterListener {
|
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
/** Snapshot running totals. O(1). */
|
|
90
|
-
getTotals():
|
|
91
|
-
return { costUsd: this.totalCostUsd, tokens: this.totalTokens };
|
|
107
|
+
getTotals(): SubcallTotals {
|
|
108
|
+
return { costUsd: this.totalCostUsd, tokens: this.totalTokens, tokensIn: this.totalTokensIn, tokensOut: this.totalTokensOut };
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
/**
|
|
@@ -126,33 +143,43 @@ export class SubcallStore extends EmitterListener {
|
|
|
126
143
|
const taken: RlmSubcall[] = [];
|
|
127
144
|
let costUsd = 0;
|
|
128
145
|
let tokens = 0;
|
|
146
|
+
let tokensIn = 0;
|
|
147
|
+
let tokensOut = 0;
|
|
129
148
|
for (const root of children.get(undefined) ?? []) {
|
|
130
149
|
const subtree = settledSubtree(root);
|
|
131
150
|
if (subtree === undefined) continue;
|
|
132
151
|
for (const node of subtree) {
|
|
133
152
|
costUsd += node.costUsd;
|
|
134
153
|
tokens += node.tokens;
|
|
154
|
+
tokensIn += node.tokensIn;
|
|
155
|
+
tokensOut += node.tokensOut;
|
|
135
156
|
taken.push(Object.freeze({ ...node, status: node.status as SubcallStatus }));
|
|
136
157
|
this.subcalls.delete(node.id);
|
|
137
158
|
}
|
|
138
159
|
}
|
|
139
160
|
this.totalCostUsd -= costUsd;
|
|
140
161
|
this.totalTokens -= tokens;
|
|
141
|
-
|
|
162
|
+
this.totalTokensIn -= tokensIn;
|
|
163
|
+
this.totalTokensOut -= tokensOut;
|
|
164
|
+
return { subcalls: taken, totals: { costUsd, tokens, tokensIn, tokensOut } };
|
|
142
165
|
}
|
|
143
166
|
|
|
144
167
|
// ── Root usage (delegated from RlmEventAggregator) ──
|
|
145
168
|
|
|
146
169
|
/** Accumulate root-level usage into shared totals. Called by aggregator. */
|
|
147
|
-
addRootUsage(costUsd: number, tokens: number): void {
|
|
170
|
+
addRootUsage(costUsd: number, tokens: number, tokensIn = 0, tokensOut = 0): void {
|
|
148
171
|
this.totalCostUsd += costUsd;
|
|
149
172
|
this.totalTokens += tokens;
|
|
173
|
+
this.totalTokensIn += tokensIn;
|
|
174
|
+
this.totalTokensOut += tokensOut;
|
|
150
175
|
this.rootCostUsd += costUsd;
|
|
151
176
|
this.rootTokens += tokens;
|
|
177
|
+
this.rootTokensIn += tokensIn;
|
|
178
|
+
this.rootTokensOut += tokensOut;
|
|
152
179
|
}
|
|
153
180
|
|
|
154
181
|
/** Root engine's OWN spend (driver-model turns only) — never blends sub-call models. */
|
|
155
182
|
getRootUsage(): SubcallTotals {
|
|
156
|
-
return { costUsd: this.rootCostUsd, tokens: this.rootTokens };
|
|
183
|
+
return { costUsd: this.rootCostUsd, tokens: this.rootTokens, tokensIn: this.rootTokensIn, tokensOut: this.rootTokensOut };
|
|
157
184
|
}
|
|
158
185
|
}
|
|
@@ -29,6 +29,8 @@ export interface RunRegistration {
|
|
|
29
29
|
readonly rootModel?: () => string | undefined;
|
|
30
30
|
/** Root's OWN spend (driver-model turns) — never a subtree sum across models. */
|
|
31
31
|
readonly rootTokens?: () => number;
|
|
32
|
+
readonly rootTokensIn?: () => number;
|
|
33
|
+
readonly rootTokensOut?: () => number;
|
|
32
34
|
/** Persistent entries (background work) stay hidden until they hold subcalls. */
|
|
33
35
|
readonly hideWhenEmpty?: boolean;
|
|
34
36
|
}
|
|
@@ -44,6 +46,8 @@ export interface RunEntry {
|
|
|
44
46
|
readonly turns: () => { readonly current: number; readonly max: number };
|
|
45
47
|
readonly rootModel: () => string | undefined;
|
|
46
48
|
readonly rootTokens: () => number;
|
|
49
|
+
readonly rootTokensIn: () => number;
|
|
50
|
+
readonly rootTokensOut: () => number;
|
|
47
51
|
readonly hideWhenEmpty: boolean;
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -66,6 +70,8 @@ export class RunRegistry {
|
|
|
66
70
|
turns: run.turns ?? (() => DEFAULT_TURNS),
|
|
67
71
|
rootModel: run.rootModel ?? (() => undefined),
|
|
68
72
|
rootTokens: run.rootTokens ?? (() => 0),
|
|
73
|
+
rootTokensIn: run.rootTokensIn ?? (() => 0),
|
|
74
|
+
rootTokensOut: run.rootTokensOut ?? (() => 0),
|
|
69
75
|
hideWhenEmpty: run.hideWhenEmpty ?? false,
|
|
70
76
|
};
|
|
71
77
|
this.entries.set(run.runId, entry);
|
|
@@ -119,6 +125,8 @@ export class RunRegistry {
|
|
|
119
125
|
rootPhase: entry.rootPhase(),
|
|
120
126
|
rootModel: entry.rootModel(),
|
|
121
127
|
rootTokens: entry.rootTokens(),
|
|
128
|
+
rootTokensIn: entry.rootTokensIn(),
|
|
129
|
+
rootTokensOut: entry.rootTokensOut(),
|
|
122
130
|
subcalls: entry.subcalls(),
|
|
123
131
|
});
|
|
124
132
|
}
|
package/src/ui/theme.ts
CHANGED
|
@@ -12,6 +12,12 @@ export function formatTokens(n: number): string {
|
|
|
12
12
|
return String(n);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** `190.2k↑ 18.6k↓ tok` — the in/out split. Falls back to plain when out is zero. */
|
|
16
|
+
export function formatTokensSplit(tokensIn: number, tokensOut: number): string {
|
|
17
|
+
if (tokensOut > 0) return `${formatTokens(tokensIn)}↑ ${formatTokens(tokensOut)}↓ tok`;
|
|
18
|
+
return `${formatTokens(tokensIn)} tok`;
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
export function formatDuration(ms: number): string {
|
|
16
22
|
const s = ms / 1000;
|
|
17
23
|
return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`;
|
|
@@ -26,6 +26,8 @@ export interface RunSnapshot {
|
|
|
26
26
|
readonly rootModel?: string;
|
|
27
27
|
/** Root's OWN token spend (driver-model turns) — never a subtree sum. */
|
|
28
28
|
readonly rootTokens: number;
|
|
29
|
+
readonly rootTokensIn: number;
|
|
30
|
+
readonly rootTokensOut: number;
|
|
29
31
|
readonly subcalls: readonly RlmSubcall[];
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -46,6 +48,8 @@ export interface NodeRow {
|
|
|
46
48
|
readonly label: string;
|
|
47
49
|
/** The row's OWN token spend for its OWN model — never a subtree sum. */
|
|
48
50
|
readonly tokens: number;
|
|
51
|
+
readonly tokensIn: number;
|
|
52
|
+
readonly tokensOut: number;
|
|
49
53
|
readonly model?: string;
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -64,6 +68,8 @@ export interface GroupRow {
|
|
|
64
68
|
readonly model?: string;
|
|
65
69
|
/** Sum over members — one model only (the group key pins it), so never a blend. */
|
|
66
70
|
readonly tokens: number;
|
|
71
|
+
readonly tokensIn: number;
|
|
72
|
+
readonly tokensOut: number;
|
|
67
73
|
/** SubcallStatus, or "queued" while any member parks on the rate-limit cooldown. */
|
|
68
74
|
readonly icon: SubcallStatus | "queued";
|
|
69
75
|
readonly expandable: boolean;
|
|
@@ -147,6 +153,8 @@ export function buildRows(
|
|
|
147
153
|
phase: sc.phase,
|
|
148
154
|
label: sc.label,
|
|
149
155
|
tokens: sc.tokens,
|
|
156
|
+
tokensIn: sc.tokensIn,
|
|
157
|
+
tokensOut: sc.tokensOut,
|
|
150
158
|
model: sc.model,
|
|
151
159
|
});
|
|
152
160
|
if (!expanded || children.length === 0) return;
|
|
@@ -181,7 +189,13 @@ export function buildRows(
|
|
|
181
189
|
const id = `grp:${run.runId}:${entry.key}:${first.id}`;
|
|
182
190
|
const expanded = expandedGroups.has(id);
|
|
183
191
|
let tokens = 0;
|
|
184
|
-
|
|
192
|
+
let tokensIn = 0;
|
|
193
|
+
let tokensOut = 0;
|
|
194
|
+
for (const m of entry.members) {
|
|
195
|
+
tokens += m.tokens;
|
|
196
|
+
tokensIn += m.tokensIn;
|
|
197
|
+
tokensOut += m.tokensOut;
|
|
198
|
+
}
|
|
185
199
|
rows.push({
|
|
186
200
|
type: "group",
|
|
187
201
|
id,
|
|
@@ -192,6 +206,8 @@ export function buildRows(
|
|
|
192
206
|
label: entry.label,
|
|
193
207
|
model: entry.model,
|
|
194
208
|
tokens,
|
|
209
|
+
tokensIn,
|
|
210
|
+
tokensOut,
|
|
195
211
|
icon: iconOf(entry.status, entry.members.some((m) => m.phase === "queued") ? "queued" : undefined),
|
|
196
212
|
expandable: true,
|
|
197
213
|
expanded,
|
|
@@ -218,6 +234,8 @@ export function buildRows(
|
|
|
218
234
|
phase: run.rootPhase,
|
|
219
235
|
label: run.rootLabel,
|
|
220
236
|
tokens: run.rootTokens,
|
|
237
|
+
tokensIn: run.rootTokensIn,
|
|
238
|
+
tokensOut: run.rootTokensOut,
|
|
221
239
|
model: run.rootModel,
|
|
222
240
|
});
|
|
223
241
|
if (!collapsed.has(run.runId)) visitChildren(roots, 0, "");
|
package/src/ui/tree/tree-rows.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
-
import { formatTokens, spinnerFrame } from "../theme.ts";
|
|
11
|
+
import { formatTokens, formatTokensSplit, spinnerFrame } from "../theme.ts";
|
|
12
12
|
import type { GroupRow, NodeRow, TreeRow } from "./tree-model.ts";
|
|
13
13
|
|
|
14
14
|
const GLYPHS = Object.freeze({ done: "✓", error: "✗", queued: "◷", expanded: "▾", collapsed: "▸", leaf: " " } as const);
|
|
@@ -29,16 +29,16 @@ function iconGlyph(row: NodeRow, theme: Theme): string {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Right-hand stats: "
|
|
33
|
-
function statsText(tokens: number, model: string | undefined): string {
|
|
34
|
-
const parts = [`${formatTokens(tokens)} tok`];
|
|
32
|
+
/** Right-hand stats: "190.2k↑ 18.6k↓ tok · gpt-5-mini" (split + model only when present). */
|
|
33
|
+
function statsText(tokens: number, tokensIn: number, tokensOut: number, model: string | undefined): string {
|
|
34
|
+
const parts = [tokensOut > 0 ? formatTokensSplit(tokensIn, tokensOut) : `${formatTokens(tokens)} tok`];
|
|
35
35
|
if (model !== undefined) parts.push(modelShort(model));
|
|
36
36
|
return parts.join(" · ");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** Left/right assembly shared by node and group rows — one padding rule, no fork. */
|
|
40
|
-
function assembleLine(left: string, tokens: number, model: string | undefined, selected: boolean, width: number, theme: Theme): string {
|
|
41
|
-
const right = theme.fg("dim", statsText(tokens, model));
|
|
40
|
+
function assembleLine(left: string, tokens: number, tokensIn: number, tokensOut: number, model: string | undefined, selected: boolean, width: number, theme: Theme): string {
|
|
41
|
+
const right = theme.fg("dim", statsText(tokens, tokensIn, tokensOut, model));
|
|
42
42
|
const gap = width - visibleWidth(left) - visibleWidth(right) - 1;
|
|
43
43
|
const line = gap > 0 ? `${left}${" ".repeat(gap)}${right}` : `${truncateToWidth(left, width - 1)} `;
|
|
44
44
|
return selected ? theme.fg("accent", line) : line;
|
|
@@ -48,7 +48,7 @@ function formatNode(row: NodeRow, selected: boolean, width: number, theme: Theme
|
|
|
48
48
|
const chevron = row.expandable ? (row.expanded ? GLYPHS.expanded : GLYPHS.collapsed) : GLYPHS.leaf;
|
|
49
49
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
50
50
|
const left = `${cursor} ${row.prefix}${chevron} ${iconGlyph(row, theme)} ${row.label}`;
|
|
51
|
-
return assembleLine(left, row.tokens, row.model, selected, width, theme);
|
|
51
|
+
return assembleLine(left, row.tokens, row.tokensIn, row.tokensOut, row.model, selected, width, theme);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function formatGroup(row: GroupRow, selected: boolean, width: number, theme: Theme): string {
|
|
@@ -56,7 +56,7 @@ function formatGroup(row: GroupRow, selected: boolean, width: number, theme: The
|
|
|
56
56
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
57
57
|
const icon = row.icon === "done" ? theme.fg("success", GLYPHS.done) : row.icon === "error" ? theme.fg("error", GLYPHS.error) : theme.fg("warning", spinnerFrame());
|
|
58
58
|
const left = `${cursor} ${row.prefix}${chevron} ${icon} ${row.label} ×${row.count}`;
|
|
59
|
-
return assembleLine(left, row.tokens, row.model, selected, width, theme);
|
|
59
|
+
return assembleLine(left, row.tokens, row.tokensIn, row.tokensOut, row.model, selected, width, theme);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export function formatRow(row: TreeRow, selected: boolean, width: number, theme: Theme): string {
|
package/src/util/retry.ts
CHANGED
|
@@ -17,10 +17,13 @@ import { ProviderCooldown, sleepMs, sharedCooldown } from "./throttle.ts";
|
|
|
17
17
|
|
|
18
18
|
// Auth/quota failures must not be retried — they burn attempts and never recover.
|
|
19
19
|
const NON_RETRYABLE_TEXT =
|
|
20
|
-
/api[ -]?key|unauthorized|forbidden|permission denied|billing|insufficient|balance|quota exceeded|not.?found|context length|too large|invalid request|malformed/i;
|
|
21
|
-
// Transport/server transients — worth another attempt.
|
|
20
|
+
/api[ -]?key|unauthorized|forbidden|permission denied|billing|insufficient|balance|quota exceeded|not.?found|context length|too large|invalid request|malformed|content.?filter/i;
|
|
21
|
+
// Transport/server transients — worth another attempt. "Provider finish_reason: error" is the
|
|
22
|
+
// generic shape OpenRouter relays when the UPSTREAM kills a generation mid-stream (observed
|
|
23
|
+
// from Cohere's free pool: native_finish_reason "error", no message, no code, partial usage) —
|
|
24
|
+
// HTTP 200, so it is inherently transient-by-nature and must be retried.
|
|
22
25
|
const RETRYABLE_TEXT =
|
|
23
|
-
/\b429\b|rate.?limit|overloaded|service.?unavailable|upstream|timeout|timed.?out|temporarily|try.?again|econnreset|econnrefused|etimedout|socket hang up|network|1302|速率|频率/i;
|
|
26
|
+
/\b429\b|rate.?limit|overloaded|service.?unavailable|upstream|timeout|timed.?out|temporarily|try.?again|econnreset|econnrefused|etimedout|socket hang up|network|finish.?reason: ?(error|network_error)|1302|速率|频率/i;
|
|
24
27
|
const RATE_LIMIT_TEXT = /\b429\b|rate.?limit|1302|速率|频率/i;
|
|
25
28
|
|
|
26
29
|
const NON_RETRYABLE_STATUS = new Set([400, 401, 402, 403, 404, 413, 422]);
|