@hicaru/pi-rlm 0.1.9 → 0.2.0

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.
@@ -37,13 +37,14 @@ export function formatReplOutputs(results: readonly ReplResult[], skippedBlocks
37
37
  return "No ```repl``` block found in your response. Write one to interact with the REPL.";
38
38
  }
39
39
  const multi = results.length > 1;
40
- const parts: string[] = [];
40
+ const parts = new Array<string>(results.length);
41
41
  let hadElision = false;
42
- for (const [i, r] of results.entries()) {
42
+ for (let i = 0; i < results.length; i++) {
43
+ const r = results[i];
43
44
  const head = multi ? `[block ${i + 1}]\n` : "";
44
45
  const { text, elided } = formatStdout(r);
45
46
  hadElision ||= elided;
46
- parts.push(`${head}${text}${formatStderr(r)}`);
47
+ parts[i] = `${head}${text}${formatStderr(r)}`;
47
48
  }
48
49
  const body = parts.join("\n\n");
49
50
  const skipNote = skippedBlocks > 0
@@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process";
6
6
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import type { Result } from "../util/errors.ts";
9
+ import { errorMessage } from "../util/errors.ts";
9
10
 
10
11
  export const ARTIFACTS_DIR = ".rlm/artifacts";
11
12
 
@@ -59,7 +60,7 @@ export function captureGoal(cwd: string, brief: string): GoalCaptureResult {
59
60
  writeFileSync(join(cwd, baselinePath), JSON.stringify({ paths }, null, 2), "utf-8");
60
61
  return { ok: true, value: { goalPath, baselinePath } };
61
62
  } catch (err) {
62
- const message = err instanceof Error ? err.message : String(err);
63
+ const message = errorMessage(err);
63
64
  return { ok: false, error: message };
64
65
  }
65
66
  }
@@ -72,7 +73,7 @@ export function saveArtifact(cwd: string, dir: string, slug: string, content: st
72
73
  writeFileSync(join(cwd, rel), content, "utf-8");
73
74
  return { ok: true, path: rel };
74
75
  } catch (err) {
75
- const message = err instanceof Error ? err.message : String(err);
76
+ const message = errorMessage(err);
76
77
  return { ok: false, error: message };
77
78
  }
78
79
  }
@@ -82,7 +83,7 @@ export function readArtifact(cwd: string, relPath: string): Result<string, strin
82
83
  try {
83
84
  return { ok: true, value: readFileSync(join(cwd, relPath), "utf-8") };
84
85
  } catch (err) {
85
- const message = err instanceof Error ? err.message : String(err);
86
+ const message = errorMessage(err);
86
87
  return { ok: false, error: `could not read artifact ${relPath}: ${message}` };
87
88
  }
88
89
  }
@@ -20,31 +20,21 @@ import { mergeLibraryIntoContext } from "../context/library-context.ts";
20
20
  import { createLlmBridge } from "../bridge/llm-query.ts";
21
21
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
22
22
  import { createRlmHandlers } from "../bridge/rlm-query.ts";
23
- import { resolveModelId } from "../config/settings.ts";
23
+ import { displayModelRef, resolveModelId } from "../config/settings.ts";
24
24
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
25
25
  import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
26
26
  import { phaseGuidance } from "../prompts/phases.ts";
27
27
  import type { RlmEmitter } from "../tool/rlm-events.ts";
28
28
  import { PythonSandbox } from "../sandbox/sandbox.ts";
29
29
  import {
30
- advancePhase as validatePhaseTransition,
31
- initialPhaseState,
32
- isPhase,
33
30
  phaseGatePrompt,
34
- PHASES,
35
- reconcilePhase,
36
- routeAfterValidate,
37
- stageForArtifactKind,
38
- STAGES,
39
- type ArtifactRef,
40
31
  type Phase,
41
32
  type PhaseState,
42
- type SavedArtifact,
43
33
  type StageGateData,
44
34
  } from "./pipeline.ts";
45
- import { critiqueArtifact, formatCritique } from "./critique.ts";
35
+ import { PipelineController } from "./pipeline-handlers.ts";
46
36
  import type { ValidationGateData } from "./gates.ts";
