@hicaru/pi-rlm 0.3.18 → 0.3.20

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.
@@ -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,46 @@ 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
+ }
40
+
41
+ /**
42
+ * P3.2 (plan §3.4): never elide an ANSWER FRAME — `answer['content'] = …` is the run's only
43
+ * durable output, and dropping it from history is how a finished run still reports empty.
44
+ */
45
+ const ANSWER_FRAME_RE = /answer\[\s*['"](?:content|ready)['"]\s*\]|answers\s*\.\s*update\s*\(/;
46
+
47
+ /** Identifier shape used by the last-reference scan (`counter_a`, `rows_by_label`, …). */
48
+ const REF_TOKEN_RE = /[A-Za-z_][A-Za-z0-9_]{2,}/g;
49
+ /** Bounds: per-payload ids and the growing "future" set stay tiny (O(T·N) with small N). */
50
+ const PAYLOAD_TOKEN_CAP = 64;
51
+ const REF_TOKEN_CAP = 512;
52
+
53
+ /** Whitespace-collapsed body skeleton — two identical page dumps share it even when the
54
+ * turn banner differs (P3.2 "按内容签名去重"). Exact text, NOT digit-normalised: two
55
+ * `counter_a=875` / `counter_a=499` payloads are different conclusions and must not collapse. */
56
+ function payloadSignature(content: string): string {
57
+ return content.replace(/\s+/g, " ").trim().slice(0, 400) + "#" + content.length;
58
+ }
59
+
60
+ function payloadTokens(content: string, into: Set<string>): void {
61
+ let n = 0;
62
+ REF_TOKEN_RE.lastIndex = 0;
63
+ for (let m = REF_TOKEN_RE.exec(content); m !== null; m = REF_TOKEN_RE.exec(content)) {
64
+ into.add(m[0]);
65
+ if (++n >= PAYLOAD_TOKEN_CAP) return;
66
+ }
39
67
  }
40
68
 
41
69
  /**
@@ -44,6 +72,11 @@ export function shouldCompact(history: ChatMsg[], deps: CompactionDeps): boolean
44
72
  * alone, often avoiding the summarizer entirely. Head-ONLY elision was a measured v3 bug
45
73
  * (turns grew 3→8): the tail carries the current working set, so the last `keepTurns` turns
46
74
  * are never touched.
75
+ *
76
+ * P3.2 adds a whitelist on top of the volume rule (which stays as the floor — dedup and
77
+ * exemptions only ever REMOVE bytes, never add them back):
78
+ * - answer frames and payloads whose identifiers a later turn still mentions are kept verbatim;
79
+ * - a payload byte-identical to an earlier one collapses to a one-line stub.
47
80
  */
48
81
  export function elideOldToolPayloads(
49
82
  history: ChatMsg[],
@@ -65,17 +98,60 @@ export function elideOldToolPayloads(
65
98
  }
66
99
  }
67
100
  if (tailStart === 0) return history; // fewer turns than keepTurns — nothing to elide
101
+
102
+ // Last-reference scan (descending): `future` holds the identifiers mentioned by everything
103
+ // AFTER the message we are looking at — the working set the model still has in hand.
104
+ const future = new Set<string>();
105
+ const referenced = new Array<boolean>(history.length).fill(false);
106
+ const ids = new Set<string>();
107
+ for (let i = history.length - 1; i >= 0; i--) {
108
+ const m = history[i];
109
+ if (m.role === "assistant") {
110
+ if (future.size < REF_TOKEN_CAP) payloadTokens(m.content, future);
111
+ continue;
112
+ }
113
+ if (m.role !== "user" || i >= tailStart || m.content.length <= toolChars) continue;
114
+ if (ANSWER_FRAME_RE.test(m.content)) continue; // answer frames are kept anyway
115
+ ids.clear();
116
+ payloadTokens(m.content.slice(0, 2_000), ids);
117
+ for (const id of ids) {
118
+ if (future.has(id)) {
119
+ referenced[i] = true;
120
+ break;
121
+ }
122
+ }
123
+ }
124
+
68
125
  let changed = false;
69
- const marker = "\n…[elided v5-G1]…";
126
+ const marker =
127
+ "\n…[elided v5-G1 — your repl sandbox is INTACT: variables/answers persist; re-run or " +
128
+ "print(answers) in the next repl to re-derive this content]…";
129
+ const dupMarker =
130
+ "\n…[dup v5-G1 — byte-identical payload already in this history; sandbox INTACT: " +
131
+ "print(<expr>) to inspect it again]…";
132
+ const signatures = new Set<string>();
70
133
  const out: ChatMsg[] = new Array<ChatMsg>(history.length); // pre-allocated
71
134
  for (let i = 0; i < history.length; i++) {
72
135
  const m = history[i];
73
- if (
74
- i < tailStart &&
75
- m.role === "user" &&
76
- m.content.length > toolChars
77
- ) {
78
- out[i] = { role: "user", content: m.content.slice(0, toolChars) + marker };
136
+ if (i < tailStart && m.role === "user" && m.content.length > toolChars) {
137
+ if (ANSWER_FRAME_RE.test(m.content)) {
138
+ out[i] = m; // never touch the answer frame
139
+ continue;
140
+ }
141
+ const sig = payloadSignature(m.content);
142
+ if (signatures.has(sig)) {
143
+ out[i] = { role: "user", content: dupMarker.trimStart() };
144
+ changed = true;
145
+ continue;
146
+ }
147
+ signatures.add(sig);
148
+ if (referenced[i]) {
149
+ out[i] = m; // a later turn still names what this payload produced
150
+ continue;
151
+ }
152
+ // Elided message is capped at exactly toolChars total (§5.3 preview + marker).
153
+ const body = m.content.slice(0, Math.max(0, toolChars - marker.length));
154
+ out[i] = { role: "user", content: body + marker };
79
155
  changed = true;
80
156
  } else {
81
157
  out[i] = m;
@@ -142,6 +218,16 @@ export function rebaseWithState(history: ChatMsg[], state: RunState, count = 1):
142
218
  }
143
219
  }
144
220
  }
221
+ // Token-bounded tail (LO rule 2025-09-09): the kept turns must also fit under the absolute
222
+ // ceiling; walk tailStart forward until the tail does. Σ carries everything dropped turns held.
223
+ const sizes: number[] = new Array<number>(history.length);
224
+ for (let i = 0; i < history.length; i++) sizes[i] = estimateMessageTokens([history[i]]);
225
+ let tailTokens = 0;
226
+ for (let i = tailStart; i < history.length; i++) tailTokens += sizes[i];
227
+ while (tailStart < history.length && tailTokens > COMPACTION_CEILING_TOKENS) {
228
+ tailTokens -= sizes[tailStart];
229
+ tailStart += 1;
230
+ }
145
231
  const window: ChatMsg[] = tailStart < history.length ? history.slice(tailStart) : [];
146
232
  return [
147
233
  ...head,
@@ -26,9 +26,9 @@ 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
- import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
31
+ import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, latestStdoutOf, turnHadError } from "./answer.ts";
32
32
  import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
33
33
  import { applyStatePatches, freshRunState, runStateTurnBlock, type RunState, type RunStateMode } from "./run-state.ts";
34
34
  import { findStatePatches } from "../text/parsing.ts";
@@ -141,8 +141,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
141
141
  limits.addUsage(u);
142
142
  deps.onUsage?.(u, "sub");
143
143
  },
144
- addRaw: (costUsd, inputTokens, outputTokens) => {
145
- limits.addRaw(costUsd, inputTokens, outputTokens);
144
+ addRaw: (inputTokens, outputTokens) => {
145
+ limits.addRaw(inputTokens, outputTokens);
146
146
  },
147
147
  },
148
148
  };
