@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.
@@ -17,7 +17,9 @@
17
17
  * The queue semantics deliberately match the server's own dry replay
18
18
  * (`recordedOutputsByTool` in `scenarios/replay.ts`): a FIFO per tool name, drawn
19
19
  * in order, with a per-tool cursor. A scenario that behaved one way under
20
- * `bir scenario replay --dry` and another way here would be untestable.
20
+ * `bir scenario replay --dry` and another way here would be untestable. A step
21
+ * a test leaves out passes over its own recorded output — unless a correction
22
+ * added it, and it made no call in the recording ({@link SourceRunCache.leftOut}).
21
23
  *
22
24
  * Fetched **lazily** — only when a step actually needs it — and cached for the
23
25
  * turn, so a fully-executable replay never makes this call at all.
@@ -63,6 +65,17 @@ export declare class SourceRunCache {
63
65
  * skipped, which the caller already handles.
64
66
  */
65
67
  outputFor(entry: FlatEntry): Promise<string | undefined>;
68
+ /**
69
+ * A step its test left out (the service's autocorrect.md AC5). Every step
70
+ * of a plan was in the recording, and made its call there — unless a
71
+ * correction added it from the agent's redo (`correctionKind: "added"`),
72
+ * which made none. So one that was in it passes over its recorded output,
73
+ * and the next step of its tool takes its own; an added one passes over
74
+ * nothing. Nothing is fetched: the cursor is a count of this frame's calls
75
+ * of the tool, whichever of them are read later (docs/calculatedReplay.md
76
+ * §9.2).
77
+ */
78
+ leftOut(entry: FlatEntry): void;
66
79
  private load;
67
80
  private fetchRun;
68
81
  }
@@ -17,7 +17,9 @@
17
17
  * The queue semantics deliberately match the server's own dry replay
18
18
  * (`recordedOutputsByTool` in `scenarios/replay.ts`): a FIFO per tool name, drawn
19
19
  * in order, with a per-tool cursor. A scenario that behaved one way under
20
- * `bir scenario replay --dry` and another way here would be untestable.
20
+ * `bir scenario replay --dry` and another way here would be untestable. A step
21
+ * a test leaves out passes over its own recorded output — unless a correction
22
+ * added it, and it made no call in the recording ({@link SourceRunCache.leftOut}).
21
23
  *
22
24
  * Fetched **lazily** — only when a step actually needs it — and cached for the
23
25
  * turn, so a fully-executable replay never makes this call at all.
@@ -80,11 +82,28 @@ export class SourceRunCache {
80
82
  (o.position >= entry.stepFrom && o.position <= entry.stepTo)));
81
83
  if (inRange.length === 0)
82
84
  return undefined;
83
- const key = `${entry.frame.id}#${toolName}`;
85
+ const key = cursorKey(entry, toolName);
84
86
  const at = this.cursor.get(key) ?? 0;
85
87
  this.cursor.set(key, at + 1);
86
88
  return inRange[at]?.output;
87
89
  }
