@hicaru/pi-rlm 0.3.14 → 0.3.16

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.
@@ -20,12 +20,14 @@ import { TaskLedger, contextSig, taskKey } from "./ledger.ts";
20
20
  import { type MemoryStore, rootContextPaths } from "./memory.ts";
21
21
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
22
22
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
23
- import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
23
+ import { buildTurnPrompt, FINALIZE_PROMPT, RETRIEVAL_NUDGE, REASONING_BUDGET_HINT, VERIFICATION_NUDGE } from "../prompts/user.ts";
24
24
  import type { RlmEmitter } from "../tool/rlm-events.ts";
25
25
  import type { SubcallPhase } from "../tool/rlm-details.ts";
26
26
  import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
27
+ import type { ReplResult } from "../sandbox/protocol.ts";
27
28
  import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
28
29
  import { previewStdout, previewText } from "../text/preview.ts";
30
+ import { findReplBlocks } from "../text/parsing.ts";
29
31
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
30
32
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
31
33
  import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
@@ -44,6 +46,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
44
46
  * hold a finished run open on work whose result nobody can receive.
45
47
  */
46
48
  const DETACHED_SETTLE_MS = 5_000;
49
+ /** Verification nudge (enableVerificationNudge): only an EARLY finalize is suspicious — from
50
+ * turn 4 on, a bare answer is just... an answer. "Before iteration ~4", per the bench data. */
51
+ const VERIFICATION_NUDGE_TURN_CAP = 4;
47
52
  /** H6 (audit): root episodes snapshot at most this many real files — replay invalidation for
48
53
  * the disk-backed slice of the context without hashing an unbounded repository. */
49
54
  const ROOT_HASH_MAX = 64;
@@ -248,6 +253,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
248
253
  // v5 budget cascade state: the wrap-up note fires for exactly ONE turn after crossing soft.
249
254
  let softFired = false;
250
255
  let softNoteTurn = -1;
256
+ // H3: retrieval-discipline coach — one-shot per run; children inherit it via the same loop.
257
+ let sawRetrieval = false;
258
+ let retrievalNudged = false;
259
+ // Verification-discipline coach (enableVerificationNudge, default OFF): one coached redo
260
+ // when an early finalize looks like the confident-wrong bench shape.
261
+ let verificationNudged = false;
262
+ let verificationNudgePending = false;
251
263
 
252
264
  try {
253
265
  const meta = {
@@ -315,6 +327,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
315
327
  liveContext = input.context ?? [];
316
328
  contextPin = await pinContext(liveContext);
317
329
  await sandbox.loadContextPinned(contextPin);
330
+
331
+ // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
332
+ // Loop-invariant — built once here; finalize() applies the same merge to its own turn.
333
+ const rootSampling: Sampling = {
334
+ reasoning: deps.config.smartReasoning,
335
+ ...deps.config.rootSampling,
336
+ };
318
337
  for (let i = 0; i < deps.config.maxIterations; i++) {
319
338
  limits.checkTimeout();
320
339
  if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
@@ -355,21 +374,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
355
374
  // v5 [ledger] blackboard + [memory] notes — each silent ("") when it has nothing to say.
356
375
  const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
357
376
  const memoryBlock = rootMemory !== undefined ? rootMemory.injectBlock(input.rootPrompt) : "";
377
+ // H3: after two retrieval-free turns, inject the coach nudge exactly once, for one turn.
378
+ const nudgeNow = i >= 2 && !sawRetrieval && !retrievalNudged;
379
+ if (nudgeNow) retrievalNudged = true;
358
380
  const notes =
359
381
  [
360
382
  i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
361
383
  ledgerBlock === "" ? undefined : ledgerBlock,
362
384
  memoryBlock === "" ? undefined : memoryBlock,
385
+ nudgeNow ? RETRIEVAL_NUDGE : undefined,
386
+ verificationNudgePending ? VERIFICATION_NUDGE : undefined,
387
+ // One-shot (turn 0 only): thinking tokens share the completion budget — mirror of
388
+ // the bench's doubling rule. Advisory; never fatal, never repeated.
389
+ i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) < 8_192
390
+ ? REASONING_BUDGET_HINT
391
+ : undefined,
363
392
  ]
364
393
  .filter((s): s is string => s !== undefined)
365
394
  .join("\n\n") || undefined;
395
+ verificationNudgePending = false;
366
396
  appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
367
397
 
368
- // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
369
- const rootSampling: Sampling = {
370
- reasoning: deps.config.smartReasoning,
371
- ...deps.config.rootSampling,
372
- };
373
398
  const turn = await runTurn(history, sandbox, {
374
399
  model: model,
375
400
  registry: deps.registry,
@@ -379,6 +404,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
379
404
  complete: deps.complete,
380
405
  onPhase: reportPhase,
381
406
  });
407
+ if (turn.blocks.some((b) => /\b(?:search|grep_context)\s*\(/.test(b))) sawRetrieval = true;
382
408
  const allBlocks = turn.blocks.length > 0
383
409
  ? turn.blocks.map((b) => previewText(b, 400)).join("\n")
384
410
  : previewText(turn.response, 400);
@@ -386,8 +412,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
386
412
  emitter.emitSubcallUpdated({ id: selfReportId, args: `▶ ${allBlocks}`, resultPreview: previewStdout(turn.results) });
387
413
  }
388
414
  limits.addUsage(turn.usage);
389
- if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, costUsd: turn.usage.cost.total, tokens: turn.usage.totalTokens });
390
- else emitter.emitRootUsage(turn.usage.cost.total, turn.usage.totalTokens);
415
+ if (selfReportId) {
416
+ emitter.emitSubcallUpdated({
417
+ id: selfReportId,
418
+ costUsd: turn.usage.cost.total,
419
+ tokens: turn.usage.totalTokens,
420
+ tokensIn: turn.usage.input,
421
+ tokensOut: turn.usage.output,
422
+ });
423
+ } else {
424
+ emitter.emitRootUsage(turn.usage.cost.total, turn.usage.totalTokens, turn.usage.input, turn.usage.output);
425
+ }
391
426
  deps.onUsage?.(turn.usage, "root");
