@hicaru/pi-rlm 0.3.15 → 0.3.17

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.
Files changed (90) hide show
  1. package/README.md +96 -70
  2. package/README.ru.md +86 -59
  3. package/README.zh-CN.md +95 -65
  4. package/package.json +5 -5
  5. package/src/bridge/add-context.ts +1 -1
  6. package/src/bridge/handlers/await.ts +13 -22
  7. package/src/bridge/handlers/completion.ts +27 -5
  8. package/src/bridge/handlers/emitting.ts +2 -2
  9. package/src/bridge/handlers/llm-query.ts +46 -68
  10. package/src/bridge/handlers/rlm-query.ts +14 -84
  11. package/src/bridge/handlers/task-registry.ts +22 -17
  12. package/src/bridge/handlers/types.ts +8 -6
  13. package/src/bridge/model.ts +21 -4
  14. package/src/commands/rlm-llm.ts +1 -10
  15. package/src/commands/rlm-rlm.ts +1 -8
  16. package/src/config/defaults.ts +31 -12
  17. package/src/config/settings.ts +47 -33
  18. package/src/config/skillstate.ts +465 -0
  19. package/src/context/md-cache.ts +1 -1
  20. package/src/context/merge.ts +1 -1
  21. package/src/context/namespace.ts +2 -2
  22. package/src/context/refresh.ts +1 -1
  23. package/src/context/source-dir.ts +21 -11
  24. package/src/context/source-doc.ts +1 -1
  25. package/src/context/source-git.ts +3 -15
  26. package/src/context/source-text.ts +1 -1
  27. package/src/context/walk.ts +6 -14
  28. package/src/core/budget.ts +107 -21
  29. package/src/core/compaction.ts +44 -1
  30. package/src/core/engine.ts +192 -94
  31. package/src/core/iteration.ts +1 -1
  32. package/src/core/ledger.ts +10 -13
  33. package/src/core/limits.ts +1 -1
  34. package/src/core/model-registry.ts +1 -1
  35. package/src/core/resource-limits.ts +1 -1
  36. package/src/core/root-context.ts +126 -0
  37. package/src/core/root-digest.ts +213 -0
  38. package/src/core/root-state.ts +240 -0
  39. package/src/core/run-state.ts +577 -0
  40. package/src/core/types.ts +56 -12
  41. package/src/index.ts +167 -36
  42. package/src/mode/llm-model.ts +13 -1
  43. package/src/mode/native-guards.ts +0 -6
  44. package/src/mode/rlm-mode.ts +34 -11
  45. package/src/mode/subagent.ts +5 -5
  46. package/src/prompts/glossary.ts +41 -25
  47. package/src/prompts/native.ts +1 -3
  48. package/src/prompts/system.ts +12 -4
  49. package/src/prompts/user.ts +17 -0
  50. package/src/sandbox/context-file.ts +1 -1
  51. package/src/sandbox/interrupts.ts +25 -31
  52. package/src/sandbox/protocol.ts +14 -20
  53. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  54. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  55. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  56. package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
  57. package/src/sandbox/py/guards.py +1 -1
  58. package/src/sandbox/py/retrieval.py +4 -1
  59. package/src/sandbox/py/scaffold.py +24 -31
  60. package/src/sandbox/py/worker.py +3 -1
  61. package/src/sandbox/sandbox-manager.ts +2 -2
  62. package/src/sandbox/sandbox.ts +35 -5
  63. package/src/text/agent-text.ts +58 -0
  64. package/src/text/parsing.ts +35 -3
  65. package/src/text/preview.ts +3 -0
  66. package/src/text/repl-output.ts +1 -1
  67. package/src/tool/background-tasks.ts +1 -1
  68. package/src/tool/repl-render.ts +1 -1
  69. package/src/tool/repl-result.ts +1 -1
  70. package/src/tool/repl-tool.ts +50 -26
  71. package/src/tool/rlm-tool.ts +4 -5
  72. package/src/tool/subcall-render.ts +1 -1
  73. package/src/tool/subcall-store.ts +2 -2
  74. package/src/tool/tool-utils.ts +5 -5
  75. package/src/ui/config-panel.ts +39 -0
  76. package/src/ui/intro.ts +1 -1
  77. package/src/ui/modal/timeline-store.ts +1 -1
  78. package/src/ui/model-picker/drilldown.ts +1 -1
  79. package/src/ui/model-picker/levels.ts +1 -1
  80. package/src/ui/panel/run-registry.ts +1 -1
  81. package/src/ui/tree/tree-rows.ts +1 -1
  82. package/src/ui/tree/tree-widget.ts +1 -1
  83. package/src/util/bm25.ts +97 -0
  84. package/src/util/concurrency.ts +1 -1
  85. package/src/util/errors.ts +1 -1
  86. package/src/util/retry.ts +22 -7
  87. package/src/util/state-merge.ts +34 -0
  88. package/src/util/throttle.ts +1 -1
  89. package/src/util/type-guards.ts +6 -0
  90. package/src/core/memory.ts +0 -589