90
+ /**
91
+ * A step its test left out (the service's autocorrect.md AC5). Every step
92
+ * of a plan was in the recording, and made its call there — unless a
93
+ * correction added it from the agent's redo (`correctionKind: "added"`),
94
+ * which made none. So one that was in it passes over its recorded output,
95
+ * and the next step of its tool takes its own; an added one passes over
96
+ * nothing. Nothing is fetched: the cursor is a count of this frame's calls
97
+ * of the tool, whichever of them are read later (docs/calculatedReplay.md
98
+ * §9.2).
99
+ */
100
+ leftOut(entry) {
101
+ const toolName = entry.step.toolName;
102
+ if (!toolName || entry.step.correctionKind === "added")
103
+ return;
104
+ const key = cursorKey(entry, toolName);
105
+ this.cursor.set(key, (this.cursor.get(key) ?? 0) + 1);
106
+ }
88
107
  load(runId) {
89
108
  let pending = this.loading.get(runId);
90
109
  if (!pending) {
@@ -166,6 +185,10 @@ export class SourceRunCache {
166
185
  * recording. Kept so nothing outside has to change its import.
167
186
  */
168
187
  export { SourceRunCache as SourceRunOutputs };
188
+ /** One frame's queue of one tool's recorded outputs: a sub-task called twice replays from its start twice. */
189
+ function cursorKey(entry, toolName) {
190
+ return `${entry.frame.id}#${toolName}`;
191
+ }
169
192
  /**
170
193
  * Report a service answer to the breaker with the time its request started:
171
194
  * an answer to a request sent before a hot path gave up on the service does
@@ -130,15 +130,22 @@ function callerBodies(planned) {
130
130
  bodies.push(entry.step.toolInputLogic);
131
131
  if (entry.step.toolOutputLogic)
132
132
  bodies.push(entry.step.toolOutputLogic);
133
+ // A step's test reads the parameters too (autocorrect.md AC5): a test
134
+ // that decides on a target nobody named decides on nothing.
135
+ if (entry.step.when?.logic)
136
+ bodies.push(entry.step.when.logic);
133
137
  continue;
134
138
  }
135
- // The frame's own mapping, once, and only when its caller is frame 0.
139
+ // The frame's own mapping, once, and only when its caller is frame 0 —
140
+ // and the call row's test, which is decided in the caller's scope.
136
141
  const frame = entry.frame;
137
142
  if (frame.depth !== 1 || seenFrames.has(frame.id))
138
143
  continue;
139
144
  seenFrames.add(frame.id);
140
145
  if (frame.paramMapLogic)
141
146
  bodies.push(frame.paramMapLogic);
147
+ if (frame.when?.logic)
148
+ bodies.push(frame.when.logic);
142
149
  }
143
150
  return bodies;
144
151
  }
@@ -56,8 +56,40 @@ export interface StepStop {
56
56
  /** For `parked` past its grace, or whose tool is gone: a repair is due. */
57
57
  repairDue?: boolean;
58
58
  }
59
+ /**
60
+ * A step's test (the service's autocorrect.md AC5, contract 2): the step runs
61
+ * only when `logic` says `true`. When it says `false`, `otherwise` decides —
62
+ * `skip` leaves the step out and the plan goes on with the next one;
63
+ * `hand_over` stops the plan in front of the step and the agent does the rest
64
+ * (the other half of an "if … otherwise"). A test that throws, or answers
65
+ * anything but `true` or `false`, stops the plan: nothing is guessed.
66
+ */
67
+ export interface StepTest {
68
+ /** JS body: `(parameters, intent, respParams) => true | false`. */
69
+ logic: string;
70
+ otherwise: "skip" | "hand_over";
71
+ /** The test in words ("engaged users went over the threshold since the start date"). */
72
+ text?: string | null;
73
+ }
74
+ /** Whether a served test is one this runner can run; anything else is no plan to arm. */
75
+ export declare function isStepTest(value: unknown): value is StepTest;
59
76
  export interface SerializedScenarioStep {
60
77
  stepIndex: number;
78
+ /**
79
+ * Run this step only when its test says so (autocorrect.md AC5). Absent or
80
+ * null on a step with no test — every step of an older service. On a call
81
+ * row the test decides the whole sub-task.
82
+ */
83
+ when?: StepTest | null;
84
+ /**
85
+ * The correction that wrote this step, and what it did (the service's
86
+ * autocorrect.md AC6). `"added"` is a step taken from the agent's redo: it
87
+ * made no call in the recording the plan was calculated from, so it pairs
88
+ * with no recorded output (source-run.ts). Absent or null on every other
89
+ * step, and on every step of an older service.
90
+ */
91
+ correctionId?: string | null;
92
+ correctionKind?: "added" | "tested" | "changed" | null;
61
93
  /**
62
94
  * `"segment"` is a call: a sub-task this chain runs by calling a recorded
63
95
  * scenario instead of carrying a copy of its steps (R-CALL-1). Absent means
@@ -171,6 +203,12 @@ export interface SerializedScenario {
171
203
  paramsLogic: string | null;
172
204
  /** JS body: `(respParams, parameters, intent) => object`. The final model. */
173
205
  responseParamsLogic: string | null;
206
+ /**
207
+ * The chain's revision as served. Carried on the plan's own calls when they
208
+ * are recorded into the turn's newest run (autocorrect.md AC2), so the
209
+ * service can tell which chain made them. Absent from an older service.
210
+ */
211
+ chainRevision?: number;
174
212
  outParamsObject?: unknown;
175
213
  originalCostUsd: number | null;
176
214
  originalMs: number | null;
@@ -207,7 +245,13 @@ export type FallbackKind =
207
245
  * non-deterministic: its input needs a judgement no code computes
208
246
  * (plan-services.md D8, kind 3). Never tried, counts nothing.
209
247
  */
210
- | "nondeterministic_step";
248
+ | "nondeterministic_step"
249
+ /**
250
+ * The plan stopped in front of a step whose test said no, with `hand_over`
251
+ * (autocorrect.md AC5): this request is the other half of an "if …
252
+ * otherwise", and the agent does it. No fault of the plan; counts nothing.
253
+ */
254
+ | "other_branch";
211
255
  /**
212
256
  * Upgrade order. `not_steered` is the floor — the control group, and the safe
213
257
  * answer if a turn dies mid-flight — and each later decision point can only move
@@ -228,18 +272,23 @@ export declare const OUTCOME_RANK: Record<ExecutionOutcome, number>;
228
272
  * `failed` — the *scenario's own logic* threw, or its tool ran and reported
229
273
  * an error (stage `tool_call`). Either way the step's work did
230
274
  * not happen, and a plan cannot claim success past it.
275
+ * `not_taken` — its test said this request does not need it, and it was
276
+ * left out on purpose (autocorrect.md AC5): no call, no output
277
+ * logic. The plan went on with the next step.
231
278
  *
232
279
  * A step the run never reached is simply absent from the report. The console
233
280
  * renders those as "not run" — asserting anything about a step that never
234
281
  * started would be inventing a result.
235
282
  */
236
- export type ExecutionStepStatus = "ok" | "recorded" | "skipped" | "failed";
283
+ export type ExecutionStepStatus = "ok" | "recorded" | "skipped" | "failed" | "not_taken";
237
284
  /**
238
285
  * Where inside a step (or a run) something broke. Mirrors the service's
239
286
  * `ExecutionStage` (its `src/db.ts`) — a closed vocabulary, so the console can
240
287
  * say which part failed without parsing an error message.
241
288
  */
242
- export type ExecutionStage = "derive_params" | "params_logic" | "tool_input_logic" | "tool_call" | "tool_output_logic" | "response_logic" | "setup" | "unknown";
289
+ export type ExecutionStage = "derive_params" | "params_logic"
290
+ /** A step's test threw, or answered neither true nor false (autocorrect.md AC5). */
291
+ | "when_logic" | "tool_input_logic" | "tool_call" | "tool_output_logic" | "response_logic" | "setup" | "unknown";
243
292
  /**
244
293
  * One step's verdict, as reported to the service alongside the execution.
245
294
  *
@@ -10,6 +10,16 @@
10
10
  * everywhere and nothing is assumed present. A scenario whose `state` is not
11
11
  * `"ready"` carries an **empty** `steps` array, by the service's own rule.
12
12
  */
13
+ /** Whether a served test is one this runner can run; anything else is no plan to arm. */
14
+ export function isStepTest(value) {
15
+ if (!value || typeof value !== "object")
16
+ return false;
17
+ const t = value;
18
+ return (typeof t.logic === "string" &&
19
+ t.logic.trim().length > 0 &&
20
+ (t.otherwise === "skip" || t.otherwise === "hand_over") &&
21
+ (t.text === undefined || t.text === null || typeof t.text === "string"));
22
+ }
13
23
  /** Whether this schema has anything the runner may not guess (R-PARAM-4). */
14
24
  export function hasTargets(schema) {
15
25
  if (!schema)
@@ -51,6 +61,10 @@ export function isReadyScenario(value) {
51
61
  function readyStep(step) {
52
62
  if (!step)
53
63
  return false;
64
+ // A test this runner cannot read is a step it cannot decide: the plan is
65
+ // not armed rather than run with the step always in, or always out.
66
+ if (step.when !== undefined && step.when !== null && !isStepTest(step.when))
67
+ return false;
54
68
  if (step.kind === "segment") {
55
69
  if (step.unusable)
56
70
  return true;
@@ -5,8 +5,14 @@
5
5
  *
6
6
  * - **1** — stops in front of a step that carries `stop`, and reads
7
7
  * `withheld: "runner_update_required"` on a match.
8
+ * - **2** — runs a step (or a sub-task's call row) only when its test, `when`,
9
+ * says so: leaves it out (`not_taken`) or stops in front of it
10
+ * (`other_branch`) when the test says no, and stops when the test cannot
11
+ * answer (stage `when_logic`). The service's autocorrect.md AC5: a plan
12
+ * holding a test is handed only to a runner that names contract 2 or newer,
13
+ * because an older one would run every step — both halves of the task.
8
14
  */
9
- export declare const CONTRACT = 1;
15
+ export declare const CONTRACT = 2;
10
16
  /** Spread into the `headers` of a request to the service. */
11
17
  export declare function serviceHeaders(): Record<string, string>;
12
18
  /**
@@ -22,8 +22,14 @@ import { packageVersion } from "./version.js";
22
22
  *
23
23
  * - **1** — stops in front of a step that carries `stop`, and reads
24
24
  * `withheld: "runner_update_required"` on a match.
25
+ * - **2** — runs a step (or a sub-task's call row) only when its test, `when`,
26
+ * says so: leaves it out (`not_taken`) or stops in front of it
27
+ * (`other_branch`) when the test says no, and stops when the test cannot
28
+ * answer (stage `when_logic`). The service's autocorrect.md AC5: a plan
29
+ * holding a test is handed only to a runner that names contract 2 or newer,
30
+ * because an older one would run every step — both halves of the task.
25
31
  */
26
- export const CONTRACT = 1;
32
+ export const CONTRACT = 2;
27
33
  /** Spread into the `headers` of a request to the service. */
28
34
  export function serviceHeaders() {
29
35
  return {
@@ -475,7 +475,9 @@ What the control server does for such a session:
475
475
  text) has every request answered `{}`, and nothing of it is recorded or
476
476
  replayed (`session.host_refused`).
477
477
  - **The plan.** One starts only for a prompt that said `replay: true`.
478
- Otherwise a match is declined with `host_records_only`. The plan's mode is
478
+ Otherwise a match is declined with `host_records_only`, and a plan with
479
+ steps that run only when a test says so with `host_tests_unsupported`: the
480
+ adapter reads no tests yet (calculatedReplay.md §9.2). The plan's mode is
479
481
  always `steer`, and the runner runs no step itself: the host runs its own
480
482
  tools. The prompt's answer carries the plan as data (`plan`), because the
481
483
  directive beside it is a text written for a model.
@@ -219,7 +219,9 @@ plan is declined when no running `bir-scenario` has said it serves `run_scenario
219
219
  (`no_delivery_tool`, §6.3). In plan mode (`permission_mode: "plan"`) nothing is announced at all.
220
220
  In a session of another host (§7.4) a plan that armed is taken back, before any step of it runs,
221
221
  when it names a tool the agent that asked does not hold (`host_tool_missing`): it is booked like
222
- any gate's decline, `not_steered`, with no step marked failed.
222
+ any gate's decline, `not_steered`, with no step marked failed. A plan with steps that run only when
223
+ a test says so (§9.2) is declined for such a host before anything is spent
224
+ (`host_tests_unsupported`): its adapter steps through a plan call by call and reads no tests yet.
223
225
 
224
226
  Gate 4 is retired. Up to 0.3.6 it declined a match under `BIR_MIN_STEER_SIMILARITY` (0.92),
225
227
  above the server's 0.9, on the argument that the server only decided *"do not record this again"*
@@ -879,6 +881,77 @@ time limit, §13.1; argument order and the plain-object guard still match the se
879
881
  In direct mode both live inside one `for` loop. In steer mode they are split across *separate HTTP
880
882
  requests* — which is what §7.3's ordering rule protects.
881
883
 
884
+ ### 9.2 Steps that run only when (contract 2)
885
+
886
+ Built 2026-10-23 for the service's autocorrect.md AC5; not yet released. A task can have two
887
+ halves: "if engaged users went over the threshold, book a meeting; otherwise send a note". A
888
+ recording shows one half, so its plan knows one half. The service's correction adds the other half
889
+ and a test on each step that belongs to only one of them. A served step, and a sub-task's call
890
+ row, may carry:
891
+
892
+ ```ts
893
+ when?: { logic: string; otherwise: "skip" | "hand_over"; text?: string | null } | null
894
+ // logic: a function body, (parameters, intent, respParams) => true | false
895
+ ```
896
+
897
+ The test runs in the same sandbox as the other logic (`evalWhenLogic`, `src/replay/logic.ts`). It is
898
+ decided when its step comes due, with what is known then: the request's values and the results of
899
+ the steps before it. A call row's test is decided once, in the caller's scope, before the sub-task
900
+ starts — where its first step *or its callee's* is: a sub-task whose chain begins with a call
901
+ begins with that call's steps, so nothing of it runs before its test says it is needed. A sub-task
902
+ is entered there too, before its callee, whose mapping reads its parameters, and left after the
903
+ last step of it or its callee (`frameEdges`, `src/replay/flatten.ts`).
904
+
905
+ | The test says | What happens |
906
+ |---|---|
907
+ | `true` | The step runs. |
908
+ | `false`, `otherwise: "skip"` | Nothing is sent. The step is reported `not_taken` and the plan goes on. A skipped call row leaves out its whole sub-task and is reported `not_taken` under its own index. |
909
+ | `false`, `otherwise: "hand_over"` | The plan stops in front of the step and hands the rest of the task to the model, saying the steps from here are only for when `text` (`fallbackKind: other_branch`). |
910
+ | throws, or not exactly `true` or `false` | The plan stops: the step is `failed` at stage `when_logic` (`fallbackKind: step_failed`). The model is told the plan could not tell whether this request needs the step, and decides itself. Nothing is guessed. |
911
+
912
+ A plan whose tests left out every step it came to, and ran none, hands the whole task to the model
913
+ (`other_branch`, step 0). Its report is `failed` with `fallbackKind: other_branch`, and it is sent
914
+ although it cost nothing: it tells the service the request took the half the plan does not know.
915
+
916
+ A step left out that is the last of a sub-task still hands the sub-task's results back to its
917
+ caller. When the body that maps them throws, the plan stops there: the call row is `failed` at stage
918
+ `tool_output_logic`, and the note says the sub-task could not hand back its results — never that
919
+ the step left out ran.
920
+
921
+ - **Direct mode** decides the tests in its loop. The bundle gives a left-out step its own heading,
922
+ `--- Step 5: tool (not needed) ---`, and says it is only for when `text`, so the model does not run
923
+ it itself.
924
+ - **Steer mode** settles the tests after each call. The `PostToolUse` answer names the next step
925
+ this request does not need, and the step to call instead. A call to a left-out tool — every tool
926
+ of a sub-task left out whole — is answered in its place with the same words, at most twice, and
927
+ does not end the plan. When the left-out step and the next one share a tool and the note has not
928
+ reached the model yet, a call the pin guard (§7) finds is not the next step gets the note in its
929
+ place too, so the step left out never runs because the note was late. A note naming a step to call
930
+ next is dropped when the plan retires — a divergence, a hand-over — since the plan no longer has
931
+ that step; only "the sequence is complete" outlives a plan that finished.
932
+ - **The divergence bundle** (§8) decides the tests the same way, going on from the sub-task the
933
+ cursor is inside, with what that sub-task made so far. A `hand_over` stop ends the bundle there,
934
+ and the turn is booked `fell_back` when a step did its work (`failed` when none did) — a hand-over,
935
+ as in the other modes, never `diverged`, so the service takes the agent's work after it as that
936
+ half's evidence.
937
+ - **The directive** (§6.4) marks a tested step `(only when <text>)` and says the results, or the
938
+ notes after each call, name the steps this request does not need.
939
+ - **A hand-over note** counts only the steps that ran, names the ones their tests left out ("Not
940
+ needed for this request, so not run: step 2 (Grep)"), and marks a remaining step that runs only
941
+ under a test `(only when …)`, so the list does not read as work this request needs.
942
+ - **Another host** (a LangChain application) steps through a plan call by call, and its adapter
943
+ reads no tests yet: a plan with any test is declined for it before anything is spent
944
+ (`host_tests_unsupported`), and the model does the task.
945
+
946
+ A step left out passes over its own recorded output (§8.1): it was in the recording and made its
947
+ call there, so the next step of its tool takes its own output, not the one left out. A step a
948
+ correction added from the agent's redo (`correctionKind: "added"`) made no call in the recording
949
+ and passes over nothing (`SourceRunCache.leftOut`, `src/replay/source-run.ts`).
950
+
951
+ The runner names `x-bir-contract: 2` on its calls to the service (`src/util/service-headers.ts`).
952
+ The service hands a plan with any test only to a runner that names 2 or more; an older runner gets
953
+ no plan (`runner_update_required`) and the agent does the task.
954
+
882
955
  ---
883
956
 
884
957
  ## 10. Timing: what may block, and for how long
@@ -971,7 +1044,8 @@ threaded before the divergence. A divergence that ran nothing books `failed`, an
971
1044
  stopped at a step that broke (a logic failure, a tool error, a call sent and never answered). A
972
1045
  recorded stand-in (`BIR_REPLAY_RECORDED_OUTPUTS=1`) is never work done: it counts toward neither
973
1046
  `stepsPinned` nor the upgrade, and a direct run that used one is `diverged` at best. A call that ran
974
- beside a pinned one hands over as `fell_back` or `failed` (§7). A direct run cancelled by its caller
1047
+ beside a pinned one hands over as `fell_back` or `failed` (§7), and so does a divergence whose
1048
+ bundle stops in front of the other half of an "if … otherwise" (§9.2). A direct run cancelled by its caller
975
1049
  (Esc, or `bir-scenario`'s own timeout) books `fell_back` or `failed` with `measured: false`, never
976
1050
  `diverged`: that turn's cost says nothing about what the task costs.
977
1051
 
@@ -1012,7 +1086,8 @@ bounded flush — runs in the background, so `Stop` never blocks on the network:
1012
1086
  POST /scenarios/:scenarioId/executions
1013
1087
  { ticket, outcome, deriveCostUsd, sessionCostUsd, fallbackCostUsd,
1014
1088
  durationMs, stepsPlanned, stepsPinned, measured, pricingVersion, prompt,
1015
- steps, error, errorStage, errorStepIndex, errorToolName }
1089
+ steps, error, errorStage, errorStepIndex, errorToolName,
1090
+ redoneBy } // { runId, ownCalls }, only after a redo (below)
1016
1091
  ```
1017
1092
 
1018
1093
  An **unredeemed** ticket leaves no execution row at all. That asymmetry is intentional and worth
@@ -1023,6 +1098,14 @@ was handed out, and answers a later report `409 invalid_ticket`. The service as
1023
1098
  yet) redeems it for 24 hours. It also answers a report whose write failed `503 report_not_booked`,
1024
1099
  and the recorder sends that report once more.
1025
1100
 
1101
+ **After a redo** (the service's autocorrect.md AC1; built 2026-10-23, not yet released). When a
1102
+ plan ran to its end and the agent then did the task again itself, the rest of the turn is recorded
1103
+ as a repeat of the recording, with reason `redone` (§12). The report then says
1104
+ `redoneBy: { runId, ownCalls }`: the repeat's run id, and how many tool calls the agent made of its
1105
+ own in it. A call the plan pinned is not one of them, and neither is one a proxy reports after
1106
+ `Stop`. Only the plan the prompt armed sends it. The service judges from it whether the agent
1107
+ rejected the plan's result, and why.
1108
+
1026
1109
  ### 11.2a What each step did (the service's errorshandling.md)
1027
1110
 
1028
1111
  The last five fields are the diagnosis, and none of the money depends on them. The service's
@@ -1035,7 +1118,7 @@ at a web page.
1035
1118
  steps: Array<{
1036
1119
  stepIndex: number; // the scenario's own index, not a position in this array
1037
1120
  toolName: string;
1038
- status: "ok" | "recorded" | "skipped" | "failed";
1121
+ status: "ok" | "recorded" | "skipped" | "failed" | "not_taken";
1039
1122
  stage?: ExecutionStage; // where in the step, for `failed` / `skipped`
1040
1123
  error?: string; // message only — this is rendered in a web page
1041
1124
  durationMs?: number;
@@ -1083,7 +1166,9 @@ only another open session's proxy), or it was sent and timed out or was refused.
1083
1166
  host stopped itself, in a host that is not Claude Code, is `skipped` as well: a person refused it,
1084
1167
  or changed its call (§7.4). The plan stops at a `skipped` step.
1085
1168
  **`failed` is the only one that means the scenario is broken** rather than the environment — it is
1086
- the scenario's own logic throwing, and it is what `Recalculate` fixes.
1169
+ the scenario's own logic throwing, and it is what `Recalculate` fixes. A step's test that throws is
1170
+ `failed` too, at stage `when_logic`. `not_taken` is a step its test left out (§9.2): nothing was
1171
+ sent, so it has neither payload.
1087
1172
 
1088
1173
  Three rules this must keep:
1089
1174
 
@@ -1197,6 +1282,20 @@ Three consequences to keep straight:
1197
1282
  `request()` is *"Never used by the relay"* — it was built for `bir doctor` and tests, and this is
1198
1283
  its second, load-bearing user.
1199
1284
 
1285
+ **A plan the agent redid.** The service's newestRun.md N7 makes one exception. When the agent
1286
+ does the task again after a plan ran to its end, the rest of the turn is recorded as a repeat of the
1287
+ recording, with reason `redone`. Since autocorrect.md AC2 (built 2026-10-23, not yet released) the
1288
+ repeat starts with the plan's own calls, then the agent's. Each of the plan's calls is a pair of
1289
+ rows marked `metadata.byPlan: { scenarioId, stepIndex, chainRevision }`, and each is kept whole,
1290
+ like a recorded step (`serializeCapped(redact(…))`, up to 64 KiB), not cut to the report's 3,800
1291
+ bytes. So the service can set what the plan did beside what the agent did instead, call by call.
1292
+
1293
+ - Only the plan the prompt armed keeps its calls, and only when the match offered a repeat.
1294
+ - Only calls that were sent and answered are kept, with a result or with a tool error. A step left
1295
+ out, not sent, or standing on a recorded output is not among them.
1296
+ - The calls kept in one turn are held to about 8 MB. Past that, a call keeps only the report's cut.
1297
+ - `chainRevision` is the plan's revision as it was served, or `null` when the service did not send one.
1298
+
1200
1299
  ### 12.1 `bir replay` — the Tier 2 and debugging path
1201
1300
 
1202
1301
  ```bash
@@ -1400,6 +1499,7 @@ fail because of BaseInstRunner.**
1400
1499
  | Plan mode | Nothing is announced, pinned or approved |
1401
1500
  | Another host (§7.4): the run's adapter asked for no plan with its prompt | Declined at gate 2 (`host_records_only`), `not_steered`; the model does the task |
1402
1501
  | Another host: the plan names a tool the agent that asked does not hold | Taken back before any step runs (`host_tool_missing`), `not_steered`; the model does the task |
1502
+ | Another host: the plan has steps that run only when a test says so (§9.2) | Declined before anything is spent (`host_tests_unsupported`), `not_steered`; the model does the task |
1403
1503
  | Another host: the model makes a call of its own while the plan is live | The plan is handed over. The call runs as the model wrote it, never with the plan's arguments, and the note comes with its `/tool/post` |
1404
1504
  | Another host: a person refuses a step, or changes its call | The step is `skipped`, never `failed`, and the plan hands over; `baselineEligible: false`, and after a refusal `measured: false` (§11.1) |
1405
1505
  | Another host: the run fails, goes silent for two hours, or is closed with its plan still live | A live plan is handed over first: `fell_back` or `failed`. The report says `measured: false` and `baselineEligible: false`. A run that ended early is finished with an `error` and no final answer |
@@ -1653,7 +1753,8 @@ bir scenario check <id> --step <n> POST /scenarios/:id/steps/:n/check
1653
1753
  bir scenario edit <id> --step <n> PATCH /scenarios/:id/steps/:n the same check, then save
1654
1754
  bir scenario edits <id> GET /scenarios/:id/edits the history, newest first
1655
1755
  bir scenario undo <id> [--edit <e> | --step <n>]
1656
- POST /scenarios/:id/edits/:e/revert take back the newest edit (of step n)
1756
+ POST /scenarios/:id/edits/:e/revert take back the newest edit (of step n);
1757
+ <e> is a sedit_, pedit_ or scor_ id
1657
1758
  bir scenario editing on|off|status (no route) ~/.baseinstrunner/installed.json which `bir` MCP tools are offered
1658
1759
 
1659
1760
  bir replay --scenario <scnId> --prompt "…" [--dry] alias; the Tier 2 / debugging path (§12.1)
@@ -1684,6 +1785,16 @@ are where `bir` changes a scenario by hand, so the rules are strict:
1684
1785
  steps and the final answer (so only when it changes output logic), and answers
1685
1786
  `422 undo_refused` when it would break them, unless `--force --note "<why>"` is given. Every
1686
1787
  save and every undo bumps the scenario's `chainRevision`.
1788
+ - **Corrections** (the service's autocorrect.md AC7; built 2026-10-23, not yet released). After the
1789
+ agent redid a plan, the service may correct the plan itself, for example by adding the half of
1790
+ the task it did not know (§9.2). `edits` lists each correction beside the hand edits, under its
1791
+ `scor_` id, with its state (`proposed`, `used`, `failed`, `undone`, `replaced`), the revisions
1792
+ before and after, and what it did. `undo --edit scor_…` puts back the plan from before it, all of
1793
+ it, and the service then holds the plan: it is not handed out until its owner chooses "Use it
1794
+ again" on the recording page. A plain `undo` takes back a correction in use when it is the newest
1795
+ change; `--step` never picks one, because a correction changes the whole plan. A correction is
1796
+ protected like a hand edit: `calc --force` answers `409 scenario_has_edits` with its
1797
+ `correctionId`, and `bir` says the plan carries it.
1687
1798
  - A save lifts a mark made by hand (`--freeze`, `by_hand`) only with `--unfreeze`; undoing the
1688
1799
  freeze, or `calc --force --discard-edits`, also removes it. A forced save lifts only a mark the
1689
1800
  calculation set.
@@ -491,6 +491,7 @@ Everything replay decides is one line. `grep` for these:
491
491
  | `replay.concurrent_call` | A call ran beside a pinned step; the plan handed over |
492
492
  | `replay.thread_fallback` | A wrapped step's output was threaded from the hook's view because the proxy did not report in time |
493
493
  | `replay.step` | One direct step executed |
494
+ | `replay.step_not_taken` | A step's test said this request does not need it, and nothing was sent for it ([calculatedReplay.md](calculatedReplay.md) §9.2). `plan.armed … tests=` lists the tested steps by their place in the plan: `4:hand_over,11:skip` |
494
495
  | `replay.diverge` / `replay.compose` | Off-script, and the recovery |
495
496
  | `replay.handover` | The plan stopped and handed the rest of the task to the model; `kind=` and `step=` say where |
496
497
  | `replay.vetoed` | A direct plan retired without running anything: `run_scenario` was not the first call, or never arrived |
@@ -501,7 +502,8 @@ Everything replay decides is one line. `grep` for these:
501
502
  | `replay.delivery_tool` | A `bir` server said it can, or cannot, deliver a direct plan |
502
503
  | `control.hook_deadline` | A hook route answered `{}` at its deadline |
503
504
  | `replay.done` | Final outcome, steps completed, elapsed |
504
- | `execution.reported` | The ticket was redeemed and the saving booked |
505
+ | `execution.reported` | The ticket was redeemed and the saving booked. `redoneBy=` and `ownCalls=` when the agent did the task again after the plan |
506
+ | `run.repeat … reason=redone` | The plan ran to its end, and the agent did the task again itself. The rest of the turn is recorded as the recording's newest run, `run=`; `planCalls=` says how many of the plan's calls open it |
505
507
  | `run.unmeasured` | The turn's cost is not measured (§7). `why=` says what was missing, and `models=` names a model the runner has no price for |
506
508
  | `run.start … host=` / `plan.armed … host=` | The turn ran in a host that is not Claude Code, named after `host=` (§6.5). A Claude Code turn has no such field |
507
509
  | `replay.own_call` / `replay.step_host_stopped` | Another host only. The model made a call of its own while the plan was live, and the plan handed over / the host stopped a step itself: `stop=rejected` or `stop=edited` |
@@ -732,6 +734,25 @@ LangChain recording it raises no `steer_only` finding, and it explains an
732
734
  unmeasured cost in its own words. That part is built on the service and not
733
735
  deployed yet.
734
736
 
737
+ **A plan the agent redid** (built 2026-10-23, not yet released; the service's
738
+ autocorrect.md). The plan ran to its end, and then the agent did the task again
739
+ with calls of its own. The turn shows it three ways:
740
+
741
+ - the `reported` line ends `redone by run_… (N calls of its own)`;
742
+ - a `repeat` line names the run that holds the redo: the newest run of the
743
+ recording, never a recording of its own and never a plan;
744
+ - a `redone` finding says the agent did not take the plan's result.
745
+
746
+ From the service you also see:
747
+
748
+ - the verdict on the execution, `verdict wrong_branch: …`, with the plan's
749
+ steps it rejected and what the plan left in place. Remove that by hand if it
750
+ should not be there;
751
+ - `HELD since …` on the plan: it is not handed out until you choose "Use it
752
+ again" on the recording page;
753
+ - a step's test, `only when …, otherwise skip`, with the correction that added it;
754
+ - the newest correction, and whether it is used.
755
+
735
756
  You see your own data. An admin of the service sees everyone's and may pass
736
757
  `--user` to `executions`. `--json` on any form prints the raw merge.
737
758
 
@@ -760,7 +781,7 @@ bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-l
760
781
  [--freeze | --unfreeze] [--note "<why>"] [--force --note "<why>"] [--revision <n>]
761
782
  bir scenario edit <runId|scnId> --param <name> --kind target|setting [--note "<why>"] [--revision <n>]
762
783
  bir scenario edits <runId|scnId>
763
- bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id> | --step <n> | --param <name>] [--force --note "<why>"]
784
+ bir scenario undo <runId|scnId> [--edit <sedit_id|pedit_id|scor_id> | --step <n> | --param <name>] [--force --note "<why>"]
764
785
  bir scenario calc <runId|scnId> [--force [--discard-edits]]
765
786
  bir scenario editing on|off|status
766
787
  ```
@@ -1093,6 +1114,21 @@ How undo works:
1093
1114
  - Once every hand edit of a plan is taken back, the service repairs and rebuilds it as usual again.
1094
1115
  An undo forced past a refusal is the exception: it leaves its step counted as edited and
1095
1116
  forced, so `bir investigate` keeps warning about it.
1117
+ - **A correction the service made** (built 2026-10-23, not yet released). After the agent redid a
1118
+ plan, the service may correct the plan itself (its autocorrect.md), for example by adding the
1119
+ half of the task the recording did not show. `bir scenario edits` lists the correction beside
1120
+ your edits, under its `scor_` id:
1121
+
1122
+ ```
1123
+ scor_5b2e… 2026-10-22 17:58:10Z correction used revision 7 → 8
1124
+ 16 steps added for the otherwise half; …; 16 steps added (11–26), tests on steps 4–10, the answer changed; …
1125
+ ```
1126
+
1127
+ `bir scenario undo <id> --edit scor_…` puts back the whole plan from before it. The plan is then
1128
+ held: it is not handed out until you choose "Use it again" on the recording page. A plain
1129
+ `bir scenario undo` takes back a correction in use when it is the newest change. `--step` never
1130
+ picks one: a correction changes the whole plan. A recalculation is refused on a corrected plan
1131
+ as on an edited one (`Not recalculated: the plan carries correction scor_…`).
1096
1132
 
1097
1133
  #### What a save changes
1098
1134
 
@@ -1185,6 +1221,7 @@ The tools, as the `bir` server defines them. **Bold** arguments are required.
1185
1221
  | `scenario_undo` | with scenario editing on | **`target`**, one of `edit`, `step` or `param`, `force`, `note` | `bir scenario undo` |
1186
1222
 
1187
1223
  - `target` is a `scn_` id, or the `run_` id of a whole-run scenario. `step` is the `stepIndex`.
1224
+ - `edit` is an id from `scenario_edits`: a `sedit_`, `pedit_` or `scor_` id.
1188
1225
  - `inputLogic` and `outputLogic` are the code itself, as text. The server writes them to temporary
1189
1226
  files for `bir`, and removes the files afterwards.
1190
1227
  - `revision` is the `chainRevision` at the top of `scenario_show` with `step`.
@@ -1300,7 +1337,7 @@ Parameter database: target → setting (revision 4). Undo: bir scenario undo scn
1300
1337
  | `revision_changed` | The plan changed since you read it: another edit, or a recalculation | `show` it again, check again, save again |
1301
1338
  | `not_ready` or `scenario_calculating` | The scenario is not `ready`, or a recalculation is running | Wait for it to finish |
1302
1339
  | `undo_refused` | The undo would stop a later step from giving its recorded call, or leave the final answer without its values | Fix that later step first. Or undo it on purpose: `--force --note "why"` |
1303
- | `not_latest_edit` | The step has changed since the edit you named: a newer entry changed it | `bir` prints two commands: undo the newer one first, then yours |
1340
+ | `not_latest_edit` | The step has changed since the edit you named: a newer entry changed it. For a correction: the plan has changed since it | `bir` prints two commands: undo the newer one first, then yours |
1304
1341
  | `already_undone` | That edit was undone already | To redo it, undo the undo. `bir` prints the command |
1305
1342
  | `edit_replaced` | A recalculation replaced the plan after that edit | Nothing to undo; the edit is history now |
1306
1343
  | `logic_sandbox_unavailable` | The service could not run step code just now (HTTP 503). Nothing was checked, saved or undone | Try again in a minute |
@@ -1333,6 +1370,7 @@ part of it is not deployed yet.
1333
1370
  | **R6** | Divergence, source-run fallback, `bir replay` | `diverge()` in [controller.ts](../src/replay/controller.ts), [source-run.ts](../src/replay/source-run.ts) |
1334
1371
  | **R7** | Usage watermark, reporting, ticket, doctor | [control/transcript.ts](../src/control/transcript.ts), [remote-recorder.ts](../src/record/remote-recorder.ts) |
1335
1372
  | **§6.5** *(the package is not on PyPI; the service's part is not deployed)* | A plan stepped through by the adapter of a host that is not Claude Code: the declines `host_records_only` and `host_tool_missing`, the stops `host_rejected` and `host_edited`, the cost from the adapter's `usage` | [control/server.ts](../src/control/server.ts), [controller.ts](../src/replay/controller.ts), [handover.ts](../src/replay/handover.ts), [safety.ts](../src/replay/safety.ts), [bin/investigate.ts](../src/bin/investigate.ts); the proof: `examples/langchain-proof/` in the repository (not in the npm package) |
1373
+ | **Redo and corrections** *(not yet released; the service's autocorrect.md)* | The report's `redoneBy`; a redone repeat opens with the plan's own calls, whole and marked `byPlan`; steps that run only when (contract 2); `scor_` corrections in `bir scenario edits` and `undo`; `bir investigate`'s repeat, verdict, hold and correction lines | [control/server.ts](../src/control/server.ts), [controller.ts](../src/replay/controller.ts), [plan.ts](../src/replay/plan.ts), [scenario-edit.ts](../src/bin/scenario-edit.ts), [investigate.ts](../src/bin/investigate.ts); tests: [step-tests.test.ts](../test/step-tests.test.ts), [newest-run.test.ts](../test/newest-run.test.ts) |
1336
1374
  | **§9.2** *(not yet released)* | `bir scenario show/check/edit/edits/undo/calc/editing`, the `bir` server's scenario tools, `bir` calls never recorded (`editSteps.md` D13) | [bin/scenario-edit.ts](../src/bin/scenario-edit.ts), [bin/bir-scenario.ts](../src/bin/bir-scenario.ts), [record/housekeeping.ts](../src/record/housekeeping.ts); tests: [scenario-edit.test.ts](../test/scenario-edit.test.ts), [bir-scenario.test.ts](../test/bir-scenario.test.ts), [housekeeping.test.ts](../test/housekeeping.test.ts), [replay-server.test.ts](../test/replay-server.test.ts) |
1337
1375
 
1338
1376
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
5
5
  "type": "module",
6
6
  "license": "MIT",