@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.
@@ -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,159 @@ 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");
132
184
  }
133
- // Gate 5 — tool coverage.
134
- const mode = modeFor(scenario.steps, wrapped, this.opts.allowServers);
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");
201
+ }
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
+ // Nobody read the turn — no key here and no service to ask, or the
250
+ // service declined. Settings can still take their recorded values, but a
251
+ // target is a guess nobody is allowed to make (R-PARAM-5). The reason
252
+ // travels into the line, because "it did not run" without one is how
253
+ // this stayed invisible before.
254
+ const first = firstTargetKey(scenario.paramsObject);
255
+ return decline(`${result.reason ?? "no_derive_key"}: target ${first ?? "(unknown)"}`, "no_derive_key");
256
+ }
257
+ if (result.missing.length > 0) {
258
+ return decline(`target ${result.missing[0]} not found`, "missing_target");
259
+ }
260
+ }
261
+ // A plan armed by intent runs inside a task the agent is already doing, so
262
+ // its directive and its bundles say "continue", never "answer the user"
263
+ // (segmented.md R-OUT-13).
264
+ state.plan = new ScenarioReplayPlan({
265
+ scenario,
266
+ params,
267
+ mode,
268
+ stopAt,
269
+ entries,
270
+ subTask: armedBy === "intent",
271
+ });
139
272
  state.mode = mode;
