@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.
@@ -18,13 +18,15 @@ import { serializeCapped } from "../record/truncate.js";
18
18
  import { logDetail, logLine, errText } from "../util/log.js";
19
19
  import { MAX_REPLAY_REASON } from "./bundle.js";
20
20
  import { coverageOf, modeFor, reachOf } from "./coverage.js";
21
- import { deriveParameters } from "./derive.js";
21
+ import { deriveParameters, } from "./derive.js";
22
+ import { buildHandoverNote } from "./handover.js";
22
23
  import { ProxyWorkQueue } from "./executor.js";
23
24
  import { ScenarioReplayPlan } from "./plan.js";
24
25
  import { PRICING_VERSION } from "./pricing.js";
25
26
  import { SourceRunOutputs } from "./source-run.js";
27
+ import { clampToFrameStart, flattenChain, } from "./flatten.js";
26
28
  import { toolResultError } from "./tool-error.js";
27
- import { OUTCOME_RANK, isReadyScenario, } from "./types.js";
29
+ import { OUTCOME_RANK, hasTargets, isReadyScenario, } from "./types.js";
28
30
  /** The first-party tool a `direct` plan is delivered through (§6.3). */
29
31
  export const DIRECT_TOOL_NAME = "mcp__bir__run_scenario";
30
32
  /** How long a `/proxy/poll` is held open before it answers empty. */
@@ -35,6 +37,12 @@ export const DEFAULT_BUDGETS = {
35
37
  planMs: 120_000,
36
38
  stepMs: 60_000,
37
39
  };
40
+ export const DEFAULT_INTENT_MATCH = {
41
+ enabled: true,
42
+ budgetMs: 4_000,
43
+ maxPerTurn: 3,
44
+ maxRequestsPerTurn: 40,
45
+ };
38
46
  /** How many step verdicts one report carries. A chain longer than this is not
39
47
  * a chain anybody is reading step by step, and the report has to stay small. */
40
48
  const MAX_STEP_RESULTS = 200;
