@basein/runner 0.2.0 → 0.2.1

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,
@@ -326,6 +370,9 @@ export class ControlServer {
326
370
  promptSeen: input.length > 0,
327
371
  builtIns: new Map(),
328
372
  correlations: new Map(),
373
+ replays: [],
374
+ turnId: runId,
375
+ intent: { armed: 0, requests: 0 },
329
376
  // Taken here, beside `startedAtMs`, so the cost window and the duration
330
377
  // window are the same window (docs/calculatedReplay.md §11.4).
331
378
  usageMark: markTranscriptUsage(session.transcriptPath),
@@ -334,7 +381,7 @@ export class ControlServer {
334
381
  logLine("run.start", { run: runId, sess: session.sessionId, tier: "bound" });
335
382
  // Started here so the round trip overlaps whatever the caller does next;
336
383
  // `onPrompt` awaits the same memoized promise under its own budget.
337
- void this.watchForMatch(run);
384
+ void this.watchForMatch(session, run);
338
385
  return run;
339
386
  }
340
387
  runMetadata(session) {
@@ -362,7 +409,7 @@ export class ControlServer {
362
409
  *
363
410
  * Resolves with the steering directive to inject, or undefined.
364
411
  */
365
- watchForMatch(run, prompt = "") {
412
+ watchForMatch(session, run, prompt = "") {
366
413
  if (run.matchWatch)
367
414
  return run.matchWatch;
368
415
  if (!isMatchAware(this.recorder))
@@ -370,7 +417,7 @@ export class ControlServer {
370
417
  const pending = this.recorder.getMatch(run.runId);
371
418
  run.matchWatch = this.replay
372
419
  .awaitMatch(pending)
373
- .then((match) => {
420
+ .then(async (match) => {
374
421
  if (!match)
375
422
  return undefined;
376
423
  run.recording = false;
@@ -381,8 +428,15 @@ export class ControlServer {
381
428
  similarity: match.similarity,
382
429
  why: "similar prompt — the service kept its own run; not recording this one",
383
430
  });
384
- run.replay = this.replay.arm(match, prompt || run.input, this.wrapped);
385
- return this.replay.directiveFor(run.replay);
431
+ // Awaited: a scenario with a target derives before the directive goes
432
+ // out, so a plan that cannot find its target is never delivered
433
+ // (segmented.md R-PARAM-4). Inside `UserPromptSubmit`'s 15 s.
434
+ const state = await this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
435
+ recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
436
+ });
437
+ run.replay = state;
438
+ run.replays.push(state);
439
+ return this.replay.directiveFor(state);
386
440
  })
387
441
  .catch((err) => {
388
442
  // Replay is an optimisation; a broken one must never cost a turn.
@@ -423,7 +477,12 @@ export class ControlServer {
423
477
  // A matched turn records nothing, but it still owes the ledger a number —
424
478
  // including when it declined to steer, which is a *baseline* sample and is
425
479
  // what every saving is measured against (docs/calculatedReplay.md §11).
426
- this.reportExecution(run, durationMs, cost);
480
+ this.reportExecution(session, run, durationMs, cost);
481
+ // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
482
+ // and duration are the model's own work, not the steps the scenario did.
483
+ const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
484
+ const recordedCost = fragment ? this.costSince(session, fragment.usageMark) : cost;
485
+ const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
427
486
  if (run.recording) {
428
487
  const transcriptPath = session.transcriptPath;
429
488
  // Resolve the answer *outside* the queue: the wait below is up to two
@@ -442,8 +501,8 @@ export class ControlServer {
442
501
  // scenario that has no samples yet. A run recorded at $0 makes
443
502
  // every later saving measured against nothing.
444
503
  this.recorder.finishRun(run.runId, answer || undefined, {
445
- durationMs,
446
- costUsd: cost.measured ? cost.usd : undefined,
504
+ durationMs: recordedMs,
505
+ costUsd: recordedCost.measured ? recordedCost.usd : undefined,
447
506
  });
448
507
  });
449
508
  }));
@@ -486,24 +545,66 @@ export class ControlServer {
486
545
  });
487
546
  return { usd: 0, measured: false };
488
547
  }
489
- const deltas = usageSince(session.transcriptPath, run.usageMark);
548
+ return this.costSince(session, run.usageMark);
549
+ }
550
+ /** Transcript cost since `mark`; unmeasured (and zero) without one. */
551
+ costSince(session, mark) {
552
+ if (mark === undefined)
553
+ return { usd: 0, measured: false };
554
+ const deltas = usageSince(session.transcriptPath, mark);
490
555
  const usd = deltas.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
491
556
  return { usd, measured: deltas.length > 0 };
492
557
  }
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)
558
+ reportExecution(session, run, durationMs, cost) {
559
+ if (!isScenarioReporter(this.recorder))
504
560
  return;
561
+ const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
505
562
  const recorder = this.recorder;
506
- this.queue.push(() => recorder.reportExecution(report));
563
+ // One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
564
+ // for its own window of the transcript — from when it armed to when the next
565
+ // one did — so the turn's tokens are split between them, never counted twice.
566
+ // A turn with a single prompt-armed plan is exactly the report it always was.
567
+ states.forEach((state, i) => {
568
+ const next = states[i + 1];
569
+ const nextMark = next?.armedBy === "intent" ? next.usageMark : undefined;
570
+ let windowCost = cost;
571
+ let windowMs = durationMs;
572
+ // An intent-armed plan's window *ends* where its work ended (segmented.md
573
+ // R-MONEY-3): at its own retire mark when it completed, else at the next
574
+ // plan's arm, else at the end of the turn. Without the first, a segment
575
+ // that finished in three steps would be billed for everything the agent
576
+ // did afterwards, and its saving would read as a loss.
577
+ let end = nextMark;
578
+ if (state.armedBy === "intent") {
579
+ windowCost = this.costSince(session, state.usageMark);
580
+ windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
581
+ end = state.retiredMark ?? nextMark;
582
+ }
583
+ else if (next) {
584
+ windowMs = next.armedAt - run.startedAtMs;
585
+ }
586
+ if (end !== undefined) {
587
+ const later = this.costSince(session, end);
588
+ windowCost = { usd: Math.max(0, windowCost.usd - later.usd), measured: windowCost.measured };
589
+ }
590
+ const report = this.replay.buildReport(state, {
591
+ sessionCostUsd: windowCost.usd,
592
+ measured: windowCost.measured,
593
+ durationMs: Math.max(0, windowMs),
594
+ prompt: run.input || undefined,
595
+ siblings: states,
596
+ });
597
+ if (!report)
598
+ return;
599
+ if (state.armedBy === "intent") {
600
+ logDetail("replay.done", {
601
+ scenario: state.scenarioId ?? undefined,
602
+ windowMs: Math.max(0, windowMs),
603
+ billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
604
+ });
605
+ }
606
+ this.queue.push(() => recorder.reportExecution(report));
607
+ });
507
608
  }
508
609
  /** Seal the run *and* wait for everything queued to reach the service. */
509
610
  async finalizeRun(session) {
@@ -569,7 +670,7 @@ export class ControlServer {
569
670
  // a slow or unreachable service costs the user nothing but an ordinary turn
570
671
  // (docs/calculatedReplay.md §10); `additionalContext` carries no `decision`,
571
672
  // so the prompt still reaches the model either way.
572
- const directive = await this.watchForMatch(run, input);
673
+ const directive = await this.watchForMatch(session, run, input);
573
674
  if (!directive)
574
675
  return {};
575
676
  return {
@@ -605,36 +706,49 @@ export class ControlServer {
605
706
  return {};
606
707
  }
607
708
  const agentId = payload.agent_id ?? session.agentId;
608
- const reasoning = contextForToolUse(session.transcriptPath, toolUseId);
709
+ // The real intent, read from every line sharing this message's id
710
+ // (segmented.md R-INTENT-3). `context` stays the *written* text only, with
711
+ // its agent prefix: thinking is embedded but never stored as text
712
+ // (R-INTENT-2), so it rides separately in `intent`.
713
+ const intent = intentForToolUse(session.transcriptPath, toolUseId, this.intentSources);
714
+ const reasoning = intent?.source === "text" ? intent.text : "";
715
+ // The prefix rides on whether there is a subagent, not on whether it said
716
+ // anything: a subagent's steps must be tellable apart even when it narrated
717
+ // nothing. What it never carries is thinking (R-INTENT-2).
609
718
  const context = agentId ? `[agent ${agentId}] ${reasoning}`.trim() : reasoning;
719
+ const thinkingIntent = intent?.source === "thinking" ? { text: intent.text, source: "thinking" } : undefined;
610
720
  // ── replay steering (docs/calculatedReplay.md §7) ────────────────────────
611
721
  // Runs before anything else, because two of its four answers end the call.
612
722
  let pinned;
613
723
  if (run.replay?.plan && !run.replay.retired) {
614
724
  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;
725
+ // A hand-over inside `preTool` (an input logic that threw) must still
726
+ // schedule the fragment, even though this call ends here.
727
+ this.noteHandover(run);
728
+ if (action.kind === "pin")
729
+ pinned = action.input;
730
+ const answer = this.preToolAnswer(action);
731
+ if (answer)
732
+ return answer;
733
+ }
734
+ if (!pinned && (!run.replay?.plan || run.replay.retired)) {
735
+ // ── falling back to the model (fallbk.md) ──────────────────────────────
736
+ // 1. A hand-over noticed where no hook answer could carry it — a proxy
737
+ // report threaded after its PostToolUse had already answered — reaches
738
+ // the model in place of this call. Never silence (D4).
739
+ const note = this.replay.takeNote(run.replay);
740
+ if (note) {
741
+ this.noteHandover(run);
742
+ return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
743
+ }
744
+ // 2. The model is driving: look for a scenario by this iteration's intent.
745
+ const armed = await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
746
+ if (armed)
747
+ return armed;
748
+ // 3. No scenario took over: the model's own work after a hand-over is
749
+ // recorded as a fragment, starting with this call.
750
+ if (run.fragment?.state === "due") {
751
+ await this.openFragment(session, run, reasoning, toolName, modelArgs);
638
752
  }
639
753
  }
640
754
  // The arguments that will actually run — which is what must be correlated and
@@ -654,6 +768,11 @@ export class ControlServer {
654
768
  createdAt: Date.now(),
655
769
  settled: false,
656
770
  fingerprint: fingerprint(mcp.serverName, mcp.toolName, args),
771
+ // Captured here because this is the last moment the code knows: by the
772
+ // time `settle` records the step, `postTool` has consumed the pin
773
+ // (segmented.md R-INTENT-7).
774
+ pinnedBy: pinned ? (run.replay?.scenarioId ?? undefined) : undefined,
775
+ intent: thinkingIntent,
657
776
  };
658
777
  run.correlations.set(correlation.callId, correlation);
659
778
  logDetail("tool.pre.correlate", {
@@ -699,16 +818,26 @@ export class ControlServer {
699
818
  responseIndex: pair.response,
700
819
  toolName,
701
820
  });
821
+ // A step a plan executed is pinned and never embedded (R-INTENT-7), so the
822
+ // system's own replays never look like the work recurring. Two paths reach
823
+ // here: a built-in step the plan pinned, and the direct plan's own vehicle
824
+ // `mcp__bir__run_scenario`, which is recorded as a built-in while its plan
825
+ // is live and is just as much the system's own doing.
826
+ const pinnedBy = pinned || (this.replay.isDirectTool(toolName) && run.replay?.plan && !run.replay.retired)
827
+ ? (run.replay?.scenarioId ?? undefined)
828
+ : undefined;
702
829
  if (run.recording) {
703
830
  this.queue.push(() => {
704
831
  this.recorder.recordToolSelected(run.runId, pair.selected, {
705
832
  toolName,
706
833
  toolInput: serializeCapped(redact(args)),
707
834
  context: context || undefined,
835
+ intent: thinkingIntent,
836
+ metadata: pinnedBy ? { pinnedBy } : undefined,
708
837
  });
709
838
  });
710
839
  }
711
- logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
840
+ logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected, pinnedBy });
712
841
  if (pinned) {
713
842
  return {
714
843
  stepIndex: pair.selected,
@@ -721,6 +850,365 @@ export class ControlServer {
721
850
  }
722
851
  return { stepIndex: pair.selected };
723
852
  }
853
+ /** A controller decision as a `PreToolUse` answer, or undefined to carry on. */
854
+ preToolAnswer(action) {
855
+ switch (action.kind) {
856
+ case "bash":
857
+ return {
858
+ hookSpecificOutput: {
859
+ hookEventName: "PreToolUse",
860
+ permissionDecision: "allow",
861
+ updatedInput: { command: action.command },
862
+ },
863
+ };
864
+ case "deny":
865
+ return {
866
+ hookSpecificOutput: {
867
+ hookEventName: "PreToolUse",
868
+ permissionDecision: "deny",
869
+ permissionDecisionReason: action.reason,
870
+ },
871
+ };
872
+ default:
873
+ return undefined;
874
+ }
875
+ }
876
+ /**
877
+ * Recordings this turn must not count a step hit against (segmented.md
878
+ * R-HIT-7).
879
+ *
880
+ * A repeat of a recording's own prompt is *prompt* recurrence, counted as
881
+ * `iterations`, never step recurrence. Without this, three prompt repeats
882
+ * would reach `SEGMENT_MIN_HITS` on every step of the whole recording and the
883
+ * detector would build a whole-run-sized segment beside the whole-run
884
+ * scenario.
885
+ */
886
+ excludeRunsFor(run) {
887
+ const out = new Set();
888
+ if (run.recording)
889
+ out.add(run.runId);
890
+ const prompt = run.replays.find((s) => s.armedBy === "prompt")?.matchedRunId;
891
+ if (prompt)
892
+ out.add(prompt);
893
+ if (run.fragment?.hitRunId)
894
+ out.add(run.fragment.hitRunId);
895
+ return [...out];
896
+ }
897
+ /**
898
+ * Per recording a plan of this turn came from, how far that plan got
899
+ * (segmented.md R-OUT-6).
900
+ *
901
+ * The server uses it to refuse a segment that starts *before* where this turn
902
+ * already is: re-running steps the turn has just done is worse than not
903
+ * replaying at all. The largest position per recording wins, and a plan
904
+ * declined for anything but a known-bad first step reports nothing — it ran
905
+ * none of the recording, so it blocks none of it.
906
+ *
907
+ * Rebuilt on every probe rather than cached: a plan that hands over between
908
+ * two calls changes the answer.
909
+ */
910
+ ranThroughFor(run) {
911
+ const best = new Map();
912
+ for (const state of run.replays) {
913
+ if (!state.scenarioId)
914
+ continue;
915
+ const recording = state.matchedRunId;
916
+ if (!recording)
917
+ continue;
918
+ let position;
919
+ if (!state.plan) {
920
+ // Only a known-bad first step means the turn is committed to doing this
921
+ // recording's work itself from position 0 (R-HIT-14); every other
922
+ // decline leaves the recording untouched.
923
+ if (state.declined === "known_bad_first_step")
924
+ position = 0;
925
+ }
926
+ else if (state.handover) {
927
+ position = state.handover.stepIndex;
928
+ }
929
+ else {
930
+ // Ran to its end: the last position it planned.
931
+ position = Math.max(0, state.plan.stepCount - 1);
932
+ }
933
+ if (position === undefined)
934
+ continue;
935
+ const seen = best.get(recording);
936
+ if (!seen || position > seen.position) {
937
+ best.set(recording, { position, scenarioId: state.scenarioId });
938
+ }
939
+ }
940
+ return [...best].map(([runId, v]) => ({
941
+ runId,
942
+ position: v.position,
943
+ scenarioId: v.scenarioId,
944
+ }));
945
+ }
946
+ /**
947
+ * Intent matching in the ReAct loop (fallbk.md §Runner 4, segmented.md 10.3).
948
+ *
949
+ * Called only while the model is driving. Every unsteered, non-housekeeping
950
+ * tool call is sent: the service counts it as a hit on whatever recorded step
951
+ * it matches, and may hand back a scenario or a segment to run in its place.
952
+ *
953
+ * A call made with **no reasoning at all** still probes (R-HIT-4): the server
954
+ * builds an intent from the tool name and arguments, and a step the agent took
955
+ * without narrating it is exactly as much a recurrence as one it explained.
956
+ * Every failure is a miss.
957
+ */
958
+ async tryIntentMatch(session, run, toolName, intent, modelArgs) {
959
+ const opts = this.replay.intentMatch;
960
+ if (!this.replay.enabled || !opts.enabled)
961
+ return undefined;
962
+ if (!isIntentMatcher(this.recorder))
963
+ return undefined;
964
+ if (this.replay.isDirectTool(toolName))
965
+ return undefined;
966
+ const text = (intent?.text ?? "").trim().slice(0, 4_000);
967
+ const source = intent?.source ?? "tool";
968
+ // Valid JSON with the serializer's own truncation marker, never a raw slice:
969
+ // the same string feeds the dedupe hash, the server's live question and the
970
+ // live call handed to derivation (R-HIT-4).
971
+ const toolInput = serializeCapped(redact(modelArgs), {
972
+ maxPayloadBytes: 2048,
973
+ maxStringBytes: 256,
974
+ });
975
+ // A retried identical call is not sent again; two different calls under one
976
+ // reasoning line both are (R-HIT-5).
977
+ const probeHash = createHash("sha1")
978
+ .update([text, toolName, toolInput].join(PROBE_SEP))
979
+ .digest("hex");
980
+ if (probeHash === run.intent.lastProbeHash)
981
+ return undefined;
982
+ run.intent.lastProbeHash = probeHash;
983
+ if (run.intent.requests >= opts.maxRequestsPerTurn)
984
+ return undefined;
985
+ run.intent.requests += 1;
986
+ // Every scenario armed this turn, so a continuation cannot re-arm the plan
987
+ // that just handed over.
988
+ const exclude = run.replays
989
+ .map((s) => s.scenarioId)
990
+ .filter((id) => typeof id === "string");
991
+ const startedAt = Date.now();
992
+ const answer = await this.withinBudget(this.recorder.matchIntent({
993
+ text,
994
+ source,
995
+ // The same value twice: `currentToolName` is what today's server reads,
996
+ // `toolName` is what the new one reads beside `toolInput`.
997
+ currentToolName: toolName,
998
+ toolName,
999
+ toolInput,
1000
+ exclude,
1001
+ turnId: run.turnId,
1002
+ stepPosition: run.ordering.next,
1003
+ excludeRuns: this.excludeRunsFor(run),
1004
+ ranThrough: this.ranThroughFor(run),
1005
+ ...(this.replay.segmentArm ? { acceptSegments: true } : {}),
1006
+ supportsCalls: true,
1007
+ }), opts.budgetMs);
1008
+ if (!answer) {
1009
+ logDetail("replay.intent_miss", { run: run.runId, tool: toolName });
1010
+ return undefined;
1011
+ }
1012
+ if (answer.stepHit) {
1013
+ run.intent.lastStepHit = { ...answer.stepHit, at: Date.now() };
1014
+ }
1015
+ // What *would* have armed, had segments been armed (segmented.md R-OUT-10).
1016
+ // Logged at line level, not detail: this is the whole observe-only period,
1017
+ // and the decision to turn arming on is made by reading these. The turn
1018
+ // then carries on with whatever else the answer holds (R-OUT-15).
1019
+ if (answer.segmentWouldArm) {
1020
+ const w = answer.segmentWouldArm;
1021
+ logLine("replay.segment_would_arm", {
1022
+ scenario: w.scenarioId,
1023
+ run: w.runId,
1024
+ key: w.key,
1025
+ similarity: w.similarity.toFixed(3),
1026
+ band: w.band,
1027
+ verified: w.verified
1028
+ ? w.verified.verdict === "skipped"
1029
+ ? `skipped:${w.verified.reason}`
1030
+ : `${w.verified.verdict}: ${w.verified.reason}`
1031
+ : undefined,
1032
+ tool: w.tool ?? toolName,
1033
+ firstTool: w.firstTool,
1034
+ stepFrom: w.stepFrom,
1035
+ stepTo: w.stepTo,
1036
+ hitCount: w.hitCount,
1037
+ });
1038
+ }
1039
+ const match = answer.matched;
1040
+ logDetail("replay.intent_probe", {
1041
+ run: run.runId,
1042
+ tool: toolName,
1043
+ source,
1044
+ stepHit: answer.stepHit ? `${answer.stepHit.runId}:${answer.stepHit.stepIndex}` : "none",
1045
+ bestStepSimilarity: answer.bestStepSimilarity,
1046
+ elapsedMs: Date.now() - startedAt,
1047
+ matched: match?.scenarioId ?? "none",
1048
+ kind: match?.kind,
1049
+ });
1050
+ if (!match)
1051
+ return undefined;
1052
+ if (match.scenarioId && exclude.includes(match.scenarioId))
1053
+ return undefined;
1054
+ // The arms cap is checked *after* the response, not before it (R-HIT-5):
1055
+ // hit counting must not stop just because the turn has already armed three
1056
+ // plans. The ticket the server issued goes unredeemed and is swept
1057
+ // (R-OUT-12).
1058
+ if (run.intent.armed >= opts.maxPerTurn) {
1059
+ logLine("replay.intent_arm_capped", {
1060
+ scenario: match.scenarioId ?? undefined,
1061
+ kind: match.kind ?? "scenario",
1062
+ similarity: match.similarity.toFixed(3),
1063
+ tool: toolName,
1064
+ });
1065
+ return undefined;
1066
+ }
1067
+ const state = await this.replay.arm(match, text, this.wrapped, "intent", {
1068
+ liveCall: { toolName, toolInput },
1069
+ recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
1070
+ });
1071
+ if (!state.plan) {
1072
+ // A declined intent hit reports nothing: the turn's cost is not a
1073
+ // measurement of that scenario's task, so it is no baseline sample either.
1074
+ state.reported = true;
1075
+ return undefined;
1076
+ }
1077
+ state.usageMark = markTranscriptUsage(session.transcriptPath);
1078
+ // The closure, not the mark: only the control server holds the transcript
1079
+ // path, and a completing retire happens in the controller (R-MONEY-3).
1080
+ state.markUsage = () => markTranscriptUsage(session.transcriptPath);
1081
+ // A segment of a recording whose whole-run plan already armed this turn
1082
+ // takes its share off that plan's baseline (R-MONEY-4). One recording's
1083
+ // steps are never counted in two baselines of one turn.
1084
+ if (state.kind === "segment" && state.segment && state.scenarioId) {
1085
+ for (const earlier of run.replays) {
1086
+ if (earlier === state || !earlier.plan)
1087
+ continue;
1088
+ if (earlier.kind !== "scenario")
1089
+ continue;
1090
+ if (earlier.matchedRunId !== state.segment.runId)
1091
+ continue;
1092
+ earlier.sharedWith.push(state.scenarioId);
1093
+ }
1094
+ }
1095
+ run.replays.push(state);
1096
+ run.replay = state;
1097
+ run.intent.armed += 1;
1098
+ const directive = this.replay.directiveFor(state) ?? "";
1099
+ logLine("replay.intent_armed", {
1100
+ run: run.runId,
1101
+ scenario: state.scenarioId ?? undefined,
1102
+ similarity: match.similarity.toFixed(3),
1103
+ mode: state.mode,
1104
+ instead: toolName,
1105
+ kind: match.kind ?? "scenario",
1106
+ key: match.key,
1107
+ verified: match.verified?.verdict,
1108
+ why: "the model's reasoning matched a calculated scenario's intent",
1109
+ });
1110
+ return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
1111
+ }
1112
+ /**
1113
+ * Schedule a fragment once a matched turn's plan has handed over (fallbk.md
1114
+ * D7). Only a turn that is not already recording needs one: an unmatched turn
1115
+ * whose intent-armed plan handed over is recording the model's work anyway.
1116
+ */
1117
+ noteHandover(run) {
1118
+ if (run.fragment || run.recording || run.finished)
1119
+ return;
1120
+ const state = run.replay;
1121
+ const h = state?.handover;
1122
+ if (!h || !state?.scenarioId)
1123
+ return;
1124
+ run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
1125
+ }
1126
+ /**
1127
+ * Open the fragment run and record from this call on (fallbk.md D7).
1128
+ *
1129
+ * The run id is swapped in place: steps allocated before belonged to a turn
1130
+ * the service never created a run for, and nothing of theirs may land in the
1131
+ * fragment — so pending correlations are latched and built-ins forgotten.
1132
+ */
1133
+ async openFragment(session, run, reasoning, toolName, args) {
1134
+ const f = run.fragment;
1135
+ if (!f || f.state !== "due")
1136
+ return;
1137
+ const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
1138
+ const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
1139
+ const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, { fallbackOf });
1140
+ for (const c of run.correlations.values()) {
1141
+ c.settled = true;
1142
+ if (c.fallback)
1143
+ clearTimeout(c.fallback);
1144
+ }
1145
+ run.builtIns.clear();
1146
+ run.runId = id;
1147
+ run.ordering = new StepIndexAllocator();
1148
+ run.recording = true;
1149
+ f.state = "open";
1150
+ f.usageMark = markTranscriptUsage(session.transcriptPath);
1151
+ f.startedAtMs = Date.now();
1152
+ logLine("run.fragment", {
1153
+ run: id,
1154
+ scenario: f.scenarioId,
1155
+ step: f.stepIndex,
1156
+ why: "recording the agent's own work after the scenario handed over",
1157
+ });
1158
+ if (isRunCreationAware(this.recorder)) {
1159
+ const created = await this.withinBudget(this.recorder.runCreated(id), this.replay.budgets.matchMs);
1160
+ if (created && !created.created) {
1161
+ run.recording = false;
1162
+ f.state = "hit";
1163
+ // Which run it hit matters from here on: this turn must not count step
1164
+ // hits against the fragment it is a repeat of (segmented.md R-HIT-7).
1165
+ f.hitRunId = created.fragmentOf?.runId;
1166
+ logLine("run.fragment_hit", {
1167
+ run: id,
1168
+ scenario: f.scenarioId,
1169
+ step: f.stepIndex,
1170
+ of: created.fragmentOf?.runId,
1171
+ why: "a hand-over at this step was recorded before — counted as a hit, not recorded",
1172
+ });
1173
+ }
1174
+ }
1175
+ }
1176
+ /** Resolve with `p`, or null once `ms` passes or it rejects. */
1177
+ async withinBudget(p, ms) {
1178
+ let timer;
1179
+ const budget = new Promise((resolve) => {
1180
+ timer = setTimeout(() => resolve(null), ms);
1181
+ timer.unref?.();
1182
+ });
1183
+ try {
1184
+ return await Promise.race([p, budget]);
1185
+ }
1186
+ catch {
1187
+ return null;
1188
+ }
1189
+ finally {
1190
+ if (timer)
1191
+ clearTimeout(timer);
1192
+ }
1193
+ }
1194
+ /**
1195
+ * `PostToolUse`, and where a hand-over noticed while threading this call's
1196
+ * output reaches the model at once, as `additionalContext` (fallbk.md D4).
1197
+ */
1198
+ onToolPost(payload) {
1199
+ const out = this.toolPost(payload);
1200
+ const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
1201
+ if (!run || run.finished)
1202
+ return out;
1203
+ this.noteHandover(run);
1204
+ const note = this.replay.takeNote(run.replay);
1205
+ if (!note)
1206
+ return out;
1207
+ return {
1208
+ ...out,
1209
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: note },
1210
+ };
1211
+ }
724
1212
  /**
725
1213
  * `PostToolUse` / `PostToolUseFailure`. For a built-in this closes the pair.
726
1214
  * For a correlated MCP call it normally does nothing — the proxy owns that
@@ -728,7 +1216,7 @@ export class ControlServer {
728
1216
  * or the call never reached it), the hook's own view is recorded after
729
1217
  * {@link PROXY_REPORT_GRACE_MS} rather than the step being lost entirely.
730
1218
  */
731
- onToolPost(payload) {
1219
+ toolPost(payload) {
732
1220
  const session = this.ensureSession(payload);
733
1221
  const run = this.ensureRun(session);
734
1222
  const toolUseId = payload.tool_use_id ?? "";
@@ -1004,6 +1492,7 @@ export class ControlServer {
1004
1492
  correlation.threadFallback = undefined;
1005
1493
  }
1006
1494
  this.replay.postTool(run.replay, toolUseId, serializeCapped(redact(report.result)));
1495
+ this.noteHandover(run);
1007
1496
  }
1008
1497
  if (correlation.settled) {
1009
1498
  logDetail("proxy.step.deduped", { run: run.runId, tool: report.qualifiedName });
@@ -1088,6 +1577,10 @@ export class ControlServer {
1088
1577
  isError: data.isError,
1089
1578
  errorMessage: data.errorMessage,
1090
1579
  context: correlation.context,
1580
+ // Carried from where the correlation was created: by now the pin is gone
1581
+ // from the map (segmented.md R-INTENT-7).
1582
+ pinnedBy: correlation.pinnedBy,
1583
+ intent: correlation.intent,
1091
1584
  });
1092
1585
  logDetail("proxy.step.merged", {
1093
1586
  run: run.runId,
@@ -1111,6 +1604,8 @@ export class ControlServer {
1111
1604
  toolName: d.toolName,
1112
1605
  toolInput,
1113
1606
  context: d.context,
1607
+ intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
1608
+ metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } : undefined,
1114
1609
  });
1115
1610
  this.recorder.recordToolResponse(run.runId, pair.response, {
1116
1611
  toolName: d.toolName,