392
427
  const answerContent = latestAnswerContentOf(turn.results);
393
428
  if (answerContent) best = answerContent;
@@ -395,10 +430,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
395
430
  completedTurns = i + 1;
396
431
  const final = finalAnswerOf(turn.results);
397
432
  if (final != null) {
398
- const done = result(final, i + 1, limits);
399
- persistRoot(done.answer);
400
- lastAnswer = done.answer;
401
- return done;
433
+ // Verification-discipline nudge (enableVerificationNudge, default OFF): an early
434
+ // finalize whose answer is a bare number / short label is the confident-wrong shape
435
+ // that dominated bench failures. ONE coached redo, then the answer is accepted.
436
+ if (deps.config.enableVerificationNudge === true && !verificationNudged
437
+ && completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final)) {
438
+ verificationNudged = true;
439
+ verificationNudgePending = true;
440
+ } else {
441
+ const done = result(final, i + 1, limits);
442
+ persistRoot(done.answer);
443
+ lastAnswer = done.answer;
444
+ return done;
445
+ }
402
446
  }
403
447
 
404
448
  limits.observe(turnHadError(turn.results));
@@ -458,7 +502,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
458
502
  }
459
503
  }
460
504
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
461
- const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
505
+ const finalized = result(await finalize(history, model, deps, limits, sandbox), deps.config.maxIterations, limits);
462
506
  persistRoot(finalized.answer);
463
507
  lastAnswer = finalized.answer;
464
508
  return finalized;
@@ -516,17 +560,48 @@ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegist
516
560
  return registry.limitFor(`${model.provider}/${model.id}`);
517
561
  }
518
562
 
