@basein/runner 0.3.23 → 0.3.25

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.
@@ -85,20 +85,4 @@ export declare function commandShape(command: string, depth?: number, escape?: s
85
85
  * only the general rule is applied there.
86
86
  */
87
87
  export declare function pinCompatible(toolName: string, modelArgs: unknown, pinnedArgs: unknown, otherHost?: boolean): PinVerdict;
88
- /**
89
- * Whether the model's own call is a continuation's first step: the same call,
90
- * not only the same kind of call (the service's fallbk.md Part 3).
91
- *
92
- * Stricter than {@link pinCompatible}, on purpose. A pin takes a call the
93
- * model was told to make — the directive named the step — and one argument in
94
- * common is enough to know it is that call. A continuation starts on a call
95
- * nobody told the model to make: after a hand-over, the call is the model's
96
- * own choice of what to do next, and the plan may run only when that choice
97
- * is the one the recording made. Looking up another person, restarting
98
- * another worker, reading another file is another choice. So the call must
99
- * pass the pin guard, share at least one argument with the step, and hold
100
- * the same value in every argument they share — text compared without
101
- * surrounding space or letter case, anything else exactly.
102
- */
103
- export declare function sameFirstCall(toolName: string, modelArgs: unknown, stepArgs: unknown): PinVerdict;
104
88
  //# sourceMappingURL=pin-guard.d.ts.map
@@ -545,40 +545,4 @@ export function pinCompatible(toolName, modelArgs, pinnedArgs, otherHost = false
545
545
  ? { ok: true }
546
546
  : { ok: false, why: "it shares no argument with the step" };
547
547
  }
548
- /**
549
- * Whether the model's own call is a continuation's first step: the same call,
550
- * not only the same kind of call (the service's fallbk.md Part 3).
551
- *
552
- * Stricter than {@link pinCompatible}, on purpose. A pin takes a call the
553
- * model was told to make — the directive named the step — and one argument in
554
- * common is enough to know it is that call. A continuation starts on a call
555
- * nobody told the model to make: after a hand-over, the call is the model's
556
- * own choice of what to do next, and the plan may run only when that choice
557
- * is the one the recording made. Looking up another person, restarting
558
- * another worker, reading another file is another choice. So the call must
559
- * pass the pin guard, share at least one argument with the step, and hold
560
- * the same value in every argument they share — text compared without
561
- * surrounding space or letter case, anything else exactly.
562
- */
563
- export function sameFirstCall(toolName, modelArgs, stepArgs) {
564
- const verdict = pinCompatible(toolName, modelArgs, stepArgs);
565
- if (!verdict.ok)
566
- return verdict;
567
- const model = record(modelArgs);
568
- const step = record(stepArgs);
569
- const shared = Object.keys(model).filter((key) => Object.prototype.hasOwnProperty.call(step, key));
570
- if (shared.length === 0) {
571
- return stableStringify(model) === stableStringify(step)
572
- ? { ok: true }
573
- : { ok: false, why: "it shares no argument with the step" };
574
- }
575
- const differs = shared.find((key) => !sameValue(model[key], step[key]));
576
- return differs === undefined ? { ok: true } : { ok: false, why: `its ${differs} is not the step's` };
577
- }
578
- function sameValue(a, b) {
579
- if (typeof a === "string" && typeof b === "string") {
580
- return a.trim().toLowerCase() === b.trim().toLowerCase();
581
- }
582
- return stableStringify(a) === stableStringify(b);
583
- }
584
548
  //# sourceMappingURL=pin-guard.js.map
@@ -1178,12 +1178,17 @@ export declare class ControlServer {
1178
1178
  * hand-over itself is the question — the plan and the step — so the service
1179
1179
  * names one recording and matches no words.
1180
1180
  *
1181
- * It arms only when the call the model is making is that plan's first step —
1182
- * its tool, and arguments the pin guard would pin to the step's input — and
1183
- * is then answered in that call's place, as an intent plan whose first step
1184
- * the call is. The plan before it stopped because the next step needed a
1185
- * judgement. This call is the model's judgement; a plan that starts with
1186
- * another call is a way the model did not take, and must not run.
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.
1187
1192
  *
1188
1193
  * Its values are read from the turn's own request — the rest of the task is
1189
1194
  * the one the user asked for — with the model's words, the call it was about
@@ -1197,6 +1202,24 @@ export declare class ControlServer {
1197
1202
  * not armed is reported for its ticket, with the reason.
1198
1203
  */
1199
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;
1200
1223
  /**
1201
1224
  * A continuation handed out and not armed: kept, retired, for its ticket's
1202
1225
  * report at the seal (`not_steered`, with the reason, unmeasured). `code`
@@ -1211,12 +1234,6 @@ export declare class ControlServer {
1211
1234
  * continuation request can never return it.
1212
1235
  */
1213
1236
  private armedScenarioIds;
1214
- /**
1215
- * Whether the model's call is a continuation's first step: its tool, and
1216
- * the step's own input in every argument the two share (pin-guard.ts
1217
- * `sameFirstCall`). Anything that cannot be computed is "no".
1218
- */
1219
- private isContinuationFirstCall;
1220
1237
  /** Whether continuations are asked for: on unless `BIR_CONTINUATIONS=0`. */
1221
1238
  private continuationsOn;
1222
1239
  /**
@@ -30,7 +30,7 @@ import { join, resolve as resolvePath } from "node:path";
30
30
  import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
31
31
  import { StepIndexAllocator } from "./ordering.js";
32
32
  import { pidAlive } from "./discovery.js";
33
- import { pinCompatible, sameFirstCall } from "./pin-guard.js";
33
+ import { pinCompatible } from "./pin-guard.js";
34
34
  import { agentDayOf, contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, settledTextAfterToolUse, thinkingForToolUse, usageSince, } from "./transcript.js";
35
35
  import { isHousekeepingCall } from "../record/housekeeping.js";
36
36
  import { asSent, userText } from "../record/prompt-text.js";
@@ -492,17 +492,6 @@ function reportedArgs(args) {
492
492
  function score(value) {
493
493
  return typeof value === "number" && Number.isFinite(value) ? value.toFixed(3) : undefined;
494
494
  }
495
- /**
496
- * The tool a served scenario starts with, or undefined when the payload does
497
- * not say. Untyped JSON from the service, read without trusting its shape.
498
- */
499
- function firstStepTool(scenario) {
500
- const steps = scenario?.steps;
501
- if (!Array.isArray(steps) || steps.length === 0)
502
- return undefined;
503
- const name = steps[0]?.toolName;
504
- return typeof name === "string" ? name : undefined;
505
- }
506
495
  function positiveEnv(name, fallback) {
507
496
  const n = Number(process.env[name]);
508
497
  return Number.isFinite(n) && n > 0 ? n : fallback;
@@ -3043,6 +3032,10 @@ export class ControlServer {
3043
3032
  // Billed apart, the part after the hand-over would be saved twice.
3044
3033
  const continuedBy = (state, from) => {
3045
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 };
3046
3039
  const ids = new Set(state.scenarioId ? [state.scenarioId] : []);
3047
3040
  for (const s of states.slice(from + 1)) {
3048
3041
  if (s.continuation && ids.has(s.continuation.ofScenarioId)) {
@@ -3057,12 +3050,15 @@ export class ControlServer {
3057
3050
  const deriveUsd = [...chain, ...declined].reduce((usd, s) => usd + s.deriveCostUsd, 0);
3058
3051
  return { chain, deriveUsd };
3059
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));
3060
3056
  // One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
3061
3057
  // for its own window of the transcript — from when it armed to when the next
3062
3058
  // one did — so the turn's tokens are split between them, never counted twice.
3063
3059
  // A turn with a single prompt-armed plan is exactly the report it always was.
3064
3060
  states.forEach((state, i) => {
3065
- const { chain, deriveUsd: chainDeriveUsd } = continuedBy(state, i);
3061
+ const { chain, deriveUsd: chainDeriveUsd } = chains[i];
3066
3062
  // The plan whose arm ends this one's window: never one that continued it.
3067
3063
  const next = states.slice(i + 1).find((s) => !chain.includes(s));
3068
3064
  const nextMark = next?.armedBy === "intent" ? next.usageMark : undefined;
@@ -3098,6 +3094,7 @@ export class ControlServer {
3098
3094
  cutShort: cutShort || undefined,
3099
3095
  redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
3100
3096
  chainDeriveUsd,
3097
+ booksOwnSaving: booksOwn(state),
3101
3098
  });
3102
3099
  if (!report)
3103
3100
  return;
@@ -3564,6 +3561,21 @@ export class ControlServer {
3564
3561
  if (stashed)
3565
3562
  return this.preToolAnswer(this.replay.deliverInstead(stashed, toolName)) ?? {};
3566
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;
3567
3579
  const live = this.livePlan(run);
3568
3580
  /** `run_scenario` for this session's own announced direct plan: allowed, see below. */
3569
3581
  let allowDirect = false;
@@ -3766,8 +3778,7 @@ export class ControlServer {
3766
3778
  // 2a. The model's first call after a plan handed over: the work recorded
3767
3779
  // after an earlier hand-over at the same step may have a plan of its
3768
3780
  // own (the service's fallbk.md Part 3). Asked with the hand-over, not
3769
- // with words, and armed only when this call is its first step.
3770
- const dueFragment = run.fragment;
3781
+ // with words, and armed on this call, whatever the call is.
3771
3782
  const before = run.replay;
3772
3783
  const continued = planning
3773
3784
  ? undefined
@@ -3792,10 +3803,18 @@ export class ControlServer {
3792
3803
  run.replays.splice(at, 1);
3793
3804
  run.replay = before;
3794
3805
  this.keepDeclinedContinuation(session, run, late, "arm_unannounced");
3795
- run.fragment = dueFragment;
3806
+ if (run.parkedFragment?.by === late) {
3807
+ run.fragment = run.parkedFragment.fragment;
3808
+ run.parkedFragment = undefined;
3809
+ }
3796
3810
  }
3797
3811
  }
3798
- const armed = planning || continued
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
3799
3818
  ? undefined
3800
3819
  : await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
3801
3820
  if (armed) {
@@ -3816,8 +3835,9 @@ export class ControlServer {
3816
3835
  }
3817
3836
  // 3. No scenario took over: the model's own work after a hand-over is
3818
3837
  // recorded as a fragment, starting with this call.
3819
- // Not for the call an intent match just armed a plan beside: the plan
3820
- // runs next, and the fragment opens on the model's own work after it.
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.
3821
3841
  if (run.fragment?.state === "due" && !run.pendingDirective) {
3822
3842
  await this.openFragment(session, run, reasoning, toolName, modelArgs);
3823
3843
  }
@@ -4572,12 +4592,17 @@ export class ControlServer {
4572
4592
  * hand-over itself is the question — the plan and the step — so the service
4573
4593
  * names one recording and matches no words.
4574
4594
  *
4575
- * It arms only when the call the model is making is that plan's first step —
4576
- * its tool, and arguments the pin guard would pin to the step's input — and
4577
- * is then answered in that call's place, as an intent plan whose first step
4578
- * the call is. The plan before it stopped because the next step needed a
4579
- * judgement. This call is the model's judgement; a plan that starts with
4580
- * another call is a way the model did not take, and must not run.
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.
4581
4606
  *
4582
4607
  * Its values are read from the turn's own request — the rest of the task is
4583
4608
  * the one the user asked for — with the model's words, the call it was about
@@ -4610,13 +4635,43 @@ export class ControlServer {
4610
4635
  return undefined;
4611
4636
  }
4612
4637
  f.asked = true;
4613
- const text = (intent?.text ?? "").trim().slice(0, 4_000);
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;
4614
4658
  const framework = frameworkOf(session);
4615
- const handover = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
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;
4616
4668
  let landed = false;
4617
- const probe = this.recorder
4669
+ const probe = recorder
4618
4670
  .matchIntent({
4619
- text,
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: "",
4620
4675
  currentToolName: toolName,
4621
4676
  exclude: this.armedScenarioIds(run),
4622
4677
  handover,
@@ -4629,13 +4684,11 @@ export class ControlServer {
4629
4684
  });
4630
4685
  const answer = await this.withinBudget(probe, CONTINUATION_PROBE_MS);
4631
4686
  if (!answer) {
4632
- if (!landed) {
4633
- // Too slow, not failed: the service most likely counted the hand-over
4634
- // already, so the fragment says so. A plan its late answer hands out
4635
- // is reported as late, for its ticket.
4636
- f.counted = true;
4637
- void probe.then((late) => this.noteLateContinuation(run, handover, late));
4638
- }
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));
4639
4692
  logDetail("replay.continuation_miss", {
4640
4693
  run: run.runId,
4641
4694
  tool: toolName,
@@ -4655,7 +4708,7 @@ export class ControlServer {
4655
4708
  continues: `${f.scenarioId}@${f.stepIndex}`,
4656
4709
  recording: answer.continuation?.runId,
4657
4710
  why: answer.continuation
4658
- ? "the work recorded after an earlier hand-over here has no plan ready"
4711
+ ? "the work recorded after an earlier hand-over here has no plan to hand out"
4659
4712
  : "no hand-over at this step was recorded before",
4660
4713
  });
4661
4714
  return undefined;
@@ -4665,11 +4718,8 @@ export class ControlServer {
4665
4718
  stepIndex: f.stepIndex,
4666
4719
  runId: answer.continuation.runId,
4667
4720
  };
4668
- // Before anything is read: the plan starts with another tool than the
4669
- // model's call, so the model chose another way.
4670
- const first = firstStepTool(match.scenario);
4671
- if (match.scenario && first !== undefined && first !== toolName) {
4672
- this.keepDeclinedContinuation(session, run, this.replay.declinedContinuation(match, "not_first_step", continuation));
4721
+ if (!stillDue()) {
4722
+ this.keepDeclinedContinuation(session, run, this.replay.declinedContinuation(match, "match_late", continuation));
4673
4723
  return undefined;
4674
4724
  }
4675
4725
  const startedAt = Date.now();
@@ -4681,7 +4731,8 @@ export class ControlServer {
4681
4731
  });
4682
4732
  // The rest of the task the user asked for: its values are in the turn's
4683
4733
  // request, and the model's words say what it is doing next.
4684
- const prompt = [run.input, text].filter((s) => s.trim().length > 0).join("\n\n");
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");
4685
4736
  const arming = this.armOrLog(run, this.replay.arm(match, prompt, this.wrapped, "intent", {
4686
4737
  liveCall: { toolName, toolInput },
4687
4738
  recentResults: this.recentResultsFor(session, "call"),
@@ -4714,21 +4765,40 @@ export class ControlServer {
4714
4765
  this.keepDeclinedContinuation(session, run, state);
4715
4766
  return undefined;
4716
4767
  }
4717
- // Its values are in: the model's call must be its first step, arguments
4718
- // and all — the same call, not merely one the pin guard would take over
4719
- // (pin-guard.ts `sameFirstCall`).
4720
- await this.withinBudget(state.plan.ready().catch(() => undefined), Math.max(0, CONTINUATION_ARM_MS - (Date.now() - startedAt)));
4721
- if (!this.isContinuationFirstCall(state, toolName, modelArgs)) {
4722
- this.keepDeclinedContinuation(session, run, state, "not_first_step");
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");
4723
4786
  return undefined;
4724
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);
4725
4794
  state.usageMark = markTranscriptUsage(session.transcriptPath);
4726
4795
  state.markUsage = () => markTranscriptUsage(session.transcriptPath);
4727
4796
  run.replays.push(state);
4728
4797
  run.replay = state;
4729
4798
  // From here the continuation is this turn's plan. A hand-over of its own,
4730
- // if it makes one, schedules its own fragment: the one the plan before it
4731
- // left due is not opened.
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 };
4732
4802
  run.fragment = undefined;
4733
4803
  const directive = this.replay.directiveFor(state) ?? "";
4734
4804
  logLine("replay.continuation_armed", {
@@ -4737,11 +4807,67 @@ export class ControlServer {
4737
4807
  continues: `${continuation.ofScenarioId}@${continuation.stepIndex}`,
4738
4808
  recording: continuation.runId,
4739
4809
  mode: state.mode,
4740
- instead: toolName,
4810
+ instead: inPlace ? toolName : undefined,
4811
+ after: inPlace ? undefined : toolName,
4741
4812
  ms: Date.now() - startedAt,
4742
- why: "the model's call after the hand-over is the first step of the plan calculated from the work recorded after an earlier hand-over at this step",
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",
4743
4870
  });
4744
- return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
4745
4871
  }
4746
4872
  /**
4747
4873
  * A continuation handed out and not armed: kept, retired, for its ticket's
@@ -4759,9 +4885,9 @@ export class ControlServer {
4759
4885
  continues: state.continuation
4760
4886
  ? `${state.continuation.ofScenarioId}@${state.continuation.stepIndex}`
4761
4887
  : undefined,
4762
- why: code === "not_first_step"
4763
- ? "the model's call after the hand-over is not this continuation's first step — the model chose another way"
4764
- : "the continuation was not armed in time",
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",
4765
4891
  });
4766
4892
  }
4767
4893
  state.sessionId = session.sessionId;
@@ -4797,27 +4923,6 @@ export class ControlServer {
4797
4923
  .map((s) => s.scenarioId)
4798
4924
  .filter((id) => typeof id === "string");
4799
4925
  }
4800
- /**
4801
- * Whether the model's call is a continuation's first step: its tool, and
4802
- * the step's own input in every argument the two share (pin-guard.ts
4803
- * `sameFirstCall`). Anything that cannot be computed is "no".
4804
- */
4805
- isContinuationFirstCall(state, toolName, modelArgs) {
4806
- const plan = state.plan;
4807
- if (!plan || plan.expectedTool() !== toolName)
4808
- return false;
4809
- try {
4810
- const verdict = sameFirstCall(toolName, modelArgs, plan.toolInputForCurrentStep());
4811
- if (!verdict.ok) {
4812
- logDetail("replay.continuation_other_call", { tool: toolName, why: verdict.why });
4813
- }
4814
- return verdict.ok;
4815
- }
4816
- catch (err) {
4817
- logDetail("replay.first_step_unknown", { tool: toolName, error: errText(err) });
4818
- return false;
4819
- }
4820
- }
4821
4926
  /** Whether continuations are asked for: on unless `BIR_CONTINUATIONS=0`. */
4822
4927
  continuationsOn() {
4823
4928
  if (this.opts.continuations !== undefined)
@@ -4884,7 +4989,15 @@ export class ControlServer {
4884
4989
  const h = state?.handover;
4885
4990
  if (!h || !state?.scenarioId)
4886
4991
  return;
4887
- run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
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
+ };
4888
5001
  }
4889
5002
  /**
4890
5003
  * Open the fragment run and record from this call on (fallbk.md D7).
@@ -4904,9 +5017,15 @@ export class ControlServer {
4904
5017
  return;
4905
5018
  const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
4906
5019
  const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
4907
- // The service counted this hand-over already, when it was asked for its
4908
- // continuation (Part 3): the create says so, and is not counted again.
4909
- const sent = f.counted ? { ...fallbackOf, counted: true } : fallbackOf;
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
+ };
4910
5029
  // The fragment is a run of the same host as the turn it continues.
4911
5030
  const framework = frameworkOf(session);
4912
5031
  const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf: sent, framework } : { fallbackOf: sent });
@@ -8,13 +8,18 @@
8
8
  import type { ExecutionStage, ExecutionStepResult, FallbackKind } from "../replay/types.js";
9
9
  export interface StartRunOptions {
10
10
  /**
11
- * Marks a *fragment* (fallbk.md D7). `counted`: the runner asked for this
12
- * hand-over's continuation first, and the service counted the hand-over as
13
- * a hit then, so this request must not count it again (Part 3).
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.
14
17
  */
15
18
  fallbackOf?: {
16
19
  scenarioId: string;
17
20
  stepIndex: number;
21
+ kind?: string;
22
+ handoverId?: string;
18
23
  counted?: boolean;
19
24
  };
20
25
  /**
@@ -313,11 +318,16 @@ export interface IntentMatchRequest {
313
318
  * A continuation request (the service's fallbk.md Part 3): the plan that
314
319
  * just handed the task to the model, and the step it stopped in front of.
315
320
  * The service answers with the plan of the work recorded after an earlier
316
- * hand-over at that step, if there is one, and matches no words.
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.
317
325
  */
318
326
  handover?: {
319
327
  scenarioId: string;
320
328
  stepIndex: number;
329
+ kind?: string;
330
+ id?: string;
321
331
  };
322
332
  }
323
333
  /** The answer to a probe. */