@hicaru/pi-rlm 0.3.18 → 0.3.19
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 +9 -21
- package/package.json +1 -1
- package/src/config/defaults.ts +9 -2
- package/src/core/budget.ts +13 -10
- package/src/core/compaction.ts +18 -8
- package/src/core/engine.ts +11 -6
- package/src/core/limits.ts +9 -0
- package/src/core/root-state.ts +17 -8
- package/src/core/run-state.ts +7 -2
- package/src/core/types.ts +2 -1
- package/src/index.ts +12 -2
- package/src/text/parsing.ts +88 -2
- package/src/ui/config-panel.ts +1 -1
- package/src/ui/tree/tree-model.ts +14 -7
package/README.md
CHANGED
|
@@ -43,24 +43,14 @@ models, recursively. Same Pi session, same tools, same keys: `/rlm` and go. Read
|
|
|
43
43
|
|
|
44
44
|
## Benchmarks
|
|
45
45
|
|
|
46
|
-
**OOLONG (oolong-synth)** — paper-tier long-context suite
|
|
47
|
-
cost per task from real `costUsd
|
|
46
|
+
**OOLONG (oolong-synth)** — paper-tier long-context suite (the only suite); latest
|
|
47
|
+
journal per model, cost per task from real `costUsd`:
|
|
48
48
|
|
|
49
49
|
| Model | Score | Avg. cost/task |
|
|
50
50
|
|-------|-------|----------------|
|
|
51
|
-
| `qwen/qwen3.8-27b` | **
|
|
52
|
-
| `google/gemma-3-27b-it` |
|
|
53
|
-
| `
|
|
54
|
-
| `mistralai/mistral-small-3.2-24b-instruct` | 66.7% | $0.0025 |
|
|
55
|
-
|
|
56
|
-
Lite suite — `needle` multi-needle recall, `codeqa` repo-QA, `coding` fix task
|
|
57
|
-
(7 tasks × 2 passes per model, deterministic graders, no LLM-as-judge):
|
|
58
|
-
|
|
59
|
-
| Model | Score | Accuracy |
|
|
60
|
-
|-------|-------|----------|
|
|
61
|
-
| `qwen/qwen3-30b-a3b-instruct-2507` | **14/14** | **100%** |
|
|
62
|
-
| `google/gemma-3-27b-it` | 12/14 | 86% |
|
|
63
|
-
| `mistralai/mistral-small-3.2-24b-instruct` | 12/14 | 86% |
|
|
51
|
+
| `qwen/qwen3.8-27b` | **87.5%** | $0.0460 |
|
|
52
|
+
| `google/gemma-3-27b-it` | 50.0% | $0.0011 |
|
|
53
|
+
| `inception/mercury-2.5` | — | — |
|
|
64
54
|
|
|
65
55
|
Raw per-task rows (correct, recall, latency, tokens, cost) live in
|
|
66
56
|
`bench/runs/*.jsonl` — one JSONL row per task, committed as history.
|
|
@@ -70,15 +60,13 @@ Raw per-task rows (correct, recall, latency, tokens, cost) live in
|
|
|
70
60
|
```bash
|
|
71
61
|
export OPENROUTER_API_KEY=sk-or-... # required — env vars are the only key transport
|
|
72
62
|
|
|
73
|
-
bun run bench #
|
|
74
|
-
bun run bench --
|
|
75
|
-
bun run bench --model openrouter/
|
|
63
|
+
bun run bench # oolong suite, default model (qwen3.8-27b)
|
|
64
|
+
bun run bench --model openrouter/google/gemma-3-27b-it
|
|
65
|
+
bun run bench --model openrouter/inception/mercury-2.5
|
|
76
66
|
bun run bench --list # print tasks, no engine / no key
|
|
77
|
-
bun run bench --suite paper # paper tier: s_niah, oolong, browsecomp, codeqa_lb (downloads datasets)
|
|
78
67
|
```
|
|
79
68
|
|
|
80
|
-
|
|
81
|
-
`oolong` · `browsecomp` · `codeqa_lb`. Regenerate the hero chart:
|
|
69
|
+
One suite (`oolong`). Regenerate the hero chart:
|
|
82
70
|
`python3 bench/hero.py` (needs `matplotlib`).
|
|
83
71
|
|
|
84
72
|
## How it works
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -9,7 +9,10 @@ const DEFAULT_SUB_SYSTEM_PROMPT =
|
|
|
9
9
|
export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
10
10
|
enabled: true,
|
|
11
11
|
maxDepth: 4,
|
|
12
|
-
|
|
12
|
+
// Max-long runs: the engine may keep iterating until budget/compaction walls hit. The budget
|
|
13
|
+
// cascade and compactionThresholdPct are the real length controls; this ceiling only stops
|
|
14
|
+
// truly runaway loops. Was 30 — capped long tasks prematurely.
|
|
15
|
+
maxIterations: 200,
|
|
13
16
|
execTimeoutS: 120,
|
|
14
17
|
requestTimeoutMs: 15 * 60_000,
|
|
15
18
|
// Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
|
|
@@ -34,7 +37,11 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
|
34
37
|
maxErrors: 5,
|
|
35
38
|
orchestrator: true,
|
|
36
39
|
compaction: true,
|
|
37
|
-
|
|
40
|
+
// Compact only near the hard ceiling: ≈125K of the default 128K window (0.976). Earlier
|
|
41
|
+
// compaction (0.65) amputated usable working memory long before it was needed.
|
|
42
|
+
// DEPRECATED: ignored since the absolute 256k compaction ceiling (limits.ts); kept so old
|
|
43
|
+
// rlm.json files still load. Do not read this value in new code.
|
|
44
|
+
compactionThresholdPct: 0.976,
|
|
38
45
|
python: "python3",
|
|
39
46
|
sandboxInitTimeoutMs: 30_000,
|
|
40
47
|
contextLoader: true,
|
package/src/core/budget.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token budget cascade (port of the v4/v5 `budget.py` engine).
|
|
3
3
|
*
|
|
4
|
-
* The budget is the PRIMARY run-length control:
|
|
4
|
+
* The budget is the PRIMARY run-length control: above COMPACTION_CEILING_TOKENS the cap is
|
|
5
|
+
* max(ceiling, budgetShare × model context window) — the share can only stretch the working
|
|
6
|
+
* budget further out, never cut under the ceiling;
|
|
5
7
|
* one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
|
|
6
8
|
* handoff (`distillTrajectory`) is handed to a fresh continuation run — chain-capped at
|
|
7
9
|
* `maxContinuations`. Wall-clock timeouts stay only as hang backstops.
|
|
@@ -16,6 +18,7 @@ import type { ChatMsg } from "../bridge/model.ts";
|
|
|
16
18
|
import type { RlmConfig } from "./types.ts";
|
|
17
19
|
import type { RunState } from "./run-state.ts";
|
|
18
20
|
import { compactJSON } from "./run-state.ts";
|
|
21
|
+
import { COMPACTION_CEILING_TOKENS } from "./limits.ts";
|
|
19
22
|
|
|
20
23
|
interface TokenBudgetOptions {
|
|
21
24
|
readonly softFrac?: number;
|
|
@@ -110,14 +113,13 @@ export class TokenBudget {
|
|
|
110
113
|
/**
|
|
111
114
|
* Minimum context window (tokens) for the token-budget cascade to engage at all.
|
|
112
115
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
116
|
+
* LO rule (2025-09-09): windows at/below COMPACTION_CEILING_TOKENS (256k) are never
|
|
117
|
+
* budget-amputated — the derived share would shrink below a task's FIXED overhead (system
|
|
118
|
+
* prompt + per-turn history re-send + sub-LLM calls); a 32k window would cap a task at 8k
|
|
119
|
+
* tokens, less than the protocol scaffolding alone. Windows above the ceiling are budgeted
|
|
120
|
+
* AT the ceiling, never below it. Unbounded runs stay bounded by
|
|
121
|
+
* maxIterations / maxErrors / wall-clock instead.
|
|
119
122
|
*/
|
|
120
|
-
export const BUDGET_WINDOW_FLOOR = 250_000;
|
|
121
123
|
|
|
122
124
|
/** One TokenBudget construction shape — the cap varies, the policy knobs never do (DRY). */
|
|
123
125
|
function makeBudget(config: RlmConfig, cap: number): TokenBudget {
|
|
@@ -135,8 +137,9 @@ function unboundedBudget(config: RlmConfig): TokenBudget {
|
|
|
135
137
|
|
|
136
138
|
export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
|
|
137
139
|
const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
|
|
138
|
-
if (ctx
|
|
139
|
-
|
|
140
|
+
if (ctx <= COMPACTION_CEILING_TOKENS) return unboundedBudget(config);
|
|
141
|
+
// The share only stretches the budget BEYOND the absolute ceiling — never under it.
|
|
142
|
+
const shareCap = Math.max(COMPACTION_CEILING_TOKENS, Math.floor(ctx * config.budgetShare));
|
|
140
143
|
const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
|
|
141
144
|
return makeBudget(config, Math.max(cap, 1));
|
|
142
145
|
}
|
package/src/core/compaction.ts
CHANGED
|
@@ -13,8 +13,7 @@ import type { RetryPolicy } from "../util/retry.ts";
|
|
|
13
13
|
import { estimateMessageTokens } from "../text/tokens.ts";
|
|
14
14
|
import type { RunState } from "../core/run-state.ts";
|
|
15
15
|
import { compactJSON } from "../core/run-state.ts";
|
|
16
|
-
|
|
17
|
-
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
16
|
+
import { COMPACTION_CEILING_TOKENS } from "./limits.ts";
|
|
18
17
|
|
|
19
18
|
const SUMMARY_REQUEST =
|
|
20
19
|
"Summarize your progress so far. Include: (1) which sub-tasks are done and which remain; " +
|
|
@@ -25,17 +24,18 @@ interface CompactionDeps {
|
|
|
25
24
|
readonly model: Model<Api>;
|
|
26
25
|
readonly registry: ModelRegistry;
|
|
27
26
|
readonly contextWindow?: number;
|
|
28
|
-
readonly thresholdPct?: number;
|
|
29
27
|
readonly signal?: AbortSignal;
|
|
30
28
|
/** v5.1 retry policy for modelComplete; defaults apply when omitted. */
|
|
31
29
|
readonly retry?: RetryPolicy;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
/**
|
|
33
|
+
* True if the history is at/over the compaction threshold — the ABSOLUTE
|
|
34
|
+
* COMPACTION_CEILING_TOKENS (LO rule 2025-09-09): windows ≤ 256k never compact; larger
|
|
35
|
+
* windows compact exactly at 256k. `contextWindow`/`thresholdPct` percentage math is gone.
|
|
36
|
+
*/
|
|
37
|
+
export function shouldCompact(history: ChatMsg[]): boolean {
|
|
38
|
+
return estimateMessageTokens(history) >= COMPACTION_CEILING_TOKENS;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -142,6 +142,16 @@ export function rebaseWithState(history: ChatMsg[], state: RunState, count = 1):
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
// Token-bounded tail (LO rule 2025-09-09): the kept turns must also fit under the absolute
|
|
146
|
+
// ceiling; walk tailStart forward until the tail does. Σ carries everything dropped turns held.
|
|
147
|
+
const sizes: number[] = new Array<number>(history.length);
|
|
148
|
+
for (let i = 0; i < history.length; i++) sizes[i] = estimateMessageTokens([history[i]]);
|
|
149
|
+
let tailTokens = 0;
|
|
150
|
+
for (let i = tailStart; i < history.length; i++) tailTokens += sizes[i];
|
|
151
|
+
while (tailStart < history.length && tailTokens > COMPACTION_CEILING_TOKENS) {
|
|
152
|
+
tailTokens -= sizes[tailStart];
|
|
153
|
+
tailStart += 1;
|
|
154
|
+
}
|
|
145
155
|
const window: ChatMsg[] = tailStart < history.length ? history.slice(tailStart) : [];
|
|
146
156
|
return [
|
|
147
157
|
...head,
|
package/src/core/engine.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox
|
|
|
26
26
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
27
27
|
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
28
28
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
29
|
-
import { findReplBlocks } from "../text/parsing.ts";
|
|
29
|
+
import { findReplBlocks, stripStateFences } from "../text/parsing.ts";
|
|
30
30
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
31
31
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
32
32
|
import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
|
|
@@ -364,16 +364,16 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
364
364
|
// v5 G1 first: elide old tool payloads head+tail — often avoids the summary entirely.
|
|
365
365
|
history = elideOldToolPayloads(history);
|
|
366
366
|
const compactionDeps = {
|
|
367
|
-
// Summarisation is done by the cheap worker model;
|
|
368
|
-
//
|
|
367
|
+
// Summarisation is done by the cheap worker model; compaction fires on the ABSOLUTE
|
|
368
|
+
// COMPACTION_CEILING_TOKENS (limits.ts): ≤256k windows never compact, larger ones
|
|
369
|
+
// compact exactly at 256k (LO rule 2025-09-09).
|
|
369
370
|
model: deps.llmModel,
|
|
370
371
|
registry: deps.registry,
|
|
371
372
|
contextWindow: model.contextWindow,
|
|
372
|
-
thresholdPct: deps.config.compactionThresholdPct,
|
|
373
373
|
retry: retryPolicy(deps.config),
|
|
374
374
|
signal: deps.signal,
|
|
375
375
|
};
|
|
376
|
-
if (shouldCompact(history
|
|
376
|
+
if (shouldCompact(history)) {
|
|
377
377
|
// Workstream A: with Σ active, rebase structurally — [P, Σ_t, window(O)] — and
|
|
378
378
|
// the summarizer call disappears entirely; degraded runs keep compactHistory.
|
|
379
379
|
history = runStateMode.kind === "active"
|
|
@@ -602,8 +602,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
602
602
|
}
|
|
603
603
|
|
|
604
604
|
function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
|
|
605
|
+
// State fences are a Σ transport, never user-visible output (§7): scrub them from the
|
|
606
|
+
// FINAL answer. A fence-only answer means the model spent its last turn committing state
|
|
607
|
+
// and never re-answered — surface the stub instead of a raw patch JSON.
|
|
608
|
+
const clean = stripStateFences(answer);
|
|
609
|
+
const final = clean.trim().length > 0 ? clean.trim() : "(no final answer — last turn committed state only; see Σ)";
|
|
605
610
|
const u = limits.usage();
|
|
606
|
-
return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
|
|
611
|
+
return { answer: final, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
|
|
607
612
|
}
|
|
608
613
|
|
|
609
614
|
/** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
|
package/src/core/limits.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Absolute working ceiling (LO rule 2025-09-09): model windows AT/BELOW this value are never
|
|
13
|
+
* compacted or budget-amputated — the agent runs its full window. Windows ABOVE it are
|
|
14
|
+
* compacted/budgeted exactly AT the ceiling (e.g. a 1M-context model works up to ~256k tokens
|
|
15
|
+
* of history, then rebases/compacts). Single source of truth for budget.ts (resolveBudget)
|
|
16
|
+
* and compaction.ts (shouldCompact + rebaseWithState token bound).
|
|
17
|
+
*/
|
|
18
|
+
export const COMPACTION_CEILING_TOKENS = 256_000;
|
|
19
|
+
|
|
11
20
|
export interface Limits {
|
|
12
21
|
readonly maxTimeoutMs?: number;
|
|
13
22
|
readonly maxTokens?: number;
|
package/src/core/root-state.ts
CHANGED
|
@@ -206,7 +206,11 @@ export class RootStateTracker {
|
|
|
206
206
|
* while runtime `observeToolResult` remains the Σ floor (degrade, never crash).
|
|
207
207
|
*/
|
|
208
208
|
applyFences(fences: readonly StateFenceResult[]): FenceOutcome {
|
|
209
|
-
|
|
209
|
+
// R7-fix (recoverable degrade): a DEGRADED tracker no longer drops fences on the floor.
|
|
210
|
+
// The old early-return turned idle degrade into a one-way amnesia valve — every later
|
|
211
|
+
// fence vanished silently while the context transform kept eliding turns. Now the same
|
|
212
|
+
// validation ladder runs in degraded mode, and a CLEAN batch re-activates compensation.
|
|
213
|
+
// Degrade stays sticky only against zero-progress storms (all-malformed batches).
|
|
210
214
|
if (fences.length === 0) {
|
|
211
215
|
// R4 (G6): a fence-free turn on a conditioned loop is IDLE — the contract rode the
|
|
212
216
|
// prompt for nothing. Grow the streak; degrade at the engine's threshold.
|
|
@@ -235,24 +239,29 @@ export class RootStateTracker {
|
|
|
235
239
|
this.idleFenceTurns = accepted > 0 ? 0 : this.idleFenceTurns + 1;
|
|
236
240
|
this.degradeIfIdle();
|
|
237
241
|
if (problems.length === 0) {
|
|
242
|
+
// Recovery seam: a clean batch re-activates a degraded tracker, so one honest fence
|
|
243
|
+
// ends the amnesia window instead of requiring a session restart.
|
|
238
244
|
this.mode = { kind: "active", retries: 0 };
|
|
239
245
|
this.pendingObservation = undefined;
|
|
240
246
|
this.draft = this.toMutable(state);
|
|
241
247
|
this.touch();
|
|
242
248
|
return { fences: fences.length, accepted, problems: 0 };
|
|
243
249
|
}
|
|
244
|
-
|
|
250
|
+
// Degraded variant carries `reason`, not `retries` — recovery is clean-batch-only, so a
|
|
251
|
+
// degraded tracker neither accumulates retries nor re-activates on a partial batch.
|
|
252
|
+
const retries = (this.mode.kind === "active" ? this.mode.retries : 0) + problems.length;
|
|
245
253
|
this.pendingObservation = statePatchObservation(problems);
|
|
246
254
|
// Accepted deltas in a partially-failing batch still land — engine parity: real work is
|
|
247
255
|
// never rolled back just because a sibling fence was malformed.
|
|
248
256
|
this.draft = this.toMutable(state);
|
|
249
257
|
this.touch();
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
if (this.mode.kind === "active") {
|
|
259
|
+
if (retries > this.retryMax) {
|
|
260
|
+
this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
|
|
261
|
+
} else {
|
|
262
|
+
// Persist the running rejection count — the retry cap is CUMULATIVE across turns.
|
|
263
|
+
this.mode = { kind: "active", retries };
|
|
264
|
+
}
|
|
256
265
|
}
|
|
257
266
|
return { fences: fences.length, accepted, problems: problems.length };
|
|
258
267
|
}
|
package/src/core/run-state.ts
CHANGED
|
@@ -552,13 +552,18 @@ export function statePatchObservation(problems: readonly string[]): string | und
|
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
export const STATE_FENCE_INSTRUCTION: string =
|
|
555
|
-
"[state]
|
|
555
|
+
"[state] Your user-facing reply is normal prose — a readable report. The ```state fence is OPTIONAL compact metadata that trails it, never a replacement for the report:\n" +
|
|
556
556
|
'{"state_patch": {"verifiedFacts[+]": "src/x.ts — fact", "testedApproaches.h1.status": "failed"}}\n' +
|
|
557
|
+
"Σ is an index of pointers, not a report: ≤ 5 keys per patch, every string value ≤ 120 chars, " +
|
|
558
|
+
"telegraphic style (`path — fact`, `verdict — numbers`). NEVER paste findings, tables, JSON " +
|
|
559
|
+
"blobs, or long excerpts into Σ — the prose carries the story, Σ carries only the pointers.\n" +
|
|
557
560
|
"Keys: dotted paths write record leaves; [+] appends; [N] sets an array slot; null deletes.\n" +
|
|
558
561
|
"Commit DELTAS only — never restate unchanged records or arrays; touch single dotted keys " +
|
|
559
562
|
`or append with [+]. Whole-record restatements must keep EVERY key (implicit drops are ` +
|
|
560
563
|
`rejected), and a patch over ${RUN_STATE_LIMITS.patchBytes} bytes is rejected whole.\n` +
|
|
561
|
-
"
|
|
564
|
+
"If this turn produced nothing durable and new, end your reply on the prose — NO fence at all. " +
|
|
565
|
+
"An absent fence is free; a malformed or oversized one costs a retry. " +
|
|
566
|
+
"Stale turns are elided and compensated by Σ, so a durable fact you skip here is gone.";
|
|
562
567
|
|
|
563
568
|
/** ONE Σ-block composer (R2): the engine turn block and the root splice both delegate here —
|
|
564
569
|
* never duplicate the contract + Σ assembly. `withContract` is paper A.4 authoring mode;
|
package/src/core/types.ts
CHANGED
|
@@ -54,7 +54,8 @@ export interface RlmConfig {
|
|
|
54
54
|
readonly orchestrator: boolean;
|
|
55
55
|
/** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
|
|
56
56
|
readonly compaction: boolean;
|
|
57
|
-
/**
|
|
57
|
+
/** DEPRECATED (LO rule 2025-09-09): ignored — compaction is governed by the absolute
|
|
58
|
+
* COMPACTION_CEILING_TOKENS (limits.ts). Kept for settings/UI compatibility only. */
|
|
58
59
|
readonly compactionThresholdPct: number;
|
|
59
60
|
/** Python executable used to launch the sandbox worker. */
|
|
60
61
|
readonly python: string;
|
package/src/index.ts
CHANGED
|
@@ -433,6 +433,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
433
433
|
idle: tracker.idleTurns,
|
|
434
434
|
active: tracker.isActive,
|
|
435
435
|
degraded: wasActive && !tracker.isActive,
|
|
436
|
+
// R7-fix: recovery observability — a degraded tracker that accepted a clean batch.
|
|
437
|
+
recovered: !wasActive && tracker.isActive,
|
|
436
438
|
});
|
|
437
439
|
}
|
|
438
440
|
if (wasActive && !tracker.isActive) {
|
|
@@ -503,12 +505,20 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
503
505
|
});
|
|
504
506
|
elidedMessages += elided;
|
|
505
507
|
const tracker = rootTracker;
|
|
506
|
-
// R4
|
|
508
|
+
// R4 REV (amnesia fix): degrade suspends fence WRITES (applyFences gate) — never Σ
|
|
509
|
+
// READBACK. SKILL.state §5.3 makes elision lossless precisely because Σ rides with
|
|
510
|
+
// the elided turns; stubbing history while withholding Σ amnesia-loops the agent
|
|
511
|
+
// (announce-continue-then-stop). Splice whenever Σ has content, active or degraded;
|
|
512
|
+
// the fence CONTRACT rides only while active — a degraded tracker ignores fences
|
|
513
|
+
// (applyFences early-returns), so teaching the contract is pure tax.
|
|
507
514
|
if (
|
|
508
515
|
controller.config.rootContextSnapshot && tracker !== undefined &&
|
|
509
|
-
|
|
516
|
+
!tracker.isEmpty
|
|
510
517
|
) {
|
|
511
518
|
spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint(), {
|
|
519
|
+
// R7-fix: teach the contract while DEGRADED too — it is the only road back.
|
|
520
|
+
// Recovery is a clean fence; hiding the notation after degrade made the
|
|
521
|
+
// amnesia window permanent (the fence turns that taught it get elided).
|
|
512
522
|
withContract: controller.config.enableRootStateFences,
|
|
513
523
|
});
|
|
514
524
|
sigmaSplices += 1;
|
package/src/text/parsing.ts
CHANGED
|
@@ -49,10 +49,55 @@ export type StateFenceResult =
|
|
|
49
49
|
|
|
50
50
|
const STATE_FENCE = /(`{3,})[ \t]*state[ \t]*\r?\n([\s\S]*?)\1/g;
|
|
51
51
|
|
|
52
|
+
/** Tolerant fallback: a payload object whose FIRST key is the patch key, emitted without a
|
|
53
|
+
* (well-formed) fence — soak keeps catching `...report.state {"state_patch": …}}` blobs from
|
|
54
|
+
* small models that mangle the opening backticks. Matched literally so ordinary prose or
|
|
55
|
+
* example JSON never trips the scanner. */
|
|
56
|
+
const BARE_PATCH = /\{"state_patch"\s*:/g;
|
|
57
|
+
|
|
58
|
+
/** String-aware balanced-brace scan from `start` (an index of `{`). Honors string literals and
|
|
59
|
+
* backslash escapes so braces inside JSON strings cannot unbalance the count. Returns the
|
|
60
|
+
* complete object slice, or undefined when braces never balance before EOF. */
|
|
61
|
+
function balancedJsonObject(text: string, start: number): string | undefined {
|
|
62
|
+
let depth = 0;
|
|
63
|
+
let inStr = false;
|
|
64
|
+
let esc = false;
|
|
65
|
+
for (let i = start; i < text.length; i++) {
|
|
66
|
+
const ch = text.charAt(i);
|
|
67
|
+
if (inStr) {
|
|
68
|
+
if (esc) esc = false;
|
|
69
|
+
else if (ch === "\\") esc = true;
|
|
70
|
+
else if (ch === '"') inStr = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (ch === '"') inStr = true;
|
|
74
|
+
else if (ch === "{") depth += 1;
|
|
75
|
+
else if (ch === "}") {
|
|
76
|
+
depth -= 1;
|
|
77
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A dangling opener/closer pair after the fence body was mangled (e.g. `.state {…}}` followed
|
|
84
|
+
* by a lone ``` line) — cosmetic residue we scrub alongside the object itself. */
|
|
85
|
+
const ORPHAN_FENCE = /^ {0,3}`{3,}[ \t]*\r?$/gm;
|
|
86
|
+
|
|
87
|
+
/** Bookkeeping transport, never content: remove well-formed ```state fences AND the bare
|
|
88
|
+
* {"state_patch"…} objects models leak when they botch the fence syntax (both directions —
|
|
89
|
+
* parse for Σ harvest, strip for user-visible answers). Order: well-formed fences out first,
|
|
90
|
+
* then the bare-object scan over the remainder can never double-count the same payload. */
|
|
91
|
+
function sansFences(text: string): string {
|
|
92
|
+
return text.replace(STATE_FENCE, "");
|
|
93
|
+
}
|
|
94
|
+
|
|
52
95
|
/**
|
|
53
96
|
* Workstream A: extract ```state fences (model-proposed ΔΣ_t) from a response, in document
|
|
54
|
-
* order. ```repl parsing is untouched — the two fences coexist in one response.
|
|
55
|
-
*
|
|
97
|
+
* order. ```repl parsing is untouched — the two fences coexist in one response. Well-formed
|
|
98
|
+
* fences yield parsed payloads or a parse error (error-as-observation for the retry ladder);
|
|
99
|
+
* malformed-fence payloads are recovered by the tolerant bare-object scanner, so a mangled
|
|
100
|
+
* opening fence never orphans a valid delta.
|
|
56
101
|
*/
|
|
57
102
|
export function findStatePatches(text: string): readonly StateFenceResult[] {
|
|
58
103
|
const out: StateFenceResult[] = [];
|
|
@@ -67,9 +112,50 @@ export function findStatePatches(text: string): readonly StateFenceResult[] {
|
|
|
67
112
|
out.push({ ok: false, error: errorMessage(err) });
|
|
68
113
|
}
|
|
69
114
|
}
|
|
115
|
+
// Tolerant harvest over fence-free remainder (well-formed payloads already taken above).
|
|
116
|
+
const rest = sansFences(text);
|
|
117
|
+
BARE_PATCH.lastIndex = 0;
|
|
118
|
+
while ((m = BARE_PATCH.exec(rest)) !== null) {
|
|
119
|
+
const obj = balancedJsonObject(rest, m.index);
|
|
120
|
+
if (obj === undefined) continue;
|
|
121
|
+
try {
|
|
122
|
+
out.push({ ok: true, value: JSON.parse(obj) as unknown });
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
out.push({ ok: false, error: errorMessage(err) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
70
127
|
return out;
|
|
71
128
|
}
|
|
72
129
|
|
|
130
|
+
/** Strip ```state fences from free text. A Σ fence is bookkeeping, never content — but models
|
|
131
|
+
* that finalize right after a Σ splice tend to echo the fence verbatim as their final output,
|
|
132
|
+
* which leaked raw state JSON into RlmResult.answer (bench graders scored JSON, reports showed
|
|
133
|
+
* bookkeeping). Deterministic scrub on the answer path; parse semantics stay in findStatePatches.
|
|
134
|
+
* Also removes bare {"state_patch"…} objects (mangled-fence leaks), a `state` token glued to
|
|
135
|
+
* preceding prose, and orphan ``` lines left behind by the mangled pair. */
|
|
136
|
+
export function stripStateFences(text: string): string {
|
|
137
|
+
let out = sansFences(text);
|
|
138
|
+
const parts: string[] = [];
|
|
139
|
+
let cursor = 0;
|
|
140
|
+
BARE_PATCH.lastIndex = 0;
|
|
141
|
+
let m: RegExpExecArray | null;
|
|
142
|
+
while ((m = BARE_PATCH.exec(out)) !== null) {
|
|
143
|
+
const obj = balancedJsonObject(out, m.index);
|
|
144
|
+
if (obj === undefined) continue;
|
|
145
|
+
// Glom any immediately-preceding bare `state`/`.state` token (prose like "...report.state {").
|
|
146
|
+
const before = out.slice(cursor, m.index).replace(/\s*(?:\.?state)\s*$/i, "");
|
|
147
|
+
parts.push(before);
|
|
148
|
+
cursor = m.index + obj.length;
|
|
149
|
+
BARE_PATCH.lastIndex = cursor;
|
|
150
|
+
}
|
|
151
|
+
// No bare objects → leave the text exactly as the well-formed pass left it (a lone ```
|
|
152
|
+
// line can be a legitimate unclosed code fence in real content; only scrub residue that
|
|
153
|
+
// our own removal created).
|
|
154
|
+
if (parts.length === 0) return out.trim();
|
|
155
|
+
parts.push(out.slice(cursor));
|
|
156
|
+
return parts.join("").replace(ORPHAN_FENCE, "").trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
73
159
|
/** Truncate REPL stdout for the model's context window (head + tail, with an elision note).
|
|
74
160
|
* `mark` lets callers specialize the wording (root elision cites the session log) while the
|
|
75
161
|
* head/tail math stays the one implementation. */
|
package/src/ui/config-panel.ts
CHANGED
|
@@ -52,7 +52,7 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
|
|
|
52
52
|
item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
|
|
53
53
|
item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
|
|
54
54
|
item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
|
|
55
|
-
item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "
|
|
55
|
+
item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "DEPRECATED — ignored: compaction uses the absolute 256k ceiling (COMPACTION_CEILING_TOKENS)."),
|
|
56
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
57
|
item("rootSamplingTemperature", "Root sampling temperature", config.rootSampling?.temperature === undefined ? "default" : String(config.rootSampling?.temperature), CHOICES.rootSamplingTemperature,
|
|
58
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."),
|
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Nothing is ever hidden: every sub-call renders as its own row (parity with
|
|
8
8
|
* pi, which shows each concurrent tool call individually) — except runs of
|
|
9
|
-
* IDENTICAL sibling leaves (same label+model+status), which
|
|
10
|
-
* expandable "label ×N" group row so a 20-item llm_batch is
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* CONSECUTIVE IDENTICAL sibling leaves (same label+model+status), which
|
|
10
|
+
* collapse into one expandable "label ×N" group row so a 20-item llm_batch is
|
|
11
|
+
* one line, not 20 and a wholesale batch failure is one `✗ label ×N` line.
|
|
12
|
+
* Errors group exactly like successes; distinct failures keep their own rows
|
|
13
|
+
* and reasons, and singletons render as plain rows. Interleaved siblings (✗ ✓
|
|
14
|
+
* ✗ with different keys between) stay in encounter order — position is never
|
|
15
|
+
* rewritten. Collapsed subtrees are skipped at the user's explicit request
|
|
16
|
+
* (chevron flips). Token rows are own-spend only — a row never blends models.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import type { RlmSubcall, RlmRunStatus, SubcallPhase, SubcallStatus } from "../../tool/rlm-details.ts";
|
|
@@ -81,9 +84,13 @@ type Entry =
|
|
|
81
84
|
| { readonly type: "node"; readonly sc: RlmSubcall }
|
|
82
85
|
| { readonly type: "group"; readonly key: string; readonly label: string; readonly model?: string; readonly status: SubcallStatus; readonly members: RlmSubcall[] };
|
|
83
86
|
|
|
84
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Any childless llm leaf may group — errors included. groupKey pins status, so
|
|
89
|
+
* only runs of identical failures merge; per-item reasons stay in the detail
|
|
90
|
+
* modal (expand the group).
|
|
91
|
+
*/
|
|
85
92
|
const groupable = (sc: RlmSubcall, byParent: ReadonlyMap<string | undefined, RlmSubcall[]>): boolean =>
|
|
86
|
-
sc.kind === "llm" &&
|
|
93
|
+
sc.kind === "llm" && (byParent.get(sc.id)?.length ?? 0) === 0;
|
|
87
94
|
|
|
88
95
|
const groupKey = (sc: RlmSubcall): string => `${sc.label}|${sc.model ?? ""}|${sc.status}`;
|
|
89
96
|
|