519
- /** Out of turns: ask the model for its best final answer (plain text). */
520
- async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps, limits: LimitGuard): Promise<string> {
563
+ /** Bare number / short label the early-confident answer shape the verification nudge
564
+ * targets (28/33 bench failures were early confident wrong answers). */
565
+ function isBareAnswer(answer: string): boolean {
566
+ const t = answer.trim();
567
+ return t.length <= 12 || /^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t);
568
+ }
569
+
570
+ /** Out of turns: ask the model for its best final answer. FINALIZE_PROMPT asks for a fenced
571
+ * ```repl``` block, so execute it like any turn and prefer the captured answer (H2) — a raw
572
+ * fence echoed verbatim must never become the run answer. Plain text stays the fallback. */
573
+ async function finalize(
574
+ history: ChatMsg[],
575
+ model: Model<Api>,
576
+ deps: EngineDeps,
577
+ limits: LimitGuard,
578
+ sandbox: PythonSandbox,
579
+ ): Promise<string> {
521
580
  const finalHistory = [...history];
522
581
  appendUserMessage(finalHistory, FINALIZE_PROMPT);
523
582
  const complete = deps.complete ?? modelComplete;
583
+ // Same merge rule as the main loop (the `rootSampling` construction in run()): rootSampling
584
+ // wins, smartReasoning is the reasoning default. Finalize is a root turn — it must obey the
585
+ // user's sampling too, or the last turn of every run silently reverts to provider defaults.
586
+ const rootSampling: Sampling = {
587
+ reasoning: deps.config.smartReasoning,
588
+ ...deps.config.rootSampling,
589
+ };
524
590
  const { text, usage } = await complete(finalHistory, {
525
591
  model,
526
592
  registry: deps.registry,
527
- reasoning: deps.config.smartReasoning,
593
+ maxTokens: rootSampling.maxTokens,
594
+ temperature: rootSampling.temperature,
595
+ reasoning: rootSampling.reasoning,
528
596
  signal: deps.signal,
529
597
  });
530
598
  limits.addUsage(usage);
599
+ const blocks = findReplBlocks(text);
600
+ const results = new Array<ReplResult>(blocks.length);
601
+ for (let i = 0; i < blocks.length; i++) {
602
+ results[i] = await sandbox.exec(blocks[i]);
603
+ }
604
+ const final = finalAnswerOf(results) ?? latestAnswerContentOf(results);
605
+ if (final !== null && final.trim() !== "") return final.trim();
531
606
  return text.trim();
532
607
  }
package/src/core/types.ts CHANGED
@@ -110,6 +110,11 @@ export interface RlmConfig {
110
110
  /** v5 doctrine: "delegation" = child engines get llm/memory/ledger only (no repo retrieval);
111
111
  * "legacy" keeps today's full child surface as a one-flip rollback. */
112
112
  readonly childSurface: "delegation" | "legacy";
113
+ /** Verification-discipline nudge (default OFF — it changes interactive behavior): when the
114
+ * root finalizes before turn 4 with a bare number / short label, it gets ONE coached redo
115
+ * ("recompute and sanity-check in Python") instead of accepting the answer. Opt-in via
116
+ * rlm.json; evidence: 28/33 bench failures were early confident wrong answers. */
117
+ readonly enableVerificationNudge?: boolean;
113
118
  }
114
119
 
115
120
  /** Input to a (headless) RLM run. */
@@ -26,3 +26,28 @@ export const FINALIZE_PROMPT =
26
26
  "You are out of turns. Finalize NOW: set `answer[\"content\"]` and `answer[\"ready\"] = True` " +
27
27
  "(fenced ```repl```) with your best final answer from everything you have gathered. " +
28
28
  "Only if the REPL is unavailable, answer as plain text.";
29
+
30
+ /** One-shot retrieval-discipline nudge (the engine owns the when — see core/engine.ts). The
31
+ * context is external by design, so a model that never calls search()/grep_context() is
32
+ * guessing from padding vocabulary; after two retrieval-free turns it gets this once. */
33
+ export const RETRIEVAL_NUDGE =
34
+ "[coach] You have not inspected the external context yet — it is NOT included in this " +
35
+ "chat, and guessing is useless: the text is padding. On THIS turn, call search(\"...\") " +
36
+ "or grep_context(\"...\") inside a ```repl block before answering.";
37
+
38
+ /** One-shot budget hint (engine-owned, turn 0 only): reasoning tokens share the completion
39
+ * budget with the answer, mirroring the bench's doubling rule — a reasoning root with a
40
+ * small output cap risks truncated thought. Advisory only; never fatal. */
41
+ export const REASONING_BUDGET_HINT =
42
+ "[budget] Reasoning is on while the root output cap is below 8192 tokens: thinking shares " +
43
+ "the completion budget with the answer, so long thought may be cut off mid-reasoning. " +
44
+ "Keep thought concise, or raise rootSampling.maxTokens.";
45
+
46
+ /** One-shot verification-discipline nudge (default OFF — enableVerificationNudge): fired when
47
+ * the root finalizes suspiciously early with a bare number / short label. The model gets ONE
48
+ * coached redo instead of having the answer accepted. */
49
+ export const VERIFICATION_NUDGE =
50
+ "[coach] That answer was submitted suspiciously early and looks under-verified. Before " +
51
+ "finalizing: recompute the key quantity inside a ```repl block (show the actual computation, " +
52
+ "not a restatement), sanity-check it against the source material, and only then set " +
53
+ "answer[\"content\"] again.";
@@ -36,6 +36,30 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
36
36
  return chunks
