@basein/runner 0.3.21 → 0.3.23

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.
Files changed (50) hide show
  1. package/dist/bin/bir-hooks.d.ts +12 -16
  2. package/dist/bin/bir-hooks.js +12 -16
  3. package/dist/bin/bir-scenario.js +1 -1
  4. package/dist/bin/bir.js +1 -21
  5. package/dist/bin/investigate.d.ts +0 -9
  6. package/dist/bin/investigate.js +14 -42
  7. package/dist/bin/scenario-edit.js +13 -44
  8. package/dist/control/pin-guard.d.ts +16 -0
  9. package/dist/control/pin-guard.js +36 -0
  10. package/dist/control/recorder-main.d.ts +0 -15
  11. package/dist/control/recorder-main.js +7 -39
  12. package/dist/control/server.d.ts +81 -131
  13. package/dist/control/server.js +460 -968
  14. package/dist/record/recorder.d.ts +47 -138
  15. package/dist/record/recorder.js +0 -3
  16. package/dist/record/remote-recorder.d.ts +1 -9
  17. package/dist/record/remote-recorder.js +11 -49
  18. package/dist/record/switchable-recorder.js +0 -2
  19. package/dist/replay/bundle.d.ts +2 -2
  20. package/dist/replay/bundle.js +3 -3
  21. package/dist/replay/controller.d.ts +75 -124
  22. package/dist/replay/controller.js +207 -529
  23. package/dist/replay/derive.d.ts +0 -10
  24. package/dist/replay/derive.js +0 -3
  25. package/dist/replay/handover.d.ts +2 -37
  26. package/dist/replay/handover.js +38 -106
  27. package/dist/replay/logic.d.ts +0 -11
  28. package/dist/replay/logic.js +0 -24
  29. package/dist/replay/plan.d.ts +43 -163
  30. package/dist/replay/plan.js +144 -556
  31. package/dist/replay/safety.d.ts +4 -5
  32. package/dist/replay/safety.js +7 -7
  33. package/dist/replay/source-run.d.ts +15 -24
  34. package/dist/replay/source-run.js +25 -58
  35. package/dist/replay/targets.d.ts +11 -21
  36. package/dist/replay/targets.js +30 -69
  37. package/dist/replay/types.d.ts +9 -96
  38. package/dist/replay/types.js +3 -16
  39. package/dist/util/service-headers.d.ts +1 -2
  40. package/dist/util/service-headers.js +1 -2
  41. package/docs/calculatedReplay.md +17 -74
  42. package/docs/calculatedReplayGuide.md +10 -30
  43. package/package.json +1 -1
  44. package/production.md +5 -35
  45. package/dist/replay/condition-words.d.ts +0 -28
  46. package/dist/replay/condition-words.js +0 -59
  47. package/dist/replay/flatten.d.ts +0 -161
  48. package/dist/replay/flatten.js +0 -222
  49. package/dist/replay/reads-only.d.ts +0 -29
  50. package/dist/replay/reads-only.js +0 -261
