@basein/runner 0.2.0 → 0.2.2

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.
@@ -24,21 +24,29 @@
24
24
  * accepts unauthenticated step reports is a local exfiltration channel.
25
25
  */
26
26
  import { createServer } from "node:http";
27
- import { randomBytes, randomUUID } from "node:crypto";
27
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
28
28
  import { FINGERPRINT_WINDOW_MS, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
29
29
  import { StepIndexAllocator } from "./ordering.js";
30
- import { contextForToolUse, markTranscriptUsage, settledLastAssistantText, usageSince, } from "./transcript.js";
30
+ import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, usageSince, } from "./transcript.js";
31
31
  import { isHousekeeping } from "../record/housekeeping.js";
32
32
  import { redact } from "../record/redact.js";
33
33
  import { serializeCapped } from "../record/truncate.js";
34
34
  import { StepQueue } from "../record/queue.js";
35
- import { NullRecorder, isMatchAware, isScenarioReporter, } from "../record/recorder.js";
35
+ import { NullRecorder, isIntentMatcher, isMatchAware, isRunCreationAware, isScenarioReporter, } from "../record/recorder.js";
36
36
  import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
37
37
  import { calculateCostUsd } from "../replay/pricing.js";
38
38
  import { logDetail, logLine, errText } from "../util/log.js";
39
39
  import { packageVersion } from "../util/version.js";
40
40
  /** How long a `/tool/post` waits for the proxy's own report before recording its own view. */
41
41
  const PROXY_REPORT_GRACE_MS = 1_500;
