@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.
- package/README.md +8 -5
- package/package.json +1 -1
- package/src/bridge/handlers/completion.ts +3 -0
- package/src/bridge/handlers/emitting.ts +0 -4
- package/src/bridge/handlers/rlm-query.ts +3 -3
- package/src/bridge/handlers/task-registry.ts +46 -19
- package/src/bridge/handlers/types.ts +6 -3
- package/src/bridge/model.ts +4 -0
- package/src/commands/rlm.ts +14 -7
- package/src/config/defaults.ts +41 -13
- package/src/config/settings.ts +7 -3
- package/src/config/skillstate.ts +236 -44
- package/src/context/merge.ts +10 -3
- package/src/context/namespace.ts +6 -2
- package/src/context/refresh.ts +32 -11
- package/src/core/answer.ts +15 -0
- package/src/core/budget.ts +39 -17
- package/src/core/compaction.ts +85 -9
- package/src/core/engine.ts +117 -32
- package/src/core/iteration.ts +4 -0
- package/src/core/limits.ts +10 -14
- package/src/core/root-context.ts +83 -19
- package/src/core/root-digest.ts +48 -11
- package/src/core/root-state.ts +39 -12
- package/src/core/run-state.ts +86 -14
- package/src/core/session-archive.ts +174 -0
- package/src/core/types.ts +13 -2
- package/src/index.ts +142 -12
- package/src/mode/rlm-mode.ts +2 -2
- package/src/prompts/glossary.ts +36 -5
- package/src/prompts/native.ts +8 -2
- package/src/prompts/user.ts +6 -4
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/retrieval.py +202 -36
- package/src/sandbox/py/scaffold.py +20 -5
- package/src/sandbox/py/worker.py +1 -1
- package/src/sandbox/sandbox-manager.ts +19 -0
- package/src/sandbox/sandbox.ts +13 -1
- package/src/text/parsing.ts +133 -2
- package/src/text/tokens.ts +39 -4
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +38 -2
- package/src/tool/repl-tool.ts +37 -23
- package/src/tool/rlm-aggregator.ts +1 -1
- package/src/tool/rlm-details.ts +1 -2
- package/src/tool/rlm-events.ts +3 -6
- package/src/tool/rlm-tool.ts +1 -1
- package/src/tool/subcall-render.ts +7 -4
- package/src/tool/subcall-store.ts +5 -18
- package/src/ui/config-panel.ts +4 -19
- package/src/ui/intro.ts +1 -1
- package/src/ui/panel/run-registry.ts +2 -2
- package/src/ui/python-highlight.ts +49 -0
- package/src/ui/stage-cards.ts +192 -0
- package/src/ui/tree/tree-model.ts +69 -19
- package/src/ui/tree/tree-rows.ts +2 -1
- package/src/util/abort.ts +34 -0
- package/src/util/bm25.ts +170 -21
- package/src/util/errors.ts +1 -1
package/src/core/budget.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
34
|
-
'(set answer["ready"] = True). Otherwise print a compact findings dump
|
|
35
|
-
"current file/line or search position, and the exact next
|
|
36
|
-
"it up.
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
package/src/core/compaction.ts
CHANGED
|
@@ -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 ≤
|
|
35
|
-
* windows compact exactly at
|
|
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 =
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
package/src/core/engine.ts
CHANGED
|
@@ -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: (
|
|
145
|
-
limits.addRaw(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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): ≤
|
|
369
|
-
// compact exactly at
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
460
|
-
//
|
|
461
|
-
//
|
|
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),
|
|
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 {
|
|
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
|
-
/**
|
|
626
|
-
*
|
|
627
|
-
|
|
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
|
-
|
|
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
|
package/src/core/iteration.ts
CHANGED
|
@@ -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);
|