47
- import { captureGoal, readArtifact, saveArtifact, type GoalCapture } from "./artifacts.ts";
37
+ import { captureGoal, type GoalCapture } from "./artifacts.ts";
48
38
  import { previewStdout, previewText } from "../text/preview.ts";
49
39
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
50
40
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
@@ -187,29 +177,26 @@ export function createEngine(deps: EngineDeps): RunRlm {
187
177
  });
188
178
 
189
179
  const llm = createLlmBridge({
190
- workerModel: deps.workerModel,
180
+ workerModel: () => deps.workerModel,
191
181
  registry: deps.registry,
192
- subSystem: deps.config.subSystemPrompt,
193
- maxPromptChars: deps.config.maxPromptChars,
194
- maxConcurrent: deps.config.maxConcurrentSubcalls,
195
- sampling: deps.config.subSampling,
182
+ config: () => deps.config,
196
183
  signal: deps.signal,
197
184
  onUsage: (u) => {
198
185
  limits.addUsage(u);
199
186
  deps.onUsage?.(u, "sub");
200
187
  },
201
- emitter,
202
- parentId: selfReportId,
203
- depth: input.depth,
188
+ emitter: () => emitter,
189
+ parentId: () => selfReportId,
190
+ depth: () => input.depth,
204
191
  remainingBudget,
205
192
  });