@@ -16,11 +16,10 @@ 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
+ import { TaskLedger } from "./ledger.ts";
21
20
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
22
21
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
23
- import { buildTurnPrompt, FINALIZE_PROMPT, RETRIEVAL_NUDGE } from "../prompts/user.ts";
22
+ import { buildTurnPrompt, FINALIZE_PROMPT, RETRIEVAL_NUDGE, REASONING_BUDGET_HINT, VERIFICATION_NUDGE } from "../prompts/user.ts";
24
23
  import type { RlmEmitter } from "../tool/rlm-events.ts";
25
24
  import type { SubcallPhase } from "../tool/rlm-details.ts";
26
25
  import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
@@ -30,12 +29,32 @@ import { previewStdout, previewText } from "../text/preview.ts";
30
29
  import { findReplBlocks } from "../text/parsing.ts";
31
30
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
32
31
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
33
- import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
32
+ import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
33
+ import { applyStatePatches, freshRunState, runStateTurnBlock, type RunState, type RunStateMode } from "./run-state.ts";
34
+ import { findStatePatches } from "../text/parsing.ts";
35
+ import { complete1, completeDeps } from "../bridge/handlers/completion.ts";
36
+ import type { SubcallHandlerDeps } from "../bridge/handlers/types.ts";
37
+ import {
38
+ distillPromptFor,
39
+ groundLeafPrompt,
40
+ notesFromRunState,
41
+ parseDistilledNotes,
42
+ skillSearchHandler,
43
+ type SkillStore,
44
+ } from "../config/skillstate.ts";
34
45
  import { retryPolicy } from "../util/retry.ts";
35
46
  import { appendUserMessage } from "./history.ts";
36
47
  import { runTurn } from "./iteration.ts";
37
48
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
38
- import { continuationPrompt, distillTrajectory, resolveBudget, WRAP_UP_BUDGET } from "./budget.ts";
49
+ import {
50
+ continuationPrompt,
51
+ distillTrajectory,
52
+ rectify,
53
+ rectifyLabel,
54
+ resolveBudget,
55
+ stateHandoff,
56
+ WRAP_UP_BUDGET,
57
+ } from "./budget.ts";
39
58
  import { ModelContextRegistry, modelsCachePath } from "./model-registry.ts";
40
59
  import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
41
60
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
@@ -46,11 +65,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
46
65
  * hold a finished run open on work whose result nobody can receive.
47
66
  */
48
67
  const DETACHED_SETTLE_MS = 5_000;
