@hicaru/pi-rlm 0.3.19 → 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 +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/config/defaults.ts +8 -3
- 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 +32 -14
- package/src/core/compaction.ts +83 -7
- package/src/core/engine.ts +30 -13
- package/src/core/iteration.ts +4 -0
- package/src/core/limits.ts +10 -14
- package/src/core/root-context.ts +13 -2
- package/src/core/types.ts +7 -2
- package/src/prompts/glossary.ts +4 -2
- package/src/prompts/user.ts +2 -1
- package/src/sandbox/sandbox.ts +13 -1
- 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 +2 -17
- package/src/ui/panel/run-registry.ts +2 -2
package/src/core/compaction.ts
CHANGED
|
@@ -38,12 +38,45 @@ 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
|
@@ -28,7 +28,7 @@ import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
|
28
28
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
29
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;
|
|
@@ -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,14 +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 {
|
|
605
615
|
// State fences are a Σ transport, never user-visible output (§7): scrub them from the
|
|
606
616
|
// FINAL answer. A fence-only answer means the model spent its last turn committing state
|
|
607
617
|
// and never re-answered — surface the stub instead of a raw patch JSON.
|
|
608
618
|
const clean = stripStateFences(answer);
|
|
609
619
|
const final = clean.trim().length > 0 ? clean.trim() : "(no final answer — last turn committed state only; see Σ)";
|
|
610
620
|
const u = limits.usage();
|
|
611
|
-
return {
|
|
621
|
+
return {
|
|
622
|
+
answer: final,
|
|
623
|
+
iterations,
|
|
624
|
+
inputTokens: u.inputTokens,
|
|
625
|
+
outputTokens: u.outputTokens,
|
|
626
|
+
durationMs: u.durationMs,
|
|
627
|
+
lastStdout,
|
|
628
|
+
};
|
|
612
629
|
}
|
|
613
630
|
|
|
614
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,19 +3,20 @@
|
|
|
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
11
|
/**
|
|
12
|
-
* Absolute working ceiling (LO rule 2025-09-09): model windows AT/BELOW
|
|
13
|
-
* compacted or budget-amputated — the agent runs its full window
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* and compaction.ts (shouldCompact +
|
|
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).
|
|
17
18
|
*/
|
|
18
|
-
export const COMPACTION_CEILING_TOKENS =
|
|
19
|
+
export const COMPACTION_CEILING_TOKENS = 1_000_000;
|
|
19
20
|
|
|
20
21
|
export interface Limits {
|
|
21
22
|
readonly maxTimeoutMs?: number;
|
|
@@ -36,7 +37,6 @@ export function limitsFromConfig(config: Limits): Limits {
|
|
|
36
37
|
interface UsageSnapshot {
|
|
37
38
|
readonly inputTokens: number;
|
|
38
39
|
readonly outputTokens: number;
|
|
39
|
-
readonly costUsd: number;
|
|
40
40
|
readonly durationMs: number;
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -54,7 +54,6 @@ export class LimitGuard {
|
|
|
54
54
|
private start: number;
|
|
55
55
|
private inputTokens = 0;
|
|
56
56
|
private outputTokens = 0;
|
|
57
|
-
private costUsd = 0;
|
|
58
57
|
private consecutiveErrors = 0;
|
|
59
58
|
|
|
60
59
|
constructor(private readonly limits: Limits = {}, seedElapsedMs = 0) {
|
|
@@ -73,12 +72,10 @@ export class LimitGuard {
|
|
|
73
72
|
addUsage(usage: Usage): void {
|
|
74
73
|
this.inputTokens += usage.input;
|
|
75
74
|
this.outputTokens += usage.output;
|
|
76
|
-
this.costUsd += usage.cost.total;
|
|
77
75
|
}
|
|
78
76
|
|
|
79
|
-
/** Fold a recursive child run's total
|
|
80
|
-
addRaw(
|
|
81
|
-
this.costUsd += costUsd;
|
|
77
|
+
/** Fold a recursive child run's total tokens into this guard. */
|
|
78
|
+
addRaw(inputTokens: number, outputTokens: number): void {
|
|
82
79
|
this.inputTokens += inputTokens;
|
|
83
80
|
this.outputTokens += outputTokens;
|
|
84
81
|
}
|
|
@@ -99,7 +96,6 @@ export class LimitGuard {
|
|
|
99
96
|
return {
|
|
100
97
|
inputTokens: this.inputTokens,
|
|
101
98
|
outputTokens: this.outputTokens,
|
|
102
|
-
costUsd: this.costUsd,
|
|
103
99
|
durationMs: Date.now() - this.start,
|
|
104
100
|
};
|
|
105
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/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. */
|
|
@@ -181,10 +182,14 @@ export interface RlmInput {
|
|
|
181
182
|
export interface RlmResult {
|
|
182
183
|
readonly answer: string;
|
|
183
184
|
readonly iterations: number;
|
|
184
|
-
readonly costUsd: number;
|
|
185
185
|
readonly inputTokens: number;
|
|
186
186
|
readonly outputTokens: number;
|
|
187
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;
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
/** A function that runs an RLM to completion — used to wire recursion (rlm_query). */
|
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");
|
package/src/tool/repl-details.ts
CHANGED
|
@@ -18,8 +18,8 @@ export interface ReplDetails {
|
|
|
18
18
|
readonly executionTimeMs: number;
|
|
19
19
|
/** Sub-calls triggered during this execution (llm_query, rlm_query, etc.). */
|
|
20
20
|
readonly subcalls: readonly RlmSubcall[];
|
|
21
|
-
/** Running totals for this repl() call (
|
|
22
|
-
readonly totals: { readonly
|
|
21
|
+
/** Running totals for this repl() call (tokens from sub-LLM calls). */
|
|
22
|
+
readonly totals: { readonly tokens: number };
|
|
23
23
|
/** Final answer submitted through answer["ready"] without echoing it to the model. */
|
|
24
24
|
readonly finalAnswer?: string;
|
|
25
25
|
/** Detached spawn() sub-calls still running when this call returned. Absent when none. */
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -270,7 +270,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
270
270
|
stderr: errors,
|
|
271
271
|
executionTimeMs: 0,
|
|
272
272
|
subcalls: [],
|
|
273
|
-
totals: {
|
|
273
|
+
totals: { tokens: 0 },
|
|
274
274
|
}));
|
|
275
275
|
if (!validation.ok) return validation.error;
|
|
276
276
|
const params = validation.value;
|
|
@@ -318,7 +318,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
318
318
|
stderr: capturedStderr,
|
|
319
319
|
executionTimeMs: Date.now() - startedAt,
|
|
320
320
|
subcalls: live.length > 0 ? [...store.getSubcalls(), ...live] : store.getSubcalls(),
|
|
321
|
-
totals: {
|
|
321
|
+
totals: { tokens: own.tokens + bg.tokens },
|
|
322
322
|
backgroundPending: background.pending > 0 ? background.pending : undefined,
|
|
323
323
|
};
|
|
324
324
|
},
|
|
@@ -400,12 +400,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
400
400
|
? [...store.getSubcalls(), ...adopted.subcalls]
|
|
401
401
|
: store.getSubcalls();
|
|
402
402
|
const totals = {
|
|
403
|
-
costUsd: store.getTotals().costUsd + adopted.totals.costUsd,
|
|
404
403
|
tokens: store.getTotals().tokens + adopted.totals.tokens,
|
|
405
404
|
};
|
|
406
405
|
const subUsage: Usage = {
|
|
407
406
|
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: totals.tokens,
|
|
408
|
-
cost: { total:
|
|
407
|
+
cost: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
409
408
|
};
|
|
410
409
|
onUsage?.(subUsage, "sub");
|
|
411
410
|
|
|
@@ -453,7 +452,6 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
453
452
|
executionTimeMs: 0,
|
|
454
453
|
subcalls: [...store.getSubcalls(), ...adopted.subcalls],
|
|
455
454
|
totals: {
|
|
456
|
-
costUsd: store.getTotals().costUsd + adopted.totals.costUsd,
|
|
457
455
|
tokens: store.getTotals().tokens + adopted.totals.tokens,
|
|
458
456
|
},
|
|
459
457
|
backgroundPending: background.pending > 0 ? background.pending : undefined,
|
|
@@ -55,7 +55,7 @@ export class RlmEventAggregator extends EmitterListener {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
private handleRootUsage(event: RootUsageEvent): void {
|
|
58
|
-
this.store.addRootUsage(event.
|
|
58
|
+
this.store.addRootUsage(event.tokens, event.tokensIn, event.tokensOut);
|
|
59
59
|
this.notify();
|
|
60
60
|
}
|
|
61
61
|
|
package/src/tool/rlm-details.ts
CHANGED
|
@@ -31,7 +31,6 @@ export interface RlmSubcall {
|
|
|
31
31
|
readonly resultPreview?: string;
|
|
32
32
|
readonly startedAt: number;
|
|
33
33
|
readonly endedAt?: number;
|
|
34
|
-
readonly costUsd: number;
|
|
35
34
|
readonly tokens: number;
|
|
36
35
|
/** In/out split (input / output) — mirrors tokens. */
|
|
37
36
|
readonly tokensIn: number;
|
|
@@ -49,7 +48,7 @@ export interface RlmDetails {
|
|
|
49
48
|
readonly rootPrompt: string;
|
|
50
49
|
readonly turns: { readonly current: number; readonly max: number };
|
|
51
50
|
readonly subcalls: readonly RlmSubcall[];
|
|
52
|
-
readonly totals: { readonly
|
|
51
|
+
readonly totals: { readonly tokens: number; readonly tokensIn: number; readonly tokensOut: number };
|
|
53
52
|
readonly answer?: string;
|
|
54
53
|
}
|
|
55
54
|
|
package/src/tool/rlm-events.ts
CHANGED
|
@@ -38,8 +38,6 @@ export interface SubcallUpdatedEvent {
|
|
|
38
38
|
readonly args?: string;
|
|
39
39
|
readonly resultPreview?: string;
|
|
40
40
|
/** Delta — additive on both the subcall and running totals. */
|
|
41
|
-
readonly costUsd?: number;
|
|
42
|
-
/** Delta — additive on both the subcall and running totals. */
|
|
43
41
|
readonly tokens?: number;
|
|
44
42
|
/** Deltas for the in/out split shown in the tree (input / output). Additive like tokens. */
|
|
45
43
|
readonly tokensIn?: number;
|
|
@@ -56,7 +54,6 @@ export interface TurnEvent {
|
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
export interface RootUsageEvent {
|
|
59
|
-
readonly costUsd: number;
|
|
60
57
|
readonly tokens: number;
|
|
61
58
|
readonly tokensIn?: number;
|
|
62
59
|
readonly tokensOut?: number;
|
|
@@ -108,7 +105,7 @@ export class RlmEmitter {
|
|
|
108
105
|
return id;
|
|
109
106
|
}
|
|
110
107
|
|
|
111
|
-
/** Update an existing sub-call. All fields are partial.
|
|
108
|
+
/** Update an existing sub-call. All fields are partial. tokens are additive. */
|
|
112
109
|
emitSubcallUpdated(event: SubcallUpdatedEvent): void {
|
|
113
110
|
this.ee.emit("subcall:updated", event);
|
|
114
111
|
}
|
|
@@ -119,8 +116,8 @@ export class RlmEmitter {
|
|
|
119
116
|
}
|
|
120
117
|
|
|
121
118
|
/** Accumulate usage directly to root-level totals. */
|
|
122
|
-
emitRootUsage(
|
|
123
|
-
this.ee.emit("root-usage", {
|
|
119
|
+
emitRootUsage(tokens: number, tokensIn?: number, tokensOut?: number): void {
|
|
120
|
+
this.ee.emit("root-usage", { tokens, tokensIn, tokensOut } satisfies RootUsageEvent);
|
|
124
121
|
}
|
|
125
122
|
|
|
126
123
|
/** Set the final answer text (root-only). */
|
package/src/tool/rlm-tool.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function createRlmTool(controller: RlmController, runRegistry?: RunRegist
|
|
|
51
51
|
rootPrompt: "",
|
|
52
52
|
turns: { current: 0, max: 0 },
|
|
53
53
|
subcalls: [],
|
|
54
|
-
totals: {
|
|
54
|
+
totals: { tokens: 0, tokensIn: 0, tokensOut: 0 },
|
|
55
55
|
}));
|
|
56
56
|
if (!validation.ok) return validation.error;
|
|
57
57
|
const params = validation.value;
|