140
- state.stepsPlanned = scenario.steps.length;
273
+ state.stepsPlanned = entries.length;
274
+ // Why the plan stops, when a call is what stops it: the note names the
275
+ // sub-task rather than a tool (R-CALL-36).
276
+ state.flatStop =
277
+ typeof stopAt === "number" && flat.stopAt !== undefined && stopAt <= flat.stopAt
278
+ ? flat.stopReason
279
+ : undefined;
141
280
  if (this.opts.authUrl && this.opts.authToken) {
142
281
  state.sourceRun = new SourceRunOutputs({
143
282
  baseUrl: this.opts.authUrl,
@@ -151,16 +290,54 @@ export class ReplayController {
151
290
  run: match.runId,
152
291
  scenario: scenario.id,
153
292
  mode,
154
- steps: scenario.steps.length,
293
+ steps: entries.length,
294
+ stopsAt: stopAt,
295
+ by: armedBy === "intent" ? "intent" : undefined,
296
+ kind: state.kind,
297
+ key: state.key,
298
+ verified: state.verified?.verdict,
155
299
  similarity: match.similarity.toFixed(3),
156
- coverage: coverageOf(scenario.steps, wrapped, this.opts.allowServers).join(","),
157
- tools: scenario.steps.map((s) => s.toolName).join(","),
300
+ // What this chain calls, and what answers each call right now (R-CALL-32).
301
+ calls: callLines(scenario),
302
+ unusable: flat.stopReason?.reason,
303
+ coverage: coverageOf(entries.map((e) => e.step), wrapped, this.opts.allowServers).join(","),
304
+ tools: entries.map((e) => e.step.toolName ?? "").join(","),
158
305
  });
159
306
  return state;
160
307
  }
161
308
  /** The directive to inject via `additionalContext`, or undefined when declined. */
162
309
  directiveFor(state) {
163
- return state.plan?.steeringDirective(DIRECT_TOOL_NAME);
310
+ return state.plan?.steeringDirective(DIRECT_TOOL_NAME, {
311
+ subTask: state.armedBy === "intent",
312
+ });
313
+ }
314
+ /**
315
+ * A hand-over note not yet delivered, claimed for delivery (fallbk.md D4).
316
+ * Returns it once and marks it delivered; undefined when there is none.
317
+ */
318
+ takeNote(state) {
319
+ const h = state?.handover;
320
+ if (!h || h.delivered)
321
+ return undefined;
322
+ h.delivered = true;
323
+ logDetail("replay.handover_delivered", {
324
+ scenario: state.scenarioId ?? undefined,
325
+ bytes: h.note.length,
326
+ });
327
+ return h.note;
328
+ }
329
+ /**
330
+ * Deliver text to the model in place of the tool call it was about to make:
331
+ * as genuine command output for `Bash`, as the denial reason otherwise — the
332
+ * two channels a divergence bundle already uses.
333
+ */
334
+ deliverInstead(text, toolName) {
335
+ if (toolName === "Bash") {
336
+ const delimiter = "BIR_EOF";
337
+ const safe = text.split(delimiter).join("BIR_EOF_");
338
+ return { kind: "bash", command: `cat <<'${delimiter}'\n${safe}\n${delimiter}` };
339
+ }
340
+ return { kind: "deny", reason: text };
164
341
  }
165
342
  /**
166
343
  * `PreToolUse`, while a plan is active.
@@ -210,6 +387,11 @@ export class ReplayController {
210
387
  // step and will throw on the same logic — and its own report would then
211
388
  // be the only trace, attributed to a re-execution rather than to the
212
389
  // steered call that actually hit it first.
390
+ //
391
+ // Not a divergence any more (fallbk.md): recovering by executing the
392
+ // rest would start at this very step and throw on the same logic. The
393
+ // step is recorded as failed, and the task is handed to the model with
394
+ // a note, delivered in place of this call.
213
395
  const step = plan.currentStep();
214
396
  if (step) {
215
397
  this.recordStep(state, {
@@ -218,8 +400,18 @@ export class ReplayController {
218
400
  stage: "tool_input_logic",
219
401
  error: errText(err),
220
402
  });
403
+ this.handOver(state, {
404
+ kind: "step_failed",
405
+ step,
406
+ position: plan.currentStepIndex,
407
+ error: errText(err),
408
+ delivery: "above",
409
+ });
410
+ const note = this.takeNote(state);
411
+ if (note)
412
+ return this.deliverInstead(note, toolName);
221
413
  }
222
- return await this.diverge(state, toolName, `toolInputLogic threw: ${errText(err)}`);
414
+ return { kind: "abort" };
223
415
  }
224
416
  }
225
417
  // HOUSEKEEPING IS NOT DIVERGENCE. See `record/housekeeping.ts`.
@@ -270,8 +462,22 @@ export class ReplayController {
270
462
  error: errText(err),
271
463
  ms: Date.now() - pin.pinnedAt,
272
464
  });
465
+ // The tool ran, so the step's call and result are in front of the model;
466
+ // what failed is threading its output into the plan. The rest is the
467
+ // model's (fallbk.md). `stepsPinned` counts it: its work happened.
468
+ state.stepsPinned += 1;
469
+ this.handOver(state, {
470
+ kind: "step_failed",
471
+ step,
472
+ position: pin.stepIndex,
473
+ error: errText(err),
474
+ delivery: "above",
475
+ ranThisStep: true,
476
+ });
477
+ }
478
+ else {
479
+ this.retire(state, "failed");
273
480
  }
274
- this.retire(state, "failed");
275
481
  return false;
276
482
  }
277
483
  // One more step ran under the plan. Counted here, as each pin threads, and
@@ -293,7 +499,15 @@ export class ReplayController {
293
499
  };
294
500
  this.logStep(info);
295
501
  this.recordStep(state, info);
296
- this.retire(state, "failed");
502
+ // Uncount it: it was counted above as threaded, but its work did not happen.
503
+ state.stepsPinned -= 1;
504
+ this.handOver(state, {
505
+ kind: "step_failed",
506
+ step,
507
+ position: pin.stepIndex,
508
+ error: toolError,
509
+ delivery: "above",
510
+ });
297
511
  return false;
298
512
  }
299
513
  if (step) {
@@ -310,9 +524,25 @@ export class ReplayController {
310
524
  emitted: derivedKeys.join(",") || undefined,
311
525
  done,
312
526
  });
527
+ if (done && plan.stopsEarly()) {
528
+ // Every planned step ran, and the next one is parked (fallbk.md D3) — or
529
+ // is a call the service could not answer (segmented.md R-CALL-29).
530
+ const parked = plan.stopStep();
531
+ this.handOver(state, {
532
+ kind: state.flatStop ? "unusable_call" : "known_bad_step",
533
+ step: parked,
534
+ entry: plan.stopEntry(),
535
+ position: plan.stepCount,
536
+ delivery: "above",
537
+ });
538
+ return true;
539
+ }
313
540
  if (done) {
314
541
  this.upgrade(state, "steered_full");
315
- this.retire(state, undefined);
542
+ // A completing retire: the last step threaded and the plan's work is over
543
+ // (segmented.md R-MONEY-3). Everything the agent spends after this is the
544
+ // agent's own, not this plan's.
545
+ this.retire(state, undefined, true);
316
546
  logLine("replay.done", {
317
547
  scenario: state.scenarioId ?? undefined,
318
548
  mode: state.mode,
@@ -354,7 +584,7 @@ export class ReplayController {
354
584
  const deadline = Date.now() + this.budgets.planMs;
355
585
  let result;
356
586
  try {
357
- result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state), deadline);
587
+ result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, this.observeStep(state), deadline, MAX_REPLAY_REASON, { stopOnFailure: true });
358
588
  }
359
589
  catch (err) {
360
590
  // The scenario's own logic failed. Retire and let the model do the work.
@@ -366,6 +596,37 @@ export class ReplayController {
366
596
  this.retire(state, "failed");
367
597
  return { ok: false, why: "not_ready" };
368
598
  }
599
+ // The plan stopped part-way (fallbk.md §Runner 3): a step broke, or the
600
+ // next one is parked. The steps that ran are real, so their bundle goes to
601
+ // the model — behind a note saying the rest of the task is its own.
602
+ if (result.stopped) {
603
+ const stop = result.stopped;
604
+ const ranOk = result.executed + result.recorded - result.errored;
605
+ state.stepsPinned += Math.max(0, ranOk);
606
+ const position = stop.flatIndex ?? plan.stepCount;
607
+ const step = plan.allSteps()[position];
608
+ this.handOver(state, {
609
+ kind: stop.kind,
610
+ step,
611
+ entry: plan.allEntries()[position],
612
+ position,
613
+ error: stop.error,
614
+ delivery: "below",
615
+ });
616
+ const note = this.takeNote(state) ?? state.handover?.note ?? "";
617
+ if (result.executed + result.recorded === 0) {
618
+ // Nothing ran: there is no bundle, only the task. The tool answers with
619
+ // an error that says so, which is what a model reacts to best.
620
+ return { ok: false, why: note };
621
+ }
622
+ return {
623
+ ok: true,
624
+ text: `${note}\n\n${result.text}`,
625
+ responseModel: {},
626
+ steps: result.executed + result.recorded,
627
+ partial: result.partial,
628
+ };
629
+ }
369
630
  state.stepsPinned += result.executed + result.recorded;
370
631
  let responseModel = {};
371
632
  try {
@@ -382,7 +643,10 @@ export class ReplayController {
382
643
  : result.partial || result.skipped > 0
383
644
  ? "diverged"
384
645
  : "steered_full");
385
- this.retire(state, undefined);
646
+ // `runToCompletion` returned without a stop: the plan ran to its end, so
647
+ // this is a completing retire (R-MONEY-3). The stopped path handed over
648
+ // above and took no mark.
649
+ this.retire(state, undefined, true);
386
650
  logLine("replay.done", {
387
651
  scenario: state.scenarioId ?? undefined,
388
652
  mode: state.mode,
@@ -445,6 +709,23 @@ export class ReplayController {
445
709
  errorStage: blame?.stage,
446
710
  errorStepIndex: blame?.stepIndex,
447
711
  errorToolName: blame?.toolName,
712
+ // Where the plan handed the task to the model (fallbk.md). Only on
713
+ // `fell_back`: a hand-over before any step ran is `failed`, and a parked
714
+ // step found after a divergence is still a divergence.
715
+ fallbackStepIndex: state.outcome === "fell_back" ? state.handover?.stepIndex : undefined,
716
+ fallbackKind: state.outcome === "fell_back" ? state.handover?.kind : undefined,
717
+ // Segments of this plan's recording that armed later in the turn
718
+ // (segmented.md R-MONEY-4): their share comes off this plan's baseline.
719
+ sharedWith: state.sharedWith.length > 0 ? [...state.sharedWith] : undefined,
720
+ // A turn in which another plan ran is not a measurement of what this task
721
+ // costs unaided, so it is no baseline sample (segmented.md R-MONEY-5).
722
+ // This covers two plans that both armed, and — the case that was silently
723
+ // wrong before — a declined prompt scenario followed by an intent-armed
724
+ // plan, whose "baseline" was really the turn cut short at the second arm.
725
+ // A lone plan, armed or declined, sends nothing: the default is eligible.
726
+ baselineEligible: d.siblings?.some((s) => s !== state && s.plan !== undefined)
727
+ ? false
728
+ : undefined,
448
729
  };
449
730
  }
450
731
  /**
@@ -476,7 +757,18 @@ export class ReplayController {
476
757
  async runAdHoc(scenario, prompt, wrapped) {
477
758
  if (!isReadyScenario(scenario))
478
759
  return { ok: false, why: "scenario is not ready" };
479
- const mode = modeFor(scenario.steps, wrapped, this.opts.allowServers);
760
+ // A chain with calls is run by hand the same way it is run by a match: one
761
+ // flat list, one frame per call (segmented.md R-CALL-37).
762
+ let flat;
763
+ try {
764
+ flat = flattenChain(scenario);
765
+ }
766
+ catch (err) {
767
+ return { ok: false, why: `the chain could not be flattened: ${errText(err)}` };
768
+ }
769
+ if (flat.entries.length === 0)
770
+ return { ok: false, why: "the chain has no runnable step" };
771
+ const mode = modeFor(flat.entries.map((e) => e.step), wrapped, this.opts.allowServers);
480
772
  if (mode === "none")
481
773
  return { ok: false, why: "no step is executable" };
482
774
  const state = {
@@ -497,10 +789,43 @@ export class ReplayController {
497
789
  // an ad-hoc run anyway.
498
790
  reported: true,
499
791
  retired: false,
792
+ armedBy: "prompt",
793
+ // `bir replay` runs a scenario an operator named by id: never a segment
794
+ // hand-out, and nothing to share a baseline with.
795
+ kind: "scenario",
796
+ sharedWith: [],
500
797
  };
501
- const params = this.startDerivation(scenario, prompt, state);
502
- const plan = new ScenarioReplayPlan({ scenario, params, mode });
798
+ // `bir replay` keeps its own contract on a missing target (segmented.md
799
+ // R-CALL-37): an operator asked for this run by id, so a sample stands in
800
+ // and it never declines. It says which, as the console's dry replay does
801
+ // (R-PARAM-7), so nobody reads the output as proof the values were derived.
802
+ const schema = scenario.paramsObject ?? {};
803
+ const params = this.startDerivation(scenario, prompt, state).then((r) => {
804
+ const out = { ...r.params };
805
+ const fromSamples = [];
806
+ for (const key of Object.keys(schema)) {
807
+ if (out[key] !== null && out[key] !== undefined)
808
+ continue;
809
+ out[key] = schema[key].sampleValue;
810
+ fromSamples.push(key);
811
+ }
812
+ if (fromSamples.length > 0) {
813
+ logLine("replay.derived", {
814
+ scenario: scenario.id,
815
+ why: `targets from samples: ${fromSamples.join(", ")}`,
816
+ });
817
+ }
818
+ return out;
819
+ });
820
+ const plan = new ScenarioReplayPlan({
821
+ scenario,
822
+ params,
823
+ mode,
824
+ entries: flat.entries,
825
+ stopAt: flat.stopAt,
826
+ });
503
827
  state.plan = plan;
828
+ state.flatStop = flat.stopReason;
504
829
  if (this.opts.authUrl && this.opts.authToken) {
505
830
  state.sourceRun = new SourceRunOutputs({
506
831
  baseUrl: this.opts.authUrl,
@@ -524,11 +849,11 @@ export class ReplayController {
524
849
  }
525
850
  const trace = [];
526
851
  try {
527
- const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, (info) => {
852
+ const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, (info) => {
528
853
  this.observeStep(state)(info);
529
854
  trace.push({
530
855
  step: info.step.stepIndex,
531
- tool: info.step.toolName,
856
+ tool: info.step.toolName ?? "",
532
857
  input: info.input,
533
858
  outcome: info.outcome,
534
859
  ms: info.ms,
@@ -564,14 +889,35 @@ export class ReplayController {
564
889
  * directive the moment the match lands; the first `PreToolUse` — or
565
890
  * `/scenario/run`, which has no hook timeout at all — is where the wait lands.
566
891
  */
567
- startDerivation(scenario, prompt, state) {
892
+ /**
893
+ * Where to ask the service to read this turn (segmented.md R-PARAM-5).
894
+ *
895
+ * Undefined when there is nobody to ask — an unauthenticated session, or one
896
+ * whose token has gone. `derive` then falls back to the recorded samples, and
897
+ * a scenario with a target declines, exactly as a keyless runner always did.
898
+ *
899
+ * The token is read here rather than captured, because the recorder refreshes
900
+ * it as a session outlives it.
901
+ */
902
+ deriveService(scenarioId) {
903
+ const token = this.opts.authToken?.();
904
+ if (!this.opts.authUrl || !token)
905
+ return undefined;
906
+ return { baseUrl: this.opts.authUrl, token, scenarioId };
907
+ }
908
+ startDerivation(scenario, prompt, state, ctx = {}) {
568
909
  const derive = this.opts.deriveImpl ?? deriveParameters;
569
910
  return derive({
570
911
  prompt,
571
912
  intent: scenario.intent ?? "",
572
913
  paramsObject: scenario.paramsObject,
573
914
  apiKey: this.opts.apiKey ?? process.env.ANTHROPIC_API_KEY,
915
+ // Where to ask when there is no key here, which is the ordinary case
916
+ // (segmented.md R-PARAM-5).
917
+ service: this.deriveService(scenario.id),
574
918
  fetchImpl: this.opts.fetchImpl,
919
+ liveCall: ctx.liveCall,
920
+ recentResults: ctx.recentResults,
575
921
  })
576
922
  .then((r) => {
577
923
  state.deriveCostUsd += r.costUsd;
@@ -579,21 +925,42 @@ export class ReplayController {
579
925
  scenario: scenario.id,
580
926
  params: Object.keys(r.params).length,
581
927
  costUsd: r.costUsd.toFixed(4),
582
- source: r.derived ? "prompt" : "recorded samples",
928
+ source: r.derived
929
+ ? r.via === "service"
930
+ ? "the service"
931
+ : "prompt"
932
+ : "recorded samples",
933
+ why: r.derived ? undefined : r.reason,
934
+ missing: r.missing.length > 0 ? r.missing.join(",") : undefined,
935
+ sampled: r.sampled.length > 0 ? r.sampled.join(",") : undefined,
583
936
  });
584
- return r.params;
937
+ return r;
585
938
  })
586
939
  .catch((err) => {
587
940
  logLine("replay.derive_failed", {
588
941
  scenario: scenario.id,
589
- why: "falling back to the scenario's recorded sample values",
942
+ why: "settings fall back to their recorded samples; a target does not",
590
943
  error: errText(err),
591
944
  });
945
+ // A throw is a missing target when the scenario has one: gate 7 rethrows
946
+ // on `missing`, and this shape says so without the caller having to know
947
+ // whether the failure was a timeout or a 500. Settings still take their
948
+ // samples, so an all-settings scenario is unaffected by a broken key.
949
+ const schema = scenario.paramsObject ?? {};
592
950
  const fallback = {};
593
- for (const [key, entry] of Object.entries(scenario.paramsObject ?? {})) {
594
- fallback[key] = entry.sampleValue;
951
+ const missing = [];
952
+ const sampled = [];
953
+ for (const [key, entry] of Object.entries(schema)) {
954
+ if (entry?.kind === "setting") {
955
+ fallback[key] = entry.sampleValue;
956
+ sampled.push(key);
957
+ }
958
+ else {
959
+ fallback[key] = null;
960
+ missing.push(key);
961
+ }
595
962
  }
596
- return fallback;
963
+ return { params: fallback, costUsd: 0, derived: true, missing, sampled };
597
964
  });
598
965
  }
599
966
  /**
@@ -616,7 +983,9 @@ export class ReplayController {
616
983
  this.retire(state, undefined);
617
984
  let composed;
618
985
  try {
619
- composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state));
986
+ composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state),
987
+ // A parked step ends the bundle short of the task (fallbk.md D3).
988
+ { handover: plan.stopsEarly() });
620
989
  }
621
990
  catch (err) {
622
991
  this.upgrade(state, "failed");
@@ -653,17 +1022,110 @@ export class ReplayController {
653
1022
  // Nothing survived; a bundle of nothing is worse than no bundle.
654
1023
  return { kind: "abort" };
655
1024
  }
1025
+ // The bundle stops in front of a parked step: say so ahead of it, and the
1026
+ // model carries on from there. The outcome stays `diverged` — the model
1027
+ // went off-script first — so the report carries no fallback fields.
1028
+ let text = composed.text;
1029
+ if (plan.stopsEarly()) {
1030
+ const parked = plan.stopStep();
1031
+ const stopKind = state.flatStop ? "unusable_call" : "known_bad_step";
1032
+ state.handover = {
1033
+ kind: stopKind,
1034
+ stepIndex: parked.stepIndex,
1035
+ toolName: parked.toolName ?? undefined,
1036
+ note: this.noteFor(state, {
1037
+ kind: stopKind,
1038
+ step: parked,
1039
+ entry: plan.stopEntry(),
1040
+ position: plan.stepCount,
1041
+ delivery: "below",
1042
+ }),
1043
+ delivered: true,
1044
+ };
1045
+ text = `${state.handover.note}\n\n${composed.text}`;
1046
+ }
656
1047
  // Bash-clean delivery: the model reaches for `Bash` when it cannot call a
657
1048
  // scripted tool, and a genuine command output is trusted where a `deny`
658
1049
  // 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}` };
1050
+ const action = this.deliverInstead(text, toolName);
1051
+ logDetail("replay.inject", { channel: action.kind, bytes: text.length });
1052
+ return action;
1053
+ }
1054
+ /**
1055
+ * Hand the rest of the task to the model (fallbk.md D4/D5).
1056
+ *
1057
+ * Retires the plan and builds the note; delivery is the caller's, because the
1058
+ * channel depends on the hook that noticed. The outcome is `fell_back` when at
1059
+ * least one step did its work under the plan, and `failed` when none did — a
1060
+ * scenario that did nothing has nothing to book, and is a baseline sample.
1061
+ */
1062
+ handOver(state, h) {
1063
+ if (state.handover)
1064
+ return;
1065
+ const ran = state.stepsPinned;
1066
+ this.retire(state, ran > 0 ? "fell_back" : "failed");
1067
+ state.handover = {
1068
+ kind: h.kind,
1069
+ // The *caller's* index: a fragment opens at the call row, not at a step
1070
+ // number the caller's own chain does not have (R-CALL-32, R-FALL-7).
1071
+ stepIndex: callerStepIndexOf(h.entry) ?? h.step?.stepIndex ?? h.position,
1072
+ toolName: h.step?.toolName ?? undefined,
1073
+ error: h.error,
1074
+ note: this.noteFor(state, h),
1075
+ delivered: false,
1076
+ };
1077
+ logLine("replay.handover", {
1078
+ scenario: state.scenarioId ?? undefined,
1079
+ kind: h.kind,
1080
+ step: `${h.position}/${state.plan?.totalSteps ?? state.stepsPlanned}`,
1081
+ tool: h.step?.toolName ?? undefined,
1082
+ // The scenario that owns the stopped step, when it is not the matched one.
1083
+ owner: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
1084
+ unusable: state.flatStop?.reason,
1085
+ callIntent: state.flatStop?.callIntent ?? undefined,
1086
+ segment: state.flatStop?.segmentId ?? undefined,
1087
+ ran,
1088
+ outcome: state.outcome,
1089
+ why: h.kind === "unusable_call"
1090
+ ? "a sub-task of this chain cannot run — the agent does that part itself"
1091
+ : h.kind === "known_bad_step"
1092
+ ? "the next step has failed too often — the agent continues from here"
1093
+ : "a step broke — the agent continues from here",
1094
+ });
1095
+ }
1096
+ noteFor(state, h) {
1097
+ const plan = state.plan;
1098
+ const executed = h.ranThisStep ? h.position + 1 : h.position;
1099
+ let params;
1100
+ try {
1101
+ params = plan?.parameters;
1102
+ }
1103
+ catch {
1104
+ params = undefined;
664
1105
  }
665
- logDetail("replay.inject", { channel: "deny", bytes: composed.text.length });
666
- return { kind: "deny", reason: composed.text };
1106
+ return buildHandoverNote({
1107
+ intent: plan?.intent ?? "",
1108
+ kind: h.kind,
1109
+ stepIndex: h.position,
1110
+ stepToolName: h.step?.toolName ?? undefined,
1111
+ failureCount: h.step?.failureCount,
1112
+ error: h.error,
1113
+ unusable: state.flatStop
1114
+ ? {
1115
+ reason: state.flatStop.reason,
1116
+ callIntent: state.flatStop.callIntent,
1117
+ segmentId: state.flatStop.segmentId,
1118
+ ownerScenarioId: state.flatStop.scenarioId,
1119
+ }
1120
+ : undefined,
1121
+ ownerScenarioId: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
1122
+ inSubTask: h.entry && h.entry.depth > 0 ? (h.entry.frame.callIntent ?? null) : null,
1123
+ executed: Math.max(0, Math.min(executed, plan?.totalSteps ?? executed)),
1124
+ totalSteps: plan?.totalSteps ?? state.stepsPlanned,
1125
+ remainingTools: plan ? plan.toolsFrom(h.ranThisStep ? h.position + 1 : h.position) : [],
1126
+ params,
1127
+ delivery: h.delivery,
1128
+ });
667
1129
  }
668
1130
  /**
669
1131
  * The executor handed to a plan: dispatch a step to the proxy that owns its
@@ -675,9 +1137,10 @@ export class ReplayController {
675
1137
  */
676
1138
  executeStep() {
677
1139
  return async (step, input) => {
678
- const mcp = parseQualifiedName(step.toolName);
679
- if (!mcp)
1140
+ const mcp = parseQualifiedName(step.toolName ?? "");
1141
+ if (!mcp) {
680
1142
  throw new Error(`${step.toolName} is not an MCP tool — it can only run in the session`);
1143
+ }
681
1144
  const result = await this.work.call(mcp.serverName, mcp.toolName, input, this.budgets.stepMs);
682
1145
  // Serialize exactly as the proxy records it, or `toolOutputLogic` — which
683
1146
  // was authored against that shape — silently derives nothing (§7.2).
@@ -697,7 +1160,7 @@ export class ReplayController {
697
1160
  * hook's differently-shaped view would derive nothing at all.
698
1161
  */
699
1162
  reachFor(state, step) {
700
- return reachOf(step.toolName, state.wrapped);
1163
+ return reachOf(step.toolName ?? "", state.wrapped);
701
1164
  }
702
1165
  /**
703
1166
  * Log a step and remember its verdict, in that order.
@@ -715,7 +1178,13 @@ export class ReplayController {
715
1178
  /** Upsert one step's verdict. Later news about a step replaces earlier news. */
716
1179
  recordStep(state, info) {
717
1180
  const status = info.outcome === "executed" ? "ok" : info.outcome;
718
- if (state.stepResults.size >= MAX_STEP_RESULTS && !state.stepResults.has(info.step.stepIndex)) {
1181
+ // Keyed by the *flat* position, not by the step's own index: two scenarios
1182
+ // in one plan both have a step 0, and one would otherwise overwrite the
1183
+ // other's verdict (segmented.md R-CALL-13).
1184
+ const key = info.entry
1185
+ ? `${info.entry.scenarioId}#${info.entry.stepIndex}`
1186
+ : `#${info.step.stepIndex}`;
1187
+ if (state.stepResults.size >= MAX_STEP_RESULTS && !state.stepResults.has(key)) {
719
1188
  return;
720
1189
  }
721
1190
  const error = info.error
@@ -723,29 +1192,35 @@ export class ReplayController {
723
1192
  ? `${info.error.slice(0, MAX_STEP_ERROR_CHARS)}…`
724
1193
  : info.error
725
1194
  : undefined;
726
- state.stepResults.set(info.step.stepIndex, {
1195
+ state.stepResults.set(key, {
727
1196
  stepIndex: info.step.stepIndex,
728
- toolName: info.step.toolName,
1197
+ toolName: info.step.toolName ?? "",
729
1198
  status,
730
1199
  // A stage is only meaningful when something went wrong. `skipped` broke at
731
1200
  // the call itself — its tool could not run here.
732
1201
  stage: info.stage ?? (status === "skipped" ? "tool_call" : undefined),
733
1202
  error,
734
1203
  durationMs: info.ms,
1204
+ // A verdict about a called segment's step is counted on that segment, at
1205
+ // the revision it was served (R-CALL-13).
1206
+ scenarioId: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
1207
+ chainRevision: info.entry && info.entry.depth > 0 ? info.entry.frame.chainRevision : undefined,
735
1208
  });
736
1209
  }
737
1210
  /** The step verdicts, in chain order, for the execution report. */
738
1211
  stepResultsOf(state) {
739
1212
  if (state.stepResults.size === 0)
740
1213
  return undefined;
741
- return [...state.stepResults.values()].sort((a, b) => a.stepIndex - b.stepIndex);
1214
+ return [...state.stepResults.values()];
742
1215
  }
743
1216
  logStep(info) {
744
- const mcp = parseQualifiedName(info.step.toolName);
1217
+ const mcp = parseQualifiedName(info.step.toolName ?? "");
745
1218
  if (info.outcome === "failed") {
746
1219
  logLine("replay.step_failed", {
747
1220
  n: info.step.stepIndex,
748
- tool: info.step.toolName,
1221
+ scenario: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
1222
+ frame: info.entry?.frame.id,
1223
+ tool: info.step.toolName ?? undefined,
749
1224
  stage: info.stage,
750
1225
  why: info.stage === "tool_call"
751
1226
  ? "its tool ran and reported an error — the step's work did not happen"
@@ -786,9 +1261,22 @@ export class ReplayController {
786
1261
  if (OUTCOME_RANK[outcome] > OUTCOME_RANK[state.outcome])
787
1262
  state.outcome = outcome;
788
1263
  }
789
- retire(state, outcome) {
1264
+ retire(state, outcome, completing = false) {
790
1265
  if (outcome)
791
1266
  this.upgrade(state, outcome);
1267
+ // Only a plan that *finished* stops the clock (segmented.md R-MONEY-3). A
1268
+ // hand-over or a divergence takes no mark: the agent's next tokens are the
1269
+ // consequence of this plan stopping, and belong to its bill.
1270
+ if (completing) {
1271
+ state.retiredAt = Date.now();
1272
+ try {
1273
+ state.retiredMark = state.markUsage?.();
1274
+ }
1275
+ catch {
1276
+ // An unreadable transcript leaves the mark undefined, and the window
1277
+ // falls back to the next arm or the end of the turn.
1278
+ }
1279
+ }
792
1280
  state.retired = true;
793
1281
  state.pinned.clear();
794
1282
  }
@@ -804,4 +1292,45 @@ export class ReplayController {
804
1292
  });
805
1293
  }
806
1294
  }
1295
+ /** The first target of a schema, for the sentence a decline logs. */
1296
+ function firstTargetKey(schema) {
1297
+ if (!schema)
1298
+ return undefined;
1299
+ return Object.keys(schema).find((k) => schema[k]?.kind !== "setting");
1300
+ }
1301
+ /**
1302
+ * Position of the first step whose failure count is above `maxStepFailures`
1303
+ * (fallbk.md D3), or undefined when none is — including when the service sent
1304
+ * no policy, which an older service never does.
1305
+ */
1306
+ /**
1307
+ * The index a hand-over reports, in the *caller's* chain (R-CALL-32).
1308
+ *
1309
+ * A step inside a called segment has no position in the caller's own chain; the
1310
+ * call row that opened its frame does. Reporting the segment's own index would
1311
+ * line the execution up against a step the caller's card does not have.
1312
+ */
1313
+ function callerStepIndexOf(entry) {
1314
+ if (!entry || entry.depth === 0)
1315
+ return entry?.stepIndex;
1316
+ let frame = entry.frame;
1317
+ while (frame.parent && frame.depth > 1)
1318
+ frame = frame.parent;
1319
+ return frame.callerStepIndex;
1320
+ }
1321
+ /** The calls in a served chain, for the `plan.armed` line (R-CALL-32). */
1322
+ function callLines(scenario) {
1323
+ const calls = (scenario.steps ?? []).filter((s) => s.kind === "segment");
1324
+ if (calls.length === 0)
1325
+ return undefined;
1326
+ return calls
1327
+ .map((s) => `${s.segmentId ?? "-"}:${s.unusable ?? s.resolution?.state ?? "resolved"}`)
1328
+ .join(",");
1329
+ }
1330
+ export function knownBadStepIndex(steps, maxStepFailures) {
1331
+ if (typeof maxStepFailures !== "number" || !Number.isFinite(maxStepFailures))
1332
+ return undefined;
1333
+ const i = steps.findIndex((s) => (s.failureCount ?? 0) > maxStepFailures);
1334
+ return i < 0 ? undefined : i;
1335
+ }
807
1336
  //# sourceMappingURL=controller.js.map