@@ -44,11 +52,23 @@ export class ReplayController {
44
52
  enabled;
45
53
  work = new ProxyWorkQueue();
46
54
  budgets;
55
+ intentMatch;
47
56
  opts;
48
57
  constructor(opts) {
49
58
  this.opts = opts;
50
59
  this.enabled = opts.enabled;
51
60
  this.budgets = { ...DEFAULT_BUDGETS, ...(opts.budgets ?? {}) };
61
+ this.intentMatch = { ...DEFAULT_INTENT_MATCH, ...(opts.intentMatch ?? {}) };
62
+ }
63
+ /**
64
+ * Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
65
+ *
66
+ * Off by default: until an operator has read the observe-only logs and turned
67
+ * `BIR_SEGMENT_ARM=1` on, the runner asks for no segment and arms none. The
68
+ * server enforces the same thing from its side (R-OUT-9).
69
+ */
70
+ get segmentArm() {
71
+ return this.opts.segmentArm === true;
52
72
  }
53
73
  /** The plan's own delivery vehicle is never a scenario step. */
54
74
  isDirectTool(toolName) {
@@ -86,7 +106,7 @@ export class ReplayController {
86
106
  * baseline sample to contribute, and losing that is how a savings ledger ends
87
107
  * up with a denominator nobody measured.
88
108
  */
89
- arm(match, prompt, wrapped) {
109
+ async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}) {
90
110
  const state = {
91
111
  scenarioId: match.scenarioId,
92
112
  ticket: match.executionTicket,
@@ -104,40 +124,156 @@ export class ReplayController {
104
124
  armedAt: Date.now(),
105
125
  reported: false,
106
126
  retired: false,
127
+ armedBy,
128
+ // An older server sends no `kind`, and a whole-run scenario is the safe
129
+ // reading: it is what every hand-out was before segments existed.
130
+ kind: match.kind ?? "scenario",
131
+ segment: match.segment,
132
+ key: match.key,
133
+ verified: match.verified,
134
+ sharedWith: [],
107
135
  };
108
- const decline = (why) => {
136
+ // A segment is judged against its own gate, and a `same` verdict stands in
137
+ // for the score (segmented.md R-OUT-11): the question was asked *because*
138
+ // the score sat below the threshold, so a numeric gate would decline every
139
+ // verified hand-out.
140
+ const isSegment = state.kind === "segment";
141
+ const threshold = isSegment
142
+ ? (this.opts.minSegmentSimilarity ?? 0.95)
143
+ : this.opts.minSimilarity;
144
+ const decline = (why, code) => {
145
+ state.declined = code;
109
146
  logLine("replay.decision", {
110
147
  verdict: "no-steer",
111
148
  run: match.runId,
112
149
  scenario: match.scenarioId ?? undefined,
113
150
  similarity: match.similarity.toFixed(3),
114
- threshold: this.opts.minSimilarity,
151
+ threshold,
152
+ kind: state.kind,
153
+ key: state.key,
154
+ verified: state.verified?.verdict,
115
155
  why,
116
156
  });
117
157
  return state;
118
158
  };
159
+ // The server never sends a segment to a runner that did not ask for one
160
+ // (R-OUT-9). One arriving anyway is declined here rather than run: an
161
+ // observe-only period that quietly armed something would be worthless.
162
+ if (isSegment && !this.segmentArm) {
163
+ return decline("segment hand-out while BIR_SEGMENT_ARM is off", "replay_disabled");
164
+ }
119
165
  // Gate 2 — replay enabled.
120
166
  if (!this.enabled)
121
- return decline("BIR_REPLAY is not set");
167
+ return decline("BIR_REPLAY is not set", "replay_disabled");
122
168
  // Gate 3 — a ready scenario with steps.
123
169
  const scenario = match.scenario;
124
170
  if (!match.scenarioId || !isReadyScenario(scenario)) {
125
- return decline(match.scenarioId ? "scenario is not ready" : "no scenario for the matched run");
171
+ return decline(match.scenarioId ? "scenario is not ready" : "no scenario for the matched run", "not_ready");
126
172
  }
127
173
  // Gate 4 — similarity. Detection decides "don't record this again"; steering
128
174
  // decides "don't think about this again", which is a much stronger claim and
129
175
  // deserves a stronger threshold.
130
- if (match.similarity < this.opts.minSimilarity) {
131
- return decline("similarity below threshold");
176
+ //
177
+ // For a segment, a `same` verdict from the live question stands in for the
178
+ // score (R-OUT-11). Only `same` ever reaches a runner with a ticket, so it
179
+ // is the only verdict this sees.
180
+ const scoreOk = match.similarity >= threshold;
181
+ const verifiedOk = isSegment && match.verified?.verdict === "same";
182
+ if (!scoreOk && !verifiedOk) {
183
+ return decline("similarity below threshold", "similarity");
184
+ }
185
+ // Flatten the chain before the remaining gates, so every one of them judges
186
+ // the steps that will *really* run — the caller's own and, in order, those
187
+ // of every segment it calls (segmented.md R-CALL-29, R-CALL-30).
188
+ let flat;
189
+ try {
190
+ flat = flattenChain(scenario, {
191
+ range: match.segment
192
+ ? { stepFrom: match.segment.stepFrom, stepTo: match.segment.stepTo }
193
+ : null,
194
+ });
195
+ }
196
+ catch (err) {
197
+ // A payload we cannot lay out is a payload we cannot run. It costs the
198
+ // turn nothing: the agent does the task (R-FALL-12).
199
+ logLine("replay.flatten_failed", { scenario: scenario.id, error: errText(err) });
200
+ return decline("the chain could not be flattened", "flatten_failed");
132
201
  }
133
- // Gate 5 — tool coverage.
134
- const mode = modeFor(scenario.steps, wrapped, this.opts.allowServers);
202
+ const entries = flat.entries;
203
+ if (entries.length === 0)
204
+ return decline("the chain has no runnable step", "not_ready");
205
+ // Gate 5 — known-bad steps (fallbk.md D3). The plan ends in front of the
206
+ // first step the service has seen fail more than its limit — the counter on
207
+ // whichever scenario owns that step (R-CALL-30). A call the service could
208
+ // not answer stops the plan the same way, and the earlier of the two wins.
209
+ const badAt = knownBadStepIndex(entries.map((e) => e.step), match.fallback?.maxStepFailures);
210
+ const rawStop = [flat.stopAt, badAt].filter((n) => typeof n === "number");
211
+ // Clamped to the start of the outermost called frame it falls in: a plan
212
+ // never runs half a sub-task and leaves its result mapping unevaluated.
213
+ const stopAt = rawStop.length > 0 ? clampToFrameStart(entries, Math.min(...rawStop)) : undefined;
214
+ if (stopAt === 0) {
215
+ const why = flat.stopAt === 0 && flat.stopReason
216
+ ? `its first step is a call that cannot run: ${flat.stopReason.reason}`
217
+ : `its first step has failed ${entries[0].step.failureCount ?? 0} times — the agent does this task`;
218
+ return decline(why, flat.stopAt === 0 && flat.stopReason ? "unusable_first_step" : "known_bad_first_step");
219
+ }
220
+ const planned = stopAt === undefined ? entries : entries.slice(0, stopAt);
221
+ // Gate 6 — tool coverage, over the steps that will actually run.
222
+ const mode = modeFor(planned.map((e) => e.step), wrapped, this.opts.allowServers);
135
223
  if (mode === "none")
136
- return decline("no step is executable");
137
- const params = this.startDerivation(scenario, prompt, state);
138
- state.plan = new ScenarioReplayPlan({ scenario, params, mode });
224
+ return decline("no step is executable", "coverage");
225
+ const derivation = this.startDerivation(scenario, prompt, state, ctx);
226
+ const params = derivation.then((r) => r.params);
227
+ // `params` is awaited by the plan, and by gate 7 below when there is a
228
+ // target. Without this, the losing race in gate 7 leaves `derivation`
229
+ // rejected and unhandled, which takes the process down on an unhandled
230
+ // rejection rather than declining one plan.
231
+ params.catch(() => undefined);
232
+ // Gate 7 — targets (segmented.md R-PARAM-3, R-PARAM-4). The last gate,
233
+ // because it is the only one that spends a model call and waits. A scenario
234
+ // whose parameters are all settings arms at once, as before: there is
235
+ // nothing the turn has to supply.
236
+ if (hasTargets(scenario.paramsObject)) {
237
+ let result;
238
+ try {
239
+ result = await this.withBudget(derivation, this.budgets.deriveMs, "derivation");
240
+ }
241
+ catch (err) {
242
+ // Out of budget, or the call threw. Either way the turn has not said
243
+ // what this task is to act on, and running it would act on something
244
+ // else (R-PARAM-3).
245
+ const first = firstTargetKey(scenario.paramsObject);
246
+ return decline(`target ${first ?? "(unknown)"} not found — ${errText(err)}`, "missing_target");
247
+ }
248
+ if (!result.derived) {
249
+ // No key: settings can still take their recorded values, but a target
250
+ // is a guess nobody is allowed to make (R-PARAM-5).
251
+ const first = firstTargetKey(scenario.paramsObject);
252
+ return decline(`no_derive_key: target ${first ?? "(unknown)"}`, "no_derive_key");
253
+ }
254
+ if (result.missing.length > 0) {
255
+ return decline(`target ${result.missing[0]} not found`, "missing_target");
256
+ }
257
+ }
258
+ // A plan armed by intent runs inside a task the agent is already doing, so
259
+ // its directive and its bundles say "continue", never "answer the user"
260
+ // (segmented.md R-OUT-13).
261
+ state.plan = new ScenarioReplayPlan({
262
+ scenario,
263
+ params,
264
+ mode,
265
+ stopAt,
266
+ entries,
267
+ subTask: armedBy === "intent",
268
+ });
139
269
  state.mode = mode;
140
- state.stepsPlanned = scenario.steps.length;
270
+ state.stepsPlanned = entries.length;
271
+ // Why the plan stops, when a call is what stops it: the note names the
272
+ // sub-task rather than a tool (R-CALL-36).
273
+ state.flatStop =
274
+ typeof stopAt === "number" && flat.stopAt !== undefined && stopAt <= flat.stopAt
275
+ ? flat.stopReason
276
+ : undefined;
141
277
  if (this.opts.authUrl && this.opts.authToken) {
142
278
  state.sourceRun = new SourceRunOutputs({
143
279
  baseUrl: this.opts.authUrl,
@@ -151,16 +287,54 @@ export class ReplayController {
151
287
  run: match.runId,
152
288
  scenario: scenario.id,
153
289
  mode,
154
- steps: scenario.steps.length,
290
+ steps: entries.length,
291
+ stopsAt: stopAt,
292
+ by: armedBy === "intent" ? "intent" : undefined,
293
+ kind: state.kind,
294
+ key: state.key,
295
+ verified: state.verified?.verdict,
155
296
  similarity: match.similarity.toFixed(3),
156
- coverage: coverageOf(scenario.steps, wrapped, this.opts.allowServers).join(","),
157
- tools: scenario.steps.map((s) => s.toolName).join(","),
297
+ // What this chain calls, and what answers each call right now (R-CALL-32).
298
+ calls: callLines(scenario),
299
+ unusable: flat.stopReason?.reason,
300
+ coverage: coverageOf(entries.map((e) => e.step), wrapped, this.opts.allowServers).join(","),
301
+ tools: entries.map((e) => e.step.toolName ?? "").join(","),
158
302
  });
159
303
  return state;
160
304
  }
161
305
  /** The directive to inject via `additionalContext`, or undefined when declined. */
162
306
  directiveFor(state) {
163
- return state.plan?.steeringDirective(DIRECT_TOOL_NAME);
307
+ return state.plan?.steeringDirective(DIRECT_TOOL_NAME, {
308
+ subTask: state.armedBy === "intent",
309
+ });
310
+ }
311
+ /**
312
+ * A hand-over note not yet delivered, claimed for delivery (fallbk.md D4).
313
+ * Returns it once and marks it delivered; undefined when there is none.
314
+ */
315
+ takeNote(state) {
316
+ const h = state?.handover;
317
+ if (!h || h.delivered)
318
+ return undefined;
319
+ h.delivered = true;
320
+ logDetail("replay.handover_delivered", {
321
+ scenario: state.scenarioId ?? undefined,
322
+ bytes: h.note.length,
323
+ });
324
+ return h.note;
325
+ }
326
+ /**
327
+ * Deliver text to the model in place of the tool call it was about to make:
328
+ * as genuine command output for `Bash`, as the denial reason otherwise — the
329
+ * two channels a divergence bundle already uses.
330
+ */
331
+ deliverInstead(text, toolName) {
332
+ if (toolName === "Bash") {
333
+ const delimiter = "BIR_EOF";
334
+ const safe = text.split(delimiter).join("BIR_EOF_");
335
+ return { kind: "bash", command: `cat <<'${delimiter}'\n${safe}\n${delimiter}` };
336
+ }
337
+ return { kind: "deny", reason: text };
164
338
  }
165
339
  /**
166
340
  * `PreToolUse`, while a plan is active.
@@ -210,6 +384,11 @@ export class ReplayController {
210
384
  // step and will throw on the same logic — and its own report would then
211
385
  // be the only trace, attributed to a re-execution rather than to the
212
386
  // steered call that actually hit it first.
387
+ //
388
+ // Not a divergence any more (fallbk.md): recovering by executing the
389
+ // rest would start at this very step and throw on the same logic. The
390
+ // step is recorded as failed, and the task is handed to the model with
391
+ // a note, delivered in place of this call.
213
392
  const step = plan.currentStep();
214
393
  if (step) {
215
394
  this.recordStep(state, {
@@ -218,8 +397,18 @@ export class ReplayController {
218
397
  stage: "tool_input_logic",
219
398
  error: errText(err),
220
399
  });
400
+ this.handOver(state, {
401
+ kind: "step_failed",
402
+ step,
403
+ position: plan.currentStepIndex,
404
+ error: errText(err),
405
+ delivery: "above",
406
+ });
407
+ const note = this.takeNote(state);
408
+ if (note)
409
+ return this.deliverInstead(note, toolName);
221
410
  }
222
- return await this.diverge(state, toolName, `toolInputLogic threw: ${errText(err)}`);
411
+ return { kind: "abort" };
223
412
  }
224
413
  }
225
414
  // HOUSEKEEPING IS NOT DIVERGENCE. See `record/housekeeping.ts`.
@@ -270,8 +459,22 @@ export class ReplayController {
270
459
  error: errText(err),
271
460
  ms: Date.now() - pin.pinnedAt,
272
461
  });
462
+ // The tool ran, so the step's call and result are in front of the model;
463
+ // what failed is threading its output into the plan. The rest is the
464
+ // model's (fallbk.md). `stepsPinned` counts it: its work happened.
465
+ state.stepsPinned += 1;
466
+ this.handOver(state, {
467
+ kind: "step_failed",
468
+ step,
469
+ position: pin.stepIndex,
470
+ error: errText(err),
471
+ delivery: "above",
472
+ ranThisStep: true,
473
+ });
474
+ }
475
+ else {
476
+ this.retire(state, "failed");
273
477
  }
274
- this.retire(state, "failed");
275
478
  return false;
276
479
  }
277
480
  // One more step ran under the plan. Counted here, as each pin threads, and
@@ -293,7 +496,15 @@ export class ReplayController {
293
496
  };
294
497
  this.logStep(info);
295
498
  this.recordStep(state, info);
296
- this.retire(state, "failed");
499
+ // Uncount it: it was counted above as threaded, but its work did not happen.
500
+ state.stepsPinned -= 1;
501
+ this.handOver(state, {
502
+ kind: "step_failed",
503
+ step,
504
+ position: pin.stepIndex,
505
+ error: toolError,
506
+ delivery: "above",
507
+ });
297
508
  return false;
298
509
  }
299
510
  if (step) {
@@ -310,9 +521,25 @@ export class ReplayController {
310
521
  emitted: derivedKeys.join(",") || undefined,
311
522
  done,
312
523
  });
524
+ if (done && plan.stopsEarly()) {
525
+ // Every planned step ran, and the next one is parked (fallbk.md D3) — or
526
+ // is a call the service could not answer (segmented.md R-CALL-29).
527
+ const parked = plan.stopStep();
528
+ this.handOver(state, {
529
+ kind: state.flatStop ? "unusable_call" : "known_bad_step",
530
+ step: parked,
531
+ entry: plan.stopEntry(),
532
+ position: plan.stepCount,
533
+ delivery: "above",
534
+ });
535
+ return true;
536
+ }
313
537
  if (done) {
314
538
  this.upgrade(state, "steered_full");
315
- this.retire(state, undefined);
539
+ // A completing retire: the last step threaded and the plan's work is over
540
+ // (segmented.md R-MONEY-3). Everything the agent spends after this is the
541
+ // agent's own, not this plan's.
542
+ this.retire(state, undefined, true);
316
543
  logLine("replay.done", {
317
544
  scenario: state.scenarioId ?? undefined,
318
545
  mode: state.mode,
@@ -354,7 +581,7 @@ export class ReplayController {
354
581
  const deadline = Date.now() + this.budgets.planMs;
355
582
  let result;
356
583
  try {
357
- result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state), deadline);
584
+ result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, this.observeStep(state), deadline, MAX_REPLAY_REASON, { stopOnFailure: true });
358
585
  }
359
586
  catch (err) {
360
587
  // The scenario's own logic failed. Retire and let the model do the work.
@@ -366,6 +593,37 @@ export class ReplayController {
366
593
  this.retire(state, "failed");
367
594
  return { ok: false, why: "not_ready" };
368
595
  }
596
+ // The plan stopped part-way (fallbk.md §Runner 3): a step broke, or the
597
+ // next one is parked. The steps that ran are real, so their bundle goes to
598
+ // the model — behind a note saying the rest of the task is its own.
599
+ if (result.stopped) {
600
+ const stop = result.stopped;
601
+ const ranOk = result.executed + result.recorded - result.errored;
602
+ state.stepsPinned += Math.max(0, ranOk);
603
+ const position = stop.flatIndex ?? plan.stepCount;
604
+ const step = plan.allSteps()[position];
605
+ this.handOver(state, {
606
+ kind: stop.kind,
607
+ step,
608
+ entry: plan.allEntries()[position],
609
+ position,
610
+ error: stop.error,
611
+ delivery: "below",
612
+ });
613
+ const note = this.takeNote(state) ?? state.handover?.note ?? "";
614
+ if (result.executed + result.recorded === 0) {
615
+ // Nothing ran: there is no bundle, only the task. The tool answers with
616
+ // an error that says so, which is what a model reacts to best.
617
+ return { ok: false, why: note };
618
+ }
619
+ return {
620
+ ok: true,
621
+ text: `${note}\n\n${result.text}`,
622
+ responseModel: {},
623
+ steps: result.executed + result.recorded,
624
+ partial: result.partial,
625
+ };
626
+ }
369
627
  state.stepsPinned += result.executed + result.recorded;
370
628
  let responseModel = {};
371
629
  try {
@@ -382,7 +640,10 @@ export class ReplayController {
382
640
  : result.partial || result.skipped > 0
383
641
  ? "diverged"
384
642
  : "steered_full");
385
- this.retire(state, undefined);
643
+ // `runToCompletion` returned without a stop: the plan ran to its end, so
644
+ // this is a completing retire (R-MONEY-3). The stopped path handed over
645
+ // above and took no mark.
646
+ this.retire(state, undefined, true);
386
647
  logLine("replay.done", {
387
648
  scenario: state.scenarioId ?? undefined,
388
649
  mode: state.mode,
@@ -445,6 +706,23 @@ export class ReplayController {
445
706
  errorStage: blame?.stage,
446
707
  errorStepIndex: blame?.stepIndex,
447
708
  errorToolName: blame?.toolName,
709
+ // Where the plan handed the task to the model (fallbk.md). Only on
710
+ // `fell_back`: a hand-over before any step ran is `failed`, and a parked
711
+ // step found after a divergence is still a divergence.
712
+ fallbackStepIndex: state.outcome === "fell_back" ? state.handover?.stepIndex : undefined,
713
+ fallbackKind: state.outcome === "fell_back" ? state.handover?.kind : undefined,
714
+ // Segments of this plan's recording that armed later in the turn
715
+ // (segmented.md R-MONEY-4): their share comes off this plan's baseline.
716
+ sharedWith: state.sharedWith.length > 0 ? [...state.sharedWith] : undefined,
717
+ // A turn in which another plan ran is not a measurement of what this task
718
+ // costs unaided, so it is no baseline sample (segmented.md R-MONEY-5).
719
+ // This covers two plans that both armed, and — the case that was silently
720
+ // wrong before — a declined prompt scenario followed by an intent-armed
721
+ // plan, whose "baseline" was really the turn cut short at the second arm.
722
+ // A lone plan, armed or declined, sends nothing: the default is eligible.
723
+ baselineEligible: d.siblings?.some((s) => s !== state && s.plan !== undefined)
724
+ ? false
725
+ : undefined,
448
726
  };
449
727
  }
450
728
  /**
@@ -476,7 +754,18 @@ export class ReplayController {
476
754
  async runAdHoc(scenario, prompt, wrapped) {
477
755
  if (!isReadyScenario(scenario))
478
756
  return { ok: false, why: "scenario is not ready" };
479
- const mode = modeFor(scenario.steps, wrapped, this.opts.allowServers);
757
+ // A chain with calls is run by hand the same way it is run by a match: one
758
+ // flat list, one frame per call (segmented.md R-CALL-37).
759
+ let flat;
760
+ try {
761
+ flat = flattenChain(scenario);
762
+ }
763
+ catch (err) {
764
+ return { ok: false, why: `the chain could not be flattened: ${errText(err)}` };
765
+ }
766
+ if (flat.entries.length === 0)
767
+ return { ok: false, why: "the chain has no runnable step" };
768
+ const mode = modeFor(flat.entries.map((e) => e.step), wrapped, this.opts.allowServers);
480
769
  if (mode === "none")
481
770
  return { ok: false, why: "no step is executable" };
482
771
  const state = {
@@ -497,10 +786,43 @@ export class ReplayController {
497
786
  // an ad-hoc run anyway.
498
787
  reported: true,
499
788
  retired: false,
789
+ armedBy: "prompt",
790
+ // `bir replay` runs a scenario an operator named by id: never a segment
791
+ // hand-out, and nothing to share a baseline with.
792
+ kind: "scenario",
793
+ sharedWith: [],
500
794
  };
501
- const params = this.startDerivation(scenario, prompt, state);
502
- const plan = new ScenarioReplayPlan({ scenario, params, mode });
795
+ // `bir replay` keeps its own contract on a missing target (segmented.md
796
+ // R-CALL-37): an operator asked for this run by id, so a sample stands in
797
+ // and it never declines. It says which, as the console's dry replay does
798
+ // (R-PARAM-7), so nobody reads the output as proof the values were derived.
799
+ const schema = scenario.paramsObject ?? {};
800
+ const params = this.startDerivation(scenario, prompt, state).then((r) => {
801
+ const out = { ...r.params };
802
+ const fromSamples = [];
803
+ for (const key of Object.keys(schema)) {
804
+ if (out[key] !== null && out[key] !== undefined)
805
+ continue;
806
+ out[key] = schema[key].sampleValue;
807
+ fromSamples.push(key);
808
+ }
809
+ if (fromSamples.length > 0) {
810
+ logLine("replay.derived", {
811
+ scenario: scenario.id,
812
+ why: `targets from samples: ${fromSamples.join(", ")}`,
813
+ });
814
+ }
815
+ return out;
816
+ });
817
+ const plan = new ScenarioReplayPlan({
818
+ scenario,
819
+ params,
820
+ mode,
821
+ entries: flat.entries,
822
+ stopAt: flat.stopAt,
823
+ });
503
824
  state.plan = plan;
825
+ state.flatStop = flat.stopReason;
504
826
  if (this.opts.authUrl && this.opts.authToken) {
505
827
  state.sourceRun = new SourceRunOutputs({
506
828
  baseUrl: this.opts.authUrl,
@@ -524,11 +846,11 @@ export class ReplayController {
524
846
  }
525
847
  const trace = [];
526
848
  try {
527
- const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, (info) => {
849
+ const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, (info) => {
528
850
  this.observeStep(state)(info);
529
851
  trace.push({
530
852
  step: info.step.stepIndex,
531
- tool: info.step.toolName,
853
+ tool: info.step.toolName ?? "",
532
854
  input: info.input,
533
855
  outcome: info.outcome,
534
856
  ms: info.ms,
@@ -564,7 +886,7 @@ export class ReplayController {
564
886
  * directive the moment the match lands; the first `PreToolUse` — or
565
887
  * `/scenario/run`, which has no hook timeout at all — is where the wait lands.
566
888
  */
567
- startDerivation(scenario, prompt, state) {
889
+ startDerivation(scenario, prompt, state, ctx = {}) {
568
890
  const derive = this.opts.deriveImpl ?? deriveParameters;
569
891
  return derive({
570
892
  prompt,
@@ -572,6 +894,8 @@ export class ReplayController {
572
894
  paramsObject: scenario.paramsObject,
573
895
  apiKey: this.opts.apiKey ?? process.env.ANTHROPIC_API_KEY,
574
896
  fetchImpl: this.opts.fetchImpl,
897
+ liveCall: ctx.liveCall,
898
+ recentResults: ctx.recentResults,
575
899
  })
576
900
  .then((r) => {
577
901
  state.deriveCostUsd += r.costUsd;
@@ -580,20 +904,36 @@ export class ReplayController {
580
904
  params: Object.keys(r.params).length,
581
905
  costUsd: r.costUsd.toFixed(4),
582
906
  source: r.derived ? "prompt" : "recorded samples",
907
+ missing: r.missing.length > 0 ? r.missing.join(",") : undefined,
908
+ sampled: r.sampled.length > 0 ? r.sampled.join(",") : undefined,
583
909
  });
584
- return r.params;
910
+ return r;
585
911
  })
586
912
  .catch((err) => {
587
913
  logLine("replay.derive_failed", {
588
914
  scenario: scenario.id,
589
- why: "falling back to the scenario's recorded sample values",
915
+ why: "settings fall back to their recorded samples; a target does not",
590
916
  error: errText(err),
591
917
  });
918
+ // A throw is a missing target when the scenario has one: gate 7 rethrows
919
+ // on `missing`, and this shape says so without the caller having to know
920
+ // whether the failure was a timeout or a 500. Settings still take their
921
+ // samples, so an all-settings scenario is unaffected by a broken key.
922
+ const schema = scenario.paramsObject ?? {};
592
923
  const fallback = {};
593
- for (const [key, entry] of Object.entries(scenario.paramsObject ?? {})) {
594
- fallback[key] = entry.sampleValue;
924
+ const missing = [];
925
+ const sampled = [];
926
+ for (const [key, entry] of Object.entries(schema)) {
927
+ if (entry?.kind === "setting") {
928
+ fallback[key] = entry.sampleValue;
929
+ sampled.push(key);
930
+ }
931
+ else {
932
+ fallback[key] = null;
933
+ missing.push(key);
934
+ }
595
935
  }
596
- return fallback;
936
+ return { params: fallback, costUsd: 0, derived: true, missing, sampled };
597
937
  });
598
938
  }
599
939
  /**
@@ -616,7 +956,9 @@ export class ReplayController {
616
956
  this.retire(state, undefined);
617
957
  let composed;
618
958
  try {
619
- composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state));
959
+ composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state),
960
+ // A parked step ends the bundle short of the task (fallbk.md D3).
961
+ { handover: plan.stopsEarly() });
620
962
  }
621
963
  catch (err) {
622
964
  this.upgrade(state, "failed");
@@ -653,17 +995,110 @@ export class ReplayController {
653
995
  // Nothing survived; a bundle of nothing is worse than no bundle.
654
996
  return { kind: "abort" };
655
997
  }
998
+ // The bundle stops in front of a parked step: say so ahead of it, and the
999
+ // model carries on from there. The outcome stays `diverged` — the model
1000
+ // went off-script first — so the report carries no fallback fields.
1001
+ let text = composed.text;
1002
+ if (plan.stopsEarly()) {
1003
+ const parked = plan.stopStep();
1004
+ const stopKind = state.flatStop ? "unusable_call" : "known_bad_step";
1005
+ state.handover = {
1006
+ kind: stopKind,
1007
+ stepIndex: parked.stepIndex,
1008
+ toolName: parked.toolName ?? undefined,
1009
+ note: this.noteFor(state, {
1010
+ kind: stopKind,
1011
+ step: parked,
1012
+ entry: plan.stopEntry(),
1013
+ position: plan.stepCount,
1014
+ delivery: "below",
1015
+ }),
1016
+ delivered: true,
1017
+ };
1018
+ text = `${state.handover.note}\n\n${composed.text}`;
1019
+ }
656
1020
  // Bash-clean delivery: the model reaches for `Bash` when it cannot call a
657
1021
  // scripted tool, and a genuine command output is trusted where a `deny`
658
1022
  // reason is read as adversarial interception. Neutralise the delimiter first.
659
- if (toolName === "Bash") {
660
- const delimiter = "BIR_EOF";
661
- const safe = composed.text.split(delimiter).join("BIR_EOF_");
662
- logDetail("replay.inject", { channel: "bash", bytes: composed.text.length });
663
- return { kind: "bash", command: `cat <<'${delimiter}'\n${safe}\n${delimiter}` };
1023
+ const action = this.deliverInstead(text, toolName);
1024
+ logDetail("replay.inject", { channel: action.kind, bytes: text.length });
1025
+ return action;
1026
+ }
1027
+ /**
1028
+ * Hand the rest of the task to the model (fallbk.md D4/D5).
1029
+ *
1030
+ * Retires the plan and builds the note; delivery is the caller's, because the
1031
+ * channel depends on the hook that noticed. The outcome is `fell_back` when at
1032
+ * least one step did its work under the plan, and `failed` when none did — a
1033
+ * scenario that did nothing has nothing to book, and is a baseline sample.
1034
+ */
1035
+ handOver(state, h) {
1036
+ if (state.handover)
1037
+ return;
1038
+ const ran = state.stepsPinned;
1039
+ this.retire(state, ran > 0 ? "fell_back" : "failed");
1040
+ state.handover = {
1041
+ kind: h.kind,
1042
+ // The *caller's* index: a fragment opens at the call row, not at a step
1043
+ // number the caller's own chain does not have (R-CALL-32, R-FALL-7).
1044
+ stepIndex: callerStepIndexOf(h.entry) ?? h.step?.stepIndex ?? h.position,
1045
+ toolName: h.step?.toolName ?? undefined,
1046
+ error: h.error,
1047
+ note: this.noteFor(state, h),
1048
+ delivered: false,
1049
+ };
1050
+ logLine("replay.handover", {
1051
+ scenario: state.scenarioId ?? undefined,
1052
+ kind: h.kind,
1053
+ step: `${h.position}/${state.plan?.totalSteps ?? state.stepsPlanned}`,
1054
+ tool: h.step?.toolName ?? undefined,
1055
+ // The scenario that owns the stopped step, when it is not the matched one.
1056
+ owner: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
1057
+ unusable: state.flatStop?.reason,
1058
+ callIntent: state.flatStop?.callIntent ?? undefined,
1059
+ segment: state.flatStop?.segmentId ?? undefined,
1060
+ ran,
1061
+ outcome: state.outcome,
1062
+ why: h.kind === "unusable_call"
1063
+ ? "a sub-task of this chain cannot run — the agent does that part itself"
1064
+ : h.kind === "known_bad_step"
1065
+ ? "the next step has failed too often — the agent continues from here"
1066
+ : "a step broke — the agent continues from here",
1067
+ });
1068
+ }
1069
+ noteFor(state, h) {
1070
+ const plan = state.plan;
1071
+ const executed = h.ranThisStep ? h.position + 1 : h.position;
1072
+ let params;
1073
+ try {
1074
+ params = plan?.parameters;
1075
+ }
1076
+ catch {
1077
+ params = undefined;
664
1078
  }
665
- logDetail("replay.inject", { channel: "deny", bytes: composed.text.length });
666
- return { kind: "deny", reason: composed.text };
1079
+ return buildHandoverNote({
1080
+ intent: plan?.intent ?? "",
1081
+ kind: h.kind,
1082
+ stepIndex: h.position,
1083
+ stepToolName: h.step?.toolName ?? undefined,
1084
+ failureCount: h.step?.failureCount,
1085
+ error: h.error,
1086
+ unusable: state.flatStop
1087
+ ? {
1088
+ reason: state.flatStop.reason,
1089
+ callIntent: state.flatStop.callIntent,
1090
+ segmentId: state.flatStop.segmentId,
1091
+ ownerScenarioId: state.flatStop.scenarioId,
1092
+ }
1093
+ : undefined,
1094
+ ownerScenarioId: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
1095
+ inSubTask: h.entry && h.entry.depth > 0 ? (h.entry.frame.callIntent ?? null) : null,
1096
+ executed: Math.max(0, Math.min(executed, plan?.totalSteps ?? executed)),
1097
+ totalSteps: plan?.totalSteps ?? state.stepsPlanned,
1098
+ remainingTools: plan ? plan.toolsFrom(h.ranThisStep ? h.position + 1 : h.position) : [],
1099
+ params,
1100
+ delivery: h.delivery,
1101
+ });
667
1102
  }
668
1103
  /**
669
1104
  * The executor handed to a plan: dispatch a step to the proxy that owns its
@@ -675,9 +1110,10 @@ export class ReplayController {
675
1110
  */
676
1111
  executeStep() {
677
1112
  return async (step, input) => {
678
- const mcp = parseQualifiedName(step.toolName);
679
- if (!mcp)
1113
+ const mcp = parseQualifiedName(step.toolName ?? "");
1114
+ if (!mcp) {
680
1115
  throw new Error(`${step.toolName} is not an MCP tool — it can only run in the session`);
1116
+ }
681
1117
  const result = await this.work.call(mcp.serverName, mcp.toolName, input, this.budgets.stepMs);
682
1118
  // Serialize exactly as the proxy records it, or `toolOutputLogic` — which
683
1119
  // was authored against that shape — silently derives nothing (§7.2).
@@ -697,7 +1133,7 @@ export class ReplayController {
697
1133
  * hook's differently-shaped view would derive nothing at all.
698
1134
  */
699
1135
  reachFor(state, step) {
700
- return reachOf(step.toolName, state.wrapped);
1136
+ return reachOf(step.toolName ?? "", state.wrapped);
701
1137
  }
702
1138
  /**
703
1139
  * Log a step and remember its verdict, in that order.
@@ -715,7 +1151,13 @@ export class ReplayController {
715
1151
  /** Upsert one step's verdict. Later news about a step replaces earlier news. */
716
1152
  recordStep(state, info) {
717
1153
  const status = info.outcome === "executed" ? "ok" : info.outcome;
718
- if (state.stepResults.size >= MAX_STEP_RESULTS && !state.stepResults.has(info.step.stepIndex)) {
1154
+ // Keyed by the *flat* position, not by the step's own index: two scenarios
1155
+ // in one plan both have a step 0, and one would otherwise overwrite the
1156
+ // other's verdict (segmented.md R-CALL-13).
1157
+ const key = info.entry
1158
+ ? `${info.entry.scenarioId}#${info.entry.stepIndex}`
1159
+ : `#${info.step.stepIndex}`;
1160
+ if (state.stepResults.size >= MAX_STEP_RESULTS && !state.stepResults.has(key)) {
719
1161
  return;
720
1162
  }
721
1163
  const error = info.error
@@ -723,29 +1165,35 @@ export class ReplayController {
723
1165
  ? `${info.error.slice(0, MAX_STEP_ERROR_CHARS)}…`
724
1166
  : info.error
725
1167
  : undefined;
726
- state.stepResults.set(info.step.stepIndex, {
1168
+ state.stepResults.set(key, {
727
1169
  stepIndex: info.step.stepIndex,
728
- toolName: info.step.toolName,
1170
+ toolName: info.step.toolName ?? "",
729
1171
  status,
730
1172
  // A stage is only meaningful when something went wrong. `skipped` broke at
731
1173
  // the call itself — its tool could not run here.
732
1174
  stage: info.stage ?? (status === "skipped" ? "tool_call" : undefined),
733
1175
  error,
734
1176
  durationMs: info.ms,
1177
+ // A verdict about a called segment's step is counted on that segment, at
1178
+ // the revision it was served (R-CALL-13).
1179
+ scenarioId: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
1180
+ chainRevision: info.entry && info.entry.depth > 0 ? info.entry.frame.chainRevision : undefined,
735
1181
  });
736
1182
  }
737
1183
  /** The step verdicts, in chain order, for the execution report. */
738
1184
  stepResultsOf(state) {
739
1185
  if (state.stepResults.size === 0)
740
1186
  return undefined;
741
- return [...state.stepResults.values()].sort((a, b) => a.stepIndex - b.stepIndex);
1187
+ return [...state.stepResults.values()];
742
1188
  }
743
1189
  logStep(info) {
744
- const mcp = parseQualifiedName(info.step.toolName);
1190
+ const mcp = parseQualifiedName(info.step.toolName ?? "");
745
1191
  if (info.outcome === "failed") {
746
1192
  logLine("replay.step_failed", {
747
1193
  n: info.step.stepIndex,
748
- tool: info.step.toolName,
1194
+ scenario: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
1195
+ frame: info.entry?.frame.id,
1196
+ tool: info.step.toolName ?? undefined,
749
1197
  stage: info.stage,
750
1198
  why: info.stage === "tool_call"
751
1199
  ? "its tool ran and reported an error — the step's work did not happen"
@@ -786,9 +1234,22 @@ export class ReplayController {
786
1234
  if (OUTCOME_RANK[outcome] > OUTCOME_RANK[state.outcome])
787
1235
  state.outcome = outcome;
788
1236
  }
789
- retire(state, outcome) {
1237
+ retire(state, outcome, completing = false) {
790
1238
  if (outcome)
791
1239
  this.upgrade(state, outcome);
1240
+ // Only a plan that *finished* stops the clock (segmented.md R-MONEY-3). A
1241
+ // hand-over or a divergence takes no mark: the agent's next tokens are the
1242
+ // consequence of this plan stopping, and belong to its bill.
1243
+ if (completing) {
1244
+ state.retiredAt = Date.now();
1245
+ try {
1246
+ state.retiredMark = state.markUsage?.();
1247
+ }
1248
+ catch {
1249
+ // An unreadable transcript leaves the mark undefined, and the window
1250
+ // falls back to the next arm or the end of the turn.
1251
+ }
1252
+ }
792
1253
  state.retired = true;
793
1254
  state.pinned.clear();
794
1255
  }
@@ -804,4 +1265,45 @@ export class ReplayController {
804
1265
  });
805
1266
  }
806
1267
  }
1268
+ /** The first target of a schema, for the sentence a decline logs. */
1269
+ function firstTargetKey(schema) {
1270
+ if (!schema)
1271
+ return undefined;
1272
+ return Object.keys(schema).find((k) => schema[k]?.kind !== "setting");
1273
+ }
1274
+ /**
1275
+ * Position of the first step whose failure count is above `maxStepFailures`
1276
+ * (fallbk.md D3), or undefined when none is — including when the service sent
1277
+ * no policy, which an older service never does.
1278
+ */
1279
+ /**
1280
+ * The index a hand-over reports, in the *caller's* chain (R-CALL-32).
1281
+ *
1282
+ * A step inside a called segment has no position in the caller's own chain; the
1283
+ * call row that opened its frame does. Reporting the segment's own index would
1284
+ * line the execution up against a step the caller's card does not have.
1285
+ */
1286
+ function callerStepIndexOf(entry) {
1287
+ if (!entry || entry.depth === 0)
1288
+ return entry?.stepIndex;
1289
+ let frame = entry.frame;
1290
+ while (frame.parent && frame.depth > 1)
1291
+ frame = frame.parent;
1292
+ return frame.callerStepIndex;
1293
+ }
1294
+ /** The calls in a served chain, for the `plan.armed` line (R-CALL-32). */
1295
+ function callLines(scenario) {
1296
+ const calls = (scenario.steps ?? []).filter((s) => s.kind === "segment");
1297
+ if (calls.length === 0)
1298
+ return undefined;
1299
+ return calls
1300
+ .map((s) => `${s.segmentId ?? "-"}:${s.unusable ?? s.resolution?.state ?? "resolved"}`)
1301
+ .join(",");
1302
+ }
1303
+ export function knownBadStepIndex(steps, maxStepFailures) {
1304
+ if (typeof maxStepFailures !== "number" || !Number.isFinite(maxStepFailures))
1305
+ return undefined;
1306
+ const i = steps.findIndex((s) => (s.failureCount ?? 0) > maxStepFailures);
1307
+ return i < 0 ? undefined : i;
1308
+ }
807
1309
  //# sourceMappingURL=controller.js.map