@@ -269,6 +269,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
269
269
  await previous?.release();
270
270
  };
271
271
  let best = "";
272
+ // P2 §3.4: last non-empty repl stdout across the whole run — recovered by the bench when
273
+ // the run ends with no `answer[…]` frame (the value was printed, just never submitted).
274
+ let lastStdout = "";
272
275
  let lastAnswer = "";
273
276
  let compactions = 0;
274
277
  let completedTurns = 0;
@@ -364,16 +367,16 @@ export function createEngine(deps: EngineDeps): RunRlm {
364
367
  // v5 G1 first: elide old tool payloads head+tail — often avoids the summary entirely.
365
368
  history = elideOldToolPayloads(history);
366
369
  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).
370
+ // Summarisation is done by the cheap worker model; compaction fires on the ABSOLUTE
371
+ // COMPACTION_CEILING_TOKENS (limits.ts): ≤256k windows never compact, larger ones
372
+ // compact exactly at 256k (LO rule 2025-09-09).
369
373
  model: deps.llmModel,
370
374
  registry: deps.registry,
371
375
  contextWindow: model.contextWindow,
372
- thresholdPct: deps.config.compactionThresholdPct,
373
376
  retry: retryPolicy(deps.config),
374
377
  signal: deps.signal,
375
378
  };