@@ -29,7 +29,6 @@ import { PREVIEW_BUDGET_MS, RecordedValueError, ScenarioReplayPlan, StepRunError
29
29
  import { PRICING_VERSION } from "./pricing.js";
30
30
  import { hasShellStep, sinkOf, sinkOfPlan, unsafeParameter } from "./safety.js";
31
31
  import { SourceRunOutputs } from "./source-run.js";
32
- import { clampToFrameStart, flattenChain, frameEdges, } from "./flatten.js";
33
32
  import { blockingTargets, testedOnlyTargets } from "./targets.js";
34
33
  import { isNoResult, toolResultError } from "./tool-error.js";
35
34
  import { OUTCOME_RANK, hasTargets, isReadyScenario, } from "./types.js";
@@ -168,7 +167,7 @@ function handedOver(note) {
168
167
  }
169
168
  export const DEFAULT_INTENT_MATCH = {
170
169
  enabled: true,
171
- budgetMs: 4_000,
170
+ budgetMs: 1_500,
172
171
  maxPerTurn: 3,
173
172
  maxRequestsPerTurn: 40,
174
173
  };
@@ -197,6 +196,15 @@ const MAX_STEP_ERROR_CHARS = 500;
197
196
  * cannot hold more than a few megabytes in the recorder.
198
197
  */
199
198
  const TURN_CALLS_MAX_CHARS = 8 * 1024 * 1024;
199
+ /**
200
+ * Whether a plan is a part of the task the agent is already doing — armed
201
+ * mid-turn by its intent — so its words say "continue" and never "answer the
202
+ * user". A continuation is armed mid-turn too, and it is the rest of the
203
+ * user's request: it ends with the answer.
204
+ */
205
+ export function isSubTask(state) {
206
+ return state.armedBy === "intent" && state.continuation === undefined;
207
+ }
200
208
  export class ReplayController {
201
209
  enabled;
202
210
  work = new ProxyWorkQueue();
@@ -291,18 +299,6 @@ export class ReplayController {
291
299
  ? builtins
292
300
  : new Set([...builtins].filter(reachable));
293
301
  }
294
- /**
295
- * Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
296
- *
297
- * Only when the option says so. The recorder passes it on unless
298
- * `BIR_SEGMENT_ARM=0` (from 0.3.19; the service's segmentsbreakdown.md
299
- * SB-21); a controller built without it stays observe-only, asks for no
300
- * segment and arms none. The server enforces the same thing from its side
301
- * (R-OUT-9).
302
- */
303
- get segmentArm() {
304
- return this.opts.segmentArm === true;
305
- }
306
302
  /** The plan's own delivery vehicle is never a scenario step. */
307
303
  isDirectTool(toolName) {
308
304
  return toolName === DIRECT_TOOL_NAME;
@@ -362,13 +358,6 @@ export class ReplayController {
362
358
  reported: false,
363
359
  retired: false,
364
360
  armedBy,
365
- // An older server sends no `kind`, and a whole-run scenario is the safe
366
- // reading: it is what every hand-out was before segments existed.
367
- kind: match.kind ?? "scenario",
368
- segment: match.segment,
369
- key: match.key,
370
- verified: match.verified,
371
- sharedWith: [],
372
361
  };
373
362
  }
374
363
  /**
@@ -385,6 +374,30 @@ export class ReplayController {
385
374
  state.retired = true;
386
375
  return state;
387
376
  }
377
+ /**
378
+ * The state of a continuation handed out and not armed (the service's
379
+ * fallbk.md Part 3): declined before any gate ran — its first step is not
380
+ * the model's call (`not_first_step`), or the service's answer came too
381
+ * late (`match_late`). Like {@link lateMatch}, it exists only for its
382
+ * ticket's report, with the reason.
383
+ */
384
+ declinedContinuation(match, code, continuation) {
385
+ const state = this.blankState(match, new Set(), "intent");
386
+ state.continuation = continuation;
387
+ state.declined = code;
388
+ state.retired = true;
389
+ logLine("replay.decision", {
390
+ verdict: "no-steer",
391
+ code,
392
+ run: match.runId,
393
+ scenario: match.scenarioId ?? undefined,
394
+ continues: `${continuation.ofScenarioId}@${continuation.stepIndex}`,
395
+ why: code === "not_first_step"
396
+ ? "a continuation starts only on its first step, and the model's call after the hand-over is another one"
397
+ : "the continuation came after the call it could have replaced had gone ahead",
398
+ });
399
+ return state;
400
+ }
388
401
  /**
389
402
  * Run the gate ladder and, if every gate passes, arm a plan.
390
403
  *
@@ -393,15 +406,14 @@ export class ReplayController {
393
406
  * up with a denominator nobody measured.
394
407
  *
395
408
  * `host` is given for a session whose host is not Claude Code, and is kept
396
- * on the state (see {@link HostContext}).
409
+ * on the state (see {@link HostContext}). `extra.continuation` arms a
410
+ * continuation (the service's fallbk.md Part 3): see
411
+ * {@link ReplayState.continuation}.
397
412
  */
398
- async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host) {
413
+ async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host, extra = {}) {
399
414
  const state = this.blankState(match, wrapped, armedBy, host);
400
- // A decision note quotes the request (SB-18); a plan armed by intent was
401
- // given the step's intent here, and its control server sets the request.
402
- if (armedBy === "prompt")
403
- state.request = prompt;
404
- const isSegment = state.kind === "segment";
415
+ if (extra.continuation)
416
+ state.continuation = extra.continuation;
405
417
  const decline = (why, code) => {
406
418
  state.declined = code;
407
419
  logLine("replay.decision", {
@@ -410,19 +422,10 @@ export class ReplayController {
410
422
  run: match.runId,
411
423
  scenario: match.scenarioId ?? undefined,
412
424
  similarity: match.similarity.toFixed(3),
413
- kind: state.kind,
414
- key: state.key,
415
- verified: state.verified?.verdict,
416
425
  why,
417
426
  });
418
427
  return state;
419
428
  };
420
- // The server never sends a segment to a runner that did not ask for one
421
- // (R-OUT-9). One arriving anyway is declined here rather than run: an
422
- // observe-only period that quietly armed something would be worthless.
423
- if (isSegment && !this.segmentArm) {
424
- return decline("segment hand-out while BIR_SEGMENT_ARM is off", "replay_disabled");
425
- }
426
429
  // Gate 2 — replay enabled.
427
430
  if (!this.enabled)
428
431
  return decline("BIR_REPLAY=0", "replay_disabled");
@@ -452,65 +455,24 @@ export class ReplayController {
452
455
  }
453
456
  // Gate 4 — similarity — is gone. A hand-out is the service's decision that
454
457
  // this scenario does what the prompt asks: its match threshold, the
455
- // same-request question under it, the intent pass, a segment's clear band
456
- // or `same` verdict. Scoring it again here declined matches the service
457
- // had made (a checked 0.85, a task line at 0.91), so every match now
458
- // steers. What a plan cannot do — a step that keeps failing, a tool
459
- // nobody wrapped, a target the prompt never named — the gates below
460
- // still stop.
461
- // Flatten the chain before the remaining gates, so every one of them judges
462
- // the steps that will *really* run — the caller's own and, in order, those
463
- // of every segment it calls (segmented.md R-CALL-29, R-CALL-30).
464
- let flat;
465
- try {
466
- flat = flattenChain(scenario, {
467
- range: match.segment
468
- ? { stepFrom: match.segment.stepFrom, stepTo: match.segment.stepTo }
469
- : null,
470
- });
471
- }
472
- catch (err) {
473
- // A payload we cannot lay out is a payload we cannot run. It costs the
474
- // turn nothing: the agent does the task (R-FALL-12).
475
- logLine("replay.flatten_failed", { scenario: scenario.id, error: errText(err) });
476
- return decline("the chain could not be flattened", "flatten_failed");
477
- }
478
- const entries = flat.entries;
479
- if (entries.length === 0)
480
- return decline("the chain has no runnable step", "not_ready");
458
+ // same-request question under it, the intent pass. Scoring it again here
459
+ // declined matches the service had made (a checked 0.85, a task line at
460
+ // 0.91), so every match now steers. What a plan cannot do — a step that
461
+ // keeps failing, a tool nobody wrapped, a target the prompt never named —
462
+ // the gates below still stop.
463
+ const steps = scenario.steps;
481
464
  // Gate 5 — steps the plan must stop in front of (fallbk.md D3,
482
465
  // plan-services.md D8): a step the service parked — failed more than its
483
- // limit, retry not due — or one the calculation marked non-deterministic,
484
- // on whichever scenario owns it (R-CALL-30). A call the service could not
485
- // answer stops the plan the same way, and the earliest of them wins.
486
- const badAt = stopIndex(entries.map((e) => e.step), match.fallback?.maxStepFailures);
487
- const rawStop = [flat.stopAt, badAt].filter((n) => typeof n === "number");
488
- // Clamped to the start of the outermost called frame it falls in: a plan
489
- // never runs half a sub-task and leaves its result mapping unevaluated.
490
- const stopAt = rawStop.length > 0 ? clampToFrameStart(entries, Math.min(...rawStop)) : undefined;
466
+ // limit, retry not due — or one the calculation marked non-deterministic.
467
+ const stopAt = stopIndex(steps, match.fallback?.maxStepFailures);
491
468
  if (stopAt === 0) {
492
- const unusable = flat.stopAt === 0 && flat.stopReason ? flat.stopReason : undefined;
493
- // The step that stops the plan, before the clamp: the reason is its own.
494
- const stopping = entries[Math.min(...rawStop)]?.step ?? entries[0].step;
495
- // The service never serves a decision at step 0 (segmentsbreakdown.md
496
- // SB-5): should one come, the agent decides from the start, as it makes
497
- // a judgement there. No new code reaches a service that may not know it.
498
- const decisionFirst = stopping.stop?.kind === "decision";
499
- const judgement = stopping.stop?.kind === "nondeterministic" || decisionFirst;
500
- const why = unusable
501
- ? `its first step is a call that cannot run: ${unusable.reason}`
502
- : decisionFirst
503
- ? "its first step is a decision the agent makes — the agent does this task"
504
- : judgement
505
- ? "its first step needs a judgement the plan cannot compute — the agent does this task"
506
- : `its first step has failed ${stopping.failureCount ?? 0} times — the agent does this task`;
507
- return decline(why, unusable
508
- ? "unusable_first_step"
509
- : judgement
510
- ? "nondeterministic_first_step"
511
- : "known_bad_first_step");
512
- }
513
- const planned = stopAt === undefined ? entries : entries.slice(0, stopAt);
469
+ const stopping = steps[0];
470
+ const judgement = stopping.stop?.kind === "nondeterministic";
471
+ return decline(judgement
472
+ ? "its first step needs a judgement the plan cannot compute — the agent does this task"
473
+ : `its first step has failed ${stopping.failureCount ?? 0} times — the agent does this task`, judgement ? "nondeterministic_first_step" : "known_bad_first_step");
474
+ }
475
+ const planned = stopAt === undefined ? steps : steps.slice(0, stopAt);
514
476
  // Another host's adapter steps through a plan call by call and knows no
515
477
  // tests (AC5): asked for a step its test left out, it is refused, reads
516
478
  // that as the plan's end, and the model's own calls after it are booked as
@@ -530,7 +492,7 @@ export class ReplayController {
530
492
  // `mcp__fleet__list` is not the runner's to run or a proxy's to reach.
531
493
  const allowServers = host ? undefined : this.opts.allowServers;
532
494
  const builtinTools = host ? undefined : this.builtinTools;
533
- const mode = modeFor(planned.map((e) => e.step), state.wrapped, allowServers, builtinTools);
495
+ const mode = modeFor(planned, state.wrapped, allowServers, builtinTools);
534
496
  if (mode === "none")
535
497
  return decline("no step is executable", "coverage");
536
498
  // Tool results feed derivation as context, and they are whatever the agent
@@ -544,7 +506,7 @@ export class ReplayController {
544
506
  // command nobody asked for.
545
507
  let deriveCtx = ctx;
546
508
  if (ctx.recentResults?.length && sinkOfPlan(planned, host?.sinks) === "command") {
547
- deriveCtx = { liveCall: ctx.liveCall, agentDay: ctx.agentDay, atDecision: ctx.atDecision };
509
+ deriveCtx = { liveCall: ctx.liveCall, agentDay: ctx.agentDay };
548
510
  logLine("replay.derive_context", {
549
511
  scenario: scenario.id,
550
512
  shell: hasShellStep(planned, host?.sinks) || undefined,
@@ -671,15 +633,14 @@ export class ReplayController {
671
633
  }
672
634
  }
673
635
  // A plan armed by intent runs inside a task the agent is already doing, so
674
- // its directive and its bundles say "continue", never "answer the user"
675
- // (segmented.md R-OUT-13).
636
+ // its directive and its bundles say "continue", never "answer the user".
637
+ // A continuation is the rest of the user's request: it ends with the answer.
676
638
  state.plan = new ScenarioReplayPlan({
677
639
  scenario,
678
640
  params,
679
641
  mode,
680
642
  stopAt,
681
- entries,
682
- subTask: armedBy === "intent",
643
+ subTask: isSubTask(state),
683
644
  });
684
645
  if (mode === "steer") {
685
646
  // A steer directive shows the arguments the steps will run with, so the
@@ -697,17 +658,11 @@ export class ReplayController {
697
658
  await state.plan.ready().catch(() => undefined);
698
659
  }
699
660
  state.mode = mode;
700
- state.stepsPlanned = entries.length;
661
+ state.stepsPlanned = steps.length;
701
662
  // The chain this plan was served at: what its own calls are marked with
702
663
  // when they are recorded into a redo of the turn (AC2).
703
664
  if (typeof scenario.chainRevision === "number")
704
665
  state.chainRevision = scenario.chainRevision;
705
- // Why the plan stops, when a call is what stops it: the note names the
706
- // sub-task rather than a tool (R-CALL-36).
707
- state.flatStop =
708
- typeof stopAt === "number" && flat.stopAt !== undefined && stopAt <= flat.stopAt
709
- ? flat.stopReason
710
- : undefined;
711
666
  // Recorded outputs are read only when they may stand in (opt-in), so a
712
667
  // plan never fetches a run it will not show.
713
668
  if (this.recordedFallback && this.opts.authUrl && this.opts.authToken) {
@@ -724,22 +679,20 @@ export class ReplayController {
724
679
  scenario: scenario.id,
725
680
  mode,
726
681
  host: host?.app,
727
- steps: entries.length,
682
+ steps: steps.length,
728
683
  stopsAt: stopAt,
729
- by: armedBy === "intent" ? "intent" : undefined,
730
- kind: state.kind,
731
- key: state.key,
732
- verified: state.verified?.verdict,
684
+ by: armedBy === "intent" ? (state.continuation ? "continuation" : "intent") : undefined,
685
+ // The plan it continues and the step that plan stopped in front of.
686
+ continues: state.continuation
687
+ ? `${state.continuation.ofScenarioId}@${state.continuation.stepIndex}`
688
+ : undefined,
733
689
  similarity: match.similarity.toFixed(3),
734
- // What this chain calls, and what answers each call right now (R-CALL-32).
735
- calls: callLines(scenario),
736
- unusable: flat.stopReason?.reason,
737
690
  // Which steps the service told this plan to stop in front of, and why.
738
- stops: stopLines(entries),
739
- coverage: coverageOf(entries.map((e) => e.step), state.wrapped, allowServers, builtinTools).join(","),
740
- tools: entries.map((e) => e.step.toolName ?? "").join(","),
741
- // Which steps run only under a test, by flat position (AC5).
742
- tests: testLines(entries),
691
+ stops: stopLines(steps),
692
+ coverage: coverageOf(steps, state.wrapped, allowServers, builtinTools).join(","),
693
+ tools: steps.map((s) => s.toolName).join(","),
694
+ // Which steps run only under a test, by position (AC5).
695
+ tests: testLines(steps),
743
696
  });
744
697
  return state;
745
698
  }
@@ -748,16 +701,16 @@ export class ReplayController {
748
701
  * stops when that is before its end. For a host that steps through the plan
749
702
  * itself and must not have to pick the tool names out of a directive
750
703
  * written for a model — a reworded sentence would break every adapter in
751
- * the field. Built from the same entries the directive lists.
704
+ * the field. Built from the same steps the directive lists.
752
705
  */
753
706
  planData(state) {
754
707
  const plan = state.plan;
755
708
  if (!plan || state.retired)
756
709
  return undefined;
757
710
  const steps = plan
758
- .allEntries()
711
+ .allSteps()
759
712
  .slice(0, plan.stepCount)
760
- .map((entry, stepIndex) => ({ stepIndex, toolName: entry.step.toolName ?? "" }));
713
+ .map((step, stepIndex) => ({ stepIndex, toolName: step.toolName }));
761
714
  return {
762
715
  scenarioId: state.scenarioId,
763
716
  mode: state.mode,
@@ -765,15 +718,6 @@ export class ReplayController {
765
718
  ...(plan.stopsEarly() ? { stopAt: plan.stepCount } : {}),
766
719
  };
767
720
  }
768
- /**
769
- * Whether the plan's steps end in front of a decision (the service's
770
- * segmentsbreakdown.md SB-6): it runs the steps before it, then hands the
771
- * task back there. The control server gives such a plan a usage mark, so
772
- * that it is billed only up to the stop (SB-26).
773
- */
774
- stopsAtDecision(state) {
775
- return state.plan?.stopEntry()?.step.stop?.kind === "decision";
776
- }
777
721
  /**
778
722
  * Take back a plan that armed and has run nothing, as a decline.
779
723
  *
@@ -797,7 +741,6 @@ export class ReplayController {
797
741
  run: state.matchedRunId,
798
742
  scenario: state.scenarioId ?? undefined,
799
743
  similarity: state.similarity.toFixed(3),
800
- kind: state.kind,
801
744
  why,
802
745
  });
803
746
  state.plan = undefined;
@@ -828,7 +771,6 @@ export class ReplayController {
828
771
  this.handOver(state, {
829
772
  kind: "step_failed",
830
773
  step: plan.currentStep(),
831
- entry: plan.currentEntry(),
832
774
  position,
833
775
  error: why === "own_call"
834
776
  ? "the model made a call of its own, so the scenario stopped"
@@ -852,7 +794,7 @@ export class ReplayController {
852
794
  directiveFor(state, opts = {}) {
853
795
  const asked = opts.previewMs;
854
796
  return state.plan?.steeringDirective(DIRECT_TOOL_NAME, {
855
- subTask: state.armedBy === "intent",
797
+ subTask: isSubTask(state),
856
798
  // NaN is a caller's arithmetic gone wrong: no time known, no preview.
857
799
  previewBudgetMs: typeof asked === "number"
858
800
  ? Math.max(0, Math.min(Number.isNaN(asked) ? 0 : asked, PREVIEW_BUDGET_MS))
@@ -889,8 +831,8 @@ export class ReplayController {
889
831
  return false;
890
832
  if (plan.parametersSettled)
891
833
  return true;
892
- const planned = plan.allEntries().slice(0, plan.stepCount);
893
- if (!planned.some((e) => pinnedToShape(e.step.toolName)))
834
+ const planned = plan.allSteps().slice(0, plan.stepCount);
835
+ if (!planned.some((s) => pinnedToShape(s.toolName)))
894
836
  return false;
895
837
  if (state.deriveOnService && !serviceHealth.canWait())
896
838
  return false;
@@ -1023,7 +965,6 @@ export class ReplayController {
1023
965
  if (step && ready.blame) {
1024
966
  this.recordStep(state, {
1025
967
  step,
1026
- entry: plan.currentEntry(),
1027
968
  outcome: "failed",
1028
969
  stage: ready.stage,
1029
970
  error: ready.error,
@@ -1032,7 +973,6 @@ export class ReplayController {
1032
973
  this.handOver(state, {
1033
974
  kind: "step_failed",
1034
975
  step,
1035
- entry: plan.currentEntry(),
1036
976
  position: plan.currentStepIndex,
1037
977
  error: ready.error,
1038
978
  delivery: "above",
@@ -1136,7 +1076,6 @@ export class ReplayController {
1136
1076
  const recorded = err instanceof RecordedValueError;
1137
1077
  this.recordStep(state, {
1138
1078
  step,
1139
- entry: plan.currentEntry(),
1140
1079
  outcome: recorded ? "skipped" : "failed",
1141
1080
  stage: "tool_input_logic",
1142
1081
  error: errText(err),
@@ -1165,7 +1104,6 @@ export class ReplayController {
1165
1104
  this.handOver(state, {
1166
1105
  kind: "step_failed",
1167
1106
  step: plan.currentStep(),
1168
- entry: plan.currentEntry(),
1169
1107
  position: plan.currentStepIndex,
1170
1108
  error: `the scenario's next step is ${expected ?? "none"}, and the host asked for ${toolName}`,
1171
1109
  delivery: "above",
@@ -1213,7 +1151,6 @@ export class ReplayController {
1213
1151
  this.handOver(state, {
1214
1152
  kind: "step_failed",
1215
1153
  step: plan.allSteps()[next],
1216
- entry: plan.allEntries()[next],
1217
1154
  position: next,
1218
1155
  delivery: "above",
1219
1156
  stopReason: "concurrent",
@@ -1351,10 +1288,6 @@ export class ReplayController {
1351
1288
  // reading it after the call would judge the *next* step, and reading it
1352
1289
  // before assumes no other pin ever moved it.
1353
1290
  const step = plan.allSteps()[pin.stepIndex];
1354
- // The entry it ran as, so its verdict is kept under the scenario that owns
1355
- // the step: a called segment numbers its steps from zero too, and without
1356
- // this its step 0 replaced the caller's (segmented.md R-CALL-13).
1357
- const entry = plan.allEntries()[pin.stepIndex];
1358
1291
  // THE HOST STOPPED THE STEP, and says so. Read before any failure: a
1359
1292
  // person's "no" arrives as an error result, and booked as one it would be
1360
1293
  // counted against a plan that did nothing wrong — after enough of them the
@@ -1362,11 +1295,10 @@ export class ReplayController {
1362
1295
  // `rejected` the tool never ran, and after `edited` its result answers a
1363
1296
  // call the plan did not plan, which its later steps were not computed for.
1364
1297
  if (how.hostStop && step) {
1365
- this.hostStopped(state, pin, step, entry, output, how.hostStop);
1298
+ this.hostStopped(state, pin, step, output, how.hostStop);
1366
1299
  this.handOver(state, {
1367
1300
  kind: "step_failed",
1368
1301
  step,
1369
- entry,
1370
1302
  position: pin.stepIndex,
1371
1303
  delivery: "above",
1372
1304
  stopReason: how.hostStop === "edited" ? "host_edited" : "host_rejected",
@@ -1387,7 +1319,6 @@ export class ReplayController {
1387
1319
  `the plan's cursor is at step ${plan.currentStepIndex}, not the pinned step ${pin.stepIndex}`;
1388
1320
  const info = {
1389
1321
  step,
1390
- entry,
1391
1322
  input: pin.input,
1392
1323
  output: answerOf(output),
1393
1324
  outcome: "failed",
@@ -1401,7 +1332,6 @@ export class ReplayController {
1401
1332
  this.handOver(state, {
1402
1333
  kind: "step_failed",
1403
1334
  step,
1404
- entry,
1405
1335
  position: pin.stepIndex,
1406
1336
  error,
1407
1337
  delivery: "above",
@@ -1423,7 +1353,6 @@ export class ReplayController {
1423
1353
  if (step) {
1424
1354
  this.recordStep(state, {
1425
1355
  step,
1426
- entry,
1427
1356
  input: pin.input,
1428
1357
  // What the logic threw on — the output is usually the explanation.
1429
1358
  output,
@@ -1462,7 +1391,6 @@ export class ReplayController {
1462
1391
  if (step && toolError) {
1463
1392
  const info = {
1464
1393
  step,
1465
- entry,
1466
1394
  input: pin.input,
1467
1395
  output,
1468
1396
  outcome: "failed",
@@ -1486,7 +1414,6 @@ export class ReplayController {
1486
1414
  if (step) {
1487
1415
  this.recordStep(state, {
1488
1416
  step,
1489
- entry,
1490
1417
  input: pin.input,
1491
1418
  output,
1492
1419
  outcome: "executed",
@@ -1510,19 +1437,17 @@ export class ReplayController {
1510
1437
  return done;
1511
1438
  }
1512
1439
  /**
1513
- * A steer plan whose cursor reached its end: in front of a parked step (or a
1514
- * call the service could not answer), which hands over; otherwise it ran to
1515
- * its end — and from here every call is the model's own.
1440
+ * A steer plan whose cursor reached its end: in front of a parked step,
1441
+ * which hands over; otherwise it ran to its end — and from here every call
1442
+ * is the model's own.
1516
1443
  */
1517
1444
  finishSteer(state, plan) {
1518
1445
  if (plan.atStop()) {
1519
- // Every planned step ran, and the next one is parked (fallbk.md D3) — or
1520
- // is a call the service could not answer (segmented.md R-CALL-29).
1446
+ // Every planned step ran, and the next one is parked (fallbk.md D3).
1521
1447
  const parked = plan.stopStep();
1522
1448
  this.handOver(state, {
1523
- kind: this.stopKindOf(state, plan.stopEntry()),
1449
+ kind: stopKindOf(parked),
1524
1450
  step: parked,
1525
- entry: plan.stopEntry(),
1526
1451
  position: plan.stepCount,
1527
1452
  delivery: "above",
1528
1453
  });
@@ -1559,34 +1484,28 @@ export class ReplayController {
1559
1484
  settleCursor(state, plan, delivery) {
1560
1485
  if (!plan.hasTests())
1561
1486
  return "ready";
1562
- // Never throws: a sub-task whose results cannot go back after its last
1563
- // step was left out is a stop like any other (`result_map`).
1564
1487
  const settled = plan.settleTests(this.observeStep(state));
1565
1488
  // The recorded outputs of the steps left out are passed over, so a
1566
1489
  // divergence's bundle later stands each step's own in (source-run.ts).
1567
1490
  const source = state.sourceRun;
1568
1491
  if (source) {
1569
1492
  for (const l of settled.left) {
1570
- for (let i = l.flatIndex; i < l.until; i += 1) {
1571
- const entry = plan.allEntries()[i];
1572
- if (entry)
1573
- source.leftOut(entry);
1574
- }
1493
+ const step = plan.allSteps()[l.position];
1494
+ if (step)
1495
+ source.leftOut(step);
1575
1496
  }
1576
1497
  }
1577
1498
  if (settled.stop) {
1578
1499
  const stop = settled.stop;
1579
- const position = stop.flatIndex ?? plan.currentStepIndex;
1500
+ const position = stop.position ?? plan.currentStepIndex;
1580
1501
  this.handOver(state, {
1581
1502
  kind: stop.kind,
1582
1503
  step: plan.allSteps()[position],
1583
- entry: plan.allEntries()[position],
1584
1504
  position,
1585
1505
  error: stop.error,
1586
1506
  delivery,
1587
1507
  stopReason: stop.reason,
1588
1508
  whenText: stop.whenText,
1589
- subTask: stop.subTask,
1590
1509
  });
1591
1510
  return "stopped";
1592
1511
  }
@@ -1599,7 +1518,6 @@ export class ReplayController {
1599
1518
  this.handOver(state, {
1600
1519
  kind: "other_branch",
1601
1520
  step: plan.allSteps()[0],
1602
- entry: plan.allEntries()[0],
1603
1521
  position: 0,
1604
1522
  delivery,
1605
1523
  noneNeeded: true,
@@ -1608,9 +1526,8 @@ export class ReplayController {
1608
1526
  }
1609
1527
  state.skipNote = {
1610
1528
  text: this.skipNoteText(state, plan, settled.left),
1611
- // Every tool of a sub-task left out whole, not its call's intent: a call
1612
- // the model makes to any of them is one to a step left out.
1613
- tools: new Set(settled.left.flatMap((l) => (l.tools.length > 0 ? l.tools : [l.toolName]))),
1529
+ // A call the model makes to any of these tools is one to a step left out.
1530
+ tools: new Set(settled.left.map((l) => l.toolName)),
1614
1531
  delivered: false,
1615
1532
  complete: plan.isDone() && !plan.atStop(),
1616
1533
  };
@@ -1618,20 +1535,19 @@ export class ReplayController {
1618
1535
  }
1619
1536
  /** What the model is told about steps a test left out (AC5). */
1620
1537
  skipNoteText(state, plan, left) {
1621
- const named = left.map((l) => `step ${l.flatIndex + 1} (${l.toolName})`);
1538
+ const named = left.map((l) => `step ${l.position + 1} (${l.toolName})`);
1622
1539
  const texts = [...new Set(left.map((l) => l.text).filter((t) => !!t))];
1623
1540
  const why = texts.length === 1 ? ` — only for when ${texts[0]}` : "";
1624
1541
  const what = left.length === 1 ? `Not needed for this request: ${named[0]}${why}.` : `Not needed for this request: ${named.join(", ")}${why}.`;
1625
1542
  let next;
1626
1543
  if (plan.isDone() && !plan.atStop()) {
1627
- next =
1628
- state.armedBy === "intent"
1629
- ? "The sequence is complete: continue your task, calling tools as needed."
1630
- : "The sequence is complete: answer the user's request from the tool results.";
1544
+ next = isSubTask(state)
1545
+ ? "The sequence is complete: continue your task, calling tools as needed."
1546
+ : "The sequence is complete: answer the user's request from the tool results.";
1631
1547
  }
1632
1548
  else {
1633
- const entry = plan.currentEntry();
1634
- next = `Leave ${left.length === 1 ? "it" : "them"} out, and call step ${plan.currentStepIndex + 1}${entry?.step.toolName ? ` (${entry.step.toolName})` : ""} next.`;
1549
+ const step = plan.currentStep();
1550
+ next = `Leave ${left.length === 1 ? "it" : "them"} out, and call step ${plan.currentStepIndex + 1}${step?.toolName ? ` (${step.toolName})` : ""} next.`;
1635
1551
  }
1636
1552
  return `[BaseInstRunner calculated replay] ${what} ${next}`;
1637
1553
  }
@@ -1693,7 +1609,7 @@ export class ReplayController {
1693
1609
  return;
1694
1610
  if (how.hostStop) {
1695
1611
  // The host stopped it: neither a failure nor work the plan did.
1696
- this.hostStopped(state, pin, step, plan.allEntries()[pin.stepIndex], output, how.hostStop);
1612
+ this.hostStopped(state, pin, step, output, how.hostStop);
1697
1613
  return;
1698
1614
  }
1699
1615
  const ms = Date.now() - pin.pinnedAt;
@@ -1705,11 +1621,9 @@ export class ReplayController {
1705
1621
  ? `${failure.error} — it was sent, and may or may not have completed`
1706
1622
  : failure.error
1707
1623
  : toolResultError(output);
1708
- const entry = plan.allEntries()[pin.stepIndex];
1709
1624
  if (error !== undefined) {
1710
1625
  const info = {
1711
1626
  step,
1712
- entry,
1713
1627
  input: pin.input,
1714
1628
  output: answerOf(output),
1715
1629
  outcome: "failed",
@@ -1721,7 +1635,7 @@ export class ReplayController {
1721
1635
  this.recordStep(state, info);
1722
1636
  return;
1723
1637
  }
1724
- this.recordStep(state, { step, entry, input: pin.input, output, outcome: "executed", ms });
1638
+ this.recordStep(state, { step, input: pin.input, output, outcome: "executed", ms });
1725
1639
  state.stepsPinned += 1;
1726
1640
  this.upgrade(state, "fell_back");
1727
1641
  logDetail("replay.thread_late", {
@@ -1736,7 +1650,7 @@ export class ReplayController {
1736
1650
  * `failed`: the service counts failures against the plan. And not counted
1737
1651
  * in `stepsPinned`: the plan's own call did not run.
1738
1652
  */
1739
- hostStopped(state, pin, step, entry, output, stop) {
1653
+ hostStopped(state, pin, step, output, stop) {
1740
1654
  const edited = stop === "edited";
1741
1655
  // After `rejected` nothing later can change what the report may claim.
1742
1656
  if (state.hostStop !== "rejected")
@@ -1752,7 +1666,6 @@ export class ReplayController {
1752
1666
  });
1753
1667
  this.recordStep(state, {
1754
1668
  step,
1755
- entry,
1756
1669
  // The plan's own arguments are what a refused call was asked with; a
1757
1670
  // changed call ran with other ones, which only the host knows.
1758
1671
  input: edited ? undefined : pin.input,
@@ -1829,7 +1742,6 @@ export class ReplayController {
1829
1742
  if (step && ready.blame) {
1830
1743
  this.recordStep(state, {
1831
1744
  step,
1832
- entry: plan.currentEntry(),
1833
1745
  outcome: "failed",
1834
1746
  stage: ready.stage,
1835
1747
  error: ready.error,
@@ -1838,7 +1750,6 @@ export class ReplayController {
1838
1750
  this.handOver(state, {
1839
1751
  kind: "step_failed",
1840
1752
  step,
1841
- entry: plan.currentEntry(),
1842
1753
  position: plan.currentStepIndex,
1843
1754
  error: ready.error,
1844
1755
  delivery: "none",
@@ -1872,7 +1783,7 @@ export class ReplayController {
1872
1783
  // them alone is a baseline sample, not a hand-over "saving".
1873
1784
  const ranOk = result.executed - result.errored;
1874
1785
  state.stepsPinned += Math.max(0, ranOk);
1875
- const position = stop.flatIndex ?? plan.stepCount;
1786
+ const position = stop.position ?? plan.stepCount;
1876
1787
  const step = plan.allSteps()[position];
1877
1788
  // Cut short by the clock, nothing failed: the steps that ran did their
1878
1789
  // work and none is blamed. A divergence for the ledger — a partial
@@ -1890,11 +1801,10 @@ export class ReplayController {
1890
1801
  const live = result.executed > 0;
1891
1802
  this.handOver(state, {
1892
1803
  // The plan only knows it stopped in front of its end; which kind of
1893
- // stop that is — parked, a judgement, a call that cannot run — is
1894
- // decided here, where the served reasons are.
1895
- kind: stop.kind === "known_bad_step" ? this.stopKindOf(state, plan.stopEntry()) : stop.kind,
1804
+ // stop that is — parked, or a judgement — is decided here, where the
1805
+ // served reasons are.
1806
+ kind: stop.kind === "known_bad_step" ? stopKindOf(plan.stopStep()) : stop.kind,
1896
1807
  step,
1897
- entry: plan.allEntries()[position],
1898
1808
  position,
1899
1809
  error: stop.error,
1900
1810
  // A cancelled run's note is kept for the model's next call, alone —
@@ -1905,12 +1815,9 @@ export class ReplayController {
1905
1815
  ran: live ? undefined : 0,
1906
1816
  // An output-logic failure stops *after* its step's tool ran: the step
1907
1817
  // is done, its result is in the bundle, and it must not be run again.
1908
- // Not a sub-task's result mapping after its last step was left out
1909
- // (`result_map`): that step did not run (AC5).
1910
- ranThisStep: stop.stage === "tool_output_logic" && stop.reason !== "result_map",
1818
+ ranThisStep: stop.stage === "tool_output_logic",
1911
1819
  // The other half (AC5): what the steps from here are for.
1912
1820
  whenText: stop.whenText,
1913
- subTask: stop.subTask,
1914
1821
  });
1915
1822
  if (stop.reason === "cancelled") {
1916
1823
  // The caller is gone and reads nothing. The note stays undelivered: a
@@ -1947,7 +1854,6 @@ export class ReplayController {
1947
1854
  this.handOver(state, {
1948
1855
  kind: "other_branch",
1949
1856
  step: plan.allSteps()[0],
1950
- entry: plan.allEntries()[0],
1951
1857
  position: 0,
1952
1858
  delivery: "none",
1953
1859
  ran: 0,
@@ -1962,7 +1868,6 @@ export class ReplayController {
1962
1868
  this.handOver(state, {
1963
1869
  kind: "step_failed",
1964
1870
  step: plan.allSteps()[0],
1965
- entry: plan.allEntries()[0],
1966
1871
  position: 0,
1967
1872
  error: "none of the scenario's steps could run here",
1968
1873
  delivery: "none",
@@ -2026,23 +1931,20 @@ export class ReplayController {
2026
1931
  if (state.reported || !state.scenarioId)
2027
1932
  return undefined;
2028
1933
  state.reported = true;
2029
- const total = state.deriveCostUsd + d.sessionCostUsd + state.fallbackCostUsd;
1934
+ const deriveCostUsd = state.deriveCostUsd + Math.max(0, d.chainDeriveUsd ?? 0);
1935
+ const total = deriveCostUsd + d.sessionCostUsd + state.fallbackCostUsd;
2030
1936
  const steps = this.stepResultsOf(state);
2031
1937
  const isBaseline = state.outcome === "not_steered" || state.outcome === "failed";
2032
- // The plan handed the task back by design, not by a fault: in front of the
2033
- // other half of an "if … otherwise" (AC5), or at its decision (the
2034
- // service's segmentsbreakdown.md SB-19). Such a stop is named whatever the
2035
- // outcome, and the agent's work after it is the service's evidence.
2036
- const handedBack = state.handover?.kind === "other_branch" || state.handover?.kind === "decision";
2037
1938
  // A costless decline is noise on both sides — *unless* it says something the
2038
1939
  // recording page cannot learn any other way: a step that actually broke, the
2039
- // gate that declined, or a plan that handed the task back by design. The
2040
- // service accepts a costless report that carries any of them (its
2041
- // errorshandling.md).
1940
+ // gate that declined, or a plan that stood in front of the other half of
1941
+ // its task (AC5), which the service takes the agent's work after as its
1942
+ // evidence of that half. The service accepts a costless report that carries
1943
+ // any of them (its errorshandling.md).
2042
1944
  if (isBaseline &&
2043
1945
  total <= 0 &&
2044
1946
  !state.declined &&
2045
- !handedBack &&
1947
+ state.handover?.kind !== "other_branch" &&
2046
1948
  !steps?.some((s) => s.status === "failed")) {
2047
1949
  return undefined;
2048
1950
  }
@@ -2052,7 +1954,7 @@ export class ReplayController {
2052
1954
  runId: d.runId,
2053
1955
  ticket: state.ticket,
2054
1956
  outcome: state.outcome,
2055
- deriveCostUsd: state.deriveCostUsd,
1957
+ deriveCostUsd,
2056
1958
  sessionCostUsd: d.sessionCostUsd,
2057
1959
  fallbackCostUsd: state.fallbackCostUsd,
2058
1960
  durationMs: d.durationMs,
@@ -2083,22 +1985,21 @@ export class ReplayController {
2083
1985
  declined: state.declined,
2084
1986
  // Where the plan handed the task to the model (fallbk.md). Only on
2085
1987
  // `fell_back`: a hand-over before any step ran is `failed`, and a parked
2086
- // step found after a divergence is still a divergence. Except a plan
2087
- // that handed the task back by design — the other half of an "if …
2088
- // otherwise" (AC5), or its decision (SB-19) — named whatever the
2089
- // outcome: the agent's work after it is the service's evidence, whether
1988
+ // step found after a divergence is still a divergence. Except the other
1989
+ // half of an "if … otherwise" (AC5), named whatever the outcome: the
1990
+ // agent's work after it is the service's evidence of that half, whether
2090
1991
  // or not a step ran first.
2091
- fallbackStepIndex: state.outcome === "fell_back" || handedBack
1992
+ fallbackStepIndex: state.outcome === "fell_back" || state.handover?.kind === "other_branch"
2092
1993
  ? state.handover?.stepIndex
2093
1994
  : undefined,
2094
- fallbackKind: state.outcome === "fell_back" || handedBack
1995
+ fallbackKind: state.outcome === "fell_back" || state.handover?.kind === "other_branch"
2095
1996
  ? state.handover?.kind
2096
1997
  : undefined,
2097
1998
  // The agent did not take the plan's result and redid the task (AC1).
2098
1999
  ...(d.redoneBy ? { redoneBy: d.redoneBy } : {}),
2099
- // Segments of this plan's recording that armed later in the turn
2100
- // (segmented.md R-MONEY-4): their share comes off this plan's baseline.
2101
- sharedWith: state.sharedWith.length > 0 ? [...state.sharedWith] : undefined,
2000
+ // A continuation (the service's fallbk.md Part 3): the plan it continued
2001
+ // books the whole turn, so this report books no saving of its own.
2002
+ ...(state.continuation ? { continues: { scenarioId: state.continuation.ofScenarioId } } : {}),
2102
2003
  // A turn in which another plan ran is not a measurement of what this task
2103
2004
  // costs unaided, so it is no baseline sample (segmented.md R-MONEY-5).
2104
2005
  // This covers two plans that both armed, and — the case that was silently
@@ -2142,26 +2043,11 @@ export class ReplayController {
2142
2043
  * there is nothing to be similar *to* and nothing to decline. It books no
2143
2044
  * execution either — there is no ticket, and inventing a saving for a manual
2144
2045
  * invocation is exactly the kind of number a ledger must never contain.
2145
- *
2146
- * One served stop is kept: a plan's decision (the service's
2147
- * segmentsbreakdown.md SB-6). The run stops in front of it, and `stopped`
2148
- * says so with the reason `decision`.
2149
2046
  */
2150
2047
  async runAdHoc(scenario, prompt, wrapped) {
2151
2048
  if (!isReadyScenario(scenario))
2152
2049
  return { ok: false, why: "scenario is not ready" };
2153
- // A chain with calls is run by hand the same way it is run by a match: one
2154
- // flat list, one frame per call (segmented.md R-CALL-37).
2155
- let flat;
2156
- try {
2157
- flat = flattenChain(scenario);
2158
- }
2159
- catch (err) {
2160
- return { ok: false, why: `the chain could not be flattened: ${errText(err)}` };
2161
- }
2162
- if (flat.entries.length === 0)
2163
- return { ok: false, why: "the chain has no runnable step" };
2164
- const mode = modeFor(flat.entries.map((e) => e.step), wrapped, this.opts.allowServers, this.builtinTools);
2050
+ const mode = modeFor(scenario.steps, wrapped, this.opts.allowServers, this.builtinTools);
2165
2051
  if (mode === "none")
2166
2052
  return { ok: false, why: "no step is executable" };
2167
2053
  const state = {
@@ -2183,15 +2069,11 @@ export class ReplayController {
2183
2069
  reported: true,
2184
2070
  retired: false,
2185
2071
  armedBy: "prompt",
2186
- // `bir replay` runs a scenario an operator named by id: never a segment
2187
- // hand-out, and nothing to share a baseline with.
2188
- kind: "scenario",
2189
- sharedWith: [],
2190
2072
  };
2191
- // `bir replay` keeps its own contract on a missing target (segmented.md
2192
- // R-CALL-37): an operator asked for this run by id, so a sample stands in
2193
- // and it never declines. It says which, as the console's dry replay does
2194
- // (R-PARAM-7), so nobody reads the output as proof the values were derived.
2073
+ // `bir replay` keeps its own contract on a missing target: an operator
2074
+ // asked for this run by id, so a sample stands in and it never declines.
2075
+ // It says which, as the console's dry replay does (R-PARAM-7), so nobody
2076
+ // reads the output as proof the values were derived.
2195
2077
  const schema = scenario.paramsObject ?? {};
2196
2078
  const params = this.startDerivation(scenario, prompt, state).then((r) => {
2197
2079
  const out = { ...r.params };
@@ -2210,25 +2092,13 @@ export class ReplayController {
2210
2092
  }
2211
2093
  return out;
2212
2094
  });
2213
- // The one served stop an operator's run keeps: a decision (the service's
2214
- // segmentsbreakdown.md SB-6). The steps after it are one half of an "if …
2215
- // otherwise", the half the recording took, and running them by id would
2216
- // do that half's writes whatever the request. Every other served stop is
2217
- // the plan's health, which an operator's run is there to try.
2218
- const decisionAt = flat.entries.findIndex((e) => e.step.stop?.kind === "decision");
2219
- const stopAt = decisionAt >= 0 && (flat.stopAt === undefined || decisionAt < flat.stopAt)
2220
- ? clampToFrameStart(flat.entries, decisionAt)
2221
- : flat.stopAt;
2222
2095
  const plan = new ScenarioReplayPlan({
2223
2096
  scenario,
2224
2097
  params,
2225
2098
  mode,
2226
- entries: flat.entries,
2227
- stopAt,
2228
2099
  fallbackTargets: "samples",
2229
2100
  });
2230
2101
  state.plan = plan;
2231
- state.flatStop = stopAt === flat.stopAt ? flat.stopReason : undefined;
2232
2102
  if (this.opts.authUrl && this.opts.authToken) {
2233
2103
  state.sourceRun = new SourceRunOutputs({
2234
2104
  baseUrl: this.opts.authUrl,
@@ -2244,8 +2114,8 @@ export class ReplayController {
2244
2114
  coverage: coverageOf(scenario.steps, wrapped, this.opts.allowServers, this.builtinTools).join(","),
2245
2115
  why: "bir replay — no match, an operator asked for it",
2246
2116
  });
2247
- // An operator's run keeps its own contract (R-CALL-37): out of time, the
2248
- // targets too take their samples. What it never does is run on `{}`.
2117
+ // An operator's run keeps its own contract: out of time, the targets too
2118
+ // take their samples. What it never does is run on `{}`.
2249
2119
  const ready = await this.readyFor(state, plan, this.adHocBudgets.deriveMs, { adHoc: true });
2250
2120
  if (!ready.ok)
2251
2121
  return { ok: false, why: ready.error };
@@ -2274,16 +2144,7 @@ export class ReplayController {
2274
2144
  logDetail("replay.response_model_failed", { error: errText(err) });
2275
2145
  }
2276
2146
  const stop = result.stopped;
2277
- const stoppedStep = stop ? plan.allSteps()[stop.flatIndex ?? plan.stepCount] : undefined;
2278
- // The plan's end in front of its decision: said so, by its own word.
2279
- const atDecision = stop?.kind === "known_bad_step" && stoppedStep?.stop?.kind === "decision";
2280
- if (atDecision) {
2281
- logLine("replay.adhoc_decision", {
2282
- scenario: scenario.id,
2283
- step: stop.stepIndex,
2284
- why: "bir replay stops in front of the plan's decision: the steps after it are the half the recording took",
2285
- });
2286
- }
2147
+ const stoppedStep = stop ? plan.allSteps()[stop.position ?? plan.stepCount] : undefined;
2287
2148
  return {
2288
2149
  ok: true,
2289
2150
  text: result.text,
@@ -2295,7 +2156,7 @@ export class ReplayController {
2295
2156
  ? {
2296
2157
  step: stop.stepIndex,
2297
2158
  tool: stoppedStep?.toolName ?? "",
2298
- reason: atDecision ? "decision" : (stop.reason ?? stop.stage ?? stop.kind),
2159
+ reason: stop.reason ?? stop.stage ?? stop.kind,
2299
2160
  error: stop.error,
2300
2161
  }
2301
2162
  : undefined,
@@ -2358,8 +2219,6 @@ export class ReplayController {
2358
2219
  fetchImpl: this.opts.fetchImpl,
2359
2220
  liveCall: ctx.liveCall,
2360
2221
  recentResults: ctx.recentResults,
2361
- // A reading for a scenario that follows a plan's decision (offer1.md O4).
2362
- ...(ctx.atDecision ? { atDecision: ctx.atDecision } : {}),
2363
2222
  // The request itself ends too. Without this a hung service or model held
2364
2223
  // the socket for up to 300 s after every wait on it had given up. Long
2365
2224
  // enough to cover both waits that can use its answer (the arm and the
@@ -2492,16 +2351,7 @@ export class ReplayController {
2492
2351
  // when a step did its work — so the service takes the agent's work after
2493
2352
  // it as that half's evidence. Booked `diverged`, it never did.
2494
2353
  const otherHalf = stop?.kind === "other_branch";
2495
- // A bundle that ran up to the plan's decision and stopped there (the
2496
- // service's segmentsbreakdown.md SB-17) is a hand-over too: the plan did
2497
- // all it was for, and the agent decides. Not one of recorded outputs
2498
- // only, which is no step done here — that stays a plain divergence.
2499
- const atDecision = !stop &&
2500
- plan.stopsEarly() &&
2501
- composed.endedAt === plan.stepCount &&
2502
- (composed.executed > 0 || composed.recorded === 0) &&
2503
- this.stopKindOf(state, plan.stopEntry()) === "decision";
2504
- this.upgrade(state, broke || !didWork ? "failed" : otherHalf || atDecision ? "fell_back" : "diverged");
2354
+ this.upgrade(state, broke || !didWork ? "failed" : otherHalf ? "fell_back" : "diverged");
2505
2355
  state.stepsPinned += composed.executed;
2506
2356
  // The headline number of this whole design: recovering from a divergence
2507
2357
  // costs nothing, because the proxies were already connected.
@@ -2512,7 +2362,7 @@ export class ReplayController {
2512
2362
  recorded: composed.recorded,
2513
2363
  skipped: composed.skipped,
2514
2364
  errored: composed.errored || undefined,
2515
- stopped: stop ? `${stop.flatIndex ?? "?"}:${stop.reason ?? stop.stage ?? stop.kind}` : undefined,
2365
+ stopped: stop ? `${stop.position ?? "?"}:${stop.reason ?? stop.stage ?? stop.kind}` : undefined,
2516
2366
  bytes: composed.text.length,
2517
2367
  costUsd: state.fallbackCostUsd.toFixed(2),
2518
2368
  });
@@ -2528,40 +2378,16 @@ export class ReplayController {
2528
2378
  // of recorded outputs only is last week's work. The model's own call goes
2529
2379
  // ahead — unless a step was *sent* and never answered. That one may have
2530
2380
  // done its work, and the model must know before it does the task itself.
2531
- if (stop?.reason === "uncertain" && stop.flatIndex !== undefined) {
2381
+ if (stop?.reason === "uncertain" && stop.position !== undefined) {
2532
2382
  const note = this.stopNote(state, plan, stop, "none", composed.left);
2533
2383
  return this.deliverInstead(note, toolName, state.host !== undefined);
2534
2384
  }
2535
2385
  // The rest is the other half (AC5): the model is told so, in place of
2536
2386
  // its call, before it does any of the plan's remaining steps.
2537
- if (stop?.kind === "other_branch" && stop.flatIndex !== undefined) {
2387
+ if (stop?.kind === "other_branch" && stop.position !== undefined) {
2538
2388
  const note = this.stopNote(state, plan, stop, "none", composed.left);
2539
2389
  return this.deliverInstead(note, toolName, state.host !== undefined);
2540
2390
  }
2541
- // Every step left before the decision was one this request does not
2542
- // need: the plan has done its part, and the agent decides. Told in
2543
- // place of its call, like the other half. The steps that ran are the
2544
- // model's own calls above; the ones left out are named.
2545
- if (atDecision) {
2546
- const parked = plan.stopStep();
2547
- state.handover = {
2548
- kind: "decision",
2549
- stepIndex: callerStepIndexOf(plan.stopEntry()) ?? parked.stepIndex,
2550
- toolName: parked.toolName ?? undefined,
2551
- note: this.noteFor(state, {
2552
- kind: "decision",
2553
- step: parked,
2554
- entry: plan.stopEntry(),
2555
- position: plan.stepCount,
2556
- delivery: "above",
2557
- executedAbove: plan.currentStepIndex,
2558
- leftOut: composed.left,
2559
- }),
2560
- delivered: true,
2561
- };
2562
- this.decidedInDivergence(state, plan);
2563
- return this.deliverInstead(state.handover.note, toolName, state.host !== undefined);
2564
- }
2565
2391
  // Every remaining step was left out by its test: the steps the session
2566
2392
  // ran were the whole of the plan this request needs, and it ran to its
2567
2393
  // end (newestRun.md N7 reads that).
@@ -2576,13 +2402,13 @@ export class ReplayController {
2576
2402
  // outcome stays as judged above, so the report carries no fallback fields
2577
2403
  // (the other half of an "if … otherwise" excepted: see `buildReport`).
2578
2404
  let text = composed.text;
2579
- if (stop && (stop.kind === "step_failed" || stop.kind === "other_branch") && stop.flatIndex !== undefined) {
2405
+ if (stop && (stop.kind === "step_failed" || stop.kind === "other_branch") && stop.position !== undefined) {
2580
2406
  const note = this.stopNote(state, plan, stop, "below", composed.left);
2581
2407
  text = `${note}\n\n${composed.text}`;
2582
2408
  }
2583
2409
  else if (plan.stopsEarly() && composed.endedAt === plan.stepCount) {
2584
2410
  const parked = plan.stopStep();
2585
- const stopKind = this.stopKindOf(state, plan.stopEntry());
2411
+ const stopKind = stopKindOf(parked);
2586
2412
  state.handover = {
2587
2413
  kind: stopKind,
2588
2414
  stepIndex: parked.stepIndex,
@@ -2590,7 +2416,6 @@ export class ReplayController {
2590
2416
  note: this.noteFor(state, {
2591
2417
  kind: stopKind,
2592
2418
  step: parked,
2593
- entry: plan.stopEntry(),
2594
2419
  position: plan.stepCount,
2595
2420
  delivery: "below",
2596
2421
  executedAbove: plan.currentStepIndex,
@@ -2600,8 +2425,6 @@ export class ReplayController {
2600
2425
  delivered: true,
2601
2426
  };
2602
2427
  text = `${state.handover.note}\n\n${composed.text}`;
2603
- if (atDecision)
2604
- this.decidedInDivergence(state, plan);
2605
2428
  }
2606
2429
  else if (!stop) {
2607
2430
  // The bundle carries the rest of the chain, and its header tells the
@@ -2623,13 +2446,11 @@ export class ReplayController {
2623
2446
  stopNote(state, plan, stop, delivery,
2624
2447
  /** The steps the bundle left out (AC5): no step that ran. */
2625
2448
  left = []) {
2626
- const position = stop.flatIndex ?? plan.currentStepIndex;
2449
+ const position = stop.position ?? plan.currentStepIndex;
2627
2450
  const step = plan.allSteps()[position];
2628
- const entry = plan.allEntries()[position];
2629
2451
  const note = this.noteFor(state, {
2630
2452
  kind: stop.kind,
2631
2453
  step,
2632
- entry,
2633
2454
  position,
2634
2455
  error: stop.error,
2635
2456
  delivery,
@@ -2639,16 +2460,14 @@ export class ReplayController {
2639
2460
  // steps that ran; "no step ran" would invite the model to run them again.
2640
2461
  ran: delivery === "none" ? plan.currentStepIndex : undefined,
2641
2462
  executedAbove: plan.currentStepIndex,
2642
- // An output-logic failure stops after its step's tool ran (plan.ts) —
2643
- // not a sub-task's result mapping after its last step was left out.
2644
- ranThisStep: stop.stage === "tool_output_logic" && stop.reason !== "result_map",
2463
+ // An output-logic failure stops after its step's tool ran (plan.ts).
2464
+ ranThisStep: stop.stage === "tool_output_logic",
2645
2465
  whenText: stop.whenText,
2646
- subTask: stop.subTask,
2647
2466
  leftOut: left,
2648
2467
  });
2649
2468
  state.handover ??= {
2650
2469
  kind: stop.kind,
2651
- stepIndex: callerStepIndexOf(entry) ?? step?.stepIndex ?? position,
2470
+ stepIndex: step?.stepIndex ?? position,
2652
2471
  toolName: step?.toolName ?? undefined,
2653
2472
  error: stop.error,
2654
2473
  note,
@@ -2669,17 +2488,9 @@ export class ReplayController {
2669
2488
  return;
2670
2489
  const ran = state.stepsPinned;
2671
2490
  this.retire(state, ran > 0 ? "fell_back" : "failed");
2672
- // A plan stopped at its decision did all it was for: its bill ends here,
2673
- // and the agent's decision and the half it takes are the agent's own
2674
- // (the service's segmentsbreakdown.md SB-26). Only once a step ran: a
2675
- // plan that did nothing is a baseline sample of the whole turn.
2676
- if (h.kind === "decision" && ran > 0)
2677
- this.markRetired(state);
2678
2491
  state.handover = {
2679
2492
  kind: h.kind,
2680
- // The *caller's* index: a fragment opens at the call row, not at a step
2681
- // number the caller's own chain does not have (R-CALL-32, R-FALL-7).
2682
- stepIndex: callerStepIndexOf(h.entry) ?? h.step?.stepIndex ?? h.position,
2493
+ stepIndex: h.step?.stepIndex ?? h.position,
2683
2494
  toolName: h.step?.toolName ?? undefined,
2684
2495
  error: h.error,
2685
2496
  note: this.noteFor(state, h),
@@ -2690,83 +2501,38 @@ export class ReplayController {
2690
2501
  kind: h.kind,
2691
2502
  step: `${h.position}/${state.plan?.totalSteps ?? state.stepsPlanned}`,
2692
2503
  tool: h.step?.toolName ?? undefined,
2693
- // The scenario that owns the stopped step, when it is not the matched one.
2694
- owner: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
2695
- unusable: state.flatStop?.reason,
2696
- callIntent: state.flatStop?.callIntent ?? undefined,
2697
- segment: state.flatStop?.segmentId ?? undefined,
2698
2504
  ran,
2699
2505
  outcome: state.outcome,
2700
2506
  why: h.kind === "other_branch"
2701
2507
  ? h.noneNeeded
2702
2508
  ? "no step of the plan was needed for this request — the agent does the task"
2703
2509
  : `this request is the other half: the steps from here are only for when ${h.whenText ?? "their test says so"} — the agent continues`
2704
- : h.kind === "decision"
2705
- ? "the plan ran the steps before its decision — the agent decides which way applies and continues"
2706
- : h.kind === "unusable_call"
2707
- ? "a sub-task of this chain cannot run — the agent does that part itself"
2708
- : h.kind === "known_bad_step"
2709
- ? "the next step has failed too often — the agent continues from here"
2710
- : h.kind === "nondeterministic_step"
2711
- ? "the next step needs a judgement the plan cannot compute — the agent makes it"
2712
- : h.stopReason === "concurrent"
2713
- ? "a second call arrived while the pinned step was still running — the agent continues from here"
2714
- : h.stopReason === "budget" || h.stopReason === "cancelled"
2715
- ? "the run was cut short before this step — the agent continues from here"
2716
- : h.stopReason === "host_rejected" || h.stopReason === "host_edited"
2717
- ? "the host stopped this step itself — no fault of the plan; the agent continues from here"
2718
- : h.stopReason === "not_run" || h.stopReason === "uncertain"
2719
- ? "a step produced no result here — the agent continues from here"
2720
- : "a step broke — the agent continues from here",
2721
- });
2722
- }
2723
- /**
2724
- * Which kind of stop a plan's end is (plan-services.md D8). The stopping
2725
- * step's own served reason wins — the calculation's verdict is more exact
2726
- * than a counter — then a call the service could not answer, then a parked
2727
- * step.
2728
- */
2729
- stopKindOf(state, entry) {
2730
- // The service serves a decision ahead of any other stop of its step
2731
- // (the service's segmentsbreakdown.md SB-6), and so it is read first.
2732
- if (entry?.step.stop?.kind === "decision")
2733
- return "decision";
2734
- if (entry?.step.stop?.kind === "nondeterministic")
2735
- return "nondeterministic_step";
2736
- return state.flatStop ? "unusable_call" : "known_bad_step";
2737
- }
2738
- /**
2739
- * A divergence's bundle that ended at the plan's decision (SB-17): logged
2740
- * as `handOver` logs every other hand-over, and its usage mark taken there
2741
- * when a step of the plan ran (SB-26).
2742
- */
2743
- decidedInDivergence(state, plan) {
2744
- if (state.stepsPinned > 0)
2745
- this.markRetired(state);
2746
- logLine("replay.handover", {
2747
- scenario: state.scenarioId ?? undefined,
2748
- kind: "decision",
2749
- step: `${plan.stepCount}/${plan.totalSteps}`,
2750
- via: "divergence",
2751
- ran: state.stepsPinned,
2752
- outcome: state.outcome,
2753
- why: "the plan ran the steps before its decision — the agent decides which way applies and continues",
2510
+ : h.kind === "known_bad_step"
2511
+ ? "the next step has failed too often — the agent continues from here"
2512
+ : h.kind === "nondeterministic_step"
2513
+ ? "the next step needs a judgement the plan cannot compute — the agent makes it"
2514
+ : h.stopReason === "concurrent"
2515
+ ? "a second call arrived while the pinned step was still running — the agent continues from here"
2516
+ : h.stopReason === "budget" || h.stopReason === "cancelled"
2517
+ ? "the run was cut short before this step — the agent continues from here"
2518
+ : h.stopReason === "host_rejected" || h.stopReason === "host_edited"
2519
+ ? "the host stopped this step itself — no fault of the plan; the agent continues from here"
2520
+ : h.stopReason === "not_run" || h.stopReason === "uncertain"
2521
+ ? "a step produced no result here — the agent continues from here"
2522
+ : "a step broke — the agent continues from here",
2754
2523
  });
2755
2524
  }
2756
2525
  /**
2757
- * The steps before flat position `bound` that ran, 1-based, and the ones
2758
- * their tests left out (AC5) — the plan cursor's own, and a divergence
2759
- * bundle's (`extra`). `left` is empty when no step was left out; `ran` is
2760
- * then not filled, since every step before `bound` ran.
2526
+ * The steps before position `bound` that ran, 1-based, and the ones their
2527
+ * tests left out (AC5) — the plan cursor's own, and a divergence bundle's
2528
+ * (`extra`). `left` is empty when no step was left out; `ran` is then not
2529
+ * filled, since every step before `bound` ran.
2761
2530
  */
2762
2531
  ranBefore(plan, bound, extra = []) {
2763
- const left = [...(plan?.leftOut() ?? []), ...extra].filter((l) => l.flatIndex < bound);
2532
+ const left = [...(plan?.leftOut() ?? []), ...extra].filter((l) => l.position < bound);
2764
2533
  if (left.length === 0)
2765
2534
  return { ran: [], left };
2766
- const out = new Set();
2767
- for (const l of left)
2768
- for (let i = l.flatIndex; i < Math.min(l.until, bound); i += 1)
2769
- out.add(i);
2535
+ const out = new Set(left.map((l) => l.position));
2770
2536
  const ran = [];
2771
2537
  for (let i = 0; i < bound; i += 1)
2772
2538
  if (!out.has(i))
@@ -2777,8 +2543,7 @@ export class ReplayController {
2777
2543
  const plan = state.plan;
2778
2544
  // The steps before the stop — or the bound a caller gives — less the ones
2779
2545
  // their tests left out (AC5): a note must never say a step ran that did not.
2780
- // A `result_map` stop rests on the step left out, which is then named so.
2781
- const bound = h.ran ?? (h.ranThisStep || h.stopReason === "result_map" ? h.position + 1 : h.position);
2546
+ const bound = h.ran ?? (h.ranThisStep ? h.position + 1 : h.position);
2782
2547
  const before = this.ranBefore(plan, bound, h.leftOut);
2783
2548
  const skipped = before.left.length > 0;
2784
2549
  const executed = skipped ? before.ran.length : bound;
@@ -2787,50 +2552,31 @@ export class ReplayController {
2787
2552
  : before.ran.filter((n) => n <= h.executedAbove).length;
2788
2553
  // A step its host stopped was dealt with there — refused, answered, or
2789
2554
  // run with a person's arguments — so it is not among what is left to do.
2790
- // Nor is the step a test left out, after which a sub-task's results could
2791
- // not go back (`result_map`, AC5).
2792
2555
  const handled = h.ranThisStep === true ||
2793
2556
  h.stopReason === "host_rejected" ||
2794
- h.stopReason === "host_edited" ||
2795
- h.stopReason === "result_map";
2796
- // A chain that holds a decision (the service's segmentsbreakdown.md SB-18)
2797
- // keeps the decision note's protections in every note, even when the plan
2798
- // stops earlier for another reason: no intent, no parameters, and no step
2799
- // from the decision on. Each points at the half the recording took, and an
2800
- // agent that follows it there runs that half's segment: the plan decided.
2801
- const decisionAt = plan ? plan.decisionIndex() : -1;
2557
+ h.stopReason === "host_edited";
2802
2558
  let params;
2803
2559
  try {
2804
- params = decisionAt >= 0 ? undefined : plan?.parameters;
2560
+ params = plan?.parameters;
2805
2561
  }
2806
2562
  catch {
2807
2563
  params = undefined;
2808
2564
  }
2809
2565
  return buildHandoverNote({
2810
- intent: decisionAt >= 0 ? "" : (plan?.intent ?? ""),
2566
+ intent: plan?.intent ?? "",
2811
2567
  kind: h.kind,
2812
2568
  stepIndex: h.position,
2813
2569
  stepToolName: h.step?.toolName ?? undefined,
2814
2570
  failureCount: h.step?.failureCount,
2815
2571
  error: h.error,
2816
2572
  stopReason: h.stopReason,
2817
- unusable: state.flatStop
2818
- ? {
2819
- reason: state.flatStop.reason,
2820
- callIntent: state.flatStop.callIntent,
2821
- segmentId: state.flatStop.segmentId,
2822
- ownerScenarioId: state.flatStop.scenarioId,
2823
- }
2824
- : undefined,
2825
- ownerScenarioId: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
2826
- inSubTask: h.entry && h.entry.depth > 0 ? (h.entry.frame.callIntent ?? null) : null,
2827
2573
  executed: Math.max(0, Math.min(executed, plan?.totalSteps ?? executed)),
2828
2574
  ...(skipped
2829
2575
  ? {
2830
2576
  ranSteps: before.ran,
2831
2577
  leftOut: before.left.map((l) => ({
2832
- from: l.flatIndex + 1,
2833
- to: Math.min(l.until, bound),
2578
+ from: l.position + 1,
2579
+ to: l.position + 1,
2834
2580
  toolName: l.toolName,
2835
2581
  text: l.text,
2836
2582
  })),
@@ -2842,17 +2588,12 @@ export class ReplayController {
2842
2588
  totalSteps: plan?.totalSteps ?? state.stepsPlanned,
2843
2589
  // A step that runs only under a test is marked so: the list must not
2844
2590
  // read as steps this request needs (AC5).
2845
- remainingTools: plan
2846
- ? plan.remainingFrom(handled ? h.position + 1 : h.position, decisionAt >= 0 ? decisionAt : undefined)
2847
- : [],
2591
+ remainingTools: plan ? plan.remainingFrom(handled ? h.position + 1 : h.position) : [],
2848
2592
  params,
2849
2593
  delivery: h.delivery,
2850
2594
  whenText: h.whenText ?? null,
2851
- subTask: h.subTask ?? null,
2852
2595
  // "None was needed" is the note's own wording for the end of the plan.
2853
2596
  ...(h.noneNeeded ? { stepIndex: plan?.totalSteps ?? state.stepsPlanned } : {}),
2854
- // The request's own words for the choice (SB-18); never the service's.
2855
- ...(h.kind === "decision" ? { request: state.request ?? null } : {}),
2856
2597
  });
2857
2598
  }
2858
2599
  /**
@@ -2991,8 +2732,8 @@ export class ReplayController {
2991
2732
  return undefined;
2992
2733
  // A step a test left out passes over its recorded output, unless a
2993
2734
  // correction added it (AC5, source-run.ts).
2994
- return Object.assign((entry) => source.outputFor(entry), {
2995
- leftOut: (entry) => source.leftOut(entry),
2735
+ return Object.assign((step) => source.outputFor(step), {
2736
+ leftOut: (step) => source.leftOut(step),
2996
2737
  });
2997
2738
  }
2998
2739
  /**
@@ -3071,7 +2812,7 @@ export class ReplayController {
3071
2812
  }
3072
2813
  if (!opts.adHoc) {
3073
2814
  const schema = plan.paramsSchema;
3074
- const planned = plan.allEntries().slice(0, plan.stepCount);
2815
+ const planned = plan.allSteps().slice(0, plan.stepCount);
3075
2816
  const blocked = blockingTargets(targetKeys(schema), schema, planned, plan.paramsLogic);
3076
2817
  if (blocked.length > 0) {
3077
2818
  return {
@@ -3088,7 +2829,7 @@ export class ReplayController {
3088
2829
  const missingAgain = state.rederivedMissing;
3089
2830
  state.rederivedMissing = undefined;
3090
2831
  if (missingAgain && !opts.adHoc) {
3091
- const planned = plan.allEntries().slice(0, plan.stepCount);
2832
+ const planned = plan.allSteps().slice(0, plan.stepCount);
3092
2833
  const blocked = blockingTargets(missingAgain, plan.paramsSchema, planned, plan.paramsLogic);
3093
2834
  if (blocked.length > 0) {
3094
2835
  return {
@@ -3101,7 +2842,7 @@ export class ReplayController {
3101
2842
  }
3102
2843
  const sink = opts.adHoc
3103
2844
  ? undefined
3104
- : sinkOfPlan(plan.allEntries().slice(0, plan.stepCount), state.host?.sinks);
2845
+ : sinkOfPlan(plan.allSteps().slice(0, plan.stepCount), state.host?.sinks);
3105
2846
  if (sink) {
3106
2847
  const bad = unsafeParameter(plan.derivedValues, plan.paramsSchema, sink);
3107
2848
  if (bad) {
@@ -3200,12 +2941,7 @@ export class ReplayController {
3200
2941
  */
3201
2942
  recordStep(state, info) {
3202
2943
  const status = info.outcome === "executed" ? "ok" : info.outcome;
3203
- // Keyed by the *flat* position, not by the step's own index: two scenarios
3204
- // in one plan both have a step 0, and one would otherwise overwrite the
3205
- // other's verdict (segmented.md R-CALL-13).
3206
- const key = info.entry
3207
- ? `${info.entry.scenarioId}#${info.entry.stepIndex}`
3208
- : `#${info.step.stepIndex}`;
2944
+ const key = `#${info.step.stepIndex}`;
3209
2945
  if (state.stepResults.size >= MAX_STEP_RESULTS && !state.stepResults.has(key)) {
3210
2946
  return;
3211
2947
  }
@@ -3214,29 +2950,19 @@ export class ReplayController {
3214
2950
  ? `${info.error.slice(0, MAX_STEP_ERROR_CHARS)}…`
3215
2951
  : info.error
3216
2952
  : undefined;
3217
- // Payloads only for a step of the reported scenario's own chain. The
3218
- // console still files an execution's verdicts by step number alone
3219
- // (plan-services.md W1.9), and a called segment numbers its steps from
3220
- // zero as well: a called step's call would be shown under the caller's
3221
- // step of the same number — the very mix-up the payloads are there to end.
3222
- const own = !info.entry || info.entry.depth === 0;
3223
2953
  // A step whose input logic threw never had an input: the `{}` the plan
3224
2954
  // reports there is a placeholder, not a call with no arguments. Nor had a
3225
2955
  // step its test left out or stopped in front of (AC5).
3226
2956
  const noCall = info.stage === "tool_input_logic" || info.stage === "when_logic" || status === "not_taken";
3227
- const sent = own && !noCall ? info.input : undefined;
3228
- const received = own && !noCall ? info.output : undefined;
2957
+ const sent = !noCall ? info.input : undefined;
2958
+ const received = !noCall ? info.output : undefined;
3229
2959
  // The call, kept whole for the turn's newest run (AC2): only one that
3230
2960
  // really went out — it answered, or it answered with an error.
3231
2961
  if (state.keepCalls &&
3232
2962
  !noCall &&
3233
2963
  info.input !== undefined &&
3234
2964
  (status === "ok" || (status === "failed" && info.stage === "tool_call"))) {
3235
- // By frame, not by scenario: a chain that calls the same sub-task twice
3236
- // makes two calls of each of its steps, and both are the turn's (AC5
3237
- // review). A frame is one call of one sub-task.
3238
- const callKey = info.entry ? `${info.entry.frame.id}#${info.entry.stepIndex}` : key;
3239
- this.keepCall(state, callKey, info, status === "failed");
2965
+ this.keepCall(state, key, info, status === "failed");
3240
2966
  }
3241
2967
  state.stepResults.set(key, {
3242
2968
  stepIndex: info.step.stepIndex,
@@ -3252,10 +2978,6 @@ export class ReplayController {
3252
2978
  // The tool is gone from its server: the service repairs the plan without
3253
2979
  // waiting out the grace (plan-services.md D8, kind 2).
3254
2980
  toolMissing: info.toolMissing || undefined,
3255
- // A verdict about a called segment's step is counted on that segment, at
3256
- // the revision it was served (R-CALL-13).
3257
- scenarioId: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
3258
- chainRevision: info.entry && info.entry.depth > 0 ? info.entry.frame.chainRevision : undefined,
3259
2981
  });
3260
2982
  }
3261
2983
  /**
@@ -3274,14 +2996,11 @@ export class ReplayController {
3274
2996
  ? serializeCapped(redact(info.input ?? {}))
3275
2997
  : reportPayload(serializeCapped(redact(info.input ?? {})), true);
3276
2998
  const answer = info.output === undefined ? undefined : room ? heldPayload(info.output) : reportPayload(info.output);
3277
- const inSegment = info.entry && info.entry.depth > 0;
3278
2999
  const call = {
3279
- scenarioId: inSegment ? info.entry.scenarioId : (state.scenarioId ?? info.entry?.scenarioId ?? ""),
3000
+ scenarioId: state.scenarioId ?? "",
3280
3001
  stepIndex: info.step.stepIndex,
3281
- chainRevision: inSegment
3282
- ? (info.entry.frame.chainRevision ?? null)
3283
- : (state.chainRevision ?? null),
3284
- toolName: info.step.toolName ?? "",
3002
+ chainRevision: state.chainRevision ?? null,
3003
+ toolName: info.step.toolName,
3285
3004
  input,
3286
3005
  ...(failed
3287
3006
  ? { error: info.error ? capText(redactText(info.error), DEFAULT_MAX_PAYLOAD_BYTES) : answer }
@@ -3305,8 +3024,6 @@ export class ReplayController {
3305
3024
  if (info.outcome === "failed") {
3306
3025
  logLine("replay.step_failed", {
3307
3026
  n: info.step.stepIndex,
3308
- scenario: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
3309
- frame: info.entry?.frame.id,
3310
3027
  tool: info.step.toolName ?? undefined,
3311
3028
  stage: info.stage,
3312
3029
  why: info.stage === "tool_call"
@@ -3338,8 +3055,7 @@ export class ReplayController {
3338
3055
  const text = testWords(info.step.when?.text);
3339
3056
  logLine("replay.step_not_taken", {
3340
3057
  n: info.step.stepIndex,
3341
- tool: info.step.toolName ?? info.step.callIntent ?? undefined,
3342
- scenario: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
3058
+ tool: info.step.toolName,
3343
3059
  why: `its test said this request does not need it${text ? ` — only for when ${text}` : ""}; left out, and the plan goes on`,
3344
3060
  });
3345
3061
  return;
@@ -3363,10 +3079,17 @@ export class ReplayController {
3363
3079
  this.upgrade(state, outcome);
3364
3080
  // Only a plan that *finished* stops the clock (segmented.md R-MONEY-3). A
3365
3081
  // hand-over or a divergence takes no mark: the agent's next tokens are the
3366
- // consequence of this plan stopping, and belong to its bill. (A decision
3367
- // hand-over takes one of its own: `markRetired`.)
3368
- if (completing)
3369
- this.markRetired(state);
3082
+ // consequence of this plan stopping, and belong to its bill.
3083
+ if (completing) {
3084
+ state.retiredAt = Date.now();
3085
+ try {
3086
+ state.retiredMark = state.markUsage?.();
3087
+ }
3088
+ catch {
3089
+ // An unreadable transcript leaves the mark undefined, and the window
3090
+ // falls back to the next arm or the end of the turn.
3091
+ }
3092
+ }
3370
3093
  state.retired = true;
3371
3094
  state.pinned.clear();
3372
3095
  // A note naming a step to call next is void once the plan is: after a
@@ -3378,21 +3101,6 @@ export class ReplayController {
3378
3101
  // A call waiting for a pin to thread has nothing left to wait for.
3379
3102
  this.notifyPins(state);
3380
3103
  }
3381
- /**
3382
- * Where the plan's work ended, for its bill: now, and the transcript's
3383
- * watermark now. Taken at a completing retire, and at a decision hand-over
3384
- * after a step ran (the service's segmentsbreakdown.md SB-26).
3385
- */
3386
- markRetired(state) {
3387
- state.retiredAt = Date.now();
3388
- try {
3389
- state.retiredMark = state.markUsage?.();
3390
- }
3391
- catch {
3392
- // An unreadable transcript leaves the mark undefined, and the window
3393
- // falls back to the next arm or the end of the turn.
3394
- }
3395
- }
3396
3104
  }
3397
3105
  /** Every target of a schema. A parameter with no kind is one (R-PARAM-3). */
3398
3106
  function targetKeys(schema) {
@@ -3530,58 +3238,29 @@ function heldPayload(text) {
3530
3238
  }
3531
3239
  return capText(text, DEFAULT_MAX_PAYLOAD_BYTES, redactText);
3532
3240
  }
3533
- /** Whether an entry runs only under a test (AC5): its own, or a sub-task's it is in. */
3534
- function underTest(entry) {
3535
- if (entry.step.when)
3536
- return true;
3537
- for (let f = entry.frame; f; f = f.parent)
3538
- if (f.when)
3539
- return true;
3540
- return false;
3241
+ /** Whether a step runs only under a test (AC5). */
3242
+ function underTest(step) {
3243
+ return Boolean(step.when);
3541
3244
  }
3542
- /** The tests of a flat chain, by flat position, for the `plan.armed` line (AC5). */
3543
- function testLines(entries) {
3544
- // A sub-task's test sits where the sub-task begins — at its callee's first
3545
- // step when its chain begins with a call.
3546
- const { startsAt } = frameEdges(entries);
3547
- const lines = entries
3548
- .map((e, i) => {
3549
- const own = e.step.when;
3550
- const call = (startsAt.get(i) ?? []).find((f) => f.when)?.when;
3551
- const t = own ?? call;
3552
- return t ? `${i}:${call && !own ? "call/" : ""}${t.otherwise}` : undefined;
3553
- })
3245
+ /** The tests of a chain, by position, for the `plan.armed` line (AC5). */
3246
+ function testLines(steps) {
3247
+ const lines = steps
3248
+ .map((s, i) => (s.when ? `${i}:${s.when.otherwise}` : undefined))
3554
3249
  .filter((s) => s !== undefined);
3555
3250
  return lines.length > 0 ? lines.join(",") : undefined;
3556
3251
  }
3557
3252
  /**
3558
- * The index a hand-over reports, in the *caller's* chain (R-CALL-32).
3559
- *
3560
- * A step inside a called segment has no position in the caller's own chain; the
3561
- * call row that opened its frame does. Reporting the segment's own index would
3562
- * line the execution up against a step the caller's card does not have.
3253
+ * Which kind of stop a plan's end is (plan-services.md D8): the stopping
3254
+ * step's own served reason wins — the calculation's verdict is more exact
3255
+ * than a counter — then a parked step.
3563
3256
  */
3564
- function callerStepIndexOf(entry) {
3565
- if (!entry || entry.depth === 0)
3566
- return entry?.stepIndex;
3567
- let frame = entry.frame;
3568
- while (frame.parent && frame.depth > 1)
3569
- frame = frame.parent;
3570
- return frame.callerStepIndex;
3571
- }
3572
- /** The calls in a served chain, for the `plan.armed` line (R-CALL-32). */
3573
- function callLines(scenario) {
3574
- const calls = (scenario.steps ?? []).filter((s) => s.kind === "segment");
3575
- if (calls.length === 0)
3576
- return undefined;
3577
- return calls
3578
- .map((s) => `${s.segmentId ?? "-"}:${s.unusable ?? s.resolution?.state ?? "resolved"}`)
3579
- .join(",");
3257
+ function stopKindOf(step) {
3258
+ return step?.stop?.kind === "nondeterministic" ? "nondeterministic_step" : "known_bad_step";
3580
3259
  }
3581
- /** The served stops in a flat chain, for the `plan.armed` line (plan-services.md D8). */
3582
- function stopLines(entries) {
3583
- const lines = entries
3584
- .map((e, i) => e.step.stop ? `${i}:${e.step.stop.kind}${e.step.stop.repairDue ? "/repair" : ""}` : undefined)
3260
+ /** The served stops in a chain, for the `plan.armed` line (plan-services.md D8). */
3261
+ function stopLines(steps) {
3262
+ const lines = steps
3263
+ .map((s, i) => (s.stop ? `${i}:${s.stop.kind}${s.stop.repairDue ? "/repair" : ""}` : undefined))
3585
3264
  .filter((s) => s !== undefined);
3586
3265
  return lines.length > 0 ? lines.join(",") : undefined;
3587
3266
  }
@@ -3606,8 +3285,7 @@ function isKnownBad(step, maxStepFailures) {
3606
3285
  *
3607
3286
  * A step that carries `stop` was decided by the service — `null` means run it,
3608
3287
  * even when its count is past the limit: its retry is due. A step without the
3609
- * field came from an older service, and the old counter rule decides it. Both
3610
- * can sit in one chain, since a called segment is served on its own terms.
3288
+ * field came from an older service, and the old counter rule decides it.
3611
3289
  */
3612
3290
  export function stopIndex(steps, maxStepFailures) {
3613
3291
  const i = steps.findIndex((s) => s.stop !== undefined ? s.stop !== null : isKnownBad(s, maxStepFailures));