@basein/runner 0.3.15 → 0.3.17

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 (38) hide show
  1. package/README.md +7 -0
  2. package/dist/bin/bir-scenario.js +8 -5
  3. package/dist/bin/bir.js +2 -1
  4. package/dist/bin/investigate.d.ts +71 -0
  5. package/dist/bin/investigate.js +145 -13
  6. package/dist/bin/scenario-edit.d.ts +45 -1
  7. package/dist/bin/scenario-edit.js +134 -14
  8. package/dist/control/server.d.ts +16 -0
  9. package/dist/control/server.js +156 -7
  10. package/dist/control/transcript.d.ts +18 -0
  11. package/dist/control/transcript.js +55 -0
  12. package/dist/record/recorder.d.ts +47 -4
  13. package/dist/record/remote-recorder.d.ts +5 -4
  14. package/dist/record/remote-recorder.js +23 -1
  15. package/dist/record/switchable-recorder.d.ts +1 -5
  16. package/dist/replay/bundle.d.ts +10 -0
  17. package/dist/replay/bundle.js +23 -2
  18. package/dist/replay/controller.d.ts +133 -0
  19. package/dist/replay/controller.js +496 -59
  20. package/dist/replay/flatten.d.ts +39 -3
  21. package/dist/replay/flatten.js +42 -2
  22. package/dist/replay/handover.d.ts +39 -1
  23. package/dist/replay/handover.js +81 -2
  24. package/dist/replay/logic.d.ts +8 -0
  25. package/dist/replay/logic.js +19 -0
  26. package/dist/replay/plan.d.ts +165 -4
  27. package/dist/replay/plan.js +488 -25
  28. package/dist/replay/source-run.d.ts +14 -1
  29. package/dist/replay/source-run.js +25 -2
  30. package/dist/replay/targets.js +8 -1
  31. package/dist/replay/types.d.ts +52 -3
  32. package/dist/replay/types.js +14 -0
  33. package/dist/util/service-headers.d.ts +7 -1
  34. package/dist/util/service-headers.js +7 -1
  35. package/docs/BaseInstRunner.md +3 -1
  36. package/docs/calculatedReplay.md +117 -6
  37. package/docs/calculatedReplayGuide.md +41 -3
  38. package/package.json +1 -1
@@ -20,9 +20,9 @@
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, LogicTimeoutError, } from "./logic.js";
24
- import { flatEntriesOf } from "./flatten.js";
25
- import { assembleBundle, bundleInput, MAX_REPLAY_REASON } from "./bundle.js";
23
+ import { evalParamMapLogic, evalParamsLogic, evalResponseParamsLogic, evalResultMapLogic, evalToolInputLogic, evalToolOutputLogic, evalWhenLogic, LogicTimeoutError, } from "./logic.js";
24
+ import { flatEntriesOf, frameEdges } from "./flatten.js";
25
+ import { assembleBundle, bundleInput, MAX_REPLAY_REASON, testWords } from "./bundle.js";
26
26
  import { redact, redactText } from "../record/redact.js";
27
27
  import { sinkOfPlan, unsafeParameter } from "./safety.js";
28
28
  import { errText } from "../util/log.js";
