@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
@@ -20,8 +20,7 @@
20
20
  * step to know its reach, so it can choose between the proxy, a recorded
21
21
  * output, and skipping.
22
22
  */
23
- import { evalParamMapLogic, evalParamsLogic, evalResponseParamsLogic, evalResultMapLogic, evalToolInputLogic, evalToolOutputLogic, evalWhenLogic, LogicTimeoutError, } from "./logic.js";
24
- import { flatEntriesOf, frameEdges } from "./flatten.js";
23
+ import { evalParamsLogic, evalResponseParamsLogic, evalToolInputLogic, evalToolOutputLogic, evalWhenLogic, LogicTimeoutError, } from "./logic.js";
25
24
  import { assembleBundle, bundleInput, MAX_REPLAY_REASON, testWords } from "./bundle.js";
26
25
  import { redact, redactText } from "../record/redact.js";
27
26
  import { sinkOfPlan, unsafeParameter } from "./safety.js";
@@ -272,25 +271,6 @@ function renderArguments(toolName, input) {
272
271
  }
273
272
  return `arguments: {${parts.join(",")}}${shortened ? " (shortened)" : ""}`;
274
273
  }
275
- /**
276
- * The recorded values for the *settings* a mapping did not name (R-CALL-21).
277
- *
278
- * A target must always be supplied by the caller; a setting is what the segment
279
- * was recorded doing, and taking its sample here is what makes "may be omitted"
280
- * true rather than "runs with undefined".
281
- */
282
- function settingsOf(paramsObject, mapped) {
283
- const out = {};
284
- if (!paramsObject || typeof paramsObject !== "object")
285
- return out;
286
- for (const [name, p] of Object.entries(paramsObject)) {
287
- if (name in mapped)
288
- continue;
289
- if (p?.kind === "setting" && p && "sampleValue" in p)
290
- out[name] = p.sampleValue;
291
- }
292
- return out;
293
- }
294
274
  /**
295
275
  * Evaluate one of the scenario's logic bodies, naming the step and the stage to
296
276
  * the observer if it throws — then rethrow, unchanged and uncaught.
@@ -303,14 +283,13 @@ function settingsOf(paramsObject, mapped) {
303
283
  * `output` is what the body was given, for a stage that runs on one: a throw
304
284
  * in the output logic is only readable next to the output it threw on.
305
285
  */
