@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 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; latest journal per model,
47
- cost per task from real `costUsd` (older journals estimated at OpenRouter list prices):
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` | **100%** | $0.0127 |
52
- | `google/gemma-3-27b-it` | 83.3% | $0.0013 |
53
- | `qwen/qwen3-30b-a3b-instruct-2507` | 66.7% | $0.0009 |
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 # lite suite: needle + codeqa + coding
74
- bun run bench --suite needle --limit 1 # one suite, first task only
75
- bun run bench --model openrouter/qwen/qwen3-30b-a3b-instruct-2507
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
- Suites: `all` (lite, default) · `needle` · `codeqa` · `coding` · `paper` · `s_niah` ·
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- maxIterations: 30,
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
- compactionThresholdPct: 0.65,
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,
@@ -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: cap = budgetShare × model context window,
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
- * 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.
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 < BUDGET_WINDOW_FLOOR) return unboundedBudget(config);
139
- const shareCap = Math.floor(ctx * config.budgetShare);
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
  }
@@ -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
- /** True if the history is at/over the compaction threshold. */
35
- export function shouldCompact(history: ChatMsg[], deps: CompactionDeps): boolean {
36
- const contextWindow = deps.contextWindow && deps.contextWindow > 0 ? deps.contextWindow : DEFAULT_CONTEXT_WINDOW;
37
- const threshold = (deps.thresholdPct ?? 0.85) * contextWindow;
38
- return estimateMessageTokens(history) >= threshold;
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,
@@ -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; the threshold stays on the
368
- // root model's context window (that is the window the history fills each turn).
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, compactionDeps)) {
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). */
@@ -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;
@@ -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
- if (this.mode.kind !== "active") return { fences: fences.length, accepted: 0, problems: 0 };
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
- const retries = this.mode.retries + problems.length;
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 (retries > this.retryMax) {
251
- this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
252
- } else if (this.mode.kind === "active") {
253
- // An idle degrade fired earlier in this call wins over re-activating — degrade is
254
- // sticky; the runtime observation floor keeps Σ alive until the session ends.
255
- this.mode = { kind: "active", retries };
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
  }
@@ -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] Alongside your ```repl block(s), commit durable progress to Σ with a ```state fence:\n" +
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
- "Commit anything possibly relevant NOW; the raw observation will not be shown again.";
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
- /** Compact when estimated history tokens reach this fraction of the model's context window. */
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: a DEGRADED tracker stops splicing Σ (isActive gate) — pure input tax otherwise.
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
- tracker.isActive && !tracker.isEmpty
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;
@@ -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. Malformed
55
- * JSON is surfaced as an error result for the retry loop, never thrown.
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. */
@@ -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, "Compact once estimated history tokens reach this share of the root model's context window."),
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 collapse into one
10
- * expandable "label ×N" group row so a 20-item llm_batch is one line, not 20.
11
- * Error leaves are NEVER grouped each keeps its own row and reason.
12
- * Collapsed subtrees are skipped at the user's explicit request (chevron flips).
13
- * Token rows are own-spend only a row never blends models.
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
- /** Errors never group — each keeps its own row and its own reason. */
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" && sc.status !== "error" && (byParent.get(sc.id)?.length ?? 0) === 0;
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