@hicaru/pi-rlm 0.3.19 → 0.3.21

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 (60) hide show
  1. package/README.md +8 -5
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/completion.ts +3 -0
  4. package/src/bridge/handlers/emitting.ts +0 -4
  5. package/src/bridge/handlers/rlm-query.ts +3 -3
  6. package/src/bridge/handlers/task-registry.ts +46 -19
  7. package/src/bridge/handlers/types.ts +6 -3
  8. package/src/bridge/model.ts +4 -0
  9. package/src/commands/rlm.ts +14 -7
  10. package/src/config/defaults.ts +41 -13
  11. package/src/config/settings.ts +7 -3
  12. package/src/config/skillstate.ts +236 -44
  13. package/src/context/merge.ts +10 -3
  14. package/src/context/namespace.ts +6 -2
  15. package/src/context/refresh.ts +32 -11
  16. package/src/core/answer.ts +15 -0
  17. package/src/core/budget.ts +39 -17
  18. package/src/core/compaction.ts +85 -9
  19. package/src/core/engine.ts +117 -32
  20. package/src/core/iteration.ts +4 -0
  21. package/src/core/limits.ts +10 -14
  22. package/src/core/root-context.ts +83 -19
  23. package/src/core/root-digest.ts +48 -11
  24. package/src/core/root-state.ts +39 -12
  25. package/src/core/run-state.ts +86 -14
  26. package/src/core/session-archive.ts +174 -0
  27. package/src/core/types.ts +13 -2
  28. package/src/index.ts +142 -12
  29. package/src/mode/rlm-mode.ts +2 -2
  30. package/src/prompts/glossary.ts +36 -5
  31. package/src/prompts/native.ts +8 -2
  32. package/src/prompts/user.ts +6 -4
  33. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  35. package/src/sandbox/py/retrieval.py +202 -36
  36. package/src/sandbox/py/scaffold.py +20 -5
  37. package/src/sandbox/py/worker.py +1 -1
  38. package/src/sandbox/sandbox-manager.ts +19 -0
  39. package/src/sandbox/sandbox.ts +13 -1
  40. package/src/text/parsing.ts +133 -2
  41. package/src/text/tokens.ts +39 -4
  42. package/src/tool/repl-details.ts +2 -2
  43. package/src/tool/repl-render.ts +38 -2
  44. package/src/tool/repl-tool.ts +37 -23
  45. package/src/tool/rlm-aggregator.ts +1 -1
  46. package/src/tool/rlm-details.ts +1 -2
  47. package/src/tool/rlm-events.ts +3 -6
  48. package/src/tool/rlm-tool.ts +1 -1
  49. package/src/tool/subcall-render.ts +7 -4
  50. package/src/tool/subcall-store.ts +5 -18
  51. package/src/ui/config-panel.ts +4 -19
  52. package/src/ui/intro.ts +1 -1
  53. package/src/ui/panel/run-registry.ts +2 -2
  54. package/src/ui/python-highlight.ts +49 -0
  55. package/src/ui/stage-cards.ts +192 -0
  56. package/src/ui/tree/tree-model.ts +69 -19
  57. package/src/ui/tree/tree-rows.ts +2 -1
  58. package/src/util/abort.ts +34 -0
  59. package/src/util/bm25.ts +170 -21
  60. package/src/util/errors.ts +1 -1
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Token budget cascade (port of the v4/v5 `budget.py` engine).
3
3
  *
4
- * The budget is the PRIMARY run-length control: above COMPACTION_CEILING_TOKENS the cap is
4
+ * The budget is an OUTLIER CEILING, not a progress control (progress = maxIterations):
5
+ * windows at/below COMPACTION_CEILING_TOKENS are never metered; above it the cap is
5
6
  * max(ceiling, budgetShare × model context window) — the share can only stretch the working