49
- /** H6 (audit): root episodes snapshot at most this many real files replay invalidation for
50
- * the disk-backed slice of the context without hashing an unbounded repository. */
51
- const ROOT_HASH_MAX = 64;
52
-
53
-
68
+ /** Verification nudge (enableVerificationNudge): only an EARLY finalize is suspiciousfrom
69
+ * turn 4 on, a bare answer is just... an answer. "Before iteration ~4", per the bench data. */
70
+ const VERIFICATION_NUDGE_TURN_CAP = 4;
54
71
  export interface EngineDeps {
55
72
  readonly model: Model<Api>;
56
73
  readonly llmModel: Model<Api>;
@@ -66,8 +83,13 @@ export interface EngineDeps {
66
83
  readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
67
84
  /** Test-only: override model completion (scripted multi-turn responses). */
68
85
  readonly complete?: import("./iteration.ts").CompleteFn;
69
- /** v5: session-wide durable memory store (`.rlm/`); omitted memory off for this engine. */
70
- readonly memory?: MemoryStore;
86
+ /** SKILL.state (Workstream B): session-scoped distilled-knowledge store. Omitted no Ξ
87
+ * harvest, no leaf grounding, no skill_search — zero behavior change. */
88
+ readonly skillStore?: SkillStore;
89
+ /** Root Σ (WS-4): observer for the FINAL Σ of accepted runs — the root session tracker
90
+ * mirrors it (one source, two sinks: the SkillStore harvest below + the tracker). Fires
91
+ * for child engines (depth > 0) too; the tracker's dedup keeps the volume bounded. */
92
+ readonly onRunState?: (state: RunState) => void;
71
93
  }
72
94
 
73
95
  /** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
@@ -84,6 +106,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
84
106
  }
85
107
 
86
108
  const model = deps.model;
109
+ const skillStore = deps.skillStore;
87
110
 
88
111
  // Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
89
112
  // Children inherit the parent's remaining timeout (propagated as remaining amount, not
@@ -139,54 +162,26 @@ export function createEngine(deps: EngineDeps): RunRlm {
139
162
  // childRun (RlmInput.ledger — the one construction seam, DRY #6).
140
163
  const runLedger = input.ledger ?? new TaskLedger();
141
164
  if (deps.config.enableLedger) runLedger.beginRun(input.rootPrompt);
165
+ // ── Workstream A: Σ_t — the structured execution state (paper §3). Degraded ⇒ the run
166
+ // behaves exactly as built. History-as-deliverable runs (narrative) keep their archive
167
+ // as the product — RunState never activates there (§12.1).
168
+ let runStateMode: RunStateMode =
169
+ deps.config.enableRunState && input.narrative !== true
170
+ ? { kind: "active", state: freshRunState(input.rootPrompt.slice(0, 200)), retries: 0, idle: 0 }
171
+ : { kind: "degraded", reason: input.narrative === true ? "narrative" : "disabled" };
142
172
 
143
- // v5 durable memory: read-only root replay an identical prompt over an identical
144
- // context answers for zero API calls (measured 10,051 0 tok in bake-off runs).
145
- const rootMemory =
146
- deps.memory !== undefined && deps.config.enableMemory ? deps.memory : undefined;
147
- const modelRefStr = `${model.provider}/${model.id}`;
148
- const rootKey = taskKey("root", input.rootPrompt, [], modelRefStr, contextSig(input.context));
149
- if (rootMemory !== undefined && input.depth === 0 && input.budget === undefined) {
150
- const hit = rootMemory.replay(rootKey);
151
- if (hit !== undefined) {
152
- emitter.emitStatus("done");
153
- return {
154
- answer: hit.result,
155
- iterations: 0,
156
- costUsd: 0,
157
- inputTokens: 0,
158
- outputTokens: 0,
159
- durationMs: 0,
160
- };
161
- }
162
- }
163
- const persistRoot = (
164
- answer: string,
165
- spend?: { readonly inputTokens: number; readonly outputTokens: number },
166
- ): void => {
167
- if (rootMemory === undefined || input.depth !== 0) return;
168
- // H2 (audit): only clean root runs persist — a continuation leaf carries the ORIGINAL
169
- // run's key (it persists the chain itself), and stopped/aborted partials must never
170
- // replay as if they were real answers.
171
- if (input.budget !== undefined) return;
172
- if (answer === "" || answer === "(aborted)" || answer.startsWith("(stopped")) return;
173
- const u = spend ?? limits.usage();
174
- rootMemory.recordEpisode({
175
- key: rootKey,
176
- kind: "root",
177
- model: modelRefStr,
178
- prompt: input.rootPrompt,
179
- paths: rootContextPaths(input.context, ROOT_HASH_MAX),
180
- result: answer,
181
- tokensIn: u.inputTokens,
182
- tokensOut: u.outputTokens,
183
- });
184
- };
185
-
186
- const subcalls = createSubcallHandlers({
173
+ // Workstream F: a rectified continuation may shrink leaf admission for THIS run only —
174
+ // the choice rides in on input.rectification (deterministic, logged). DOCTRINE: the
175
+ // model/provider pair is never a rectification axis — a failing model retries on itself
176
+ // until the attempt budget is exhausted, then fails loudly. No silent fallback.
177
+ const leafLimit = input.rectification?.kind === "reduce-concurrency"
178
+ ? input.rectification.maxConcurrentSubcalls
179
+ : deps.config.maxConcurrentSubcalls;
180
+ const gates = deps.gates
181
+ ?? createSubcallGates(leafLimit, deps.config.maxConcurrentChildren);
182
+ const subcallDeps = {
187
183
  resolve: () => invocation,
188
- gates: deps.gates
189
- ?? createSubcallGates(deps.config.maxConcurrentSubcalls, deps.config.maxConcurrentChildren),
184
+ gates,
190
185
  registry: deps.registry,
191
186
  getLlmModel: () => deps.llmModel,
192
187
  getModel: () => model,
@@ -198,7 +193,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
198
193
  // interrupt during runTurn, which is strictly after loadContext below.
199
194
  getChildContext: () => liveContext,
200
195
  ledger: runLedger,
201
- memory: rootMemory,
202
196
  trackDetached: async (task) => {
203
197
  detachedInFlight += 1;
204
198
  try {
@@ -208,7 +202,39 @@ export function createEngine(deps: EngineDeps): RunRlm {
208
202
  if (detachedInFlight === 0) detachedIdle?.();
209
203
  }
210
204
  },
211
- }, taskRegistry);
205
+ // SKILL.state: leaf grounding (Workstream D, DRY #1) + the parent Ξ for children (C, DRY #6).
206
+ groundLeaf:
207
+ skillStore === undefined || !deps.config.enableSkillState
208
+ ? undefined
209
+ : (prompt: string) => groundLeafPrompt(skillStore, deps.config, prompt),
210
+ getSkillBlock: () => input.skillBlock,
211
+ } satisfies SubcallHandlerDeps;
212
+ const subcalls = createSubcallHandlers(subcallDeps, taskRegistry);
213
+
214
+ // ── Workstream B write path: distill Σ into the session store at run end ──────────
215
+ // Deterministic harvest is free (Σ is already structured); the opt-in A-Mem phrasing is
216
+ // ONE cheap leaf call. Fail-soft: a distill failure must never damage a finished run.
217
+ const harvestSkillNotes = async (): Promise<void> => {
218
+ if (runStateMode.kind !== "active") return;
219
+ // Root Σ WS-4 mirror FIRST — the tracker absorbs the final Σ even when the store is
220
+ // off (one source, two sinks; never a second harvest implementation).
221
+ deps.onRunState?.(runStateMode.state);
222
+ if (skillStore === undefined || !deps.config.enableSkillState) return;
223
+ skillStore.merge(notesFromRunState(runStateMode.state));
224
+ if (!deps.config.enableSkillStateDistill || deps.signal?.aborted === true) return;
225
+ try {
226
+ const raw = await complete1(
227
+ invocation,
228
+ distillPromptFor(runStateMode.state),
229
+ () => {},
230
+ completeDeps(subcallDeps),
231
+ );
232
+ const parsed = parseDistilledNotes(raw);
233
+ if (parsed.length > 0) skillStore.merge(parsed);
234
+ } catch {
235
+ // fail-soft by design
236
+ }
237
+ };
212
238
  /** Wait (bounded) for detached work before the sandbox goes away. */
213
239
  const settleDetached = async (): Promise<void> => {
214
240
  if (detachedInFlight === 0) return;
@@ -253,6 +279,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
253
279
  // H3: retrieval-discipline coach — one-shot per run; children inherit it via the same loop.
254
280
  let sawRetrieval = false;
255
281
  let retrievalNudged = false;
282
+ // Verification-discipline coach (enableVerificationNudge, default OFF): one coached redo
283
+ // when an early finalize looks like the confident-wrong bench shape.
284
+ let verificationNudged = false;
285
+ let verificationNudgePending = false;
256
286
 
257
287
  try {
258
288
  const meta = {
@@ -260,6 +290,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
260
290
  contextChars: contextLength(input.context),
261
291
  contextStats: contextSizeStats(input.context),
262
292
  rootPrompt: input.rootPrompt || undefined,
293
+ skillBlock: input.skillBlock,
263
294
  };
264
295
  const system = buildRlmSystemPrompt(meta, {
265
296
  orchestrator: deps.config.orchestrator,
@@ -267,13 +298,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
267
298
  maxPromptChars: deps.config.maxPromptChars,
268
299
  contextLoader: deps.config.contextLoader,
269
300
  child: input.depth > 0,
270
- delegation: input.depth > 0 && deps.config.childSurface === "delegation",
301
+ delegation: input.depth > 0,
271
302
  depth: input.depth,
272
303
  });
273
304
 
274
305
  // v5 (audit M5): a delegation child does not grow the world — add_context stays root-only.
275
306
  const contextHandlers =
276
- deps.config.contextLoader && (input.depth === 0 || deps.config.childSurface !== "delegation")
307
+ deps.config.contextLoader && input.depth === 0
277
308
  ? buildAddContextHandler({
278
309
  cwd: runCwd,
279
310
  emitter,
@@ -289,9 +320,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
289
320
  }).handlers
290
321
  : {};
291
322
 
292
- // v5 doctrine: one condition feeds BOTH the python surface and the memory scope —
293
- // delegation children keep llm/memory-read/ledger, never repo retrieval or memory.add.
294
- const surface = input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root";
323
+ // v5 doctrine: delegation children keep the llm/ledger surface never repo retrieval.
324
+ const surface = input.depth > 0 ? "child" : "root";
295
325
  sandbox = await PythonSandbox.spawn({
296
326
  depth: input.depth,
297
327
  surface,
@@ -306,10 +336,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
306
336
  ...subcalls,
307
337
  ...contextHandlers,
308
338
  ledgerClaims: () => Promise.resolve(runLedger.listClaims()),
309
- memoryOp: (op, args) =>
310
- Promise.resolve(
311
- rootMemory === undefined ? "memory off" : rootMemory.serviceOp(op, args, surface === "child" ? "child" : "root"),
312
- ),
339
+ // SKILL.state (Workstream E): the model-visible recall surface — one function.
340
+ skillSearch: skillSearchHandler(() => (deps.config.enableSkillState ? skillStore : undefined)),
313
341
  },
314
342
  });
315
343
 
@@ -320,6 +348,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
320
348
  liveContext = input.context ?? [];
321
349
  contextPin = await pinContext(liveContext);
322
350
  await sandbox.loadContextPinned(contextPin);
351
+
352
+ // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
353
+ // Loop-invariant — built once here; finalize() applies the same merge to its own turn.
354
+ const rootSampling: Sampling = {
355
+ reasoning: deps.config.smartReasoning,
356
+ ...deps.config.rootSampling,
357
+ };
323
358
  for (let i = 0; i < deps.config.maxIterations; i++) {
324
359
  limits.checkTimeout();
325
360
  if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
@@ -339,7 +374,11 @@ export function createEngine(deps: EngineDeps): RunRlm {
339
374
  signal: deps.signal,
340
375
  };
341
376
  if (shouldCompact(history, compactionDeps)) {
342
- history = await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
377
+ // Workstream A: with Σ active, rebase structurally — [P, Σ_t, window(O)] and
378
+ // the summarizer call disappears entirely; degraded runs keep compactHistory.
379
+ history = runStateMode.kind === "active"
380
+ ? rebaseWithState(history, runStateMode.state, ++compactions)
381
+ : await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
343
382
  }
344
383
  }
345
384
 
@@ -357,28 +396,31 @@ export function createEngine(deps: EngineDeps): RunRlm {
357
396
  );
358
397
  }
359
398
 
360
- // v5 [ledger] blackboard + [memory] notes each silent ("") when it has nothing to say.
399
+ // v5 [ledger] blackboard — silent ("") when it has nothing to say.
361
400
  const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
362
- const memoryBlock = rootMemory !== undefined ? rootMemory.injectBlock(input.rootPrompt) : "";
363
401
  // H3: after two retrieval-free turns, inject the coach nudge exactly once, for one turn.
364
402
  const nudgeNow = i >= 2 && !sawRetrieval && !retrievalNudged;
365
403
  if (nudgeNow) retrievalNudged = true;
366
404
  const notes =
367
405
  [
368
406
  i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
407
+ // Workstream A: from iteration 3 the run conditions on Σ (A_t = (P, Σ_t, O_t)) and
408
+ // the state fence is requested — exploratory cold-start stays as-built (§12.2).
409
+ runStateMode.kind === "active" && i >= 2 ? runStateTurnBlock(runStateMode.state) : undefined,
369
410
  ledgerBlock === "" ? undefined : ledgerBlock,
370
- memoryBlock === "" ? undefined : memoryBlock,
371
411
  nudgeNow ? RETRIEVAL_NUDGE : undefined,
412
+ verificationNudgePending ? VERIFICATION_NUDGE : undefined,
413
+ // One-shot (turn 0 only): thinking tokens share the completion budget — mirror of
414
+ // the bench's doubling rule. Advisory; never fatal, never repeated.
415
+ i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) < 8_192
416
+ ? REASONING_BUDGET_HINT
417
+ : undefined,
372
418
  ]
