@basein/runner 0.3.15 → 0.3.16

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.
@@ -66,7 +66,7 @@ const USAGE = {
66
66
  ' [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>] [--json]\n' +
67
67
  ' bir scenario edit <runId|scnId> --param <name> --kind target|setting [--note "why"] [--revision <n>] [--json]',
68
68
  edits: "usage: bir scenario edits <runId|scnId> [--json]",
69
- undo: 'usage: bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id> | --step <n> | --param <name>] [--force --note "why"] [--json]',
69
+ undo: 'usage: bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id|scor_id> | --step <n> | --param <name>] [--force --note "why"] [--json]',
70
70
  calc: "usage: bir scenario calc <runId|scnId> [--force [--discard-edits]]",
71
71
  editing: "usage: bir scenario editing on|off|status",
72
72
  };
@@ -594,13 +594,19 @@ function refusal(out, report, opening, hint) {
594
594
  }
595
595
  out(hint);
596
596
  }
597
- // ── edits / undo ──────────────────────────────────────────────────────────────
598
- /** The service's history: step edits, and — from a service that has them — kind changes. */
597
+ /**
598
+ * The service's history: step edits, and — from a service that has them —
599
+ * kind changes and corrections (autocorrect.md AC7). An older service sends
600
+ * neither list, and there is nothing of them to print.
601
+ */
599
602
  function historyOf(body) {
600
603
  const b = body;
601
604
  return {
602
605
  edits: Array.isArray(b?.edits) ? b.edits : [],
603
606
  paramEdits: Array.isArray(b?.paramEdits) ? b.paramEdits : [],
607
+ corrections: Array.isArray(b?.corrections)
608
+ ? b.corrections.filter((c) => typeof c?.id === "string" && typeof c?.createdAt === "string")
609
+ : [],
604
610
  };
605
611
  }