37
37
 
38
38
 
39
+ def _snippet_window(text: str, terms: set[str]) -> str:
40
+ """Slice `text` around the earliest occurrence of any query term, capped at _SNIPPET_CHARS.
41
+
42
+ BM25 finds the right window chunk; the snippet must show the match, not the chunk head.
43
+ Clipped edges get "..." markers, which count toward the cap (the body is trimmed to fit).
44
+ Falls back to the chunk head when no term occurs (tokenize/camelCase mismatches).
45
+ """
46
+ lowered = text.lower()
47
+ hits = [p for p in (lowered.find(t) for t in terms) if p >= 0]
48
+ if not hits:
49
+ return text[:_SNIPPET_CHARS]
50
+ start = max(0, min(hits) - _SNIPPET_LEAD)
51
+ end = min(len(text), start + _SNIPPET_CHARS)
52
+ lead = "..." if start > 0 else ""
53
+ trail = "..." if end < len(text) else ""
54
+ body = text[start:end]
55
+ if len(body) > _SNIPPET_CHARS - len(lead) - len(trail):
56
+ body = body[:_SNIPPET_CHARS - len(lead) - len(trail)]
57
+ if start + len(body) < len(text):
58
+ trail = "..." # trimming pulled the window edge back inside the chunk
59
+ body = body[:_SNIPPET_CHARS - len(lead) - len(trail)]
60
+ return lead + body + trail
61
+
62
+
39
63
  # ---- deterministic retrieval over `context` -----------------------------------------------
40
64
  #
41
65
  # The RLM paper's trajectories retrieve by having the root model hand-write regex over the
@@ -48,6 +72,7 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
48
72
  _INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
49
73
  _INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge add_context() cannot exhaust worker memory
50
74
  _SNIPPET_CHARS = 400
75
+ _SNIPPET_LEAD = 100 # chars of lead-in kept before the earliest matched term
51
76
  _GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
52
77
  _BM25_K1 = 1.2
53
78
  _BM25_B = 0.75
@@ -165,9 +190,10 @@ class _Bm25Index:
165
190
  return []
166
191
  top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
167
192
  out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
193
+ term_set = set(terms)
168
194
  for i, (idx, score) in enumerate(top):
169
195
  text = self.texts[idx]
170
- snip = text[:_SNIPPET_CHARS]
196
+ snip = _snippet_window(text, term_set)
171
197
  # Both `snippet` and `text` so agents never KeyError mixing search vs grep shapes.
