@basein/runner 0.3.14 → 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;
@@ -1075,23 +1075,54 @@ export declare class ControlServer {
1075
1075
  private applyFragmentCreation;
1076
1076
  /**
1077
1077
  * Whether this matched turn's task is now the model's own, so its work is
1078
- * the repeat the service offered to record (the service's newestRun.md N1):
1079
- * offered, nothing recording, no fragment, and no plan of the prompt match
1080
- * that ran a step or still may. A plan that handed over is the fragment's to
1081
- * record (fallbk.md D7).
1078
+ * the repeat the service offered to record: offered, nothing recording, no
1079
+ * fragment, and the prompt match's plan done with — retired, not running,
1080
+ * not diverging — and either:
1081
+ *
1082
+ * - it ran no step: the model does the whole task (newestRun.md N1); or
1083
+ * - it ran to its end, and the model, told to answer from its results,
1084
+ * called tools of its own instead (N7). Before 0.3.15 that work was
1085
+ * lost: a model that did not take the plan's result as the answer and
1086
+ * did the task again left nothing to fix the plan from.
1087
+ *
1088
+ * A plan that handed over is the fragment's to record (fallbk.md D7). One
1089
+ * that ran part of the task and stopped without a hand-over records
1090
+ * nothing, as before: the call where it stopped ran while it was still
1091
+ * live, so a run of what came after would lack the step that matters.
1082
1092
  */
1083
1093
  private repeatDue;
1084
1094
  /**
1085
- * Open the repeat run and record from this call on (newestRun.md N1).
1095
+ * Whether the prompt match's plan ran to its end before the model went on
1096
+ * by itself (newestRun.md N7). False when it ran no step, which is N1.
1097
+ */
1098
+ private redoneAfterPlan;
1099
+ /**
1100
+ * Open the repeat run and record from this call on (newestRun.md N1, N7).
1086
1101
  *
1087
1102
  * The same swap as {@link openFragment}: nothing allocated before it belongs
1088
- * to it. Unlike a fragment it carries the turn's prompt, and its cost and
1089
- * duration are the whole turn's, because no plan did any of the task. Nothing
1090
- * waits for the service's answer: the steps queue behind the create, and a
1091
- * create answered `id: null` — the service records no repeat of it — drops
1092
- * them.
1103
+ * to it. Unlike a fragment it carries the turn's prompt. When no plan did
1104
+ * any of the task, its cost and duration are the whole turn's; after a plan
1105
+ * that ran to its end, they start here, as a fragment's do. Nothing waits
1106
+ * for the service's answer: the steps queue behind the create, and a create
1107
+ * answered `id: null` — the service records no repeat of it — drops them.
1093
1108
  */
1094
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;
1095
1126
  /**
1096
1127
  * Why the matched turn's plan did not run, as the service shows it: what
1097
1128
  * the service withheld it for, else the code of the gate that declined it