606
612
  async function fetchEdits(scenarioId, deps) {
@@ -628,15 +634,16 @@ async function listEdits(args, deps) {
628
634
  out(jsonText(reply.body));
629
635
  return 0;
630
636
  }
631
- const { edits, paramEdits } = historyOf(reply.body);
632
- if (edits.length === 0 && paramEdits.length === 0) {
637
+ const { edits, paramEdits, corrections } = historyOf(reply.body);
638
+ if (edits.length === 0 && paramEdits.length === 0 && corrections.length === 0) {
633
639
  out(`No hand edits of ${target}.`);
634
640
  return 0;
635
641
  }
636
642
  // Ids are printed whole: they are what `undo --edit` takes. The detail line
637
643
  // is indented rather than aligned under the date, which a 42-character id
638
644
  // would push past most terminals' width. One list, newest first: a step's
639
- // edits and a parameter's kind changes are walked back by the same `undo`.
645
+ // edits, a parameter's kind changes and the service's corrections of the
646
+ // plan are walked back by the same `undo`.
640
647
  const noteOf = (note) => (note ? ` "${note.replace(/\s+/g, " ")}"` : "");
641
648
  const rows = [
642
649
  ...edits.map((e) => ({
@@ -649,6 +656,11 @@ async function listEdits(args, deps) {
649
656
  line: `${p.id} ${when(p.createdAt)} param ${p.param} ${String(p.kind).padEnd(7)} revision ${p.chainRevisionBefore} → ${p.chainRevisionAfter}${noteOf(p.note)}`,
650
657
  detail: paramSummary(p),
651
658
  })),
659
+ ...corrections.map((c) => ({
660
+ at: c.createdAt,
661
+ line: `${c.id} ${when(c.createdAt)} correction ${String(c.state).padEnd(8)} revision ${c.chainRevisionBefore ?? "?"} → ${c.chainRevisionAfter ?? "—"}`,
662
+ detail: correctionSummary(c),
663
+ })),
652
664
  ].sort((x, y) => (x.at < y.at ? 1 : x.at > y.at ? -1 : 0));
653
665
  for (const r of rows) {
654
666
  out(r.line);
@@ -656,6 +668,62 @@ async function listEdits(args, deps) {
656
668
  }
657
669
  return 0;
658
670
  }
671
+ /** `[11,12,13,15]` → "11–13, 15": the step lists of a correction. */
672
+ export function stepRanges(steps) {
673
+ const sorted = [...new Set((steps ?? []).filter((n) => Number.isInteger(n)))].sort((a, b) => a - b);
674
+ const parts = [];
675
+ for (let i = 0; i < sorted.length;) {
676
+ let j = i;
677
+ while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
678
+ j += 1;
679
+ parts.push(j === i ? String(sorted[i]) : `${sorted[i]}–${sorted[j]}`);
680
+ i = j + 1;
681
+ }
682
+ return parts.join(", ");
683
+ }
684
+ /** The second line of a correction in `edits`: what it changed, from which run, and what became of it. */
685
+ export function correctionSummary(c) {
686
+ const parts = [];
687
+ if (c.summary)
688
+ parts.push(c.summary.replace(/\s+/g, " ").trim());
689
+ const changes = [];
690
+ const count = (steps) => steps?.length ?? 0;
691
+ if (count(c.added))
692
+ changes.push(`${count(c.added)} step${count(c.added) === 1 ? "" : "s"} added (${stepRanges(c.added)})`);
693
+ if (count(c.tested))
694
+ changes.push(`tests on step${count(c.tested) === 1 ? "" : "s"} ${stepRanges(c.tested)}`);
695
+ if (count(c.changed))
696
+ changes.push(`step${count(c.changed) === 1 ? "" : "s"} ${stepRanges(c.changed)} changed`);
697
+ if (c.answerChanged)
698
+ changes.push("the answer changed");
699
+ if (changes.length > 0)
700
+ parts.push(changes.join(", "));
701
+ if (c.redoneRunId)
702
+ parts.push(`from the redo ${c.redoneRunId}${c.verdictKind ? ` (${c.verdictKind})` : ""}`);
703
+ switch (c.state) {
704
+ case "proposed":
705
+ parts.push('shown, not used yet — "Use the correction" on the recording page uses it');
706
+ break;
707
+ case "used":
708
+ parts.push(`used${c.usedAt ? ` ${when(c.usedAt)}` : ""}`);
709
+ break;
710
+ case "failed": {
711
+ const first = c.problems?.[0];
712
+ parts.push(`not used: its check failed${first ? ` — ${first}` : ""}`);
713
+ break;
714
+ }
715
+ case "undone":
716
+ parts.push(`undone${c.undoneAt ? ` ${when(c.undoneAt)}` : ""}`);
717
+ break;
718
+ case "replaced":
719
+ parts.push("replaced by a recalculation");
720
+ break;
721
+ default:
722
+ if (c.state)
723
+ parts.push(String(c.state));
724
+ }
725
+ return parts.join("; ");
726
+ }
659
727
  /** The second line of a kind change in `edits`: the kinds, and what became of it. */
660
728
  function paramSummary(p) {
661
729
  const parts = [];
@@ -722,14 +790,29 @@ export function newestUndoableParam(paramEdits, param) {
722
790
  .sort((x, y) => (x.createdAt < y.createdAt ? 1 : x.createdAt > y.createdAt ? -1 : 0))
723
791
  .find((e) => e.kind !== "revert" && !e.revertedAt && !e.replacedAt && (param === undefined || e.param === param));
724
792
  }
793
+ /**
794
+ * The correction `undo` without `--edit` takes back, when it is the newest
795
+ * change of all: one in use, not undone. A plan-wide change, so never one
796
+ * when `--step` or `--param` narrows the undo to a step or a parameter.
797
+ */
798
+ export function newestUndoableCorrection(corrections) {
799
+ return [...corrections]
800
+ .filter((c) => c.state === "used" && !c.undoneAt)
801
+ .sort((x, y) => {
802
+ const a = x.usedAt ?? x.createdAt;
803
+ const b = y.usedAt ?? y.createdAt;
804
+ return a < b ? 1 : a > b ? -1 : 0;
805
+ })[0];
806
+ }
725
807
  const isParamEditId = (id) => id.startsWith("pedit_");
808
+ const isCorrectionId = (id) => id.startsWith("scor_");
726
809
  async function undo(args, deps) {
727
810
  const { out } = deps;
728
811
  const target = args.positionals[1];
729
812
  if (!target)
730
813
  return usageError(deps, "undo");
731
- if (args.edit !== undefined && !/^(sedit|pedit)_[\w-]+$/.test(args.edit)) {
732
- return usageError(deps, "undo", `--edit takes a sedit_… or pedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
814
+ if (args.edit !== undefined && !/^(sedit|pedit|scor)_[\w-]+$/.test(args.edit)) {
815
+ return usageError(deps, "undo", `--edit takes a sedit_…, pedit_… or scor_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
733
816
  }
734
817
  const picks = [args.edit, args.step, args.param].filter((v) => v !== undefined).length;
735
818
  if (picks > 1) {
@@ -754,11 +837,18 @@ async function undo(args, deps) {
754
837
  const listed = await fetchEdits(resolved.id, deps);
755
838
  if ("reply" in listed)
756
839
  return explain(listed.reply, args, deps, { verb: "Could not read the history", target });
757
- // Undo walks back across both lists (D9): the newest change of either
758
- // kind goes first, unless --step or --param narrows it to one.
840
+ // Undo walks back across every list (D9): the newest change of any kind
841
+ // goes first — a correction in use too, which is a change of the whole
842
+ // plan (autocorrect.md AC7) — unless --step or --param narrows it to one.
759
843
  const stepPick = args.param === undefined ? newestUndoable(listed.edits, args.step) : undefined;
760
844
  const paramPick = args.step === undefined ? newestUndoableParam(listed.paramEdits, args.param) : undefined;
761
- const pick = !stepPick ? paramPick : !paramPick ? stepPick : paramPick.createdAt > stepPick.createdAt ? paramPick : stepPick;
845
+ const correctionPick = args.step === undefined && args.param === undefined ? newestUndoableCorrection(listed.corrections) : undefined;
846
+ const candidates = [
847
+ ...(stepPick ? [{ id: stepPick.id, at: stepPick.createdAt }] : []),
848
+ ...(paramPick ? [{ id: paramPick.id, at: paramPick.createdAt }] : []),
849
+ ...(correctionPick ? [{ id: correctionPick.id, at: correctionPick.usedAt ?? correctionPick.createdAt }] : []),
850
+ ];
851
+ const pick = candidates.sort((x, y) => (x.at < y.at ? 1 : x.at > y.at ? -1 : 0))[0];
762
852
  if (!pick) {
763
853
  return localFailure(args, deps, "nothing_to_undo", args.step !== undefined
764
854
  ? `Nothing to undo: step ${args.step} of ${target} has no hand edit that can be undone.`
@@ -793,6 +883,8 @@ async function undo(args, deps) {
793
883
  }
794
884
  if (isParamEditId(editId))
795
885
  return paramUndone(reply.body, editId, target, args, deps);
886
+ if (isCorrectionId(editId))
887
+ return correctionUndone(reply.body, editId, target, deps);
796
888
  if (answer?.report)
797
889
  renderReport(out, answer.report, { brief: true, undo: true });
798
890
  if (refused && answer?.report) {
@@ -820,6 +912,20 @@ async function undo(args, deps) {
820
912
  }
821
913
  return 0;
822
914
  }
915
+ /**
916
+ * A correction taken back: the plan is as it was before it, exactly — and
917
+ * held, because that plan was the one the agent rejected. Said so, with the
918
+ * way to let it go.
919
+ */
920
+ function correctionUndone(answer, editId, target, deps) {
921
+ const revision = answer?.chainRevision ?? answer?.correction?.chainRevisionAfter;
922
+ deps.out(`The plan is back as it was before correction ${answer?.correction?.id ?? editId} (revision ${revision ?? "?"}).`);
923
+ if (answer?.held) {
924
+ deps.out(` It is held${answer.held.reason ? ` (${answer.held.reason})` : ""}: the plan from before is the one the agent rejected, so it is not used — the agent does the task — until it is corrected again or someone presses "Use it again" on the recording page.`);
925
+ }
926
+ deps.out(` One further back: bir scenario undo ${target}`);
927
+ return 0;
928
+ }
823
929
  /**
824
930
  * A kind change undone. Nothing ran, so there is no report: the parameter's
825
931
  * kind as it is now, what that means for the next prompt, and the way on —
@@ -884,8 +990,14 @@ async function calc(args, deps) {
884
990
  if (status === 409 && code === "scenario_has_edits") {
885
991
  const steps = body.editedSteps;
886
992
  const list = Array.isArray(steps) ? steps.map(String) : [];
993
+ // A correction the service made from a redo is protected the same way
994
+ // (autocorrect.md AC6): a recalculation from the recording would bring
995
+ // back the one-half plan it fixed.
996
+ const correction = body.correctionId;
887
997
  const which = list.length === 0 ? "some steps were" : list.length === 1 ? `step ${list[0]} was` : `steps ${list.join(", ")} were`;
888
- out(`Not recalculated: ${which} edited by hand, and a recalculation rebuilds every step.`);
998
+ out(typeof correction === "string"
999
+ ? `Not recalculated: the plan carries correction ${correction}, and a recalculation from the recording would throw it away.`
1000
+ : `Not recalculated: ${which} edited by hand, and a recalculation rebuilds every step.`);
889
1001
  out(` To recalculate anyway and discard the edits: bir scenario calc ${target} --force --discard-edits`);
890
1002
  out(" (the edits stay in `bir scenario edits`, marked replaced)");
891
1003
  return 1;
@@ -1057,6 +1169,8 @@ function explain(reply, args, deps, e) {
1057
1169
  const stepFlag = e.step !== undefined ? ` --step ${e.step}` : "";
1058
1170
  // A kind change (findoffer.md) is about a parameter, and nothing in it is checked.
1059
1171
  const aboutParam = e.param !== undefined || (e.editId?.startsWith("pedit_") ?? false);
1172
+ // A correction (autocorrect.md AC7) is about the whole plan.
1173
+ const aboutCorrection = e.editId?.startsWith("scor_") ?? false;
1060
1174
  if (reply.status === 404) {
1061
1175
  out(e.notFound ??
1062
1176
  `${e.verb} (not_found): no such scenario, step or edit — or it is not yours. Only a scenario's owner can see or change it.`);
@@ -1110,13 +1224,19 @@ function explain(reply, args, deps, e) {
1110
1224
  // newer entry puts the step back as this one left it, and then this one
1111
1225
  // can be undone. It used to loop, when undo only toggled (C23).
1112
1226
  const latest = typeof body.latestEditId === "string" ? body.latestEditId : "<the newer edit>";
1113
- out(`${e.verb} (not_latest_edit): the ${aboutParam ? "parameter" : "step"} has changed since that edit — ${latest} changed it after. Undo the newer one first, then this one:`);
1227
+ out(aboutCorrection
1228
+ ? `${e.verb} (not_latest_edit): the plan has changed since that correction — ${latest} changed it after. Undo the newer one first, then this one:`
1229
+ : `${e.verb} (not_latest_edit): the ${aboutParam ? "parameter" : "step"} has changed since that edit — ${latest} changed it after. Undo the newer one first, then this one:`);
1114
1230
  out(` bir scenario undo ${target} --edit ${latest}`);
1115
1231
  out(` bir scenario undo ${target} --edit ${e.editId ?? "<this edit>"}`);
1116
1232
  return 1;
1117
1233
  }
1118
1234
  case "already_undone": {
1119
1235
  const by = typeof body.revertedById === "string" ? body.revertedById : undefined;
1236
+ if (aboutCorrection) {
1237
+ out(`${e.verb} (already_undone): ${e.editId ?? "that correction"} was taken back already; \`bir scenario edits ${target}\` lists what the plan has now.`);
1238
+ return 1;
1239
+ }
1120
1240
  out(`${e.verb} (already_undone): ${e.editId ?? "that edit"} was undone already${by ? `, by ${by}` : ""}. To redo it, undo that undo:`);
1121
1241
  out(` bir scenario undo ${target} --edit ${by ?? "<the undo's sedit_ id, from bir scenario edits>"}`);
1122
1242
  return 1;
@@ -1124,7 +1244,7 @@ function explain(reply, args, deps, e) {
1124
1244
  case "edit_replaced":
1125
1245
  out(aboutParam
1126
1246
  ? `${e.verb} (edit_replaced): a recalculation dropped that parameter after the change. It stays in \`bir scenario edits\`, but there is nothing left to undo.`
1127
- : `${e.verb} (edit_replaced): a recalculation replaced the plan after that edit. It stays in \`bir scenario edits\`, but there is nothing left to undo.`);
1247
+ : `${e.verb} (edit_replaced): a recalculation replaced the plan after that ${aboutCorrection ? "correction" : "edit"}. It stays in \`bir scenario edits\`, but there is nothing left to undo.`);
1128
1248
  return 1;
1129
1249
  case "rate_limited": {
1130
1250
  const ms = typeof body.retryAfterMs === "number" ? body.retryAfterMs : undefined;
@@ -1107,6 +1107,22 @@ export declare class ControlServer {
1107
1107
  * answered `id: null` — the service records no repeat of it — drops them.
1108
1108
  */
1109
1109
  private openRepeat;
1110
+ /**
1111
+ * Record the prompt plan's own calls of this turn into the `redone` repeat
1112
+ * `id`, in the order they went out, before the agent's first call of its
1113
+ * own (the service's autocorrect.md AC2). Each is a pair, both rows marked
1114
+ * `byPlan` with the plan, its step and its chain revision, so the service
1115
+ * stores them without a vector and counts none of them as the agent's.
1116
+ * Payloads are whole — redacted and capped as any recorded call's — where
1117
+ * the report has only 3,800 bytes of each. Returns how many it recorded.
1118
+ */
1119
+ private recordPlanCalls;
1120
+ /**
1121
+ * One tool call of the model's own was recorded into `run`: counted for
1122
+ * the plan's report when it is the `redone` repeat (AC1). A step a plan
1123
+ * pinned is the plan's doing, not the model's, and is not counted.
1124
+ */
1125
+ private noteOwnCall;
1110
1126
  /**
1111
1127
  * Why the matched turn's plan did not run, as the service shows it: what
1112
1128
  * the service withheld it for, else the code of the gate that declined it
@@ -1811,6 +1811,11 @@ export class ControlServer {
1811
1811
  // the jail it may not leave (replay/builtins.ts). The session's own cwd
1812
1812
  // from the hook payloads, never the daemon's.
1813
1813
  state.sessionCwd = session.cwd ?? this.opts.cwd;
1814
+ // The service offered to record this turn as the recording's newest run:
1815
+ // should the agent redo the task after the plan, the plan's own calls go
1816
+ // into that run first, so they are kept whole as they run (AC2).
1817
+ if (match.repeat?.runId)
1818
+ state.keepCalls = true;
1814
1819
  this.declineWithoutDeliveryTool(state);
1815
1820
  // A steer plan with a shell or file-write step can only be followed if the
1816
1821
  // directive shows its computed arguments (the pin guard holds those steps
@@ -2290,6 +2295,13 @@ export class ControlServer {
2290
2295
  // including when it declined to steer, which is a *baseline* sample and is
2291
2296
  // what every saving is measured against (docs/calculatedReplay.md §11).
2292
2297
  this.reportExecution(session, run, durationMs, cost, cutShort);
2298
+ // The plan's calls kept for a redo went into the repeat when it opened
2299
+ // (AC2); nothing reads them after the turn, and they can run to megabytes
2300
+ // on a sealed run the session keeps as its previous one.
2301
+ for (const s of run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : []) {
2302
+ s.turnCalls = undefined;
2303
+ s.turnCallChars = 0;
2304
+ }
2293
2305
  // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
2294
2306
  // and duration are the model's own work, not the steps the scenario did.
2295
2307
  // So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
@@ -2447,6 +2459,14 @@ export class ControlServer {
2447
2459
  // Another host hands over one number for the whole run. It cannot be
2448
2460
  // split between two plans, so with more than one neither is measured.
2449
2461
  const unsplit = otherHost(session) && states.length > 1;
2462
+ // The plan ran to its end and the agent redid the task in a repeat the
2463
+ // service took (newestRun.md N7): the prompt plan's report says so, with
2464
+ // the repeat's id and the agent's own calls in it (autocorrect.md AC1).
2465
+ const r = run.repeat;
2466
+ const redoneBy = r?.state === "open" && r.reason === "redone" && r.openedRunId
2467
+ ? { runId: r.openedRunId, ownCalls: r.ownCalls ?? 0 }
2468
+ : undefined;
2469
+ const prompt = states.find((s) => s.armedBy === "prompt");
2450
2470
  // One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
2451
2471
  // for its own window of the transcript — from when it armed to when the next
2452
2472
  // one did — so the turn's tokens are split between them, never counted twice.
@@ -2484,6 +2504,7 @@ export class ControlServer {
2484
2504
  siblings: states,
2485
2505
  runId: run.runId,
2486
2506
  cutShort: cutShort || undefined,
2507
+ redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
2487
2508
  });
2488
2509
  if (!report)
2489
2510
  return;
@@ -2894,6 +2915,7 @@ export class ControlServer {
2894
2915
  if (!steering.retired) {
2895
2916
  steering.pinned.clear();
2896
2917
  steering.retired = true;
2918
+ steering.skipNote = undefined;
2897
2919
  logLine("replay.pin_overran", {
2898
2920
  scenario: steering.scenarioId ?? undefined,
2899
2921
  tool: toolName,
@@ -2915,9 +2937,25 @@ export class ControlServer {
2915
2937
  if (verdict.ok) {
2916
2938
  pinned = action.input;
2917
2939
  this.declinedPins.delete(steering);
2940
+ this.replay.pinTaken(steering);
2918
2941
  }
2919
2942
  else {
2920
2943
  steering.pinned.delete(toolUseId);
2944
+ // Not the next step — and perhaps the step a test just left out,
2945
+ // which shares its tool, made because the note saying so had not
2946
+ // reached the model yet (AC5). The note goes in its place: a step
2947
+ // left out never runs because the note was late. The plan stays
2948
+ // armed for the step that is next.
2949
+ const skipped = this.replay.skipNoteFor(steering, toolName);
2950
+ if (skipped) {
2951
+ logLine("replay.pin_declined", {
2952
+ scenario: steering.scenarioId ?? undefined,
2953
+ tool: toolName,
2954
+ step: action.stepIndex,
2955
+ why: "a call to the step a test left out — the note saying so is given in its place",
2956
+ });
2957
+ return this.preToolAnswer(this.replay.deliverInstead(skipped, toolName));
2958
+ }
2921
2959
  this.declinedPins.add(steering);
2922
2960
  logLine("replay.pin_declined", {
2923
2961
  scenario: steering.scenarioId ?? undefined,
@@ -2938,6 +2976,13 @@ export class ControlServer {
2938
2976
  // a plan nobody would ever hear of.
2939
2977
  if (!overran && !pinned && (!run.replay?.plan || run.replay.retired)) {
2940
2978
  // ── falling back to the model (fallbk.md) ──────────────────────────────
2979
+ // 0. A call to a step a test left out (AC5), when the note saying so has
2980
+ // not reached the model yet — the plan may have finished on it. The
2981
+ // note goes in its place: a step left out must never run because the
2982
+ // note was late.
2983
+ const skipped = planning ? undefined : this.replay.skipNoteFor(run.replay, toolName);
2984
+ if (skipped)
2985
+ return this.preToolAnswer(this.replay.deliverInstead(skipped, toolName));
2941
2986
  // 1. A hand-over noticed where no hook answer could carry it — a proxy
2942
2987
  // report threaded after its PostToolUse had already answered — reaches
2943
2988
  // the model in place of this call. Never silence (D4).
@@ -3071,6 +3116,7 @@ export class ControlServer {
3071
3116
  ? (run.replay?.scenarioId ?? undefined)
3072
3117
  : undefined;
3073
3118
  if (run.recording) {
3119
+ this.noteOwnCall(run, pinnedBy);
3074
3120
  this.queue.push(() => {
3075
3121
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3076
3122
  toolName,
@@ -3302,6 +3348,7 @@ export class ControlServer {
3302
3348
  toolName,
3303
3349
  });
3304
3350
  if (run.recording) {
3351
+ this.noteOwnCall(run);
3305
3352
  const toolInput = serializeCapped(redact(modelArgs));
3306
3353
  this.queue.push(() => {
3307
3354
  this.recorder.recordToolSelected(run.runId, pair.selected, {
@@ -3360,6 +3407,7 @@ export class ControlServer {
3360
3407
  state.retired = true;
3361
3408
  state.retiredAt = Date.now();
3362
3409
  state.pinned.clear();
3410
+ state.skipNote = undefined;
3363
3411
  this.directCalls = this.directCalls.filter((c) => c.state !== state);
3364
3412
  logLine("replay.vetoed", {
3365
3413
  run: run.runId,
@@ -3390,6 +3438,8 @@ export class ControlServer {
3390
3438
  state.retired = true;
3391
3439
  state.retiredAt = Date.now();
3392
3440
  state.pinned.clear();
3441
+ // A note naming a step to call next is void with the plan (AC5 review).
3442
+ state.skipNote = undefined;
3393
3443
  run.handback = HANDBACK_NOTE;
3394
3444
  logLine("replay.pin_abandoned", {
3395
3445
  run: run.runId,
@@ -4099,6 +4149,13 @@ export class ControlServer {
4099
4149
  run.ordering = new StepIndexAllocator();
4100
4150
  run.recording = true;
4101
4151
  r.state = "open";
4152
+ r.openedRunId = id;
4153
+ r.reason = reason;
4154
+ r.ownCalls = 0;
4155
+ // The plan's own calls of this turn go in first, whole and marked, so the
4156
+ // newest run holds the whole turn: the agent's work is built on what the
4157
+ // plan's calls returned (the service's autocorrect.md AC2).
4158
+ const planCalls = redone ? this.recordPlanCalls(run, id) : 0;
4102
4159
  if (redone) {
4103
4160
  // The plan's steps are not the model's work: this run is billed and
4104
4161
  // timed from its own first call on, as a fragment is.
@@ -4109,6 +4166,7 @@ export class ControlServer {
4109
4166
  run: id,
4110
4167
  of: r.runId,
4111
4168
  reason,
4169
+ planCalls: planCalls || undefined,
4112
4170
  why: redone
4113
4171
  ? "the plan ran to its end, and the agent went on with calls of its own instead of answering from its results — recorded as that recording's newest run"
4114
4172
  : "the agent is doing a recorded task itself — recorded as that recording's newest run",
@@ -4119,10 +4177,15 @@ export class ControlServer {
4119
4177
  .runCreated(id)
4120
4178
  .catch(() => ({ created: true }))
4121
4179
  .then((created) => {
4122
- if (created.created || run.runId !== id)
4180
+ if (created.created)
4123
4181
  return;
4124
- run.recording = false;
4182
+ // Refused: never named as the turn's redo (the service's
4183
+ // autocorrect.md AC1) — also when the turn has moved on to another
4184
+ // run since, which used to leave the repeat `open` and named.
4125
4185
  r.state = "refused";
4186
+ if (run.runId !== id)
4187
+ return;
4188
+ run.recording = false;
4126
4189
  logLine("run.repeat_refused", {
4127
4190
  run: id,
4128
4191
  of: r.runId,
@@ -4130,6 +4193,54 @@ export class ControlServer {
4130
4193
  });
4131
4194
  });
4132
4195
  }
4196
+ /**
4197
+ * Record the prompt plan's own calls of this turn into the `redone` repeat
4198
+ * `id`, in the order they went out, before the agent's first call of its
4199
+ * own (the service's autocorrect.md AC2). Each is a pair, both rows marked
4200
+ * `byPlan` with the plan, its step and its chain revision, so the service
4201
+ * stores them without a vector and counts none of them as the agent's.
4202
+ * Payloads are whole — redacted and capped as any recorded call's — where
4203
+ * the report has only 3,800 bytes of each. Returns how many it recorded.
4204
+ */
4205
+ recordPlanCalls(run, id) {
4206
+ const prompt = run.replays.find((s) => s.armedBy === "prompt");
4207
+ const calls = this.replay.turnCallsOf(prompt);
4208
+ for (const call of calls) {
4209
+ const pair = run.ordering.allocatePair();
4210
+ const byPlan = {
4211
+ scenarioId: call.scenarioId,
4212
+ stepIndex: call.stepIndex,
4213
+ // Left out, never null, when the plan was served without one: the
4214
+ // service's row schema takes a number or nothing.
4215
+ ...(call.chainRevision !== null ? { chainRevision: call.chainRevision } : {}),
4216
+ };
4217
+ this.queue.push(() => {
4218
+ this.recorder.recordToolSelected(id, pair.selected, {
4219
+ toolName: call.toolName,
4220
+ toolInput: call.input,
4221
+ metadata: { byPlan },
4222
+ });
4223
+ this.recorder.recordToolResponse(id, pair.response, {
4224
+ toolName: call.toolName,
4225
+ toolOutput: call.error === undefined ? call.output : undefined,
4226
+ toolError: call.error,
4227
+ metadata: { byPlan },
4228
+ });
4229
+ });
4230
+ }
4231
+ return calls.length;
4232
+ }
4233
+ /**
4234
+ * One tool call of the model's own was recorded into `run`: counted for
4235
+ * the plan's report when it is the `redone` repeat (AC1). A step a plan
4236
+ * pinned is the plan's doing, not the model's, and is not counted.
4237
+ */
4238
+ noteOwnCall(run, pinnedBy) {
4239
+ const r = run.repeat;
4240
+ if (pinnedBy || !r || r.state !== "open" || run.runId !== r.openedRunId)
4241
+ return;
4242
+ r.ownCalls = (r.ownCalls ?? 0) + 1;
4243
+ }
4133
4244
  /**
4134
4245
  * Why the matched turn's plan did not run, as the service shows it: what
4135
4246
  * the service withheld it for, else the code of the gate that declined it
@@ -4175,6 +4286,11 @@ export class ControlServer {
4175
4286
  const note = this.replay.takeNote(run.replay);
4176
4287
  if (note)
4177
4288
  context.push(note);
4289
+ // The steps a test just left out (AC5): said before the model makes its
4290
+ // next call, which must not be one of them.
4291
+ const skipped = this.replay.takeSkipNote(run.replay);
4292
+ if (skipped)
4293
+ context.push(skipped);
4178
4294
  if (run.handback) {
4179
4295
  context.push(run.handback);
4180
4296
  run.handback = undefined;
@@ -5150,6 +5266,7 @@ export class ControlServer {
5150
5266
  record(run, pair, d) {
5151
5267
  if (!run.recording)
5152
5268
  return;
5269
+ this.noteOwnCall(run, d.pinnedBy);
5153
5270
  const toolInput = serializeCapped(redact(d.args));
5154
5271
  const toolOutput = d.isError ? undefined : serializeCapped(redact(d.result));
5155
5272
  const toolError = d.isError
@@ -61,6 +61,27 @@ export interface FlushOptions {
61
61
  */
62
62
  timeoutMs?: number;
63
63
  }
64
+ /**
65
+ * A plan's own call recorded into the turn's newest run (the service's
66
+ * autocorrect.md AC2): which plan made it, which of its steps, and the chain
67
+ * revision it was served at — left out when the service served none, since
68
+ * its schema takes a number or nothing, never null.
69
+ */
70
+ export interface PlanCallMark {
71
+ scenarioId: string;
72
+ stepIndex: number;
73
+ chainRevision?: number;
74
+ }
75
+ /**
76
+ * A recorded step's metadata. `pinnedBy`: a plan executed this step live in
77
+ * the turn (R-INTENT-7). `byPlan`: the plan made this call before the agent
78
+ * redid the task, and it is recorded first into the agent's repeat so the
79
+ * repeat holds the whole turn (AC2). The service stores neither with a vector.
80
+ */
81
+ export type StepMetadata = {
82
+ pinnedBy?: string;
83
+ byPlan?: PlanCallMark;
84
+ };
64
85
  export interface Recorder {
65
86
  /**
66
87
  * Create the run; returns the client-generated runId synchronously.
@@ -86,17 +107,18 @@ export interface Recorder {
86
107
  };
87
108
  /**
88
109
  * Set when a plan executed this step, to the plan's scenario id
89
- * (R-INTENT-7). The service stores such a step without a vector, so the
110
+ * (R-INTENT-7), or made it before the agent redid the task (`byPlan`,
111
+ * AC2). The service stores such a step without a vector, so the
90
112
  * system's own replays never count as recurrences of the work.
91
113
  */
92
- metadata?: {
93
- pinnedBy: string;
94
- };
114
+ metadata?: StepMetadata;
95
115
  }): string;
96
116
  recordToolResponse(runId: string, stepIndex: number, d: {
97
117
  toolName: string;
98
118
  toolOutput?: string;
99
119
  toolError?: string;
120
+ /** `byPlan` on the answer of a plan's own call (AC2); omitted otherwise. */
121
+ metadata?: StepMetadata;
100
122
  }): string;
101
123
  recordFinalAnswer(runId: string, stepIndex: number, d: {
102
124
  answer: string;
@@ -417,6 +439,19 @@ export interface ExecutionReport {
417
439
  * person reading the audit log, this is for the console.
418
440
  */
419
441
  declined?: string;
442
+ /**
443
+ * The plan ran to its end and the agent did not take its result: it went on
444
+ * with calls of its own, recorded as the recording's newest run with the
445
+ * reason `redone` (the service's newestRun.md N7, autocorrect.md AC1).
446
+ * `runId` is that repeat's own id; `ownCalls` the agent's own tool calls
447
+ * recorded into it — not the plan's calls recorded first (AC2), and not a
448
+ * step a plan pinned. Only on the report of the prompt's plan, and only when
449
+ * the service took the repeat; an older service drops the field.
450
+ */
451
+ redoneBy?: {
452
+ runId: string;
453
+ ownCalls: number;
454
+ };
420
455
  }
421
456
  /** Optional capability: reporting needs a service, and a NullRecorder has none. */
422
457
  export interface ScenarioReporter {
@@ -50,7 +50,7 @@
50
50
  */
51
51
  import { type AuthSession } from "../auth/client.js";
52
52
  import { type ServiceHealth } from "../util/service-health.js";
53
- import type { ExecutionReport, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions } from "./recorder.js";
53
+ import type { ExecutionReport, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions, StepMetadata } from "./recorder.js";
54
54
  /**
55
55
  * Caps for the free text this recorder sends, in UTF-8 bytes. Tool payloads
56
56
  * arrive capped at 64 KiB by their producers; text never passed through one,
@@ -225,14 +225,13 @@ export declare class RemoteRecorder implements Recorder, RecorderHealthReporter
225
225
  text: string;
226
226
  source: "thinking";
227
227
  };
228
- metadata?: {
229
- pinnedBy: string;
230
- };
228
+ metadata?: StepMetadata;
231
229
  }): string;
232
230
  recordToolResponse(runId: string, stepIndex: number, data: {
233
231
  toolName: string;
234
232
  toolOutput?: string;
235
233
  toolError?: string;
234
+ metadata?: StepMetadata;
236
235
  }): string;
237
236
  recordFinalAnswer(runId: string, stepIndex: number, data: {
238
237
  answer: string;