@hicaru/pi-rlm 0.3.18 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -21
- 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/config/defaults.ts +16 -4
- package/src/config/settings.ts +1 -3
- 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 +42 -21
- package/src/core/compaction.ts +101 -15
- package/src/core/engine.ts +40 -18
- package/src/core/iteration.ts +4 -0
- package/src/core/limits.ts +13 -8
- package/src/core/root-context.ts +13 -2
- package/src/core/root-state.ts +17 -8
- package/src/core/run-state.ts +7 -2
- package/src/core/types.ts +9 -3
- package/src/index.ts +12 -2
- package/src/prompts/glossary.ts +4 -2
- package/src/prompts/user.ts +2 -1
- package/src/sandbox/sandbox.ts +13 -1
- package/src/text/parsing.ts +88 -2
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-tool.ts +3 -5
- 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-store.ts +5 -18
- package/src/ui/config-panel.ts +3 -18
- package/src/ui/panel/run-registry.ts +2 -2
- package/src/ui/tree/tree-model.ts +14 -7
package/src/core/compaction.ts
CHANGED
|
@@ -13,8 +13,7 @@ import type { RetryPolicy } from "../util/retry.ts";
|
|
|
13
13
|
import { estimateMessageTokens } from "../text/tokens.ts";
|
|
14
14
|
import type { RunState } from "../core/run-state.ts";
|
|
15
15
|
import { compactJSON } from "../core/run-state.ts";
|
|
16
|
-
|
|
17
|
-
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
16
|
+
import { COMPACTION_CEILING_TOKENS } from "./limits.ts";
|
|
18
17
|
|
|
19
18
|
const SUMMARY_REQUEST =
|
|
20
19
|
"Summarize your progress so far. Include: (1) which sub-tasks are done and which remain; " +
|
|
@@ -25,17 +24,46 @@ interface CompactionDeps {
|
|
|
25
24
|
readonly model: Model<Api>;
|
|
26
25
|
readonly registry: ModelRegistry;
|
|
27
26
|
readonly contextWindow?: number;
|
|
28
|
-
readonly thresholdPct?: number;
|
|
29
27
|
readonly signal?: AbortSignal;
|
|
30
28
|
/** v5.1 retry policy for modelComplete; defaults apply when omitted. */
|
|
31
29
|
readonly retry?: RetryPolicy;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
/**
|
|
33
|
+
* True if the history is at/over the compaction threshold — the ABSOLUTE
|
|
34
|
+
* COMPACTION_CEILING_TOKENS (LO rule 2025-09-09): windows ≤ 256k never compact; larger
|
|
35
|
+
* windows compact exactly at 256k. `contextWindow`/`thresholdPct` percentage math is gone.
|
|
36
|
+
*/
|
|
37
|
+
export function shouldCompact(history: ChatMsg[]): boolean {
|
|
38
|
+
return estimateMessageTokens(history) >= COMPACTION_CEILING_TOKENS;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* P3.2 (plan §3.4): never elide an ANSWER FRAME — `answer['content'] = …` is the run's only
|
|
43
|
+
* durable output, and dropping it from history is how a finished run still reports empty.
|
|
44
|
+
*/
|
|
45
|
+
const ANSWER_FRAME_RE = /answer\[\s*['"](?:content|ready)['"]\s*\]|answers\s*\.\s*update\s*\(/;
|
|
46
|
+
|
|
47
|
+
/** Identifier shape used by the last-reference scan (`counter_a`, `rows_by_label`, …). */
|
|
48
|
+
const REF_TOKEN_RE = /[A-Za-z_][A-Za-z0-9_]{2,}/g;
|
|
49
|
+
/** Bounds: per-payload ids and the growing "future" set stay tiny (O(T·N) with small N). */
|
|
50
|
+
const PAYLOAD_TOKEN_CAP = 64;
|
|
51
|
+
const REF_TOKEN_CAP = 512;
|
|
52
|
+
|
|
53
|
+
/** Whitespace-collapsed body skeleton — two identical page dumps share it even when the
|
|
54
|
+
* turn banner differs (P3.2 "按内容签名去重"). Exact text, NOT digit-normalised: two
|
|
55
|
+
* `counter_a=875` / `counter_a=499` payloads are different conclusions and must not collapse. */
|
|
56
|
+
function payloadSignature(content: string): string {
|
|
57
|
+
return content.replace(/\s+/g, " ").trim().slice(0, 400) + "#" + content.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function payloadTokens(content: string, into: Set<string>): void {
|
|
61
|
+
let n = 0;
|
|
62
|
+
REF_TOKEN_RE.lastIndex = 0;
|
|
63
|
+
for (let m = REF_TOKEN_RE.exec(content); m !== null; m = REF_TOKEN_RE.exec(content)) {
|
|
64
|
+
into.add(m[0]);
|
|
65
|
+
if (++n >= PAYLOAD_TOKEN_CAP) return;
|
|
66
|
+
}
|
|
39
67
|
}
|
|
40
68
|
|
|
41
69
|
/**
|
|
@@ -44,6 +72,11 @@ export function shouldCompact(history: ChatMsg[], deps: CompactionDeps): boolean
|
|
|
44
72
|
* alone, often avoiding the summarizer entirely. Head-ONLY elision was a measured v3 bug
|
|
45
73
|
* (turns grew 3→8): the tail carries the current working set, so the last `keepTurns` turns
|
|
46
74
|
* are never touched.
|
|
75
|
+
*
|
|
76
|
+
* P3.2 adds a whitelist on top of the volume rule (which stays as the floor — dedup and
|
|
77
|
+
* exemptions only ever REMOVE bytes, never add them back):
|
|
78
|
+
* - answer frames and payloads whose identifiers a later turn still mentions are kept verbatim;
|
|
79
|
+
* - a payload byte-identical to an earlier one collapses to a one-line stub.
|
|
47
80
|
*/
|
|
48
81
|
export function elideOldToolPayloads(
|
|
49
82
|
history: ChatMsg[],
|
|
@@ -65,17 +98,60 @@ export function elideOldToolPayloads(
|
|
|
65
98
|
}
|
|
66
99
|
}
|
|
67
100
|
if (tailStart === 0) return history; // fewer turns than keepTurns — nothing to elide
|
|
101
|
+
|
|
102
|
+
// Last-reference scan (descending): `future` holds the identifiers mentioned by everything
|
|
103
|
+
// AFTER the message we are looking at — the working set the model still has in hand.
|
|
104
|
+
const future = new Set<string>();
|
|
105
|
+
const referenced = new Array<boolean>(history.length).fill(false);
|
|
106
|
+
const ids = new Set<string>();
|
|
107
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
108
|
+
const m = history[i];
|
|
109
|
+
if (m.role === "assistant") {
|
|
110
|
+
if (future.size < REF_TOKEN_CAP) payloadTokens(m.content, future);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (m.role !== "user" || i >= tailStart || m.content.length <= toolChars) continue;
|
|
114
|
+
if (ANSWER_FRAME_RE.test(m.content)) continue; // answer frames are kept anyway
|
|
115
|
+
ids.clear();
|
|
116
|
+
payloadTokens(m.content.slice(0, 2_000), ids);
|
|
117
|
+
for (const id of ids) {
|
|
118
|
+
if (future.has(id)) {
|
|
119
|
+
referenced[i] = true;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
68
125
|
let changed = false;
|
|
69
|
-
const marker =
|
|
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;
|
|
@@ -142,6 +218,16 @@ export function rebaseWithState(history: ChatMsg[], state: RunState, count = 1):
|
|
|
142
218
|
}
|
|
143
219
|
}
|
|
144
220
|
}
|
|
221
|
+
// Token-bounded tail (LO rule 2025-09-09): the kept turns must also fit under the absolute
|
|
222
|
+
// ceiling; walk tailStart forward until the tail does. Σ carries everything dropped turns held.
|
|
223
|
+
const sizes: number[] = new Array<number>(history.length);
|
|
224
|
+
for (let i = 0; i < history.length; i++) sizes[i] = estimateMessageTokens([history[i]]);
|
|
225
|
+
let tailTokens = 0;
|
|
226
|
+
for (let i = tailStart; i < history.length; i++) tailTokens += sizes[i];
|
|
227
|
+
while (tailStart < history.length && tailTokens > COMPACTION_CEILING_TOKENS) {
|
|
228
|
+
tailTokens -= sizes[tailStart];
|
|
229
|
+
tailStart += 1;
|
|
230
|
+
}
|
|
145
231
|
const window: ChatMsg[] = tailStart < history.length ? history.slice(tailStart) : [];
|
|
146
232
|
return [
|
|
147
233
|
...head,
|
package/src/core/engine.ts
CHANGED
|
@@ -26,9 +26,9 @@ import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox
|
|
|
26
26
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
27
27
|
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
28
28
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
29
|
-
import { findReplBlocks } from "../text/parsing.ts";
|
|
29
|
+
import { findReplBlocks, stripStateFences } from "../text/parsing.ts";
|
|
30
30
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
31
|
-
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
31
|
+
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, latestStdoutOf, turnHadError } from "./answer.ts";
|
|
32
32
|
import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
|
|
33
33
|
import { applyStatePatches, freshRunState, runStateTurnBlock, type RunState, type RunStateMode } from "./run-state.ts";
|
|
34
34
|
import { findStatePatches } from "../text/parsing.ts";
|
|
@@ -141,8 +141,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
141
141
|
limits.addUsage(u);
|
|
142
142
|
deps.onUsage?.(u, "sub");
|
|
143
143
|
},
|
|
144
|
-
addRaw: (
|
|
145
|
-
limits.addRaw(
|
|
144
|
+
addRaw: (inputTokens, outputTokens) => {
|
|
145
|
+
limits.addRaw(inputTokens, outputTokens);
|
|
146
146
|
},
|
|
147
147
|
},
|
|
148
148
|
};
|
|
@@ -269,6 +269,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
269
269
|
await previous?.release();
|
|
270
270
|
};
|
|
271
271
|
let best = "";
|
|
272
|
+
// P2 §3.4: last non-empty repl stdout across the whole run — recovered by the bench when
|
|
273
|
+
// the run ends with no `answer[…]` frame (the value was printed, just never submitted).
|
|
274
|
+
let lastStdout = "";
|
|
272
275
|
let lastAnswer = "";
|
|
273
276
|
let compactions = 0;
|
|
274
277
|
let completedTurns = 0;
|
|
@@ -364,16 +367,16 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
364
367
|
// v5 G1 first: elide old tool payloads head+tail — often avoids the summary entirely.
|
|
365
368
|
history = elideOldToolPayloads(history);
|
|
366
369
|
const compactionDeps = {
|
|
367
|
-
// Summarisation is done by the cheap worker model;
|
|
368
|
-
//
|
|
370
|
+
// Summarisation is done by the cheap worker model; compaction fires on the ABSOLUTE
|
|
371
|
+
// COMPACTION_CEILING_TOKENS (limits.ts): ≤256k windows never compact, larger ones
|
|
372
|
+
// compact exactly at 256k (LO rule 2025-09-09).
|
|
369
373
|
model: deps.llmModel,
|
|
370
374
|
registry: deps.registry,
|
|
371
375
|
contextWindow: model.contextWindow,
|
|
372
|
-
thresholdPct: deps.config.compactionThresholdPct,
|
|
373
376
|
retry: retryPolicy(deps.config),
|
|
374
377
|
signal: deps.signal,
|
|
375
378
|
};
|
|
376
|
-
if (shouldCompact(history
|
|
379
|
+
if (shouldCompact(history)) {
|
|
377
380
|
// Workstream A: with Σ active, rebase structurally — [P, Σ_t, window(O)] — and
|
|
378
381
|
// the summarizer call disappears entirely; degraded runs keep compactHistory.
|
|
379
382
|
history = runStateMode.kind === "active"
|
|
@@ -412,7 +415,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
412
415
|
verificationNudgePending ? VERIFICATION_NUDGE : undefined,
|
|
413
416
|
// One-shot (turn 0 only): thinking tokens share the completion budget — mirror of
|
|
414
417
|
// the bench's doubling rule. Advisory; never fatal, never repeated.
|
|
415
|
-
|
|
418
|
+
// P3.1a (plan §3.4): `<= 8_192` — the bench pins maxTokens = 8192 exactly, so the old
|
|
419
|
+
// strict `<` made this a dead condition and REASONING_BUDGET_HINT never fired.
|
|
420
|
+
i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) <= 8_192
|
|
416
421
|
? REASONING_BUDGET_HINT
|
|
417
422
|
: undefined,
|
|
418
423
|
]
|
|
@@ -427,6 +432,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
427
432
|
sampling: rootSampling,
|
|
428
433
|
retry: deps.complete === undefined ? retryPolicy(deps.config) : undefined,
|
|
429
434
|
signal: deps.signal,
|
|
435
|
+
// Long-context providers: bigmodel.cn TTFB scales ~1 min per 10k ctx chars; the
|
|
436
|
+
// pi-ai default idle cap aborts such turns as "Request timed out". One knob, both seams.
|
|
437
|
+
timeoutMs: deps.config.requestTimeoutMs,
|
|
430
438
|
complete: deps.complete,
|
|
431
439
|
onPhase: reportPhase,
|
|
432
440
|
});
|
|
@@ -441,18 +449,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
441
449
|
if (selfReportId) {
|
|
442
450
|
emitter.emitSubcallUpdated({
|
|
443
451
|
id: selfReportId,
|
|
444
|
-
costUsd: turn.usage.cost.total,
|
|
445
452
|
tokens: turn.usage.totalTokens,
|
|
446
453
|
tokensIn: turn.usage.input,
|
|
447
454
|
tokensOut: turn.usage.output,
|
|
448
455
|
});
|
|
449
456
|
} else {
|
|
450
|
-
emitter.emitRootUsage(turn.usage.
|
|
457
|
+
emitter.emitRootUsage(turn.usage.totalTokens, turn.usage.input, turn.usage.output);
|
|
451
458
|
}
|
|
452
459
|
deps.onUsage?.(turn.usage, "root");
|
|
453
460
|
const answerContent = latestAnswerContentOf(turn.results);
|
|
454
461
|
if (answerContent) best = answerContent;
|
|
455
462
|
else if (!best && turn.response.trim()) best = turn.response;
|
|
463
|
+
// Stdout fallback floor (P2 §3.4): keep the newest non-empty block output, always —
|
|
464
|
+
// cheapest possible recovery for a run that never submits a final frame.
|
|
465
|
+
const turnStdout = latestStdoutOf(turn.results);
|
|
466
|
+
if (turnStdout) lastStdout = turnStdout;
|
|
456
467
|
completedTurns = i + 1;
|
|
457
468
|
const final = finalAnswerOf(turn.results);
|
|
458
469
|
if (final != null) {
|
|
@@ -464,7 +475,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
464
475
|
verificationNudged = true;
|
|
465
476
|
verificationNudgePending = true;
|
|
466
477
|
} else {
|
|
467
|
-
const done = result(final, i + 1, limits);
|
|
478
|
+
const done = result(final, i + 1, limits, lastStdout);
|
|
468
479
|
lastAnswer = done.answer;
|
|
469
480
|
return done;
|
|
470
481
|
}
|
|
@@ -546,7 +557,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
546
557
|
iterations: inner.iterations + completedTurns,
|
|
547
558
|
inputTokens: inner.inputTokens + u.inputTokens,
|
|
548
559
|
outputTokens: inner.outputTokens + u.outputTokens,
|
|
549
|
-
costUsd: inner.costUsd + u.costUsd,
|
|
550
560
|
};
|
|
551
561
|
// R2: lastAnswer must be set before return — `finally` emitAnswer reads it.
|
|
552
562
|
lastAnswer = chained.answer;
|
|
@@ -558,19 +568,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
558
568
|
}
|
|
559
569
|
}
|
|
560
570
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
561
|
-
const finalized = result(await finalize(history, model, deps, limits, sandbox),
|
|
571
|
+
const finalized = result(await finalize(history, model, deps, limits, sandbox), completedTurns, limits, lastStdout);
|
|
562
572
|
lastAnswer = finalized.answer;
|
|
563
573
|
return finalized;
|
|
564
574
|
} catch (err) {
|
|
565
575
|
// Abort is a user action — resolve with the best partial, not an error.
|
|
566
576
|
if (deps.signal?.aborted) {
|
|
567
|
-
const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
|
|
577
|
+
const aborted = result(best.trim() || "(aborted)", completedTurns, limits, lastStdout);
|
|
568
578
|
lastAnswer = aborted.answer;
|
|
569
579
|
return aborted;
|
|
570
580
|
}
|
|
571
581
|
if (err instanceof LimitError) {
|
|
572
582
|
nodeStatus = "error";
|
|
573
|
-
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
|
|
583
|
+
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits, lastStdout);
|
|
574
584
|
lastAnswer = stopped.answer;
|
|
575
585
|
return stopped;
|
|
576
586
|
}
|
|
@@ -601,9 +611,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
601
611
|
return run;
|
|
602
612
|
}
|
|
603
613
|
|
|
604
|
-
function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
|
|
614
|
+
function result(answer: string, iterations: number, limits: LimitGuard, lastStdout: string): RlmResult {
|
|
615
|
+
// State fences are a Σ transport, never user-visible output (§7): scrub them from the
|
|
616
|
+
// FINAL answer. A fence-only answer means the model spent its last turn committing state
|
|
617
|
+
// and never re-answered — surface the stub instead of a raw patch JSON.
|
|
618
|
+
const clean = stripStateFences(answer);
|
|
619
|
+
const final = clean.trim().length > 0 ? clean.trim() : "(no final answer — last turn committed state only; see Σ)";
|
|
605
620
|
const u = limits.usage();
|
|
606
|
-
return {
|
|
621
|
+
return {
|
|
622
|
+
answer: final,
|
|
623
|
+
iterations,
|
|
624
|
+
inputTokens: u.inputTokens,
|
|
625
|
+
outputTokens: u.outputTokens,
|
|
626
|
+
durationMs: u.durationMs,
|
|
627
|
+
lastStdout,
|
|
628
|
+
};
|
|
607
629
|
}
|
|
608
630
|
|
|
609
631
|
/** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
|
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);
|
package/src/core/limits.ts
CHANGED
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
* (ported from rlm/core/rlm.py `_check_timeout` / `_check_iteration_limits`). Any breach throws
|
|
4
4
|
* a LimitError; the engine catches it and returns the best partial answer it has.
|
|
5
5
|
*
|
|
6
|
-
* Cost is tracked
|
|
6
|
+
* Cost is NOT tracked: there is no USD spend ceiling and no cost reporting.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Absolute working ceiling (LO rule 2025-09-09, raised 2025-09-10): model windows AT/BELOW
|
|
13
|
+
* this value are never compacted or budget-amputated — the agent runs its full window, and
|
|
14
|
+
* tree-wide spend (root turns + sub-LLM calls) is NOT metered against it. Windows ABOVE it
|
|
15
|
+
* are budgeted exactly AT the ceiling — an outlier context-fit guard, not a cost meter.
|
|
16
|
+
* Single source of truth for budget.ts (resolveBudget) and compaction.ts (shouldCompact +
|
|
17
|
+
* rebaseWithState token bound).
|
|
18
|
+
*/
|
|
19
|
+
export const COMPACTION_CEILING_TOKENS = 1_000_000;
|
|
20
|
+
|
|
11
21
|
export interface Limits {
|
|
12
22
|
readonly maxTimeoutMs?: number;
|
|
13
23
|
readonly maxTokens?: number;
|
|
@@ -27,7 +37,6 @@ export function limitsFromConfig(config: Limits): Limits {
|
|
|
27
37
|
interface UsageSnapshot {
|
|
28
38
|
readonly inputTokens: number;
|
|
29
39
|
readonly outputTokens: number;
|
|
30
|
-
readonly costUsd: number;
|
|
31
40
|
readonly durationMs: number;
|
|
32
41
|
}
|
|
33
42
|
|
|
@@ -45,7 +54,6 @@ export class LimitGuard {
|
|
|
45
54
|
private start: number;
|
|
46
55
|
private inputTokens = 0;
|
|
47
56
|
private outputTokens = 0;
|
|
48
|
-
private costUsd = 0;
|
|
49
57
|
private consecutiveErrors = 0;
|
|
50
58
|
|
|
51
59
|
constructor(private readonly limits: Limits = {}, seedElapsedMs = 0) {
|
|
@@ -64,12 +72,10 @@ export class LimitGuard {
|
|
|
64
72
|
addUsage(usage: Usage): void {
|
|
65
73
|
this.inputTokens += usage.input;
|
|
66
74
|
this.outputTokens += usage.output;
|
|
67
|
-
this.costUsd += usage.cost.total;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
/** Fold a recursive child run's total
|
|
71
|
-
addRaw(
|
|
72
|
-
this.costUsd += costUsd;
|
|
77
|
+
/** Fold a recursive child run's total tokens into this guard. */
|
|
78
|
+
addRaw(inputTokens: number, outputTokens: number): void {
|
|
73
79
|
this.inputTokens += inputTokens;
|
|
74
80
|
this.outputTokens += outputTokens;
|
|
75
81
|
}
|
|
@@ -90,7 +96,6 @@ export class LimitGuard {
|
|
|
90
96
|
return {
|
|
91
97
|
inputTokens: this.inputTokens,
|
|
92
98
|
outputTokens: this.outputTokens,
|
|
93
|
-
costUsd: this.costUsd,
|
|
94
99
|
durationMs: Date.now() - this.start,
|
|
95
100
|
};
|
|
96
101
|
}
|
package/src/core/root-context.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface ElideOptions {
|
|
|
31
31
|
readonly elideChars: number;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const ELIDE_MARK = "chars elided —
|
|
34
|
+
const ELIDE_MARK = "chars elided — repl sandbox persists: print(answers[k]) or re-run repl to re-derive";
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* WS-3a: elide stale turns. The newest `keepTurns` assistant turns and the final user message
|
|
@@ -109,7 +109,18 @@ function elideRange(
|
|
|
109
109
|
if (i === lastUser) continue; // paranoia: the final user message is never touched
|
|
110
110
|
if (m.role === "assistant") {
|
|
111
111
|
staleAssistants -= 1; // turns AFTER this one = count minus itself
|
|
112
|
-
|
|
112
|
+
// Provider pairing invariant: Anthropic requires every tool_result to follow the
|
|
113
|
+
// assistant message carrying its tool_use; OpenAI requires each tool/function_call_output
|
|
114
|
+
// to pair with its tool_call/function_call. Eliding the toolCall blocks here orphaned the
|
|
115
|
+
// surviving toolResults and broke the request. Elide only the prose — keep toolCall blocks.
|
|
116
|
+
const toolCalls = Array.isArray(m.content)
|
|
117
|
+
? (m.content as Array<{ type?: string }>).filter((b) => b?.type === "toolCall")
|
|
118
|
+
: [];
|
|
119
|
+
const content: unknown[] =
|
|
120
|
+
toolCalls.length > 0
|
|
121
|
+
? [...toolCalls, { type: "text", text: ROOT_TURN_ELIDED_LINE }]
|
|
122
|
+
: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }];
|
|
123
|
+
messages[i] = { ...m, content } as RootMessage;
|
|
113
124
|
elided += 1;
|
|
114
125
|
continue;
|
|
115
126
|
}
|
package/src/core/root-state.ts
CHANGED
|
@@ -206,7 +206,11 @@ export class RootStateTracker {
|
|
|
206
206
|
* while runtime `observeToolResult` remains the Σ floor (degrade, never crash).
|
|
207
207
|
*/
|
|
208
208
|
applyFences(fences: readonly StateFenceResult[]): FenceOutcome {
|
|
209
|
-
|
|
209
|
+
// R7-fix (recoverable degrade): a DEGRADED tracker no longer drops fences on the floor.
|
|
210
|
+
// The old early-return turned idle degrade into a one-way amnesia valve — every later
|
|
211
|
+
// fence vanished silently while the context transform kept eliding turns. Now the same
|
|
212
|
+
// validation ladder runs in degraded mode, and a CLEAN batch re-activates compensation.
|
|
213
|
+
// Degrade stays sticky only against zero-progress storms (all-malformed batches).
|
|
210
214
|
if (fences.length === 0) {
|
|
211
215
|
// R4 (G6): a fence-free turn on a conditioned loop is IDLE — the contract rode the
|
|
212
216
|
// prompt for nothing. Grow the streak; degrade at the engine's threshold.
|
|
@@ -235,24 +239,29 @@ export class RootStateTracker {
|
|
|
235
239
|
this.idleFenceTurns = accepted > 0 ? 0 : this.idleFenceTurns + 1;
|
|
236
240
|
this.degradeIfIdle();
|
|
237
241
|
if (problems.length === 0) {
|
|
242
|
+
// Recovery seam: a clean batch re-activates a degraded tracker, so one honest fence
|
|
243
|
+
// ends the amnesia window instead of requiring a session restart.
|
|
238
244
|
this.mode = { kind: "active", retries: 0 };
|
|
239
245
|
this.pendingObservation = undefined;
|
|
240
246
|
this.draft = this.toMutable(state);
|
|
241
247
|
this.touch();
|
|
242
248
|
return { fences: fences.length, accepted, problems: 0 };
|
|
243
249
|
}
|
|
244
|
-
|
|
250
|
+
// Degraded variant carries `reason`, not `retries` — recovery is clean-batch-only, so a
|
|
251
|
+
// degraded tracker neither accumulates retries nor re-activates on a partial batch.
|
|
252
|
+
const retries = (this.mode.kind === "active" ? this.mode.retries : 0) + problems.length;
|
|
245
253
|
this.pendingObservation = statePatchObservation(problems);
|
|
246
254
|
// Accepted deltas in a partially-failing batch still land — engine parity: real work is
|
|
247
255
|
// never rolled back just because a sibling fence was malformed.
|
|
248
256
|
this.draft = this.toMutable(state);
|
|
249
257
|
this.touch();
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
if (this.mode.kind === "active") {
|
|
259
|
+
if (retries > this.retryMax) {
|
|
260
|
+
this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
|
|
261
|
+
} else {
|
|
262
|
+
// Persist the running rejection count — the retry cap is CUMULATIVE across turns.
|
|
263
|
+
this.mode = { kind: "active", retries };
|
|
264
|
+
}
|
|
256
265
|
}
|
|
257
266
|
return { fences: fences.length, accepted, problems: problems.length };
|
|
258
267
|
}
|
package/src/core/run-state.ts
CHANGED
|
@@ -552,13 +552,18 @@ export function statePatchObservation(problems: readonly string[]): string | und
|
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
export const STATE_FENCE_INSTRUCTION: string =
|
|
555
|
-
"[state]
|
|
555
|
+
"[state] Your user-facing reply is normal prose — a readable report. The ```state fence is OPTIONAL compact metadata that trails it, never a replacement for the report:\n" +
|
|
556
556
|
'{"state_patch": {"verifiedFacts[+]": "src/x.ts — fact", "testedApproaches.h1.status": "failed"}}\n' +
|
|
557
|
+
"Σ is an index of pointers, not a report: ≤ 5 keys per patch, every string value ≤ 120 chars, " +
|
|
558
|
+
"telegraphic style (`path — fact`, `verdict — numbers`). NEVER paste findings, tables, JSON " +
|
|
559
|
+
"blobs, or long excerpts into Σ — the prose carries the story, Σ carries only the pointers.\n" +
|
|
557
560
|
"Keys: dotted paths write record leaves; [+] appends; [N] sets an array slot; null deletes.\n" +
|
|
558
561
|
"Commit DELTAS only — never restate unchanged records or arrays; touch single dotted keys " +
|
|
559
562
|
`or append with [+]. Whole-record restatements must keep EVERY key (implicit drops are ` +
|
|
560
563
|
`rejected), and a patch over ${RUN_STATE_LIMITS.patchBytes} bytes is rejected whole.\n` +
|
|
561
|
-
"
|
|
564
|
+
"If this turn produced nothing durable and new, end your reply on the prose — NO fence at all. " +
|
|
565
|
+
"An absent fence is free; a malformed or oversized one costs a retry. " +
|
|
566
|
+
"Stale turns are elided and compensated by Σ, so a durable fact you skip here is gone.";
|
|
562
567
|
|
|
563
568
|
/** ONE Σ-block composer (R2): the engine turn block and the root splice both delegate here —
|
|
564
569
|
* never duplicate the contract + Σ assembly. `withContract` is paper A.4 authoring mode;
|
package/src/core/types.ts
CHANGED
|
@@ -13,7 +13,8 @@ export interface RlmConfig {
|
|
|
13
13
|
readonly enabled: boolean;
|
|
14
14
|
/** Max recursion depth. depth >= maxDepth ⇒ rlm_query falls back to a plain llm_query. */
|
|
15
15
|
readonly maxDepth: number;
|
|
16
|
-
/** Max turns before the engine must finalize.
|
|
16
|
+
/** Max turns before the engine must finalize. Deliberately large — runs end on FINAL
|
|
17
|
+
* answer, errors, or wall-clock long before this bites. */
|
|
17
18
|
readonly maxIterations: number;
|
|
18
19
|
/** Per-`repl`-block wall-clock timeout inside the worker (seconds).
|
|
19
20
|
* v5 doctrine: content limits are the token budget's job — this is a HANG backstop only. */
|
|
@@ -54,7 +55,8 @@ export interface RlmConfig {
|
|
|
54
55
|
readonly orchestrator: boolean;
|
|
55
56
|
/** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
|
|
56
57
|
readonly compaction: boolean;
|
|
57
|
-
/**
|
|
58
|
+
/** DEPRECATED (LO rule 2025-09-09): ignored — compaction is governed by the absolute
|
|
59
|
+
* COMPACTION_CEILING_TOKENS (limits.ts). Kept for settings/UI compatibility only. */
|
|
58
60
|
readonly compactionThresholdPct: number;
|
|
59
61
|
/** Python executable used to launch the sandbox worker. */
|
|
60
62
|
readonly python: string;
|
|
@@ -180,10 +182,14 @@ export interface RlmInput {
|
|
|
180
182
|
export interface RlmResult {
|
|
181
183
|
readonly answer: string;
|
|
182
184
|
readonly iterations: number;
|
|
183
|
-
readonly costUsd: number;
|
|
184
185
|
readonly inputTokens: number;
|
|
185
186
|
readonly outputTokens: number;
|
|
186
187
|
readonly durationMs: number;
|
|
188
|
+
/** Last non-empty repl stdout of the run (capped, P2 §3.4). The engine itself never reads
|
|
189
|
+
* it: a run that ends without `answer[...]` (no final frame) would otherwise score as an
|
|
190
|
+
* empty submission even though the winning value was printed. The bench/grader recovers
|
|
191
|
+
* that value from here instead of re-running the whole task. */
|
|
192
|
+
readonly lastStdout: string;
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
/** A function that runs an RLM to completion — used to wire recursion (rlm_query). */
|
package/src/index.ts
CHANGED
|
@@ -433,6 +433,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
433
433
|
idle: tracker.idleTurns,
|
|
434
434
|
active: tracker.isActive,
|
|
435
435
|
degraded: wasActive && !tracker.isActive,
|
|
436
|
+
// R7-fix: recovery observability — a degraded tracker that accepted a clean batch.
|
|
437
|
+
recovered: !wasActive && tracker.isActive,
|
|
436
438
|
});
|
|
437
439
|
}
|
|
438
440
|
if (wasActive && !tracker.isActive) {
|
|
@@ -503,12 +505,20 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
503
505
|
});
|
|
504
506
|
elidedMessages += elided;
|
|
505
507
|
const tracker = rootTracker;
|
|
506
|
-
// R4
|
|
508
|
+
// R4 REV (amnesia fix): degrade suspends fence WRITES (applyFences gate) — never Σ
|
|
509
|
+
// READBACK. SKILL.state §5.3 makes elision lossless precisely because Σ rides with
|
|
510
|
+
// the elided turns; stubbing history while withholding Σ amnesia-loops the agent
|
|
511
|
+
// (announce-continue-then-stop). Splice whenever Σ has content, active or degraded;
|
|
512
|
+
// the fence CONTRACT rides only while active — a degraded tracker ignores fences
|
|
513
|
+
// (applyFences early-returns), so teaching the contract is pure tax.
|
|
507
514
|
if (
|
|
508
515
|
controller.config.rootContextSnapshot && tracker !== undefined &&
|
|
509
|
-
|
|
516
|
+
!tracker.isEmpty
|
|
510
517
|
) {
|
|
511
518
|
spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint(), {
|
|
519
|
+
// R7-fix: teach the contract while DEGRADED too — it is the only road back.
|
|
520
|
+
// Recovery is a clean fence; hiding the notation after degrade made the
|
|
521
|
+
// amnesia window permanent (the fence turns that taught it get elided).
|
|
512
522
|
withContract: controller.config.enableRootStateFences,
|
|
513
523
|
});
|
|
514
524
|
sigmaSplices += 1;
|
package/src/prompts/glossary.ts
CHANGED
|
@@ -63,9 +63,11 @@ export const SKILL_RECALL_LINE =
|
|
|
63
63
|
"Recall more anytime inside repl: `skill_search(query, k=8)` → [{id, text, tags, score}].";
|
|
64
64
|
|
|
65
65
|
/** R5 (G4, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the one-line replacement for assistant prose
|
|
66
|
-
* older than the keep window — durable facts live in
|
|
66
|
+
* older than the keep window — durable facts live in Σ; the repl sandbox (answers/vars) is
|
|
67
|
+
* the model-reachable recovery channel — the session log is host-side only, never re-openable
|
|
68
|
+
* by the model, so stubs must not promise it. */
|
|
67
69
|
export const ROOT_TURN_ELIDED_LINE =
|
|
68
|
-
"… turn elided — durable facts live in Σ;
|
|
70
|
+
"… turn elided — durable facts live in Σ; your repl sandbox persists: print(answers) / SHOW_VARS() to re-derive";
|
|
69
71
|
|
|
70
72
|
export function skillStateLines(noteCount: number, body: string): string {
|
|
71
73
|
return [
|
package/src/prompts/user.ts
CHANGED
|
@@ -21,7 +21,8 @@ export function buildTurnPrompt(
|
|
|
21
21
|
|
|
22
22
|
/** Asked once when the engine runs out of turns without a submitted answer. Same finalize
|
|
23
23
|
* dialect as the budget wrap-up note (audit M6): answer-ready first, plain text only as an
|
|
24
|
-
* explicit fallback the engine still accepts.
|
|
24
|
+
* explicit fallback the engine still accepts. Deliberately near-unreachable: the cap is
|
|
25
|
+
* large by design (the old 16-cap all-failed oolong mid-retrieval). */
|
|
25
26
|
export const FINALIZE_PROMPT =
|
|
26
27
|
"You are out of turns. Finalize NOW: set `answer[\"content\"]` and `answer[\"ready\"] = True` " +
|
|
27
28
|
"(fenced ```repl```) with your best final answer from everything you have gathered. " +
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -92,7 +92,10 @@ export class PythonSandbox {
|
|
|
92
92
|
private scanOffset = 0;
|
|
93
93
|
private seq = 0;
|
|
94
94
|
private readonly pending = new Map<string, Pending>();
|
|
95
|
-
|
|
95
|
+
// Not readonly: the engine's continuation handoff re-installs a successor run's closures
|
|
96
|
+
// on a live worker (installHandlers) instead of respawning it — respawn would destroy the
|
|
97
|
+
// pinned context accumulator, which is exactly what the chain must preserve.
|
|
98
|
+
private handlers: SubLlmHandlers;
|
|
96
99
|
private readonly requestTimeoutMs: number;
|
|
97
100
|
private readonly initTimeoutMs: number;
|
|
98
101
|
/** Bounded stderr tail (chunks, newest last) — avoids rebuilding the buffer per chunk. */
|
|
@@ -224,6 +227,15 @@ export class PythonSandbox {
|
|
|
224
227
|
return res.index ?? 0;
|
|
225
228
|
}
|
|
226
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Continuation handoff (engine chain): re-install a successor run's sub-call handlers on
|
|
232
|
+
* THIS live worker so a chained engine run can adopt it without a respawn. Respawning
|
|
233
|
+
* would destroy the pinned context accumulator — the one thing the chain must preserve.
|
|
234
|
+
*/
|
|
235
|
+
installHandlers(handlers: SubLlmHandlers): void {
|
|
236
|
+
this.handlers = { ...REJECT, ...handlers };
|
|
237
|
+
}
|
|
238
|
+
|
|
227
239
|
async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
|
|
228
240
|
const res = await this.request({ type: "exec", code }, signal);
|
|
229
241
|
if (!res.ok) throw new Error(res.error ?? "exec failed");
|