6
- * budget further out, never cut under the ceiling;
7
- * one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
8
- * handoff (`distillTrajectory`) is handed to a fresh continuation run — chain-capped at
9
- * `maxContinuations`. Wall-clock timeouts stay only as hang backstops.
7
+ * budget further out, never cut under the ceiling. One soft wrap-up turn at `softFrac` of
8
+ * the cap, and at the hard cap a deterministic handoff (`distillTrajectory`) is handed to
9
+ * a fresh continuation run — chain-capped at `maxContinuations`. Wall-clock timeouts stay
10
+ * only as hang backstops.
10
11
  *
11
12
  * v5 counts the whole tree (root turns + sub-LLM usage) against the cap; the engine feeds
12
13
  * the run's LimitGuard totals in via `observeTotal` after every turn. Each continuation
@@ -30,22 +31,25 @@ type BudgetState = "" | "soft" | "hard";
30
31
 
31
32
  /** v5 verbatim: the soft wrap-up note prepended to the single turn after crossing soft. */
32
33
  export const WRAP_UP_BUDGET: string =
33
- "[budget] ~80% of your token cap — ONE turn left. If the task is answerable NOW, finalize " +
34
- '(set answer["ready"] = True). Otherwise print a compact findings dump: what is confirmed, ' +
35
- "current file/line or search position, and the exact next step — a fresh continuation picks " +
36
- "it up. Do not start new exploration.";
34
+ "[budget] ~80% of this run's outlier cap — ONE turn left. If the task is answerable NOW, " +
35
+ 'finalize (set answer["ready"] = True). Otherwise print a compact findings dump IN THE ' +
36
+ "NOTES: what is confirmed, current file/line or search position, and the exact next " +
37
+ "step — a continuation picks it up. The task, the packed context and the ledger carry " +
38
+ "over; only repl variables are re-derived. Do not start new exploration.";
37
39
 
38
40
  export const DEFAULT_NEXT_STEP: string =
39
41
  "continue the probing that was in flight, then finalize";
40
42
 
41
43
  /** v5 verbatim template (adapting the finalize spelling to this plugin's REPL). */
42
44
  const HANDOFF_TEMPLATE: string =
43
- "A prior RLM run hit its token cap mid-task.\n" +
45
+ "A prior RLM run hit its outlier token ceiling mid-task.\n" +
44
46
  "You are its continuation — pick up EXACTLY where it stopped.\n\n" +
45
47
  "ORIGINAL TASK:\n{query}\n\n" +
46
48
  "CONFIRMED FINDINGS SO FAR:\n{findings}\n\n" +
47
49
  "CURRENT STATE / LAST ACTIONS:\n{state}\n\n" +
48
50
  "NEXT STEP: {next}\n" +
51
+ "NOTE: repl variables are re-derived, but the task, the packed context and the ledger " +
52
+ "carry over. `add_context()` the same external sources again if you still need them.\n" +
49
53
  "Do not re-do confirmed work; continue from the NEXT STEP and finalize as\n" +
50
54
  'soon as the task is answerable (answer["ready"] = True).';
51
55
 
