@basein/runner 0.3.22 → 0.3.24
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/dist/bin/bir-hooks.d.ts +5 -0
- package/dist/bin/bir-hooks.js +5 -0
- package/dist/control/server.d.ts +80 -0
- package/dist/control/server.js +515 -10
- package/dist/record/recorder.d.ts +51 -1
- package/dist/record/remote-recorder.js +8 -0
- package/dist/replay/controller.d.ts +71 -3
- package/dist/replay/controller.js +56 -11
- package/docs/calculatedReplayGuide.md +1 -0
- package/package.json +1 -1
package/dist/bin/bir-hooks.d.ts
CHANGED
|
@@ -109,6 +109,11 @@
|
|
|
109
109
|
* call. Unset (or 0) is off: no probe is sent and
|
|
110
110
|
* nothing arms mid-turn
|
|
111
111
|
* BIR_INTENT_ARM_BUDGET_MS how long a call waits for that probe (default 500)
|
|
112
|
+
* BIR_CONTINUATIONS 0 stops asking, after a plan handed over, for the
|
|
113
|
+
* plan of the work recorded after an earlier
|
|
114
|
+
* hand-over at that step (default on: one request
|
|
115
|
+
* per hand-over; it runs only when the agent's next
|
|
116
|
+
* call is its first step)
|
|
112
117
|
* BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40)
|
|
113
118
|
* BIR_INTENT_MATCH_BUDGET_MS one intent probe's own ceiling (default 1500)
|
|
114
119
|
* BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (unset: the
|
package/dist/bin/bir-hooks.js
CHANGED
|
@@ -109,6 +109,11 @@
|
|
|
109
109
|
* call. Unset (or 0) is off: no probe is sent and
|
|
110
110
|
* nothing arms mid-turn
|
|
111
111
|
* BIR_INTENT_ARM_BUDGET_MS how long a call waits for that probe (default 500)
|
|
112
|
+
* BIR_CONTINUATIONS 0 stops asking, after a plan handed over, for the
|
|
113
|
+
* plan of the work recorded after an earlier
|
|
114
|
+
* hand-over at that step (default on: one request
|
|
115
|
+
* per hand-over; it runs only when the agent's next
|
|
116
|
+
* call is its first step)
|
|
112
117
|
* BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40)
|
|
113
118
|
* BIR_INTENT_MATCH_BUDGET_MS one intent probe's own ceiling (default 1500)
|
|
114
119
|
* BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (unset: the
|
package/dist/control/server.d.ts
CHANGED
|
@@ -282,6 +282,15 @@ export interface ControlServerOptions {
|
|
|
282
282
|
* failing. Unset or `BIR_INTENT_ARM=0` is off.
|
|
283
283
|
*/
|
|
284
284
|
intentArm?: boolean;
|
|
285
|
+
/**
|
|
286
|
+
* Whether the model's first call after a plan handed over asks the service
|
|
287
|
+
* for the plan of the work recorded after an earlier hand-over at that step,
|
|
288
|
+
* and runs it when that call is its first step (the service's fallbk.md
|
|
289
|
+
* Part 3). On by default: it is one request per hand-over, never per call.
|
|
290
|
+
* `BIR_CONTINUATIONS=0` turns it off; so does `BIR_INTENT_MATCH=0`, which
|
|
291
|
+
* turns off every mid-turn request.
|
|
292
|
+
*/
|
|
293
|
+
continuations?: boolean;
|
|
285
294
|
/**
|
|
286
295
|
* The verdict hot paths consult before they wait on the service. Defaults to
|
|
287
296
|
* the process-wide one every service call reports to; tests pass their own.
|
|
@@ -1156,6 +1165,77 @@ export declare class ControlServer {
|
|
|
1156
1165
|
* matched call has run by then, and the plan's first step would repeat it.
|
|
1157
1166
|
*/
|
|
1158
1167
|
private tryIntentMatch;
|
|
1168
|
+
/**
|
|
1169
|
+
* A continuation (the service's fallbk.md Part 3), on the model's first call
|
|
1170
|
+
* after a plan handed over.
|
|
1171
|
+
*
|
|
1172
|
+
* The model's work after an earlier hand-over of the same plan at the same
|
|
1173
|
+
* step was recorded once, as a fragment, and the service may have calculated
|
|
1174
|
+
* it into a plan. Until 0.3.23 nothing asked for that plan: the only way a
|
|
1175
|
+
* plan started mid-task was a match of the model's words against plan
|
|
1176
|
+
* intents ({@link tryIntentMatch}, off by default), and a one-line note
|
|
1177
|
+
* after a hand-over does not read like a plan's intent paragraph. Here the
|
|
1178
|
+
* hand-over itself is the question — the plan and the step — so the service
|
|
1179
|
+
* names one recording and matches no words.
|
|
1180
|
+
*
|
|
1181
|
+
* It arms on the model's first call after the hand-over, whatever that call
|
|
1182
|
+
* is (Eran, 2026-10-26). Runner 0.3.23 armed it only when the call was the
|
|
1183
|
+
* plan's first step, and lost it whenever the model first re-checked what
|
|
1184
|
+
* the plan before had already done. How the model is told depends on the
|
|
1185
|
+
* call. A call to one of the plan's own tools is answered in its place: the
|
|
1186
|
+
* plan makes that call itself, and letting it run would make it twice. Any
|
|
1187
|
+
* other call runs as the model wrote it, and the plan is told on its
|
|
1188
|
+
* PostToolUse, as an intent plan armed beside the model's own work is. The
|
|
1189
|
+
* plan before it stopped because the next step needed a judgement; the
|
|
1190
|
+
* continuation runs the half that was recorded, whatever the model would
|
|
1191
|
+
* have judged.
|
|
1192
|
+
*
|
|
1193
|
+
* Its values are read from the turn's own request — the rest of the task is
|
|
1194
|
+
* the one the user asked for — with the model's words, the call it was about
|
|
1195
|
+
* to make and this turn's tool results beside it, as for any plan started at
|
|
1196
|
+
* a tool call.
|
|
1197
|
+
*
|
|
1198
|
+
* Asked once per hand-over, waited on {@link CONTINUATION_PROBE_MS} and
|
|
1199
|
+
* {@link CONTINUATION_ARM_MS} at most, and not at all while the service is
|
|
1200
|
+
* failing. Every way it does not arm leaves the turn as it was: the model's
|
|
1201
|
+
* call runs, and its work is recorded as the fragment. A plan handed out and
|
|
1202
|
+
* not armed is reported for its ticket, with the reason.
|
|
1203
|
+
*/
|
|
1204
|
+
private tryContinuation;
|
|
1205
|
+
/** The body of {@link tryContinuation}, once the continuation is asked for. */
|
|
1206
|
+
private continuationAttempt;
|
|
1207
|
+
/**
|
|
1208
|
+
* A call meeting a continuation that armed beside another call of the same
|
|
1209
|
+
* message and still waits to be told (`pendingDirective`). A call to one of
|
|
1210
|
+
* the plan's own tools gets the plan's directive in its place: letting it
|
|
1211
|
+
* run would have the plan make the same call again — a duplicated write.
|
|
1212
|
+
* Any other call runs, and the plan is told on the first PostToolUse.
|
|
1213
|
+
*/
|
|
1214
|
+
private announceContinuationInstead;
|
|
1215
|
+
/**
|
|
1216
|
+
* Put back the fragment a continuation took the place of, when that
|
|
1217
|
+
* continuation retired having run no step and handed nothing over —
|
|
1218
|
+
* `run_scenario` refused, the plan vetoed, or a steer pin left by the
|
|
1219
|
+
* model. The model is doing the rest of the task itself, and that work is
|
|
1220
|
+
* recorded from its next call, as it would have been with no continuation.
|
|
1221
|
+
*/
|
|
1222
|
+
private restoreParkedFragment;
|
|
1223
|
+
/**
|
|
1224
|
+
* A continuation handed out and not armed: kept, retired, for its ticket's
|
|
1225
|
+
* report at the seal (`not_steered`, with the reason, unmeasured). `code`
|
|
1226
|
+
* says why when no gate did.
|
|
1227
|
+
*/
|
|
1228
|
+
private keepDeclinedContinuation;
|
|
1229
|
+
/** A continuation request's answer that came after the call it was for went ahead. */
|
|
1230
|
+
private noteLateContinuation;
|
|
1231
|
+
/**
|
|
1232
|
+
* Every scenario this turn was handed and armed or declined: never handed
|
|
1233
|
+
* out again in it. A plan that handed over is among them, so its own
|
|
1234
|
+
* continuation request can never return it.
|
|
1235
|
+
*/
|
|
1236
|
+
private armedScenarioIds;
|
|
1237
|
+
/** Whether continuations are asked for: on unless `BIR_CONTINUATIONS=0`. */
|
|
1238
|
+
private continuationsOn;
|
|
1159
1239
|
/**
|
|
1160
1240
|
* Whether the model's call is plausibly `state`'s first step: its tool, and
|
|
1161
1241
|
* arguments the pin guard would pin to that step's input. Anything that
|
package/dist/control/server.js
CHANGED
|
@@ -106,6 +106,27 @@ const INTENT_ARM_PROBE_MS = 500;
|
|
|
106
106
|
const INTENT_ARM_DERIVE_MS = 1_500;
|
|
107
107
|
/** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
|
|
108
108
|
const FRAGMENT_CREATE_MS = 300;
|
|
109
|
+
/**
|
|
110
|
+
* How long the model's first call after a hand-over waits for the service to
|
|
111
|
+
* say whether the work recorded after an earlier hand-over at that step has a
|
|
112
|
+
* plan (the service's fallbk.md Part 3). One lookup, no words matched: it
|
|
113
|
+
* answers in a round trip. Asked once per hand-over, never per call.
|
|
114
|
+
*/
|
|
115
|
+
const CONTINUATION_PROBE_MS = 1_500;
|
|
116
|
+
/**
|
|
117
|
+
* How long the continuation request itself may take — longer than the call
|
|
118
|
+
* waits for it, so that an answer too late for the call still lands, and the
|
|
119
|
+
* ticket it may carry is reported as late instead of never.
|
|
120
|
+
*/
|
|
121
|
+
const CONTINUATION_REQUEST_MS = 6_000;
|
|
122
|
+
/**
|
|
123
|
+
* What a continuation may spend arming — reading its values — inside that
|
|
124
|
+
* `PreToolUse`. Longer than an intent arm may: it is asked once per hand-over,
|
|
125
|
+
* the plan it arms does the rest of the task, and a value the request names
|
|
126
|
+
* is read by the service, which gives a reading 6 s. With the probe it stays
|
|
127
|
+
* well inside the route's 12 s.
|
|
128
|
+
*/
|
|
129
|
+
const CONTINUATION_ARM_MS = 7_000;
|
|
109
130
|
/** What `close()` spends draining the last runs to the service before it lets go. */
|
|
110
131
|
const CLOSE_DRAIN_MS = 5_000;
|
|
111
132
|
/** How long a background drain follows one run before it stops waiting for it. */
|
|
@@ -920,6 +941,14 @@ export class ControlServer {
|
|
|
920
941
|
unannounced: s.run.pendingDirective?.state === s.run.replay ? true : undefined,
|
|
921
942
|
running: this.runningPlans.has(s.run.replay) ? true : undefined,
|
|
922
943
|
armedBy: s.run.replay.armedBy,
|
|
944
|
+
// A continuation (the service's fallbk.md Part 3): the plan it
|
|
945
|
+
// continues, and the step that plan stopped in front of.
|
|
946
|
+
continues: s.run.replay.continuation
|
|
947
|
+
? {
|
|
948
|
+
scenarioId: s.run.replay.continuation.ofScenarioId,
|
|
949
|
+
stepIndex: s.run.replay.continuation.stepIndex,
|
|
950
|
+
}
|
|
951
|
+
: null,
|
|
923
952
|
handover: s.run.replay.handover
|
|
924
953
|
? { kind: s.run.replay.handover.kind, stepIndex: s.run.replay.handover.stepIndex }
|
|
925
954
|
: null,
|
|
@@ -934,7 +963,13 @@ export class ControlServer {
|
|
|
934
963
|
armed: s.run.intent.armed,
|
|
935
964
|
}
|
|
936
965
|
: null,
|
|
937
|
-
fragment: s.run?.fragment
|
|
966
|
+
fragment: s.run?.fragment
|
|
967
|
+
? {
|
|
968
|
+
state: s.run.fragment.state,
|
|
969
|
+
...(s.run.fragment.asked ? { asked: true } : {}),
|
|
970
|
+
...(s.run.fragment.counted ? { counted: true } : {}),
|
|
971
|
+
}
|
|
972
|
+
: null,
|
|
938
973
|
// The service's offer to record the turn as a recording's newest run,
|
|
939
974
|
// and what became of it (newestRun.md).
|
|
940
975
|
repeat: s.run?.repeat ? { state: s.run.repeat.state, of: s.run.repeat.runId } : null,
|
|
@@ -2989,12 +3024,43 @@ export class ControlServer {
|
|
|
2989
3024
|
? { runId: r.openedRunId, ownCalls: r.ownCalls ?? 0 }
|
|
2990
3025
|
: undefined;
|
|
2991
3026
|
const prompt = states.find((s) => s.armedBy === "prompt");
|
|
3027
|
+
// The plans that continued a plan in this turn (the service's fallbk.md
|
|
3028
|
+
// Part 3), directly or through one another: its hand-over's follow-on
|
|
3029
|
+
// work. Its baseline is the whole task, theirs only the part after the
|
|
3030
|
+
// hand-over, so its report bills the whole turn — its window runs through
|
|
3031
|
+
// theirs, and their readings are booked on it — and theirs book no saving.
|
|
3032
|
+
// Billed apart, the part after the hand-over would be saved twice.
|
|
3033
|
+
const continuedBy = (state, from) => {
|
|
3034
|
+
const chain = [];
|
|
3035
|
+
// A plan that ran no step reports `failed`, which books no saving: its
|
|
3036
|
+
// continuations then book their own, and it bills only its own window.
|
|
3037
|
+
if (state.outcome === "failed")
|
|
3038
|
+
return { chain, deriveUsd: 0 };
|
|
3039
|
+
const ids = new Set(state.scenarioId ? [state.scenarioId] : []);
|
|
3040
|
+
for (const s of states.slice(from + 1)) {
|
|
3041
|
+
if (s.continuation && ids.has(s.continuation.ofScenarioId)) {
|
|
3042
|
+
chain.push(s);
|
|
3043
|
+
if (s.scenarioId)
|
|
3044
|
+
ids.add(s.scenarioId);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
// A continuation declined after its values were read spent that reading
|
|
3048
|
+
// on this turn too, and its own report books nothing.
|
|
3049
|
+
const declined = (run.declinedContinuations ?? []).filter((s) => s.continuation !== undefined && ids.has(s.continuation.ofScenarioId));
|
|
3050
|
+
const deriveUsd = [...chain, ...declined].reduce((usd, s) => usd + s.deriveCostUsd, 0);
|
|
3051
|
+
return { chain, deriveUsd };
|
|
3052
|
+
};
|
|
3053
|
+
const chains = states.map((state, i) => continuedBy(state, i));
|
|
3054
|
+
/** A continuation no plan of this turn books: its plan before it ran nothing. */
|
|
3055
|
+
const booksOwn = (state) => state.continuation !== undefined && !chains.some((c, j) => states[j] !== state && c.chain.includes(state));
|
|
2992
3056
|
// One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
|
|
2993
3057
|
// for its own window of the transcript — from when it armed to when the next
|
|
2994
3058
|
// one did — so the turn's tokens are split between them, never counted twice.
|
|
2995
3059
|
// A turn with a single prompt-armed plan is exactly the report it always was.
|
|
2996
3060
|
states.forEach((state, i) => {
|
|
2997
|
-
const
|
|
3061
|
+
const { chain, deriveUsd: chainDeriveUsd } = chains[i];
|
|
3062
|
+
// The plan whose arm ends this one's window: never one that continued it.
|
|
3063
|
+
const next = states.slice(i + 1).find((s) => !chain.includes(s));
|
|
2998
3064
|
const nextMark = next?.armedBy === "intent" ? next.usageMark : undefined;
|
|
2999
3065
|
let windowCost = cost;
|
|
3000
3066
|
let windowMs = durationMs;
|
|
@@ -3027,6 +3093,8 @@ export class ControlServer {
|
|
|
3027
3093
|
runId: run.runId,
|
|
3028
3094
|
cutShort: cutShort || undefined,
|
|
3029
3095
|
redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
|
|
3096
|
+
chainDeriveUsd,
|
|
3097
|
+
booksOwnSaving: booksOwn(state),
|
|
3030
3098
|
});
|
|
3031
3099
|
if (!report)
|
|
3032
3100
|
return;
|
|
@@ -3087,6 +3155,23 @@ export class ControlServer {
|
|
|
3087
3155
|
if (report)
|
|
3088
3156
|
this.queue.push(() => recorder.reportExecution(report));
|
|
3089
3157
|
}
|
|
3158
|
+
// Continuations handed out and not armed (the service's fallbk.md Part 3):
|
|
3159
|
+
// a report for each ticket, with the reason, so the console says why and
|
|
3160
|
+
// not "no report". Nothing of theirs ran: unmeasured and costless.
|
|
3161
|
+
for (const declined of run.declinedContinuations ?? []) {
|
|
3162
|
+
if (states.includes(declined))
|
|
3163
|
+
continue;
|
|
3164
|
+
const report = this.replay.buildReport(declined, {
|
|
3165
|
+
sessionCostUsd: 0,
|
|
3166
|
+
measured: false,
|
|
3167
|
+
durationMs: 0,
|
|
3168
|
+
prompt: run.input || undefined,
|
|
3169
|
+
siblings: [...states, declined],
|
|
3170
|
+
runId: run.runId,
|
|
3171
|
+
});
|
|
3172
|
+
if (report)
|
|
3173
|
+
this.queue.push(() => recorder.reportExecution(report));
|
|
3174
|
+
}
|
|
3090
3175
|
}
|
|
3091
3176
|
/**
|
|
3092
3177
|
* The plan's own calls of the turn, whole, as an answered turn's report
|
|
@@ -3476,6 +3561,21 @@ export class ControlServer {
|
|
|
3476
3561
|
if (stashed)
|
|
3477
3562
|
return this.preToolAnswer(this.replay.deliverInstead(stashed, toolName)) ?? {};
|
|
3478
3563
|
}
|
|
3564
|
+
// A continuation request of another call of this same message is still
|
|
3565
|
+
// out (the service's fallbk.md Part 3): calls of one message reach their
|
|
3566
|
+
// hooks at once. This one waits for it, so it meets whatever that request
|
|
3567
|
+
// armed — a direct plan whose first call is not `run_scenario` is vetoed
|
|
3568
|
+
// just below — and never opens the fragment beside it.
|
|
3569
|
+
const attempt = run.fragment?.attempt;
|
|
3570
|
+
if (attempt)
|
|
3571
|
+
await this.withinBudget(attempt, CONTINUATION_PROBE_MS + CONTINUATION_ARM_MS);
|
|
3572
|
+
// A continuation armed beside another call of this message is told on
|
|
3573
|
+
// that call's PostToolUse. A call to one of its own tools must not run
|
|
3574
|
+
// before then — the plan makes that call itself — so it is answered in
|
|
3575
|
+
// its place, and that answer tells the model of the plan.
|
|
3576
|
+
const told = planning ? undefined : this.announceContinuationInstead(run, toolName);
|
|
3577
|
+
if (told)
|
|
3578
|
+
return told;
|
|
3479
3579
|
const live = this.livePlan(run);
|
|
3480
3580
|
/** `run_scenario` for this session's own announced direct plan: allowed, see below. */
|
|
3481
3581
|
let allowDirect = false;
|
|
@@ -3675,7 +3775,46 @@ export class ControlServer {
|
|
|
3675
3775
|
return {};
|
|
3676
3776
|
});
|
|
3677
3777
|
}
|
|
3678
|
-
|
|
3778
|
+
// 2a. The model's first call after a plan handed over: the work recorded
|
|
3779
|
+
// after an earlier hand-over at the same step may have a plan of its
|
|
3780
|
+
// own (the service's fallbk.md Part 3). Asked with the hand-over, not
|
|
3781
|
+
// with words, and armed on this call, whatever the call is.
|
|
3782
|
+
const before = run.replay;
|
|
3783
|
+
const continued = planning
|
|
3784
|
+
? undefined
|
|
3785
|
+
: await this.tryContinuation(session, run, toolName, intent, modelArgs);
|
|
3786
|
+
if (continued) {
|
|
3787
|
+
if (!overran)
|
|
3788
|
+
return continued;
|
|
3789
|
+
// The deadline answered first: the call ran as the model wrote it, and
|
|
3790
|
+
// the plan was never announced. The model's own work from this call is
|
|
3791
|
+
// the fragment, as it would have been with no plan.
|
|
3792
|
+
const late = run.replay;
|
|
3793
|
+
if (late && late !== before && late.continuation) {
|
|
3794
|
+
logLine("replay.decision", {
|
|
3795
|
+
verdict: "no-steer",
|
|
3796
|
+
code: "arm_unannounced",
|
|
3797
|
+
run: run.runId,
|
|
3798
|
+
scenario: late.scenarioId ?? undefined,
|
|
3799
|
+
why: "the call had to go ahead before this continuation's directive could replace it — retired unannounced, so never run",
|
|
3800
|
+
});
|
|
3801
|
+
const at = run.replays.indexOf(late);
|
|
3802
|
+
if (at >= 0)
|
|
3803
|
+
run.replays.splice(at, 1);
|
|
3804
|
+
run.replay = before;
|
|
3805
|
+
this.keepDeclinedContinuation(session, run, late, "arm_unannounced");
|
|
3806
|
+
if (run.parkedFragment?.by === late) {
|
|
3807
|
+
run.fragment = run.parkedFragment.fragment;
|
|
3808
|
+
run.parkedFragment = undefined;
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
// A continuation that retired having run no step — refused, vetoed, or
|
|
3813
|
+
// left by the model — gives the model's work back to the fragment.
|
|
3814
|
+
this.restoreParkedFragment(run);
|
|
3815
|
+
// A continuation armed beside this call is this turn's plan, waiting for
|
|
3816
|
+
// the call's PostToolUse: no other plan is looked for in its place.
|
|
3817
|
+
const armed = planning || continued || run.replay !== before
|
|
3679
3818
|
? undefined
|
|
3680
3819
|
: await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
|
|
3681
3820
|
if (armed) {
|
|
@@ -3696,8 +3835,9 @@ export class ControlServer {
|
|
|
3696
3835
|
}
|
|
3697
3836
|
// 3. No scenario took over: the model's own work after a hand-over is
|
|
3698
3837
|
// recorded as a fragment, starting with this call.
|
|
3699
|
-
// Not for the call an intent match just armed a plan
|
|
3700
|
-
// runs next, and the fragment opens on the model's
|
|
3838
|
+
// Not for the call a continuation or an intent match just armed a plan
|
|
3839
|
+
// beside: the plan runs next, and the fragment opens on the model's
|
|
3840
|
+
// own work after it.
|
|
3701
3841
|
if (run.fragment?.state === "due" && !run.pendingDirective) {
|
|
3702
3842
|
await this.openFragment(session, run, reasoning, toolName, modelArgs);
|
|
3703
3843
|
}
|
|
@@ -4327,9 +4467,7 @@ export class ControlServer {
|
|
|
4327
4467
|
run.intent.requests += 1;
|
|
4328
4468
|
// Every scenario armed this turn, so a continuation cannot re-arm the plan
|
|
4329
4469
|
// that just handed over.
|
|
4330
|
-
const exclude = run
|
|
4331
|
-
.map((s) => s.scenarioId)
|
|
4332
|
-
.filter((id) => typeof id === "string");
|
|
4470
|
+
const exclude = this.armedScenarioIds(run);
|
|
4333
4471
|
const startedAt = Date.now();
|
|
4334
4472
|
const framework = frameworkOf(session);
|
|
4335
4473
|
const probe = this.recorder
|
|
@@ -4441,6 +4579,356 @@ export class ControlServer {
|
|
|
4441
4579
|
run.pendingDirective = { state, text: directive };
|
|
4442
4580
|
return undefined;
|
|
4443
4581
|
}
|
|
4582
|
+
/**
|
|
4583
|
+
* A continuation (the service's fallbk.md Part 3), on the model's first call
|
|
4584
|
+
* after a plan handed over.
|
|
4585
|
+
*
|
|
4586
|
+
* The model's work after an earlier hand-over of the same plan at the same
|
|
4587
|
+
* step was recorded once, as a fragment, and the service may have calculated
|
|
4588
|
+
* it into a plan. Until 0.3.23 nothing asked for that plan: the only way a
|
|
4589
|
+
* plan started mid-task was a match of the model's words against plan
|
|
4590
|
+
* intents ({@link tryIntentMatch}, off by default), and a one-line note
|
|
4591
|
+
* after a hand-over does not read like a plan's intent paragraph. Here the
|
|
4592
|
+
* hand-over itself is the question — the plan and the step — so the service
|
|
4593
|
+
* names one recording and matches no words.
|
|
4594
|
+
*
|
|
4595
|
+
* It arms on the model's first call after the hand-over, whatever that call
|
|
4596
|
+
* is (Eran, 2026-10-26). Runner 0.3.23 armed it only when the call was the
|
|
4597
|
+
* plan's first step, and lost it whenever the model first re-checked what
|
|
4598
|
+
* the plan before had already done. How the model is told depends on the
|
|
4599
|
+
* call. A call to one of the plan's own tools is answered in its place: the
|
|
4600
|
+
* plan makes that call itself, and letting it run would make it twice. Any
|
|
4601
|
+
* other call runs as the model wrote it, and the plan is told on its
|
|
4602
|
+
* PostToolUse, as an intent plan armed beside the model's own work is. The
|
|
4603
|
+
* plan before it stopped because the next step needed a judgement; the
|
|
4604
|
+
* continuation runs the half that was recorded, whatever the model would
|
|
4605
|
+
* have judged.
|
|
4606
|
+
*
|
|
4607
|
+
* Its values are read from the turn's own request — the rest of the task is
|
|
4608
|
+
* the one the user asked for — with the model's words, the call it was about
|
|
4609
|
+
* to make and this turn's tool results beside it, as for any plan started at
|
|
4610
|
+
* a tool call.
|
|
4611
|
+
*
|
|
4612
|
+
* Asked once per hand-over, waited on {@link CONTINUATION_PROBE_MS} and
|
|
4613
|
+
* {@link CONTINUATION_ARM_MS} at most, and not at all while the service is
|
|
4614
|
+
* failing. Every way it does not arm leaves the turn as it was: the model's
|
|
4615
|
+
* call runs, and its work is recorded as the fragment. A plan handed out and
|
|
4616
|
+
* not armed is reported for its ticket, with the reason.
|
|
4617
|
+
*/
|
|
4618
|
+
async tryContinuation(session, run, toolName, intent, modelArgs) {
|
|
4619
|
+
const f = run.fragment;
|
|
4620
|
+
if (!f || f.state !== "due" || f.asked)
|
|
4621
|
+
return undefined;
|
|
4622
|
+
if (!this.replay.enabled || !this.replay.intentMatch.enabled || !this.continuationsOn())
|
|
4623
|
+
return undefined;
|
|
4624
|
+
// Never in a session of another host: a plan is started there by the
|
|
4625
|
+
// prompt, and stepped through by an adapter that was told of it then.
|
|
4626
|
+
if (otherHost(session) || this.recordsOnly(session, run))
|
|
4627
|
+
return undefined;
|
|
4628
|
+
if (!isIntentMatcher(this.recorder))
|
|
4629
|
+
return undefined;
|
|
4630
|
+
// The model calling the delivery tool itself is no step of a plan.
|
|
4631
|
+
if (this.replay.isDirectTool(toolName))
|
|
4632
|
+
return undefined;
|
|
4633
|
+
if (!this.service.canWait()) {
|
|
4634
|
+
logDetail("replay.continuation_skipped", { run: run.runId, tool: toolName, why: "service unhealthy" });
|
|
4635
|
+
return undefined;
|
|
4636
|
+
}
|
|
4637
|
+
f.asked = true;
|
|
4638
|
+
// Calls of one message reach their hooks at once. The others wait for
|
|
4639
|
+
// this attempt (`onToolPre`), so they meet whatever it armed, and none
|
|
4640
|
+
// opens the fragment while it is still out.
|
|
4641
|
+
let done;
|
|
4642
|
+
f.attempt = new Promise((resolve) => {
|
|
4643
|
+
done = resolve;
|
|
4644
|
+
});
|
|
4645
|
+
try {
|
|
4646
|
+
return await this.continuationAttempt(session, run, f, toolName, intent, modelArgs);
|
|
4647
|
+
}
|
|
4648
|
+
finally {
|
|
4649
|
+
f.attempt = undefined;
|
|
4650
|
+
done();
|
|
4651
|
+
}
|
|
4652
|
+
}
|
|
4653
|
+
/** The body of {@link tryContinuation}, once the continuation is asked for. */
|
|
4654
|
+
async continuationAttempt(session, run, f, toolName, intent, modelArgs) {
|
|
4655
|
+
const recorder = this.recorder;
|
|
4656
|
+
if (!isIntentMatcher(recorder))
|
|
4657
|
+
return undefined;
|
|
4658
|
+
const framework = frameworkOf(session);
|
|
4659
|
+
const handover = {
|
|
4660
|
+
scenarioId: f.scenarioId,
|
|
4661
|
+
stepIndex: f.stepIndex,
|
|
4662
|
+
...(f.kind ? { kind: f.kind } : {}),
|
|
4663
|
+
...(f.handoverId ? { id: f.handoverId } : {}),
|
|
4664
|
+
};
|
|
4665
|
+
// Whether this hand-over is still the turn's: its fragment due, nothing
|
|
4666
|
+
// recording, the turn open. Checked after every wait.
|
|
4667
|
+
const stillDue = () => run.fragment === f && f.state === "due" && !run.recording && !run.finished;
|
|
4668
|
+
let landed = false;
|
|
4669
|
+
const probe = recorder
|
|
4670
|
+
.matchIntent({
|
|
4671
|
+
// Answered by the hand-over alone, so no words go: a service from
|
|
4672
|
+
// before continuations would match them against plan intents, and
|
|
4673
|
+
// the agent's reasoning is not sent for nothing.
|
|
4674
|
+
text: "",
|
|
4675
|
+
currentToolName: toolName,
|
|
4676
|
+
exclude: this.armedScenarioIds(run),
|
|
4677
|
+
handover,
|
|
4678
|
+
...(framework ? { framework } : {}),
|
|
4679
|
+
}, { timeoutMs: CONTINUATION_REQUEST_MS })
|
|
4680
|
+
.catch(() => null)
|
|
4681
|
+
.then((answer) => {
|
|
4682
|
+
landed = true;
|
|
4683
|
+
return answer;
|
|
4684
|
+
});
|
|
4685
|
+
const answer = await this.withinBudget(probe, CONTINUATION_PROBE_MS);
|
|
4686
|
+
if (!answer) {
|
|
4687
|
+
// A plan a late answer hands out is reported as late, for its ticket.
|
|
4688
|
+
// Whether the hand-over was counted is the service's to know: the
|
|
4689
|
+
// fragment's create call carries the same id.
|
|
4690
|
+
if (!landed)
|
|
4691
|
+
void probe.then((late) => this.noteLateContinuation(run, f, late));
|
|
4692
|
+
logDetail("replay.continuation_miss", {
|
|
4693
|
+
run: run.runId,
|
|
4694
|
+
tool: toolName,
|
|
4695
|
+
why: landed ? "the request failed" : "no answer in time",
|
|
4696
|
+
});
|
|
4697
|
+
return undefined;
|
|
4698
|
+
}
|
|
4699
|
+
// Only a service that knows continuations answers one. An older service
|
|
4700
|
+
// matched the text by words instead, and that is no continuation.
|
|
4701
|
+
if (answer.continuation === undefined)
|
|
4702
|
+
return undefined;
|
|
4703
|
+
f.counted = answer.continuation?.counted === true;
|
|
4704
|
+
const match = answer.matched;
|
|
4705
|
+
if (!answer.continuation || !match) {
|
|
4706
|
+
logDetail("replay.continuation_none", {
|
|
4707
|
+
run: run.runId,
|
|
4708
|
+
continues: `${f.scenarioId}@${f.stepIndex}`,
|
|
4709
|
+
recording: answer.continuation?.runId,
|
|
4710
|
+
why: answer.continuation
|
|
4711
|
+
? "the work recorded after an earlier hand-over here has no plan to hand out"
|
|
4712
|
+
: "no hand-over at this step was recorded before",
|
|
4713
|
+
});
|
|
4714
|
+
return undefined;
|
|
4715
|
+
}
|
|
4716
|
+
const continuation = {
|
|
4717
|
+
ofScenarioId: f.scenarioId,
|
|
4718
|
+
stepIndex: f.stepIndex,
|
|
4719
|
+
runId: answer.continuation.runId,
|
|
4720
|
+
};
|
|
4721
|
+
if (!stillDue()) {
|
|
4722
|
+
this.keepDeclinedContinuation(session, run, this.replay.declinedContinuation(match, "match_late", continuation));
|
|
4723
|
+
return undefined;
|
|
4724
|
+
}
|
|
4725
|
+
const startedAt = Date.now();
|
|
4726
|
+
// Valid JSON with the serializer's own truncation marker, never a raw
|
|
4727
|
+
// slice: the live call handed to the reading.
|
|
4728
|
+
const toolInput = serializeCapped(redact(modelArgs), {
|
|
4729
|
+
maxPayloadBytes: 2048,
|
|
4730
|
+
maxStringBytes: 256,
|
|
4731
|
+
});
|
|
4732
|
+
// The rest of the task the user asked for: its values are in the turn's
|
|
4733
|
+
// request, and the model's words say what it is doing next.
|
|
4734
|
+
const words = (intent?.text ?? "").trim().slice(0, 4_000);
|
|
4735
|
+
const prompt = [run.input, words].filter((s) => s.trim().length > 0).join("\n\n");
|
|
4736
|
+
const arming = this.armOrLog(run, this.replay.arm(match, prompt, this.wrapped, "intent", {
|
|
4737
|
+
liveCall: { toolName, toolInput },
|
|
4738
|
+
recentResults: this.recentResultsFor(session, "call"),
|
|
4739
|
+
agentDay: this.agentDayFor(session),
|
|
4740
|
+
}, undefined, { continuation }));
|
|
4741
|
+
const state = await this.withinBudget(arming, CONTINUATION_ARM_MS);
|
|
4742
|
+
if (state === undefined)
|
|
4743
|
+
return undefined; // it threw, and said so
|
|
4744
|
+
if (state === null) {
|
|
4745
|
+
logLine("replay.decision", {
|
|
4746
|
+
verdict: "no-steer",
|
|
4747
|
+
code: "arm_timeout",
|
|
4748
|
+
run: run.runId,
|
|
4749
|
+
scenario: match.scenarioId ?? undefined,
|
|
4750
|
+
continues: `${f.scenarioId}@${f.stepIndex}`,
|
|
4751
|
+
ms: CONTINUATION_ARM_MS,
|
|
4752
|
+
why: "the continuation's values were not read before this call had to go ahead — never delivered, so never run",
|
|
4753
|
+
});
|
|
4754
|
+
void arming.then((late) => {
|
|
4755
|
+
if (late)
|
|
4756
|
+
this.keepDeclinedContinuation(session, run, late, "arm_timeout");
|
|
4757
|
+
});
|
|
4758
|
+
return undefined;
|
|
4759
|
+
}
|
|
4760
|
+
state.sessionId = session.sessionId;
|
|
4761
|
+
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
4762
|
+
this.declineWithoutDeliveryTool(state);
|
|
4763
|
+
if (!state.plan) {
|
|
4764
|
+
// A gate declined it, and said why (`state.declined`).
|
|
4765
|
+
this.keepDeclinedContinuation(session, run, state);
|
|
4766
|
+
return undefined;
|
|
4767
|
+
}
|
|
4768
|
+
// Its values, worked out before the model is told of it: a reading that
|
|
4769
|
+
// did not settle in time, or the plan's own code for its values failing,
|
|
4770
|
+
// is said as what it is, and the plan is never handed out.
|
|
4771
|
+
let paramsFailed = false;
|
|
4772
|
+
const settled = await this.withinBudget(state.plan.ready().then(() => true, () => {
|
|
4773
|
+
paramsFailed = true;
|
|
4774
|
+
return false;
|
|
4775
|
+
}), Math.max(0, CONTINUATION_ARM_MS - (Date.now() - startedAt)));
|
|
4776
|
+
if (!stillDue()) {
|
|
4777
|
+
this.keepDeclinedContinuation(session, run, state, "arm_unannounced");
|
|
4778
|
+
return undefined;
|
|
4779
|
+
}
|
|
4780
|
+
if (settled === null) {
|
|
4781
|
+
this.keepDeclinedContinuation(session, run, state, "arm_timeout");
|
|
4782
|
+
return undefined;
|
|
4783
|
+
}
|
|
4784
|
+
if (paramsFailed) {
|
|
4785
|
+
this.keepDeclinedContinuation(session, run, state, "params_failed");
|
|
4786
|
+
return undefined;
|
|
4787
|
+
}
|
|
4788
|
+
// Whatever the model's call is, the plan takes the rest of the task. A
|
|
4789
|
+
// call to one of its own tools is answered in its place: the plan makes
|
|
4790
|
+
// that call itself, and letting it run first would make it twice — a
|
|
4791
|
+
// duplicated write. Any other call (a re-check of what the plan before
|
|
4792
|
+
// did, say) runs as the model wrote it, and the plan is told after it.
|
|
4793
|
+
const inPlace = state.plan.usesTool(toolName);
|
|
4794
|
+
state.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
4795
|
+
state.markUsage = () => markTranscriptUsage(session.transcriptPath);
|
|
4796
|
+
run.replays.push(state);
|
|
4797
|
+
run.replay = state;
|
|
4798
|
+
// From here the continuation is this turn's plan. A hand-over of its own,
|
|
4799
|
+
// if it makes one, schedules its own fragment. The one the plan before it
|
|
4800
|
+
// left due is parked, and put back should this plan run no step.
|
|
4801
|
+
run.parkedFragment = { by: state, fragment: f };
|
|
4802
|
+
run.fragment = undefined;
|
|
4803
|
+
const directive = this.replay.directiveFor(state) ?? "";
|
|
4804
|
+
logLine("replay.continuation_armed", {
|
|
4805
|
+
run: run.runId,
|
|
4806
|
+
scenario: state.scenarioId ?? undefined,
|
|
4807
|
+
continues: `${continuation.ofScenarioId}@${continuation.stepIndex}`,
|
|
4808
|
+
recording: continuation.runId,
|
|
4809
|
+
mode: state.mode,
|
|
4810
|
+
instead: inPlace ? toolName : undefined,
|
|
4811
|
+
after: inPlace ? undefined : toolName,
|
|
4812
|
+
ms: Date.now() - startedAt,
|
|
4813
|
+
why: inPlace
|
|
4814
|
+
? "the plan calculated from the work recorded after an earlier hand-over at this step takes the rest of the task, in place of the model's call to one of its own tools"
|
|
4815
|
+
: "the plan calculated from the work recorded after an earlier hand-over at this step takes the rest of the task after the model's call, which runs as written",
|
|
4816
|
+
});
|
|
4817
|
+
if (inPlace)
|
|
4818
|
+
return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
|
|
4819
|
+
run.pendingDirective = { state, text: directive };
|
|
4820
|
+
return undefined;
|
|
4821
|
+
}
|
|
4822
|
+
/**
|
|
4823
|
+
* A call meeting a continuation that armed beside another call of the same
|
|
4824
|
+
* message and still waits to be told (`pendingDirective`). A call to one of
|
|
4825
|
+
* the plan's own tools gets the plan's directive in its place: letting it
|
|
4826
|
+
* run would have the plan make the same call again — a duplicated write.
|
|
4827
|
+
* Any other call runs, and the plan is told on the first PostToolUse.
|
|
4828
|
+
*/
|
|
4829
|
+
announceContinuationInstead(run, toolName) {
|
|
4830
|
+
const pending = run.pendingDirective;
|
|
4831
|
+
const state = pending?.state;
|
|
4832
|
+
if (!pending || !state || state !== run.replay || !state.continuation)
|
|
4833
|
+
return undefined;
|
|
4834
|
+
if (!state.plan || state.retired || !pending.text || !state.plan.usesTool(toolName))
|
|
4835
|
+
return undefined;
|
|
4836
|
+
run.pendingDirective = undefined;
|
|
4837
|
+
logLine("replay.continuation_told", {
|
|
4838
|
+
run: run.runId,
|
|
4839
|
+
scenario: state.scenarioId ?? undefined,
|
|
4840
|
+
instead: toolName,
|
|
4841
|
+
why: "a call to one of the continuation's own tools, made beside the call it armed on — answered in its place, so the plan does not make it twice",
|
|
4842
|
+
});
|
|
4843
|
+
return this.preToolAnswer(this.replay.deliverInstead(pending.text, toolName));
|
|
4844
|
+
}
|
|
4845
|
+
/**
|
|
4846
|
+
* Put back the fragment a continuation took the place of, when that
|
|
4847
|
+
* continuation retired having run no step and handed nothing over —
|
|
4848
|
+
* `run_scenario` refused, the plan vetoed, or a steer pin left by the
|
|
4849
|
+
* model. The model is doing the rest of the task itself, and that work is
|
|
4850
|
+
* recorded from its next call, as it would have been with no continuation.
|
|
4851
|
+
*/
|
|
4852
|
+
restoreParkedFragment(run) {
|
|
4853
|
+
const parked = run.parkedFragment;
|
|
4854
|
+
if (!parked || run.fragment || run.recording || run.finished)
|
|
4855
|
+
return;
|
|
4856
|
+
const s = parked.by;
|
|
4857
|
+
if (!s.retired || s.stepsPinned > 0 || s.handover || s.ranToEnd || s.running || this.runningPlans.has(s))
|
|
4858
|
+
return;
|
|
4859
|
+
run.parkedFragment = undefined;
|
|
4860
|
+
run.fragment = parked.fragment;
|
|
4861
|
+
// Its report says why, for its ticket: told of the plan, the model never
|
|
4862
|
+
// asked for it — another call of the same message came first, or it chose
|
|
4863
|
+
// its own way after the directive.
|
|
4864
|
+
if (!s.declined && !s.directRequested)
|
|
4865
|
+
s.declined = "not_taken";
|
|
4866
|
+
logLine("replay.continuation_unused", {
|
|
4867
|
+
run: run.runId,
|
|
4868
|
+
scenario: s.scenarioId ?? undefined,
|
|
4869
|
+
why: "the continuation retired without running a step — the model's own work is recorded from this call, as before",
|
|
4870
|
+
});
|
|
4871
|
+
}
|
|
4872
|
+
/**
|
|
4873
|
+
* A continuation handed out and not armed: kept, retired, for its ticket's
|
|
4874
|
+
* report at the seal (`not_steered`, with the reason, unmeasured). `code`
|
|
4875
|
+
* says why when no gate did.
|
|
4876
|
+
*/
|
|
4877
|
+
keepDeclinedContinuation(session, run, state, code) {
|
|
4878
|
+
if (code) {
|
|
4879
|
+
state.declined = code;
|
|
4880
|
+
logLine("replay.decision", {
|
|
4881
|
+
verdict: "no-steer",
|
|
4882
|
+
code,
|
|
4883
|
+
run: run.runId,
|
|
4884
|
+
scenario: state.scenarioId ?? undefined,
|
|
4885
|
+
continues: state.continuation
|
|
4886
|
+
? `${state.continuation.ofScenarioId}@${state.continuation.stepIndex}`
|
|
4887
|
+
: undefined,
|
|
4888
|
+
why: code === "params_failed"
|
|
4889
|
+
? "the continuation's own code for its values failed — never handed out, so never run"
|
|
4890
|
+
: "the continuation was not armed in time — never handed out, so never run",
|
|
4891
|
+
});
|
|
4892
|
+
}
|
|
4893
|
+
state.sessionId = session.sessionId;
|
|
4894
|
+
state.plan = undefined;
|
|
4895
|
+
state.mode = "none";
|
|
4896
|
+
state.stepsPlanned = 0;
|
|
4897
|
+
state.retired = true;
|
|
4898
|
+
state.pinned.clear();
|
|
4899
|
+
// A seal has reported already: nothing more can be said for this turn.
|
|
4900
|
+
if (run.finished)
|
|
4901
|
+
return;
|
|
4902
|
+
(run.declinedContinuations ??= []).push(state);
|
|
4903
|
+
}
|
|
4904
|
+
/** A continuation request's answer that came after the call it was for went ahead. */
|
|
4905
|
+
noteLateContinuation(run, handover, late) {
|
|
4906
|
+
const match = late?.matched;
|
|
4907
|
+
if (!late?.continuation || !match?.executionTicket || run.finished)
|
|
4908
|
+
return;
|
|
4909
|
+
const state = this.replay.declinedContinuation(match, "match_late", {
|
|
4910
|
+
ofScenarioId: handover.scenarioId,
|
|
4911
|
+
stepIndex: handover.stepIndex,
|
|
4912
|
+
runId: late.continuation.runId,
|
|
4913
|
+
});
|
|
4914
|
+
(run.declinedContinuations ??= []).push(state);
|
|
4915
|
+
}
|
|
4916
|
+
/**
|
|
4917
|
+
* Every scenario this turn was handed and armed or declined: never handed
|
|
4918
|
+
* out again in it. A plan that handed over is among them, so its own
|
|
4919
|
+
* continuation request can never return it.
|
|
4920
|
+
*/
|
|
4921
|
+
armedScenarioIds(run) {
|
|
4922
|
+
return [...run.replays, ...(run.declinedContinuations ?? [])]
|
|
4923
|
+
.map((s) => s.scenarioId)
|
|
4924
|
+
.filter((id) => typeof id === "string");
|
|
4925
|
+
}
|
|
4926
|
+
/** Whether continuations are asked for: on unless `BIR_CONTINUATIONS=0`. */
|
|
4927
|
+
continuationsOn() {
|
|
4928
|
+
if (this.opts.continuations !== undefined)
|
|
4929
|
+
return this.opts.continuations;
|
|
4930
|
+
return process.env.BIR_CONTINUATIONS !== "0";
|
|
4931
|
+
}
|
|
4444
4932
|
/**
|
|
4445
4933
|
* Whether the model's call is plausibly `state`'s first step: its tool, and
|
|
4446
4934
|
* arguments the pin guard would pin to that step's input. Anything that
|
|
@@ -4501,7 +4989,15 @@ export class ControlServer {
|
|
|
4501
4989
|
const h = state?.handover;
|
|
4502
4990
|
if (!h || !state?.scenarioId)
|
|
4503
4991
|
return;
|
|
4504
|
-
run.fragment = {
|
|
4992
|
+
run.fragment = {
|
|
4993
|
+
scenarioId: state.scenarioId,
|
|
4994
|
+
stepIndex: h.stepIndex,
|
|
4995
|
+
state: "due",
|
|
4996
|
+
// Which half the model's work after it is (the service's fallbk.md
|
|
4997
|
+
// Part 3), and the id that makes the hand-over one hit.
|
|
4998
|
+
kind: h.kind,
|
|
4999
|
+
handoverId: `ho_${randomUUID()}`,
|
|
5000
|
+
};
|
|
4505
5001
|
}
|
|
4506
5002
|
/**
|
|
4507
5003
|
* Open the fragment run and record from this call on (fallbk.md D7).
|
|
@@ -4521,9 +5017,18 @@ export class ControlServer {
|
|
|
4521
5017
|
return;
|
|
4522
5018
|
const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
|
|
4523
5019
|
const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
|
|
5020
|
+
// Which half of a step's test this work is, and the hand-over's id, which
|
|
5021
|
+
// its continuation request carried too: the service counts the hand-over
|
|
5022
|
+
// once (Part 3). `counted` says the same to a service from before ids.
|
|
5023
|
+
const sent = {
|
|
5024
|
+
...fallbackOf,
|
|
5025
|
+
...(f.kind ? { kind: f.kind } : {}),
|
|
5026
|
+
...(f.handoverId ? { handoverId: f.handoverId } : {}),
|
|
5027
|
+
...(f.counted ? { counted: true } : {}),
|
|
5028
|
+
};
|
|
4524
5029
|
// The fragment is a run of the same host as the turn it continues.
|
|
4525
5030
|
const framework = frameworkOf(session);
|
|
4526
|
-
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf, framework } : { fallbackOf });
|
|
5031
|
+
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf: sent, framework } : { fallbackOf: sent });
|
|
4527
5032
|
for (const c of run.correlations.values()) {
|
|
4528
5033
|
c.settled = true;
|
|
4529
5034
|
if (c.fallback)
|
|
@@ -7,9 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { ExecutionStage, ExecutionStepResult, FallbackKind } from "../replay/types.js";
|
|
9
9
|
export interface StartRunOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Marks a *fragment* (fallbk.md D7). `kind`: the kind of hand-over, so the
|
|
12
|
+
* other half of a step's test gets a fragment of its own (Part 3).
|
|
13
|
+
* `handoverId`: the hand-over's id, which its continuation request carried
|
|
14
|
+
* too, so the service counts the hand-over once. `counted`: the same for a
|
|
15
|
+
* service from before ids — it counted the hand-over when asked for its
|
|
16
|
+
* continuation.
|
|
17
|
+
*/
|
|
10
18
|
fallbackOf?: {
|
|
11
19
|
scenarioId: string;
|
|
12
20
|
stepIndex: number;
|
|
21
|
+
kind?: string;
|
|
22
|
+
handoverId?: string;
|
|
23
|
+
counted?: boolean;
|
|
13
24
|
};
|
|
14
25
|
/**
|
|
15
26
|
* Marks a *repeat* (the service's newestRun.md): a matched turn whose plan
|
|
@@ -287,7 +298,12 @@ export interface ServiceProber {
|
|
|
287
298
|
export declare function isServiceProber(r: Recorder): r is Recorder & ServiceProber;
|
|
288
299
|
/** What a ReAct iteration sends to find a scenario by its intent (fallbk.md §Runner 4). */
|
|
289
300
|
export interface IntentMatchRequest {
|
|
290
|
-
/**
|
|
301
|
+
/**
|
|
302
|
+
* What the agent wrote, or was thinking, as it chose the call. Never empty
|
|
303
|
+
* on an intent probe. A continuation request ({@link handover}) is answered
|
|
304
|
+
* by the hand-over alone: it sends the text, possibly empty, and the service
|
|
305
|
+
* does not read it.
|
|
306
|
+
*/
|
|
291
307
|
text: string;
|
|
292
308
|
currentToolName?: string;
|
|
293
309
|
/** Scenario ids already armed this turn. */
|
|
@@ -298,12 +314,37 @@ export interface IntentMatchRequest {
|
|
|
298
314
|
* what a missing value means to the service.
|
|
299
315
|
*/
|
|
300
316
|
framework?: string;
|
|
317
|
+
/**
|
|
318
|
+
* A continuation request (the service's fallbk.md Part 3): the plan that
|
|
319
|
+
* just handed the task to the model, and the step it stopped in front of.
|
|
320
|
+
* The service answers with the plan of the work recorded after an earlier
|
|
321
|
+
* hand-over at that step, if there is one, and matches no words. `kind`:
|
|
322
|
+
* the kind of hand-over (the other half of a step's test is continued only
|
|
323
|
+
* by the other half's recording); `id`: the hand-over's id, which its
|
|
324
|
+
* fragment's create call carries too, so the hand-over counts once.
|
|
325
|
+
*/
|
|
326
|
+
handover?: {
|
|
327
|
+
scenarioId: string;
|
|
328
|
+
stepIndex: number;
|
|
329
|
+
kind?: string;
|
|
330
|
+
id?: string;
|
|
331
|
+
};
|
|
301
332
|
}
|
|
302
333
|
/** The answer to a probe. */
|
|
303
334
|
export interface IntentMatchAnswer {
|
|
304
335
|
matched: RunMatch | null;
|
|
305
336
|
/** The best score the service saw, matched or not — for the log, to tune by. */
|
|
306
337
|
bestSimilarity?: number | null;
|
|
338
|
+
/**
|
|
339
|
+
* The answer to a continuation request: the recording of the earlier
|
|
340
|
+
* hand-over at that step, and whether this hand-over was counted as a hit
|
|
341
|
+
* on it. Null when there is none; absent from a service that does not know
|
|
342
|
+
* continuations, which matched the text by words instead.
|
|
343
|
+
*/
|
|
344
|
+
continuation?: {
|
|
345
|
+
runId: string;
|
|
346
|
+
counted?: boolean;
|
|
347
|
+
} | null;
|
|
307
348
|
}
|
|
308
349
|
/** How long a probe's caller will wait; the request is abandoned past it. */
|
|
309
350
|
export interface IntentMatchOptions {
|
|
@@ -374,6 +415,15 @@ export interface ExecutionReport {
|
|
|
374
415
|
* which is what every report said before this field existed.
|
|
375
416
|
*/
|
|
376
417
|
baselineEligible?: boolean;
|
|
418
|
+
/**
|
|
419
|
+
* Set on a continuation's report (the service's fallbk.md Part 3): the plan
|
|
420
|
+
* it continued in this turn, which handed over in front of it. That plan's
|
|
421
|
+
* report bills the whole turn against its own baseline, which covers this
|
|
422
|
+
* part of the task too, so the service books this one no saving.
|
|
423
|
+
*/
|
|
424
|
+
continues?: {
|
|
425
|
+
scenarioId: string;
|
|
426
|
+
};
|
|
377
427
|
/**
|
|
378
428
|
* Which gate of the ladder declined, on a `not_steered` report.
|
|
379
429
|
*
|
|
@@ -683,6 +683,9 @@ export class RemoteRecorder {
|
|
|
683
683
|
return {
|
|
684
684
|
matched: b.matched ?? null,
|
|
685
685
|
...(typeof b.bestSimilarity === "number" ? { bestSimilarity: b.bestSimilarity } : {}),
|
|
686
|
+
// Null and absent mean different things: no continuation, or a
|
|
687
|
+
// service that does not know continuations (fallbk.md Part 3).
|
|
688
|
+
...(b.continuation !== undefined ? { continuation: b.continuation } : {}),
|
|
686
689
|
};
|
|
687
690
|
}
|
|
688
691
|
catch (err) {
|
|
@@ -1040,6 +1043,9 @@ export class RemoteRecorder {
|
|
|
1040
1043
|
fallbackStepIndex: report.fallbackStepIndex,
|
|
1041
1044
|
fallbackKind: report.fallbackKind,
|
|
1042
1045
|
baselineEligible: report.baselineEligible,
|
|
1046
|
+
// A continuation's report: the plan it continued, whose report
|
|
1047
|
+
// books the turn (the service's fallbk.md Part 3). Additive.
|
|
1048
|
+
continues: report.continues,
|
|
1043
1049
|
// Why nothing ran, when nothing ran. Additive: an older service drops
|
|
1044
1050
|
// the field and books the cost exactly as it always did.
|
|
1045
1051
|
declined: report.declined,
|
|
@@ -1070,6 +1076,8 @@ export class RemoteRecorder {
|
|
|
1070
1076
|
stepsFailed: failed || undefined,
|
|
1071
1077
|
baselineEligible: report.baselineEligible === false ? false : undefined,
|
|
1072
1078
|
declined: report.declined,
|
|
1079
|
+
// A continuation's report: the plan whose report books the turn.
|
|
1080
|
+
continues: report.continues?.scenarioId,
|
|
1073
1081
|
// `bir investigate` prints it beside the outcome: "redone by run_…".
|
|
1074
1082
|
redoneBy: report.redoneBy?.runId,
|
|
1075
1083
|
ownCalls: report.redoneBy?.ownCalls,
|
|
@@ -388,6 +388,16 @@ export interface ReplayState {
|
|
|
388
388
|
retired: boolean;
|
|
389
389
|
/** What armed it: the prompt, or a ReAct iteration's intent (fallbk.md). */
|
|
390
390
|
armedBy: "prompt" | "intent";
|
|
391
|
+
/**
|
|
392
|
+
* Set on a continuation (the service's fallbk.md Part 3): a plan armed
|
|
393
|
+
* mid-turn because the plan before it handed over at a step, and the work
|
|
394
|
+
* recorded after an earlier hand-over at that step was calculated into this
|
|
395
|
+
* plan. `armedBy` is `intent` — it arms mid-turn, and is billed for its own
|
|
396
|
+
* window like any plan armed there — but it finishes the user's request
|
|
397
|
+
* rather than a part of the agent's own task, and its report books no
|
|
398
|
+
* saving: the plan it continued books the whole turn.
|
|
399
|
+
*/
|
|
400
|
+
continuation?: ContinuationOf;
|
|
391
401
|
/**
|
|
392
402
|
* The Claude Code session this plan serves, set by the control server. Its
|
|
393
403
|
* steps run on that session's own proxies (executor.ts). Absent for `bir
|
|
@@ -596,7 +606,41 @@ export type DeclineCode = "replay_disabled" | "not_ready" | "known_bad_first_ste
|
|
|
596
606
|
* the prompt had been answered, or while prompts were not waiting on it
|
|
597
607
|
* (the match gate). Set by the control server, like the two above.
|
|
598
608
|
*/
|
|
599
|
-
| "match_late"
|
|
609
|
+
| "match_late"
|
|
610
|
+
/**
|
|
611
|
+
* A continuation (the service's fallbk.md Part 3) armed after the hand-over
|
|
612
|
+
* and told to the model, which never ran it: `run_scenario` was never asked
|
|
613
|
+
* for — another call of the same message came first, the model did the
|
|
614
|
+
* rest its own way, or the call was refused. Set by the control server.
|
|
615
|
+
* (Runner 0.3.23 armed a continuation only when the model's call was its
|
|
616
|
+
* first step and declined it otherwise as `not_first_step`. From 0.3.24 it
|
|
617
|
+
* arms on the model's first call after the hand-over, whatever that is.)
|
|
618
|
+
*/
|
|
619
|
+
| "not_taken"
|
|
620
|
+
/**
|
|
621
|
+
* A continuation whose own code for working out its values (`paramsLogic`)
|
|
622
|
+
* failed on this request, so none of its steps could be computed. Set by
|
|
623
|
+
* the control server.
|
|
624
|
+
*/
|
|
625
|
+
| "params_failed";
|
|
626
|
+
/**
|
|
627
|
+
* What a continuation continues (the service's fallbk.md Part 3): the plan
|
|
628
|
+
* that handed over, the step it stopped in front of, and the recording of the
|
|
629
|
+
* work done after an earlier hand-over there, which this plan was calculated
|
|
630
|
+
* from.
|
|
631
|
+
*/
|
|
632
|
+
export interface ContinuationOf {
|
|
633
|
+
ofScenarioId: string;
|
|
634
|
+
stepIndex: number;
|
|
635
|
+
runId: string;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Whether a plan is a part of the task the agent is already doing — armed
|
|
639
|
+
* mid-turn by its intent — so its words say "continue" and never "answer the
|
|
640
|
+
* user". A continuation is armed mid-turn too, and it is the rest of the
|
|
641
|
+
* user's request: it ends with the answer.
|
|
642
|
+
*/
|
|
643
|
+
export declare function isSubTask(state: Pick<ReplayState, "armedBy" | "continuation">): boolean;
|
|
600
644
|
/** What `PreToolUse` should do about this call. */
|
|
601
645
|
export type PreToolAction =
|
|
602
646
|
/** Pin the arguments and let the real tool run. */
|
|
@@ -674,6 +718,13 @@ export declare class ReplayController {
|
|
|
674
718
|
* itself as one that never reported.
|
|
675
719
|
*/
|
|
676
720
|
lateMatch(match: RunMatchLike, host?: HostContext): ReplayState;
|
|
721
|
+
/**
|
|
722
|
+
* The state of a continuation handed out and not armed (the service's
|
|
723
|
+
* fallbk.md Part 3): declined before any gate ran — the service's answer
|
|
724
|
+
* came too late (`match_late`). Like {@link lateMatch}, it exists only for
|
|
725
|
+
* its ticket's report, with the reason.
|
|
726
|
+
*/
|
|
727
|
+
declinedContinuation(match: RunMatchLike, code: DeclineCode, continuation: ContinuationOf): ReplayState;
|
|
677
728
|
/**
|
|
678
729
|
* Run the gate ladder and, if every gate passes, arm a plan.
|
|
679
730
|
*
|
|
@@ -682,9 +733,13 @@ export declare class ReplayController {
|
|
|
682
733
|
* up with a denominator nobody measured.
|
|
683
734
|
*
|
|
684
735
|
* `host` is given for a session whose host is not Claude Code, and is kept
|
|
685
|
-
* on the state (see {@link HostContext}).
|
|
736
|
+
* on the state (see {@link HostContext}). `extra.continuation` arms a
|
|
737
|
+
* continuation (the service's fallbk.md Part 3): see
|
|
738
|
+
* {@link ReplayState.continuation}.
|
|
686
739
|
*/
|
|
687
|
-
arm(match: RunMatchLike, prompt: string, wrapped: ReadonlySet<string>, armedBy?: "prompt" | "intent", ctx?: DeriveContext, host?: HostContext
|
|
740
|
+
arm(match: RunMatchLike, prompt: string, wrapped: ReadonlySet<string>, armedBy?: "prompt" | "intent", ctx?: DeriveContext, host?: HostContext, extra?: {
|
|
741
|
+
continuation?: ContinuationOf;
|
|
742
|
+
}): Promise<ReplayState>;
|
|
688
743
|
/**
|
|
689
744
|
* The armed plan as data: the steps it will run, in order, and where it
|
|
690
745
|
* stops when that is before its end. For a host that steps through the plan
|
|
@@ -960,6 +1015,19 @@ export declare class ReplayController {
|
|
|
960
1015
|
runId: string;
|
|
961
1016
|
ownCalls: number;
|
|
962
1017
|
};
|
|
1018
|
+
/**
|
|
1019
|
+
* What the continuations of this plan spent reading their values this
|
|
1020
|
+
* turn (the service's fallbk.md Part 3). Booked here, on the plan whose
|
|
1021
|
+
* report bills the whole turn: theirs book no saving, so a cost left on
|
|
1022
|
+
* them would be left out of the turn's saving.
|
|
1023
|
+
*/
|
|
1024
|
+
chainDeriveUsd?: number;
|
|
1025
|
+
/**
|
|
1026
|
+
* A continuation whose plan before it ran no step (its report is
|
|
1027
|
+
* `failed`, which books no saving): this one books its own, against its
|
|
1028
|
+
* own baseline, so the turn's saving is booked somewhere.
|
|
1029
|
+
*/
|
|
1030
|
+
booksOwnSaving?: boolean;
|
|
963
1031
|
}): ExecutionReport | undefined;
|
|
964
1032
|
/**
|
|
965
1033
|
* The step a failed replay is fairly blamed on: the first whose own logic
|
|
@@ -196,6 +196,15 @@ const MAX_STEP_ERROR_CHARS = 500;
|
|
|
196
196
|
* cannot hold more than a few megabytes in the recorder.
|
|
197
197
|
*/
|
|
198
198
|
const TURN_CALLS_MAX_CHARS = 8 * 1024 * 1024;
|
|
199
|
+
/**
|
|
200
|
+
* Whether a plan is a part of the task the agent is already doing — armed
|
|
201
|
+
* mid-turn by its intent — so its words say "continue" and never "answer the
|
|
202
|
+
* user". A continuation is armed mid-turn too, and it is the rest of the
|
|
203
|
+
* user's request: it ends with the answer.
|
|
204
|
+
*/
|
|
205
|
+
export function isSubTask(state) {
|
|
206
|
+
return state.armedBy === "intent" && state.continuation === undefined;
|
|
207
|
+
}
|
|
199
208
|
export class ReplayController {
|
|
200
209
|
enabled;
|
|
201
210
|
work = new ProxyWorkQueue();
|
|
@@ -365,6 +374,27 @@ export class ReplayController {
|
|
|
365
374
|
state.retired = true;
|
|
366
375
|
return state;
|
|
367
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* The state of a continuation handed out and not armed (the service's
|
|
379
|
+
* fallbk.md Part 3): declined before any gate ran — the service's answer
|
|
380
|
+
* came too late (`match_late`). Like {@link lateMatch}, it exists only for
|
|
381
|
+
* its ticket's report, with the reason.
|
|
382
|
+
*/
|
|
383
|
+
declinedContinuation(match, code, continuation) {
|
|
384
|
+
const state = this.blankState(match, new Set(), "intent");
|
|
385
|
+
state.continuation = continuation;
|
|
386
|
+
state.declined = code;
|
|
387
|
+
state.retired = true;
|
|
388
|
+
logLine("replay.decision", {
|
|
389
|
+
verdict: "no-steer",
|
|
390
|
+
code,
|
|
391
|
+
run: match.runId,
|
|
392
|
+
scenario: match.scenarioId ?? undefined,
|
|
393
|
+
continues: `${continuation.ofScenarioId}@${continuation.stepIndex}`,
|
|
394
|
+
why: "the continuation came after the call it could have replaced had gone ahead",
|
|
395
|
+
});
|
|
396
|
+
return state;
|
|
397
|
+
}
|
|
368
398
|
/**
|
|
369
399
|
* Run the gate ladder and, if every gate passes, arm a plan.
|
|
370
400
|
*
|
|
@@ -373,10 +403,14 @@ export class ReplayController {
|
|
|
373
403
|
* up with a denominator nobody measured.
|
|
374
404
|
*
|
|
375
405
|
* `host` is given for a session whose host is not Claude Code, and is kept
|
|
376
|
-
* on the state (see {@link HostContext}).
|
|
406
|
+
* on the state (see {@link HostContext}). `extra.continuation` arms a
|
|
407
|
+
* continuation (the service's fallbk.md Part 3): see
|
|
408
|
+
* {@link ReplayState.continuation}.
|
|
377
409
|
*/
|
|
378
|
-
async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host) {
|
|
410
|
+
async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host, extra = {}) {
|
|
379
411
|
const state = this.blankState(match, wrapped, armedBy, host);
|
|
412
|
+
if (extra.continuation)
|
|
413
|
+
state.continuation = extra.continuation;
|
|
380
414
|
const decline = (why, code) => {
|
|
381
415
|
state.declined = code;
|
|
382
416
|
logLine("replay.decision", {
|
|
@@ -597,12 +631,13 @@ export class ReplayController {
|
|
|
597
631
|
}
|
|
598
632
|
// A plan armed by intent runs inside a task the agent is already doing, so
|
|
599
633
|
// its directive and its bundles say "continue", never "answer the user".
|
|
634
|
+
// A continuation is the rest of the user's request: it ends with the answer.
|
|
600
635
|
state.plan = new ScenarioReplayPlan({
|
|
601
636
|
scenario,
|
|
602
637
|
params,
|
|
603
638
|
mode,
|
|
604
639
|
stopAt,
|
|
605
|
-
subTask:
|
|
640
|
+
subTask: isSubTask(state),
|
|
606
641
|
});
|
|
607
642
|
if (mode === "steer") {
|
|
608
643
|
// A steer directive shows the arguments the steps will run with, so the
|
|
@@ -643,7 +678,11 @@ export class ReplayController {
|
|
|
643
678
|
host: host?.app,
|
|
644
679
|
steps: steps.length,
|
|
645
680
|
stopsAt: stopAt,
|
|
646
|
-
by: armedBy === "intent" ? "intent" : undefined,
|
|
681
|
+
by: armedBy === "intent" ? (state.continuation ? "continuation" : "intent") : undefined,
|
|
682
|
+
// The plan it continues and the step that plan stopped in front of.
|
|
683
|
+
continues: state.continuation
|
|
684
|
+
? `${state.continuation.ofScenarioId}@${state.continuation.stepIndex}`
|
|
685
|
+
: undefined,
|
|
647
686
|
similarity: match.similarity.toFixed(3),
|
|
648
687
|
// Which steps the service told this plan to stop in front of, and why.
|
|
649
688
|
stops: stopLines(steps),
|
|
@@ -752,7 +791,7 @@ export class ReplayController {
|
|
|
752
791
|
directiveFor(state, opts = {}) {
|
|
753
792
|
const asked = opts.previewMs;
|
|
754
793
|
return state.plan?.steeringDirective(DIRECT_TOOL_NAME, {
|
|
755
|
-
subTask: state
|
|
794
|
+
subTask: isSubTask(state),
|
|
756
795
|
// NaN is a caller's arithmetic gone wrong: no time known, no preview.
|
|
757
796
|
previewBudgetMs: typeof asked === "number"
|
|
758
797
|
? Math.max(0, Math.min(Number.isNaN(asked) ? 0 : asked, PREVIEW_BUDGET_MS))
|
|
@@ -1499,10 +1538,9 @@ export class ReplayController {
|
|
|
1499
1538
|
const what = left.length === 1 ? `Not needed for this request: ${named[0]}${why}.` : `Not needed for this request: ${named.join(", ")}${why}.`;
|
|
1500
1539
|
let next;
|
|
1501
1540
|
if (plan.isDone() && !plan.atStop()) {
|
|
1502
|
-
next =
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
: "The sequence is complete: answer the user's request from the tool results.";
|
|
1541
|
+
next = isSubTask(state)
|
|
1542
|
+
? "The sequence is complete: continue your task, calling tools as needed."
|
|
1543
|
+
: "The sequence is complete: answer the user's request from the tool results.";
|
|
1506
1544
|
}
|
|
1507
1545
|
else {
|
|
1508
1546
|
const step = plan.currentStep();
|
|
@@ -1890,7 +1928,8 @@ export class ReplayController {
|
|
|
1890
1928
|
if (state.reported || !state.scenarioId)
|
|
1891
1929
|
return undefined;
|
|
1892
1930
|
state.reported = true;
|
|
1893
|
-
const
|
|
1931
|
+
const deriveCostUsd = state.deriveCostUsd + Math.max(0, d.chainDeriveUsd ?? 0);
|
|
1932
|
+
const total = deriveCostUsd + d.sessionCostUsd + state.fallbackCostUsd;
|
|
1894
1933
|
const steps = this.stepResultsOf(state);
|
|
1895
1934
|
const isBaseline = state.outcome === "not_steered" || state.outcome === "failed";
|
|
1896
1935
|
// A costless decline is noise on both sides — *unless* it says something the
|
|
@@ -1912,7 +1951,7 @@ export class ReplayController {
|
|
|
1912
1951
|
runId: d.runId,
|
|
1913
1952
|
ticket: state.ticket,
|
|
1914
1953
|
outcome: state.outcome,
|
|
1915
|
-
deriveCostUsd
|
|
1954
|
+
deriveCostUsd,
|
|
1916
1955
|
sessionCostUsd: d.sessionCostUsd,
|
|
1917
1956
|
fallbackCostUsd: state.fallbackCostUsd,
|
|
1918
1957
|
durationMs: d.durationMs,
|
|
@@ -1955,6 +1994,12 @@ export class ReplayController {
|
|
|
1955
1994
|
: undefined,
|
|
1956
1995
|
// The agent did not take the plan's result and redid the task (AC1).
|
|
1957
1996
|
...(d.redoneBy ? { redoneBy: d.redoneBy } : {}),
|
|
1997
|
+
// A continuation (the service's fallbk.md Part 3): the plan it continued
|
|
1998
|
+
// books the whole turn, so this report books no saving of its own —
|
|
1999
|
+
// unless that plan ran nothing and books none.
|
|
2000
|
+
...(state.continuation && !d.booksOwnSaving
|
|
2001
|
+
? { continues: { scenarioId: state.continuation.ofScenarioId } }
|
|
2002
|
+
: {}),
|
|
1958
2003
|
// A turn in which another plan ran is not a measurement of what this task
|
|
1959
2004
|
// costs unaided, so it is no baseline sample (segmented.md R-MONEY-5).
|
|
1960
2005
|
// This covers two plans that both armed, and — the case that was silently
|
|
@@ -373,6 +373,7 @@ so there is exactly one switch and it cannot get out of step with itself.
|
|
|
373
373
|
| `BIR_BASH_ALLOW` | *(unset: any command)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset means any command; `none` means no command, and `Bash` steps are steered in the session. A plan's Bash steps share a working directory, exactly as the host's own Bash tool does — variables and functions do not persist, also as the host's. A call with `run_in_background`, or with its own `timeout` longer than the step's clock (`BIR_STEP_TIMEOUT_MS`), is never started on the runner: the plan hands over in front of it |
|
|
374
374
|
| `BIR_PATH_ALLOW` | *(unset: any folder, from 0.3.21)* | Folders a runner-side `Read`, `Glob`, `Grep`, `Edit` or `Write` may touch (`*` for all). Unset means any path, as the agent's own tools reach it — a plan that reads the agent's memory file under `~/.claude` runs. `project` is the session's project folder alone (the rule up to 0.3.20); add folders after a comma, `~` for the home folder: `project,~/.claude`. Under a list, a path outside it, symlinks included, is refused and the plan hands over in front of it. `none` means no folder, and the file steps are steered in the session. `Bash` is `BIR_BASH_ALLOW`'s |
|
|
375
375
|
| `BIR_INTENT_ARM` | *(unset: off)* | `1` lets a whole plan whose intent matches the agent's reasoning mid-turn arm in the calling tool's place, which means waiting for it (`BIR_INTENT_ARM_BUDGET_MS`, 500). Unset or `0`: off — no probe is sent and nothing arms mid-turn, as up to 0.3.18 |
|
|
376
|
+
| `BIR_CONTINUATIONS` | *(unset: on, from 0.3.23)* | After a plan hands the task to the agent at a step, the agent's first call asks the service for the plan of the work recorded after an earlier hand-over at that step — once per hand-over, waiting 1.5 s at most for the answer and up to 7 s for its values — and that plan does the rest of the task, whatever the agent's call was (from 0.3.24; 0.3.23 ran it only when the call was its first step). A call to one of the plan's own tools is answered in its place, so the plan never makes it twice; any other call runs as the agent wrote it, and the agent is told of the plan on its result. Its saving is counted on the plan that handed over. `0`: off, and the agent's work is recorded as before. `BIR_INTENT_MATCH=0` turns it off too |
|
|
376
377
|
| `BIR_VERBOSE` | *(unset)* | `1` adds per-step `replay.*` detail lines |
|
|
377
378
|
|
|
378
379
|
The `bir` server reads its own, from the environment Claude Code starts it with:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.24",
|
|
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",
|