@basein/runner 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -6
- package/dist/auth/client.d.ts +15 -0
- package/dist/auth/client.js +34 -12
- package/dist/bin/bir-hooks.d.ts +1 -1
- package/dist/bin/bir-hooks.js +1 -1
- package/dist/bin/bir.d.ts +2 -0
- package/dist/bin/bir.js +60 -6
- package/dist/bin/investigate.d.ts +5 -0
- package/dist/bin/investigate.js +117 -15
- package/dist/bin/setup.d.ts +12 -0
- package/dist/bin/setup.js +185 -56
- package/dist/bin/update.d.ts +81 -0
- package/dist/bin/update.js +194 -0
- package/dist/config/adapters/claude-code.d.ts +18 -1
- package/dist/config/adapters/claude-code.js +30 -3
- package/dist/config/generate.d.ts +14 -0
- package/dist/control/daemon.d.ts +26 -1
- package/dist/control/daemon.js +53 -3
- package/dist/control/pin-guard.d.ts +6 -1
- package/dist/control/pin-guard.js +15 -1
- package/dist/control/recorder-keeper.js +2 -1
- package/dist/control/server.d.ts +205 -2
- package/dist/control/server.js +766 -56
- package/dist/control/transcript.d.ts +4 -0
- package/dist/control/transcript.js +4 -0
- package/dist/proxy/session.js +1 -0
- package/dist/record/housekeeping.d.ts +7 -1
- package/dist/record/housekeeping.js +11 -3
- package/dist/record/recorder.d.ts +34 -0
- package/dist/record/remote-recorder.d.ts +6 -0
- package/dist/record/remote-recorder.js +31 -4
- package/dist/record/switchable-recorder.js +1 -0
- package/dist/replay/controller.d.ts +139 -3
- package/dist/replay/controller.js +248 -24
- package/dist/replay/derive.js +2 -0
- package/dist/replay/handover.d.ts +13 -1
- package/dist/replay/handover.js +19 -0
- package/dist/replay/safety.d.ts +15 -4
- package/dist/replay/safety.js +19 -6
- package/dist/replay/source-run.js +2 -1
- package/dist/util/service-headers.d.ts +24 -0
- package/dist/util/service-headers.js +47 -0
- package/docs/BaseInstRunner.md +115 -1
- package/docs/calculatedReplay.md +209 -32
- package/docs/calculatedReplayGuide.md +133 -12
- package/docs/installRun.md +91 -10
- package/docs/quickstart.md +64 -9
- package/package.json +1 -1
- package/production.md +83 -19
- package/scripts/install.ps1 +25 -1
|
@@ -62,7 +62,7 @@ export const DEFAULT_BUDGETS = {
|
|
|
62
62
|
deriveMs: 8_000,
|
|
63
63
|
planMs: 100_000,
|
|
64
64
|
stepMs: 60_000,
|
|
65
|
-
hookDeriveMs:
|
|
65
|
+
hookDeriveMs: 8_000,
|
|
66
66
|
preToolMs: 9_000,
|
|
67
67
|
};
|
|
68
68
|
/**
|
|
@@ -311,15 +311,21 @@ export class ReplayController {
|
|
|
311
311
|
* Always returns a state — a declined match still has a ticket to redeem and a
|
|
312
312
|
* baseline sample to contribute, and losing that is how a savings ledger ends
|
|
313
313
|
* up with a denominator nobody measured.
|
|
314
|
+
*
|
|
315
|
+
* `host` is given for a session whose host is not Claude Code, and is kept
|
|
316
|
+
* on the state (see {@link HostContext}).
|
|
314
317
|
*/
|
|
315
|
-
async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}) {
|
|
318
|
+
async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host) {
|
|
316
319
|
const state = {
|
|
317
320
|
scenarioId: match.scenarioId,
|
|
318
321
|
ticket: match.executionTicket,
|
|
319
322
|
similarity: match.similarity,
|
|
320
323
|
matchedRunId: match.runId,
|
|
321
324
|
mode: "none",
|
|
322
|
-
|
|
325
|
+
// No proxy of this folder sees another host's calls, whatever a tool is
|
|
326
|
+
// named: every output is threaded from the host's own report.
|
|
327
|
+
wrapped: host ? new Set() : wrapped,
|
|
328
|
+
host,
|
|
323
329
|
pinned: new Map(),
|
|
324
330
|
outcome: "not_steered",
|
|
325
331
|
deriveCostUsd: 0,
|
|
@@ -364,6 +370,18 @@ export class ReplayController {
|
|
|
364
370
|
// Gate 2 — replay enabled.
|
|
365
371
|
if (!this.enabled)
|
|
366
372
|
return decline("BIR_REPLAY=0", "replay_disabled");
|
|
373
|
+
// And asked for. An adapter that only records never asks for a plan, and
|
|
374
|
+
// one started for it anyway would have nobody to step through it — while
|
|
375
|
+
// every call its model made itself would be read against the plan.
|
|
376
|
+
if (host?.recordsOnly) {
|
|
377
|
+
return decline(`the ${host.app} adapter of this run only records — it asked for no plan with its prompt`, "host_records_only");
|
|
378
|
+
}
|
|
379
|
+
// The service matched the prompt and handed over nothing, because this
|
|
380
|
+
// runner is older than its plans need (its RUNNER_MIN_CONTRACT). Its own
|
|
381
|
+
// code, ahead of "no scenario": the fix is an update, not a calculation.
|
|
382
|
+
if (match.withheld === "runner_update_required") {
|
|
383
|
+
return decline("the service hands this runner no plan until it is updated — run `bir update`", "runner_update_required");
|
|
384
|
+
}
|
|
367
385
|
// Gate 3 — a ready scenario with steps.
|
|
368
386
|
const scenario = match.scenario;
|
|
369
387
|
if (!match.scenarioId || !isReadyScenario(scenario)) {
|
|
@@ -428,7 +446,13 @@ export class ReplayController {
|
|
|
428
446
|
// built-in on `BIR_BUILTIN_TOOLS` counts as reachable: the runner
|
|
429
447
|
// executes it itself (replay/builtins.ts), so a chain that starts with a
|
|
430
448
|
// `Read` no longer forces steer mode.
|
|
431
|
-
|
|
449
|
+
//
|
|
450
|
+
// Another host's plan is always `steer`, every step `live`: its tools are
|
|
451
|
+
// in its own process, and a tool it happens to call `Read` or
|
|
452
|
+
// `mcp__fleet__list` is not the runner's to run or a proxy's to reach.
|
|
453
|
+
const allowServers = host ? undefined : this.opts.allowServers;
|
|
454
|
+
const builtinTools = host ? undefined : this.builtinTools;
|
|
455
|
+
const mode = modeFor(planned.map((e) => e.step), state.wrapped, allowServers, builtinTools);
|
|
432
456
|
if (mode === "none")
|
|
433
457
|
return decline("no step is executable", "coverage");
|
|
434
458
|
// Tool results feed derivation as context, and they are whatever the agent
|
|
@@ -441,11 +465,11 @@ export class ReplayController {
|
|
|
441
465
|
// target only a result names declines more often — one replay, never a
|
|
442
466
|
// command nobody asked for.
|
|
443
467
|
let deriveCtx = ctx;
|
|
444
|
-
if (ctx.recentResults?.length && sinkOfPlan(planned) === "command") {
|
|
468
|
+
if (ctx.recentResults?.length && sinkOfPlan(planned, host?.sinks) === "command") {
|
|
445
469
|
deriveCtx = { liveCall: ctx.liveCall };
|
|
446
470
|
logLine("replay.derive_context", {
|
|
447
471
|
scenario: scenario.id,
|
|
448
|
-
shell: hasShellStep(planned) || undefined,
|
|
472
|
+
shell: hasShellStep(planned, host?.sinks) || undefined,
|
|
449
473
|
why: "a step's input is run by a shell or a database (or its tool is named for one, such as *_query) — " +
|
|
450
474
|
"recent tool results are not read for its parameters, so a target only they name is not found",
|
|
451
475
|
});
|
|
@@ -527,7 +551,7 @@ export class ReplayController {
|
|
|
527
551
|
// same check still runs where the values settle later (`readyFor`).
|
|
528
552
|
// It travels as `missing_target`: the value read for a target could not
|
|
529
553
|
// be used, and no new code reaches a service that may not know it.
|
|
530
|
-
const sink = sinkOfPlan(planned);
|
|
554
|
+
const sink = sinkOfPlan(planned, host?.sinks);
|
|
531
555
|
if (result?.derived && sink) {
|
|
532
556
|
const bad = unsafeParameter(result.params, scenario.paramsObject, sink);
|
|
533
557
|
if (bad) {
|
|
@@ -590,6 +614,7 @@ export class ReplayController {
|
|
|
590
614
|
run: match.runId,
|
|
591
615
|
scenario: scenario.id,
|
|
592
616
|
mode,
|
|
617
|
+
host: host?.app,
|
|
593
618
|
steps: entries.length,
|
|
594
619
|
stopsAt: stopAt,
|
|
595
620
|
by: armedBy === "intent" ? "intent" : undefined,
|
|
@@ -602,11 +627,99 @@ export class ReplayController {
|
|
|
602
627
|
unusable: flat.stopReason?.reason,
|
|
603
628
|
// Which steps the service told this plan to stop in front of, and why.
|
|
604
629
|
stops: stopLines(entries),
|
|
605
|
-
coverage: coverageOf(entries.map((e) => e.step), wrapped,
|
|
630
|
+
coverage: coverageOf(entries.map((e) => e.step), state.wrapped, allowServers, builtinTools).join(","),
|
|
606
631
|
tools: entries.map((e) => e.step.toolName ?? "").join(","),
|
|
607
632
|
});
|
|
608
633
|
return state;
|
|
609
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* The armed plan as data: the steps it will run, in order, and where it
|
|
637
|
+
* stops when that is before its end. For a host that steps through the plan
|
|
638
|
+
* itself and must not have to pick the tool names out of a directive
|
|
639
|
+
* written for a model — a reworded sentence would break every adapter in
|
|
640
|
+
* the field. Built from the same entries the directive lists.
|
|
641
|
+
*/
|
|
642
|
+
planData(state) {
|
|
643
|
+
const plan = state.plan;
|
|
644
|
+
if (!plan || state.retired)
|
|
645
|
+
return undefined;
|
|
646
|
+
const steps = plan
|
|
647
|
+
.allEntries()
|
|
648
|
+
.slice(0, plan.stepCount)
|
|
649
|
+
.map((entry, stepIndex) => ({ stepIndex, toolName: entry.step.toolName ?? "" }));
|
|
650
|
+
return {
|
|
651
|
+
scenarioId: state.scenarioId,
|
|
652
|
+
mode: state.mode,
|
|
653
|
+
steps,
|
|
654
|
+
...(plan.stopsEarly() ? { stopAt: plan.stepCount } : {}),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Take back a plan that armed and has run nothing, as a decline.
|
|
659
|
+
*
|
|
660
|
+
* For what `arm` could not know: which tools the agent of another host
|
|
661
|
+
* holds. Such a host shows its tools only when its model is about to be
|
|
662
|
+
* called, with the first request for a step, and a plan that names a tool
|
|
663
|
+
* the agent lacks cannot be finished there. It ends before any step runs,
|
|
664
|
+
* booked like any gate's decline: `not_steered` under `code`, no step
|
|
665
|
+
* marked failed or missing — either would count against a plan that is
|
|
666
|
+
* not at fault. Returns false, changing nothing, once a step has run.
|
|
667
|
+
*/
|
|
668
|
+
declineArmed(state, code, why) {
|
|
669
|
+
if (!state.plan || state.retired)
|
|
670
|
+
return false;
|
|
671
|
+
if (state.stepsPinned > 0 || state.pinned.size > 0 || state.stepResults.size > 0)
|
|
672
|
+
return false;
|
|
673
|
+
state.declined = code;
|
|
674
|
+
logLine("replay.decision", {
|
|
675
|
+
verdict: "no-steer",
|
|
676
|
+
code,
|
|
677
|
+
run: state.matchedRunId,
|
|
678
|
+
scenario: state.scenarioId ?? undefined,
|
|
679
|
+
similarity: state.similarity.toFixed(3),
|
|
680
|
+
kind: state.kind,
|
|
681
|
+
why,
|
|
682
|
+
});
|
|
683
|
+
state.plan = undefined;
|
|
684
|
+
state.mode = "none";
|
|
685
|
+
state.stepsPlanned = 0;
|
|
686
|
+
this.retire(state, undefined);
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Hand a live plan over for something its host did, not the plan: the
|
|
691
|
+
* model made a call of its own while the plan was live (`own_call`), the
|
|
692
|
+
* run was closed with the plan still live (`closed`), or the host was
|
|
693
|
+
* answered without a step's arguments because they were not ready in time
|
|
694
|
+
* (`late`).
|
|
695
|
+
*
|
|
696
|
+
* Only for a host that is not Claude Code, where a call the plan did not
|
|
697
|
+
* hand out is by definition the model's own — it is never pinned and never
|
|
698
|
+
* answered with a bundle. No step is marked failed, so nothing is counted
|
|
699
|
+
* against the plan; the outcome is `fell_back` when a step ran and `failed`
|
|
700
|
+
* when none did, as for every hand-over. The note is the caller's to
|
|
701
|
+
* deliver ({@link takeNote}). Returns false when there was no live plan.
|
|
702
|
+
*/
|
|
703
|
+
hostHandOver(state, why) {
|
|
704
|
+
const plan = state.plan;
|
|
705
|
+
if (!plan || state.retired)
|
|
706
|
+
return false;
|
|
707
|
+
const position = plan.currentStepIndex;
|
|
708
|
+
this.handOver(state, {
|
|
709
|
+
kind: "step_failed",
|
|
710
|
+
step: plan.currentStep(),
|
|
711
|
+
entry: plan.currentEntry(),
|
|
712
|
+
position,
|
|
713
|
+
error: why === "own_call"
|
|
714
|
+
? "the model made a call of its own, so the scenario stopped"
|
|
715
|
+
: why === "late"
|
|
716
|
+
? "its arguments were not ready in time"
|
|
717
|
+
: "the run ended before this step",
|
|
718
|
+
delivery: "above",
|
|
719
|
+
stopReason: "not_run",
|
|
720
|
+
});
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
610
723
|
/**
|
|
611
724
|
* The directive to inject via `additionalContext`, or undefined when declined.
|
|
612
725
|
*
|
|
@@ -650,6 +763,10 @@ export class ReplayController {
|
|
|
650
763
|
const plan = state.plan;
|
|
651
764
|
if (!plan || state.retired || state.mode !== "steer")
|
|
652
765
|
return false;
|
|
766
|
+
// Another host reads the plan as data and is handed each step's arguments
|
|
767
|
+
// when it asks for them: there is no preview to wait for.
|
|
768
|
+
if (state.host)
|
|
769
|
+
return false;
|
|
653
770
|
if (plan.parametersSettled)
|
|
654
771
|
return true;
|
|
655
772
|
const planned = plan.allEntries().slice(0, plan.stepCount);
|
|
@@ -695,9 +812,13 @@ export class ReplayController {
|
|
|
695
812
|
* Deliver text to the model in place of the tool call it was about to make:
|
|
696
813
|
* as genuine command output for `Bash`, as the denial reason otherwise — the
|
|
697
814
|
* two channels a divergence bundle already uses.
|
|
815
|
+
*
|
|
816
|
+
* `otherHost` is a session whose host is not Claude Code: a tool it calls
|
|
817
|
+
* `Bash` is its own, and its command is never rewritten. The text is always
|
|
818
|
+
* the refusal reason there.
|
|
698
819
|
*/
|
|
699
|
-
deliverInstead(text, toolName) {
|
|
700
|
-
if (toolName === "Bash") {
|
|
820
|
+
deliverInstead(text, toolName, otherHost = false) {
|
|
821
|
+
if (toolName === "Bash" && !otherHost) {
|
|
701
822
|
// A short command, whatever the text's size (bash-channel.ts): a long
|
|
702
823
|
// heredoc is cut by the shell, and a long output by the host.
|
|
703
824
|
const command = bashCommandFor(truncate(text, BASH_CHANNEL_MAX));
|
|
@@ -750,7 +871,8 @@ export class ReplayController {
|
|
|
750
871
|
// `bir scenario show` the model runs while a Bash step is expected must
|
|
751
872
|
// run as `bir`, never be swapped for the plan's command (D13). And before
|
|
752
873
|
// the wait for the parameters, which a `ToolSearch` has no use for.
|
|
753
|
-
|
|
874
|
+
const otherHost = state.host !== undefined;
|
|
875
|
+
if (isHousekeepingCall(toolName, toolInput, otherHost)) {
|
|
754
876
|
logDetail("replay.housekeeping", {
|
|
755
877
|
tool: toolName,
|
|
756
878
|
step: `${plan.currentStepIndex}/${plan.stepCount}`,
|
|
@@ -797,7 +919,7 @@ export class ReplayController {
|
|
|
797
919
|
stopReason: "not_run",
|
|
798
920
|
});
|
|
799
921
|
const note = this.takeNote(state);
|
|
800
|
-
return note ? this.deliverInstead(note, toolName) : { kind: "abort" };
|
|
922
|
+
return note ? this.deliverInstead(note, toolName, otherHost) : { kind: "abort" };
|
|
801
923
|
}
|
|
802
924
|
// ONE CALL AT A TIME. A pin moves the cursor only when its output threads,
|
|
803
925
|
// which for a wrapped step is the proxy's report — landing either side of
|
|
@@ -871,11 +993,29 @@ export class ReplayController {
|
|
|
871
993
|
});
|
|
872
994
|
const note = this.takeNote(state);
|
|
873
995
|
if (note)
|
|
874
|
-
return this.deliverInstead(note, toolName);
|
|
996
|
+
return this.deliverInstead(note, toolName, otherHost);
|
|
875
997
|
}
|
|
876
998
|
return { kind: "abort" };
|
|
877
999
|
}
|
|
878
1000
|
}
|
|
1001
|
+
if (state.host) {
|
|
1002
|
+
// Another host steps through the plan by asking for each step in turn,
|
|
1003
|
+
// so a request for anything but the next step is its adapter out of
|
|
1004
|
+
// step with the plan. Nothing can be recovered by running the rest
|
|
1005
|
+
// here — only the host can run its tools — so the plan stops, and the
|
|
1006
|
+
// model is told which steps ran. No step is marked failed.
|
|
1007
|
+
this.handOver(state, {
|
|
1008
|
+
kind: "step_failed",
|
|
1009
|
+
step: plan.currentStep(),
|
|
1010
|
+
entry: plan.currentEntry(),
|
|
1011
|
+
position: plan.currentStepIndex,
|
|
1012
|
+
error: `the scenario's next step is ${expected ?? "none"}, and the host asked for ${toolName}`,
|
|
1013
|
+
delivery: "above",
|
|
1014
|
+
stopReason: "not_run",
|
|
1015
|
+
});
|
|
1016
|
+
const note = this.takeNote(state);
|
|
1017
|
+
return note ? this.deliverInstead(note, toolName, true) : { kind: "abort" };
|
|
1018
|
+
}
|
|
879
1019
|
return await this.diverge(state, toolName, `expected ${expected ?? "no more tools"}, model called ${toolName}`, deadline, hook.signal);
|
|
880
1020
|
}
|
|
881
1021
|
/**
|
|
@@ -927,7 +1067,9 @@ export class ReplayController {
|
|
|
927
1067
|
if (inFlight.size > 0)
|
|
928
1068
|
state.lingering = inFlight;
|
|
929
1069
|
const note = this.takeNote(state);
|
|
930
|
-
return note
|
|
1070
|
+
return note
|
|
1071
|
+
? this.deliverInstead(note, toolName, state.host !== undefined)
|
|
1072
|
+
: { kind: "passthrough" };
|
|
931
1073
|
}
|
|
932
1074
|
/**
|
|
933
1075
|
* Wait until the plan's outstanding pins thread, or until waiting is no
|
|
@@ -1055,6 +1197,24 @@ export class ReplayController {
|
|
|
1055
1197
|
// the step: a called segment numbers its steps from zero too, and without
|
|
1056
1198
|
// this its step 0 replaced the caller's (segmented.md R-CALL-13).
|
|
1057
1199
|
const entry = plan.allEntries()[pin.stepIndex];
|
|
1200
|
+
// THE HOST STOPPED THE STEP, and says so. Read before any failure: a
|
|
1201
|
+
// person's "no" arrives as an error result, and booked as one it would be
|
|
1202
|
+
// counted against a plan that did nothing wrong — after enough of them the
|
|
1203
|
+
// service stops serving the step. Nothing threads either way: after
|
|
1204
|
+
// `rejected` the tool never ran, and after `edited` its result answers a
|
|
1205
|
+
// call the plan did not plan, which its later steps were not computed for.
|
|
1206
|
+
if (how.hostStop && step) {
|
|
1207
|
+
this.hostStopped(state, pin, step, entry, output, how.hostStop);
|
|
1208
|
+
this.handOver(state, {
|
|
1209
|
+
kind: "step_failed",
|
|
1210
|
+
step,
|
|
1211
|
+
entry,
|
|
1212
|
+
position: pin.stepIndex,
|
|
1213
|
+
delivery: "above",
|
|
1214
|
+
stopReason: how.hostStop === "edited" ? "host_edited" : "host_rejected",
|
|
1215
|
+
});
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1058
1218
|
// The output threads into the step at the cursor. One pin at a time
|
|
1059
1219
|
// (`preTool`) keeps that the pin's own step; if it ever is not, threading
|
|
1060
1220
|
// would feed one step's output to another's logic, so the plan stops.
|
|
@@ -1226,6 +1386,11 @@ export class ReplayController {
|
|
|
1226
1386
|
const step = plan.allSteps()[pin.stepIndex];
|
|
1227
1387
|
if (!step)
|
|
1228
1388
|
return;
|
|
1389
|
+
if (how.hostStop) {
|
|
1390
|
+
// The host stopped it: neither a failure nor work the plan did.
|
|
1391
|
+
this.hostStopped(state, pin, step, plan.allEntries()[pin.stepIndex], output, how.hostStop);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1229
1394
|
const ms = Date.now() - pin.pinnedAt;
|
|
1230
1395
|
// Read the same way as a live pin's (`pinFailure`). A call that was sent
|
|
1231
1396
|
// and not seen through says so in its verdict: it may have done its work.
|
|
@@ -1260,6 +1425,39 @@ export class ReplayController {
|
|
|
1260
1425
|
why: "a step pinned before the hand-over finished — counted, not threaded",
|
|
1261
1426
|
});
|
|
1262
1427
|
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Record a step its host stopped ({@link PostToolOptions.hostStop}) as
|
|
1430
|
+
* `skipped`, in plain words, and remember the stop for the report. Never
|
|
1431
|
+
* `failed`: the service counts failures against the plan. And not counted
|
|
1432
|
+
* in `stepsPinned`: the plan's own call did not run.
|
|
1433
|
+
*/
|
|
1434
|
+
hostStopped(state, pin, step, entry, output, stop) {
|
|
1435
|
+
const edited = stop === "edited";
|
|
1436
|
+
// After `rejected` nothing later can change what the report may claim.
|
|
1437
|
+
if (state.hostStop !== "rejected")
|
|
1438
|
+
state.hostStop = stop;
|
|
1439
|
+
logLine("replay.step_host_stopped", {
|
|
1440
|
+
scenario: state.scenarioId ?? undefined,
|
|
1441
|
+
n: step.stepIndex,
|
|
1442
|
+
tool: step.toolName ?? undefined,
|
|
1443
|
+
stop,
|
|
1444
|
+
why: edited
|
|
1445
|
+
? "a person changed this call before it ran — it ran with their arguments, and the plan stops there"
|
|
1446
|
+
: "the host did not run this step — a person said no or answered in its place, or a limit blocked it",
|
|
1447
|
+
});
|
|
1448
|
+
this.recordStep(state, {
|
|
1449
|
+
step,
|
|
1450
|
+
entry,
|
|
1451
|
+
// The plan's own arguments are what a refused call was asked with; a
|
|
1452
|
+
// changed call ran with other ones, which only the host knows.
|
|
1453
|
+
input: edited ? undefined : pin.input,
|
|
1454
|
+
output: answerOf(output),
|
|
1455
|
+
outcome: "skipped",
|
|
1456
|
+
stage: "tool_call",
|
|
1457
|
+
error: edited ? "a person changed this call" : "the host did not run this step",
|
|
1458
|
+
ms: Date.now() - pin.pinnedAt,
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1263
1461
|
/** Whether a pinned call's output is threaded from the proxy's report (§7.2). */
|
|
1264
1462
|
threadsFromProxy(state, toolUseId) {
|
|
1265
1463
|
const pin = state.pinned.get(toolUseId) ?? state.lingering?.get(toolUseId);
|
|
@@ -1529,8 +1727,14 @@ export class ReplayController {
|
|
|
1529
1727
|
stepsPlanned: state.stepsPlanned,
|
|
1530
1728
|
stepsPinned: state.stepsPinned,
|
|
1531
1729
|
// A run its caller cancelled ended the turn early: its cost is no
|
|
1532
|
-
// measurement of the task, whatever the transcript says.
|
|
1533
|
-
|
|
1730
|
+
// measurement of the task, whatever the transcript says. So is one a
|
|
1731
|
+
// person said no in: the model is told not to run that step, the task
|
|
1732
|
+
// ends short of itself, and a verified saving booked against it would
|
|
1733
|
+
// be the cost of the work nobody did.
|
|
1734
|
+
measured: d.measured &&
|
|
1735
|
+
state.callerCancelled !== true &&
|
|
1736
|
+
state.hostStop !== "rejected" &&
|
|
1737
|
+
d.cutShort !== true,
|
|
1534
1738
|
pricingVersion: PRICING_VERSION,
|
|
1535
1739
|
prompt: d.prompt,
|
|
1536
1740
|
steps,
|
|
@@ -1559,7 +1763,14 @@ export class ReplayController {
|
|
|
1559
1763
|
// wrong before — a declined prompt scenario followed by an intent-armed
|
|
1560
1764
|
// plan, whose "baseline" was really the turn cut short at the second arm.
|
|
1561
1765
|
// A lone plan, armed or declined, sends nothing: the default is eligible.
|
|
1562
|
-
|
|
1766
|
+
//
|
|
1767
|
+
// Nor is a turn its host stopped a step of, or one that was cut short.
|
|
1768
|
+
// After a person's "no" the task ended early; after a changed call that
|
|
1769
|
+
// was the plan's first step, the turn made one model call fewer than
|
|
1770
|
+
// the task needs — the one the plan's call replaced.
|
|
1771
|
+
baselineEligible: d.siblings?.some((s) => s !== state && s.plan !== undefined) ||
|
|
1772
|
+
state.hostStop !== undefined ||
|
|
1773
|
+
d.cutShort === true
|
|
1563
1774
|
? false
|
|
1564
1775
|
: undefined,
|
|
1565
1776
|
};
|
|
@@ -1915,7 +2126,7 @@ export class ReplayController {
|
|
|
1915
2126
|
// done its work, and the model must know before it does the task itself.
|
|
1916
2127
|
if (stop?.reason === "uncertain" && stop.flatIndex !== undefined) {
|
|
1917
2128
|
const note = this.stopNote(state, plan, stop, "none");
|
|
1918
|
-
return this.deliverInstead(note, toolName);
|
|
2129
|
+
return this.deliverInstead(note, toolName, state.host !== undefined);
|
|
1919
2130
|
}
|
|
1920
2131
|
return { kind: "abort" };
|
|
1921
2132
|
}
|
|
@@ -1949,7 +2160,7 @@ export class ReplayController {
|
|
|
1949
2160
|
// Bash-clean delivery: the model reaches for `Bash` when it cannot call a
|
|
1950
2161
|
// scripted tool, and a genuine command output is trusted where a `deny`
|
|
1951
2162
|
// reason is read as adversarial interception.
|
|
1952
|
-
const action = this.deliverInstead(text, toolName);
|
|
2163
|
+
const action = this.deliverInstead(text, toolName, state.host !== undefined);
|
|
1953
2164
|
logDetail("replay.inject", { channel: action.kind, bytes: text.length });
|
|
1954
2165
|
return action;
|
|
1955
2166
|
}
|
|
@@ -2033,9 +2244,11 @@ export class ReplayController {
|
|
|
2033
2244
|
? "a second call arrived while the pinned step was still running — the agent continues from here"
|
|
2034
2245
|
: h.stopReason === "budget" || h.stopReason === "cancelled"
|
|
2035
2246
|
? "the run was cut short before this step — the agent continues from here"
|
|
2036
|
-
: h.stopReason === "
|
|
2037
|
-
? "
|
|
2038
|
-
:
|
|
2247
|
+
: h.stopReason === "host_rejected" || h.stopReason === "host_edited"
|
|
2248
|
+
? "the host stopped this step itself — no fault of the plan; the agent continues from here"
|
|
2249
|
+
: h.stopReason === "not_run" || h.stopReason === "uncertain"
|
|
2250
|
+
? "a step produced no result here — the agent continues from here"
|
|
2251
|
+
: "a step broke — the agent continues from here",
|
|
2039
2252
|
});
|
|
2040
2253
|
}
|
|
2041
2254
|
/**
|
|
@@ -2052,6 +2265,9 @@ export class ReplayController {
|
|
|
2052
2265
|
noteFor(state, h) {
|
|
2053
2266
|
const plan = state.plan;
|
|
2054
2267
|
const executed = h.ran ?? (h.ranThisStep ? h.position + 1 : h.position);
|
|
2268
|
+
// A step its host stopped was dealt with there — refused, answered, or
|
|
2269
|
+
// run with a person's arguments — so it is not among what is left to do.
|
|
2270
|
+
const handled = h.ranThisStep === true || h.stopReason === "host_rejected" || h.stopReason === "host_edited";
|
|
2055
2271
|
let params;
|
|
2056
2272
|
try {
|
|
2057
2273
|
params = plan?.parameters;
|
|
@@ -2082,7 +2298,7 @@ export class ReplayController {
|
|
|
2082
2298
|
inFlight: h.inFlight,
|
|
2083
2299
|
ranThisStep: h.ranThisStep,
|
|
2084
2300
|
totalSteps: plan?.totalSteps ?? state.stepsPlanned,
|
|
2085
|
-
remainingTools: plan ? plan.toolsFrom(
|
|
2301
|
+
remainingTools: plan ? plan.toolsFrom(handled ? h.position + 1 : h.position) : [],
|
|
2086
2302
|
params,
|
|
2087
2303
|
delivery: h.delivery,
|
|
2088
2304
|
});
|
|
@@ -2103,6 +2319,12 @@ export class ReplayController {
|
|
|
2103
2319
|
executeStep(state, opts = {}) {
|
|
2104
2320
|
return async (step, input) => {
|
|
2105
2321
|
const name = step.toolName ?? "";
|
|
2322
|
+
// Another host's tools are in its own process, whatever they are
|
|
2323
|
+
// named: one it calls `Bash` or `mcp__fleet__list` is not the runner's
|
|
2324
|
+
// built-in or a proxy's upstream. Nothing of such a plan runs here.
|
|
2325
|
+
if (state.host) {
|
|
2326
|
+
throw new StepRunError(`${name || "this step"} runs in the host's own process`, "not_run");
|
|
2327
|
+
}
|
|
2106
2328
|
const mcp = parseQualifiedName(name);
|
|
2107
2329
|
if (!mcp) {
|
|
2108
2330
|
// An allow-listed built-in runs *here*, against the session's own
|
|
@@ -2254,7 +2476,9 @@ export class ReplayController {
|
|
|
2254
2476
|
}
|
|
2255
2477
|
}
|
|
2256
2478
|
}
|
|
2257
|
-
const sink = opts.adHoc
|
|
2479
|
+
const sink = opts.adHoc
|
|
2480
|
+
? undefined
|
|
2481
|
+
: sinkOfPlan(plan.allEntries().slice(0, plan.stepCount), state.host?.sinks);
|
|
2258
2482
|
if (sink) {
|
|
2259
2483
|
const bad = unsafeParameter(plan.derivedValues, plan.paramsSchema, sink);
|
|
2260
2484
|
if (bad) {
|
package/dist/replay/derive.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { redactText } from "../record/redact.js";
|
|
34
34
|
import { isUnhealthyStatus, serviceHealth, timeoutSignal } from "../util/service-health.js";
|
|
35
|
+
import { serviceHeaders } from "../util/service-headers.js";
|
|
35
36
|
import { calculateCostUsd } from "./pricing.js";
|
|
36
37
|
export const DEFAULT_DERIVE_MODEL = "claude-haiku-4-5-20251001";
|
|
37
38
|
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
@@ -311,6 +312,7 @@ async function deriveViaService(opts, service, schema, keys) {
|
|
|
311
312
|
headers: {
|
|
312
313
|
"content-type": "application/json",
|
|
313
314
|
authorization: `Bearer ${service.token}`,
|
|
315
|
+
...serviceHeaders(),
|
|
314
316
|
},
|
|
315
317
|
body: JSON.stringify({
|
|
316
318
|
prompt: outgoingPrompt(opts.prompt),
|
|
@@ -45,7 +45,19 @@ export type HandoverStopReason =
|
|
|
45
45
|
/** The run was cancelled before it. */
|
|
46
46
|
| "cancelled"
|
|
47
47
|
/** Another of the plan's calls was still in flight (steer mode runs one at a time). */
|
|
48
|
-
| "concurrent"
|
|
48
|
+
| "concurrent"
|
|
49
|
+
/**
|
|
50
|
+
* A host that is not Claude Code wrote the step's result itself, and its
|
|
51
|
+
* tool never ran: a person said no, a person answered in the tool's place,
|
|
52
|
+
* or a call limit blocked the call. Not a fault of the plan.
|
|
53
|
+
*/
|
|
54
|
+
| "host_rejected"
|
|
55
|
+
/**
|
|
56
|
+
* The step's tool ran in such a host, with arguments a person chose in
|
|
57
|
+
* place of the plan's. Its result is real; the plan's later steps were
|
|
58
|
+
* computed for the call it planned, so it stops there.
|
|
59
|
+
*/
|
|
60
|
+
| "host_edited";
|
|
49
61
|
export interface HandoverNoteInput {
|
|
50
62
|
intent: string;
|
|
51
63
|
kind: FallbackKind;
|
package/dist/replay/handover.js
CHANGED
|
@@ -56,6 +56,12 @@ export function buildHandoverNote(n) {
|
|
|
56
56
|
// says it may have completed.
|
|
57
57
|
lines.push("No step of the scenario is known to have finished; the task is yours.");
|
|
58
58
|
}
|
|
59
|
+
else if (n.stopReason === "host_rejected" || n.stopReason === "host_edited") {
|
|
60
|
+
// The first step was answered by the host, or ran with a person's
|
|
61
|
+
// arguments: "the whole task is yours" would invite the model to do that
|
|
62
|
+
// step over, which the next line tells it not to.
|
|
63
|
+
lines.push("No step of the scenario ran as it was planned.");
|
|
64
|
+
}
|
|
59
65
|
else {
|
|
60
66
|
lines.push("No step of the scenario ran; the whole task is yours.");
|
|
61
67
|
}
|
|
@@ -109,6 +115,19 @@ export function buildHandoverNote(n) {
|
|
|
109
115
|
function stepFailedLine(n, human, tool) {
|
|
110
116
|
const at = `Step ${human} of ${n.totalSteps}${tool}`;
|
|
111
117
|
const detail = n.error ? truncate(n.error, MAX_ERROR_CHARS) : "";
|
|
118
|
+
// The two stops a host that is not Claude Code reports, first: neither is
|
|
119
|
+
// a failure, and neither is the model's to make good.
|
|
120
|
+
if (n.stopReason === "host_rejected") {
|
|
121
|
+
// One sentence for three causes — a person said no, a person answered in
|
|
122
|
+
// the tool's place, a call limit blocked the call — so it names none of
|
|
123
|
+
// them, and never says "do it yourself": the person may have meant no.
|
|
124
|
+
return (`${at} was not run by the host. The result above was written by the host, not by the tool. ` +
|
|
125
|
+
"Do not run the step again unless the user asks.");
|
|
126
|
+
}
|
|
127
|
+
if (n.stopReason === "host_edited") {
|
|
128
|
+
return (`${at} ran with arguments a person chose, not the scenario's. Its result above is real ` +
|
|
129
|
+
"and its side effects are in place. Do not run it again.");
|
|
130
|
+
}
|
|
112
131
|
if (n.ranThisStep) {
|
|
113
132
|
// Its tool ran and did its work; only the scenario's reading of the result
|
|
114
133
|
// broke. A model told "failed" here runs the write again.
|
package/dist/replay/safety.d.ts
CHANGED
|
@@ -43,16 +43,27 @@ import type { ParamsSchema } from "./types.js";
|
|
|
43
43
|
* or a database, `write` is only written into a file.
|
|
44
44
|
*/
|
|
45
45
|
export type SinkKind = "command" | "write";
|
|
46
|
+
/**
|
|
47
|
+
* The sinks a host that is not Claude Code declared for its own tools, by
|
|
48
|
+
* tool name. Passing one — even an empty one — says the tool names are that
|
|
49
|
+
* host's: `Bash`, `Edit` and `mcp__server__tool` then mean nothing by
|
|
50
|
+
* themselves. A tool the host declared is what it was declared; any other is
|
|
51
|
+
* judged by the words of its plain name (`run_sql`, `execQuery`).
|
|
52
|
+
*/
|
|
53
|
+
export interface HostSinks {
|
|
54
|
+
command?: readonly string[];
|
|
55
|
+
write?: readonly string[];
|
|
56
|
+
}
|
|
46
57
|
/** Whether this tool's input is run by a shell. */
|
|
47
|
-
export declare function isShellTool(toolName: string | null | undefined): boolean;
|
|
58
|
+
export declare function isShellTool(toolName: string | null | undefined, host?: HostSinks): boolean;
|
|
48
59
|
/** How this tool consumes its input, or undefined when it is neither run nor written. */
|
|
49
|
-
export declare function sinkOf(toolName: string | null | undefined): SinkKind | undefined;
|
|
60
|
+
export declare function sinkOf(toolName: string | null | undefined, host?: HostSinks): SinkKind | undefined;
|
|
50
61
|
/** Whether this tool's input is run or written: a shell, a file edit, a query. */
|
|
51
62
|
export declare function isCommandSink(toolName: string | null | undefined): boolean;
|
|
52
63
|
/** The strictest sink among the planned steps, or undefined when there is none. */
|
|
53
|
-
export declare function sinkOfPlan(entries: readonly FlatEntry[]): SinkKind | undefined;
|
|
64
|
+
export declare function sinkOfPlan(entries: readonly FlatEntry[], host?: HostSinks): SinkKind | undefined;
|
|
54
65
|
/** Whether any planned step runs its input through a shell. */
|
|
55
|
-
export declare function hasShellStep(entries: readonly FlatEntry[]): boolean;
|
|
66
|
+
export declare function hasShellStep(entries: readonly FlatEntry[], host?: HostSinks): boolean;
|
|
56
67
|
/** Whether any planned step's input is run or written. */
|
|
57
68
|
export declare function hasCommandSink(entries: readonly FlatEntry[]): boolean;
|
|
58
69
|
/**
|
package/dist/replay/safety.js
CHANGED
|
@@ -84,16 +84,29 @@ const UNSAFE = /[`$;&|<>\\"'\r\n\u0000]|--|\/\*/;
|
|
|
84
84
|
* apostrophe, at the cost of a denied call and a model round trip each time.
|
|
85
85
|
*/
|
|
86
86
|
const WRITE_UNSAFE = /[\r\n\u0000]/;
|
|
87
|
+
/** Whether a plain tool name of another host says its input is run. */
|
|
88
|
+
function plainNameSays(name, words) {
|
|
89
|
+
return words.test(nameWords(name));
|
|
90
|
+
}
|
|
87
91
|
/** Whether this tool's input is run by a shell. */
|
|
88
|
-
export function isShellTool(toolName) {
|
|
92
|
+
export function isShellTool(toolName, host) {
|
|
89
93
|
const name = toolName ?? "";
|
|
94
|
+
if (host)
|
|
95
|
+
return plainNameSays(name, MCP_SHELL_WORD);
|
|
90
96
|
if (SHELL_BUILTINS.has(name))
|
|
91
97
|
return true;
|
|
92
98
|
return mcpNameSays(name, MCP_SHELL_WORD);
|
|
93
99
|
}
|
|
94
100
|
/** How this tool consumes its input, or undefined when it is neither run nor written. */
|
|
95
|
-
export function sinkOf(toolName) {
|
|
101
|
+
export function sinkOf(toolName, host) {
|
|
96
102
|
const name = toolName ?? "";
|
|
103
|
+
if (host) {
|
|
104
|
+
if (host.command?.includes(name))
|
|
105
|
+
return "command";
|
|
106
|
+
if (host.write?.includes(name))
|
|
107
|
+
return "write";
|
|
108
|
+
return plainNameSays(name, MCP_COMMAND_WORD) ? "command" : undefined;
|
|
109
|
+
}
|
|
97
110
|
if (SHELL_BUILTINS.has(name))
|
|
98
111
|
return "command";
|
|
99
112
|
if (WRITE_BUILTINS.has(name))
|
|
@@ -105,10 +118,10 @@ export function isCommandSink(toolName) {
|
|
|
105
118
|
return sinkOf(toolName) !== undefined;
|
|
106
119
|
}
|
|
107
120
|
/** The strictest sink among the planned steps, or undefined when there is none. */
|
|
108
|
-
export function sinkOfPlan(entries) {
|
|
121
|
+
export function sinkOfPlan(entries, host) {
|
|
109
122
|
let found;
|
|
110
123
|
for (const e of entries) {
|
|
111
|
-
const sink = sinkOf(e.step.toolName);
|
|
124
|
+
const sink = sinkOf(e.step.toolName, host);
|
|
112
125
|
if (sink === "command")
|
|
113
126
|
return sink;
|
|
114
127
|
found ??= sink;
|
|
@@ -116,8 +129,8 @@ export function sinkOfPlan(entries) {
|
|
|
116
129
|
return found;
|
|
117
130
|
}
|
|
118
131
|
/** Whether any planned step runs its input through a shell. */
|
|
119
|
-
export function hasShellStep(entries) {
|
|
120
|
-
return entries.some((e) => isShellTool(e.step.toolName));
|
|
132
|
+
export function hasShellStep(entries, host) {
|
|
133
|
+
return entries.some((e) => isShellTool(e.step.toolName, host));
|
|
121
134
|
}
|
|
122
135
|
/** Whether any planned step's input is run or written. */
|
|
123
136
|
export function hasCommandSink(entries) {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { logDetail, logLine, errText } from "../util/log.js";
|
|
42
42
|
import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
|
|
43
|
+
import { serviceHeaders } from "../util/service-headers.js";
|
|
43
44
|
export class SourceRunCache {
|
|
44
45
|
opts;
|
|
45
46
|
/** run id → every recorded output of that run, in order. */
|
|
@@ -109,7 +110,7 @@ export class SourceRunCache {
|
|
|
109
110
|
let res;
|
|
110
111
|
try {
|
|
111
112
|
res = await doFetch(`${this.opts.baseUrl.replace(/\/+$/, "")}/recordings/runs/${runId}`, {
|
|
112
|
-
headers: { authorization: `Bearer ${token}
|
|
113
|
+
headers: { authorization: `Bearer ${token}`, ...serviceHeaders() },
|
|
113
114
|
signal: timeoutSignal(this.opts.timeoutMs ?? serviceTimeoutMs()),
|
|
114
115
|
});
|
|
115
116
|
}
|