@@ -113,7 +117,7 @@ export class TokenBudget {
113
117
  /**
114
118
  * Minimum context window (tokens) for the token-budget cascade to engage at all.
115
119
  *
116
- * LO rule (2025-09-09): windows at/below COMPACTION_CEILING_TOKENS (256k) are never
120
+ * LO rule (2025-09-09): windows at/below COMPACTION_CEILING_TOKENS (1M) are never
117
121
  * budget-amputated — the derived share would shrink below a task's FIXED overhead (system
118
122
  * prompt + per-turn history re-send + sub-LLM calls); a 32k window would cap a task at 8k
119
123
  * tokens, less than the protocol scaffolding alone. Windows above the ceiling are budgeted
@@ -137,10 +141,14 @@ function unboundedBudget(config: RlmConfig): TokenBudget {
137
141
 
138
142
  export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
139
143
  const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
140
- if (ctx <= COMPACTION_CEILING_TOKENS) return unboundedBudget(config);
141
144
  // The share only stretches the budget BEYOND the absolute ceiling — never under it.
142
145
  const shareCap = Math.max(COMPACTION_CEILING_TOKENS, Math.floor(ctx * config.budgetShare));
143
146
  const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
147
+ // LO rule (2025-09-10): small windows are never budget-amputated — but an EXPLICIT
148
+ // budgetTaskCap that actually binds (below shareCap) must still be honored. The old
149
+ // early-return swallowed the explicit cap on windows ≤ the ceiling (task-cap bug).
150
+ const userCapped = config.budgetTaskCap > 0 && config.budgetTaskCap < shareCap;
151
+ if (!userCapped && ctx <= COMPACTION_CEILING_TOKENS) return unboundedBudget(config);
144
152
  return makeBudget(config, Math.max(cap, 1));
145
153
  }
146
154
 
@@ -150,20 +158,34 @@ export function resolveBudget(contextWindow: number | undefined, config: RlmConf
150
158
  */
151
159
  export function truncateMid(text: string, maxChars: number): string {
152
160
  if (text.length <= maxChars) return text;
153
- const half = Math.max(0, maxChars - ELISION_MARK.length) >> 1;
161
+ // Reserve for the WORST-CASE rendered mark, not the shortest (FINDING-5): the elision
162
+ // count is substituted at render time, so a 2+ digit count grows the mark past the
163
+ // length `half` was budgeted from. digits(text.length) upper-bounds digits(elided) —
164
+ // one pass, and the output can never exceed maxChars.
165
+ const reserve = maxChars - (ELISION_MARK.length - 1 + String(text.length).length);
166
+ if (reserve <= 0) {
167
+ // Cap smaller than even a mark-only render: head-truncate to keep the exact
168
+ // ≤ maxChars guarantee instead of emitting an oversized degenerate mark.
169
+ return text.slice(0, maxChars);
170
+ }
171
+ const half = reserve >> 1;
154
172
  const elided = text.length - (half * 2);
155
173
  return text.slice(0, half) + ELISION_MARK.replace("N", String(elided)) + text.slice(text.length - half);
156
174
  }
157
175
 
158
176
  /** Digest/handoff section caps — ONE source: budget.ts's handoff distillation and the root
159
177
  * digest (core/root-digest.ts) must never drift apart on the same trajectory heuristics. */
160
- export const FINDINGS_MAX = 6;
178
+ export const FINDINGS_MAX = 12; // aligns with RUN_STATE_LIMITS.findings — Σ and handoff agree
161
179
  export const FINDINGS_MIN_CHARS = 20;
162
180
  export const STATE_MAX = 8;
163
- const QUERY_CHARS = 800;
181
+ const QUERY_CHARS = 4_000; // full task statement fits; 800 forced the model to "forget" its own goal
164
182
  const STATE_NEEDLE = "REPL stdout";
165
- /** Next-step probe shared by the engine handoff and the root digest (one wording source). */
166
- export const NEXT_STEP_RE = /next|then|will |todo/i;
183
+ /** Next-step probe shared by the engine handoff and the root digest (one wording source).
184
+ * Recall W4: word-boundary anchored — the old bare alternation matched substrings, so
185
+ * "annex", "welfare", "willpower" pulled prose bullets in as the next step. Knowlange
186
+ * tightening: bare "next/then/will/todo" prose still hijacked ("the next release will…"),
187
+ * so only explicit step shapes match now — colon labels, "next step", or commitments. */
188
+ export const NEXT_STEP_RE = /\b(?:(?:next|then|todo)\s*:|next step\b|(?:i|we)\s+(?:will|'ll|’ll)\b)/i;
167
189
 
168
190
  /**
169
191
  * Deterministic trajectory → handoff (v5 `distill_trajectory`). No LLM call: the model was
@@ -31,19 +31,52 @@ interface CompactionDeps {
31
31
 
32
32
  /**
33
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.
34
+ * COMPACTION_CEILING_TOKENS (LO rule 2025-09-09): windows ≤ 1M never compact; larger
35
+ * windows compact exactly at 1M. `contextWindow`/`thresholdPct` percentage math is gone.
36
36
  */
37
37
  export function shouldCompact(history: ChatMsg[]): boolean {
38
38
  return estimateMessageTokens(history) >= COMPACTION_CEILING_TOKENS;
39
39
  }
40
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
+ }
67
+ }
68
+
41
69
  /**
42
70
  * v5 G1: elide old tool/repl payload bodies, keep the head (system) and the working-set tail
43
71
  * intact. Runs BEFORE `shouldCompact` — v3 measured −97% tokens on coding tasks with this
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;
@@ -26,11 +26,11 @@ 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, stripStateFences } from "../text/parsing.ts";
29
+ import { finalVarRepairCode, findFinalTag, 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
- import { applyStatePatches, freshRunState, runStateTurnBlock, type RunState, type RunStateMode } from "./run-state.ts";
33
+ import { applyStatePatches, compactJSON, freshRunState, runStateTurnBlock, SIGMA_UNCHANGED_LINE, type RunState, type RunStateMode } from "./run-state.ts";
34
34
  import { findStatePatches } from "../text/parsing.ts";
35
35
  import { complete1, completeDeps } from "../bridge/handlers/completion.ts";
36
36
  import type { SubcallHandlerDeps } from "../bridge/handlers/types.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
  };
@@ -202,12 +202,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
202
202
  if (detachedInFlight === 0) detachedIdle?.();
203
203
  }
204
204
  },
205
- // SKILL.state: leaf grounding (Workstream D, DRY #1) + the parent Ξ for children (C, DRY #6).
205
+ // SKILL.state: leaf grounding (Workstream D, DRY #1) + the Ξ block for children (C, DRY #6).
206
206
  groundLeaf:
207
207
  skillStore === undefined || !deps.config.enableSkillState
208
208
  ? undefined
209
209
  : (prompt: string) => groundLeafPrompt(skillStore, deps.config, prompt),
210
- getSkillBlock: () => input.skillBlock,
210
+ // The child's Ξ is re-ranked against the CHILD's task from the live store — inheriting
211
+ // the parent's block verbatim wasted the Ξ budget on facts ranked for someone else's
212
+ // question. Falls back to the inherited block when re-ranking comes up empty (or for
213
+ // the degenerate same-task call), and to `undefined` semantics when the store is off.
214
+ getSkillBlock:
215
+ skillStore === undefined || !deps.config.enableSkillState
216
+ ? () => input.skillBlock
217
+ : (task: string): string | undefined => {
218
+ if (task === input.rootPrompt || task.trim() === "") return input.skillBlock;
219
+ const block = skillStore.blockFor(
220
+ task,
221
+ deps.config.skillStateMaxTokens,
222
+ deps.config.skillStateXiMinScore,
223
+ );
224
+ return block !== "" ? block : input.skillBlock;
225
+ },
211
226
  } satisfies SubcallHandlerDeps;
212
227
  const subcalls = createSubcallHandlers(subcallDeps, taskRegistry);
213
228
 
@@ -220,8 +235,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
220
235
  // off (one source, two sinks; never a second harvest implementation).
221
236
  deps.onRunState?.(runStateMode.state);
222
237
  if (skillStore === undefined || !deps.config.enableSkillState) return;
223
- skillStore.merge(notesFromRunState(runStateMode.state));
238
+ // Depth-tagged deterministic harvest runs at every depth; the LLM distill is ROOT-ONLY —
239
+ // every child distilling into the same project section crowded the note cap with
240
+ // sub-task fragments (A-Mem store hygiene). Fail-soft: a distill failure must never
241
+ // damage a finished run.
242
+ skillStore.merge(notesFromRunState(runStateMode.state, input.depth));
224
243
  if (!deps.config.enableSkillStateDistill || deps.signal?.aborted === true) return;
244
+ if (input.depth > 0) return;
225
245
  try {
226
246
  const raw = await complete1(
227
247
  invocation,
@@ -229,7 +249,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
229
249
  () => {},
230
250
  completeDeps(subcallDeps),
231
251
  );
232
- const parsed = parseDistilledNotes(raw);
252
+ const parsed = parseDistilledNotes(raw, runStateMode.state.task);
233
253
  if (parsed.length > 0) skillStore.merge(parsed);
234
254
  } catch {
235
255
  // fail-soft by design
@@ -269,6 +289,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
269
289
  await previous?.release();
270
290
  };
271
291
  let best = "";
292
+ // P2 §3.4: last non-empty repl stdout across the whole run — recovered by the bench when
293
+ // the run ends with no `answer[…]` frame (the value was printed, just never submitted).
294
+ let lastStdout = "";
272
295
  let lastAnswer = "";
273
296
  let compactions = 0;
274
297
  let completedTurns = 0;
@@ -278,6 +301,11 @@ export function createEngine(deps: EngineDeps): RunRlm {
278
301
  let softNoteTurn = -1;
279
302
  // H3: retrieval-discipline coach — one-shot per run; children inherit it via the same loop.
280
303
  let sawRetrieval = false;
304
+ let sawDelegation = false;
305
+ // Σ economics: the compact JSON of the Σ last sent to the model — an unchanged Σ
306
+ // re-sends as SIGMA_UNCHANGED_LINE instead of the full block. Reset by compaction/rebase
307
+ // (the rebase message re-anchors Σ verbatim) and by degrade (no more Σ blocks at all).
308
+ let lastSigmaSent: string | undefined;
281
309
  let retrievalNudged = false;
282
310
  // Verification-discipline coach (enableVerificationNudge, default OFF): one coached redo
283
311
  // when an early finalize looks like the confident-wrong bench shape.
@@ -365,8 +393,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
365
393
  history = elideOldToolPayloads(history);
366
394
  const compactionDeps = {
367
395
  // Summarisation is done by the cheap worker model; compaction fires on the ABSOLUTE
368
- // COMPACTION_CEILING_TOKENS (limits.ts): ≤256k windows never compact, larger ones
369
- // compact exactly at 256k (LO rule 2025-09-09).
396
+ // COMPACTION_CEILING_TOKENS (limits.ts): ≤1M windows never compact, larger ones
397
+ // compact exactly at 1M (LO rule 2025-09-09).
370
398
  model: deps.llmModel,
371
399
  registry: deps.registry,
372
400
  contextWindow: model.contextWindow,
@@ -379,6 +407,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
379
407
  history = runStateMode.kind === "active"
380
408
  ? rebaseWithState(history, runStateMode.state, ++compactions)
381
409
  : await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
410
+ lastSigmaSent = undefined; // the rebase message re-anchors Σ — full block next turn
382
411
  }
383
412
  }
384
413
 
@@ -398,27 +427,37 @@ export function createEngine(deps: EngineDeps): RunRlm {
398
427
 
399
428
  // v5 [ledger] blackboard — silent ("") when it has nothing to say.
400
429
  const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
430
+ const sigmaJson = runStateMode.kind === "active" ? compactJSON(runStateMode.state) : "";
401
431
  // H3: after two retrieval-free turns, inject the coach nudge exactly once, for one turn.
402
- const nudgeNow = i >= 2 && !sawRetrieval && !retrievalNudged;
432
+ // Surface-aware: delegation children (depth > 0) have NO search/grep_context — nudging
433
+ // them taught a tool their prompt elsewhere forbids (NameError bait).
434
+ const nudgeNow = input.depth === 0 && i >= 2 && !sawRetrieval && !retrievalNudged;
403
435
  if (nudgeNow) retrievalNudged = true;
404
436
  const notes =
405
437
  [
406
438
  i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
407
439
  // Workstream A: from iteration 3 the run conditions on Σ (A_t = (P, Σ_t, O_t)) and
408
440
  // the state fence is requested — exploratory cold-start stays as-built (§12.2).
409
- runStateMode.kind === "active" && i >= 2 ? runStateTurnBlock(runStateMode.state) : undefined,
441
+ // Σ economics: an unchanged Σ re-sends as a one-line marker, not the full JSON —
442
+ // full re-send happens only after compaction/rebase (lastSigmaSent reset below).
443
+ runStateMode.kind === "active" && i >= 2
444
+ ? (sigmaJson === lastSigmaSent ? SIGMA_UNCHANGED_LINE : runStateTurnBlock(runStateMode.state))
445
+ : undefined,
410
446
  ledgerBlock === "" ? undefined : ledgerBlock,
411
447
  nudgeNow ? RETRIEVAL_NUDGE : undefined,
412
448
  verificationNudgePending ? VERIFICATION_NUDGE : undefined,
413
449
  // One-shot (turn 0 only): thinking tokens share the completion budget — mirror of
414
450
  // the bench's doubling rule. Advisory; never fatal, never repeated.
415
- i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) < 8_192
451
+ // P3.1a (plan §3.4): `<= 8_192` the bench pins maxTokens = 8192 exactly, so the old
452
+ // strict `<` made this a dead condition and REASONING_BUDGET_HINT never fired.
453
+ i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) <= 8_192
416
454
  ? REASONING_BUDGET_HINT
417
455
  : undefined,
418
456
  ]
419
457
  .filter((s): s is string => s !== undefined)
420
458
  .join("\n\n") || undefined;
421
459
  verificationNudgePending = false;
460
+ if (runStateMode.kind === "active" && i >= 2) lastSigmaSent = sigmaJson;
422
461
  appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
423
462
 
424
463
  const turn = await runTurn(history, sandbox, {
@@ -427,10 +466,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
427
466
  sampling: rootSampling,
428
467
  retry: deps.complete === undefined ? retryPolicy(deps.config) : undefined,
429
468
  signal: deps.signal,
469
+ // Long-context providers: bigmodel.cn TTFB scales ~1 min per 10k ctx chars; the
470
+ // pi-ai default idle cap aborts such turns as "Request timed out". One knob, both seams.
471
+ timeoutMs: deps.config.requestTimeoutMs,
430
472
  complete: deps.complete,
431
473
  onPhase: reportPhase,
432
474
  });
433
475
  if (turn.blocks.some((b) => /\b(?:search|grep_context)\s*\(/.test(b))) sawRetrieval = true;
476
+ if (turn.blocks.some((b) => /\b(?:llm_query|llm_batch|rlm_query|rlm_batch|map_files|llm_query_chunked|llm_map_reduce|spawn)\s*\(/.test(b))) sawDelegation = true;
434
477
  const allBlocks = turn.blocks.length > 0
435
478
  ? turn.blocks.map((b) => previewText(b, 400)).join("\n")
436
479
  : previewText(turn.response, 400);
@@ -441,30 +484,53 @@ export function createEngine(deps: EngineDeps): RunRlm {
441
484
  if (selfReportId) {
442
485
  emitter.emitSubcallUpdated({
443
486
  id: selfReportId,
444
- costUsd: turn.usage.cost.total,
445
487
  tokens: turn.usage.totalTokens,
446
488
  tokensIn: turn.usage.input,
447
489
  tokensOut: turn.usage.output,
448
490
  });
449
491
  } else {
450
- emitter.emitRootUsage(turn.usage.cost.total, turn.usage.totalTokens, turn.usage.input, turn.usage.output);
492
+ emitter.emitRootUsage(turn.usage.totalTokens, turn.usage.input, turn.usage.output);
451
493
  }
452
494
  deps.onUsage?.(turn.usage, "root");
453
495
  const answerContent = latestAnswerContentOf(turn.results);
454
496
  if (answerContent) best = answerContent;
455
497
  else if (!best && turn.response.trim()) best = turn.response;
498
+ // Stdout fallback floor (P2 §3.4): keep the newest non-empty block output, always —
499
+ // cheapest possible recovery for a run that never submits a final frame.
500
+ const turnStdout = latestStdoutOf(turn.results);
501
+ if (turnStdout) lastStdout = turnStdout;
456
502
  completedTurns = i + 1;
457
- const final = finalAnswerOf(turn.results);
503
+ let final = finalAnswerOf(turn.results);
504
+ // RLM-paper App. A template repair: a finalize written as FINAL(x) / FINAL_VAR(v)
505
+ // instead of an `answer` flip is converted (16%/13% of small-model turns in the
506
+ // paper). The repair note rides the next observation so the model learns the channel.
507
+ let repairNote: string | undefined;
508
+ if (final == null && sandbox !== undefined) {
509
+ const tag = findFinalTag(turn.response);
510
+ if (tag?.kind === "final") {
511
+ final = tag.value;
512
+ repairNote = "[runtime] Converted FINAL(...) from your prose into the final answer — flip `answer[\"ready\"]` directly next time.";
513
+ } else if (tag?.kind === "final_var") {
514
+ const repaired = await sandbox.exec(finalVarRepairCode(tag.value));
515
+ const recovered = finalAnswerOf([repaired]);
516
+ if (recovered !== null && !recovered.startsWith("Error:")) {
517
+ final = recovered;
518
+ repairNote = `[runtime] Converted FINAL_VAR(${tag.value}) into the final answer — flip \`answer["ready"]\` directly next time.`;
519
+ } else if (recovered !== null) {
520
+ repairNote = recovered;
521
+ }
522
+ }
523
+ }
458
524
  if (final != null) {
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.
525
+ // Verification-discipline nudge (default ON — bench: 28/33 failures were early
526
+ // confident wrong answers): a finalize that is bare, or arrived without any
527
+ // inspection of the context, gets ONE coached redo, then the answer is accepted.
462
528
  if (deps.config.enableVerificationNudge === true && !verificationNudged
463
- && completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final)) {
529
+ && completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final, sawRetrieval, sawDelegation)) {
464
530
  verificationNudged = true;
465
531
  verificationNudgePending = true;
466
532
  } else {
467
- const done = result(final, i + 1, limits);
533
+ const done = result(final, i + 1, limits, lastStdout);
468
534
  lastAnswer = done.answer;
469
535
  return done;
470
536
  }
@@ -473,6 +539,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
473
539
  limits.observe(turnHadError(turn.results));
474
540
  history.push({ role: "assistant", content: turn.response });
475
541
  pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
542
+ if (repairNote !== undefined) {
543
+ pendingReplOutputs = `${pendingReplOutputs}\n\n${repairNote}`;
544
+ }
476
545
  // ── Workstream A: apply ΔΣ_t AFTER the environment reply — Algorithm 1 ordering:
477
546
  // state reflects intended effects; feedback arrives as the next O_t. Rejections roll
478
547
  // back and lead the next observation (error-as-observation retry); retries exhausted
@@ -484,6 +553,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
484
553
  i + 1,
485
554
  deps.config,
486
555
  i >= 2, // the fence was requested this turn → empty turns count as idle (bench rec #2)
556
+ // Productive-turn parity (root tracker, recall W2): executed work that neither
557
+ // raised nor skipped resets the idle streak — repl progress is progress.
558
+ turn.blocks.length > 0 && !turnHadError(turn.results) && turn.skippedBlocks === 0,
487
559
  );
488
560
  runStateMode = applied.mode;
489
561
  if (applied.observation !== undefined) {
@@ -546,7 +618,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
546
618
  iterations: inner.iterations + completedTurns,
547
619
  inputTokens: inner.inputTokens + u.inputTokens,
548
620
  outputTokens: inner.outputTokens + u.outputTokens,
549
- costUsd: inner.costUsd + u.costUsd,
550
621
  };
551
622
  // R2: lastAnswer must be set before return — `finally` emitAnswer reads it.
552
623
  lastAnswer = chained.answer;
@@ -558,19 +629,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
558
629
  }
559
630
  }
560
631
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
561
- const finalized = result(await finalize(history, model, deps, limits, sandbox), deps.config.maxIterations, limits);
632
+ const finalized = result(await finalize(history, model, deps, limits, sandbox), completedTurns, limits, lastStdout);
562
633
  lastAnswer = finalized.answer;
563
634
  return finalized;
564
635
  } catch (err) {
565
636
  // Abort is a user action — resolve with the best partial, not an error.
566
637
  if (deps.signal?.aborted) {
567
- const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
638
+ const aborted = result(best.trim() || "(aborted)", completedTurns, limits, lastStdout);
568
639
  lastAnswer = aborted.answer;
569
640
  return aborted;
570
641
  }
571
642
  if (err instanceof LimitError) {
572
643
  nodeStatus = "error";
573
- const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
644
+ const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits, lastStdout);
574
645
  lastAnswer = stopped.answer;
575
646
  return stopped;
576
647
  }
@@ -601,14 +672,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
601
672
  return run;
602
673
  }
603
674
 
604
- function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
675
+ function result(answer: string, iterations: number, limits: LimitGuard, lastStdout: string): RlmResult {
605
676
  // State fences are a Σ transport, never user-visible output (§7): scrub them from the
606
677
  // FINAL answer. A fence-only answer means the model spent its last turn committing state
607
678
  // and never re-answered — surface the stub instead of a raw patch JSON.
608
679
  const clean = stripStateFences(answer);
609
680
  const final = clean.trim().length > 0 ? clean.trim() : "(no final answer — last turn committed state only; see Σ)";
610
681
  const u = limits.usage();
611
- return { answer: final, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
682
+ return {
683
+ answer: final,
684
+ iterations,
685
+ inputTokens: u.inputTokens,
686
+ outputTokens: u.outputTokens,
687
+ durationMs: u.durationMs,
688
+ lastStdout,
689
+ };
612
690
  }
613
691
 
614
692
  /** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
@@ -622,11 +700,18 @@ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegist
622
700
  return registry.limitFor(`${model.provider}/${model.id}`);
623
701
  }
624
702
 
625
- /** Bare number / short label — the early-confident answer shape the verification nudge
626
- * targets (28/33 bench failures were early confident wrong answers). */
627
- function isBareAnswer(answer: string): boolean {
703
+ /** Early-confident answer shape — the target of the verification nudge (28/33 bench failures
704
+ * were early confident wrong answers). Bare when: tiny, ≤3 words, a pure number/date/label,
705
+ * or — strongest signal — the run NEVER inspected its context (no retrieval, no delegation):
706
+ * the answer was then guessed, whatever its length. */
707
+ function isBareAnswer(answer: string, sawRetrieval: boolean, sawDelegation: boolean): boolean {
628
708
  const t = answer.trim();
629
- return t.length <= 12 || /^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t);
709
+ if (t.length === 0) return true;
710
+ if (!sawRetrieval && !sawDelegation) return true;
711
+ if (t.length <= 12) return true;
712
+ if (t.split(/\s+/).length <= 3) return true;
713
+ if (/^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t)) return true;
714
+ return false;
630
715
  }
631
716
 
632
717
  /** Out of turns: ask the model for its best final answer. FINALIZE_PROMPT asks for a fenced
@@ -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);