@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
@@ -14,12 +14,12 @@
14
14
  import { parseQualifiedName } from "../control/correlation.js";
15
15
  import { isHousekeepingCall } from "../record/housekeeping.js";
16
16
  import { REDACT_SCAN_MAX_CHARS, redact, redactJsonText, redactText } from "../record/redact.js";
17
- import { capText, REPORT_PAYLOAD_MAX_BYTES, serializeCapped } from "../record/truncate.js";
17
+ import { capText, DEFAULT_MAX_PAYLOAD_BYTES, REPORT_PAYLOAD_MAX_BYTES, serializeCapped, } from "../record/truncate.js";
18
18
  import { logDetail, logLine, errText } from "../util/log.js";
19
19
  import { serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
20
20
  import { bashCommandFor } from "./bash-channel.js";
21
21
  import { BuiltinNotRunnable, BuiltinTimeout, parseBashAllow, parseBuiltinTools, runBuiltin, } from "./builtins.js";
22
- import { BASH_CHANNEL_MAX, MAX_REPLAY_REASON, truncate } from "./bundle.js";
22
+ import { BASH_CHANNEL_MAX, MAX_REPLAY_REASON, testWords, truncate } from "./bundle.js";
23
23
  import { coverageOf, modeFor, reachOf } from "./coverage.js";
24
24
  import { deriveParameters, } from "./derive.js";
25
25
  import { buildHandoverNote, MAX_HANDOVER_NOTE, } from "./handover.js";
@@ -29,7 +29,7 @@ import { PREVIEW_BUDGET_MS, ScenarioReplayPlan, StepRunError, } from "./plan.js"
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, } from "./flatten.js";
32
+ import { clampToFrameStart, flattenChain, frameEdges, } from "./flatten.js";
33
33
  import { blockingTargets } from "./targets.js";
34
34
  import { isNoResult, toolResultError } from "./tool-error.js";
35
35
  import { OUTCOME_RANK, hasTargets, isReadyScenario, } from "./types.js";
@@ -171,6 +171,12 @@ const HOST_RACE_MS = 200;
171
171
  const MAX_STEP_RESULTS = 200;
172
172
  /** Per-step error budget. The console shows these inline, under the step. */
173
173
  const MAX_STEP_ERROR_CHARS = 500;
174
+ /**
175
+ * All of one plan's kept calls together, in characters. A plan of a few dozen
176
+ * calls keeps them whole; past this the rest keep a report's cut, so one turn
177
+ * cannot hold more than a few megabytes in the recorder.
178
+ */
179
+ const TURN_CALLS_MAX_CHARS = 8 * 1024 * 1024;
174
180
  export class ReplayController {
175
181
  enabled;
176
182
  work = new ProxyWorkQueue();
@@ -400,6 +406,13 @@ export class ReplayController {
400
406
  if (match.withheld === "runner_update_required") {
401
407
  return decline("the service hands this runner no plan until it is updated — run `bir update`", "runner_update_required");
402
408
  }
409
+ // Held (the service's autocorrect.md AC4): the agent redid this plan's
410
+ // task after its last run, and the service hands the plan out again only
411
+ // when its owner lets it go or uses a correction. Its own code, so the
412
+ // turn is not read as "not ready", whose fix — calculate — is wrong here.
413
+ if (match.withheld === "held") {
414
+ return decline("the service holds this plan: the agent did its task again after its last run", "held");
415
+ }
403
416
  // Gate 3 — a ready scenario with steps.
404
417
  const scenario = match.scenario;
405
418
  if (!match.scenarioId || !isReadyScenario(scenario)) {
@@ -460,6 +473,15 @@ export class ReplayController {
460
473
  : "known_bad_first_step");
461
474
  }
462
475
  const planned = stopAt === undefined ? entries : entries.slice(0, stopAt);
476
+ // Another host's adapter steps through a plan call by call and knows no
477
+ // tests (AC5): asked for a step its test left out, it is refused, reads
478
+ // that as the plan's end, and the model's own calls after it are booked as
479
+ // a broken step — the step counts drift when two steps share a tool. Such
480
+ // a plan is declined for it here, before anything is spent, until the
481
+ // adapter reads `when`.
482
+ if (host && planned.some(underTest)) {
483
+ return decline(`the ${host.app} adapter cannot yet run a plan whose steps run only when a test says so`, "host_tests_unsupported");
484
+ }
463
485
  // Gate 6 — tool coverage, over the steps that will actually run. A
464
486
  // built-in on `BIR_BUILTIN_TOOLS` counts as reachable: the runner
465
487
  // executes it itself (replay/builtins.ts), so a chain that starts with a
@@ -611,6 +633,10 @@ export class ReplayController {
611
633
  }
612
634
  state.mode = mode;
613
635
  state.stepsPlanned = entries.length;
636
+ // The chain this plan was served at: what its own calls are marked with
637
+ // when they are recorded into a redo of the turn (AC2).
638
+ if (typeof scenario.chainRevision === "number")
639
+ state.chainRevision = scenario.chainRevision;
614
640
  // Why the plan stops, when a call is what stops it: the note names the
615
641
  // sub-task rather than a tool (R-CALL-36).
616
642
  state.flatStop =
@@ -647,6 +673,8 @@ export class ReplayController {
647
673
  stops: stopLines(entries),
648
674
  coverage: coverageOf(entries.map((e) => e.step), state.wrapped, allowServers, builtinTools).join(","),
649
675
  tools: entries.map((e) => e.step.toolName ?? "").join(","),
676
+ // Which steps run only under a test, by flat position (AC5).
677
+ tests: testLines(entries),
650
678
  });
651
679
  return state;
652
680
  }