@@ -259,6 +259,18 @@ export class ScenarioReplayPlan {
259
259
  stopAt;
260
260
  /** This plan is a sub-task inside the agent's own task (segmented.md R-OUT-13). */
261
261
  subTask;
262
+ /**
263
+ * Where each called frame begins and ends, its callees' entries included:
264
+ * where it is entered, its test decided and it is left, and where a
265
+ * sub-task its test leaves out ends (AC5).
266
+ */
267
+ edges;
268
+ /** Each entry's flat position. */
269
+ positions = new Map();
270
+ /** Every frame of the flat list, by id. */
271
+ frames = new Map();
272
+ /** The plan's own walk: what its cursor has entered and decided (AC5). */
273
+ walk = { entered: new Set(), tested: new Set(), passed: new Set(), left: [] };
262
274
  constructor(opts) {
263
275
  this.scenario = opts.scenario;
264
276
  this.scenarioId = opts.scenario.id;
@@ -279,6 +291,19 @@ export class ScenarioReplayPlan {
279
291
  typeof stop === "number" && stop >= 0 && stop < this.entries.length
280
292
  ? stop
281
293
  : this.entries.length;
294
+ this.edges = frameEdges(this.entries);
295
+ this.entries.forEach((entry, index) => {
296
+ this.positions.set(entry, index);
297
+ for (let f = entry.frame; f; f = f.parent)
298
+ this.frames.set(f.id, f);
299
+ });
300
+ }
301
+ frameById(id) {
302
+ return this.frames.get(id);
303
+ }
304
+ /** The called frames that begin at flat position `flatIndex`, outermost first. */
305
+ framesStartingAt(flatIndex) {
306
+ return this.edges.startsAt.get(flatIndex) ?? [];
282
307
  }
283
308
  /** The caller's own frame — the one at depth 0. */
284
309
  rootFrame() {
@@ -304,10 +329,22 @@ export class ScenarioReplayPlan {
304
329
  * out. A mapping that omits a setting therefore cannot pass the service's
305
330
  * check and then run `undefined` here.
306
331
  */
307
- enterFrame(entry, resp) {
308
- if (!entry.first || entry.depth === 0)
332
+ enterFramesAt(flatIndex, walk) {
333
+ // Outermost first: a sub-task whose chain begins with a call begins where
334
+ // its callee does, and the callee's mapping reads the sub-task's
335
+ // parameters — built here, before it (AC5 review).
336
+ for (const frame of this.framesStartingAt(flatIndex))
337
+ this.enterFrame(frame, walk);
338
+ }
339
+ enterFrame(frame, walk) {
340
+ if (frame.depth === 0)
309
341
  return;
310
- const frame = entry.frame;
342
+ // Once per walk: a step's test may need the frame's parameters before the
343
+ // step's input is computed (AC5), and a steer cursor computes one step's
344
+ // input again when a pin was declined.
345
+ if (walk.entered.has(frame.id))
346
+ return;
347
+ const resp = walk.resp;
311
348
  const parent = frame.parent;
312
349
  const parentParams = parent
313
350
  ? parent.depth === 0
@@ -329,15 +366,91 @@ export class ScenarioReplayPlan {
329
366
  resp.set(frame.id, {});
330
367
  else
331
368
  frame.respParams = {};
369
+ walk.entered.add(frame.id);
370
+ }
371
+ /**
372
+ * Leave every called frame that ends at `flatIndex` and that `walk`
373
+ * entered, innermost first: a sub-task whose chain ends with a call is left
374
+ * after its callee has handed it its results.
375
+ */
376
+ leaveFramesAt(flatIndex, walk) {
377
+ for (const frame of this.edges.endsAt.get(flatIndex) ?? []) {
378
+ if (walk.entered.has(frame.id))
379
+ this.leaveFrame(frame, walk.resp);
380
+ }
381
+ }
382
+ /**
383
+ * After a skip that ends at flat position `last`: leave every entered
384
+ * sub-task that ends there — its results still go back to its caller. A
385
+ * result mapping that throws ends the walk: its verdict is filed on the
386
+ * call row, in the caller's chain, and the stop says the step at `last` was
387
+ * left out, not run (`result_map`). Rethrown instead when `rethrow`.
388
+ */
389
+ leaveAfterSkip(last, walk, onStep, rethrow) {
390
+ for (const frame of this.edges.endsAt.get(last) ?? []) {
391
+ if (!walk.entered.has(frame.id))
392
+ continue;
393
+ try {
394
+ this.leaveFrame(frame, walk.resp);
395
+ }
396
+ catch (err) {
397
+ if (rethrow)
398
+ throw err;
399
+ const error = errText(err);
400
+ const { step, entry } = this.callRowOf(frame, this.entries[last]);
401
+ onStep?.({ step, entry, input: {}, outcome: "failed", stage: "tool_output_logic", error, ms: 0 });
402
+ return {
403
+ kind: "step_failed",
404
+ stepIndex: step.stepIndex,
405
+ // At the step left out, which did not run: what ran before it did
406
+ // its work, and what comes after it is left to do.
407
+ flatIndex: last,
408
+ stage: "tool_output_logic",
409
+ error,
410
+ reason: "result_map",
411
+ subTask: frame.callIntent ?? null,
412
+ };
413
+ }
414
+ }
415
+ return undefined;
416
+ }
417
+ /**
418
+ * The call row that opened `frame`, as a step of its caller's chain: where
419
+ * a sub-task's test and its result mapping are filed (R-CALL-13).
420
+ */
421
+ callRowOf(frame, at) {
422
+ const step = frame.callStep ?? {
423
+ stepIndex: frame.callerStepIndex ?? at.stepIndex,
424
+ kind: "segment",
425
+ toolName: null,
426
+ toolInputLogic: null,
427
+ toolOutputLogic: null,
428
+ reasoning: null,
429
+ };
430
+ const owner = frame.parent ?? frame;
431
+ return {
432
+ step,
433
+ entry: {
434
+ scenarioId: owner.scenarioId,
435
+ runId: owner.runId,
436
+ stepFrom: owner.stepFrom,
437
+ stepTo: owner.stepTo,
438
+ stepIndex: step.stepIndex,
439
+ step,
440
+ frame: owner,
441
+ depth: owner.depth,
442
+ first: false,
443
+ last: false,
444
+ },
445
+ };
332
446
  }
333
447
  /**
334
448
  * Leaving a called frame: hand what the segment emitted back to its caller
335
449
  * (R-CALL-29). `out` is the frame's own accumulated `respParams`.
336
450
  */
337
- leaveFrame(entry, resp) {
338
- if (!entry.last || entry.depth === 0)
451
+ leaveFrame(frame, resp) {
452
+ if (frame.depth === 0)
339
453
  return;
340
- const frame = entry.frame;
341
454
  const parent = frame.parent;
342
455
  if (!frame.resultMapLogic || !parent)
343
456
  return;
@@ -448,10 +561,6 @@ export class ScenarioReplayPlan {
448
561
  stopEntry() {
449
562
  return this.stopsEarly() ? this.entries[this.stopAt] : undefined;
450
563
  }
451
- /** Tool names from position `from` to the end of the flat chain. */
452
- toolsFrom(from) {
453
- return this.entries.slice(Math.max(0, from)).map((e) => e.step.toolName ?? "");
454
- }
455
564
  /** Every entry in order — for the report, and for the owning scenario ids. */
456
565
  allEntries() {
457
566
  return this.entries;
@@ -514,10 +623,265 @@ export class ScenarioReplayPlan {
514
623
  if (!entry)
515
624
  throw new Error("no current step to compute input for");
516
625
  // Entering a called frame happens here, on its first step, because this is
517
- // the first moment the caller's own accumulation is final (R-CALL-29).
518
- this.enterFrame(entry);
626
+ // the first moment the caller's own accumulation is final (R-CALL-29) —
627
+ // unless the step's test needed the frame first (AC5).
628
+ this.enterFramesAt(this.stepIndex, this.walk);
519
629
  return evalToolInputLogic(entry.step.toolInputLogic ?? "return {};", this.paramsFor(entry), entry.frame.intent, this.respFor(entry));
520
630
  }
631
+ // ── tests (the service's autocorrect.md AC5) ──────────────────────────────
632
+ //
633
+ // A step may run only when its test says so: a JavaScript body over the
634
+ // parameters, the intent and what the earlier steps accumulated, decided
635
+ // when the step comes due — never sooner, because the accumulation it reads
636
+ // is final only then. A call row's test decides its whole sub-task, in the
637
+ // caller's scope, before the sub-task's frame is entered. `skip` leaves the
638
+ // step out; `hand_over` stops the plan in front of it (the other half of an
639
+ // "if … otherwise", which the agent does); a test that throws or answers
640
+ // anything but true or false stops it too. Nothing is ever guessed.
641
+ /** Whether the plan's cursor rests in front of the step it is parked before. */
642
+ atStop() {
643
+ return this.stopsEarly() && this.stepIndex === this.stopAt;
644
+ }
645
+ /**
646
+ * The test an entry runs under, in words, when it has one: its own, or that
647
+ * of a sub-task beginning at it — one whose chain begins with a call begins
648
+ * at its callee's first step.
649
+ */
650
+ onlyWhen(entry) {
651
+ const own = entry.step.when;
652
+ if (own)
653
+ return { text: testWords(own.text) };
654
+ const at = this.positions.get(entry);
655
+ const call = at === undefined ? undefined : this.framesStartingAt(at).find((f) => f.when)?.when;
656
+ if (call)
657
+ return { text: testWords(call.text) };
658
+ return undefined;
659
+ }
660
+ /**
661
+ * The tool names of the steps from flat position `from` to the end, each
662
+ * that runs only under a test marked so — for a hand-over note's "remaining
663
+ * planned steps", which must not read as steps this request needs.
664
+ */
665
+ remainingFrom(from) {
666
+ return this.entries.slice(Math.max(0, from)).map((e) => {
667
+ const name = e.step.toolName ?? "";
668
+ const t = this.onlyWhen(e);
669
+ return t ? `${name} (only when ${t.text ?? "a request needs it"})` : name;
670
+ });
671
+ }
672
+ /** The steps the plan's own cursor has left out so far, in order (AC5). */
673
+ leftOut() {
674
+ return this.walk.left;
675
+ }
676
+ /** Whether any planned step runs only under a test. */
677
+ hasTests() {
678
+ return this.entries.slice(0, this.stopAt).some((e) => this.onlyWhen(e) !== undefined);
679
+ }
680
+ /** One test, answered — or the reason it could not be. Never throws. */
681
+ answer(test, params, intent, resp) {
682
+ try {
683
+ return { yes: evalWhenLogic(test.logic, params, intent, resp) };
684
+ }
685
+ catch (err) {
686
+ return { error: errText(err) };
687
+ }
688
+ }
689
+ /**
690
+ * What the tests decide at flat position `flatIndex` for `walk`. Enters the
691
+ * position's frame when its own test needs the frame's parameters, and
692
+ * nothing else changes: the caller moves its cursor.
693
+ */
694
+ gate(flatIndex, walk) {
695
+ const entry = this.entries[flatIndex];
696
+ if (!entry)
697
+ return { kind: "run" };
698
+ const frame = entry.frame;
699
+ const resp = walk.resp;
700
+ // 1. The sub-tasks that begin here, outermost first — one whose chain
701
+ // begins with a call begins where its callee's steps do, so nothing of
702
+ // it runs before its test says it is needed. Each test is decided
703
+ // once, in its caller's scope, before its frame is entered; a caller
704
+ // that begins here too is entered first, for its parameters.
705
+ const starting = this.framesStartingAt(flatIndex);
706
+ for (let k = 0; k < starting.length; k += 1) {
707
+ const sub = starting[k];
708
+ if (!sub.when || walk.tested.has(sub.id))
709
+ continue;
710
+ try {
711
+ for (const outer of starting.slice(0, k))
712
+ this.enterFrame(outer, walk);
713
+ }
714
+ catch (err) {
715
+ return {
716
+ kind: "stop",
717
+ stop: { kind: "step_failed", stepIndex: entry.stepIndex, flatIndex, stage: "tool_input_logic", error: errText(err) },
718
+ info: { step: entry.step, entry, input: {}, outcome: "failed", stage: "tool_input_logic", error: errText(err), ms: 0 },
719
+ };
720
+ }
721
+ const decided = this.decideCall(sub, entry, flatIndex, walk);
722
+ if (decided)
723
+ return decided;
724
+ walk.tested.add(sub.id);
725
+ }
726
+ // 2. The step's own test, in its own frame's scope.
727
+ const test = entry.step.when;
728
+ if (!test || walk.passed.has(flatIndex))
729
+ return { kind: "run" };
730
+ try {
731
+ // Its frame's parameters, which a test inside a sub-task reads.
732
+ this.enterFramesAt(flatIndex, walk);
733
+ }
734
+ catch (err) {
735
+ return {
736
+ kind: "stop",
737
+ stop: { kind: "step_failed", stepIndex: entry.stepIndex, flatIndex, stage: "tool_input_logic", error: errText(err) },
738
+ info: { step: entry.step, entry, input: {}, outcome: "failed", stage: "tool_input_logic", error: errText(err), ms: 0 },
739
+ };
740
+ }
741
+ const own = resp ? (resp.get(frame.id) ?? {}) : this.respFor(entry);
742
+ const said = this.answer(test, this.paramsFor(entry), frame.intent, own);
743
+ const text = testWords(test.text);
744
+ if ("error" in said) {
745
+ return {
746
+ kind: "stop",
747
+ stop: {
748
+ kind: "step_failed",
749
+ stepIndex: entry.stepIndex,
750
+ flatIndex,
751
+ stage: "when_logic",
752
+ error: said.error,
753
+ reason: "test_failed",
754
+ },
755
+ info: { step: entry.step, entry, input: {}, outcome: "failed", stage: "when_logic", error: said.error, ms: 0 },
756
+ };
757
+ }
758
+ if (said.yes) {
759
+ walk.passed.add(flatIndex);
760
+ return { kind: "run" };
761
+ }
762
+ if (test.otherwise === "skip") {
763
+ return {
764
+ kind: "skip",
765
+ until: flatIndex + 1,
766
+ info: { step: entry.step, entry, input: {}, outcome: "not_taken", ms: 0 },
767
+ entry,
768
+ text,
769
+ tools: [entry.step.toolName ?? ""],
770
+ };
771
+ }
772
+ return { kind: "stop", stop: { kind: "other_branch", stepIndex: entry.stepIndex, flatIndex, whenText: text } };
773
+ }
774
+ /**
775
+ * A sub-task's test, decided in its caller's scope at flat position
776
+ * `flatIndex`, where the sub-task begins. Undefined when it says yes.
777
+ */
778
+ decideCall(sub, entry, flatIndex, walk) {
779
+ const resp = walk.resp;
780
+ const parent = sub.parent;
781
+ const params = !parent || parent.depth === 0 ? this.params : (parent.parameters ?? {});
782
+ const parentResp = parent
783
+ ? resp
784
+ ? (resp.get(parent.id) ?? (parent.depth === 0 ? this.respParams : parent.respParams))
785
+ : parent.depth === 0
786
+ ? this.respParams
787
+ : parent.respParams
788
+ : this.respParams;
789
+ // The call row, as a step of its caller's chain: its verdict is filed
790
+ // there, under the call's own index.
791
+ const call = this.callRowOf(sub, entry);
792
+ const owner = parent ?? sub;
793
+ const said = this.answer(sub.when, params, owner.intent ?? this.intent, parentResp);
794
+ const text = testWords(sub.when.text);
795
+ if ("error" in said) {
796
+ return {
797
+ kind: "stop",
798
+ stop: {
799
+ kind: "step_failed",
800
+ stepIndex: call.step.stepIndex,
801
+ flatIndex,
802
+ stage: "when_logic",
803
+ error: said.error,
804
+ reason: "test_failed",
805
+ },
806
+ info: { step: call.step, entry: call.entry, input: {}, outcome: "failed", stage: "when_logic", error: said.error, ms: 0 },
807
+ };
808
+ }
809
+ if (said.yes)
810
+ return undefined;
811
+ if (sub.when.otherwise === "skip") {
812
+ const until = (this.edges.end.get(sub.id) ?? flatIndex) + 1;
813
+ return {
814
+ kind: "skip",
815
+ until,
816
+ info: { step: call.step, entry: call.entry, input: {}, outcome: "not_taken", ms: 0 },
817
+ entry: call.entry,
818
+ text,
819
+ // Every tool of the sub-task: a call the model makes to any of them is
820
+ // one to a step left out.
821
+ tools: [...new Set(this.entries.slice(flatIndex, until).map((e) => e.step.toolName ?? ""))],
822
+ };
823
+ }
824
+ return {
825
+ kind: "stop",
826
+ stop: { kind: "other_branch", stepIndex: call.step.stepIndex, flatIndex, whenText: text },
827
+ };
828
+ }
829
+ /** What a skip the gate decided leaves out, as the notes name it. */
830
+ leftOutOf(g, flatIndex) {
831
+ return {
832
+ stepIndex: g.entry.stepIndex,
833
+ flatIndex,
834
+ until: g.until,
835
+ toolName: g.entry.step.toolName ?? g.entry.step.callIntent ?? "a sub-task",
836
+ tools: g.tools.filter((t) => t !== ""),
837
+ text: g.text,
838
+ };
839
+ }
840
+ /**
841
+ * Tell a recorded-output source of the steps one skip left out — every step
842
+ * of a sub-task left out whole — so its queues pass over the outputs of the
843
+ * ones that were in the recording ({@link RecordedOutputFor}).
844
+ */
845
+ passOver(l, recordedOutputFor) {
846
+ const tell = recordedOutputFor?.leftOut;
847
+ if (!tell)
848
+ return;
849
+ for (let i = l.flatIndex; i < l.until; i += 1) {
850
+ const entry = this.entries[i];
851
+ if (entry)
852
+ tell(entry);
853
+ }
854
+ }
855
+ /**
856
+ * Move the plan's own cursor past every step this request does not need,
857
+ * deciding each test as its step comes due (AC5). Stops at a step that will
858
+ * run, at the end, or at a test that stops the plan — which it returns, with
859
+ * the steps it left out. The observer is told each verdict as it is made.
860
+ * A sub-task whose results cannot go back after its last step was left out
861
+ * stops the plan too (`result_map`), its verdict filed on the call row.
862
+ */
863
+ settleTests(onStep, opts = {}) {
864
+ const left = [];
865
+ while (this.stepIndex < this.stopAt) {
866
+ const g = this.gate(this.stepIndex, this.walk);
867
+ if (g.kind === "run")
868
+ break;
869
+ if (g.kind === "stop") {
870
+ if (g.info)
871
+ onStep?.(g.info);
872
+ return { stop: g.stop, left };
873
+ }
874
+ onStep?.(g.info);
875
+ const l = this.leftOutOf(g, this.stepIndex);
876
+ left.push(l);
877
+ this.walk.left.push(l);
878
+ const failed = this.leaveAfterSkip(g.until - 1, this.walk, onStep, opts.rethrow === true);
879
+ if (failed)
880
+ return { stop: failed, left };
881
+ this.stepIndex = g.until;
882
+ }
883
+ return { left };
884
+ }
521
885
  /**
522
886
  * Thread a real tool output into `respParams` and advance.
523
887
  *
@@ -540,7 +904,7 @@ export class ScenarioReplayPlan {
540
904
  }
541
905
  // Leaving a called frame hands its results back to the caller before the
542
906
  // caller's next step reads them (R-CALL-29).
543
- this.leaveFrame(entry);
907
+ this.leaveFramesAt(this.stepIndex, this.walk);
544
908
  this.stepIndex += 1;
545
909
  return derivedKeys;
546
910
  }
@@ -661,9 +1025,19 @@ export class ScenarioReplayPlan {
661
1025
  // because in steer mode the list *is* the instruction.
662
1026
  const planned = this.entries.slice(0, this.stopAt);
663
1027
  const args = this.previewArguments(planned, opts?.previewBudgetMs, opts?.previewBodyMs);
1028
+ // A step that runs only under a test says so on its line (AC5): which of
1029
+ // them this request needs is known only as the steps run.
1030
+ const tests = planned.map((e) => this.onlyWhen(e));
1031
+ const anyTest = tests.some((t) => t !== undefined);
1032
+ const onlyWhen = (i) => {
1033
+ const t = tests[i];
1034
+ if (!t)
1035
+ return "";
1036
+ return ` (only when ${t.text ?? "this request needs it"})`;
1037
+ };
664
1038
  const listing = (withWhy, argsBefore) => planned.map((e, i) => {
665
1039
  const why = withWhy ? clipReasoning(e.step.reasoning) : "";
666
- const head = ` ${i + 1}. ${e.step.toolName ?? ""}${why ? ` — ${why}` : ""}`;
1040
+ const head = ` ${i + 1}. ${e.step.toolName ?? ""}${onlyWhen(i)}${why ? ` — ${why}` : ""}`;
667
1041
  const shown = i < argsBefore ? args[i] : undefined;
668
1042
  return shown ? `${head}\n ${shown}` : head;
669
1043
  });
@@ -701,7 +1075,9 @@ export class ScenarioReplayPlan {
701
1075
  "[BaseInstRunner calculated replay] A known-good tool sequence was recorded",
702
1076
  `for this request${intent ? ` (intent: ${intent})` : ""}.`,
703
1077
  "",
704
- "It runs these tools, in order, and returns their results:",
1078
+ anyTest
1079
+ ? '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:'
1080
+ : "It runs these tools, in order, and returns their results:",
705
1081
  "",
706
1082
  ...lines,
707
1083
  "",
@@ -720,6 +1096,14 @@ export class ScenarioReplayPlan {
720
1096
  "",
721
1097
  ...lines,
722
1098
  ...(intent ? ["", `Context: ${intent}`] : []),
1099
+ ...(anyTest
1100
+ ? [
1101
+ "",
1102
+ 'A step marked "only when" may not be needed for this request. After each call the',
1103
+ "system says when the next one is not needed: leave that one out and go on with the",
1104
+ "one it names. It supplies arguments only for the steps this request needs.",
1105
+ ]
1106
+ : []),
723
1107
  "",
724
1108
  ...(anyArgs
725
1109
  ? [
@@ -764,16 +1148,67 @@ export class ScenarioReplayPlan {
764
1148
  const resp = new Map();
765
1149
  if (this.entries[0])
766
1150
  resp.set(this.rootFrame().id, { ...this.respParams });
1151
+ // A sub-task the plan's cursor is inside goes on from what it has made so
1152
+ // far: its copy, and the frames and tests the cursor already settled. A
1153
+ // bundle that started such a frame afresh read `{}` for it — its steps'
1154
+ // outputs were lost, and a test there read nothing (AC5 review).
1155
+ for (const id of this.walk.entered) {
1156
+ const frame = this.frameById(id);
1157
+ if (frame)
1158
+ resp.set(id, { ...frame.respParams });
1159
+ }
1160
+ // Its own walk, on those copies: what it enters and decides is not the
1161
+ // live plan's either (AC5).
1162
+ const walk = {
1163
+ resp,
1164
+ entered: new Set(this.walk.entered),
1165
+ tested: new Set(this.walk.tested),
1166
+ passed: new Set(this.walk.passed),
1167
+ left: [],
1168
+ };
767
1169
  const entries = [];
768
1170
  let executed = 0;
769
1171
  let recordedCount = 0;
770
1172
  let skipped = 0;
771
1173
  let errored = 0;
1174
+ let notTaken = 0;
772
1175
  let stopped;
773
1176
  const stopOnFailure = opts.stopOnFailure === true;
774
- for (let flatIndex = this.stepIndex; flatIndex < this.stopAt; flatIndex += 1) {
1177
+ let flatIndex = this.stepIndex;
1178
+ for (; flatIndex < this.stopAt; flatIndex += 1) {
775
1179
  const entry = this.entries[flatIndex];
776
1180
  const step = entry.step;
1181
+ // A step this request does not need is left out, and said so in the
1182
+ // bundle; a test that says "the other half" stops the bundle here (AC5).
1183
+ const g = this.gate(flatIndex, walk);
1184
+ if (g.kind === "stop") {
1185
+ if (g.info)
1186
+ onStep?.(g.info);
1187
+ stopped = g.stop;
1188
+ break;
1189
+ }
1190
+ if (g.kind === "skip") {
1191
+ onStep?.(g.info);
1192
+ notTaken += 1;
1193
+ const l = this.leftOutOf(g, flatIndex);
1194
+ walk.left.push(l);
1195
+ this.passOver(l, recordedOutputFor);
1196
+ entries.push({
1197
+ toolName: g.entry.step.toolName ?? g.entry.step.callIntent ?? "a sub-task",
1198
+ input: "",
1199
+ response: "",
1200
+ notNeeded: { text: g.text },
1201
+ });
1202
+ // The sub-tasks that end with the step left out still hand back what
1203
+ // they made; one that cannot stops the bundle (`result_map`).
1204
+ const failed = this.leaveAfterSkip(g.until - 1, walk, onStep, !stopOnFailure);
1205
+ if (failed) {
1206
+ stopped = failed;
1207
+ break;
1208
+ }
1209
+ flatIndex = g.until - 1;
1210
+ continue;
1211
+ }
777
1212
  const before = this.stopBefore(entry, flatIndex, opts.signal, opts.deadline);
778
1213
  if (before) {
779
1214
  stopped = before;
@@ -783,7 +1218,7 @@ export class ScenarioReplayPlan {
783
1218
  let computed;
784
1219
  try {
785
1220
  computed = reportingStage(onStep, entry, "tool_input_logic", {}, startedAt, () => {
786
- this.enterFrame(entry, resp);
1221
+ this.enterFramesAt(flatIndex, walk);
787
1222
  const respParams = resp.get(entry.frame.id) ?? {};
788
1223
  return evalToolInputLogic(step.toolInputLogic ?? "return {};", this.paramsFor(entry), entry.frame.intent, respParams);
789
1224
  });
@@ -849,7 +1284,7 @@ export class ScenarioReplayPlan {
849
1284
  derivedKeys = Object.keys(derived);
850
1285
  Object.assign(resp.get(entry.frame.id) ?? {}, derived);
851
1286
  }
852
- this.leaveFrame(entry, resp);
1287
+ this.leaveFramesAt(flatIndex, walk);
853
1288
  }, response);
854
1289
  }
855
1290
  catch (err) {
@@ -891,15 +1326,19 @@ export class ScenarioReplayPlan {
891
1326
  return {
892
1327
  text: assembleBundle(entries, maxChars, {
893
1328
  // A bundle that stops short of the task — in front of a parked step, or
894
- // at a step that broke — must never say "the work is done".
895
- handover: opts.handover === true || stopped !== undefined,
1329
+ // at a step that broke — must never say "the work is done". One that
1330
+ // left out a sub-task reaching past the parked step did not stop short.
1331
+ handover: (opts.handover === true && flatIndex <= this.stopAt) || stopped !== undefined,
896
1332
  subTask: this.subTask,
897
1333
  }),
898
1334
  executed,
899
1335
  recorded: recordedCount,
900
1336
  skipped,
901
1337
  errored,
1338
+ notTaken,
1339
+ left: walk.left,
902
1340
  stopped,
1341
+ endedAt: flatIndex,
903
1342
  };
904
1343
  }
905
1344
  /**
@@ -926,11 +1365,31 @@ export class ScenarioReplayPlan {
926
1365
  let recordedCount = 0;
927
1366
  let skipped = 0;
928
1367
  let errored = 0;
1368
+ let notTaken = 0;
1369
+ const left = [];
929
1370
  let partial = false;
930
1371
  let aborted = false;
931
1372
  let stopped;
932
1373
  const stopOnFailure = opts.stopOnFailure === true;
933
1374
  while (!this.isDone()) {
1375
+ // The steps this request does not need are left out, and said so in the
1376
+ // bundle; a test that says "the other half" stops the run here (AC5).
1377
+ // A sub-task whose results cannot go back after its last step was left
1378
+ // out stops the run (`result_map`) — or, without `stopOnFailure`, its
1379
+ // throw propagates, as any logic throw does.
1380
+ const settled = this.settleTests(onStep, { rethrow: !stopOnFailure });
1381
+ left.push(...settled.left);
1382
+ for (const l of settled.left) {
1383
+ this.passOver(l, recordedOutputFor);
1384
+ entries.push({ toolName: l.toolName, input: "", response: "", notNeeded: { text: l.text } });
1385
+ }
1386
+ notTaken += settled.left.length;
1387
+ if (settled.stop) {
1388
+ stopped = settled.stop;
1389
+ break;
1390
+ }
1391
+ if (this.isDone())
1392
+ break;
934
1393
  const entry = this.entries[this.stepIndex];
935
1394
  const step = entry.step;
936
1395
  // Where this step sits in the flat list — what a stop reports. Taken now,
@@ -1042,8 +1501,9 @@ export class ScenarioReplayPlan {
1042
1501
  }
1043
1502
  entries.push({ toolName: step.toolName ?? "", input: bundleInput(computed), response, recorded });
1044
1503
  }
1045
- // Every planned step ran and the plan ends in front of a parked one.
1046
- if (!stopped && this.stopsEarly() && this.isDone()) {
1504
+ // Every planned step ran and the plan ends in front of a parked one — not
1505
+ // when a test left out a sub-task that reaches past it (AC5).
1506
+ if (!stopped && this.atStop()) {
1047
1507
  // `known_bad_step` here means "in front of the plan's end": the
1048
1508
  // controller reads the served reason and names the kind (D8).
1049
1509
  stopped = {
@@ -1061,9 +1521,12 @@ export class ScenarioReplayPlan {
1061
1521
  recorded: recordedCount,
1062
1522
  skipped,
1063
1523
  errored,
1524
+ notTaken,
1525
+ left,
1064
1526
  partial,
1065
1527
  stopped,
1066
1528
  aborted: aborted || undefined,
1529
+ endedAt: this.stepIndex,
1067
1530
  };
1068
1531
  }
1069
1532
  /**