306
- function reportingStage(onStep, entry, stage, input, startedAt, fn, output) {
286
+ function reportingStage(onStep, step, stage, input, startedAt, fn, output) {
307
287
  try {
308
288
  return fn();
309
289
  }
310
290
  catch (err) {
311
291
  onStep?.({
312
- step: entry.step,
313
- entry,
292
+ step,
314
293
  input,
315
294
  output,
316
295
  // A value only the recording holds is no fault of the step's (issue 1212).
@@ -328,8 +307,8 @@ export class ScenarioReplayPlan {
328
307
  mode;
329
308
  intent;
330
309
  scenario;
331
- /** The flat list this plan walks. One entry per step that will really run. */
332
- entries;
310
+ /** The chain this plan walks. */
311
+ steps;
333
312
  paramsPromise;
334
313
  /** The values a step worked out in the recording ({@link FROM_STEP_MARK}). */
335
314
  fromStep;
@@ -352,219 +331,33 @@ export class ScenarioReplayPlan {
352
331
  readyPromise;
353
332
  /** Exclusive end of the planned steps. Equal to the chain length unless a step is parked. */
354
333
  stopAt;
355
- /** This plan is a sub-task inside the agent's own task (segmented.md R-OUT-13). */
334
+ /** This plan is a sub-task inside the agent's own task: it was armed mid-task by intent. */
356
335
  subTask;
357
- /**
358
- * Where each called frame begins and ends, its callees' entries included:
359
- * where it is entered, its test decided and it is left, and where a
360
- * sub-task its test leaves out ends (AC5).
361
- */
362
- edges;
363
- /** Each entry's flat position. */
364
- positions = new Map();
365
- /** Every frame of the flat list, by id. */
366
- frames = new Map();
367
- /** The plan's own walk: what its cursor has entered and decided (AC5). */
368
- walk = { entered: new Set(), tested: new Set(), passed: new Set(), left: [] };
336
+ /** The plan's own walk: what its cursor has decided (AC5). */
337
+ walk = { passed: new Set(), left: [] };
369
338
  constructor(opts) {
370
339
  this.scenario = opts.scenario;
371
340
  this.scenarioId = opts.scenario.id;
372
341
  this.runId = opts.scenario.runId;
373
342
  this.intent = opts.scenario.intent ?? "";
374
- this.entries = opts.entries ?? flatEntriesOf(opts.scenario);
343
+ this.steps = opts.scenario.steps ?? [];
375
344
  this.mode = opts.mode;
376
345
  this.paramsPromise = opts.params;
377
346
  this.fromStep = fromStepKeys(opts.scenario.paramsObject);
378
347
  this.fallbackTargets = opts.fallbackTargets ?? "null";
379
348
  this.respParams = { ...(opts.respParamsInit ?? {}) };
380
- // The caller's own frame accumulates into the plan's `respParams`, so
381
- // `accumulated`, `responseModel` and every depth-0 step see one object.
382
- if (this.entries[0])
383
- this.rootFrame().respParams = this.respParams;
384
349
  this.subTask = opts.subTask === true;
385
350
  const stop = opts.stopAt;
386
351
  this.stopAt =
387
- typeof stop === "number" && stop >= 0 && stop < this.entries.length
352
+ typeof stop === "number" && stop >= 0 && stop < this.steps.length
388
353
  ? stop
389
- : this.entries.length;
354
+ : this.steps.length;
390
355
  // Judged over the steps this plan will run, as the gate that armed it was.
391
356
  const schema = opts.scenario.paramsObject;
392
357
  this.oneHalf =
393
358
  schema && typeof schema === "object"
394
- ? testedOnlyTargets(Object.keys(schema).filter((k) => schema[k]?.kind !== "setting"), schema, this.entries.slice(0, this.stopAt), opts.scenario.paramsLogic)
359
+ ? testedOnlyTargets(Object.keys(schema).filter((k) => schema[k]?.kind !== "setting"), schema, this.steps.slice(0, this.stopAt), opts.scenario.paramsLogic)
395
360
  : [];
396
- this.edges = frameEdges(this.entries);
397
- this.entries.forEach((entry, index) => {
398
- this.positions.set(entry, index);
399
- for (let f = entry.frame; f; f = f.parent)
400
- this.frames.set(f.id, f);
401
- });
402
- }
403
- frameById(id) {
404
- return this.frames.get(id);
405
- }
406
- /** The called frames that begin at flat position `flatIndex`, outermost first. */
407
- framesStartingAt(flatIndex) {
408
- return this.edges.startsAt.get(flatIndex) ?? [];
409
- }
410
- /** The caller's own frame — the one at depth 0. */
411
- rootFrame() {
412
- let frame = this.entries[0].frame;
413
- while (frame.parent)
414
- frame = frame.parent;
415
- return frame;
416
- }
417
- /** The parameters one entry's logic runs with (segmented.md R-CALL-29). */
418
- paramsFor(entry) {
419
- return entry.depth === 0 ? this.params : (entry.frame.parameters ?? {});
420
- }
421
- /** The accumulated values one entry's logic runs with. */
422
- respFor(entry) {
423
- return entry.depth === 0 ? this.respParams : entry.frame.respParams;
424
- }
425
- /**
426
- * Entering a called frame: build the segment's parameters from the caller's
427
- * state (R-CALL-29).
428
- *
429
- * The recipe is the dry run's, exactly (9.8 step 10): the mapping's output,
430
- * plus the segment's own recorded sample for every *setting* the mapping left
431
- * out. A mapping that omits a setting therefore cannot pass the service's
432
- * check and then run `undefined` here.
433
- */
434
- enterFramesAt(flatIndex, walk) {
435
- // Outermost first: a sub-task whose chain begins with a call begins where
436
- // its callee does, and the callee's mapping reads the sub-task's
437
- // parameters — built here, before it (AC5 review).
438
- for (const frame of this.framesStartingAt(flatIndex))
439
- this.enterFrame(frame, walk);
440
- }
441
- enterFrame(frame, walk) {
442
- if (frame.depth === 0)
443
- return;
444
- // Once per walk: a step's test may need the frame's parameters before the
445
- // step's input is computed (AC5), and a steer cursor computes one step's
446
- // input again when a pin was declined.
447
- if (walk.entered.has(frame.id))
448
- return;
449
- const resp = walk.resp;
450
- const parent = frame.parent;
451
- const parentParams = parent
452
- ? parent.depth === 0
453
- ? this.params
454
- : (parent.parameters ?? {})
455
- : this.params;
456
- const parentResp = parent
457
- ? resp
458
- ? (resp.get(parent.id) ?? parent.respParams)
459
- : parent.respParams
460
- : this.respParams;
461
- const mapped = frame.paramMapLogic
462
- ? evalParamMapLogic(frame.paramMapLogic, parentParams, parent?.intent ?? this.intent, {
463
- ...parentResp,
464
- })
465
- : {};
466
- frame.parameters = { ...settingsOf(frame.paramsObject, mapped), ...mapped };
467
- if (resp)
468
- resp.set(frame.id, {});
469
- else
470
- frame.respParams = {};
471
- walk.entered.add(frame.id);
472
- }
473
- /**
474
- * Leave every called frame that ends at `flatIndex` and that `walk`
475
- * entered, innermost first: a sub-task whose chain ends with a call is left
476
- * after its callee has handed it its results.
477
- */
478
- leaveFramesAt(flatIndex, walk) {
479
- for (const frame of this.edges.endsAt.get(flatIndex) ?? []) {
480
- if (walk.entered.has(frame.id))
481
- this.leaveFrame(frame, walk.resp);
482
- }
483
- }
484
- /**
485
- * After a skip that ends at flat position `last`: leave every entered
486
- * sub-task that ends there — its results still go back to its caller. A
487
- * result mapping that throws ends the walk: its verdict is filed on the
488
- * call row, in the caller's chain, and the stop says the step at `last` was
489
- * left out, not run (`result_map`). Rethrown instead when `rethrow`.
490
- */
491
- leaveAfterSkip(last, walk, onStep, rethrow) {
492
- for (const frame of this.edges.endsAt.get(last) ?? []) {
493
- if (!walk.entered.has(frame.id))
494
- continue;
495
- try {
496
- this.leaveFrame(frame, walk.resp);
497
- }
498
- catch (err) {
499
- if (rethrow)
500
- throw err;
501
- const error = errText(err);
502
- const { step, entry } = this.callRowOf(frame, this.entries[last]);
503
- onStep?.({ step, entry, input: {}, outcome: "failed", stage: "tool_output_logic", error, ms: 0 });
504
- return {
505
- kind: "step_failed",
506
- stepIndex: step.stepIndex,
507
- // At the step left out, which did not run: what ran before it did
508
- // its work, and what comes after it is left to do.
509
- flatIndex: last,
510
- stage: "tool_output_logic",
511
- error,
512
- reason: "result_map",
513
- subTask: frame.callIntent ?? null,
514
- };
515
- }
516
- }
517
- return undefined;
518
- }
519
- /**
520
- * The call row that opened `frame`, as a step of its caller's chain: where
521
- * a sub-task's test and its result mapping are filed (R-CALL-13).
522
- */
523
- callRowOf(frame, at) {
524
- const step = frame.callStep ?? {
525
- stepIndex: frame.callerStepIndex ?? at.stepIndex,
526
- kind: "segment",
527
- toolName: null,
528
- toolInputLogic: null,
529
- toolOutputLogic: null,
530
- reasoning: null,
531
- };
532
- const owner = frame.parent ?? frame;
533
- return {
534
- step,
535
- entry: {
536
- scenarioId: owner.scenarioId,
537
- runId: owner.runId,
538
- stepFrom: owner.stepFrom,
539
- stepTo: owner.stepTo,
540
- stepIndex: step.stepIndex,
541
- step,
542
- frame: owner,
543
- depth: owner.depth,
544
- first: false,
545
- last: false,
546
- },
547
- };
548
- }
549
- /**
550
- * Leaving a called frame: hand what the segment emitted back to its caller
551
- * (R-CALL-29). `out` is the frame's own accumulated `respParams`.
552
- */
553
- leaveFrame(frame, resp) {
554
- if (frame.depth === 0)
555
- return;
556
- const parent = frame.parent;
557
- if (!frame.resultMapLogic || !parent)
558
- return;
559
- const parentParams = parent.depth === 0 ? this.params : (parent.parameters ?? {});
560
- const target = resp
561
- ? (resp.get(parent.id) ?? parent.respParams)
562
- : parent.depth === 0
563
- ? this.respParams
564
- : parent.respParams;
565
- const own = resp ? (resp.get(frame.id) ?? frame.respParams) : frame.respParams;
566
- const out = evalResultMapLogic(frame.resultMapLogic, { ...own }, parentParams, parent.intent, { ...target });
567
- Object.assign(target, out);
568
361
  }
569
362
  /**
570
363
  * Await the derivation, then apply `paramsLogic` — once, however many callers
@@ -684,37 +477,17 @@ export class ScenarioReplayPlan {
684
477
  get stepCount() {
685
478
  return this.stopAt;
686
479
  }
687
- /** Every step this plan could run, planned or not. */
480
+ /** Every step in the scenario's chain, planned or not. */
688
481
  get totalSteps() {
689
- return this.entries.length;
482
+ return this.steps.length;
690
483
  }
691
484
  /** True when the plan ends in front of a known-bad step (fallbk.md D3). */
692
485
  stopsEarly() {
693
- return this.stopAt < this.entries.length;
486
+ return this.stopAt < this.steps.length;
694
487
  }
695
488
  /** The known-bad step the plan stops in front of, if any. */
696
489
  stopStep() {
697
- return this.stopsEarly() ? this.entries[this.stopAt]?.step : undefined;
698
- }
699
- /** The entry the plan stops in front of, with the frame that owns it. */
700
- stopEntry() {
701
- return this.stopsEarly() ? this.entries[this.stopAt] : undefined;
702
- }
703
- /**
704
- * The flat position of the chain's decision stop (the service's
705
- * segmentsbreakdown.md SB-18), or -1 when it has none. The steps from there
706
- * on are the half the recording took, whatever stops the plan first.
707
- */
708
- decisionIndex() {
709
- return this.entries.findIndex((e) => e.step.stop?.kind === "decision");
710
- }
711
- /** Every entry in order — for the report, and for the owning scenario ids. */
712
- allEntries() {
713
- return this.entries;
714
- }
715
- /** The entry awaiting execution, or undefined when the plan is done. */
716
- currentEntry() {
717
- return this.stepIndex < this.stopAt ? this.entries[this.stepIndex] : undefined;
490
+ return this.stopsEarly() ? this.steps[this.stopAt] : undefined;
718
491
  }
719
492
  /** The parameters in force. Empty until {@link ready} resolves. */
720
493
  get parameters() {
@@ -772,19 +545,19 @@ export class ScenarioReplayPlan {
772
545
  }
773
546
  /** The step awaiting execution, or undefined when the plan is done. */
774
547
  currentStep() {
775
- return this.currentEntry()?.step;
548
+ return this.stepIndex < this.stopAt ? this.steps[this.stepIndex] : undefined;
776
549
  }
777
550
  /** The tool the current step expects, or undefined when done. */
778
551
  expectedTool() {
779
- return this.currentStep()?.toolName ?? undefined;
552
+ return this.currentStep()?.toolName;
780
553
  }
781
554
  /** True once every planned step has been applied — the plan should be retired. */
782
555
  isDone() {
783
556
  return this.stepIndex >= this.stopAt;
784
557
  }
785
- /** Every step in order — for the directive, and for coverage reporting. */
558
+ /** Every step in order — for the directive, the report and coverage reporting. */
786
559
  allSteps() {
787
- return this.entries.map((e) => e.step);
560
+ return this.steps;
788
561
  }
789
562
  /**
790
563
  * True when `toolName` is one of this scenario's own tools.
@@ -796,7 +569,7 @@ export class ScenarioReplayPlan {
796
569
  * abandoned the sequence.
797
570
  */
798
571
  usesTool(toolName) {
799
- return this.entries.some((e) => e.step.toolName === toolName);
572
+ return this.steps.some((s) => s.toolName === toolName);
800
573
  }
801
574
  /**
802
575
  * Compute the pinned input for the current step. Throws when there is no
@@ -804,14 +577,10 @@ export class ScenarioReplayPlan {
804
577
  * a throw as divergence.
805
578
  */
806
579
  toolInputForCurrentStep() {
807
- const entry = this.entries[this.stepIndex];
808
- if (!entry)
580
+ const step = this.steps[this.stepIndex];
581
+ if (!step)
809
582
  throw new Error("no current step to compute input for");
810
- // Entering a called frame happens here, on its first step, because this is
811
- // the first moment the caller's own accumulation is final (R-CALL-29) —
812
- // unless the step's test needed the frame first (AC5).
813
- this.enterFramesAt(this.stepIndex, this.walk);
814
- return this.inputOf(entry, this.paramsFor(entry), entry.frame.intent, this.respFor(entry));
583
+ return this.inputOf(step, this.params, this.intent, this.respParams);
815
584
  }
816
585
  /**
817
586
  * A step's input, refused when it holds a value only the recording had: its
@@ -820,9 +589,9 @@ export class ScenarioReplayPlan {
820
589
  * is one that reads, or holds, a value the request did not give (review
821
590
  * item 11): {@link MissingValueError}.
822
591
  */
823
- inputOf(entry, params, intent, resp, opts) {
592
+ inputOf(step, params, intent, resp, opts) {
824
593
  const w = this.watched(params);
825
- const input = evalToolInputLogic(entry.step.toolInputLogic ?? "return {};", w.params, intent, resp, opts);
594
+ const input = evalToolInputLogic(step.toolInputLogic, w.params, intent, resp, opts);
826
595
  const read = [...w.read][0];
827
596
  if (read !== undefined)
828
597
  throw this.valueError(read);
@@ -836,41 +605,28 @@ export class ScenarioReplayPlan {
836
605
  // A step may run only when its test says so: a JavaScript body over the
837
606
  // parameters, the intent and what the earlier steps accumulated, decided
838
607
  // when the step comes due — never sooner, because the accumulation it reads
839
- // is final only then. A call row's test decides its whole sub-task, in the
840
- // caller's scope, before the sub-task's frame is entered. `skip` leaves the
841
- // step out; `hand_over` stops the plan in front of it (the other half of an
842
- // "if … otherwise", which the agent does); a test that throws or answers
843
- // anything but true or false stops it too. Nothing is ever guessed.
608
+ // is final only then. `skip` leaves the step out; `hand_over` stops the plan
609
+ // in front of it (the other half of an "if … otherwise", which the agent
610
+ // does); a test that throws or answers anything but true or false stops it
611
+ // too. Nothing is ever guessed.
844
612
  /** Whether the plan's cursor rests in front of the step it is parked before. */
845
613
  atStop() {
846
614
  return this.stopsEarly() && this.stepIndex === this.stopAt;
847
615
  }
848
- /**
849
- * The test an entry runs under, in words, when it has one: its own, or that
850
- * of a sub-task beginning at it — one whose chain begins with a call begins
851
- * at its callee's first step.
852
- */
853
- onlyWhen(entry) {
854
- const own = entry.step.when;
855
- if (own)
856
- return { text: testWords(own.text) };
857
- const at = this.positions.get(entry);
858
- const call = at === undefined ? undefined : this.framesStartingAt(at).find((f) => f.when)?.when;
859
- if (call)
860
- return { text: testWords(call.text) };
861
- return undefined;
616
+ /** The test a step runs under, in words, when it has one. */
617
+ onlyWhen(step) {
618
+ const own = step.when;
619
+ return own ? { text: testWords(own.text) } : undefined;
862
620
  }
863
621
  /**
864
- * The tool names of the steps from flat position `from` to the end (or to
865
- * `until`, not included), each that runs only under a test marked so — for a
866
- * hand-over note's "remaining planned steps", which must not read as steps
867
- * this request needs.
622
+ * The tool names of the steps from position `from` to the end, each that
623
+ * runs only under a test marked so — for a hand-over note's "remaining
624
+ * planned steps", which must not read as steps this request needs.
868
625
  */
869
- remainingFrom(from, until) {
870
- return this.entries.slice(Math.max(0, from), until).map((e) => {
871
- const name = e.step.toolName ?? "";
872
- const t = this.onlyWhen(e);
873
- return t ? `${name} (only when ${t.text ?? "a request needs it"})` : name;
626
+ remainingFrom(from) {
627
+ return this.steps.slice(Math.max(0, from)).map((s) => {
628
+ const t = this.onlyWhen(s);
629
+ return t ? `${s.toolName} (only when ${t.text ?? "a request needs it"})` : s.toolName;
874
630
  });
875
631
  }
876
632
  /** The steps the plan's own cursor has left out so far, in order (AC5). */
@@ -879,7 +635,7 @@ export class ScenarioReplayPlan {
879
635
  }
880
636
  /** Whether any planned step runs only under a test. */
881
637
  hasTests() {
882
- return this.entries.slice(0, this.stopAt).some((e) => this.onlyWhen(e) !== undefined);
638
+ return this.steps.slice(0, this.stopAt).some((s) => this.onlyWhen(s) !== undefined);
883
639
  }
884
640
  /** One test, answered — or the reason it could not be. Never throws. */
885
641
  answer(test, params, intent, resp) {
@@ -898,180 +654,72 @@ export class ScenarioReplayPlan {
898
654
  }
899
655
  }
900
656
  /**
901
- * What the tests decide at flat position `flatIndex` for `walk`. Enters the
902
- * position's frame when its own test needs the frame's parameters, and
903
- * nothing else changes: the caller moves its cursor.
657
+ * What the step's test decides at `position` for `walk`. Nothing changes:
658
+ * the caller moves its cursor.
904
659
  */
905
- gate(flatIndex, walk) {
906
- const entry = this.entries[flatIndex];
907
- if (!entry)
660
+ gate(position, walk) {
661
+ const step = this.steps[position];
662
+ if (!step)
908
663
  return { kind: "run" };
909
- const frame = entry.frame;
910
- const resp = walk.resp;
911
- // 1. The sub-tasks that begin here, outermost first — one whose chain
912
- // begins with a call begins where its callee's steps do, so nothing of
913
- // it runs before its test says it is needed. Each test is decided
914
- // once, in its caller's scope, before its frame is entered; a caller
915
- // that begins here too is entered first, for its parameters.
916
- const starting = this.framesStartingAt(flatIndex);
917
- for (let k = 0; k < starting.length; k += 1) {
918
- const sub = starting[k];
919
- if (!sub.when || walk.tested.has(sub.id))
920
- continue;
921
- try {
922
- for (const outer of starting.slice(0, k))
923
- this.enterFrame(outer, walk);
924
- }
925
- catch (err) {
926
- return {
927
- kind: "stop",
928
- stop: { kind: "step_failed", stepIndex: entry.stepIndex, flatIndex, stage: "tool_input_logic", error: errText(err) },
929
- info: { step: entry.step, entry, input: {}, outcome: "failed", stage: "tool_input_logic", error: errText(err), ms: 0 },
930
- };
931
- }
932
- const decided = this.decideCall(sub, entry, flatIndex, walk);
933
- if (decided)
934
- return decided;
935
- walk.tested.add(sub.id);
936
- }
937
- // 2. The step's own test, in its own frame's scope.
938
- const test = entry.step.when;
939
- if (!test || walk.passed.has(flatIndex))
664
+ const test = step.when;
665
+ if (!test || walk.passed.has(position))
940
666
  return { kind: "run" };
941
- try {
942
- // Its frame's parameters, which a test inside a sub-task reads.
943
- this.enterFramesAt(flatIndex, walk);
944
- }
945
- catch (err) {
946
- return {
947
- kind: "stop",
948
- stop: { kind: "step_failed", stepIndex: entry.stepIndex, flatIndex, stage: "tool_input_logic", error: errText(err) },
949
- info: { step: entry.step, entry, input: {}, outcome: "failed", stage: "tool_input_logic", error: errText(err), ms: 0 },
950
- };
951
- }
952
- const own = resp ? (resp.get(frame.id) ?? {}) : this.respFor(entry);
953
- const said = this.answer(test, this.paramsFor(entry), frame.intent, own);
667
+ const said = this.answer(test, this.params, this.intent, walk.resp ?? this.respParams);
954
668
  const text = testWords(test.text);
955
669
  if ("error" in said) {
956
670
  return {
957
671
  kind: "stop",
958
672
  stop: {
959
673
  kind: "step_failed",
960
- stepIndex: entry.stepIndex,
961
- flatIndex,
674
+ stepIndex: step.stepIndex,
675
+ position,
962
676
  stage: "when_logic",
963
677
  error: said.error,
964
678
  reason: "test_failed",
965
679
  },
966
- info: { step: entry.step, entry, input: {}, outcome: "failed", stage: "when_logic", error: said.error, ms: 0 },
680
+ info: { step, input: {}, outcome: "failed", stage: "when_logic", error: said.error, ms: 0 },
967
681
  };
968
682
  }
969
683
  if (said.yes) {
970
- walk.passed.add(flatIndex);
684
+ walk.passed.add(position);
971
685
  return { kind: "run" };
972
686
  }
973
687
  if (test.otherwise === "skip") {
974
688
  return {
975
689
  kind: "skip",
976
- until: flatIndex + 1,
977
- info: { step: entry.step, entry, input: {}, outcome: "not_taken", ms: 0 },
978
- entry,
979
- text,
980
- tools: [entry.step.toolName ?? ""],
981
- };
982
- }
983
- return { kind: "stop", stop: { kind: "other_branch", stepIndex: entry.stepIndex, flatIndex, whenText: text } };
984
- }
985
- /**
986
- * A sub-task's test, decided in its caller's scope at flat position
987
- * `flatIndex`, where the sub-task begins. Undefined when it says yes.
988
- */
989
- decideCall(sub, entry, flatIndex, walk) {
990
- const resp = walk.resp;
991
- const parent = sub.parent;
992
- const params = !parent || parent.depth === 0 ? this.params : (parent.parameters ?? {});
993
- const parentResp = parent
994
- ? resp
995
- ? (resp.get(parent.id) ?? (parent.depth === 0 ? this.respParams : parent.respParams))
996
- : parent.depth === 0
997
- ? this.respParams
998
- : parent.respParams
999
- : this.respParams;
1000
- // The call row, as a step of its caller's chain: its verdict is filed
1001
- // there, under the call's own index.
1002
- const call = this.callRowOf(sub, entry);
1003
- const owner = parent ?? sub;
1004
- const said = this.answer(sub.when, params, owner.intent ?? this.intent, parentResp);
1005
- const text = testWords(sub.when.text);
1006
- if ("error" in said) {
1007
- return {
1008
- kind: "stop",
1009
- stop: {
1010
- kind: "step_failed",
1011
- stepIndex: call.step.stepIndex,
1012
- flatIndex,
1013
- stage: "when_logic",
1014
- error: said.error,
1015
- reason: "test_failed",
1016
- },
1017
- info: { step: call.step, entry: call.entry, input: {}, outcome: "failed", stage: "when_logic", error: said.error, ms: 0 },
1018
- };
1019
- }
1020
- if (said.yes)
1021
- return undefined;
1022
- if (sub.when.otherwise === "skip") {
1023
- const until = (this.edges.end.get(sub.id) ?? flatIndex) + 1;
1024
- return {
1025
- kind: "skip",
1026
- until,
1027
- info: { step: call.step, entry: call.entry, input: {}, outcome: "not_taken", ms: 0 },
1028
- entry: call.entry,
690
+ info: { step, input: {}, outcome: "not_taken", ms: 0 },
691
+ step,
1029
692
  text,
1030
- // Every tool of the sub-task: a call the model makes to any of them is
1031
- // one to a step left out.
1032
- tools: [...new Set(this.entries.slice(flatIndex, until).map((e) => e.step.toolName ?? ""))],
1033
693
  };
1034
694
  }
1035
- return {
1036
- kind: "stop",
1037
- stop: { kind: "other_branch", stepIndex: call.step.stepIndex, flatIndex, whenText: text },
1038
- };
695
+ return { kind: "stop", stop: { kind: "other_branch", stepIndex: step.stepIndex, position, whenText: text } };
1039
696
  }
1040
697
  /** What a skip the gate decided leaves out, as the notes name it. */
1041
- leftOutOf(g, flatIndex) {
698
+ leftOutOf(g, position) {
1042
699
  return {
1043
- stepIndex: g.entry.stepIndex,
1044
- flatIndex,
1045
- until: g.until,
1046
- toolName: g.entry.step.toolName ?? g.entry.step.callIntent ?? "a sub-task",
1047
- tools: g.tools.filter((t) => t !== ""),
700
+ stepIndex: g.step.stepIndex,
701
+ position,
702
+ toolName: g.step.toolName,
1048
703
  text: g.text,
1049
704
  };
1050
705
  }
1051
706
  /**
1052
- * Tell a recorded-output source of the steps one skip left out — every step
1053
- * of a sub-task left out whole — so its queues pass over the outputs of the
1054
- * ones that were in the recording ({@link RecordedOutputFor}).
707
+ * Tell a recorded-output source of a step a test left out, so its queues
708
+ * pass over that step's output when it was in the recording
709
+ * ({@link RecordedOutputFor}).
1055
710
  */
1056
711
  passOver(l, recordedOutputFor) {
1057
- const tell = recordedOutputFor?.leftOut;
1058
- if (!tell)
1059
- return;
1060
- for (let i = l.flatIndex; i < l.until; i += 1) {
1061
- const entry = this.entries[i];
1062
- if (entry)
1063
- tell(entry);
1064
- }
712
+ const step = this.steps[l.position];
713
+ if (step)
714
+ recordedOutputFor?.leftOut?.(step);
1065
715
  }
1066
716
  /**
1067
717
  * Move the plan's own cursor past every step this request does not need,
1068
718
  * deciding each test as its step comes due (AC5). Stops at a step that will
1069
719
  * run, at the end, or at a test that stops the plan — which it returns, with
1070
720
  * the steps it left out. The observer is told each verdict as it is made.
1071
- * A sub-task whose results cannot go back after its last step was left out
1072
- * stops the plan too (`result_map`), its verdict filed on the call row.
1073
721
  */
1074
- settleTests(onStep, opts = {}) {
722
+ settleTests(onStep) {
1075
723
  const left = [];
1076
724
  while (this.stepIndex < this.stopAt) {
1077
725
  const g = this.gate(this.stepIndex, this.walk);
@@ -1086,10 +734,7 @@ export class ScenarioReplayPlan {
1086
734
  const l = this.leftOutOf(g, this.stepIndex);
1087
735
  left.push(l);
1088
736
  this.walk.left.push(l);
1089
- const failed = this.leaveAfterSkip(g.until - 1, this.walk, onStep, opts.rethrow === true);
1090
- if (failed)
1091
- return { stop: failed, left };
1092
- this.stepIndex = g.until;
737
+ this.stepIndex += 1;
1093
738
  }
1094
739
  return { left };
1095
740
  }
@@ -1102,20 +747,16 @@ export class ScenarioReplayPlan {
1102
747
  * Throws when `toolOutputLogic` fails; the caller aborts to a normal turn.
1103
748
  */
1104
749
  applyOutput(realOutput) {
1105
- const entry = this.entries[this.stepIndex];
1106
- if (!entry)
750
+ const step = this.steps[this.stepIndex];
751
+ if (!step)
1107
752
  return [];
1108
753
  let derivedKeys = [];
1109
- if (entry.step.toolOutputLogic) {
1110
- const derived = evalToolOutputLogic(entry.step.toolOutputLogic, realOutput, this.paramsFor(entry), entry.frame.intent, this.respFor(entry));
754
+ if (step.toolOutputLogic) {
755
+ const derived = evalToolOutputLogic(step.toolOutputLogic, realOutput, this.params, this.intent, this.respParams);
1111
756
  derivedKeys = Object.keys(derived);
1112
- // Mutated, not replaced: the frame's store is the object the plan and
1113
- // every later step of that frame already hold.
1114
- Object.assign(this.respFor(entry), derived);
757
+ // Mutated, not replaced: `accumulated` hands out this very object.
758
+ Object.assign(this.respParams, derived);
1115
759
  }
1116
- // Leaving a called frame hands its results back to the caller before the
1117
- // caller's next step reads them (R-CALL-29).
1118
- this.leaveFramesAt(this.stepIndex, this.walk);
1119
760
  this.stepIndex += 1;
1120
761
  return derivedKeys;
1121
762
  }
@@ -1145,12 +786,11 @@ export class ScenarioReplayPlan {
1145
786
  * The arguments each planned step will run with, where they are known before
1146
787
  * any step runs — for the steer directive (see {@link steeringDirective}).
1147
788
  *
1148
- * Known means: the parameters are settled, the step is the caller's own
1149
- * (a called segment's parameters are only built when it is entered), and
1150
- * its input logic neither reads nor passes on anything an earlier step
1151
- * accumulates — or it is the step at the cursor, whose accumulation is
1152
- * already final. Everything else is undefined, and the directive leaves
1153
- * those steps' arguments to the system.
789
+ * Known means: the parameters are settled, and the step's input logic
790
+ * neither reads nor passes on anything an earlier step accumulates — or it
791
+ * is the step at the cursor, whose accumulation is already final.
792
+ * Everything else is undefined, and the directive leaves those steps'
793
+ * arguments to the system.
1154
794
  *
1155
795
  * A look-ahead only: every body runs on copies, under a short limit, and a
1156
796
  * body that throws or runs long just leaves its step without arguments —
@@ -1175,9 +815,7 @@ export class ScenarioReplayPlan {
1175
815
  const left = deadline - Date.now();
1176
816
  if (left < MIN_PREVIEW_BODY_MS)
1177
817
  break;
1178
- const entry = planned[i];
1179
- if (entry.depth !== 0)
1180
- continue;
818
+ const step = planned[i];
1181
819
  const params = copyOf(this.params);
1182
820
  const accumulated = copyOf(this.respParams);
1183
821
  if (!params || !accumulated)
@@ -1190,7 +828,7 @@ export class ScenarioReplayPlan {
1190
828
  });
1191
829
  let input;
1192
830
  try {
1193
- input = this.inputOf(entry, params, entry.frame.intent, resp, { timeoutMs: Math.min(bodyMs, left) });
831
+ input = this.inputOf(step, params, this.intent, resp, { timeoutMs: Math.min(bodyMs, left) });
1194
832
  }
1195
833
  catch (err) {
1196
834
  // A body that throws (often on an earlier result it does not have
@@ -1207,7 +845,7 @@ export class ScenarioReplayPlan {
1207
845
  try {
1208
846
  if (readsEarlier || (resp !== accumulated && holds(input, resp)))
1209
847
  continue;
1210
- out[i] = renderArguments(entry.step.toolName ?? "", input);
848
+ out[i] = renderArguments(step.toolName, input);
1211
849
  }
1212
850
  catch {
1213
851
  // A value that throws when read (a getter the body returned): nothing
@@ -1239,8 +877,8 @@ export class ScenarioReplayPlan {
1239
877
  * {@link MIN_PREVIEW_BODY_MS} there is no preview at all.
1240
878
  */
1241
879
  steeringDirective(directToolName = "mcp__bir__run_scenario", opts) {
1242
- // A sub-task plan (segmented.md R-OUT-13) never tells the agent to stop
1243
- // calling tools: the steps below are a part of the task it is already doing.
880
+ // A sub-task plan (armed mid-task) never tells the agent to stop calling
881
+ // tools: the steps below are a part of the task it is already doing.
1244
882
  const subTask = opts?.subTask ?? this.subTask;
1245
883
  // The intent is a paragraph the analyser wrote; it is context, not an
1246
884
  // instruction, so it is clipped rather than dumped whole into the prompt.
@@ -1250,11 +888,11 @@ export class ScenarioReplayPlan {
1250
888
  // paragraph: its first line, clipped. A chain long enough that even the
1251
889
  // labels would run to kilobytes lists its tools alone — every tool always,
1252
890
  // because in steer mode the list *is* the instruction.
1253
- const planned = this.entries.slice(0, this.stopAt);
891
+ const planned = this.steps.slice(0, this.stopAt);
1254
892
  const args = this.previewArguments(planned, opts?.previewBudgetMs, opts?.previewBodyMs);
1255
893
  // A step that runs only under a test says so on its line (AC5): which of
1256
894
  // them this request needs is known only as the steps run.
1257
- const tests = planned.map((e) => this.onlyWhen(e));
895
+ const tests = planned.map((s) => this.onlyWhen(s));
1258
896
  const anyTest = tests.some((t) => t !== undefined);
1259
897
  const onlyWhen = (i) => {
1260
898
  const t = tests[i];
@@ -1262,9 +900,9 @@ export class ScenarioReplayPlan {
1262
900
  return "";
1263
901
  return ` (only when ${t.text ?? "this request needs it"})`;
1264
902
  };
1265
- const listing = (withWhy, argsBefore) => planned.map((e, i) => {
1266
- const why = withWhy ? clipReasoning(e.step.reasoning) : "";
1267
- const head = ` ${i + 1}. ${e.step.toolName ?? ""}${onlyWhen(i)}${why ? ` — ${why}` : ""}`;
903
+ const listing = (withWhy, argsBefore) => planned.map((s, i) => {
904
+ const why = withWhy ? clipReasoning(s.reasoning) : "";
905
+ const head = ` ${i + 1}. ${s.toolName}${onlyWhen(i)}${why ? ` — ${why}` : ""}`;
1268
906
  const shown = i < argsBefore ? args[i] : undefined;
1269
907
  return shown ? `${head}\n ${shown}` : head;
1270
908
  });
@@ -1288,21 +926,10 @@ export class ScenarioReplayPlan {
1288
926
  break;
1289
927
  }
1290
928
  const anyArgs = args.slice(0, argsShown).some((a) => a !== undefined);
1291
- // A plan whose chain holds a decision (the service's segmentsbreakdown.md
1292
- // SB-18) never shows its intent, even when it stops earlier for another
1293
- // reason: the intent names the recorded task, the half the recording took
1294
- // included, and which way applies is the agent's choice.
1295
- const atDecision = this.stopStep()?.stop?.kind === "decision";
1296
- const shownIntent = this.decisionIndex() >= 0 ? "" : intent;
1297
929
  // A parked step (fallbk.md D3): the model is told up front that the plan is
1298
930
  // partial, so the hand-over that follows is expected rather than a surprise.
1299
931
  const tail = this.stopsEarly()
1300
- ? [
1301
- "",
1302
- atDecision
1303
- ? "The system will then hand the task back to you at a decision."
1304
- : "The system will then hand the rest of the task back to you.",
1305
- ]
932
+ ? ["", "The system will then hand the rest of the task back to you."]
1306
933
  : [];
1307
934
  if (this.mode === "direct") {
1308
935
  // The sequence is listed even though the model does not call it itself:
@@ -1311,7 +938,7 @@ export class ScenarioReplayPlan {
1311
938
  // place an operator watching the session can see what a replay is doing.
1312
939
  return [
1313
940
  "[BaseInstRunner calculated replay] A known-good tool sequence was recorded",
1314
- `for this request${shownIntent ? ` (intent: ${shownIntent})` : ""}.`,
941
+ `for this request${intent ? ` (intent: ${intent})` : ""}.`,
1315
942
  "",
1316
943
  anyTest
1317
944
  ? 'It runs these tools, in order, and returns their results. A step marked "only when" runs only when this request needs it, and the results say which ones did not:'
@@ -1333,7 +960,7 @@ export class ScenarioReplayPlan {
1333
960
  "recorded for this request. Call exactly these tools, in order, one at a time:",
1334
961
  "",
1335
962
  ...lines,
1336
- ...(shownIntent ? ["", `Context: ${shownIntent}`] : []),
963
+ ...(intent ? ["", `Context: ${intent}`] : []),
1337
964
  ...(anyTest
1338
965
  ? [
1339
966
  "",
@@ -1381,26 +1008,13 @@ export class ScenarioReplayPlan {
1381
1008
  * waiting is a side effect nobody is told about.
1382
1009
  */
1383
1010
  async composeBundle(maxChars, execute, recordedOutputFor, onStep, opts = {}) {
1384
- // One copy per frame: composing must not corrupt the live plan's
1385
- // accumulation if the caller decides to keep steering afterwards.
1386
- const resp = new Map();
1387
- if (this.entries[0])
1388
- resp.set(this.rootFrame().id, { ...this.respParams });
1389
- // A sub-task the plan's cursor is inside goes on from what it has made so
1390
- // far: its copy, and the frames and tests the cursor already settled. A
1391
- // bundle that started such a frame afresh read `{}` for it — its steps'
1392
- // outputs were lost, and a test there read nothing (AC5 review).
1393
- for (const id of this.walk.entered) {
1394
- const frame = this.frameById(id);
1395
- if (frame)
1396
- resp.set(id, { ...frame.respParams });
1397
- }
1398
- // Its own walk, on those copies: what it enters and decides is not the
1399
- // live plan's either (AC5).
1011
+ // A copy: composing must not corrupt the live plan's accumulation if the
1012
+ // caller decides to keep steering afterwards.
1013
+ const resp = { ...this.respParams };
1014
+ // Its own walk, on that copy, from the tests the cursor already settled:
1015
+ // what it decides is not the live plan's either (AC5).
1400
1016
  const walk = {
1401
1017
  resp,
1402
- entered: new Set(this.walk.entered),
1403
- tested: new Set(this.walk.tested),
1404
1018
  passed: new Set(this.walk.passed),
1405
1019
  left: [],
1406
1020
  };
@@ -1412,13 +1026,12 @@ export class ScenarioReplayPlan {
1412
1026
  let notTaken = 0;
1413
1027
  let stopped;
1414
1028
  const stopOnFailure = opts.stopOnFailure === true;
1415
- let flatIndex = this.stepIndex;
1416
- for (; flatIndex < this.stopAt; flatIndex += 1) {
1417
- const entry = this.entries[flatIndex];
1418
- const step = entry.step;
1029
+ let position = this.stepIndex;
1030
+ for (; position < this.stopAt; position += 1) {
1031
+ const step = this.steps[position];
1419
1032
  // A step this request does not need is left out, and said so in the
1420
1033
  // bundle; a test that says "the other half" stops the bundle here (AC5).
1421
- const g = this.gate(flatIndex, walk);
1034
+ const g = this.gate(position, walk);
1422
1035
  if (g.kind === "stop") {
1423
1036
  if (g.info)
1424
1037
  onStep?.(g.info);
@@ -1428,26 +1041,18 @@ export class ScenarioReplayPlan {
1428
1041
  if (g.kind === "skip") {
1429
1042
  onStep?.(g.info);
1430
1043
  notTaken += 1;
1431
- const l = this.leftOutOf(g, flatIndex);
1044
+ const l = this.leftOutOf(g, position);
1432
1045
  walk.left.push(l);
1433
1046
  this.passOver(l, recordedOutputFor);
1434
1047
  entries.push({
1435
- toolName: g.entry.step.toolName ?? g.entry.step.callIntent ?? "a sub-task",
1048
+ toolName: step.toolName,
1436
1049
  input: "",
1437
1050
  response: "",
1438
1051
  notNeeded: { text: g.text },
1439
1052
  });
1440
- // The sub-tasks that end with the step left out still hand back what
1441
- // they made; one that cannot stops the bundle (`result_map`).
1442
- const failed = this.leaveAfterSkip(g.until - 1, walk, onStep, !stopOnFailure);
1443
- if (failed) {
1444
- stopped = failed;
1445
- break;
1446
- }
1447
- flatIndex = g.until - 1;
1448
1053
  continue;
1449
1054
  }
1450
- const before = this.stopBefore(entry, flatIndex, opts.signal, opts.deadline);
1055
+ const before = this.stopBefore(step, position, opts.signal, opts.deadline);
1451
1056
  if (before) {
1452
1057
  stopped = before;
1453
1058
  break;
@@ -1455,11 +1060,7 @@ export class ScenarioReplayPlan {
1455
1060
  const startedAt = Date.now();
1456
1061
  let computed;
1457
1062
  try {
1458
- computed = reportingStage(onStep, entry, "tool_input_logic", {}, startedAt, () => {
1459
- this.enterFramesAt(flatIndex, walk);
1460
- const respParams = resp.get(entry.frame.id) ?? {};
1461
- return this.inputOf(entry, this.paramsFor(entry), entry.frame.intent, respParams);
1462
- });
1063
+ computed = reportingStage(onStep, step, "tool_input_logic", {}, startedAt, () => this.inputOf(step, this.params, this.intent, resp));
1463
1064
  }
1464
1065
  catch (err) {
1465
1066
  if (!stopOnFailure)
@@ -1467,14 +1068,14 @@ export class ScenarioReplayPlan {
1467
1068
  stopped = {
1468
1069
  kind: "step_failed",
1469
1070
  stepIndex: step.stepIndex,
1470
- flatIndex,
1071
+ position,
1471
1072
  stage: "tool_input_logic",
1472
1073
  error: errText(err),
1473
1074
  ...(err instanceof RecordedValueError ? { reason: "not_run" } : {}),
1474
1075
  };
1475
1076
  break;
1476
1077
  }
1477
- const call = await this.callStep(entry, flatIndex, computed, startedAt, execute, recordedOutputFor, onStep, opts, opts.deadline);
1078
+ const call = await this.callStep(step, position, computed, startedAt, execute, recordedOutputFor, onStep, opts, opts.deadline);
1478
1079
  if (call.stopped) {
1479
1080
  skipped += 1;
1480
1081
  stopped = call.stopped;
@@ -1493,7 +1094,6 @@ export class ScenarioReplayPlan {
1493
1094
  errored += 1;
1494
1095
  onStep?.({
1495
1096
  step,
1496
- entry,
1497
1097
  input: computed,
1498
1098
  output: response,
1499
1099
  outcome: "failed",
@@ -1502,11 +1102,11 @@ export class ScenarioReplayPlan {
1502
1102
  toolMissing: isMissingToolError(toolError) || undefined,
1503
1103
  ms: Date.now() - startedAt,
1504
1104
  });
1505
- entries.push({ toolName: step.toolName ?? "", input: bundleInput(computed), response, recorded });
1105
+ entries.push({ toolName: step.toolName, input: bundleInput(computed), response, recorded });
1506
1106
  stopped = {
1507
1107
  kind: "step_failed",
1508
1108
  stepIndex: step.stepIndex,
1509
- flatIndex,
1109
+ position,
1510
1110
  stage: "tool_call",
1511
1111
  error: toolError,
1512
1112
  };
@@ -1517,24 +1117,23 @@ export class ScenarioReplayPlan {
1517
1117
  // too: `toolOutputLogic` was authored against exactly this shape.
1518
1118
  let derivedKeys = [];
1519
1119
  try {
1520
- reportingStage(onStep, entry, "tool_output_logic", computed, startedAt, () => {
1120
+ reportingStage(onStep, step, "tool_output_logic", computed, startedAt, () => {
1521
1121
  if (step.toolOutputLogic) {
1522
- const derived = evalToolOutputLogic(step.toolOutputLogic, response, this.paramsFor(entry), entry.frame.intent, resp.get(entry.frame.id) ?? {});
1122
+ const derived = evalToolOutputLogic(step.toolOutputLogic, response, this.params, this.intent, resp);
1523
1123
  derivedKeys = Object.keys(derived);
1524
- Object.assign(resp.get(entry.frame.id) ?? {}, derived);
1124
+ Object.assign(resp, derived);
1525
1125
  }
1526
- this.leaveFramesAt(flatIndex, walk);
1527
1126
  }, response);
1528
1127
  }
1529
1128
  catch (err) {
1530
1129
  if (!stopOnFailure)
1531
1130
  throw err;
1532
1131
  // The tool ran; its output is real and belongs in front of the model.
1533
- entries.push({ toolName: step.toolName ?? "", input: bundleInput(computed), response, recorded });
1132
+ entries.push({ toolName: step.toolName, input: bundleInput(computed), response, recorded });
1534
1133
  stopped = {
1535
1134
  kind: "step_failed",
1536
1135
  stepIndex: step.stepIndex,
1537
- flatIndex,
1136
+ position,
1538
1137
  stage: "tool_output_logic",
1539
1138
  error: errText(err),
1540
1139
  };
@@ -1549,7 +1148,6 @@ export class ScenarioReplayPlan {
1549
1148
  errored += 1;
1550
1149
  onStep?.({
1551
1150
  step,
1552
- entry,
1553
1151
  input: computed,
1554
1152
  output: response,
1555
1153
  outcome: error ? "failed" : "executed",
@@ -1560,14 +1158,13 @@ export class ScenarioReplayPlan {
1560
1158
  ms: Date.now() - startedAt,
1561
1159
  });
1562
1160
  }
1563
- entries.push({ toolName: step.toolName ?? "", input: bundleInput(computed), response, recorded });
1161
+ entries.push({ toolName: step.toolName, input: bundleInput(computed), response, recorded });
1564
1162
  }
1565
1163
  return {
1566
1164
  text: assembleBundle(entries, maxChars, {
1567
1165
  // A bundle that stops short of the task — in front of a parked step, or
1568
- // at a step that broke — must never say "the work is done". One that
1569
- // left out a sub-task reaching past the parked step did not stop short.
1570
- handover: (opts.handover === true && flatIndex <= this.stopAt) || stopped !== undefined,
1166
+ // at a step that broke — must never say "the work is done".
1167
+ handover: opts.handover === true || stopped !== undefined,
1571
1168
  subTask: this.subTask,
1572
1169
  reportTool: opts.reportTool,
1573
1170
  }),
@@ -1578,7 +1175,7 @@ export class ScenarioReplayPlan {
1578
1175
  notTaken,
1579
1176
  left: walk.left,
1580
1177
  stopped,
1581
- endedAt: flatIndex,
1178
+ endedAt: position,
1582
1179
  };
1583
1180
  }
1584
1181
  /**
@@ -1614,10 +1211,7 @@ export class ScenarioReplayPlan {
1614
1211
  while (!this.isDone()) {
1615
1212
  // The steps this request does not need are left out, and said so in the
1616
1213
  // bundle; a test that says "the other half" stops the run here (AC5).
1617
- // A sub-task whose results cannot go back after its last step was left
1618
- // out stops the run (`result_map`) — or, without `stopOnFailure`, its
1619
- // throw propagates, as any logic throw does.
1620
- const settled = this.settleTests(onStep, { rethrow: !stopOnFailure });
1214
+ const settled = this.settleTests(onStep);
1621
1215
  left.push(...settled.left);
1622
1216
  for (const l of settled.left) {
1623
1217
  this.passOver(l, recordedOutputFor);
@@ -1630,12 +1224,11 @@ export class ScenarioReplayPlan {
1630
1224
  }
1631
1225
  if (this.isDone())
1632
1226
  break;
1633
- const entry = this.entries[this.stepIndex];
1634
- const step = entry.step;
1635
- // Where this step sits in the flat list — what a stop reports. Taken now,
1227
+ const step = this.steps[this.stepIndex];
1228
+ // Where this step sits in the chain — what a stop reports. Taken now,
1636
1229
  // because the cursor has moved on by the time some stops are written.
1637
- const flatIndex = this.stepIndex;
1638
- const before = this.stopBefore(entry, flatIndex, opts.signal, deadline);
1230
+ const position = this.stepIndex;
1231
+ const before = this.stopBefore(step, position, opts.signal, deadline);
1639
1232
  if (before) {
1640
1233
  stopped = before;
1641
1234
  if (before.reason === "budget")
@@ -1647,7 +1240,7 @@ export class ScenarioReplayPlan {
1647
1240
  const startedAt = Date.now();
1648
1241
  let computed;
1649
1242
  try {
1650
- computed = reportingStage(onStep, entry, "tool_input_logic", {}, startedAt, () => this.toolInputForCurrentStep());
1243
+ computed = reportingStage(onStep, step, "tool_input_logic", {}, startedAt, () => this.toolInputForCurrentStep());
1651
1244
  }
1652
1245
  catch (err) {
1653
1246
  if (!stopOnFailure)
@@ -1655,14 +1248,14 @@ export class ScenarioReplayPlan {
1655
1248
  stopped = {
1656
1249
  kind: "step_failed",
1657
1250
  stepIndex: step.stepIndex,
1658
- flatIndex,
1251
+ position,
1659
1252
  stage: "tool_input_logic",
1660
1253
  error: errText(err),
1661
1254
  ...(err instanceof RecordedValueError ? { reason: "not_run" } : {}),
1662
1255
  };
1663
1256
  break;
1664
1257
  }
1665
- const call = await this.callStep(entry, flatIndex, computed, startedAt, execute, recordedOutputFor, onStep, opts, deadline);
1258
+ const call = await this.callStep(step, position, computed, startedAt, execute, recordedOutputFor, onStep, opts, deadline);
1666
1259
  if (call.stopped) {
1667
1260
  skipped += 1;
1668
1261
  stopped = call.stopped;
@@ -1681,7 +1274,6 @@ export class ScenarioReplayPlan {
1681
1274
  errored += 1;
1682
1275
  onStep?.({
1683
1276
  step,
1684
- entry,
1685
1277
  input: computed,
1686
1278
  output: response,
1687
1279
  outcome: "failed",
@@ -1690,12 +1282,12 @@ export class ScenarioReplayPlan {
1690
1282
  toolMissing: isMissingToolError(toolError) || undefined,
1691
1283
  ms: Date.now() - startedAt,
1692
1284
  });
1693
- entries.push({ toolName: step.toolName ?? "", input: bundleInput(computed), response, recorded });
1285
+ entries.push({ toolName: step.toolName, input: bundleInput(computed), response, recorded });
1694
1286
  this.stepIndex += 1;
1695
1287
  stopped = {
1696
1288
  kind: "step_failed",
1697
1289
  stepIndex: step.stepIndex,
1698
- flatIndex,
1290
+ position,
1699
1291
  stage: "tool_call",
1700
1292
  error: toolError,
1701
1293
  };
@@ -1704,17 +1296,17 @@ export class ScenarioReplayPlan {
1704
1296
  }
1705
1297
  let derivedKeys;
1706
1298
  try {
1707
- derivedKeys = reportingStage(onStep, entry, "tool_output_logic", computed, startedAt, () => this.applyOutput(response), response);
1299
+ derivedKeys = reportingStage(onStep, step, "tool_output_logic", computed, startedAt, () => this.applyOutput(response), response);
1708
1300
  }
1709
1301
  catch (err) {
1710
1302
  if (!stopOnFailure)
1711
1303
  throw err;
1712
1304
  // The tool ran; its output is real and belongs in front of the model.
1713
- entries.push({ toolName: step.toolName ?? "", input: bundleInput(computed), response, recorded });
1305
+ entries.push({ toolName: step.toolName, input: bundleInput(computed), response, recorded });
1714
1306
  stopped = {
1715
1307
  kind: "step_failed",
1716
1308
  stepIndex: step.stepIndex,
1717
- flatIndex,
1309
+ position,
1718
1310
  stage: "tool_output_logic",
1719
1311
  error: errText(err),
1720
1312
  };
@@ -1729,7 +1321,6 @@ export class ScenarioReplayPlan {
1729
1321
  errored += 1;
1730
1322
  onStep?.({
1731
1323
  step,
1732
- entry,
1733
1324
  input: computed,
1734
1325
  output: response,
1735
1326
  outcome: error ? "failed" : "executed",
@@ -1740,17 +1331,16 @@ export class ScenarioReplayPlan {
1740
1331
  ms: Date.now() - startedAt,
1741
1332
  });
1742
1333
  }
1743
- entries.push({ toolName: step.toolName ?? "", input: bundleInput(computed), response, recorded });
1334
+ entries.push({ toolName: step.toolName, input: bundleInput(computed), response, recorded });
1744
1335
  }
1745
- // Every planned step ran and the plan ends in front of a parked one — not
1746
- // when a test left out a sub-task that reaches past it (AC5).
1336
+ // Every planned step ran and the plan ends in front of a parked one.
1747
1337
  if (!stopped && this.atStop()) {
1748
1338
  // `known_bad_step` here means "in front of the plan's end": the
1749
1339
  // controller reads the served reason and names the kind (D8).
1750
1340
  stopped = {
1751
1341
  kind: "known_bad_step",
1752
- stepIndex: this.entries[this.stopAt].stepIndex,
1753
- flatIndex: this.stopAt,
1342
+ stepIndex: this.steps[this.stopAt].stepIndex,
1343
+ position: this.stopAt,
1754
1344
  };
1755
1345
  }
1756
1346
  return {
@@ -1775,12 +1365,12 @@ export class ScenarioReplayPlan {
1775
1365
  * A reason not to *start* the next step: the caller is gone, or the time is
1776
1366
  * up. Undefined means go ahead.
1777
1367
  */
1778
- stopBefore(entry, flatIndex, signal, deadline) {
1368
+ stopBefore(step, position, signal, deadline) {
1779
1369
  if (signal?.aborted) {
1780
- return { kind: "step_failed", stepIndex: entry.step.stepIndex, flatIndex, reason: "cancelled" };
1370
+ return { kind: "step_failed", stepIndex: step.stepIndex, position, reason: "cancelled" };
1781
1371
  }
1782
1372
  if (deadline !== undefined && Date.now() >= deadline) {
1783
- return { kind: "step_failed", stepIndex: entry.step.stepIndex, flatIndex, reason: "budget" };
1373
+ return { kind: "step_failed", stepIndex: step.stepIndex, position, reason: "budget" };
1784
1374
  }
1785
1375
  return undefined;
1786
1376
  }
@@ -1789,10 +1379,9 @@ export class ScenarioReplayPlan {
1789
1379
  * that may stand in for it (only under `recordedFallback`, only for a step
1790
1380
  * that could not be sent), or with the stop a missing result means.
1791
1381
  */
1792
- async callStep(entry, flatIndex, computed, startedAt, execute, recordedOutputFor, onStep, opts,
1382
+ async callStep(step, position, computed, startedAt, execute, recordedOutputFor, onStep, opts,
1793
1383
  /** The driver's deadline: a recorded-output lookup is not waited on past it. */
1794
1384
  deadline) {
1795
- const step = entry.step;
1796
1385
  try {
1797
1386
  return { response: await execute(step, computed), recorded: false };
1798
1387
  }
@@ -1802,11 +1391,10 @@ export class ScenarioReplayPlan {
1802
1391
  const standIn = opts.recordedFallback === true && reason === "not_run"
1803
1392
  ? ((typeof step.recordedOutput === "string" && step.recordedOutput.length > 0
1804
1393
  ? step.recordedOutput
1805
- : undefined) ?? (await this.recordedWithin(recordedOutputFor, entry, deadline)))
1394
+ : undefined) ?? (await this.recordedWithin(recordedOutputFor, step, deadline)))
1806
1395
  : undefined;
1807
1396
  onStep?.({
1808
1397
  step,
1809
- entry,
1810
1398
  input: computed,
1811
1399
  // The stand-in is what the step's output logic runs on from here.
1812
1400
  output: standIn,
@@ -1821,7 +1409,7 @@ export class ScenarioReplayPlan {
1821
1409
  stopped: {
1822
1410
  kind: "step_failed",
1823
1411
  stepIndex: step.stepIndex,
1824
- flatIndex,
1412
+ position,
1825
1413
  stage: "tool_call",
1826
1414
  error,
1827
1415
  reason,
@@ -1839,14 +1427,14 @@ export class ScenarioReplayPlan {
1839
1427
  * breaker, and a lookup that has not answered in time is no recorded output.
1840
1428
  * A sliver of time left is not worth a request, nor a verdict on the service.
1841
1429
  */
1842
- async recordedWithin(recordedOutputFor, entry, deadline) {
1430
+ async recordedWithin(recordedOutputFor, step, deadline) {
1843
1431
  if (!recordedOutputFor)
1844
1432
  return undefined;
1845
1433
  if (deadline !== undefined && deadline - Date.now() < MIN_RECORDED_LOOKUP_MS)
1846
1434
  return undefined;
1847
1435
  let pending;
1848
1436
  try {
1849
- pending = recordedOutputFor(entry);
1437
+ pending = recordedOutputFor(step);
1850
1438
  }
1851
1439
  catch {
1852
1440
  return undefined;