@@ -963,6 +991,41 @@ export class ReplayController {
963
991
  return this.concurrentCall(state, plan, toolName);
964
992
  }
965
993
  }
994
+ // The steps this request does not need are left out before the call is
995
+ // judged, and a test that says "the other half" stops the plan here
996
+ // (AC5). Only a steer plan's cursor moves with the model's calls.
997
+ if (state.mode === "steer") {
998
+ if (this.settleCursor(state, plan, "above") === "stopped") {
999
+ const note = this.takeNote(state);
1000
+ return note ? this.deliverInstead(note, toolName, otherHost) : { kind: "abort" };
1001
+ }
1002
+ if (plan.isDone()) {
1003
+ // What was left of the plan was not needed: it is complete, and this
1004
+ // call is the model's own — unless a parked step is next, whose
1005
+ // hand-over is told in place of this call.
1006
+ this.finishSteer(state, plan);
1007
+ const note = this.takeNote(state);
1008
+ return note ? this.deliverInstead(note, toolName, otherHost) : { kind: "passthrough" };
1009
+ }
1010
+ // A call to a step that was just left out is told so in its place —
1011
+ // even when the note already went out, which a model may not have read:
1012
+ // read as a divergence, it would end the plan. Twice at most, so a model
1013
+ // that means the call for itself is not refused for ever.
1014
+ const skip = state.skipNote;
1015
+ if (skip && toolName !== plan.expectedTool() && skip.tools.has(toolName) && (skip.refused ?? 0) < 2) {
1016
+ skip.delivered = true;
1017
+ skip.refused = (skip.refused ?? 0) + 1;
1018
+ return this.deliverInstead(skip.text, toolName, otherHost);
1019
+ }
1020
+ // A call to the next step's tool: a note the model has read is done
1021
+ // with. One it has not read yet waits for the pin guard (the control
1022
+ // server's `pinTaken` / `skipNoteFor`): when the left-out step and the
1023
+ // next one share a tool, the call may be the left-out one, made because
1024
+ // the note was late — its arguments then fail the guard, and the note
1025
+ // goes in its place rather than the call running as written.
1026
+ if (skip && toolName === plan.expectedTool() && skip.delivered)
1027
+ state.skipNote = undefined;
1028
+ }
966
1029
  // A direct plan does not steer individual calls: the model was asked for one
967
1030
  // tool call and made a different one. That is divergence.
968
1031
  const expected = plan.expectedTool();
@@ -1353,6 +1416,11 @@ export class ReplayController {
1353
1416
  ms: Date.now() - pin.pinnedAt,
1354
1417
  });
1355
1418
  }
1419
+ // With this output in, the next steps' tests can be decided: the ones this
1420
+ // request does not need are left out, and one that says "the other half"
1421
+ // hands over here, its note on this very PostToolUse (AC5).
1422
+ if (this.settleCursor(state, plan, "above") === "stopped")
1423
+ return false;
1356
1424
  const done = plan.isDone();
