@hicaru/pi-rlm 0.3.6 → 0.3.8

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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Token budget cascade (port of rlm_test v4/v5 `budget.py`).
3
+ *
4
+ * The budget is the PRIMARY run-length control: cap = budgetShare × model context window,
5
+ * one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
6
+ * handoff (`distillTrajectory`) is handed to a fresh continuation run — chain-capped at
7
+ * `maxContinuations`. Wall-clock timeouts stay only as hang backstops.
8
+ *
9
+ * v5 counts the whole tree (root turns + sub-LLM usage) against the cap; the engine feeds
10
+ * the run's LimitGuard totals in via `observeTotal` after every turn. Each continuation
11
+ * starts a fresh spend window (v5's offset-anchoring) — the chain total is bounded by
12
+ * `cap × (1 + maxContinuations)`, never by re-charging prior work.
13
+ */
14
+
15
+ import type { ChatMsg } from "../bridge/model.ts";
16
+ import type { RlmConfig } from "./types.ts";
17
+
18
+ export interface TokenBudgetOptions {
19
+ readonly softFrac?: number;
20
+ readonly continuations?: number;
21
+ readonly maxContinuations?: number;
22
+ }
23
+
24
+ export type BudgetState = "" | "soft" | "hard";
25
+
26
+ /** v5 verbatim: the soft wrap-up note prepended to the single turn after crossing soft. */
27
+ export const WRAP_UP_BUDGET: string = Object.freeze(
28
+ "[budget] ~80% of your token cap — ONE turn left. If the task is answerable NOW, finalize " +
29
+ '(set answer["ready"] = True). Otherwise print a compact findings dump: what is confirmed, ' +
30
+ "current file/line or search position, and the exact next step — a fresh continuation picks " +
31
+ "it up. Do not start new exploration.",
32
+ );
33
+
34
+ export const DEFAULT_NEXT_STEP: string =
35
+ Object.freeze("continue the probing that was in flight, then finalize");
36
+
37
+ /** v5 verbatim template (adapting the finalize spelling to this plugin's REPL). */
38
+ const HANDOFF_TEMPLATE: string = Object.freeze(
39
+ "A prior RLM run hit its token cap mid-task.\n" +
40
+ "You are its continuation — pick up EXACTLY where it stopped.\n\n" +
41
+ "ORIGINAL TASK:\n{query}\n\n" +
42
+ "CONFIRMED FINDINGS SO FAR:\n{findings}\n\n" +
43
+ "CURRENT STATE / LAST ACTIONS:\n{state}\n\n" +
44
+ "NEXT STEP: {next}\n" +
45
+ "Do not re-do confirmed work; continue from the NEXT STEP and finalize as\n" +
46
+ 'soon as the task is answerable (answer["ready"] = True).',
47
+ );
48
+
49
+ /** v5's elision marker, used whenever a handoff section is trimmed. */
50
+ const ELISION_MARK = "\n…(+N chars elided [v5 handoff])…\n";
51
+
52
+ export class TokenBudget {
53
+ readonly cap: number;
54
+ readonly softFrac: number;
55
+ readonly continuations: number;
56
+ readonly maxContinuations: number;
57
+ private spent = 0;
58
+
59
+ constructor(cap: number, opts: TokenBudgetOptions = {}) {
60
+ this.cap = Math.max(1, Math.floor(cap));
61
+ this.softFrac = opts.softFrac ?? 0.8;
62
+ this.continuations = opts.continuations ?? 0;
63
+ this.maxContinuations = opts.maxContinuations ?? 2;
64
+ }
65
+
66
+ get soft(): number {
67
+ return Math.floor(this.cap * this.softFrac);
68
+ }
69
+
70
+ get hard(): number {
71
+ return this.cap;
72
+ }
73
+
74
+ /** Tokens charged to this run so far (root + sub-LLM, whole tree). */
75
+ get tokensSpent(): number {
76
+ return this.spent;
77
+ }
78
+
79
+ /**
80
+ * Feed the run's cumulative token totals (LimitGuard::usage()) after each turn.
81
+ * Absolute, not incremental: one budget instance observes exactly one run, which is
82
+ * what makes a continuation's fresh instance start from zero (v5 offset anchoring).
83
+ */
84
+ observeTotal(inputTokens: number, outputTokens: number): void {
85
+ this.spent = Math.max(0, inputTokens) + Math.max(0, outputTokens);
86
+ }
87
+
88
+ state(): BudgetState {
89
+ if (this.cap <= 0) return "";
90
+ if (this.spent >= this.hard) return "hard";
91
+ if (this.spent >= this.soft) return "soft";
92
+ return "";
93
+ }
94
+
95
+ canContinue(): boolean {
96
+ return this.continuations < this.maxContinuations;
97
+ }
98
+
99
+ /** Fresh spend window, one step deeper in the chain. */
100
+ nextContinuation(): TokenBudget {
101
+ return new TokenBudget(this.cap, {
102
+ softFrac: this.softFrac,
103
+ continuations: this.continuations + 1,
104
+ maxContinuations: this.maxContinuations,
105
+ });
106
+ }
107
+ }
108
+
109
+ /** Cap derivation (v5 `resolve_budget`): share × context window, clamped by the task cap. */
110
+ export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
111
+ const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
112
+ const shareCap = Math.floor(ctx * config.budgetShare);
113
+ const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
114
+ return new TokenBudget(Math.max(cap, 1), {
115
+ softFrac: config.budgetSoftFrac,
116
+ maxContinuations: config.budgetMaxContinuations,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Truncate at the midpoint so both the head and the tail of the content survive
122
+ * (v5 semantics: keep the opening context and the most recent actions).
123
+ */
124
+ export function truncateMid(text: string, maxChars: number): string {
125
+ if (text.length <= maxChars) return text;
126
+ const half = Math.max(0, maxChars - ELISION_MARK.length) >> 1;
127
+ const elided = text.length - (half * 2);
128
+ return text.slice(0, half) + ELISION_MARK.replace("N", String(elided)) + text.slice(text.length - half);
129
+ }
130
+
131
+ const QUERY_CHARS = 800;
132
+ const FINDINGS_MAX = 6;
133
+ const FINDINGS_MIN_CHARS = 20;
134
+ const STATE_MAX = 8;
135
+ const STATE_NEEDLE = "REPL stdout";
136
+ const NEXT_STEP_RE = /next|then|will |todo/i;
137
+
138
+ /**
139
+ * Deterministic trajectory → handoff (v5 `distill_trajectory`). No LLM call: the model was
140
+ * just told (soft wrap-up) to print a findings dump, and this harvests it — query, the last
141
+ * substantive assistant findings, the last REPL states, and the next step.
142
+ */
143
+ export function distillTrajectory(
144
+ history: readonly ChatMsg[],
145
+ query: string,
146
+ handoffChars = 4_000,
147
+ ): string {
148
+ const findings: string[] = [];
149
+ for (let i = history.length - 1; i >= 0 && findings.length < FINDINGS_MAX; i--) {
150
+ const m = history[i];
151
+ if (m.role === "assistant" && m.content.trim().length > FINDINGS_MIN_CHARS) {
152
+ findings.push(m.content.trim());
153
+ }
154
+ }
155
+ // v5 parity (audit C4): the next-step hint scans NEWEST-first; the join below is chronological.
156
+ const next = findings.find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
157
+ findings.reverse();
158
+ const states: string[] = [];
159
+ for (let i = history.length - 1; i >= 0 && states.length < STATE_MAX; i--) {
160
+ const m = history[i];
161
+ if (m.role === "user" && m.content.includes(STATE_NEEDLE)) {
162
+ states.push(m.content.trim());
163
+ }
164
+ }
165
+ states.reverse();
166
+
167
+ const querySlice = query.slice(0, QUERY_CHARS);
168
+ const queryBlock = truncateMid(querySlice, Math.floor(handoffChars * 0.3));
169
+ const findingsBlock = truncateMid(findings.join("\n\n"), Math.floor(handoffChars * 0.35));
170
+ const stateBlock = truncateMid(states.join("\n\n"), Math.floor(handoffChars * 0.35));
171
+
172
+ return HANDOFF_TEMPLATE
173
+ .replace("{query}", queryBlock)
174
+ .replace("{findings}", findingsBlock)
175
+ .replace("{state}", stateBlock)
176
+ .replace("{next}", next);
177
+ }
178
+
179
+ /** The full continuation prompt: `[continuation n]` header + distilled handoff. */
180
+ export function continuationPrompt(n: number, handoff: string): string {
181
+ return `[continuation ${n}]\n${handoff}`;
182
+ }
@@ -33,6 +33,52 @@ export function shouldCompact(history: ChatMsg[], deps: CompactionDeps): boolean
33
33
  return estimateMessageTokens(history) >= threshold;
34
34
  }
35
35
 
36
+ /**
37
+ * v5 G1: elide old tool/repl payload bodies, keep the head (system) and the working-set tail
38
+ * intact. Runs BEFORE `shouldCompact` — v3 measured −97% tokens on coding tasks with this
39
+ * alone, often avoiding the summarizer entirely. Head-ONLY elision was a measured v3 bug
40
+ * (turns grew 3→8): the tail carries the current working set, so the last `keepTurns` turns
41
+ * are never touched.
42
+ */
43
+ export function elideOldToolPayloads(
44
+ history: ChatMsg[],
45
+ keepTurns = 2,
46
+ toolChars = 1_500,
47
+ ): ChatMsg[] {
48
+ if (history.length === 0) return history;
49
+ // Find the assistant message that starts the keepTurns-th-from-last turn; everything from
50
+ // there on is the protected tail.
51
+ let tailStart = 0;
52
+ let seen = 0;
53
+ for (let i = history.length - 1; i >= 0; i--) {
54
+ if (history[i].role === "assistant") {
55
+ seen++;
56
+ if (seen >= keepTurns) {
57
+ tailStart = i;
58
+ break;
59
+ }
60
+ }
61
+ }
62
+ if (tailStart === 0) return history; // fewer turns than keepTurns — nothing to elide
63
+ let changed = false;
64
+ const marker = "\n…[elided v5-G1]…";
65
+ const out: ChatMsg[] = new Array<ChatMsg>(history.length); // pre-allocated
66
+ for (let i = 0; i < history.length; i++) {
67
+ const m = history[i];
68
+ if (
69
+ i < tailStart &&
70
+ m.role === "user" &&
71
+ m.content.length > toolChars
72
+ ) {
73
+ out[i] = { role: "user", content: m.content.slice(0, toolChars) + marker };
74
+ changed = true;
75
+ } else {
76
+ out[i] = m;
77
+ }
78
+ }
79
+ return changed ? out : history;
80
+ }
81
+
36
82
  /**
37
83
  * Summarize the trajectory and return a compacted history: [system, summary(assistant),
38
84
  * continue(user)]. The caller continues appending turns from there.
@@ -16,19 +16,23 @@ import {
16
16
  createTaskRegistry,
17
17
  type Invocation,
18
18
  } from "../bridge/handlers/index.ts";
19
+ import { TaskLedger, contextSig, taskKey } from "./ledger.ts";
20
+ import { type MemoryStore, rootContextPaths } from "./memory.ts";
19
21
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
20
22
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
21
23
  import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
22
24
  import type { RlmEmitter } from "../tool/rlm-events.ts";
23
- import { PythonSandbox } from "../sandbox/sandbox.ts";
25
+ import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
24
26
  import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
25
27
  import { previewStdout, previewText } from "../text/preview.ts";
26
28
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
27
29
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
28
- import { compactHistory, shouldCompact } from "./compaction.ts";
30
+ import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
29
31
  import { appendUserMessage } from "./history.ts";
30
32
  import { runTurn } from "./iteration.ts";
31
33
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
34
+ import { continuationPrompt, distillTrajectory, resolveBudget, WRAP_UP_BUDGET } from "./budget.ts";
35
+ import { ModelContextRegistry, modelsCachePath } from "./model-registry.ts";
32
36
  import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
33
37
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
34
38
 
@@ -38,6 +42,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
38
42
  * hold a finished run open on work whose result nobody can receive.
39
43
  */
40
44
  const DETACHED_SETTLE_MS = 5_000;
45
+ /** H6 (audit): root episodes snapshot at most this many real files — replay invalidation for
46
+ * the disk-backed slice of the context without hashing an unbounded repository. */
47
+ const ROOT_HASH_MAX = 64;
41
48
 
42
49
 
43
50
  export interface EngineDeps {
@@ -55,6 +62,8 @@ export interface EngineDeps {
55
62
  readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
56
63
  /** Test-only: override model completion (scripted multi-turn responses). */
57
64
  readonly complete?: import("./iteration.ts").CompleteFn;
65
+ /** v5: session-wide durable memory store (`.rlm/`); omitted → memory off for this engine. */
66
+ readonly memory?: MemoryStore;
58
67
  }
59
68
 
60
69
  /** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
@@ -81,6 +90,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
81
90
  maxTokens: deps.limits?.maxTokens,
82
91
  });
83
92
 
93
+ // v5 token budget: the primary run-length control. A continuation run carries its own
94
+ // budget in `input.budget`; a fresh run resolves one from the model's context window.
95
+ // ONE registry per run (audit M1): shared by budget resolution, and observed when the
96
+ // model metadata already knows the window so the disk cache populates for other callers.
97
+ const modelCtxRegistry = new ModelContextRegistry(modelsCachePath(runCwd));
98
+ const budget =
99
+ input.budget ??
100
+ (deps.config.enableTokenBudget
101
+ ? resolveBudget(contextWindowOrFallback(model, modelCtxRegistry), deps.config)
102
+ : undefined);
103
+
84
104
  // One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
85
105
  // and its emitter and LimitGuard outlive every sub-call it services — including
86
106
  // detached ones, which is why the headless path needs no session registry.
@@ -105,6 +125,54 @@ export function createEngine(deps: EngineDeps): RunRlm {
105
125
  let detachedIdle: (() => void) | undefined;
106
126
  // One registry per run — unawaited task reminders share the same map as await handlers.
107
127
  const taskRegistry = createTaskRegistry();
128
+ // v5 TaskLedger: one blackboard per root run; children inherit the same instance via
129
+ // childRun (RlmInput.ledger — the one construction seam, DRY #6).
130
+ const runLedger = input.ledger ?? new TaskLedger();
131
+ if (deps.config.enableLedger) runLedger.beginRun(input.rootPrompt);
132
+
133
+ // v5 durable memory: read-only root replay — an identical prompt over an identical
134
+ // context answers for zero API calls (measured 10,051 → 0 tok in rlm_test).
135
+ const rootMemory =
136
+ deps.memory !== undefined && deps.config.enableMemory ? deps.memory : undefined;
137
+ const modelRefStr = `${model.provider}/${model.id}`;
138
+ const rootKey = taskKey("root", input.rootPrompt, [], modelRefStr, contextSig(input.context));
139
+ if (rootMemory !== undefined && input.depth === 0 && input.budget === undefined) {
140
+ const hit = rootMemory.replay(rootKey);
141
+ if (hit !== undefined) {
142
+ emitter.emitStatus("done");
143
+ return {
144
+ answer: hit.result,
145
+ iterations: 0,
146
+ costUsd: 0,
147
+ inputTokens: 0,
148
+ outputTokens: 0,
149
+ durationMs: 0,
150
+ };
151
+ }
152
+ }
153
+ const persistRoot = (
154
+ answer: string,
155
+ spend?: { readonly inputTokens: number; readonly outputTokens: number },
156
+ ): void => {
157
+ if (rootMemory === undefined || input.depth !== 0) return;
158
+ // H2 (audit): only clean root runs persist — a continuation leaf carries the ORIGINAL
159
+ // run's key (it persists the chain itself), and stopped/aborted partials must never
160
+ // replay as if they were real answers.
161
+ if (input.budget !== undefined) return;
162
+ if (answer === "" || answer === "(aborted)" || answer.startsWith("(stopped")) return;
163
+ const u = spend ?? limits.usage();
164
+ rootMemory.recordEpisode({
165
+ key: rootKey,
166
+ kind: "root",
167
+ model: modelRefStr,
168
+ prompt: input.rootPrompt,
169
+ paths: rootContextPaths(input.context, ROOT_HASH_MAX),
170
+ result: answer,
171
+ tokensIn: u.inputTokens,
172
+ tokensOut: u.outputTokens,
173
+ });
174
+ };
175
+
108
176
  const subcalls = createSubcallHandlers({
109
177
  resolve: () => invocation,
110
178
  gates: deps.gates
@@ -119,6 +187,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
119
187
  // despite being wired before liveContext is assigned — children can only spawn from an
120
188
  // interrupt during runTurn, which is strictly after loadContext below.
121
189
  getChildContext: () => liveContext,
190
+ ledger: runLedger,
191
+ memory: rootMemory,
122
192
  trackDetached: async (task) => {
123
193
  detachedInFlight += 1;
124
194
  try {
@@ -139,6 +209,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
139
209
  detachedIdle = undefined;
140
210
  };
141
211
  let sandbox: PythonSandbox | undefined;
212
+ const watchdogHeartbeat = setInterval(() => {
213
+ if (detachedInFlight > 0) sandbox?.refreshWatchdog();
214
+ }, SANDBOX_WATCHDOG_HEARTBEAT_MS);
215
+ watchdogHeartbeat.unref?.();
142
216
  /**
143
217
  * This run's live context: whatever was seeded plus every source added so far. Children
144
218
  * inherit it, so it must grow when add_context appends (see the handler's onLoaded below).
@@ -163,6 +237,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
163
237
  let compactions = 0;
164
238
  let completedTurns = 0;
165
239
  let nodeStatus: "done" | "error" = "done";
240
+ // v5 budget cascade state: the wrap-up note fires for exactly ONE turn after crossing soft.
241
+ let softFired = false;
242
+ let softNoteTurn = -1;
166
243
 
167
244
  try {
168
245
  const meta = {
@@ -177,10 +254,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
177
254
  maxPromptChars: deps.config.maxPromptChars,
178
255
  contextLoader: deps.config.contextLoader,
179
256
  child: input.depth > 0,
257
+ delegation: input.depth > 0 && deps.config.childSurface === "delegation",
180
258
  depth: input.depth,
181
259
  });
182
260
 
183
- const contextHandlers = deps.config.contextLoader
261
+ // v5 (audit M5): a delegation child does not grow the world — add_context stays root-only.
262
+ const contextHandlers =
263
+ deps.config.contextLoader && (input.depth === 0 || deps.config.childSurface !== "delegation")
184
264
  ? buildAddContextHandler({
185
265
  cwd: runCwd,
186
266
  emitter,
@@ -198,6 +278,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
198
278
 
199
279
  sandbox = await PythonSandbox.spawn({
200
280
  depth: input.depth,
281
+ surface: input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root",
201
282
  execTimeoutS: deps.config.execTimeoutS,
202
283
  requestTimeoutMs: deps.config.requestTimeoutMs,
203
284
  python: deps.config.python,
@@ -205,7 +286,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
205
286
  initTimeoutMs: deps.config.sandboxInitTimeoutMs,
206
287
  maxPromptChars: deps.config.maxPromptChars,
207
288
  awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
208
- handlers: { ...subcalls, ...contextHandlers },
289
+ handlers: {
290
+ ...subcalls,
291
+ ...contextHandlers,
292
+ ledgerClaims: () => Promise.resolve(runLedger.listClaims()),
293
+ memoryOp: (op, args) => Promise.resolve(rootMemory?.serviceOp(op, args) ?? "memory off"),
294
+ },
209
295
  });
210
296
 
211
297
  let history: ChatMsg[] = [{ role: "system", content: system }];
@@ -221,6 +307,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
221
307
  else emitter.emitTurn(i + 1, deps.config.maxIterations);
222
308
 
223
309
  if (deps.config.compaction) {
310
+ // v5 G1 first: elide old tool payloads head+tail — often avoids the summary entirely.
311
+ history = elideOldToolPayloads(history);
224
312
  const compactionDeps = {
225
313
  // Summarisation is done by the cheap worker model; the threshold stays on the
226
314
  // root model's context window (that is the window the history fills each turn).
@@ -249,7 +337,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
249
337
  );
250
338
  }
251
339
 
252
- appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
340
+ // v5 [ledger] blackboard + [memory] notes — each silent ("") when it has nothing to say.
341
+ const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
342
+ const memoryBlock = rootMemory !== undefined ? rootMemory.injectBlock(input.rootPrompt) : "";
343
+ const notes =
344
+ [
345
+ i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
346
+ ledgerBlock === "" ? undefined : ledgerBlock,
347
+ memoryBlock === "" ? undefined : memoryBlock,
348
+ ]
349
+ .filter((s): s is string => s !== undefined)
350
+ .join("\n\n") || undefined;
351
+ appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
253
352
 
254
353
  // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
255
354
  const rootSampling: Sampling = {
@@ -280,6 +379,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
280
379
  const final = finalAnswerOf(turn.results);
281
380
  if (final != null) {
282
381
  const done = result(final, i + 1, limits);
382
+ persistRoot(done.answer);
283
383
  lastAnswer = done.answer;
284
384
  return done;
285
385
  }
@@ -287,9 +387,62 @@ export function createEngine(deps: EngineDeps): RunRlm {
287
387
  limits.observe(turnHadError(turn.results));
288
388
  history.push({ role: "assistant", content: turn.response });
289
389
  pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
390
+
391
+ // ── v5 budget cascade ─────────────────────────────────────────────────────
392
+ // Content control lives here; wall-clock timeouts stay hang backstops. Whole-tree
393
+ // tokens (root + sub-LLM) reach `limits` through the invocation's addUsage/addRaw seams.
394
+ if (budget !== undefined) {
395
+ const u = limits.usage();
396
+ budget.observeTotal(u.inputTokens, u.outputTokens);
397
+ const bstate = budget.state();
398
+ if (bstate === "soft" && !softFired) {
399
+ softFired = true;
400
+ softNoteTurn = i + 1;
401
+ if (selfReportId) {
402
+ emitter.emitSubcallUpdated({ id: selfReportId, detail: `budget soft @ ${u.inputTokens + u.outputTokens}/${budget.soft} tok` });
403
+ }
404
+ }
405
+ if (bstate === "hard") {
406
+ if (budget.canContinue()) {
407
+ // Distill the trajectory and chain a fresh run with a fresh spend window —
408
+ // the v4 "finalize NOW" flaw fix: never abort mid-task, restructure-and-resume.
409
+ const handoff = distillTrajectory(history, input.rootPrompt, deps.config.budgetHandoffChars);
410
+ const cont = budget.nextContinuation();
411
+ if (selfReportId) {
412
+ emitter.emitSubcallUpdated({ id: selfReportId, detail: `budget hard → continuation ${cont.continuations}` });
413
+ }
414
+ const inner = await run({
415
+ ...input,
416
+ rootPrompt: continuationPrompt(cont.continuations, handoff),
417
+ context: liveContext, // H9: sources added mid-run reach the leaf
418
+ budget: cont,
419
+ remainingTimeoutMs: limits.remainingTimeoutMs(),
420
+ });
421
+ // H9: report the CHAIN's spend, not just the leaf's fresh guard.
422
+ const u = limits.usage();
423
+ const chained: RlmResult = {
424
+ ...inner,
425
+ iterations: inner.iterations + completedTurns,
426
+ inputTokens: inner.inputTokens + u.inputTokens,
427
+ outputTokens: inner.outputTokens + u.outputTokens,
428
+ costUsd: inner.costUsd + u.costUsd,
429
+ };
430
+ // H2: the ORIGINAL run persists the chain's answer under the ORIGINAL key —
431
+ // the next identical prompt must replay the full result, not miss.
432
+ // R2: lastAnswer must be set before return — `finally` emitAnswer reads it,
433
+ // and persist must store the CHAIN totals, not just the parent window.
434
+ lastAnswer = chained.answer;
435
+ persistRoot(chained.answer, chained);
436
+ return chained;
437
+ }
438
+ // Chain cap reached — finalize with the best partial (a budget never throws).
439
+ break;
440
+ }
441
+ }
290
442
  }
291
443
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
292
444
  const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
445
+ persistRoot(finalized.answer);
293
446
  lastAnswer = finalized.answer;
294
447
  return finalized;
295
448
  } catch (err) {
@@ -308,6 +461,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
308
461
  nodeStatus = "error";
309
462
  throw err;
310
463
  } finally {
464
+ if (deps.config.enableLedger) runLedger.endRun();
311
465
  if (selfReportId) {
312
466
  emitter.emitSubcallUpdated({
313
467
  id: selfReportId,
@@ -319,6 +473,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
319
473
  if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
320
474
  emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
321
475
  }
476
+ clearInterval(watchdogHeartbeat);
322
477
  // Settle detached work FIRST: a child still running may be about to pin this same payload.
323
478
  await settleDetached();
324
479
  await contextPin?.release();
@@ -333,6 +488,17 @@ function result(answer: string, iterations: number, limits: LimitGuard): RlmResu
333
488
  return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
334
489
  }
335
490
 
491
+ /** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
492
+ /** Model metadata window, else the offline registry fallback (disk cache → table → 32k).
493
+ * When metadata provides the window it is observed into the cache (fail-soft, audit M1). */
494
+ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegistry): number {
495
+ if (model.contextWindow !== undefined && model.contextWindow > 0) {
496
+ registry.observe(`${model.provider}/${model.id}`, model.contextWindow);
497
+ return model.contextWindow;
498
+ }
499
+ return registry.limitFor(`${model.provider}/${model.id}`);
500
+ }
501
+
336
502
  /** Out of turns: ask the model for its best final answer (plain text). */
337
503
  async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps, limits: LimitGuard): Promise<string> {
338
504
  const finalHistory = [...history];