373
419
  .filter((s): s is string => s !== undefined)
374
420
  .join("\n\n") || undefined;
421
+ verificationNudgePending = false;
375
422
  appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
376
423
 
377
- // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
378
- const rootSampling: Sampling = {
379
- reasoning: deps.config.smartReasoning,
380
- ...deps.config.rootSampling,
381
- };
382
424
  const turn = await runTurn(history, sandbox, {
383
425
  model: model,
384
426
  registry: deps.registry,
@@ -414,15 +456,40 @@ export function createEngine(deps: EngineDeps): RunRlm {
414
456
  completedTurns = i + 1;
415
457
  const final = finalAnswerOf(turn.results);
416
458
  if (final != null) {
417
- const done = result(final, i + 1, limits);
418
- persistRoot(done.answer);
419
- lastAnswer = done.answer;
420
- return done;
459
+ // Verification-discipline nudge (enableVerificationNudge, default OFF): an early
460
+ // finalize whose answer is a bare number / short label is the confident-wrong shape
461
+ // that dominated bench failures. ONE coached redo, then the answer is accepted.
462
+ if (deps.config.enableVerificationNudge === true && !verificationNudged
463
+ && completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final)) {
464
+ verificationNudged = true;
465
+ verificationNudgePending = true;
466
+ } else {
467
+ const done = result(final, i + 1, limits);
468
+ lastAnswer = done.answer;
469
+ return done;
470
+ }
421
471
  }
422
472
 
423
473
  limits.observe(turnHadError(turn.results));
424
474
  history.push({ role: "assistant", content: turn.response });
425
475
  pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
476
+ // ── Workstream A: apply ΔΣ_t AFTER the environment reply — Algorithm 1 ordering:
477
+ // state reflects intended effects; feedback arrives as the next O_t. Rejections roll
478
+ // back and lead the next observation (error-as-observation retry); retries exhausted
479
+ // ⇒ degrade to as-built for the rest of the run.
480
+ if (runStateMode.kind === "active") {
481
+ const applied = applyStatePatches(
482
+ runStateMode,
483
+ findStatePatches(turn.response),
484
+ i + 1,
485
+ deps.config,
486
+ i >= 2, // the fence was requested this turn → empty turns count as idle (bench rec #2)
487
+ );
488
+ runStateMode = applied.mode;
489
+ if (applied.observation !== undefined) {
490
+ pendingReplOutputs = `${applied.observation}\n\n${pendingReplOutputs}`;
491
+ }
492
+ }
426
493
 
427
494
  // ── v5 budget cascade ─────────────────────────────────────────────────────
428
495
  // Content control lives here; wall-clock timeouts stay hang backstops. Whole-tree
@@ -440,19 +507,37 @@ export function createEngine(deps: EngineDeps): RunRlm {
440
507
  }
441
508
  if (bstate === "hard") {
442
509
  if (budget.canContinue()) {
443
- // Distill the trajectory and chain a fresh run with a fresh spend window —
444
- // the v4 "finalize NOW" flaw fix: never abort mid-task, restructure-and-resume.
445
- const handoff = distillTrajectory(history, input.rootPrompt, deps.config.budgetHandoffChars);
510
+ // Distill and chain a fresh run with a fresh spend window — the v4 "finalize
511
+ // NOW" flaw fix: never abort mid-task, restructure-and-resume. Workstream A:
512
+ // with Σ active the handoff IS the state (compactJSON lossless where it
513
+ // matters); the prose walk stays only for degraded runs.
514
+ const handoff = runStateMode.kind === "active"
515
+ ? stateHandoff(runStateMode.state, input.rootPrompt, deps.config.budgetHandoffChars)
516
+ : distillTrajectory(history, input.rootPrompt, deps.config.budgetHandoffChars);
446
517
  const cont = budget.nextContinuation();
518
+ // Workstream F (MAS2 Eq. 5): one deterministic local fix for the continuation.
519
+ const fix = rectify({
520
+ state: runStateMode.kind === "active" ? runStateMode.state : undefined,
521
+ config: deps.config,
522
+ });
447
523
  if (selfReportId) {
448
- emitter.emitSubcallUpdated({ id: selfReportId, detail: `budget hard → continuation ${cont.continuations}` });
524
+ emitter.emitSubcallUpdated({
525
+ id: selfReportId,
526
+ detail:
527
+ `budget hard → continuation ${cont.continuations}` +
528
+ (fix.kind === "none" ? "" : ` · rectify: ${rectifyLabel(fix)}`),
529
+ });
449
530
  }
450
531
  const inner = await run({
451
532
  ...input,
452
- rootPrompt: continuationPrompt(cont.continuations, handoff),
533
+ rootPrompt: continuationPrompt(cont.continuations, handoff)
534
+ + (fix.kind === "narrow-paths"
535
+ ? `\n\n[rectify] narrow child spawns: rlm_query(task, paths=${JSON.stringify(fix.paths)})`
536
+ : ""),
453
537
  context: liveContext, // H9: sources added mid-run reach the leaf
454
538
  budget: cont,
455
539
  remainingTimeoutMs: limits.remainingTimeoutMs(),
540
+ ...(fix.kind === "none" ? {} : { rectification: fix }),
456
541
  });
457
542
  // H9: report the CHAIN's spend, not just the leaf's fresh guard.
458
543
  const u = limits.usage();
@@ -463,12 +548,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
463
548
  outputTokens: inner.outputTokens + u.outputTokens,
464
549
  costUsd: inner.costUsd + u.costUsd,
465
550
  };
466
- // H2: the ORIGINAL run persists the chain's answer under the ORIGINAL key —
467
- // the next identical prompt must replay the full result, not miss.
468
- // R2: lastAnswer must be set before return — `finally` emitAnswer reads it,
469
- // and persist must store the CHAIN totals, not just the parent window.
551
+ // R2: lastAnswer must be set before return `finally` emitAnswer reads it.
470
552
  lastAnswer = chained.answer;
471
- persistRoot(chained.answer, chained);
472
553
  return chained;
473
554
  }
474
555
  // Chain cap reached — finalize with the best partial (a budget never throws).
@@ -478,7 +559,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
478
559
  }
479
560
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
480
561
  const finalized = result(await finalize(history, model, deps, limits, sandbox), deps.config.maxIterations, limits);
481
- persistRoot(finalized.answer);
482
562
  lastAnswer = finalized.answer;
483
563
  return finalized;
484
564
  } catch (err) {
@@ -497,6 +577,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
497
577
  nodeStatus = "error";
498
578
  throw err;
499
579
  } finally {
580
+ // Workstream B Hook 1: Σ → SkillState notes at run end (all return paths; never throws).
581
+ if (skillStore !== undefined && deps.config.enableSkillState) await harvestSkillNotes();
500
582
  if (deps.config.enableLedger) runLedger.endRun();
501
583
  if (selfReportId) {
502
584
  emitter.emitSubcallUpdated({
@@ -535,6 +617,13 @@ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegist
535
617
  return registry.limitFor(`${model.provider}/${model.id}`);
536
618
  }
537
619
 
620
+ /** Bare number / short label — the early-confident answer shape the verification nudge
621
+ * targets (28/33 bench failures were early confident wrong answers). */
622
+ function isBareAnswer(answer: string): boolean {
623
+ const t = answer.trim();
624
+ return t.length <= 12 || /^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t);
625
+ }
626
+
538
627
  /** Out of turns: ask the model for its best final answer. FINALIZE_PROMPT asks for a fenced
539
628
  * ```repl``` block, so execute it like any turn and prefer the captured answer (H2) — a raw
540
629
  * fence echoed verbatim must never become the run answer. Plain text stays the fallback. */
@@ -548,10 +637,19 @@ async function finalize(
548
637
  const finalHistory = [...history];
549
638
  appendUserMessage(finalHistory, FINALIZE_PROMPT);
550
639
  const complete = deps.complete ?? modelComplete;
640
+ // Same merge rule as the main loop (the `rootSampling` construction in run()): rootSampling
641
+ // wins, smartReasoning is the reasoning default. Finalize is a root turn — it must obey the
642
+ // user's sampling too, or the last turn of every run silently reverts to provider defaults.
643
+ const rootSampling: Sampling = {
644
+ reasoning: deps.config.smartReasoning,
645
+ ...deps.config.rootSampling,
646
+ };
551
647
  const { text, usage } = await complete(finalHistory, {
552
648
  model,
553
649
  registry: deps.registry,
554
- reasoning: deps.config.smartReasoning,
650
+ maxTokens: rootSampling.maxTokens,
651
+ temperature: rootSampling.temperature,
652
+ reasoning: rootSampling.reasoning,
555
653
  signal: deps.signal,
556
654
  });
557
655
  limits.addUsage(usage);
@@ -25,7 +25,7 @@ export interface Turn {
25
25
 
26
26
  export type CompleteFn = (messages: readonly ChatMsg[], opts: CompleteOptions) => Promise<CompleteResult>;
27
27
 
28
- export interface TurnDeps {
28
+ interface TurnDeps {
29
29
  readonly model: Model<Api>;
30
30
  readonly registry: ModelRegistry;
31
31
  readonly sampling?: Sampling;
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { createHash } from "node:crypto";
15
+ import { isRecord } from "../util/type-guards.ts";
15
16
 
16
17
  const NOISE = /\b(no edits?|do not edit|analysis[- ]only|do not change)\.?/gi;
17
18
  const TOK = /[a-z0-9_]{2,}/g;
@@ -23,13 +24,13 @@ const DONE_LINES = 6;
23
24
  const PROMPT_PREVIEW = 80;
24
25
  /** v5 wait() parity (audit H1): a coalescing twin never parks forever. Generous default —
25
26
  * a twin can legitimately wait out a full child engine run. */
26
- export const WAIT_TIMEOUT_MS = 600_000;
27
+ const WAIT_TIMEOUT_MS = 600_000;
27
28
 
28
- export type ClaimKind = "llm" | "rlm";
29
- export type ClaimStatus = "pending" | "running" | "done" | "error";
29
+ type ClaimKind = "llm" | "rlm";
30
+ type ClaimStatus = "pending" | "running" | "done" | "error";
30
31
 
31
32
  /** All-readonly (project rule): transitions replace the map entry with a new frozen Claim. */
32
- export interface Claim {
33
+ interface Claim {
33
34
  readonly key: string;
34
35
  readonly kind: ClaimKind;
35
36
  readonly prompt: string;
@@ -39,7 +40,7 @@ export interface Claim {
39
40
  readonly result: string | null;
40
41
  }
41
42
 
42
- export interface ClaimRequest {
43
+ interface ClaimRequest {
43
44
  readonly kind: ClaimKind;
44
45
  readonly prompt: string;
45
46
  readonly paths: readonly string[];
@@ -47,12 +48,12 @@ export interface ClaimRequest {
47
48
  }
48
49
 
49
50
  /** Result of `tryClaim` — a discriminated union, never an exception. */
50
- export type ClaimDecision =
51
+ type ClaimDecision =
51
52
  | { readonly type: "run"; readonly key: string }
52
53
  | { readonly type: "coalesce"; readonly key: string; readonly done: boolean }
53
54
  | { readonly type: "echo" };
54
55
 
55
- export interface LedgerHits {
56
+ interface LedgerHits {
56
57
  readonly exact: number;
57
58
  readonly echo: number;
58
59
  readonly near: number;
@@ -98,11 +99,6 @@ function sha256Hex(text: string): string {
98
99
  return createHash("sha256").update(text).digest("hex");
99
100
  }
100
101
 
101
- /** Type guard (project rule: no `as` narrowing) — used by contextSig over unknown payloads. */
102
- function isRecord(value: unknown): value is Record<string, unknown> {
103
- return typeof value === "object" && value !== null;
104
- }
105
-
106
102
  /** v5 `context_sig`: fingerprint a packed context so same-question/different-haystack never collide. */
107
103
  export function contextSig(context: unknown): string {
108
104
  if (context === undefined || context === null) return "";
@@ -124,7 +120,8 @@ export function contextSig(context: unknown): string {
124
120
  }
125
121
  return h.digest("hex").slice(0, 16);
126
122
  }
127
- return sha256Hex(String(context)).slice(0, 16);
123
+ // JSON (not String()): String(obj) collapsed EVERY object context to "[object Object]".
124
+ return sha256Hex(JSON.stringify(context)).slice(0, 16);
128
125
  }
129
126
 
130
127
  export function taskKey(
@@ -24,7 +24,7 @@ export function limitsFromConfig(config: Limits): Limits {
24
24
  }
25
25
 
26
26
  /** Point-in-time totals for a run. */
27
- export interface UsageSnapshot {
27
+ interface UsageSnapshot {
28
28
  readonly inputTokens: number;
29
29
  readonly outputTokens: number;
30
30
  readonly costUsd: number;
@@ -30,7 +30,7 @@ interface CacheEntry {
30
30
  }
31
31
  type CacheFile = Readonly<Record<string, CacheEntry>>;
32
32
 
33
- /** `<root>/.rlm/models_cache.json` — the single cache path helper (also used by memory). */
33
+ /** `<root>/.rlm/models_cache.json` — the single cache path helper. */
34
34
  export function modelsCachePath(root: string): string {
35
35
  return `${root.replace(/\/+$/, "")}/.rlm/models_cache.json`;
36
36
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { formatError } from "../util/errors.ts";
4
4
 
5
- export interface RemainingResources {
5
+ interface RemainingResources {
6
6
  readonly timeoutMs?: number;
7
7
  }
8
8