206
193
  const rlm = createRlmHandlers({
207
194
  run,
208
195
  llm,
209
- emitter,
210
- maxDepth: deps.config.maxDepth,
211
- maxConcurrent: deps.config.maxConcurrentSubcalls,
212
- parentNodeId: selfReportId,
196
+ config: () => deps.config,
197
+ modelLabel: (override) => displayModelRef(deps.registry, override, model),
198
+ emitter: () => emitter,
199
+ parentNodeId: () => selfReportId,
213
200
  remainingBudget,
214
201
  onChildUsage: (costUsd, inputTokens, outputTokens) => {
215
202
  limits.addRaw(costUsd, inputTokens, outputTokens);
@@ -220,18 +207,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
220
207
  let lastAnswer = "";
221
208
  let compactions = 0;
222
209
  let completedTurns = 0;
223
- let phaseState: PhaseState | undefined;
224
- /**
225
- * Latest save per phase: path + optional gate memo (single record so they cannot desync).
226
- * Invalidated by clearLastSaved on phase exit / loop-back.
227
- */
228
- let lastSaved: Partial<Record<Phase, SavedArtifact>> = {};
229
- /** Advisory warnings accumulated from save_artifact critiques (TUI). */
230
- let pipelineWarnings: string[] = [];
231
- /** Serviced ask_user_question rounds in the current phase (session-only; reset on transition). */
232
- let askRoundsThisPhase = 0;
233
- let pendingHistoryReset: ChatMsg[] | undefined;
234
- let goal: GoalCapture | undefined;
210
+ /** Owns all pipeline state (phase, per-phase latest save, ask rounds, pending reset). */
211
+ let pipeline: PipelineController | undefined;
235
212
  let nodeStatus: "done" | "error" = "done";
236
213
  let persistOn = persist;
237
214
  if (persist && deps.runState && !input.resume && runId) {
@@ -284,13 +261,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
284
261
  if (!ok) persistOn = false;
285
262
  };
286
263
 
287
- /** Clear lastSaved so a re-entered stage cannot re-gate with a stale artifact. */
288
- const clearLastSaved = (...phases: readonly Phase[]): void => {
289
- const next: Partial<Record<Phase, SavedArtifact>> = { ...lastSaved };
290
- for (const p of phases) delete next[p];
291
- lastSaved = next;
292
- };
293
-
294
264
  try {
295
265
  const pipelineOn = input.depth === 0 && deps.config.pipeline;
296
266
  const meta = {
@@ -309,111 +279,22 @@ export function createEngine(deps: EngineDeps): RunRlm {
309
279
  libraryLoader: deps.config.libraryLoader,
310
280
  });
311
281
 
312
- const phaseHandlers = pipelineOn
313
- ? {
314
- saveArtifact: async (kind: string, content: string): Promise<string> => {
315
- const stage = stageForArtifactKind(kind);
316
- if (stage === undefined) {
317
- return formatError(`unknown artifact kind '${kind}' (valid: clarification, research, plan, validation)`);
318
- }
319
- const current = phaseState?.current ?? PHASES[0];
320
- if (stage.phase !== current) {
321
- return formatError(
322
- `artifact kind '${kind}' belongs to phase '${stage.phase}', but the pipeline is in '${current}'`,
323
- );
324
- }
325
- const saved = saveArtifact(runCwd, stage.artifactDir, kind, content);
326
- if (!saved.ok) return formatError(saved.error);
327
- // Preflight: run the SAME gate advance_phase will run, now instead of a turn later.
328
- const critique = critiqueArtifact(stage, content, saved.path, runCwd);
329
- // One assignment: path + optional gate memo (undefined when gate failed).
330
- lastSaved = {
331
- ...lastSaved,
332
- [stage.phase]: Object.freeze({
333
- path: saved.path,
334
- gateData: critique.gateData,
335
- }),
336
- };
337
- if (critique.warnings.length > 0) {
338
- const next = new Array<string>(pipelineWarnings.length + critique.warnings.length);
339
- for (let i = 0; i < pipelineWarnings.length; i++) next[i] = pipelineWarnings[i];
340
- for (let i = 0; i < critique.warnings.length; i++) {
341
- next[pipelineWarnings.length + i] = critique.warnings[i];
342
- }
343
- pipelineWarnings = next;
344
- emitter.emitWarnings(Object.freeze([...pipelineWarnings]));
345
- }
346
- return `ok — saved ${saved.path}.\n${formatCritique(critique)}`;
347
- },
348
- advancePhase: async (phase: string, summary: string | undefined): Promise<string> => {
349
- const current = phaseState?.current ?? PHASES[0];
350
- const outcome = validatePhaseTransition(current, phase);
351
- if (!outcome.ok) return formatError(outcome.error);
352
-
353
- // Clarify interview gate: engine counts serviced ask_user_question rounds
354
- // (un-gameable — the model cannot advance without having actually asked).
355
- if (current === "clarify" && askRoundsThisPhase === 0) {
356
- return formatError(
357
- "clarify requires at least one ask_user_question round — interview the user before advancing",
358
- );
359
- }
360
-
361
- // GATE: measure the CURRENT stage's latest save only (never fall back to
362
- // phaseState.artifacts — those are completed-channel paths and may be stale
363
- // across a corrective loop). Prefer the memo filled by save_artifact.
364
- const stage = STAGES[current];
365
- const savedEntry = lastSaved[current];
366
- const artifactPath = savedEntry?.path;
367
- if (stage.artifactDir !== "" && artifactPath === undefined) {
368
- return formatError(
369
- `phase '${current}' has no saved artifact — call save_artifact("${stage.artifactKind}", content) first`,
370
- );
371
- }
372
- let gateData: StageGateData | undefined;
373
- if (stage.artifactDir !== "" && artifactPath !== undefined) {
374
- if (savedEntry?.gateData !== undefined) {
375
- gateData = savedEntry.gateData;
376
- } else {
377
- const content = readArtifact(runCwd, artifactPath);
378
- if (!content.ok) return formatError(content.error);
379
- const gate = stage.gate(content.value, artifactPath, runCwd);
380
- if (!gate.ok) return formatError(gate.error);
381
- gateData = gate.value;
382
- lastSaved = {
383
- ...lastSaved,
384
- [current]: Object.freeze({ path: artifactPath, gateData }),
385
- };
386
- }
387
- }
388
-
389
- // Transition accepted: persist row, schedule root history reset (fresh session).
390
- const prevArtifacts = phaseState?.artifacts ?? {};
391
- const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = { ...prevArtifacts };
392
- if (artifactPath !== undefined) {
393
- nextArtifacts[current] = Object.freeze({ path: artifactPath, status: "active" });
394
- }
395
- phaseState = {
396
- current: outcome.phase,
397
- advancedAt: completedTurns,
398
- summary,
399
- artifacts: nextArtifacts,
400
- backwardJumps: phaseState?.backwardJumps ?? 0,
401
- };
402
- await persistPhaseRow(phaseState, artifactPath, artifactPath !== undefined ? current : undefined, gateData);
403
- clearLastSaved(current);
404
- askRoundsThisPhase = 0;
405
- pendingHistoryReset = resetHistoryForPhase(system, phaseState, { goal });
406
- return `ok — phase advanced to '${outcome.phase}' (was '${current}'${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
407
- },
408
- }
409
- : {};
282
+ pipeline = new PipelineController({
283
+ runCwd,
284
+ maxBackwardJumps: deps.config.maxBackwardJumps,
285
+ emitter,
286
+ completedTurns: () => completedTurns,
287
+ resetHistoryForPhase: (state, options) => resetHistoryForPhase(system, state, options),
288
+ persistPhaseRow,
289
+ });
290
+ const phaseHandlers = pipelineOn ? pipeline.handlers() : {};
410
291
  const baseAsk = deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined;
411
292
  const interactiveHandlers = buildInteractiveHandlers({
412
293
  onAskUserQuestion: baseAsk
413
294
  ? async (questions) => {
414
295
  const answers = await baseAsk(questions);
415
296
  // Count only successfully serviced root-depth rounds (handler already rejects depth>0).
416
- askRoundsThisPhase++;
297
+ pipeline?.noteAskRound();
417
298
  return answers;
418
299
  }
419
300
  : undefined,
@@ -484,43 +365,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
484
365
  best = input.resume.best;
485
366
  compactions = input.resume.compactions;
486
367
  completedTurns = input.resume.completedTurns;
487
- if (input.resume.phase) {
488
- const resumePhase = input.resume.phase;
489
- const artifacts: Partial<Record<Phase, ArtifactRef>> = {};
490
- if (resumePhase.artifacts) {
491
- for (const [k, v] of Object.entries(resumePhase.artifacts)) {
492
- if (v !== undefined && isPhase(k)) {
493
- artifacts[k] = Object.freeze({
494
- path: v.path,
495
- status: v.superseded ? "superseded" as const : "active" as const,
496
- });
497
- }
498
- }
499
- }
500
- phaseState = {
501
- current: reconcilePhase(resumePhase.current),
502
- advancedAt: resumePhase.advancedAt,
503
- summary: resumePhase.summary,
504
- artifacts,
505
- backwardJumps: resumePhase.backwardJumps ?? 0,
506
- };
507
- // lastSaved is session-only: never rehydrate from trail (would re-gate stale
508
- // plan/validation after loop-back / mid-stage resume without a fresh save).
509
- lastSaved = {};
510
- // askRoundsThisPhase is session-only (like lastSaved): a resume mid-clarify
511
- // restarts the interview count so the model must ask again in this process.
512
- askRoundsThisPhase = 0;
513
- }
368
+ if (input.resume.phase) pipeline.seedFromResume(input.resume.phase);
514
369
  } else if (pipelineOn) {
515
370
  // Goal capture (script, no LLM) + seed phase state + fresh history.
516
371
  const captured = captureGoal(runCwd, input.rootPrompt);
517
- let goalNotice: string | undefined;
518
- if (captured.ok) {
519
- goal = captured.value;
520
- } else {
521
- // Fail-soft: fold into the first reset message (never console — corrupts TUI).
522
- goalNotice = `Note: goal artifact could not be written (${captured.error}); the brief remains only in the system prompt.`;
523
- }
372
+ // Fail-soft: fold the failure into the first reset message (never console — corrupts TUI).
373
+ const goalNotice = captured.ok
374
+ ? undefined
375
+ : `Note: goal artifact could not be written (${captured.error}); the brief remains only in the system prompt.`;
524
376
  // Clarify only when interviews are enabled AND the host wired a callback.
525
377
  // Config alone is not enough: without onAskUserQuestion every ask throws and
526
378
  // the run would burn maxIterations stuck at clarify (askRounds stays 0).
@@ -528,8 +380,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
528
380
  deps.config.askUserQuestion && deps.onAskUserQuestion !== undefined
529
381
  ? "clarify"
530
382
  : "research";
531
- phaseState = initialPhaseState(0, startPhase);
532
- history = resetHistoryForPhase(system, phaseState, { goal, notice: goalNotice });
383
+ history = pipeline.seedFresh(startPhase, captured.ok ? captured.value : undefined, goalNotice);
533
384
  }
534
385
 
535
386
  // Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
@@ -550,9 +401,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
550
401
  else emitter.emitTurn(i + 1, deps.config.maxIterations);
551
402
 
552
403
  // Apply deferred history reset from a prior advance_phase (fresh session policy).
553
- if (pendingHistoryReset !== undefined) {
554
- history = pendingHistoryReset;
555
- pendingHistoryReset = undefined;
404
+ const scheduledReset = pipeline.takePendingReset();
405
+ if (scheduledReset !== undefined) {
406
+ history = scheduledReset;
556
407
  pendingReplOutputs = undefined;
557
408
  }
558
409
 
@@ -588,7 +439,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
588
439
  pendingReplOutputs = undefined;
589
440
  }
590
441
 
591
- const gateMsg = deps.config.pipeline ? phaseGatePrompt(phaseState, completedTurns) : undefined;
442
+ const gateMsg = deps.config.pipeline ? phaseGatePrompt(pipeline.phase, completedTurns) : undefined;
592
443
  const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
593
444
  // Phase guidance lives only in resetHistoryForPhase (fresh session) — do not re-inject
594
445
  // into every turn prompt (avoids duplication on turn 1 and dead post-transition flags).
@@ -625,79 +476,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
625
476
  completedTurns = i + 1;
626
477
  const final = finalAnswerOf(turn.results);
627
478
  if (final != null) {
628
- // Validate-phase finalize: measure THIS turn's validation save only (lastSaved),
629
- // never fall back to phaseState.artifacts (stale after a prior loop).
630
- if (pipelineOn && phaseState?.current === "validate") {
631
- const vPath = lastSaved.validate?.path;
632
- if (vPath === undefined) {
633
- // Reject finalize — push error into next turn.
634
- history.push({ role: "assistant", content: turn.response });
635
- pendingReplOutputs = formatError(
636
- "finalize rejected — save the validation artifact first via save_artifact(\"validation\", content) with status: ready, blockers_count, and verdict",
637
- );
638
- continue;
639
- }
640
- const content = readArtifact(runCwd, vPath);
641
- if (!content.ok) {
642
- history.push({ role: "assistant", content: turn.response });
643
- pendingReplOutputs = formatError(content.error);
644
- continue;
645
- }
646
- const gate = STAGES.validate.gate(content.value, vPath, runCwd);
647
- if (!gate.ok) {
648
- history.push({ role: "assistant", content: turn.response });
649
- pendingReplOutputs = formatError(gate.error);
650
- continue;
651
- }
652
- if (gate.value.kind !== "validation") {
479
+ // Validate-phase finalize is gated: the controller measures THIS turn's validation
480
+ // save only, never phase.artifacts (stale after a prior corrective loop).
481
+ if (pipelineOn && pipeline.phase?.current === "validate") {
482
+ const outcome = await pipeline.finalizeInValidate(final);
483
+ if (outcome.kind === "reject") {
653
484
  history.push({ role: "assistant", content: turn.response });
654
- pendingReplOutputs = formatError("internal: validate gate did not return validation data");
485
+ pendingReplOutputs = outcome.error;
655
486
  continue;
656
487
  }
657
- const validation = gate.value.validation;
658
- const route = routeAfterValidate(
659
- validation,
660
- phaseState.backwardJumps,
661
- deps.config.maxBackwardJumps,
662
- );
663
- if (route.kind === "loop-back") {
664
- // Keep all prior artifacts; mark blueprint as superseded by this validation.
665
- // lastSaved is still cleared so the gate must see a genuinely FRESH plan.
666
- const prior = phaseState.artifacts.blueprint;
667
- const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = {
668
- ...phaseState.artifacts,
669
- validate: Object.freeze({ path: vPath, status: "active" }),
670
- };
671
- if (prior !== undefined) {
672
- nextArtifacts.blueprint = Object.freeze({
673
- path: prior.path,
674
- status: "superseded",
675
- supersededBy: vPath,
676
- });
677
- }
678
- phaseState = {
679
- current: "blueprint",
680
- advancedAt: completedTurns,
681
- summary: `loop-back: ${validation.blockersCount} blocker(s)`,
682
- artifacts: nextArtifacts,
683
- backwardJumps: phaseState.backwardJumps + 1,
684
- };
685
- await persistPhaseRow(phaseState, vPath, "validate", gate.value, prior?.path);
686
- clearLastSaved("blueprint", "validate");
687
- askRoundsThisPhase = 0;
688
- history = resetHistoryForPhase(system, phaseState, { goal, validation });
488
+ if (outcome.kind === "loop-back") {
489
+ history = outcome.history;
689
490
  pendingReplOutputs = undefined;
690
- pendingHistoryReset = undefined;
691
491
  continue;
692
492
  }
693
- if (route.kind === "halt") {
694
- const report = `${route.reason}\n\n${final}`;
695
- const halted = result(report, i + 1, limits);
493
+ if (outcome.kind === "halt") {
494
+ const halted = result(outcome.report, i + 1, limits);
696
495
  await recordTerminal("completed", halted);
697
496
  lastAnswer = halted.answer;
698
497
  return halted;
699
498
  }
700
- // route.kind === "done" — accept final answer
499
+ // outcome.kind === "accept" — take the model's final answer
701
500
  }
702
501
  const done = result(final, i + 1, limits);
703
502
  await recordTerminal("completed", done);
@@ -709,9 +508,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
709
508
  // Always capture this turn's REPL outputs for the JSONL trail (fidelity).
710
509
  const turnReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
711
510
  // If advance_phase scheduled a history reset, apply it now (do not pollute fresh history).
712
- if (pendingHistoryReset !== undefined) {
713
- history = pendingHistoryReset;
714
- pendingHistoryReset = undefined;
511
+ const nextReset = pipeline.takePendingReset();
512
+ if (nextReset !== undefined) {
513
+ history = nextReset;
715
514
  // Fanout/advance result is already embedded in the reset user message — do not
716
515
  // also append raw REPL stdout as a next-turn user message.
717
516
  pendingReplOutputs = undefined;
package/src/core/gates.ts CHANGED
@@ -135,7 +135,7 @@ export function sectionHasNonEmptyBody(content: string, heading: string): boolea
135
135
  const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`);
136
136
  let inSection = false;
137
137
  let seen = false; // first match wins — do not re-enter on a later duplicate heading
138
- let body = "";
138
+ let hasBody = false; // only emptiness matters — never accumulate the body itself
139
139
  forEachLineOutsideFences(content, (line) => {
140
140
  if (/^##\s+/.test(line)) {
141
141
  if (inSection) {
@@ -148,9 +148,9 @@ export function sectionHasNonEmptyBody(content: string, heading: string): boolea
148
148
  }
149
149
  return;
150
150
  }
151
- if (inSection) body += `${line}\n`;
151
+ if (inSection && line.trim().length > 0) hasBody = true;
152
152
  });
153
- return body.trim().length > 0;
153
+ return hasBody;
154
154
  }
155
155
 
156
156
  function escapeRegExp(s: string): string {
@@ -13,6 +13,24 @@ export interface Limits {
13
13
  readonly maxErrors?: number;
14
14
  }
15
15
 
16
+ /** Pick the limit caps out of a config (`RlmConfig` satisfies this structurally). */
17
+ export function limitsFromConfig(config: Limits): Limits {
18
+ return {
19
+ maxBudgetUsd: config.maxBudgetUsd,
20
+ maxTimeoutMs: config.maxTimeoutMs,
21
+ maxTokens: config.maxTokens,
22
+ maxErrors: config.maxErrors,
23
+ };
24
+ }
25
+
26
+ /** Point-in-time totals for a run. */
27
+ export interface UsageSnapshot {
28
+ readonly inputTokens: number;
29
+ readonly outputTokens: number;
30
+ readonly costUsd: number;
31
+ readonly durationMs: number;
32
+ }
33
+
16
34
  export class LimitError extends Error {
17
35
  constructor(
18
36
  public readonly kind: "timeout" | "tokens" | "budget" | "errors",
@@ -71,7 +89,7 @@ export class LimitGuard {
71
89
  }
72
90
  }
73
91
 
74
- usage() {
92
+ usage(): UsageSnapshot {
75
93
  return {
76
94
  inputTokens: this.inputTokens,
77
95
  outputTokens: this.outputTokens,