376
- if (shouldCompact(history, compactionDeps)) {
379
+ if (shouldCompact(history)) {
377
380
  // Workstream A: with Σ active, rebase structurally — [P, Σ_t, window(O)] — and
378
381
  // the summarizer call disappears entirely; degraded runs keep compactHistory.
379
382
  history = runStateMode.kind === "active"
@@ -412,7 +415,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
412
415
  verificationNudgePending ? VERIFICATION_NUDGE : undefined,
413
416
  // One-shot (turn 0 only): thinking tokens share the completion budget — mirror of
414
417
  // the bench's doubling rule. Advisory; never fatal, never repeated.
415
- i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) < 8_192
418
+ // P3.1a (plan §3.4): `<= 8_192` the bench pins maxTokens = 8192 exactly, so the old
419
+ // strict `<` made this a dead condition and REASONING_BUDGET_HINT never fired.
420
+ i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) <= 8_192
416
421
  ? REASONING_BUDGET_HINT
417
422
  : undefined,
418
423
  ]
@@ -427,6 +432,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
427
432
  sampling: rootSampling,
428
433
  retry: deps.complete === undefined ? retryPolicy(deps.config) : undefined,
429
434
  signal: deps.signal,
435
+ // Long-context providers: bigmodel.cn TTFB scales ~1 min per 10k ctx chars; the
436
+ // pi-ai default idle cap aborts such turns as "Request timed out". One knob, both seams.
437
+ timeoutMs: deps.config.requestTimeoutMs,
430
438
  complete: deps.complete,
431
439
  onPhase: reportPhase,
432
440
  });
@@ -441,18 +449,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
441
449
  if (selfReportId) {
442
450
  emitter.emitSubcallUpdated({
443
451
  id: selfReportId,
444
- costUsd: turn.usage.cost.total,
445
452
  tokens: turn.usage.totalTokens,
446
453
  tokensIn: turn.usage.input,
447
454
  tokensOut: turn.usage.output,
448
455
  });
449
456
  } else {
450
- emitter.emitRootUsage(turn.usage.cost.total, turn.usage.totalTokens, turn.usage.input, turn.usage.output);
457
+ emitter.emitRootUsage(turn.usage.totalTokens, turn.usage.input, turn.usage.output);
451
458
  }
452
459
  deps.onUsage?.(turn.usage, "root");
453
460
  const answerContent = latestAnswerContentOf(turn.results);
454
461
  if (answerContent) best = answerContent;
455
462
  else if (!best && turn.response.trim()) best = turn.response;
463
+ // Stdout fallback floor (P2 §3.4): keep the newest non-empty block output, always —
464
+ // cheapest possible recovery for a run that never submits a final frame.
465
+ const turnStdout = latestStdoutOf(turn.results);
466
+ if (turnStdout) lastStdout = turnStdout;
456
467
  completedTurns = i + 1;
457
468
  const final = finalAnswerOf(turn.results);
458
469
  if (final != null) {
@@ -464,7 +475,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
464
475
  verificationNudged = true;
465
476
  verificationNudgePending = true;
466
477
  } else {
467
- const done = result(final, i + 1, limits);
478
+ const done = result(final, i + 1, limits, lastStdout);
468
479
  lastAnswer = done.answer;
469
480
  return done;
470
481
  }
@@ -546,7 +557,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
546
557
  iterations: inner.iterations + completedTurns,
547
558
  inputTokens: inner.inputTokens + u.inputTokens,
548
559
  outputTokens: inner.outputTokens + u.outputTokens,
549
- costUsd: inner.costUsd + u.costUsd,
550
560
  };
551
561
  // R2: lastAnswer must be set before return — `finally` emitAnswer reads it.
552
562
  lastAnswer = chained.answer;
@@ -558,19 +568,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
558
568
  }
559
569
  }
560
570
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
561
- const finalized = result(await finalize(history, model, deps, limits, sandbox), deps.config.maxIterations, limits);
571
+ const finalized = result(await finalize(history, model, deps, limits, sandbox), completedTurns, limits, lastStdout);
562
572
  lastAnswer = finalized.answer;