172
198
  out[i] = {
173
199
  "path": self.paths[idx],
@@ -209,7 +235,10 @@ def grep_context(
209
235
  `counts` is complete even when `hits` is capped.
210
236
  """
211
237
  try:
212
- rx = re.compile(pattern)
238
+ # MULTILINE: the doc-level gate below must not veto line-anchored patterns (^/$)
239
+ # without it, `^foo` on a multi-line doc never matches outside position 0 and every
240
+ # line hit is silently filtered out (grep is line-oriented; match that).
241
+ rx = re.compile(pattern, re.MULTILINE)
213
242
  except re.error as e:
214
243
  return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
215
244
  try:
@@ -22,6 +22,7 @@ awaited in a LATER exec than the one that started it.
22
22
  from __future__ import annotations
23
23
 
24
24
  import argparse
25
+ import ast
25
26
  import io
26
27
  import json
27
28
  import os
@@ -61,7 +62,15 @@ class _AnswerDict(dict):
61
62
  def __setitem__(self, key, value):
62
63
  super().__setitem__(key, value)
63
64
  if key == "ready" and value:
64
- self._on_ready(self.get("content", ""))
65
+ content = self.get("content", "")
66
+ # An empty ready-flip must not capture "": defer it — a later non-blank content
67
+ # assignment while ready stays True (branch below) is the real submission.
68
+ if str(content).strip():
69
+ self._on_ready(content)
70
+ elif key == "content" and self.get("ready"):
71
+ content = str(value)
72
+ if content.strip():
73
+ self._on_ready(content)
65
74
 
66
75
 
67
76
 
@@ -151,7 +160,7 @@ class Worker(WorkerScaffold):
151
160
  if isinstance(cur, dict):
152
161
  for k, v in cur.items():
153
162
  dict.__setitem__(ans, k, v)
154
- if cur.get("ready") and self._final_answer is None:
163
+ if cur.get("ready") and self._final_answer is None and str(cur.get("content", "")).strip():
155
164
  self._final_answer = str(cur.get("content", ""))
156
165
  ns["answer"] = ans
157
166
  # Single context variable (RLM paper: the context lives in the environment and
@@ -295,8 +304,25 @@ class Worker(WorkerScaffold):
295
304
 
296
305
  def _exec(self, code: str, ns: dict[str, Any]) -> None:
297
306
  t = self.exec_timeout_s
307
+ # Jupyter-style auto-echo: plain exec() discards a trailing bare expression's value
308
+ # (models write `search("...")` and never see the hits), so split the block: exec the
309
+ # head, eval the tail, and repr() the value when it is not None (None stays silent).
310
+ tree = ast.parse(code)
311
+ tail = tree.body[-1] if tree.body else None
312
+ tail_is_expr = isinstance(tail, ast.Expr)
313
+
314
+ def _run() -> None:
315
+ if tail_is_expr and tail is not None:
316
+ head = ast.Module(body=tree.body[:-1], type_ignores=[])
317
+ exec(compile(head, "<repl>", "exec"), ns, ns) # noqa: S102
318
+ value = eval(compile(ast.Expression(tail.value), "<repl>", "eval"), ns, ns) # noqa: S102
319
+ if value is not None:
320
+ print(repr(value))
321
+ else:
322
+ exec(compile(tree, "<repl>", "exec"), ns, ns) # noqa: S102
323
+
298
324
  if t <= 0 or not hasattr(signal, "SIGALRM"):
299
- exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
325
+ _run()
300
326
  return
301
327
 
302
328
  def _alarm(signum, frame): # noqa: ARG001
@@ -305,7 +331,7 @@ class Worker(WorkerScaffold):
305
331
  old = signal.signal(signal.SIGALRM, _alarm)
306
332
  signal.setitimer(signal.ITIMER_REAL, t)
307
333
  try:
308
- exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
334
+ _run()
309
335
  finally:
310
336
  signal.setitimer(signal.ITIMER_REAL, 0)
311
337
  signal.signal(signal.SIGALRM, old)
@@ -344,6 +344,8 @@ export class PythonSandbox {
344
344
  frame: msg.type,
345
345
  id: "id" in msg ? msg.id : undefined,
346
346
  rid: "rid" in msg ? msg.rid : undefined,
347
+ // Post-mortem needs the payload, not just the shape (mirrors repl-tool's 400-char cap).
348
+ ...(msg.type === "exec" ? { chars: msg.code.length, code: msg.code.slice(0, 400) } : {}),
347
349
  });
348
350
  }
349
351
  // Never write to a corpse. The write would fail asynchronously and, historically, take the
@@ -423,7 +425,18 @@ export class PythonSandbox {
423
425
  prompts: "prompts" in msg ? msg.prompts?.length : 1,
424
426
  });
425
427
  } else {
426
- trace("frame.in", { frame: "response", id: msg.id, ok: msg.ok });
428
+ trace("frame.in", {
429
+ frame: "response",
430
+ id: msg.id,
431
+ ok: msg.ok,
432
+ ...(msg.ok
433
+ ? {
434
+ stdout: msg.stdout?.slice(0, 300),
435
+ finalAnswer: msg.final_answer ?? undefined,
436
+ vars: msg.var_names?.length,
437
+ }
438
+ : { error: msg.error?.slice(0, 200) }),
439
+ });
427
440
  }
428
441
  }
429
442
  if (isInterrupt(msg)) {
@@ -2,23 +2,44 @@
2
2
  * Parsing helpers: extract ```repl``` code blocks from a model response.
3
3
  *
4
4
  * The RLM root model emits Python wrapped in fenced blocks tagged `repl`. We extract those
5
- * blocks in order; everything else is prose the model uses to think out loud.
5
+ * blocks in order; everything else is prose the model uses to think out loud. Small instruct
6
+ * models often finalize inside ```python / ```py fences (or bare ones) instead; when a response
7
+ * contains no `repl` block at all, those code-ish fences are used as a fallback so the engine
8
+ * still executes their code. Other language tags (```text, ```json, ...) are never executed —
9
+ * the sandbox only runs Python.
6
10
  */
7
11
 
8
12
  const FENCE = /(`{3,})[ \t]*repl[ \t]*\r?\n([\s\S]*?)\1/g;
13
+ // The info string is captured so a rejected tag (e.g. ```text) still consumes its whole fence —
14
+ // otherwise that fence's own closing ``` could later match as a bare opener and swallow code.
15
+ const FALLBACK_FENCE = /(`{3,})[ \t]*([^`\r\n]*)[ \t]*\r?\n([\s\S]*?)\1/g;
16
+ const PYTHON_TAG = /^py(thon)?$/i;
9
17
 
10
- /** Return every ```repl``` block body, in document order. */
11
- export function findReplBlocks(text: string): string[] {
18
+ /** Shared fence scan: run `re` over `text`, keep bodies the selector accepts (same trimming). */
19
+ function collectFences(text: string, re: RegExp, select: (m: RegExpExecArray) => string | null): string[] {
12
20
  const blocks: string[] = [];
13
21
  let m: RegExpExecArray | null;
14
- FENCE.lastIndex = 0;
15
- while ((m = FENCE.exec(text)) !== null) {
16
- const code = m[2] ?? "";
17
- if (code.trim()) blocks.push(code.replace(/\s+$/, ""));
22
+ re.lastIndex = 0;
23
+ while ((m = re.exec(text)) !== null) {
24
+ const code = select(m);
25
+ if (code !== null && code.trim()) blocks.push(code.replace(/\s+$/, ""));
18
26
  }
19
27
  return blocks;
20
28
  }
21
29
 
30
+ /**
31
+ * Return every ```repl``` block body, in document order. If the response has none, fall back to
32
+ * ```python / ```py / untagged fences — never other language tags, and never a mix of both kinds.
33
+ */
34
+ export function findReplBlocks(text: string): string[] {
35
+ const repl = collectFences(text, FENCE, (m) => m[2] ?? "");
36
+ if (repl.length > 0) return repl;
37
+ return collectFences(text, FALLBACK_FENCE, (m) => {
38
+ const tag = m[2] ?? "";
39
+ return tag === "" || PYTHON_TAG.test(tag) ? (m[3] ?? "") : null;
40
+ });
41
+ }
42
+
22
43
  /** Truncate REPL stdout for the model's context window (head + tail, with an elision note). */
23
44
  export function truncateOutput(text: string, limit = 20_000): string {
24
45
  if (text.length <= limit) return text;
@@ -267,6 +267,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
267
267
  return modelRef(m) ?? m.id;
268
268
  },
269
269
  rootTokens: () => store.getRootUsage().tokens,
270
+ rootTokensIn: () => store.getRootUsage().tokensIn,
271
+ rootTokensOut: () => store.getRootUsage().tokensOut,
270
272
  });
271
273
  let capturedStdout = "";
272
274
  let capturedStderr = "";
@@ -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);
58
+ this.store.addRootUsage(event.costUsd, event.tokens, event.tokensIn, event.tokensOut);
59
59
  this.notify();
60
60
  }
61
61
 
@@ -33,6 +33,9 @@ export interface RlmSubcall {
33
33
  readonly endedAt?: number;
34
34
  readonly costUsd: number;
35
35
  readonly tokens: number;
36
+ /** In/out split (input / output) — mirrors tokens. */
37
+ readonly tokensIn: number;
38
+ readonly tokensOut: number;
36
39
  /** For batch subcalls: failed prompt count (partial failure). */
37
40
  readonly failedCount?: number;
38
41
  /** For batch subcalls: total prompt count. */
@@ -46,7 +49,7 @@ export interface RlmDetails {
46
49
  readonly rootPrompt: string;
47
50
  readonly turns: { readonly current: number; readonly max: number };
48
51
  readonly subcalls: readonly RlmSubcall[];
49
- readonly totals: { readonly costUsd: number; readonly tokens: number };
52
+ readonly totals: { readonly costUsd: number; readonly tokens: number; readonly tokensIn: number; readonly tokensOut: number };
50
53
  readonly answer?: string;
51
54
  }
52
55
 
@@ -41,6 +41,9 @@ export interface SubcallUpdatedEvent {
41
41
  readonly costUsd?: number;
42
42
  /** Delta — additive on both the subcall and running totals. */
43
43
  readonly tokens?: number;
44
+ /** Deltas for the in/out split shown in the tree (input / output). Additive like tokens. */
45
+ readonly tokensIn?: number;
46
+ readonly tokensOut?: number;
44
47
  /** For batch subcalls: failed prompt count. */
45
48
  readonly failedCount?: number;
46
49
  /** For batch subcalls: total prompt count. */
@@ -55,6 +58,8 @@ export interface TurnEvent {
55
58
  export interface RootUsageEvent {
56
59
  readonly costUsd: number;
57
60
  readonly tokens: number;
61
+ readonly tokensIn?: number;
62
+ readonly tokensOut?: number;
58
63
  }
59
64
 
60
65
  export interface AnswerEvent {
@@ -114,8 +119,8 @@ export class RlmEmitter {
114
119
  }
115
120
 
116
121
  /** Accumulate usage directly to root-level totals. */
117
- emitRootUsage(costUsd: number, tokens: number): void {
118
- this.ee.emit("root-usage", { costUsd, tokens } satisfies RootUsageEvent);
122
+ emitRootUsage(costUsd: number, tokens: number, tokensIn?: number, tokensOut?: number): void {
123
+ this.ee.emit("root-usage", { costUsd, tokens, tokensIn, tokensOut } satisfies RootUsageEvent);
119
124
  }
120
125
 
121
126
  /** Set the final answer text (root-only). */
@@ -53,7 +53,7 @@ export function createRlmTool(controller: RlmController, runRegistry?: RunRegist
53
53
  rootPrompt: "",
54
54
  turns: { current: 0, max: 0 },
55
55
  subcalls: [],
56
- totals: { costUsd: 0, tokens: 0 },
56
+ totals: { costUsd: 0, tokens: 0, tokensIn: 0, tokensOut: 0 },
57
57
  }));
58
58
  if (!validation.ok) return validation.error;
59
59
  const params = validation.value;
@@ -79,6 +79,8 @@ export function createRlmTool(controller: RlmController, runRegistry?: RunRegist
79
79
  return m === undefined ? undefined : modelRef(m) ?? m.id;
80
80
  },
81
81
  rootTokens: () => aggregator.getRootUsage().tokens,
82
+ rootTokensIn: () => aggregator.getRootUsage().tokensIn,
83
+ rootTokensOut: () => aggregator.getRootUsage().tokensOut,
82
84
  });
83
85
 
84
86
  // Wire abort signal to controller
@@ -9,7 +9,7 @@
9
9
  import { Text } from "@earendil-works/pi-tui";
10
10
  import { keyText } from "@earendil-works/pi-coding-agent";
11
11
  import type { SubcallStatus } from "./rlm-details.ts";
12
- import { formatTokens, spinnerFrame } from "../ui/theme.ts";
12
+ import { formatTokens, formatTokensSplit, spinnerFrame } from "../ui/theme.ts";
13
13
  import type { Theme } from "@earendil-works/pi-coding-agent";
14
14
 
15
15
  // ── Glyphs ──
@@ -25,15 +25,21 @@ export function headlineStatusGlyph(status: SubcallStatus | "aborted" | "done",
25
25
 
26
26
  // ── Stats formatting ──
27
27
 
28
- /** The `4.2k tok · 812ms` run of a card header. Omits any zero component. */
28
+ /** The `4.2k tok · 812ms` run of a card header. In/out split when known; omits zero parts. */
29
29
  export function cardStatsLine(
30
- totals: { readonly tokens: number },
30
+ totals: { readonly tokens: number; readonly tokensIn?: number; readonly tokensOut?: number },
31
31
  theme: Theme,
32
32
  extra?: string,
33
33
  backgroundPending?: number,
34
34
  ): string {
35
35
  const parts: string[] = [];
36
- if (totals.tokens > 0) parts.push(`${formatTokens(totals.tokens)} tok`);
36
+ if (totals.tokens > 0) {
37
+ parts.push(
38
+ totals.tokensOut !== undefined && totals.tokensOut > 0
39
+ ? formatTokensSplit(totals.tokensIn ?? totals.tokens, totals.tokensOut)
40
+ : `${formatTokens(totals.tokens)} tok`,
41
+ );
42
+ }
37
43
  if (extra) parts.push(extra);
38
44
  const line = theme.fg("dim", parts.join(" · "));
39
45
  // The one thing no single line can show: spawned work that may outlive this block.