42
+ /** One MCP call, as seen from up to two sides. */
43
+ /**
44
+ * Field separator for the probe's dedupe hash (segmented.md R-HIT-5). A NUL,
45
+ * so that a text ending where a tool name begins can never hash the same as the
46
+ * other way round. Built rather than written as an escape, because a literal
47
+ * NUL in source is a trap for every tool that reads the file.
48
+ */
49
+ const PROBE_SEP = String.fromCharCode(0);
42
50
  export class ControlServer {
43
51
  token;
44
52
  sessionId = "birsess_" + randomUUID();
@@ -57,6 +65,18 @@ export class ControlServer {
57
65
  pendingSeals = [];
58
66
  /** Calculated replay. Inert unless `opts.replay.enabled`. */
59
67
  replay;
68
+ /**
69
+ * How many of this prompt's tool results derivation may read
70
+ * (`BIR_DERIVE_RECENT_RESULTS`, segmented.md R-PARAM-2). A target is often
71
+ * named by an earlier step's output and never by the prompt.
72
+ */
73
+ deriveRecentResults;
74
+ /**
75
+ * Which rungs of R-INTENT-1 the runner may send (`BIR_INTENT_SOURCES`). It
76
+ * governs only what is *sent*: with every source off, the probe still goes out
77
+ * with an empty text and the server builds the tool line (R-HIT-4).
78
+ */
79
+ intentSources;
60
80
  constructor(opts) {
61
81
  this.opts = opts;
62
82
  this.recorder = opts.recorder;
@@ -64,6 +84,8 @@ export class ControlServer {
64
84
  for (const name of opts.wrappedServers ?? [])
65
85
  this.wrapped.add(name);
66
86
  this.replay = new ReplayController(opts.replay ?? { enabled: false, minSimilarity: 1 });
87
+ this.deriveRecentResults = opts.deriveRecentResults ?? 5;
88
+ this.intentSources = opts.intentSources ?? new Set(["text", "thinking", "tool"]);
67
89
  this.queue = new StepQueue({
68
90
  onDrop: (dropped) => {
69
91
  // §10: drop oldest, flag the run lossy. The flag is louder than a
@@ -138,8 +160,25 @@ export class ControlServer {
138
160
  stepsPlanned: s.run.replay.stepsPlanned,
139
161
  stepsPinned: s.run.replay.stepsPinned,
140
162
  armed: Boolean(s.run.replay.plan) && !s.run.replay.retired,
163
+ armedBy: s.run.replay.armedBy,
164
+ handover: s.run.replay.handover
165
+ ? { kind: s.run.replay.handover.kind, stepIndex: s.run.replay.handover.stepIndex }
166
+ : null,
167
+ plans: s.run.replays.length,
168
+ kind: s.run.replay.kind,
169
+ }
170
+ : null,
171
+ // Present whether or not a plan is armed (segmented.md R-LIFE-8): the
172
+ // whole point of the observe-only period is that it is visible, and
173
+ // "nothing armed" and "nothing even probed" are different problems.
174
+ intent: s.run
175
+ ? {
176
+ probes: s.run.intent.requests,
177
+ armed: s.run.intent.armed,
178
+ lastStepHit: s.run.intent.lastStepHit ?? null,
141
179
  }
142
180
  : null,
181
+ fragment: s.run?.fragment ? { state: s.run.fragment.state } : null,
143
182
  }));
144
183
  return {
145
184
  ok: true,
@@ -147,6 +186,11 @@ export class ControlServer {
147
186
  tier: "bound",
148
187
  // The authoritative answer to "is anything actually being saved?".
149
188
  recording: this.opts.recording ?? !(this.recorder instanceof NullRecorder),
189
+ // Whether a handed-out segment may actually run mid-task, or whether the
190
+ // runner is only watching (R-OUT-10, R-LIFE-8). Observe-only must never
191
+ // be invisible: an operator has to be able to see which of the two this
192
+ // machine is doing without reading a log file.
193
+ segmentArm: this.replay.segmentArm,
150
194
  authUrl: process.env.BIR_AUTH_URL ?? null,
151
195
  sessionId: this.sessionId,
152
196
  pid: process.pid,
@@ -168,6 +212,15 @@ export class ControlServer {
168
212
  minSimilarity: this.opts.replay?.minSimilarity ?? null,
169
213
  allowServers: this.opts.replay?.allowServers ? [...this.opts.replay.allowServers] : null,
170
214
  deriveKey: Boolean(this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY),
215
+ // Who reads the turn for its parameters (segmented.md R-PARAM-5). The
216
+ // ordinary answer is `service`; a key here is an override; `samples`
217
+ // means no scenario with a target can run, which is the one state an
218
+ // operator must be able to see without reading a log.
219
+ deriveVia: (this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY)
220
+ ? "key"
221
+ : this.opts.replay?.authUrl && this.opts.replay?.authToken
222
+ ? "service"
223
+ : "samples",
171
224
  pollingProxies: this.replay.work.pollingServers(),
172
225
  },
173
226
  lossy: this.lossy,
@@ -326,6 +379,9 @@ export class ControlServer {
326
379
  promptSeen: input.length > 0,
327
380
  builtIns: new Map(),
328
381
  correlations: new Map(),
382
+ replays: [],
383
+ turnId: runId,
384
+ intent: { armed: 0, requests: 0 },
329
385
  // Taken here, beside `startedAtMs`, so the cost window and the duration
330
386
  // window are the same window (docs/calculatedReplay.md §11.4).
331
387
  usageMark: markTranscriptUsage(session.transcriptPath),
@@ -334,7 +390,7 @@ export class ControlServer {
334
390
  logLine("run.start", { run: runId, sess: session.sessionId, tier: "bound" });
335
391
  // Started here so the round trip overlaps whatever the caller does next;
336
392
  // `onPrompt` awaits the same memoized promise under its own budget.
337
- void this.watchForMatch(run);
393
+ void this.watchForMatch(session, run);
338
394
  return run;
339
395
  }
340
396
  runMetadata(session) {
@@ -362,7 +418,7 @@ export class ControlServer {
362
418
  *
363
419
  * Resolves with the steering directive to inject, or undefined.
364
420
  */
365
- watchForMatch(run, prompt = "") {
421
+ watchForMatch(session, run, prompt = "") {
366
422
  if (run.matchWatch)
367
423
  return run.matchWatch;
368
424
  if (!isMatchAware(this.recorder))
@@ -370,7 +426,7 @@ export class ControlServer {
370
426
  const pending = this.recorder.getMatch(run.runId);
371
427
  run.matchWatch = this.replay
372
428
  .awaitMatch(pending)
373
- .then((match) => {
429
+ .then(async (match) => {
374
430
  if (!match)
375
431
  return undefined;
376
432
  run.recording = false;
@@ -381,8 +437,15 @@ export class ControlServer {
381
437
  similarity: match.similarity,
382
438
  why: "similar prompt — the service kept its own run; not recording this one",
383
439
  });
384
- run.replay = this.replay.arm(match, prompt || run.input, this.wrapped);
385
- return this.replay.directiveFor(run.replay);
440
+ // Awaited: a scenario with a target derives before the directive goes
441
+ // out, so a plan that cannot find its target is never delivered
442
+ // (segmented.md R-PARAM-4). Inside `UserPromptSubmit`'s 15 s.
443
+ const state = await this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
444
+ recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
445
+ });
446
+ run.replay = state;
447
+ run.replays.push(state);
448
+ return this.replay.directiveFor(state);
386
449
  })
387
450
  .catch((err) => {
388
451
  // Replay is an optimisation; a broken one must never cost a turn.
@@ -423,7 +486,12 @@ export class ControlServer {
423
486
  // A matched turn records nothing, but it still owes the ledger a number —
424
487
  // including when it declined to steer, which is a *baseline* sample and is
425
488
  // what every saving is measured against (docs/calculatedReplay.md §11).
426
- this.reportExecution(run, durationMs, cost);
489
+ this.reportExecution(session, run, durationMs, cost);
490
+ // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
491
+ // and duration are the model's own work, not the steps the scenario did.
492
+ const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
493
+ const recordedCost = fragment ? this.costSince(session, fragment.usageMark) : cost;
494
+ const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
427
495
  if (run.recording) {
428
496
  const transcriptPath = session.transcriptPath;
429
497
  // Resolve the answer *outside* the queue: the wait below is up to two
@@ -442,8 +510,8 @@ export class ControlServer {
442
510
  // scenario that has no samples yet. A run recorded at $0 makes
443
511
  // every later saving measured against nothing.
444
512
  this.recorder.finishRun(run.runId, answer || undefined, {
445
- durationMs,
446
- costUsd: cost.measured ? cost.usd : undefined,
513
+ durationMs: recordedMs,
514
+ costUsd: recordedCost.measured ? recordedCost.usd : undefined,
447
515
  });
448
516
  });
449
517
  }));
@@ -486,24 +554,66 @@ export class ControlServer {
486
554
  });
487
555
  return { usd: 0, measured: false };
488
556
  }
489
- const deltas = usageSince(session.transcriptPath, run.usageMark);
557
+ return this.costSince(session, run.usageMark);
558
+ }
559
+ /** Transcript cost since `mark`; unmeasured (and zero) without one. */
560
+ costSince(session, mark) {
561
+ if (mark === undefined)
562
+ return { usd: 0, measured: false };
563
+ const deltas = usageSince(session.transcriptPath, mark);
490
564
  const usd = deltas.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
491
565
  return { usd, measured: deltas.length > 0 };
492
566
  }
493
- reportExecution(run, durationMs, cost) {
494
- const state = run.replay;
495
- if (!state || !isScenarioReporter(this.recorder))
496
- return;
497
- const report = this.replay.buildReport(state, {
498
- sessionCostUsd: cost.usd,
499
- measured: cost.measured,
500
- durationMs,
501
- prompt: run.input || undefined,
502
- });
503
- if (!report)
567
+ reportExecution(session, run, durationMs, cost) {
568
+ if (!isScenarioReporter(this.recorder))
504
569
  return;
570
+ const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
505
571
  const recorder = this.recorder;
506
- this.queue.push(() => recorder.reportExecution(report));
572
+ // One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
573
+ // for its own window of the transcript — from when it armed to when the next
574
+ // one did — so the turn's tokens are split between them, never counted twice.
575
+ // A turn with a single prompt-armed plan is exactly the report it always was.
576
+ states.forEach((state, i) => {
577
+ const next = states[i + 1];
578
+ const nextMark = next?.armedBy === "intent" ? next.usageMark : undefined;
579
+ let windowCost = cost;
580
+ let windowMs = durationMs;
581
+ // An intent-armed plan's window *ends* where its work ended (segmented.md
582
+ // R-MONEY-3): at its own retire mark when it completed, else at the next
583
+ // plan's arm, else at the end of the turn. Without the first, a segment
584
+ // that finished in three steps would be billed for everything the agent
585
+ // did afterwards, and its saving would read as a loss.
586
+ let end = nextMark;
587
+ if (state.armedBy === "intent") {
588
+ windowCost = this.costSince(session, state.usageMark);
589
+ windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
590
+ end = state.retiredMark ?? nextMark;
591
+ }
592
+ else if (next) {
593
+ windowMs = next.armedAt - run.startedAtMs;
594
+ }
595
+ if (end !== undefined) {
596
+ const later = this.costSince(session, end);
597
+ windowCost = { usd: Math.max(0, windowCost.usd - later.usd), measured: windowCost.measured };
598
+ }
599
+ const report = this.replay.buildReport(state, {
600
+ sessionCostUsd: windowCost.usd,
601
+ measured: windowCost.measured,
602
+ durationMs: Math.max(0, windowMs),
603
+ prompt: run.input || undefined,
604
+ siblings: states,
605
+ });
606
+ if (!report)
607
+ return;
608
+ if (state.armedBy === "intent") {
609
+ logDetail("replay.done", {
610
+ scenario: state.scenarioId ?? undefined,
611
+ windowMs: Math.max(0, windowMs),
612
+ billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
613
+ });
614
+ }
615
+ this.queue.push(() => recorder.reportExecution(report));
616
+ });
507
617
  }
508
618
  /** Seal the run *and* wait for everything queued to reach the service. */
509
619
  async finalizeRun(session) {
@@ -569,7 +679,7 @@ export class ControlServer {
569
679
  // a slow or unreachable service costs the user nothing but an ordinary turn
570
680
  // (docs/calculatedReplay.md §10); `additionalContext` carries no `decision`,
571
681
  // so the prompt still reaches the model either way.
572
- const directive = await this.watchForMatch(run, input);
682
+ const directive = await this.watchForMatch(session, run, input);
573
683
  if (!directive)
574
684
  return {};
575
685
  return {
@@ -605,36 +715,49 @@ export class ControlServer {
605
715
  return {};
606
716
  }
607
717
  const agentId = payload.agent_id ?? session.agentId;
608
- const reasoning = contextForToolUse(session.transcriptPath, toolUseId);
718
+ // The real intent, read from every line sharing this message's id
719
+ // (segmented.md R-INTENT-3). `context` stays the *written* text only, with
720
+ // its agent prefix: thinking is embedded but never stored as text
721
+ // (R-INTENT-2), so it rides separately in `intent`.
722
+ const intent = intentForToolUse(session.transcriptPath, toolUseId, this.intentSources);
723
+ const reasoning = intent?.source === "text" ? intent.text : "";
724
+ // The prefix rides on whether there is a subagent, not on whether it said
725
+ // anything: a subagent's steps must be tellable apart even when it narrated
726
+ // nothing. What it never carries is thinking (R-INTENT-2).
609
727
  const context = agentId ? `[agent ${agentId}] ${reasoning}`.trim() : reasoning;
728
+ const thinkingIntent = intent?.source === "thinking" ? { text: intent.text, source: "thinking" } : undefined;
610
729
  // ── replay steering (docs/calculatedReplay.md §7) ────────────────────────
611
730
  // Runs before anything else, because two of its four answers end the call.
612
731
  let pinned;
613
732
  if (run.replay?.plan && !run.replay.retired) {
614
733
  const action = await this.replay.preTool(run.replay, toolName, toolUseId);
615
- switch (action.kind) {
616
- case "pin":
617
- pinned = action.input;
618
- break;
619
- case "bash":
620
- return {
621
- hookSpecificOutput: {
622
- hookEventName: "PreToolUse",
623
- permissionDecision: "allow",
624
- updatedInput: { command: action.command },
625
- },
626
- };
627
- case "deny":
628
- return {
629
- hookSpecificOutput: {
630
- hookEventName: "PreToolUse",
631
- permissionDecision: "deny",
632
- permissionDecisionReason: action.reason,
633
- },
634
- };
635
- case "abort":
636
- case "passthrough":
637
- break;
734
+ // A hand-over inside `preTool` (an input logic that threw) must still
735
+ // schedule the fragment, even though this call ends here.
736
+ this.noteHandover(run);
737
+ if (action.kind === "pin")
738
+ pinned = action.input;
739
+ const answer = this.preToolAnswer(action);
740
+ if (answer)
741
+ return answer;
742
+ }
743
+ if (!pinned && (!run.replay?.plan || run.replay.retired)) {
744
+ // ── falling back to the model (fallbk.md) ──────────────────────────────
745
+ // 1. A hand-over noticed where no hook answer could carry it — a proxy
746
+ // report threaded after its PostToolUse had already answered — reaches
747
+ // the model in place of this call. Never silence (D4).
748
+ const note = this.replay.takeNote(run.replay);
749
+ if (note) {
750
+ this.noteHandover(run);
751
+ return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
752
+ }
753
+ // 2. The model is driving: look for a scenario by this iteration's intent.
754
+ const armed = await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
755
+ if (armed)
756
+ return armed;
757
+ // 3. No scenario took over: the model's own work after a hand-over is
758
+ // recorded as a fragment, starting with this call.
759
+ if (run.fragment?.state === "due") {
760
+ await this.openFragment(session, run, reasoning, toolName, modelArgs);
638
761
  }
639
762
  }
640
763
  // The arguments that will actually run — which is what must be correlated and
@@ -654,6 +777,11 @@ export class ControlServer {
654
777
  createdAt: Date.now(),
655
778
  settled: false,
656
779
  fingerprint: fingerprint(mcp.serverName, mcp.toolName, args),
780
+ // Captured here because this is the last moment the code knows: by the
781
+ // time `settle` records the step, `postTool` has consumed the pin
782
+ // (segmented.md R-INTENT-7).
783
+ pinnedBy: pinned ? (run.replay?.scenarioId ?? undefined) : undefined,
784
+ intent: thinkingIntent,
657
785
  };
658
786
  run.correlations.set(correlation.callId, correlation);
659
787
  logDetail("tool.pre.correlate", {
@@ -699,16 +827,26 @@ export class ControlServer {
699
827
  responseIndex: pair.response,
700
828
  toolName,
701
829
  });
830
+ // A step a plan executed is pinned and never embedded (R-INTENT-7), so the
831
+ // system's own replays never look like the work recurring. Two paths reach
832
+ // here: a built-in step the plan pinned, and the direct plan's own vehicle
833
+ // `mcp__bir__run_scenario`, which is recorded as a built-in while its plan
834
+ // is live and is just as much the system's own doing.
835
+ const pinnedBy = pinned || (this.replay.isDirectTool(toolName) && run.replay?.plan && !run.replay.retired)
836
+ ? (run.replay?.scenarioId ?? undefined)
837
+ : undefined;
702
838
  if (run.recording) {
703
839
  this.queue.push(() => {
704
840
  this.recorder.recordToolSelected(run.runId, pair.selected, {
705
841
  toolName,
706
842
  toolInput: serializeCapped(redact(args)),
707
843
  context: context || undefined,
844
+ intent: thinkingIntent,
845
+ metadata: pinnedBy ? { pinnedBy } : undefined,
708
846
  });
709
847
  });
710
848
  }
711
- logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
849
+ logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected, pinnedBy });
712
850
  if (pinned) {
713
851
  return {
714
852
  stepIndex: pair.selected,
@@ -721,6 +859,365 @@ export class ControlServer {
721
859
  }
722
860
  return { stepIndex: pair.selected };
723
861
  }
862
+ /** A controller decision as a `PreToolUse` answer, or undefined to carry on. */
863
+ preToolAnswer(action) {
864
+ switch (action.kind) {
865
+ case "bash":
866
+ return {
867
+ hookSpecificOutput: {
868
+ hookEventName: "PreToolUse",
869
+ permissionDecision: "allow",
870
+ updatedInput: { command: action.command },
871
+ },
872
+ };
873
+ case "deny":
874
+ return {
875
+ hookSpecificOutput: {
876
+ hookEventName: "PreToolUse",
877
+ permissionDecision: "deny",
878
+ permissionDecisionReason: action.reason,
879
+ },
880
+ };
881
+ default:
882
+ return undefined;
883
+ }
884
+ }
885
+ /**
886
+ * Recordings this turn must not count a step hit against (segmented.md
887
+ * R-HIT-7).
888
+ *
889
+ * A repeat of a recording's own prompt is *prompt* recurrence, counted as
890
+ * `iterations`, never step recurrence. Without this, three prompt repeats
891
+ * would reach `SEGMENT_MIN_HITS` on every step of the whole recording and the
892
+ * detector would build a whole-run-sized segment beside the whole-run
893
+ * scenario.
894
+ */
895
+ excludeRunsFor(run) {
896
+ const out = new Set();
897
+ if (run.recording)
898
+ out.add(run.runId);
899
+ const prompt = run.replays.find((s) => s.armedBy === "prompt")?.matchedRunId;
900
+ if (prompt)
901
+ out.add(prompt);
902
+ if (run.fragment?.hitRunId)
903
+ out.add(run.fragment.hitRunId);
904
+ return [...out];
905
+ }
906
+ /**
907
+ * Per recording a plan of this turn came from, how far that plan got
908
+ * (segmented.md R-OUT-6).
909
+ *
910
+ * The server uses it to refuse a segment that starts *before* where this turn
911
+ * already is: re-running steps the turn has just done is worse than not
912
+ * replaying at all. The largest position per recording wins, and a plan
913
+ * declined for anything but a known-bad first step reports nothing — it ran
914
+ * none of the recording, so it blocks none of it.
915
+ *
916
+ * Rebuilt on every probe rather than cached: a plan that hands over between
917
+ * two calls changes the answer.
918
+ */
919
+ ranThroughFor(run) {
920
+ const best = new Map();
921
+ for (const state of run.replays) {
922
+ if (!state.scenarioId)
923
+ continue;
924
+ const recording = state.matchedRunId;
925
+ if (!recording)
926
+ continue;
927
+ let position;
928
+ if (!state.plan) {
929
+ // Only a known-bad first step means the turn is committed to doing this
930
+ // recording's work itself from position 0 (R-HIT-14); every other
931
+ // decline leaves the recording untouched.
932
+ if (state.declined === "known_bad_first_step")
933
+ position = 0;
934
+ }
935
+ else if (state.handover) {
936
+ position = state.handover.stepIndex;
937
+ }
938
+ else {
939
+ // Ran to its end: the last position it planned.
940
+ position = Math.max(0, state.plan.stepCount - 1);
941
+ }
942
+ if (position === undefined)
943
+ continue;
944
+ const seen = best.get(recording);
945
+ if (!seen || position > seen.position) {
946
+ best.set(recording, { position, scenarioId: state.scenarioId });
947
+ }
948
+ }
949
+ return [...best].map(([runId, v]) => ({
950
+ runId,
951
+ position: v.position,
952
+ scenarioId: v.scenarioId,
953
+ }));
954
+ }
955
+ /**
956
+ * Intent matching in the ReAct loop (fallbk.md §Runner 4, segmented.md 10.3).
957
+ *
958
+ * Called only while the model is driving. Every unsteered, non-housekeeping
959
+ * tool call is sent: the service counts it as a hit on whatever recorded step
960
+ * it matches, and may hand back a scenario or a segment to run in its place.
961
+ *
962
+ * A call made with **no reasoning at all** still probes (R-HIT-4): the server
963
+ * builds an intent from the tool name and arguments, and a step the agent took
964
+ * without narrating it is exactly as much a recurrence as one it explained.
965
+ * Every failure is a miss.
966
+ */
967
+ async tryIntentMatch(session, run, toolName, intent, modelArgs) {
968
+ const opts = this.replay.intentMatch;
969
+ if (!this.replay.enabled || !opts.enabled)
970
+ return undefined;
971
+ if (!isIntentMatcher(this.recorder))
972
+ return undefined;
973
+ if (this.replay.isDirectTool(toolName))
974
+ return undefined;
975
+ const text = (intent?.text ?? "").trim().slice(0, 4_000);
976
+ const source = intent?.source ?? "tool";
977
+ // Valid JSON with the serializer's own truncation marker, never a raw slice:
978
+ // the same string feeds the dedupe hash, the server's live question and the
979
+ // live call handed to derivation (R-HIT-4).
980
+ const toolInput = serializeCapped(redact(modelArgs), {
981
+ maxPayloadBytes: 2048,
982
+ maxStringBytes: 256,
983
+ });
984
+ // A retried identical call is not sent again; two different calls under one
985
+ // reasoning line both are (R-HIT-5).
986
+ const probeHash = createHash("sha1")
987
+ .update([text, toolName, toolInput].join(PROBE_SEP))
988
+ .digest("hex");
989
+ if (probeHash === run.intent.lastProbeHash)
990
+ return undefined;
991
+ run.intent.lastProbeHash = probeHash;
992
+ if (run.intent.requests >= opts.maxRequestsPerTurn)
993
+ return undefined;
994
+ run.intent.requests += 1;
995
+ // Every scenario armed this turn, so a continuation cannot re-arm the plan
996
+ // that just handed over.
997
+ const exclude = run.replays
998
+ .map((s) => s.scenarioId)
999
+ .filter((id) => typeof id === "string");
1000
+ const startedAt = Date.now();
1001
+ const answer = await this.withinBudget(this.recorder.matchIntent({
1002
+ text,
1003
+ source,
1004
+ // The same value twice: `currentToolName` is what today's server reads,
1005
+ // `toolName` is what the new one reads beside `toolInput`.
1006
+ currentToolName: toolName,
1007
+ toolName,
1008
+ toolInput,
1009
+ exclude,
1010
+ turnId: run.turnId,
1011
+ stepPosition: run.ordering.next,
1012
+ excludeRuns: this.excludeRunsFor(run),
1013
+ ranThrough: this.ranThroughFor(run),
1014
+ ...(this.replay.segmentArm ? { acceptSegments: true } : {}),
1015
+ supportsCalls: true,
1016
+ }), opts.budgetMs);
1017
+ if (!answer) {
1018
+ logDetail("replay.intent_miss", { run: run.runId, tool: toolName });
1019
+ return undefined;
1020
+ }
1021
+ if (answer.stepHit) {
1022
+ run.intent.lastStepHit = { ...answer.stepHit, at: Date.now() };
1023
+ }
1024
+ // What *would* have armed, had segments been armed (segmented.md R-OUT-10).
1025
+ // Logged at line level, not detail: this is the whole observe-only period,
1026
+ // and the decision to turn arming on is made by reading these. The turn
1027
+ // then carries on with whatever else the answer holds (R-OUT-15).
1028
+ if (answer.segmentWouldArm) {
1029
+ const w = answer.segmentWouldArm;
1030
+ logLine("replay.segment_would_arm", {
1031
+ scenario: w.scenarioId,
1032
+ run: w.runId,
1033
+ key: w.key,
1034
+ similarity: w.similarity.toFixed(3),
1035
+ band: w.band,
1036
+ verified: w.verified
1037
+ ? w.verified.verdict === "skipped"
1038
+ ? `skipped:${w.verified.reason}`
1039
+ : `${w.verified.verdict}: ${w.verified.reason}`
1040
+ : undefined,
1041
+ tool: w.tool ?? toolName,
1042
+ firstTool: w.firstTool,
1043
+ stepFrom: w.stepFrom,
1044
+ stepTo: w.stepTo,
1045
+ hitCount: w.hitCount,
1046
+ });
1047
+ }
1048
+ const match = answer.matched;
1049
+ logDetail("replay.intent_probe", {
1050
+ run: run.runId,
1051
+ tool: toolName,
1052
+ source,
1053
+ stepHit: answer.stepHit ? `${answer.stepHit.runId}:${answer.stepHit.stepIndex}` : "none",
1054
+ bestStepSimilarity: answer.bestStepSimilarity,
1055
+ elapsedMs: Date.now() - startedAt,
1056
+ matched: match?.scenarioId ?? "none",
1057
+ kind: match?.kind,
1058
+ });
1059
+ if (!match)
1060
+ return undefined;
1061
+ if (match.scenarioId && exclude.includes(match.scenarioId))
1062
+ return undefined;
1063
+ // The arms cap is checked *after* the response, not before it (R-HIT-5):
1064
+ // hit counting must not stop just because the turn has already armed three
1065
+ // plans. The ticket the server issued goes unredeemed and is swept
1066
+ // (R-OUT-12).
1067
+ if (run.intent.armed >= opts.maxPerTurn) {
1068
+ logLine("replay.intent_arm_capped", {
1069
+ scenario: match.scenarioId ?? undefined,
1070
+ kind: match.kind ?? "scenario",
1071
+ similarity: match.similarity.toFixed(3),
1072
+ tool: toolName,
1073
+ });
1074
+ return undefined;
1075
+ }
1076
+ const state = await this.replay.arm(match, text, this.wrapped, "intent", {
1077
+ liveCall: { toolName, toolInput },
1078
+ recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
1079
+ });
1080
+ if (!state.plan) {
1081
+ // A declined intent hit reports nothing: the turn's cost is not a
1082
+ // measurement of that scenario's task, so it is no baseline sample either.
1083
+ state.reported = true;
1084
+ return undefined;
1085
+ }
1086
+ state.usageMark = markTranscriptUsage(session.transcriptPath);
1087
+ // The closure, not the mark: only the control server holds the transcript
1088
+ // path, and a completing retire happens in the controller (R-MONEY-3).
1089
+ state.markUsage = () => markTranscriptUsage(session.transcriptPath);
1090
+ // A segment of a recording whose whole-run plan already armed this turn
1091
+ // takes its share off that plan's baseline (R-MONEY-4). One recording's
1092
+ // steps are never counted in two baselines of one turn.
1093
+ if (state.kind === "segment" && state.segment && state.scenarioId) {
1094
+ for (const earlier of run.replays) {
1095
+ if (earlier === state || !earlier.plan)
1096
+ continue;
1097
+ if (earlier.kind !== "scenario")
1098
+ continue;
1099
+ if (earlier.matchedRunId !== state.segment.runId)
1100
+ continue;
1101
+ earlier.sharedWith.push(state.scenarioId);
1102
+ }
1103
+ }
1104
+ run.replays.push(state);
1105
+ run.replay = state;
1106
+ run.intent.armed += 1;
1107
+ const directive = this.replay.directiveFor(state) ?? "";
1108
+ logLine("replay.intent_armed", {
1109
+ run: run.runId,
1110
+ scenario: state.scenarioId ?? undefined,
1111
+ similarity: match.similarity.toFixed(3),
1112
+ mode: state.mode,
1113
+ instead: toolName,
1114
+ kind: match.kind ?? "scenario",
1115
+ key: match.key,
1116
+ verified: match.verified?.verdict,
1117
+ why: "the model's reasoning matched a calculated scenario's intent",
1118
+ });
1119
+ return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
1120
+ }
1121
+ /**
1122
+ * Schedule a fragment once a matched turn's plan has handed over (fallbk.md
1123
+ * D7). Only a turn that is not already recording needs one: an unmatched turn
1124
+ * whose intent-armed plan handed over is recording the model's work anyway.
1125
+ */
1126
+ noteHandover(run) {
1127
+ if (run.fragment || run.recording || run.finished)
1128
+ return;
1129
+ const state = run.replay;
1130
+ const h = state?.handover;
1131
+ if (!h || !state?.scenarioId)
1132
+ return;
1133
+ run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
1134
+ }
1135
+ /**
1136
+ * Open the fragment run and record from this call on (fallbk.md D7).
1137
+ *
1138
+ * The run id is swapped in place: steps allocated before belonged to a turn
1139
+ * the service never created a run for, and nothing of theirs may land in the
1140
+ * fragment — so pending correlations are latched and built-ins forgotten.
1141
+ */
1142
+ async openFragment(session, run, reasoning, toolName, args) {
1143
+ const f = run.fragment;
1144
+ if (!f || f.state !== "due")
1145
+ return;
1146
+ const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
1147
+ const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
1148
+ const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, { fallbackOf });
1149
+ for (const c of run.correlations.values()) {
1150
+ c.settled = true;
1151
+ if (c.fallback)
1152
+ clearTimeout(c.fallback);
1153
+ }
1154
+ run.builtIns.clear();
1155
+ run.runId = id;
1156
+ run.ordering = new StepIndexAllocator();
1157
+ run.recording = true;
1158
+ f.state = "open";
1159
+ f.usageMark = markTranscriptUsage(session.transcriptPath);
1160
+ f.startedAtMs = Date.now();
1161
+ logLine("run.fragment", {
1162
+ run: id,
1163
+ scenario: f.scenarioId,
1164
+ step: f.stepIndex,
1165
+ why: "recording the agent's own work after the scenario handed over",
1166
+ });
1167
+ if (isRunCreationAware(this.recorder)) {
1168
+ const created = await this.withinBudget(this.recorder.runCreated(id), this.replay.budgets.matchMs);
1169
+ if (created && !created.created) {
1170
+ run.recording = false;
1171
+ f.state = "hit";
1172
+ // Which run it hit matters from here on: this turn must not count step
1173
+ // hits against the fragment it is a repeat of (segmented.md R-HIT-7).
1174
+ f.hitRunId = created.fragmentOf?.runId;
1175
+ logLine("run.fragment_hit", {
1176
+ run: id,
1177
+ scenario: f.scenarioId,
1178
+ step: f.stepIndex,
1179
+ of: created.fragmentOf?.runId,
1180
+ why: "a hand-over at this step was recorded before — counted as a hit, not recorded",
1181
+ });
1182
+ }
1183
+ }
1184
+ }
1185
+ /** Resolve with `p`, or null once `ms` passes or it rejects. */
1186
+ async withinBudget(p, ms) {
1187
+ let timer;
1188
+ const budget = new Promise((resolve) => {
1189
+ timer = setTimeout(() => resolve(null), ms);
1190
+ timer.unref?.();
1191
+ });
1192
+ try {
1193
+ return await Promise.race([p, budget]);
1194
+ }
1195
+ catch {
1196
+ return null;
1197
+ }
1198
+ finally {
1199
+ if (timer)
1200
+ clearTimeout(timer);
1201
+ }
1202
+ }
1203
+ /**
1204
+ * `PostToolUse`, and where a hand-over noticed while threading this call's
1205
+ * output reaches the model at once, as `additionalContext` (fallbk.md D4).
1206
+ */
1207
+ onToolPost(payload) {
1208
+ const out = this.toolPost(payload);
1209
+ const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
1210
+ if (!run || run.finished)
1211
+ return out;
1212
+ this.noteHandover(run);
1213
+ const note = this.replay.takeNote(run.replay);
1214
+ if (!note)
1215
+ return out;
1216
+ return {
1217
+ ...out,
1218
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: note },
1219
+ };
1220
+ }
724
1221
  /**
725
1222
  * `PostToolUse` / `PostToolUseFailure`. For a built-in this closes the pair.
726
1223
  * For a correlated MCP call it normally does nothing — the proxy owns that
@@ -728,7 +1225,7 @@ export class ControlServer {
728
1225
  * or the call never reached it), the hook's own view is recorded after
729
1226
  * {@link PROXY_REPORT_GRACE_MS} rather than the step being lost entirely.
730
1227
  */
731
- onToolPost(payload) {
1228
+ toolPost(payload) {
732
1229
  const session = this.ensureSession(payload);
733
1230
  const run = this.ensureRun(session);
734
1231
  const toolUseId = payload.tool_use_id ?? "";
@@ -1004,6 +1501,7 @@ export class ControlServer {
1004
1501
  correlation.threadFallback = undefined;
1005
1502
  }
1006
1503
  this.replay.postTool(run.replay, toolUseId, serializeCapped(redact(report.result)));
1504
+ this.noteHandover(run);
1007
1505
  }
1008
1506
  if (correlation.settled) {
1009
1507
  logDetail("proxy.step.deduped", { run: run.runId, tool: report.qualifiedName });
@@ -1088,6 +1586,10 @@ export class ControlServer {
1088
1586
  isError: data.isError,
1089
1587
  errorMessage: data.errorMessage,
1090
1588
  context: correlation.context,
1589
+ // Carried from where the correlation was created: by now the pin is gone
1590
+ // from the map (segmented.md R-INTENT-7).
1591
+ pinnedBy: correlation.pinnedBy,
1592
+ intent: correlation.intent,
1091
1593
  });
1092
1594
  logDetail("proxy.step.merged", {
1093
1595
  run: run.runId,
@@ -1111,6 +1613,8 @@ export class ControlServer {
1111
1613
  toolName: d.toolName,
1112
1614
  toolInput,
1113
1615
  context: d.context,
1616
+ intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
1617
+ metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } : undefined,
1114
1618
  });
1115
1619
  this.recorder.recordToolResponse(run.runId, pair.response, {
1116
1620
  toolName: d.toolName,