1357
1425
  logDetail("replay.thread", {
1358
1426
  n: pin.stepIndex,
@@ -1360,7 +1428,17 @@ export class ReplayController {
1360
1428
  emitted: derivedKeys.join(",") || undefined,
1361
1429
  done,
1362
1430
  });
1363
- if (done && plan.stopsEarly()) {
1431
+ if (done)
1432
+ this.finishSteer(state, plan);
1433
+ return done;
1434
+ }
1435
+ /**
1436
+ * A steer plan whose cursor reached its end: in front of a parked step (or a
1437
+ * call the service could not answer), which hands over; otherwise it ran to
1438
+ * its end — and from here every call is the model's own.
1439
+ */
1440
+ finishSteer(state, plan) {
1441
+ if (plan.atStop()) {
1364
1442
  // Every planned step ran, and the next one is parked (fallbk.md D3) — or
1365
1443
  // is a call the service could not answer (segmented.md R-CALL-29).
1366
1444
  const parked = plan.stopStep();
@@ -1371,24 +1449,155 @@ export class ReplayController {
1371
1449
  position: plan.stepCount,
1372
1450
  delivery: "above",
1373
1451
  });
1374
- return true;
1452
+ return;
1375
1453
  }
1376
- if (done) {
1377
- this.upgrade(state, "steered_full");
1378
- state.ranToEnd = true;
1379
- // A completing retire: the last step threaded and the plan's work is over
1380
- // (segmented.md R-MONEY-3). Everything the agent spends after this is the
1381
- // agent's own, not this plan's.
1382
- this.retire(state, undefined, true);
1383
- logLine("replay.done", {
1384
- scenario: state.scenarioId ?? undefined,
1385
- mode: state.mode,
1386
- steps: `${plan.stepCount}/${plan.stepCount}`,
1387
- outcome: state.outcome,
1388
- ms: Date.now() - state.armedAt,
1454
+ this.upgrade(state, "steered_full");
1455
+ state.ranToEnd = true;
1456
+ // A completing retire: the last step threaded and the plan's work is over
1457
+ // (segmented.md R-MONEY-3). Everything the agent spends after this is the
1458
+ // agent's own, not this plan's.
1459
+ this.retire(state, undefined, true);
1460
+ // The steps that ran: a step its test left out is no step done (AC5).
1461
+ const before = this.ranBefore(plan, plan.stepCount);
1462
+ logLine("replay.done", {
1463
+ scenario: state.scenarioId ?? undefined,
1464
+ mode: state.mode,
1465
+ steps: `${before.left.length > 0 ? before.ran.length : plan.stepCount}/${plan.stepCount}`,
1466
+ notTaken: before.left.length || undefined,
1467
+ outcome: state.outcome,
1468
+ ms: Date.now() - state.armedAt,
1469
+ });
1470
+ }
1471
+ /**
1472
+ * Move a plan's own cursor past the steps this request does not need, and
1473
+ * act on what its tests decide (the service's autocorrect.md AC5). Every
1474
+ * verdict is recorded as it is made. `stopped` means the plan handed over —
1475
+ * the other half, a test that could not answer, or no step needed at all —
1476
+ * and its note waits in {@link takeNote}. Otherwise the steps left out are
1477
+ * named in a note for the model ({@link takeSkipNote}), and the caller goes
1478
+ * on: a step is due, or the plan is done.
1479
+ *
1480
+ * Nothing at all for a plan without tests, which behaves exactly as before.
1481
+ */
1482
+ settleCursor(state, plan, delivery) {
1483
+ if (!plan.hasTests())
1484
+ return "ready";
1485
+ // Never throws: a sub-task whose results cannot go back after its last
1486
+ // step was left out is a stop like any other (`result_map`).
1487
+ const settled = plan.settleTests(this.observeStep(state));
1488
+ // The recorded outputs of the steps left out are passed over, so a
1489
+ // divergence's bundle later stands each step's own in (source-run.ts).
1490
+ const source = state.sourceRun;
1491
+ if (source) {
1492
+ for (const l of settled.left) {
1493
+ for (let i = l.flatIndex; i < l.until; i += 1) {
1494
+ const entry = plan.allEntries()[i];
1495
+ if (entry)
1496
+ source.leftOut(entry);
1497
+ }
1498
+ }
1499
+ }
1500
+ if (settled.stop) {
1501
+ const stop = settled.stop;
1502
+ const position = stop.flatIndex ?? plan.currentStepIndex;
1503
+ this.handOver(state, {
1504
+ kind: stop.kind,
1505
+ step: plan.allSteps()[position],
1506
+ entry: plan.allEntries()[position],
1507
+ position,
1508
+ error: stop.error,
1509
+ delivery,
1510
+ stopReason: stop.reason,
1511
+ whenText: stop.whenText,
1512
+ subTask: stop.subTask,
1389
1513
  });
1514
+ return "stopped";
1515
+ }
1516
+ if (settled.left.length === 0)
1517
+ return "ready";
1518
+ if (plan.isDone() && !plan.atStop() && state.stepsPinned === 0) {
1519
+ // Every step was left out: none of the plan's work is this request's,
1520
+ // and the model does the task (AC5). Reported like any hand-over before
1521
+ // a step ran.
1522
+ this.handOver(state, {
1523
+ kind: "other_branch",
1524
+ step: plan.allSteps()[0],
1525
+ entry: plan.allEntries()[0],
1526
+ position: 0,
1527
+ delivery,
1528
+ noneNeeded: true,
1529
+ });
1530
+ return "stopped";
1390
1531
  }
1391
- return done;
1532
+ state.skipNote = {
1533
+ text: this.skipNoteText(state, plan, settled.left),
1534
+ // Every tool of a sub-task left out whole, not its call's intent: a call
1535
+ // the model makes to any of them is one to a step left out.
1536
+ tools: new Set(settled.left.flatMap((l) => (l.tools.length > 0 ? l.tools : [l.toolName]))),
1537
+ delivered: false,
1538
+ complete: plan.isDone() && !plan.atStop(),
1539
+ };
1540
+ return "ready";
1541
+ }
1542
+ /** What the model is told about steps a test left out (AC5). */
1543
+ skipNoteText(state, plan, left) {
1544
+ const named = left.map((l) => `step ${l.flatIndex + 1} (${l.toolName})`);
1545
+ const texts = [...new Set(left.map((l) => l.text).filter((t) => !!t))];
1546
+ const why = texts.length === 1 ? ` — only for when ${texts[0]}` : "";
1547
+ const what = left.length === 1 ? `Not needed for this request: ${named[0]}${why}.` : `Not needed for this request: ${named.join(", ")}${why}.`;
1548
+ let next;
1549
+ if (plan.isDone() && !plan.atStop()) {
1550
+ next =
1551
+ state.armedBy === "intent"
1552
+ ? "The sequence is complete: continue your task, calling tools as needed."
1553
+ : "The sequence is complete: answer the user's request from the tool results.";
1554
+ }
1555
+ else {
1556
+ const entry = plan.currentEntry();
1557
+ next = `Leave ${left.length === 1 ? "it" : "them"} out, and call step ${plan.currentStepIndex + 1}${entry?.step.toolName ? ` (${entry.step.toolName})` : ""} next.`;
1558
+ }
1559
+ return `[BaseInstRunner calculated replay] ${what} ${next}`;
1560
+ }
1561
+ /**
1562
+ * The note naming the steps a test just left out, claimed for delivery on a
1563
+ * `PostToolUse` (AC5). Returned once; undefined when there is none.
1564
+ */
1565
+ takeSkipNote(state) {
1566
+ const skip = state?.skipNote;
1567
+ if (!skip || skip.delivered)
1568
+ return undefined;
1569
+ skip.delivered = true;
1570
+ return skip.text;
1571
+ }
1572
+ /**
1573
+ * The same note, for a call the model is about to make to one of the steps
1574
+ * it names — when the note has not reached the model yet. The control server
1575
+ * gives it in place of that call, whether the plan is still steering or has
1576
+ * just finished: a step left out must never run because the note was late.
1577
+ */
1578
+ skipNoteFor(state, toolName) {
1579
+ const skip = state?.skipNote;
1580
+ if (!skip || skip.delivered || !skip.tools.has(toolName))
1581
+ return undefined;
1582
+ skip.delivered = true;
1583
+ return skip.text;
1584
+ }
1585
+ /**
1586
+ * The pin guard took a call to the next step as that step: a note still
1587
+ * waiting to say that a step before it was left out has nothing left to
1588
+ * stop, and goes — as it went at the call before the guard existed.
1589
+ */
1590
+ pinTaken(state) {
1591
+ if (state?.skipNote && !state.skipNote.complete)
1592
+ state.skipNote = undefined;
1593
+ }
1594
+ /**
1595
+ * The plan's own calls of the turn, whole, in the order it made them — for
1596
+ * the turn's newest run when the agent redoes the task (AC2). Empty unless
1597
+ * the control server asked for them to be kept ({@link ReplayState.keepCalls}).
1598
+ */
1599
+ turnCallsOf(state) {
1600
+ return state?.turnCalls ? [...state.turnCalls.values()] : [];
1392
1601
  }
1393
1602
  /**
1394
1603
  * The result of a call pinned under a plan that has since handed over
@@ -1619,7 +1828,12 @@ export class ReplayController {
1619
1828
  ran: live ? undefined : 0,
1620
1829
  // An output-logic failure stops *after* its step's tool ran: the step
1621
1830
  // is done, its result is in the bundle, and it must not be run again.
1622
- ranThisStep: stop.stage === "tool_output_logic",
1831
+ // Not a sub-task's result mapping after its last step was left out
1832
+ // (`result_map`): that step did not run (AC5).
1833
+ ranThisStep: stop.stage === "tool_output_logic" && stop.reason !== "result_map",
1834
+ // The other half (AC5): what the steps from here are for.
1835
+ whenText: stop.whenText,
1836
+ subTask: stop.subTask,
1623
1837
  });
1624
1838
  if (stop.reason === "cancelled") {
1625
1839
  // The caller is gone and reads nothing. The note stays undelivered: a
@@ -1650,6 +1864,20 @@ export class ReplayController {
1650
1864
  partial: result.partial,
1651
1865
  };
1652
1866
  }
1867
+ if (result.executed === 0 && result.recorded === 0 && result.notTaken > 0) {
1868
+ // Every step was left out by its test: none of the plan's work is this
1869
+ // request's (AC5). The model does the task, told so.
1870
+ this.handOver(state, {
1871
+ kind: "other_branch",
1872
+ step: plan.allSteps()[0],
1873
+ entry: plan.allEntries()[0],
1874
+ position: 0,
1875
+ delivery: "none",
1876
+ ran: 0,
1877
+ noneNeeded: true,
1878
+ });
1879
+ return handedOver(this.takeNote(state) ?? state.handover?.note) ?? { ok: false, why: "" };
1880
+ }
1653
1881
  if (result.executed === 0) {
1654
1882
  // Every step stood on its recorded output (BIR_REPLAY_RECORDED_OUTPUTS):
1655
1883
  // nothing happened here, and an answer built on last week's outputs is
@@ -1725,12 +1953,15 @@ export class ReplayController {
1725
1953
  const steps = this.stepResultsOf(state);
1726
1954
  const isBaseline = state.outcome === "not_steered" || state.outcome === "failed";
1727
1955
  // A costless decline is noise on both sides — *unless* it says something the
1728
- // recording page cannot learn any other way: a step that actually broke, or
1729
- // the gate that declined. The service accepts a costless report that carries
1730
- // either (its errorshandling.md).
1956
+ // recording page cannot learn any other way: a step that actually broke, the
1957
+ // gate that declined, or a plan that stood in front of the other half of
1958
+ // its task (AC5), which the service takes the agent's work after as its
1959
+ // evidence of that half. The service accepts a costless report that carries
1960
+ // any of them (its errorshandling.md).
1731
1961
  if (isBaseline &&
1732
1962
  total <= 0 &&
1733
1963
  !state.declined &&
1964
+ state.handover?.kind !== "other_branch" &&
1734
1965
  !steps?.some((s) => s.status === "failed")) {
1735
1966
  return undefined;
1736
1967
  }
@@ -1771,9 +2002,18 @@ export class ReplayController {
1771
2002
  declined: state.declined,
1772
2003
  // Where the plan handed the task to the model (fallbk.md). Only on
1773
2004
  // `fell_back`: a hand-over before any step ran is `failed`, and a parked
1774
- // step found after a divergence is still a divergence.
1775
- fallbackStepIndex: state.outcome === "fell_back" ? state.handover?.stepIndex : undefined,
1776
- fallbackKind: state.outcome === "fell_back" ? state.handover?.kind : undefined,
2005
+ // step found after a divergence is still a divergence. Except the other
2006
+ // half of an "if … otherwise" (AC5), named whatever the outcome: the
2007
+ // agent's work after it is the service's evidence of that half, whether
2008
+ // or not a step ran first.
2009
+ fallbackStepIndex: state.outcome === "fell_back" || state.handover?.kind === "other_branch"
2010
+ ? state.handover?.stepIndex
2011
+ : undefined,
2012
+ fallbackKind: state.outcome === "fell_back" || state.handover?.kind === "other_branch"
2013
+ ? state.handover?.kind
2014
+ : undefined,
2015
+ // The agent did not take the plan's result and redid the task (AC1).
2016
+ ...(d.redoneBy ? { redoneBy: d.redoneBy } : {}),
1777
2017
  // Segments of this plan's recording that armed later in the turn
1778
2018
  // (segmented.md R-MONEY-4): their share comes off this plan's baseline.
1779
2019
  sharedWith: state.sharedWith.length > 0 ? [...state.sharedWith] : undefined,
@@ -2113,9 +2353,11 @@ export class ReplayController {
2113
2353
  // Same rule as `runArmed`: a recovered chain whose tools errored is
2114
2354
  // `failed`, and so is one that stopped at a step that broke — its logic
2115
2355
  // threw, or it was sent and failed or never answered. A step that simply
2116
- // could not be sent from here, or the hook's clock, is still a divergence.
2356
+ // could not be sent from here, or the hook's clock, is still a divergence;
2357
+ // so is a stop in front of the other half (AC5), which broke nothing.
2117
2358
  const broke = composed.errored > 0 ||
2118
2359
  (stop !== undefined &&
2360
+ stop.kind !== "other_branch" &&
2119
2361
  stop.reason !== "not_run" &&
2120
2362
  stop.reason !== "budget" &&
2121
2363
  stop.reason !== "cancelled");
@@ -2125,7 +2367,12 @@ export class ReplayController {
2125
2367
  // delivered nothing: no saving, a baseline sample, as `handOver` books it.
2126
2368
  // Recorded stand-ins are no work here (BIR_REPLAY_RECORDED_OUTPUTS).
2127
2369
  const didWork = composed.executed > 0 || state.stepsPinned > 0;
2128
- this.upgrade(state, broke || !didWork ? "failed" : "diverged");
2370
+ // A bundle that stops in front of the other half (AC5) is a hand-over,
2371
+ // however the plan got there: booked as the contract books one — `fell_back`
2372
+ // when a step did its work — so the service takes the agent's work after
2373
+ // it as that half's evidence. Booked `diverged`, it never did.
2374
+ const otherHalf = stop?.kind === "other_branch";
2375
+ this.upgrade(state, broke || !didWork ? "failed" : otherHalf ? "fell_back" : "diverged");
2129
2376
  state.stepsPinned += composed.executed;
2130
2377
  // The headline number of this whole design: recovering from a divergence
2131
2378
  // costs nothing, because the proxies were already connected.
@@ -2153,20 +2400,34 @@ export class ReplayController {
2153
2400
  // ahead — unless a step was *sent* and never answered. That one may have
2154
2401
  // done its work, and the model must know before it does the task itself.
2155
2402
  if (stop?.reason === "uncertain" && stop.flatIndex !== undefined) {
2156
- const note = this.stopNote(state, plan, stop, "none");
2403
+ const note = this.stopNote(state, plan, stop, "none", composed.left);
2157
2404
  return this.deliverInstead(note, toolName, state.host !== undefined);
2158
2405
  }
2406
+ // The rest is the other half (AC5): the model is told so, in place of
2407
+ // its call, before it does any of the plan's remaining steps.
2408
+ if (stop?.kind === "other_branch" && stop.flatIndex !== undefined) {
2409
+ const note = this.stopNote(state, plan, stop, "none", composed.left);
2410
+ return this.deliverInstead(note, toolName, state.host !== undefined);
2411
+ }
2412
+ // Every remaining step was left out by its test: the steps the session
2413
+ // ran were the whole of the plan this request needs, and it ran to its
2414
+ // end (newestRun.md N7 reads that).
2415
+ const ranOut = !stop && composed.endedAt >= plan.stepCount;
2416
+ const parkedNext = plan.stopsEarly() && composed.endedAt === plan.stepCount;
2417
+ if (ranOut && !parkedNext && composed.notTaken > 0 && state.stepsPinned > 0)
2418
+ state.ranToEnd = true;
2159
2419
  return { kind: "abort" };
2160
2420
  }
2161
2421
  // The bundle stops short of the task: say so ahead of it — which step, and
2162
2422
  // what may be assumed about it — and the model carries on from there. The
2163
- // outcome stays as judged above, so the report carries no fallback fields.
2423
+ // outcome stays as judged above, so the report carries no fallback fields
2424
+ // (the other half of an "if … otherwise" excepted: see `buildReport`).
2164
2425
  let text = composed.text;
2165
- if (stop && stop.kind === "step_failed" && stop.flatIndex !== undefined) {
2166
- const note = this.stopNote(state, plan, stop, "below");
2426
+ if (stop && (stop.kind === "step_failed" || stop.kind === "other_branch") && stop.flatIndex !== undefined) {
2427
+ const note = this.stopNote(state, plan, stop, "below", composed.left);
2167
2428
  text = `${note}\n\n${composed.text}`;
2168
2429
  }
2169
- else if (plan.stopsEarly()) {
2430
+ else if (plan.stopsEarly() && composed.endedAt === plan.stepCount) {
2170
2431
  const parked = plan.stopStep();
2171
2432
  const stopKind = this.stopKindOf(state, plan.stopEntry());
2172
2433
  state.handover = {
@@ -2180,6 +2441,8 @@ export class ReplayController {
2180
2441
  position: plan.stepCount,
2181
2442
  delivery: "below",
2182
2443
  executedAbove: plan.currentStepIndex,
2444
+ // The steps the bundle left out ran no more than the cursor's (AC5).
2445
+ leftOut: composed.left,
2183
2446
  }),
2184
2447
  delivered: true,
2185
2448
  };
@@ -2202,7 +2465,9 @@ export class ReplayController {
2202
2465
  * plan's hand-over (already delivered — it goes out with the bundle), so the
2203
2466
  * model's own work from here is recorded as a fragment.
2204
2467
  */
2205
- stopNote(state, plan, stop, delivery) {
2468
+ stopNote(state, plan, stop, delivery,
2469
+ /** The steps the bundle left out (AC5): no step that ran. */
2470
+ left = []) {
2206
2471
  const position = stop.flatIndex ?? plan.currentStepIndex;
2207
2472
  const step = plan.allSteps()[position];
2208
2473
  const entry = plan.allEntries()[position];
@@ -2219,8 +2484,12 @@ export class ReplayController {
2219
2484
  // steps that ran; "no step ran" would invite the model to run them again.
2220
2485
  ran: delivery === "none" ? plan.currentStepIndex : undefined,
2221
2486
  executedAbove: plan.currentStepIndex,
2222
- // An output-logic failure stops after its step's tool ran (plan.ts).
2223
- ranThisStep: stop.stage === "tool_output_logic",
2487
+ // An output-logic failure stops after its step's tool ran (plan.ts) —
2488
+ // not a sub-task's result mapping after its last step was left out.
2489
+ ranThisStep: stop.stage === "tool_output_logic" && stop.reason !== "result_map",
2490
+ whenText: stop.whenText,
2491
+ subTask: stop.subTask,
2492
+ leftOut: left,
2224
2493
  });
2225
2494
  state.handover ??= {
2226
2495
  kind: stop.kind,
@@ -2267,21 +2536,25 @@ export class ReplayController {
2267
2536
  segment: state.flatStop?.segmentId ?? undefined,
2268
2537
  ran,
2269
2538
  outcome: state.outcome,
2270
- why: h.kind === "unusable_call"
2271
- ? "a sub-task of this chain cannot run — the agent does that part itself"
2272
- : h.kind === "known_bad_step"
2273
- ? "the next step has failed too often — the agent continues from here"
2274
- : h.kind === "nondeterministic_step"
2275
- ? "the next step needs a judgement the plan cannot compute — the agent makes it"
2276
- : h.stopReason === "concurrent"
2277
- ? "a second call arrived while the pinned step was still running — the agent continues from here"
2278
- : h.stopReason === "budget" || h.stopReason === "cancelled"
2279
- ? "the run was cut short before this step — the agent continues from here"
2280
- : h.stopReason === "host_rejected" || h.stopReason === "host_edited"
2281
- ? "the host stopped this step itself — no fault of the plan; the agent continues from here"
2282
- : h.stopReason === "not_run" || h.stopReason === "uncertain"
2283
- ? "a step produced no result here — the agent continues from here"
2284
- : "a step broke — the agent continues from here",
2539
+ why: h.kind === "other_branch"
2540
+ ? h.noneNeeded
2541
+ ? "no step of the plan was needed for this request — the agent does the task"
2542
+ : `this request is the other half: the steps from here are only for when ${h.whenText ?? "their test says so"} — the agent continues`
2543
+ : h.kind === "unusable_call"
2544
+ ? "a sub-task of this chain cannot run — the agent does that part itself"
2545
+ : h.kind === "known_bad_step"
2546
+ ? "the next step has failed too often — the agent continues from here"
2547
+ : h.kind === "nondeterministic_step"
2548
+ ? "the next step needs a judgement the plan cannot compute — the agent makes it"
2549
+ : h.stopReason === "concurrent"
2550
+ ? "a second call arrived while the pinned step was still running — the agent continues from here"
2551
+ : h.stopReason === "budget" || h.stopReason === "cancelled"
2552
+ ? "the run was cut short before this step — the agent continues from here"
2553
+ : h.stopReason === "host_rejected" || h.stopReason === "host_edited"
2554
+ ? "the host stopped this step itself — no fault of the plan; the agent continues from here"
2555
+ : h.stopReason === "not_run" || h.stopReason === "uncertain"
2556
+ ? "a step produced no result here — the agent continues from here"
2557
+ : "a step broke — the agent continues from here",
2285
2558
  });
2286
2559
  }
2287
2560
  /**
@@ -2295,12 +2568,46 @@ export class ReplayController {
2295
2568
  return "nondeterministic_step";
2296
2569
  return state.flatStop ? "unusable_call" : "known_bad_step";
2297
2570
  }
2571
+ /**
2572
+ * The steps before flat position `bound` that ran, 1-based, and the ones
2573
+ * their tests left out (AC5) — the plan cursor's own, and a divergence
2574
+ * bundle's (`extra`). `left` is empty when no step was left out; `ran` is
2575
+ * then not filled, since every step before `bound` ran.
2576
+ */
2577
+ ranBefore(plan, bound, extra = []) {
2578
+ const left = [...(plan?.leftOut() ?? []), ...extra].filter((l) => l.flatIndex < bound);
2579
+ if (left.length === 0)
2580
+ return { ran: [], left };
2581
+ const out = new Set();
2582
+ for (const l of left)
2583
+ for (let i = l.flatIndex; i < Math.min(l.until, bound); i += 1)
2584
+ out.add(i);
2585
+ const ran = [];
2586
+ for (let i = 0; i < bound; i += 1)
2587
+ if (!out.has(i))
2588
+ ran.push(i + 1);
2589
+ return { ran, left };
2590
+ }
2298
2591
  noteFor(state, h) {
2299
2592
  const plan = state.plan;
2300
- const executed = h.ran ?? (h.ranThisStep ? h.position + 1 : h.position);
2593
+ // The steps before the stop — or the bound a caller gives — less the ones
2594
+ // their tests left out (AC5): a note must never say a step ran that did not.
2595
+ // A `result_map` stop rests on the step left out, which is then named so.
2596
+ const bound = h.ran ?? (h.ranThisStep || h.stopReason === "result_map" ? h.position + 1 : h.position);
2597
+ const before = this.ranBefore(plan, bound, h.leftOut);
2598
+ const skipped = before.left.length > 0;
2599
+ const executed = skipped ? before.ran.length : bound;
2600
+ const executedAbove = h.executedAbove === undefined || !skipped
2601
+ ? h.executedAbove
2602
+ : before.ran.filter((n) => n <= h.executedAbove).length;
2301
2603
  // A step its host stopped was dealt with there — refused, answered, or
2302
2604
  // run with a person's arguments — so it is not among what is left to do.
2303
- const handled = h.ranThisStep === true || h.stopReason === "host_rejected" || h.stopReason === "host_edited";
2605
+ // Nor is the step a test left out, after which a sub-task's results could
2606
+ // not go back (`result_map`, AC5).
2607
+ const handled = h.ranThisStep === true ||
2608
+ h.stopReason === "host_rejected" ||
2609
+ h.stopReason === "host_edited" ||
2610
+ h.stopReason === "result_map";
2304
2611
  let params;
2305
2612
  try {
2306
2613
  params = plan?.parameters;
@@ -2327,13 +2634,30 @@ export class ReplayController {
2327
2634
  ownerScenarioId: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
2328
2635
  inSubTask: h.entry && h.entry.depth > 0 ? (h.entry.frame.callIntent ?? null) : null,
2329
2636
  executed: Math.max(0, Math.min(executed, plan?.totalSteps ?? executed)),
2330
- executedAbove: h.executedAbove,
2637
+ ...(skipped
2638
+ ? {
2639
+ ranSteps: before.ran,
2640
+ leftOut: before.left.map((l) => ({
2641
+ from: l.flatIndex + 1,
2642
+ to: Math.min(l.until, bound),
2643
+ toolName: l.toolName,
2644
+ text: l.text,
2645
+ })),
2646
+ }
2647
+ : {}),
2648
+ executedAbove,
2331
2649
  inFlight: h.inFlight,
2332
2650
  ranThisStep: h.ranThisStep,
2333
2651
  totalSteps: plan?.totalSteps ?? state.stepsPlanned,
2334
- remainingTools: plan ? plan.toolsFrom(handled ? h.position + 1 : h.position) : [],
2652
+ // A step that runs only under a test is marked so: the list must not
2653
+ // read as steps this request needs (AC5).
2654
+ remainingTools: plan ? plan.remainingFrom(handled ? h.position + 1 : h.position) : [],
2335
2655
  params,
2336
2656
  delivery: h.delivery,
2657
+ whenText: h.whenText ?? null,
2658
+ subTask: h.subTask ?? null,
2659
+ // "None was needed" is the note's own wording for the end of the plan.
2660
+ ...(h.noneNeeded ? { stepIndex: plan?.totalSteps ?? state.stepsPlanned } : {}),
2337
2661
  });
2338
2662
  }
2339
2663
  /**
@@ -2449,7 +2773,13 @@ export class ReplayController {
2449
2773
  if (!force && !this.recordedFallback)
2450
2774
  return undefined;
2451
2775
  const source = state.sourceRun;
2452
- return source ? (entry) => source.outputFor(entry) : undefined;
2776
+ if (!source)
2777
+ return undefined;
2778
+ // A step a test left out passes over its recorded output, unless a
2779
+ // correction added it (AC5, source-run.ts).
2780
+ return Object.assign((entry) => source.outputFor(entry), {
2781
+ leftOut: (entry) => source.leftOut(entry),
2782
+ });
2453
2783
  }
2454
2784
  /**
2455
2785
  * Wait for a plan's parameters within `budgetMs`, then judge whether the plan
@@ -2631,9 +2961,23 @@ export class ReplayController {
2631
2961
  // step of the same number — the very mix-up the payloads are there to end.
2632
2962
  const own = !info.entry || info.entry.depth === 0;
2633
2963
  // A step whose input logic threw never had an input: the `{}` the plan
2634
- // reports there is a placeholder, not a call with no arguments.
2635
- const sent = own && info.stage !== "tool_input_logic" ? info.input : undefined;
2636
- const received = own ? info.output : undefined;
2964
+ // reports there is a placeholder, not a call with no arguments. Nor had a
2965
+ // step its test left out or stopped in front of (AC5).
2966
+ const noCall = info.stage === "tool_input_logic" || info.stage === "when_logic" || status === "not_taken";
2967
+ const sent = own && !noCall ? info.input : undefined;
2968
+ const received = own && !noCall ? info.output : undefined;
2969
+ // The call, kept whole for the turn's newest run (AC2): only one that
2970
+ // really went out — it answered, or it answered with an error.
2971
+ if (state.keepCalls &&
2972
+ !noCall &&
2973
+ info.input !== undefined &&
2974
+ (status === "ok" || (status === "failed" && info.stage === "tool_call"))) {
2975
+ // By frame, not by scenario: a chain that calls the same sub-task twice
2976
+ // makes two calls of each of its steps, and both are the turn's (AC5
2977
+ // review). A frame is one call of one sub-task.
2978
+ const callKey = info.entry ? `${info.entry.frame.id}#${info.entry.stepIndex}` : key;
2979
+ this.keepCall(state, callKey, info, status === "failed");
2980
+ }
2637
2981
  state.stepResults.set(key, {
2638
2982
  stepIndex: info.step.stepIndex,
2639
2983
  toolName: info.step.toolName ?? "",
@@ -2654,6 +2998,42 @@ export class ReplayController {
2654
2998
  chainRevision: info.entry && info.entry.depth > 0 ? info.entry.frame.chainRevision : undefined,
2655
2999
  });
2656
3000
  }
3001
+ /**
3002
+ * Keep one call of the plan whole (AC2): its arguments and its answer,
3003
+ * redacted and capped as a recorded call's are. Later news about the same
3004
+ * step replaces earlier, in place, so the order is the order the calls went
3005
+ * out. Past {@link TURN_CALLS_MAX_CHARS} a call keeps only a report's cut.
3006
+ */
3007
+ keepCall(state, key, info, failed) {
3008
+ const calls = (state.turnCalls ??= new Map());
3009
+ if (calls.size >= MAX_STEP_RESULTS && !calls.has(key))
3010
+ return;
3011
+ const spent = state.turnCallChars ?? 0;
3012
+ const room = spent < TURN_CALLS_MAX_CHARS;
3013
+ const input = room
3014
+ ? serializeCapped(redact(info.input ?? {}))
3015
+ : reportPayload(serializeCapped(redact(info.input ?? {})), true);
3016
+ const answer = info.output === undefined ? undefined : room ? heldPayload(info.output) : reportPayload(info.output);
3017
+ const inSegment = info.entry && info.entry.depth > 0;
3018
+ const call = {
3019
+ scenarioId: inSegment ? info.entry.scenarioId : (state.scenarioId ?? info.entry?.scenarioId ?? ""),
3020
+ stepIndex: info.step.stepIndex,
3021
+ chainRevision: inSegment
3022
+ ? (info.entry.frame.chainRevision ?? null)
3023
+ : (state.chainRevision ?? null),
3024
+ toolName: info.step.toolName ?? "",
3025
+ input,
3026
+ ...(failed
3027
+ ? { error: info.error ? capText(redactText(info.error), DEFAULT_MAX_PAYLOAD_BYTES) : answer }
3028
+ : answer !== undefined
3029
+ ? { output: answer }
3030
+ : {}),
3031
+ };
3032
+ const before = calls.get(key);
3033
+ const size = (c) => c ? c.input.length + (c.output?.length ?? 0) + (c.error?.length ?? 0) : 0;
3034
+ state.turnCallChars = spent - size(before) + size(call);
3035
+ calls.set(key, call);
3036
+ }
2657
3037
  /** The step verdicts, in chain order, for the execution report. */
2658
3038
  stepResultsOf(state) {
2659
3039
  if (state.stepResults.size === 0)
@@ -2694,6 +3074,16 @@ export class ReplayController {
2694
3074
  });
2695
3075
  return;
2696
3076
  }
3077
+ if (info.outcome === "not_taken") {
3078
+ const text = testWords(info.step.when?.text);
3079
+ logLine("replay.step_not_taken", {
3080
+ n: info.step.stepIndex,
3081
+ tool: info.step.toolName ?? info.step.callIntent ?? undefined,
3082
+ scenario: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
3083
+ why: `its test said this request does not need it${text ? ` — only for when ${text}` : ""}; left out, and the plan goes on`,
3084
+ });
3085
+ return;
3086
+ }
2697
3087
  logLine("replay.step", {
2698
3088
  n: info.step.stepIndex,
2699
3089
  tool: mcp?.toolName ?? info.step.toolName,
@@ -2726,6 +3116,12 @@ export class ReplayController {
2726
3116
  }
2727
3117
  state.retired = true;
2728
3118
  state.pinned.clear();
3119
+ // A note naming a step to call next is void once the plan is: after a
3120
+ // divergence its bundle may have run that step already, and after a
3121
+ // hand-over it contradicts the hand-over's note. Only "the sequence is
3122
+ // complete", from a plan that finished, still holds (AC5 review).
3123
+ if (state.skipNote && !(completing && state.skipNote.complete))
3124
+ state.skipNote = undefined;
2729
3125
  // A call waiting for a pin to thread has nothing left to wait for.
2730
3126
  this.notifyPins(state);
2731
3127
  }
@@ -2849,6 +3245,47 @@ function reportPayload(json, clean = false) {
2849
3245
  }
2850
3246
  return capText(redactJsonText(json), REPORT_PAYLOAD_MAX_BYTES);
2851
3247
  }
3248
+ /**
3249
+ * A plan call's answer as a recorded call keeps it (autocorrect.md AC2): the
3250
+ * JSON redacted by its keys and capped at 64 KiB the way the control server
3251
+ * records every call — whole, not cut to what a report carries. Text that is
3252
+ * not JSON, or too long to scan, is redacted as text.
3253
+ */
3254
+ function heldPayload(text) {
3255
+ if (text.length <= REDACT_SCAN_MAX_CHARS) {
3256
+ try {
3257
+ return serializeCapped(redact(JSON.parse(text)));
3258
+ }
3259
+ catch {
3260
+ /* not JSON: kept as text */
3261
+ }
3262
+ }
3263
+ return capText(text, DEFAULT_MAX_PAYLOAD_BYTES, redactText);
3264
+ }
3265
+ /** Whether an entry runs only under a test (AC5): its own, or a sub-task's it is in. */
3266
+ function underTest(entry) {
3267
+ if (entry.step.when)
3268
+ return true;
3269
+ for (let f = entry.frame; f; f = f.parent)
3270
+ if (f.when)
3271
+ return true;
3272
+ return false;
3273
+ }
3274
+ /** The tests of a flat chain, by flat position, for the `plan.armed` line (AC5). */
3275
+ function testLines(entries) {
3276
+ // A sub-task's test sits where the sub-task begins — at its callee's first
3277
+ // step when its chain begins with a call.
3278
+ const { startsAt } = frameEdges(entries);
3279
+ const lines = entries
3280
+ .map((e, i) => {
3281
+ const own = e.step.when;
3282
+ const call = (startsAt.get(i) ?? []).find((f) => f.when)?.when;
3283
+ const t = own ?? call;
3284
+ return t ? `${i}:${call && !own ? "call/" : ""}${t.otherwise}` : undefined;
3285
+ })
3286
+ .filter((s) => s !== undefined);
3287
+ return lines.length > 0 ? lines.join(",") : undefined;
3288
+ }
2852
3289
  /**
2853
3290
  * The index a hand-over reports, in the *caller's* chain (R-CALL-32).
2854
3291
  *