@hicaru/pi-rlm 0.3.6 → 0.3.8
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/package.json +1 -1
- package/src/bridge/handlers/llm-query.ts +73 -17
- package/src/bridge/handlers/rlm-query.ts +130 -8
- package/src/bridge/handlers/types.ts +12 -0
- package/src/config/defaults.ts +17 -0
- package/src/config/settings.ts +45 -2
- package/src/core/answer.ts +7 -10
- package/src/core/budget.ts +182 -0
- package/src/core/compaction.ts +46 -0
- package/src/core/engine.ts +171 -5
- package/src/core/ledger.ts +451 -0
- package/src/core/memory.ts +577 -0
- package/src/core/model-registry.ts +88 -0
- package/src/core/types.ts +44 -3
- package/src/index.ts +60 -9
- package/src/mode/rlm-mode.ts +47 -9
- package/src/prompts/glossary.ts +139 -57
- package/src/prompts/native.ts +12 -7
- package/src/prompts/system.ts +22 -7
- package/src/prompts/user.ts +6 -3
- package/src/sandbox/interrupts.ts +24 -0
- package/src/sandbox/protocol.ts +69 -5
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +11 -6
- package/src/sandbox/py/scaffold.py +615 -0
- package/src/sandbox/py/worker.py +53 -506
- package/src/sandbox/sandbox.ts +21 -3
- package/src/text/repl-output.ts +15 -0
- package/src/tool/repl-result.ts +54 -10
- package/src/tool/repl-tool.ts +43 -9
- package/src/ui/status.ts +4 -1
- package/src/util/concurrency.ts +47 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token budget cascade (port of rlm_test v4/v5 `budget.py`).
|
|
3
|
+
*
|
|
4
|
+
* The budget is the PRIMARY run-length control: cap = budgetShare × model context window,
|
|
5
|
+
* one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
|
|
6
|
+
* handoff (`distillTrajectory`) is handed to a fresh continuation run — chain-capped at
|
|
7
|
+
* `maxContinuations`. Wall-clock timeouts stay only as hang backstops.
|
|
8
|
+
*
|
|
9
|
+
* v5 counts the whole tree (root turns + sub-LLM usage) against the cap; the engine feeds
|
|
10
|
+
* the run's LimitGuard totals in via `observeTotal` after every turn. Each continuation
|
|
11
|
+
* starts a fresh spend window (v5's offset-anchoring) — the chain total is bounded by
|
|
12
|
+
* `cap × (1 + maxContinuations)`, never by re-charging prior work.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChatMsg } from "../bridge/model.ts";
|
|
16
|
+
import type { RlmConfig } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export interface TokenBudgetOptions {
|
|
19
|
+
readonly softFrac?: number;
|
|
20
|
+
readonly continuations?: number;
|
|
21
|
+
readonly maxContinuations?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type BudgetState = "" | "soft" | "hard";
|
|
25
|
+
|
|
26
|
+
/** v5 verbatim: the soft wrap-up note prepended to the single turn after crossing soft. */
|
|
27
|
+
export const WRAP_UP_BUDGET: string = Object.freeze(
|
|
28
|
+
"[budget] ~80% of your token cap — ONE turn left. If the task is answerable NOW, finalize " +
|
|
29
|
+
'(set answer["ready"] = True). Otherwise print a compact findings dump: what is confirmed, ' +
|
|
30
|
+
"current file/line or search position, and the exact next step — a fresh continuation picks " +
|
|
31
|
+
"it up. Do not start new exploration.",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_NEXT_STEP: string =
|
|
35
|
+
Object.freeze("continue the probing that was in flight, then finalize");
|
|
36
|
+
|
|
37
|
+
/** v5 verbatim template (adapting the finalize spelling to this plugin's REPL). */
|
|
38
|
+
const HANDOFF_TEMPLATE: string = Object.freeze(
|
|
39
|
+
"A prior RLM run hit its token cap mid-task.\n" +
|
|
40
|
+
"You are its continuation — pick up EXACTLY where it stopped.\n\n" +
|
|
41
|
+
"ORIGINAL TASK:\n{query}\n\n" +
|
|
42
|
+
"CONFIRMED FINDINGS SO FAR:\n{findings}\n\n" +
|
|
43
|
+
"CURRENT STATE / LAST ACTIONS:\n{state}\n\n" +
|
|
44
|
+
"NEXT STEP: {next}\n" +
|
|
45
|
+
"Do not re-do confirmed work; continue from the NEXT STEP and finalize as\n" +
|
|
46
|
+
'soon as the task is answerable (answer["ready"] = True).',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** v5's elision marker, used whenever a handoff section is trimmed. */
|
|
50
|
+
const ELISION_MARK = "\n…(+N chars elided [v5 handoff])…\n";
|
|
51
|
+
|
|
52
|
+
export class TokenBudget {
|
|
53
|
+
readonly cap: number;
|
|
54
|
+
readonly softFrac: number;
|
|
55
|
+
readonly continuations: number;
|
|
56
|
+
readonly maxContinuations: number;
|
|
57
|
+
private spent = 0;
|
|
58
|
+
|
|
59
|
+
constructor(cap: number, opts: TokenBudgetOptions = {}) {
|
|
60
|
+
this.cap = Math.max(1, Math.floor(cap));
|
|
61
|
+
this.softFrac = opts.softFrac ?? 0.8;
|
|
62
|
+
this.continuations = opts.continuations ?? 0;
|
|
63
|
+
this.maxContinuations = opts.maxContinuations ?? 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get soft(): number {
|
|
67
|
+
return Math.floor(this.cap * this.softFrac);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get hard(): number {
|
|
71
|
+
return this.cap;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Tokens charged to this run so far (root + sub-LLM, whole tree). */
|
|
75
|
+
get tokensSpent(): number {
|
|
76
|
+
return this.spent;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Feed the run's cumulative token totals (LimitGuard::usage()) after each turn.
|
|
81
|
+
* Absolute, not incremental: one budget instance observes exactly one run, which is
|
|
82
|
+
* what makes a continuation's fresh instance start from zero (v5 offset anchoring).
|
|
83
|
+
*/
|
|
84
|
+
observeTotal(inputTokens: number, outputTokens: number): void {
|
|
85
|
+
this.spent = Math.max(0, inputTokens) + Math.max(0, outputTokens);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
state(): BudgetState {
|
|
89
|
+
if (this.cap <= 0) return "";
|
|
90
|
+
if (this.spent >= this.hard) return "hard";
|
|
91
|
+
if (this.spent >= this.soft) return "soft";
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
canContinue(): boolean {
|
|
96
|
+
return this.continuations < this.maxContinuations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Fresh spend window, one step deeper in the chain. */
|
|
100
|
+
nextContinuation(): TokenBudget {
|
|
101
|
+
return new TokenBudget(this.cap, {
|
|
102
|
+
softFrac: this.softFrac,
|
|
103
|
+
continuations: this.continuations + 1,
|
|
104
|
+
maxContinuations: this.maxContinuations,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Cap derivation (v5 `resolve_budget`): share × context window, clamped by the task cap. */
|
|
110
|
+
export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
|
|
111
|
+
const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
|
|
112
|
+
const shareCap = Math.floor(ctx * config.budgetShare);
|
|
113
|
+
const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
|
|
114
|
+
return new TokenBudget(Math.max(cap, 1), {
|
|
115
|
+
softFrac: config.budgetSoftFrac,
|
|
116
|
+
maxContinuations: config.budgetMaxContinuations,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Truncate at the midpoint so both the head and the tail of the content survive
|
|
122
|
+
* (v5 semantics: keep the opening context and the most recent actions).
|
|
123
|
+
*/
|
|
124
|
+
export function truncateMid(text: string, maxChars: number): string {
|
|
125
|
+
if (text.length <= maxChars) return text;
|
|
126
|
+
const half = Math.max(0, maxChars - ELISION_MARK.length) >> 1;
|
|
127
|
+
const elided = text.length - (half * 2);
|
|
128
|
+
return text.slice(0, half) + ELISION_MARK.replace("N", String(elided)) + text.slice(text.length - half);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const QUERY_CHARS = 800;
|
|
132
|
+
const FINDINGS_MAX = 6;
|
|
133
|
+
const FINDINGS_MIN_CHARS = 20;
|
|
134
|
+
const STATE_MAX = 8;
|
|
135
|
+
const STATE_NEEDLE = "REPL stdout";
|
|
136
|
+
const NEXT_STEP_RE = /next|then|will |todo/i;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Deterministic trajectory → handoff (v5 `distill_trajectory`). No LLM call: the model was
|
|
140
|
+
* just told (soft wrap-up) to print a findings dump, and this harvests it — query, the last
|
|
141
|
+
* substantive assistant findings, the last REPL states, and the next step.
|
|
142
|
+
*/
|
|
143
|
+
export function distillTrajectory(
|
|
144
|
+
history: readonly ChatMsg[],
|
|
145
|
+
query: string,
|
|
146
|
+
handoffChars = 4_000,
|
|
147
|
+
): string {
|
|
148
|
+
const findings: string[] = [];
|
|
149
|
+
for (let i = history.length - 1; i >= 0 && findings.length < FINDINGS_MAX; i--) {
|
|
150
|
+
const m = history[i];
|
|
151
|
+
if (m.role === "assistant" && m.content.trim().length > FINDINGS_MIN_CHARS) {
|
|
152
|
+
findings.push(m.content.trim());
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// v5 parity (audit C4): the next-step hint scans NEWEST-first; the join below is chronological.
|
|
156
|
+
const next = findings.find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
|
|
157
|
+
findings.reverse();
|
|
158
|
+
const states: string[] = [];
|
|
159
|
+
for (let i = history.length - 1; i >= 0 && states.length < STATE_MAX; i--) {
|
|
160
|
+
const m = history[i];
|
|
161
|
+
if (m.role === "user" && m.content.includes(STATE_NEEDLE)) {
|
|
162
|
+
states.push(m.content.trim());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
states.reverse();
|
|
166
|
+
|
|
167
|
+
const querySlice = query.slice(0, QUERY_CHARS);
|
|
168
|
+
const queryBlock = truncateMid(querySlice, Math.floor(handoffChars * 0.3));
|
|
169
|
+
const findingsBlock = truncateMid(findings.join("\n\n"), Math.floor(handoffChars * 0.35));
|
|
170
|
+
const stateBlock = truncateMid(states.join("\n\n"), Math.floor(handoffChars * 0.35));
|
|
171
|
+
|
|
172
|
+
return HANDOFF_TEMPLATE
|
|
173
|
+
.replace("{query}", queryBlock)
|
|
174
|
+
.replace("{findings}", findingsBlock)
|
|
175
|
+
.replace("{state}", stateBlock)
|
|
176
|
+
.replace("{next}", next);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The full continuation prompt: `[continuation n]` header + distilled handoff. */
|
|
180
|
+
export function continuationPrompt(n: number, handoff: string): string {
|
|
181
|
+
return `[continuation ${n}]\n${handoff}`;
|
|
182
|
+
}
|
package/src/core/compaction.ts
CHANGED
|
@@ -33,6 +33,52 @@ export function shouldCompact(history: ChatMsg[], deps: CompactionDeps): boolean
|
|
|
33
33
|
return estimateMessageTokens(history) >= threshold;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* v5 G1: elide old tool/repl payload bodies, keep the head (system) and the working-set tail
|
|
38
|
+
* intact. Runs BEFORE `shouldCompact` — v3 measured −97% tokens on coding tasks with this
|
|
39
|
+
* alone, often avoiding the summarizer entirely. Head-ONLY elision was a measured v3 bug
|
|
40
|
+
* (turns grew 3→8): the tail carries the current working set, so the last `keepTurns` turns
|
|
41
|
+
* are never touched.
|
|
42
|
+
*/
|
|
43
|
+
export function elideOldToolPayloads(
|
|
44
|
+
history: ChatMsg[],
|
|
45
|
+
keepTurns = 2,
|
|
46
|
+
toolChars = 1_500,
|
|
47
|
+
): ChatMsg[] {
|
|
48
|
+
if (history.length === 0) return history;
|
|
49
|
+
// Find the assistant message that starts the keepTurns-th-from-last turn; everything from
|
|
50
|
+
// there on is the protected tail.
|
|
51
|
+
let tailStart = 0;
|
|
52
|
+
let seen = 0;
|
|
53
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
54
|
+
if (history[i].role === "assistant") {
|
|
55
|
+
seen++;
|
|
56
|
+
if (seen >= keepTurns) {
|
|
57
|
+
tailStart = i;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (tailStart === 0) return history; // fewer turns than keepTurns — nothing to elide
|
|
63
|
+
let changed = false;
|
|
64
|
+
const marker = "\n…[elided v5-G1]…";
|
|
65
|
+
const out: ChatMsg[] = new Array<ChatMsg>(history.length); // pre-allocated
|
|
66
|
+
for (let i = 0; i < history.length; i++) {
|
|
67
|
+
const m = history[i];
|
|
68
|
+
if (
|
|
69
|
+
i < tailStart &&
|
|
70
|
+
m.role === "user" &&
|
|
71
|
+
m.content.length > toolChars
|
|
72
|
+
) {
|
|
73
|
+
out[i] = { role: "user", content: m.content.slice(0, toolChars) + marker };
|
|
74
|
+
changed = true;
|
|
75
|
+
} else {
|
|
76
|
+
out[i] = m;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return changed ? out : history;
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
/**
|
|
37
83
|
* Summarize the trajectory and return a compacted history: [system, summary(assistant),
|
|
38
84
|
* continue(user)]. The caller continues appending turns from there.
|
package/src/core/engine.ts
CHANGED
|
@@ -16,19 +16,23 @@ import {
|
|
|
16
16
|
createTaskRegistry,
|
|
17
17
|
type Invocation,
|
|
18
18
|
} from "../bridge/handlers/index.ts";
|
|
19
|
+
import { TaskLedger, contextSig, taskKey } from "./ledger.ts";
|
|
20
|
+
import { type MemoryStore, rootContextPaths } from "./memory.ts";
|
|
19
21
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
20
22
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
21
23
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
22
24
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
23
|
-
import { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
25
|
+
import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
|
|
24
26
|
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
25
27
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
26
28
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
27
29
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
28
|
-
import { compactHistory, shouldCompact } from "./compaction.ts";
|
|
30
|
+
import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
|
|
29
31
|
import { appendUserMessage } from "./history.ts";
|
|
30
32
|
import { runTurn } from "./iteration.ts";
|
|
31
33
|
import { type Limits, LimitError, LimitGuard } from "./limits.ts";
|
|
34
|
+
import { continuationPrompt, distillTrajectory, resolveBudget, WRAP_UP_BUDGET } from "./budget.ts";
|
|
35
|
+
import { ModelContextRegistry, modelsCachePath } from "./model-registry.ts";
|
|
32
36
|
import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
|
|
33
37
|
import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
34
38
|
|
|
@@ -38,6 +42,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
|
38
42
|
* hold a finished run open on work whose result nobody can receive.
|
|
39
43
|
*/
|
|
40
44
|
const DETACHED_SETTLE_MS = 5_000;
|
|
45
|
+
/** H6 (audit): root episodes snapshot at most this many real files — replay invalidation for
|
|
46
|
+
* the disk-backed slice of the context without hashing an unbounded repository. */
|
|
47
|
+
const ROOT_HASH_MAX = 64;
|
|
41
48
|
|
|
42
49
|
|
|
43
50
|
export interface EngineDeps {
|
|
@@ -55,6 +62,8 @@ export interface EngineDeps {
|
|
|
55
62
|
readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
|
|
56
63
|
/** Test-only: override model completion (scripted multi-turn responses). */
|
|
57
64
|
readonly complete?: import("./iteration.ts").CompleteFn;
|
|
65
|
+
/** v5: session-wide durable memory store (`.rlm/`); omitted → memory off for this engine. */
|
|
66
|
+
readonly memory?: MemoryStore;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
/** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
|
|
@@ -81,6 +90,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
81
90
|
maxTokens: deps.limits?.maxTokens,
|
|
82
91
|
});
|
|
83
92
|
|
|
93
|
+
// v5 token budget: the primary run-length control. A continuation run carries its own
|
|
94
|
+
// budget in `input.budget`; a fresh run resolves one from the model's context window.
|
|
95
|
+
// ONE registry per run (audit M1): shared by budget resolution, and observed when the
|
|
96
|
+
// model metadata already knows the window so the disk cache populates for other callers.
|
|
97
|
+
const modelCtxRegistry = new ModelContextRegistry(modelsCachePath(runCwd));
|
|
98
|
+
const budget =
|
|
99
|
+
input.budget ??
|
|
100
|
+
(deps.config.enableTokenBudget
|
|
101
|
+
? resolveBudget(contextWindowOrFallback(model, modelCtxRegistry), deps.config)
|
|
102
|
+
: undefined);
|
|
103
|
+
|
|
84
104
|
// One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
|
|
85
105
|
// and its emitter and LimitGuard outlive every sub-call it services — including
|
|
86
106
|
// detached ones, which is why the headless path needs no session registry.
|
|
@@ -105,6 +125,54 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
105
125
|
let detachedIdle: (() => void) | undefined;
|
|
106
126
|
// One registry per run — unawaited task reminders share the same map as await handlers.
|
|
107
127
|
const taskRegistry = createTaskRegistry();
|
|
128
|
+
// v5 TaskLedger: one blackboard per root run; children inherit the same instance via
|
|
129
|
+
// childRun (RlmInput.ledger — the one construction seam, DRY #6).
|
|
130
|
+
const runLedger = input.ledger ?? new TaskLedger();
|
|
131
|
+
if (deps.config.enableLedger) runLedger.beginRun(input.rootPrompt);
|
|
132
|
+
|
|
133
|
+
// v5 durable memory: read-only root replay — an identical prompt over an identical
|
|
134
|
+
// context answers for zero API calls (measured 10,051 → 0 tok in rlm_test).
|
|
135
|
+
const rootMemory =
|
|
136
|
+
deps.memory !== undefined && deps.config.enableMemory ? deps.memory : undefined;
|
|
137
|
+
const modelRefStr = `${model.provider}/${model.id}`;
|
|
138
|
+
const rootKey = taskKey("root", input.rootPrompt, [], modelRefStr, contextSig(input.context));
|
|
139
|
+
if (rootMemory !== undefined && input.depth === 0 && input.budget === undefined) {
|
|
140
|
+
const hit = rootMemory.replay(rootKey);
|
|
141
|
+
if (hit !== undefined) {
|
|
142
|
+
emitter.emitStatus("done");
|
|
143
|
+
return {
|
|
144
|
+
answer: hit.result,
|
|
145
|
+
iterations: 0,
|
|
146
|
+
costUsd: 0,
|
|
147
|
+
inputTokens: 0,
|
|
148
|
+
outputTokens: 0,
|
|
149
|
+
durationMs: 0,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const persistRoot = (
|
|
154
|
+
answer: string,
|
|
155
|
+
spend?: { readonly inputTokens: number; readonly outputTokens: number },
|
|
156
|
+
): void => {
|
|
157
|
+
if (rootMemory === undefined || input.depth !== 0) return;
|
|
158
|
+
// H2 (audit): only clean root runs persist — a continuation leaf carries the ORIGINAL
|
|
159
|
+
// run's key (it persists the chain itself), and stopped/aborted partials must never
|
|
160
|
+
// replay as if they were real answers.
|
|
161
|
+
if (input.budget !== undefined) return;
|
|
162
|
+
if (answer === "" || answer === "(aborted)" || answer.startsWith("(stopped")) return;
|
|
163
|
+
const u = spend ?? limits.usage();
|
|
164
|
+
rootMemory.recordEpisode({
|
|
165
|
+
key: rootKey,
|
|
166
|
+
kind: "root",
|
|
167
|
+
model: modelRefStr,
|
|
168
|
+
prompt: input.rootPrompt,
|
|
169
|
+
paths: rootContextPaths(input.context, ROOT_HASH_MAX),
|
|
170
|
+
result: answer,
|
|
171
|
+
tokensIn: u.inputTokens,
|
|
172
|
+
tokensOut: u.outputTokens,
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
108
176
|
const subcalls = createSubcallHandlers({
|
|
109
177
|
resolve: () => invocation,
|
|
110
178
|
gates: deps.gates
|
|
@@ -119,6 +187,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
119
187
|
// despite being wired before liveContext is assigned — children can only spawn from an
|
|
120
188
|
// interrupt during runTurn, which is strictly after loadContext below.
|
|
121
189
|
getChildContext: () => liveContext,
|
|
190
|
+
ledger: runLedger,
|
|
191
|
+
memory: rootMemory,
|
|
122
192
|
trackDetached: async (task) => {
|
|
123
193
|
detachedInFlight += 1;
|
|
124
194
|
try {
|
|
@@ -139,6 +209,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
139
209
|
detachedIdle = undefined;
|
|
140
210
|
};
|
|
141
211
|
let sandbox: PythonSandbox | undefined;
|
|
212
|
+
const watchdogHeartbeat = setInterval(() => {
|
|
213
|
+
if (detachedInFlight > 0) sandbox?.refreshWatchdog();
|
|
214
|
+
}, SANDBOX_WATCHDOG_HEARTBEAT_MS);
|
|
215
|
+
watchdogHeartbeat.unref?.();
|
|
142
216
|
/**
|
|
143
217
|
* This run's live context: whatever was seeded plus every source added so far. Children
|
|
144
218
|
* inherit it, so it must grow when add_context appends (see the handler's onLoaded below).
|
|
@@ -163,6 +237,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
163
237
|
let compactions = 0;
|
|
164
238
|
let completedTurns = 0;
|
|
165
239
|
let nodeStatus: "done" | "error" = "done";
|
|
240
|
+
// v5 budget cascade state: the wrap-up note fires for exactly ONE turn after crossing soft.
|
|
241
|
+
let softFired = false;
|
|
242
|
+
let softNoteTurn = -1;
|
|
166
243
|
|
|
167
244
|
try {
|
|
168
245
|
const meta = {
|
|
@@ -177,10 +254,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
177
254
|
maxPromptChars: deps.config.maxPromptChars,
|
|
178
255
|
contextLoader: deps.config.contextLoader,
|
|
179
256
|
child: input.depth > 0,
|
|
257
|
+
delegation: input.depth > 0 && deps.config.childSurface === "delegation",
|
|
180
258
|
depth: input.depth,
|
|
181
259
|
});
|
|
182
260
|
|
|
183
|
-
|
|
261
|
+
// v5 (audit M5): a delegation child does not grow the world — add_context stays root-only.
|
|
262
|
+
const contextHandlers =
|
|
263
|
+
deps.config.contextLoader && (input.depth === 0 || deps.config.childSurface !== "delegation")
|
|
184
264
|
? buildAddContextHandler({
|
|
185
265
|
cwd: runCwd,
|
|
186
266
|
emitter,
|
|
@@ -198,6 +278,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
198
278
|
|
|
199
279
|
sandbox = await PythonSandbox.spawn({
|
|
200
280
|
depth: input.depth,
|
|
281
|
+
surface: input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root",
|
|
201
282
|
execTimeoutS: deps.config.execTimeoutS,
|
|
202
283
|
requestTimeoutMs: deps.config.requestTimeoutMs,
|
|
203
284
|
python: deps.config.python,
|
|
@@ -205,7 +286,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
205
286
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
206
287
|
maxPromptChars: deps.config.maxPromptChars,
|
|
207
288
|
awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
|
|
208
|
-
handlers: {
|
|
289
|
+
handlers: {
|
|
290
|
+
...subcalls,
|
|
291
|
+
...contextHandlers,
|
|
292
|
+
ledgerClaims: () => Promise.resolve(runLedger.listClaims()),
|
|
293
|
+
memoryOp: (op, args) => Promise.resolve(rootMemory?.serviceOp(op, args) ?? "memory off"),
|
|
294
|
+
},
|
|
209
295
|
});
|
|
210
296
|
|
|
211
297
|
let history: ChatMsg[] = [{ role: "system", content: system }];
|
|
@@ -221,6 +307,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
221
307
|
else emitter.emitTurn(i + 1, deps.config.maxIterations);
|
|
222
308
|
|
|
223
309
|
if (deps.config.compaction) {
|
|
310
|
+
// v5 G1 first: elide old tool payloads head+tail — often avoids the summary entirely.
|
|
311
|
+
history = elideOldToolPayloads(history);
|
|
224
312
|
const compactionDeps = {
|
|
225
313
|
// Summarisation is done by the cheap worker model; the threshold stays on the
|
|
226
314
|
// root model's context window (that is the window the history fills each turn).
|
|
@@ -249,7 +337,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
249
337
|
);
|
|
250
338
|
}
|
|
251
339
|
|
|
252
|
-
|
|
340
|
+
// v5 [ledger] blackboard + [memory] notes — each silent ("") when it has nothing to say.
|
|
341
|
+
const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
|
|
342
|
+
const memoryBlock = rootMemory !== undefined ? rootMemory.injectBlock(input.rootPrompt) : "";
|
|
343
|
+
const notes =
|
|
344
|
+
[
|
|
345
|
+
i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
|
|
346
|
+
ledgerBlock === "" ? undefined : ledgerBlock,
|
|
347
|
+
memoryBlock === "" ? undefined : memoryBlock,
|
|
348
|
+
]
|
|
349
|
+
.filter((s): s is string => s !== undefined)
|
|
350
|
+
.join("\n\n") || undefined;
|
|
351
|
+
appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
|
|
253
352
|
|
|
254
353
|
// rootSampling fields win; smartReasoning is the default reasoning when not overridden.
|
|
255
354
|
const rootSampling: Sampling = {
|
|
@@ -280,6 +379,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
280
379
|
const final = finalAnswerOf(turn.results);
|
|
281
380
|
if (final != null) {
|
|
282
381
|
const done = result(final, i + 1, limits);
|
|
382
|
+
persistRoot(done.answer);
|
|
283
383
|
lastAnswer = done.answer;
|
|
284
384
|
return done;
|
|
285
385
|
}
|
|
@@ -287,9 +387,62 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
287
387
|
limits.observe(turnHadError(turn.results));
|
|
288
388
|
history.push({ role: "assistant", content: turn.response });
|
|
289
389
|
pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
390
|
+
|
|
391
|
+
// ── v5 budget cascade ─────────────────────────────────────────────────────
|
|
392
|
+
// Content control lives here; wall-clock timeouts stay hang backstops. Whole-tree
|
|
393
|
+
// tokens (root + sub-LLM) reach `limits` through the invocation's addUsage/addRaw seams.
|
|
394
|
+
if (budget !== undefined) {
|
|
395
|
+
const u = limits.usage();
|
|
396
|
+
budget.observeTotal(u.inputTokens, u.outputTokens);
|
|
397
|
+
const bstate = budget.state();
|
|
398
|
+
if (bstate === "soft" && !softFired) {
|
|
399
|
+
softFired = true;
|
|
400
|
+
softNoteTurn = i + 1;
|
|
401
|
+
if (selfReportId) {
|
|
402
|
+
emitter.emitSubcallUpdated({ id: selfReportId, detail: `budget soft @ ${u.inputTokens + u.outputTokens}/${budget.soft} tok` });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (bstate === "hard") {
|
|
406
|
+
if (budget.canContinue()) {
|
|
407
|
+
// Distill the trajectory and chain a fresh run with a fresh spend window —
|
|
408
|
+
// the v4 "finalize NOW" flaw fix: never abort mid-task, restructure-and-resume.
|
|
409
|
+
const handoff = distillTrajectory(history, input.rootPrompt, deps.config.budgetHandoffChars);
|
|
410
|
+
const cont = budget.nextContinuation();
|
|
411
|
+
if (selfReportId) {
|
|
412
|
+
emitter.emitSubcallUpdated({ id: selfReportId, detail: `budget hard → continuation ${cont.continuations}` });
|
|
413
|
+
}
|
|
414
|
+
const inner = await run({
|
|
415
|
+
...input,
|
|
416
|
+
rootPrompt: continuationPrompt(cont.continuations, handoff),
|
|
417
|
+
context: liveContext, // H9: sources added mid-run reach the leaf
|
|
418
|
+
budget: cont,
|
|
419
|
+
remainingTimeoutMs: limits.remainingTimeoutMs(),
|
|
420
|
+
});
|
|
421
|
+
// H9: report the CHAIN's spend, not just the leaf's fresh guard.
|
|
422
|
+
const u = limits.usage();
|
|
423
|
+
const chained: RlmResult = {
|
|
424
|
+
...inner,
|
|
425
|
+
iterations: inner.iterations + completedTurns,
|
|
426
|
+
inputTokens: inner.inputTokens + u.inputTokens,
|
|
427
|
+
outputTokens: inner.outputTokens + u.outputTokens,
|
|
428
|
+
costUsd: inner.costUsd + u.costUsd,
|
|
429
|
+
};
|
|
430
|
+
// H2: the ORIGINAL run persists the chain's answer under the ORIGINAL key —
|
|
431
|
+
// the next identical prompt must replay the full result, not miss.
|
|
432
|
+
// R2: lastAnswer must be set before return — `finally` emitAnswer reads it,
|
|
433
|
+
// and persist must store the CHAIN totals, not just the parent window.
|
|
434
|
+
lastAnswer = chained.answer;
|
|
435
|
+
persistRoot(chained.answer, chained);
|
|
436
|
+
return chained;
|
|
437
|
+
}
|
|
438
|
+
// Chain cap reached — finalize with the best partial (a budget never throws).
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
290
442
|
}
|
|
291
443
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
292
444
|
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
|
|
445
|
+
persistRoot(finalized.answer);
|
|
293
446
|
lastAnswer = finalized.answer;
|
|
294
447
|
return finalized;
|
|
295
448
|
} catch (err) {
|
|
@@ -308,6 +461,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
308
461
|
nodeStatus = "error";
|
|
309
462
|
throw err;
|
|
310
463
|
} finally {
|
|
464
|
+
if (deps.config.enableLedger) runLedger.endRun();
|
|
311
465
|
if (selfReportId) {
|
|
312
466
|
emitter.emitSubcallUpdated({
|
|
313
467
|
id: selfReportId,
|
|
@@ -319,6 +473,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
319
473
|
if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
|
|
320
474
|
emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
|
|
321
475
|
}
|
|
476
|
+
clearInterval(watchdogHeartbeat);
|
|
322
477
|
// Settle detached work FIRST: a child still running may be about to pin this same payload.
|
|
323
478
|
await settleDetached();
|
|
324
479
|
await contextPin?.release();
|
|
@@ -333,6 +488,17 @@ function result(answer: string, iterations: number, limits: LimitGuard): RlmResu
|
|
|
333
488
|
return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
|
|
334
489
|
}
|
|
335
490
|
|
|
491
|
+
/** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
|
|
492
|
+
/** Model metadata window, else the offline registry fallback (disk cache → table → 32k).
|
|
493
|
+
* When metadata provides the window it is observed into the cache (fail-soft, audit M1). */
|
|
494
|
+
function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegistry): number {
|
|
495
|
+
if (model.contextWindow !== undefined && model.contextWindow > 0) {
|
|
496
|
+
registry.observe(`${model.provider}/${model.id}`, model.contextWindow);
|
|
497
|
+
return model.contextWindow;
|
|
498
|
+
}
|
|
499
|
+
return registry.limitFor(`${model.provider}/${model.id}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
336
502
|
/** Out of turns: ask the model for its best final answer (plain text). */
|
|
337
503
|
async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps, limits: LimitGuard): Promise<string> {
|
|
338
504
|
const finalHistory = [...history];
|