@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.
@@ -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 = "\n…[elided v5-G1]…";
126
+ const marker =
127
+ "\n…[elided v5-G1 — your repl sandbox is INTACT: variables/answers persist; re-run or " +
128
+ "print(answers) in the next repl to re-derive this content]…";
129
+ const dupMarker =
130
+ "\n…[dup v5-G1 — byte-identical payload already in this history; sandbox INTACT: " +
131
+ "print(<expr>) to inspect it again]…";
132
+ const signatures = new Set<string>();
70
133
  const out: ChatMsg[] = new Array<ChatMsg>(history.length); // pre-allocated
71
134
  for (let i = 0; i < history.length; i++) {
72
135
  const m = history[i];
73
- if (
74
- i < tailStart &&
75
- m.role === "user" &&
76
- m.content.length > toolChars
77
- ) {
78
- out[i] = { role: "user", content: m.content.slice(0, toolChars) + marker };
136
+ if (i < tailStart && m.role === "user" && m.content.length > toolChars) {
137
+ if (ANSWER_FRAME_RE.test(m.content)) {
138
+ out[i] = m; // never touch the answer frame
139
+ continue;
140
+ }
141
+ const sig = payloadSignature(m.content);
142
+ if (signatures.has(sig)) {
143
+ out[i] = { role: "user", content: dupMarker.trimStart() };
144
+ changed = true;
145
+ continue;
146
+ }
147
+ signatures.add(sig);
148
+ if (referenced[i]) {
149
+ out[i] = m; // a later turn still names what this payload produced
150
+ continue;
151
+ }
152
+ // Elided message is capped at exactly toolChars total (§5.3 preview + marker).
153
+ const body = m.content.slice(0, Math.max(0, toolChars - marker.length));
154
+ out[i] = { role: "user", content: body + marker };
79
155
  changed = true;
80
156
  } else {
81
157
  out[i] = m;
@@ -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: (costUsd, inputTokens, outputTokens) => {
145
- limits.addRaw(costUsd, inputTokens, outputTokens);
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
- i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) < 8_192
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.cost.total, turn.usage.totalTokens, turn.usage.input, turn.usage.output);
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), deps.config.maxIterations, limits);
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 { answer: final, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
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). */
@@ -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);
@@ -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 for reporting only — there is no USD spend ceiling.
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 this value are never
13
- * compacted or budget-amputated — the agent runs its full window. Windows ABOVE it are
14
- * compacted/budgeted exactly AT the ceiling (e.g. a 1M-context model works up to ~256k tokens
15
- * of history, then rebases/compacts). Single source of truth for budget.ts (resolveBudget)
16
- * and compaction.ts (shouldCompact + rebaseWithState token bound).
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 = 256_000;
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 cost/tokens into this guard. */
80
- addRaw(costUsd: number, inputTokens: number, outputTokens: number): void {
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
  }
@@ -31,7 +31,7 @@ export interface ElideOptions {
31
31
  readonly elideChars: number;
32
32
  }
33
33
 
34
- const ELIDE_MARK = "chars elided — full result in session log";
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
- messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
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). */
@@ -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 Σ, the full text in the session log. */
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 Σ; full text in session log";
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 [
@@ -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. " +
@@ -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
- private readonly handlers: SubLlmHandlers;
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");
@@ -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 (cost + tokens from sub-LLM calls). */
22
- readonly totals: { readonly costUsd: number; readonly tokens: number };
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. */
@@ -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: { costUsd: 0, tokens: 0 },
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: { costUsd: own.costUsd + bg.costUsd, tokens: own.tokens + bg.tokens },
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: totals.costUsd, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
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.costUsd, event.tokens, event.tokensIn, event.tokensOut);
58
+ this.store.addRootUsage(event.tokens, event.tokensIn, event.tokensOut);
59
59
  this.notify();
60
60
  }
61
61
 
@@ -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 costUsd: number; readonly tokens: number; readonly tokensIn: number; readonly tokensOut: number };
51
+ readonly totals: { readonly tokens: number; readonly tokensIn: number; readonly tokensOut: number };
53
52
  readonly answer?: string;
54
53
  }
55
54
 
@@ -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. costUsd/tokens are additive. */
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(costUsd: number, tokens: number, tokensIn?: number, tokensOut?: number): void {
123
- this.ee.emit("root-usage", { costUsd, tokens, tokensIn, tokensOut } satisfies RootUsageEvent);
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). */
@@ -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: { costUsd: 0, tokens: 0, tokensIn: 0, tokensOut: 0 },
54
+ totals: { tokens: 0, tokensIn: 0, tokensOut: 0 },
55
55
  }));
56
56
  if (!validation.ok) return validation.error;
57
57
  const params = validation.value;