563
573
  return finalized;
564
574
  } catch (err) {
565
575
  // Abort is a user action — resolve with the best partial, not an error.
566
576
  if (deps.signal?.aborted) {
567
- const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
577
+ const aborted = result(best.trim() || "(aborted)", completedTurns, limits, lastStdout);
568
578
  lastAnswer = aborted.answer;
569
579
  return aborted;
570
580
  }
571
581
  if (err instanceof LimitError) {
572
582
  nodeStatus = "error";
573
- const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
583
+ const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits, lastStdout);
574
584
  lastAnswer = stopped.answer;
575
585
  return stopped;
576
586
  }
@@ -601,9 +611,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
601
611
  return run;
602
612
  }
603
613
 
604
- function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
614
+ function result(answer: string, iterations: number, limits: LimitGuard, lastStdout: string): RlmResult {
615
+ // State fences are a Σ transport, never user-visible output (§7): scrub them from the
616
+ // FINAL answer. A fence-only answer means the model spent its last turn committing state
617
+ // and never re-answered — surface the stub instead of a raw patch JSON.
618
+ const clean = stripStateFences(answer);
619
+ const final = clean.trim().length > 0 ? clean.trim() : "(no final answer — last turn committed state only; see Σ)";
605
620
  const u = limits.usage();
606
- return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
621
+ return {
622
+ answer: final,
623
+ iterations,
624
+ inputTokens: u.inputTokens,
625
+ outputTokens: u.outputTokens,
626
+ durationMs: u.durationMs,
627
+ lastStdout,
628
+ };
607
629
  }
608
630
 
609
631
  /** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
@@ -30,6 +30,9 @@ interface TurnDeps {
30
30
  readonly registry: ModelRegistry;
31
31
  readonly sampling?: Sampling;
32
32
  readonly signal?: AbortSignal;
33
+ /** Wall-clock cap per provider request (ms) — forwarded to the modelComplete seam.
34
+ * Long-context providers (zai bigmodel TTFB ~1 min per 10k ctx chars) need it raised. */
35
+ readonly timeoutMs?: number;
33
36
  /** Test-only override for model completion (scripted responses). */
34
37
  readonly complete?: CompleteFn;
35
38
  /** v5.1 retry policy for modelComplete (rate-limit resilience); defaults apply when omitted. */
@@ -52,6 +55,7 @@ export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbo
52
55
  onThrottlePark: deps.onPhase ? () => deps.onPhase?.("queued") : undefined,
53
56
  onThrottleRelease: deps.onPhase ? () => deps.onPhase?.("thinking") : undefined,
54
57
  signal: deps.signal,
58
+ timeoutMs: deps.timeoutMs,
55
59
  });
56
60
 
57
61
  const blocks = findReplBlocks(text);
@@ -3,11 +3,21 @@
3
3
  * (ported from rlm/core/rlm.py `_check_timeout` / `_check_iteration_limits`). Any breach throws
4
4
  * a LimitError; the engine catches it and returns the best partial answer it has.
5
5
  *
6
- * Cost is tracked for reporting only — there is no USD spend ceiling.
6
+ * Cost is NOT tracked: there is no USD spend ceiling and no cost reporting.
7
7
  */
8
8
 
9
9
  import type { Usage } from "@earendil-works/pi-ai";
10
10
 
