@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.
- package/README.md +7 -0
- package/dist/bin/bir-scenario.js +8 -5
- package/dist/bin/bir.js +2 -1
- package/dist/bin/investigate.d.ts +71 -0
- package/dist/bin/investigate.js +145 -13
- package/dist/bin/scenario-edit.d.ts +45 -1
- package/dist/bin/scenario-edit.js +134 -14
- package/dist/control/server.d.ts +16 -0
- package/dist/control/server.js +119 -2
- package/dist/record/recorder.d.ts +39 -4
- package/dist/record/remote-recorder.d.ts +3 -4
- package/dist/record/remote-recorder.js +16 -0
- package/dist/record/switchable-recorder.d.ts +1 -5
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +23 -2
- package/dist/replay/controller.d.ts +133 -0
- package/dist/replay/controller.js +496 -59
- package/dist/replay/flatten.d.ts +39 -3
- package/dist/replay/flatten.js +42 -2
- package/dist/replay/handover.d.ts +39 -1
- package/dist/replay/handover.js +81 -2
- package/dist/replay/logic.d.ts +8 -0
- package/dist/replay/logic.js +19 -0
- package/dist/replay/plan.d.ts +165 -4
- package/dist/replay/plan.js +488 -25
- package/dist/replay/source-run.d.ts +14 -1
- package/dist/replay/source-run.js +25 -2
- package/dist/replay/targets.js +8 -1
- package/dist/replay/types.d.ts +52 -3
- package/dist/replay/types.js +14 -0
- package/dist/util/service-headers.d.ts +7 -1
- package/dist/util/service-headers.js +7 -1
- package/docs/BaseInstRunner.md +3 -1
- package/docs/calculatedReplay.md +117 -6
- package/docs/calculatedReplayGuide.md +41 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
598
|
-
|
|
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
|
|
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
|
|
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
|
|
758
|
-
//
|
|
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
|
|
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(
|
|
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(
|
|
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;
|
package/dist/control/server.d.ts
CHANGED
|
@@ -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
|
package/dist/control/server.js
CHANGED
|
@@ -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
|
|
4180
|
+
if (created.created)
|
|
4123
4181
|
return;
|
|
4124
|
-
|
|
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)
|
|
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;
|