11
+ /**
12
+ * Absolute working ceiling (LO rule 2025-09-09, raised 2025-09-10): model windows AT/BELOW
13
+ * this value are never compacted or budget-amputated — the agent runs its full window, and
14
+ * tree-wide spend (root turns + sub-LLM calls) is NOT metered against it. Windows ABOVE it
15
+ * are budgeted exactly AT the ceiling — an outlier context-fit guard, not a cost meter.
16
+ * Single source of truth for budget.ts (resolveBudget) and compaction.ts (shouldCompact +
17
+ * rebaseWithState token bound).
18
+ */
19
+ export const COMPACTION_CEILING_TOKENS = 1_000_000;
20
+
11
21
  export interface Limits {
12
22
  readonly maxTimeoutMs?: number;
13
23
  readonly maxTokens?: number;
@@ -27,7 +37,6 @@ export function limitsFromConfig(config: Limits): Limits {
27
37
  interface UsageSnapshot {
28
38
  readonly inputTokens: number;
29
39
  readonly outputTokens: number;
30
- readonly costUsd: number;
31
40
  readonly durationMs: number;
32
41
  }
33
42
 
@@ -45,7 +54,6 @@ export class LimitGuard {
45
54
  private start: number;
46
55
  private inputTokens = 0;
47
56
  private outputTokens = 0;
48
- private costUsd = 0;
49
57
  private consecutiveErrors = 0;
50
58
 
51
59
  constructor(private readonly limits: Limits = {}, seedElapsedMs = 0) {
@@ -64,12 +72,10 @@ export class LimitGuard {
64
72
  addUsage(usage: Usage): void {
65
73
  this.inputTokens += usage.input;
66
74
  this.outputTokens += usage.output;
67
- this.costUsd += usage.cost.total;
68
75
  }
69
76
 
70
- /** Fold a recursive child run's total cost/tokens into this guard. */
71
- addRaw(costUsd: number, inputTokens: number, outputTokens: number): void {
72
- this.costUsd += costUsd;
77
+ /** Fold a recursive child run's total tokens into this guard. */
78
+ addRaw(inputTokens: number, outputTokens: number): void {
73
79
  this.inputTokens += inputTokens;
74
80
  this.outputTokens += outputTokens;
75
81
  }
@@ -90,7 +96,6 @@ export class LimitGuard {
90
96
  return {
91
97
  inputTokens: this.inputTokens,
92
98
  outputTokens: this.outputTokens,
93
- costUsd: this.costUsd,
94
99
  durationMs: Date.now() - this.start,
95
100
  };
96
101
  }
@@ -31,7 +31,7 @@ export interface ElideOptions {
31
31
  readonly elideChars: number;
32
32
  }
33
33
 
34
- const ELIDE_MARK = "chars elided — full result in session log";
34
+ const ELIDE_MARK = "chars elided — repl sandbox persists: print(answers[k]) or re-run repl to re-derive";
35
35
 
36
36
  /**
37
37
  * WS-3a: elide stale turns. The newest `keepTurns` assistant turns and the final user message
@@ -109,7 +109,18 @@ function elideRange(
109
109
  if (i === lastUser) continue; // paranoia: the final user message is never touched
110
110
  if (m.role === "assistant") {
111
111
  staleAssistants -= 1; // turns AFTER this one = count minus itself
112
- messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
112
+ // Provider pairing invariant: Anthropic requires every tool_result to follow the
113
+ // assistant message carrying its tool_use; OpenAI requires each tool/function_call_output
114
+ // to pair with its tool_call/function_call. Eliding the toolCall blocks here orphaned the
115
+ // surviving toolResults and broke the request. Elide only the prose — keep toolCall blocks.
116
+ const toolCalls = Array.isArray(m.content)
117
+ ? (m.content as Array<{ type?: string }>).filter((b) => b?.type === "toolCall")
118
+ : [];
119
+ const content: unknown[] =
120
+ toolCalls.length > 0
121
+ ? [...toolCalls, { type: "text", text: ROOT_TURN_ELIDED_LINE }]
122
+ : [{ type: "text", text: ROOT_TURN_ELIDED_LINE }];
123
+ messages[i] = { ...m, content } as RootMessage;
113
124
  elided += 1;
114
125
  continue;
115
126
  }
@@ -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
@@ -13,7 +13,8 @@ export interface RlmConfig {
13
13
  readonly enabled: boolean;
14
14
  /** Max recursion depth. depth >= maxDepth ⇒ rlm_query falls back to a plain llm_query. */
15
15
  readonly maxDepth: number;
16
- /** Max turns before the engine must finalize. */
16
+ /** Max turns before the engine must finalize. Deliberately large — runs end on FINAL
17
+ * answer, errors, or wall-clock long before this bites. */
17
18
  readonly maxIterations: number;
18
19
  /** Per-`repl`-block wall-clock timeout inside the worker (seconds).
19
20
  * v5 doctrine: content limits are the token budget's job — this is a HANG backstop only. */
@@ -54,7 +55,8 @@ export interface RlmConfig {
54
55
  readonly orchestrator: boolean;
55
56
  /** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
56
57
  readonly compaction: boolean;
57
- /** Compact when estimated history tokens reach this fraction of the model's context window. */
58
+ /** DEPRECATED (LO rule 2025-09-09): ignored compaction is governed by the absolute
59
+ * COMPACTION_CEILING_TOKENS (limits.ts). Kept for settings/UI compatibility only. */
58
60
  readonly compactionThresholdPct: number;
59
61
  /** Python executable used to launch the sandbox worker. */
60
62
  readonly python: string;
@@ -180,10 +182,14 @@ export interface RlmInput {
180
182
  export interface RlmResult {
181
183
  readonly answer: string;
182
184
  readonly iterations: number;
183
- readonly costUsd: number;
184
185
  readonly inputTokens: number;
185
186
  readonly outputTokens: number;
186
187
  readonly durationMs: number;
188
+ /** Last non-empty repl stdout of the run (capped, P2 §3.4). The engine itself never reads
189
+ * it: a run that ends without `answer[...]` (no final frame) would otherwise score as an
190
+ * empty submission even though the winning value was printed. The bench/grader recovers
191
+ * that value from here instead of re-running the whole task. */
192
+ readonly lastStdout: string;
187
193
  }
188
194
 
189
195
  /** A function that runs an RLM to completion — used to wire recursion (rlm_query). */
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;
@@ -63,9 +63,11 @@ export const SKILL_RECALL_LINE =
63
63
  "Recall more anytime inside repl: `skill_search(query, k=8)` → [{id, text, tags, score}].";
64
64
 
65
65
  /** R5 (G4, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the one-line replacement for assistant prose
66
- * older than the keep window — durable facts live in Σ, the full text in the session log. */
66
+ * older than the keep window — durable facts live in Σ; the repl sandbox (answers/vars) is
67
+ * the model-reachable recovery channel — the session log is host-side only, never re-openable
68
+ * by the model, so stubs must not promise it. */
67
69
  export const ROOT_TURN_ELIDED_LINE =
68
- "… turn elided — durable facts live in Σ; full text in session log";
70
+ "… turn elided — durable facts live in Σ; your repl sandbox persists: print(answers) / SHOW_VARS() to re-derive";
69
71
 
70
72
  export function skillStateLines(noteCount: number, body: string): string {
71
73
  return [
@@ -21,7 +21,8 @@ export function buildTurnPrompt(
21
21
 
22
22
  /** Asked once when the engine runs out of turns without a submitted answer. Same finalize
23
23
  * dialect as the budget wrap-up note (audit M6): answer-ready first, plain text only as an
24
- * explicit fallback the engine still accepts. */
24
+ * explicit fallback the engine still accepts. Deliberately near-unreachable: the cap is
25
+ * large by design (the old 16-cap all-failed oolong mid-retrieval). */
25
26
  export const FINALIZE_PROMPT =
26
27
  "You are out of turns. Finalize NOW: set `answer[\"content\"]` and `answer[\"ready\"] = True` " +
27
28
  "(fenced ```repl```) with your best final answer from everything you have gathered. " +
@@ -92,7 +92,10 @@ export class PythonSandbox {
92
92
  private scanOffset = 0;
93
93
  private seq = 0;
94
94
  private readonly pending = new Map<string, Pending>();
95
- private readonly handlers: SubLlmHandlers;
95
+ // Not readonly: the engine's continuation handoff re-installs a successor run's closures
96
+ // on a live worker (installHandlers) instead of respawning it — respawn would destroy the
97
+ // pinned context accumulator, which is exactly what the chain must preserve.
98
+ private handlers: SubLlmHandlers;
96
99
  private readonly requestTimeoutMs: number;
97
100
  private readonly initTimeoutMs: number;
98
101
  /** Bounded stderr tail (chunks, newest last) — avoids rebuilding the buffer per chunk. */
@@ -224,6 +227,15 @@ export class PythonSandbox {
224
227
  return res.index ?? 0;
225
228
  }
226
229
 
230
+ /**
231
+ * Continuation handoff (engine chain): re-install a successor run's sub-call handlers on
232
+ * THIS live worker so a chained engine run can adopt it without a respawn. Respawning
233
+ * would destroy the pinned context accumulator — the one thing the chain must preserve.
234
+ */
235
+ installHandlers(handlers: SubLlmHandlers): void {
236
+ this.handlers = { ...REJECT, ...handlers };
237
+ }
238
+
227
239
  async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
228
240
  const res = await this.request({ type: "exec", code }, signal);
229
241
  if (!res.ok) throw new Error(res.error ?? "exec failed");