@basein/runner 0.3.25 → 0.3.27
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 +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.d.ts +13 -0
- package/dist/bin/investigate.js +72 -2
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +75 -11
- package/dist/control/server.js +440 -77
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +15 -11
- package/dist/replay/bundle.js +167 -33
- package/dist/replay/controller.d.ts +92 -1
- package/dist/replay/controller.js +246 -47
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/plan.d.ts +41 -0
- package/dist/replay/plan.js +171 -3
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/reads-only.d.ts +48 -0
- package/dist/replay/reads-only.js +161 -0
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
package/dist/control/server.js
CHANGED
|
@@ -31,16 +31,17 @@ import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolNam
|
|
|
31
31
|
import { StepIndexAllocator } from "./ordering.js";
|
|
32
32
|
import { pidAlive } from "./discovery.js";
|
|
33
33
|
import { pinCompatible } from "./pin-guard.js";
|
|
34
|
-
import { agentDayOf, contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, settledTextAfterToolUse, thinkingForToolUse, usageSince, } from "./transcript.js";
|
|
34
|
+
import { agentDayOf, answerBeforePrompt, contextForToolUse, intentForToolUse, markTranscriptUsage, promptCount, recentToolResults, sessionTranscriptPath, settledLastAssistantText, settledTextAfterToolUse, settledTurnEnd, thinkingForToolUse, usageBetween, usageSince, } from "./transcript.js";
|
|
35
35
|
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
36
|
+
import { readsOnlyCall } from "../replay/reads-only.js";
|
|
36
37
|
import { asSent, userText } from "../record/prompt-text.js";
|
|
37
38
|
import { redact, redactText } from "../record/redact.js";
|
|
38
39
|
import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
|
|
39
40
|
import { StepQueue } from "../record/queue.js";
|
|
40
41
|
import { NullRecorder, isAttentionReader, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioDisputer, isScenarioReporter, isServiceProber, isToolListReporter, } from "../record/recorder.js";
|
|
41
|
-
import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, } from "../replay/controller.js";
|
|
42
|
+
import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, sentACall, } from "../replay/controller.js";
|
|
42
43
|
import { localDay } from "../replay/derive.js";
|
|
43
|
-
import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
|
|
44
|
+
import { calculateCostUsd, hasModelPricing, requestRates } from "../replay/pricing.js";
|
|
44
45
|
import { logDetail, logLine, logOnce, errText } from "../util/log.js";
|
|
45
46
|
import { journal } from "../util/journal.js";
|
|
46
47
|
import { packageVersion } from "../util/version.js";
|
|
@@ -106,6 +107,19 @@ const INTENT_ARM_PROBE_MS = 500;
|
|
|
106
107
|
const INTENT_ARM_DERIVE_MS = 1_500;
|
|
107
108
|
/** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
|
|
108
109
|
const FRAGMENT_CREATE_MS = 300;
|
|
110
|
+
/**
|
|
111
|
+
* How long after its caller cancelled it a plan is not armed again (when some
|
|
112
|
+
* of its steps had run): long enough for the person to look at what the cancel
|
|
113
|
+
* left and ask again, short enough that a plan asked for tomorrow runs.
|
|
114
|
+
*/
|
|
115
|
+
const AFTER_CANCEL_MS = 10 * 60_000;
|
|
116
|
+
/**
|
|
117
|
+
* How long a turn's books wait, after `Stop`, for Claude Code to write the
|
|
118
|
+
* turn's last message — whose tokens are the turn's too (`sealRun`). The wait
|
|
119
|
+
* the answer's text always had. `BIR_COST_SETTLE_MS` changes it; 0 is no wait.
|
|
120
|
+
*/
|
|
121
|
+
const COST_SETTLE_MS = 2_000;
|
|
122
|
+
const COST_SETTLE_MAX_MS = 10_000;
|
|
109
123
|
/**
|
|
110
124
|
* How long the model's first call after a hand-over waits for the service to
|
|
111
125
|
* say whether the work recorded after an earlier hand-over at that step has a
|
|
@@ -445,6 +459,24 @@ const HEALTH_SESSIONS_MAX = 50;
|
|
|
445
459
|
const HANDBACK_NOTE = "[BaseInstRunner calculated replay] The recorded sequence is no longer being supplied: carry on with the task yourself, choosing each tool call's arguments from what you have seen so far.";
|
|
446
460
|
/** What the model hears once a direct plan it never asked to run is retired. */
|
|
447
461
|
const DIRECT_SKIPPED_NOTE = "[BaseInstRunner calculated replay] The recorded scenario is not being run for this request: carry on with the task yourself.";
|
|
462
|
+
/**
|
|
463
|
+
* What the model hears, once, after its first call that only looks while a
|
|
464
|
+
* direct plan waits for `run_scenario` (B2 is11.md): the call did not cancel
|
|
465
|
+
* the plan, the steps take this request's values — named, where the reading
|
|
466
|
+
* named them — and a change made first would. In is11 the model had checked
|
|
467
|
+
* its notes because the note named the recording's person.
|
|
468
|
+
*/
|
|
469
|
+
function directWaitingNote(values) {
|
|
470
|
+
return [
|
|
471
|
+
"[BaseInstRunner calculated replay] The recorded sequence is still ready for this request:",
|
|
472
|
+
"a call that only reads does not cancel it.",
|
|
473
|
+
values
|
|
474
|
+
? `Its steps take this request's values, not the recording's: ${values}.`
|
|
475
|
+
: "Its steps take this request's values, not the recording's.",
|
|
476
|
+
"Call mcp__bir__run_scenario next, before any call that changes something:",
|
|
477
|
+
"after a change of your own it is not run.",
|
|
478
|
+
].join("\n");
|
|
479
|
+
}
|
|
448
480
|
/** What a second `run_scenario` for a plan that is already running is told. */
|
|
449
481
|
const ALREADY_RUNNING = "this scenario is already running for this turn — its results come back on the first run_scenario call; do not call it again or repeat its steps";
|
|
450
482
|
/** Request bodies past this are read and discarded, never buffered. */
|
|
@@ -619,6 +651,14 @@ function hostSinksOf(payload) {
|
|
|
619
651
|
* Claude's own names, the ones a transcript line carries, and they are mapped
|
|
620
652
|
* here as the transcript's are folded: a missing count is 0. Undefined when
|
|
621
653
|
* the payload carries no usable list — which is "no usage", never zero.
|
|
654
|
+
*
|
|
655
|
+
* A host sends one total per model, never one per request, and a model with a
|
|
656
|
+
* long-prompt price (Haiku 5.5: over 100,000 tokens, five times the rates) is
|
|
657
|
+
* priced by each request's own length. Under the threshold no request in the
|
|
658
|
+
* total was over it, and the short rates are right. Over it, which ones were
|
|
659
|
+
* cannot be told: the total is `rateUnknown`, and the run unmeasured — priced
|
|
660
|
+
* at the short rates, 150,000 tokens of one request came to a fifth of their
|
|
661
|
+
* cost, booked as measured.
|
|
622
662
|
*/
|
|
623
663
|
function hostUsageOf(payload) {
|
|
624
664
|
if (!Array.isArray(payload.usage))
|
|
@@ -629,12 +669,25 @@ function hostUsageOf(payload) {
|
|
|
629
669
|
if (!item || typeof item !== "object")
|
|
630
670
|
continue;
|
|
631
671
|
const u = item;
|
|
672
|
+
const written = count(u.cache_creation_input_tokens);
|
|
673
|
+
// Claude's own split of the cache writes, when the host passes it on: a
|
|
674
|
+
// 1-hour write bills at 2x input, a 5-minute one at 1.25x.
|
|
675
|
+
const split = u.cache_creation;
|
|
676
|
+
const written1h = Math.min(written, count(split?.ephemeral_1h_input_tokens));
|
|
677
|
+
const model = typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown";
|
|
678
|
+
const inputTokens = count(u.input_tokens);
|
|
679
|
+
const cacheReadTokens = count(u.cache_read_input_tokens);
|
|
680
|
+
// The whole total as if it were one request's prompt: over the model's
|
|
681
|
+
// long-prompt threshold, one of its requests may have been.
|
|
682
|
+
const longMaybe = requestRates(model, { promptTokens: inputTokens + cacheReadTokens + written }).longPrompt === true;
|
|
632
683
|
out.push({
|
|
633
|
-
model
|
|
634
|
-
inputTokens
|
|
684
|
+
model,
|
|
685
|
+
inputTokens,
|
|
635
686
|
outputTokens: count(u.output_tokens),
|
|
636
|
-
cacheReadTokens
|
|
637
|
-
cacheCreationTokens:
|
|
687
|
+
cacheReadTokens,
|
|
688
|
+
cacheCreationTokens: written,
|
|
689
|
+
...(written1h > 0 ? { cacheCreation1hTokens: written1h } : {}),
|
|
690
|
+
...(longMaybe ? { rateUnknown: true } : {}),
|
|
638
691
|
});
|
|
639
692
|
}
|
|
640
693
|
return out;
|
|
@@ -642,12 +695,19 @@ function hostUsageOf(payload) {
|
|
|
642
695
|
/**
|
|
643
696
|
* Price a turn's token usage. Measured only when tokens were used and every
|
|
644
697
|
* model that used them has a price: a model outside the table costs "$0",
|
|
645
|
-
* and a zero called measured is booked as a saving nobody made.
|
|
698
|
+
* and a zero called measured is booked as a saving nobody made. Nor is a
|
|
699
|
+
* total whose rates cannot be told ({@link HostUsage.rateUnknown}).
|
|
646
700
|
*/
|
|
647
701
|
function priceUsage(usage) {
|
|
648
702
|
const usd = usage.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
|
|
649
703
|
const unpriced = [...new Set(usage.filter((u) => !hasModelPricing(u.model)).map((u) => u.model))];
|
|
650
|
-
|
|
704
|
+
const rateUnknown = [...new Set(usage.filter((u) => u.rateUnknown).map((u) => u.model))];
|
|
705
|
+
return {
|
|
706
|
+
usd,
|
|
707
|
+
measured: usage.length > 0 && unpriced.length === 0 && rateUnknown.length === 0,
|
|
708
|
+
unpriced,
|
|
709
|
+
...(rateUnknown.length > 0 ? { rateUnknown } : {}),
|
|
710
|
+
};
|
|
651
711
|
}
|
|
652
712
|
/**
|
|
653
713
|
* What `/health` answers as `hostFields`: how much of another host's protocol
|
|
@@ -698,6 +758,15 @@ export class ControlServer {
|
|
|
698
758
|
attention = new Map();
|
|
699
759
|
/** Plans a `/scenario/run` is executing right now: one run per plan. */
|
|
700
760
|
runningPlans = new Set();
|
|
761
|
+
/**
|
|
762
|
+
* Plans whose direct run its caller cancelled, by scenario id, and when
|
|
763
|
+
* ({@link declineAfterCancel}): the same plan is not armed again for
|
|
764
|
+
* {@link AFTER_CANCEL_MS} when the run sent a call, so its steps do not run
|
|
765
|
+
* twice. Here, not on the session: after `/clear` — a new session — or in a
|
|
766
|
+
* second window the same request armed the plan again, and ran every step
|
|
767
|
+
* a second time.
|
|
768
|
+
*/
|
|
769
|
+
cancelledScenarios = new Map();
|
|
701
770
|
/**
|
|
702
771
|
* Steer plans whose last pin the guard declined (control/pin-guard.ts): the
|
|
703
772
|
* model made the step's call its own way, so the plan is a step behind the
|
|
@@ -1565,7 +1634,9 @@ export class ControlServer {
|
|
|
1565
1634
|
const stashed = this.claimStashedNote(payload.session_id);
|
|
1566
1635
|
// The guard answering first means no directive went out: a plan that
|
|
1567
1636
|
// arms after that must never steer a model that was not told of it.
|
|
1637
|
+
let answeredLate = false;
|
|
1568
1638
|
onLate(() => {
|
|
1639
|
+
answeredLate = true;
|
|
1569
1640
|
const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
|
|
1570
1641
|
if (run) {
|
|
1571
1642
|
run.matchExpired = true;
|
|
@@ -1574,7 +1645,11 @@ export class ControlServer {
|
|
|
1574
1645
|
return stashed ? this.withContext({}, "UserPromptSubmit", stashed) : {};
|
|
1575
1646
|
});
|
|
1576
1647
|
const out = await this.onPrompt(payload);
|
|
1577
|
-
|
|
1648
|
+
// Or the note this prompt's own match brought here: the plan was held
|
|
1649
|
+
// back after a cancel in another session (`declineAfterCancel`). Not
|
|
1650
|
+
// once the deadline has answered — it then waits for the next hook.
|
|
1651
|
+
const note = stashed ?? (answeredLate ? undefined : this.claimStashedNote(payload.session_id));
|
|
1652
|
+
return note ? this.withContext(out, "UserPromptSubmit", note) : out;
|
|
1578
1653
|
}
|
|
1579
1654
|
case "/tool/pre":
|
|
1580
1655
|
return await this.onToolPre(payload, onLate);
|
|
@@ -1635,7 +1710,22 @@ export class ControlServer {
|
|
|
1635
1710
|
send(res, status, body) {
|
|
1636
1711
|
if (res.headersSent || res.writableEnded)
|
|
1637
1712
|
return;
|
|
1638
|
-
|
|
1713
|
+
// An answer that cannot be written as JSON (a BigInt or a cycle in a tool's
|
|
1714
|
+
// arguments that came back as `updatedInput`) threw here, past the route's
|
|
1715
|
+
// own handler, and the host's hook request was left with no answer at all
|
|
1716
|
+
// until its own timeout, 15 s later. A hook that answers nothing lets the
|
|
1717
|
+
// call go ahead as the model wrote it: said, and answered so at once.
|
|
1718
|
+
let text;
|
|
1719
|
+
try {
|
|
1720
|
+
text = JSON.stringify(body ?? {});
|
|
1721
|
+
}
|
|
1722
|
+
catch (err) {
|
|
1723
|
+
logLine("hook.unserialisable_answer", {
|
|
1724
|
+
error: errText(err),
|
|
1725
|
+
why: "the answer could not be written as JSON — answered with none, so the call goes ahead as written",
|
|
1726
|
+
});
|
|
1727
|
+
text = "{}";
|
|
1728
|
+
}
|
|
1639
1729
|
res.writeHead(status, {
|
|
1640
1730
|
"content-type": "application/json",
|
|
1641
1731
|
"content-length": Buffer.byteLength(text),
|
|
@@ -1699,8 +1789,10 @@ export class ControlServer {
|
|
|
1699
1789
|
this.sessions.set(sessionId, session);
|
|
1700
1790
|
}
|
|
1701
1791
|
session.lastSeenAt = Date.now();
|
|
1792
|
+
// Always the session's own transcript: a hook of a sub-agent that named
|
|
1793
|
+
// the sub-agent's file would have the turn's marks read from two files.
|
|
1702
1794
|
if (payload.transcript_path)
|
|
1703
|
-
session.transcriptPath = payload.transcript_path;
|
|
1795
|
+
session.transcriptPath = sessionTranscriptPath(payload.transcript_path);
|
|
1704
1796
|
if (payload.cwd)
|
|
1705
1797
|
session.cwd = payload.cwd;
|
|
1706
1798
|
// The first host a session names is the one it keeps. Any route may carry
|
|
@@ -1880,6 +1972,10 @@ export class ControlServer {
|
|
|
1880
1972
|
// every run of another host would then only record.
|
|
1881
1973
|
hostReplay: asked.replay === true ? true : undefined,
|
|
1882
1974
|
};
|
|
1975
|
+
// The turn before this one ends where this one starts (`RunState.endMark`).
|
|
1976
|
+
const before = session.run ?? session.previous;
|
|
1977
|
+
if (before && before.finished && before.endMark === undefined)
|
|
1978
|
+
before.endMark = run.usageMark;
|
|
1883
1979
|
session.run = run;
|
|
1884
1980
|
logLine("run.start", {
|
|
1885
1981
|
run: runId,
|
|
@@ -2113,6 +2209,7 @@ export class ControlServer {
|
|
|
2113
2209
|
if (!otherHost(session))
|
|
2114
2210
|
state.reportTool = () => this.reportToolServed();
|
|
2115
2211
|
this.declineWithoutDeliveryTool(state);
|
|
2212
|
+
this.declineAfterCancel(session, state);
|
|
2116
2213
|
// A steer plan with a shell or file-write step can only be followed if the
|
|
2117
2214
|
// directive shows its computed arguments (the pin guard holds those steps
|
|
2118
2215
|
// to their exact shape), so give its parameters a moment to settle —
|
|
@@ -2259,6 +2356,74 @@ export class ControlServer {
|
|
|
2259
2356
|
run.replays.push(state);
|
|
2260
2357
|
return undefined;
|
|
2261
2358
|
}
|
|
2359
|
+
/**
|
|
2360
|
+
* A plan whose caller cancelled it (Esc on `run_scenario`, or a timeout) a
|
|
2361
|
+
* few minutes ago, after at least one of its steps had run, is not armed
|
|
2362
|
+
* again when the same plan matches: the steps' side effects are in place
|
|
2363
|
+
* already, and the model is told so by the note kept for it
|
|
2364
|
+
* ({@link stashCancelledNote}). Armed, the plan would have run them a second
|
|
2365
|
+
* time — a row added twice, a mail sent twice — and the model would have had
|
|
2366
|
+
* the note and the instruction to run the plan in the same message.
|
|
2367
|
+
*
|
|
2368
|
+
* The mark is spent by the first match it is asked about, whichever way that
|
|
2369
|
+
* goes: it holds back one arming, never a stream of them, and never on a
|
|
2370
|
+
* plan that sent no call (there is nothing to repeat: a step left out, one
|
|
2371
|
+
* whose logic broke, one that could not be sent), nor one that is long
|
|
2372
|
+
* cancelled ({@link AFTER_CANCEL_MS}). A person who wants the plan again asks
|
|
2373
|
+
* again. Except while the cancelled run is still going — Esc does not stop a
|
|
2374
|
+
* step in flight, and the run ends after it: every match until then is held,
|
|
2375
|
+
* and the first one after spends the mark.
|
|
2376
|
+
*
|
|
2377
|
+
* The mark is the recorder's, not the session's: after `/clear` or in a
|
|
2378
|
+
* second window the same request arrives in another session. The note the
|
|
2379
|
+
* cancelled run left goes with the decline, to the session it is declined
|
|
2380
|
+
* in, when that note is still unread.
|
|
2381
|
+
*
|
|
2382
|
+
* Returns true when it declined.
|
|
2383
|
+
*/
|
|
2384
|
+
declineAfterCancel(session, state) {
|
|
2385
|
+
const id = state.scenarioId;
|
|
2386
|
+
if (!state.plan || typeof id !== "string")
|
|
2387
|
+
return false;
|
|
2388
|
+
const mark = this.cancelledScenarios.get(id);
|
|
2389
|
+
if (!mark)
|
|
2390
|
+
return false;
|
|
2391
|
+
const running = this.runningPlans.has(mark.state);
|
|
2392
|
+
if (!running)
|
|
2393
|
+
this.cancelledScenarios.delete(id);
|
|
2394
|
+
if (Date.now() - mark.at > AFTER_CANCEL_MS && !running)
|
|
2395
|
+
return false;
|
|
2396
|
+
if (!running && !sentACall(mark.state))
|
|
2397
|
+
return false;
|
|
2398
|
+
logLine("replay.decision", {
|
|
2399
|
+
verdict: "no-steer",
|
|
2400
|
+
code: "after_cancel",
|
|
2401
|
+
scenario: id,
|
|
2402
|
+
sess: session.sessionId,
|
|
2403
|
+
why: running
|
|
2404
|
+
? "the same plan was cancelled a moment ago and its run is still finishing the step it was on — not armed again, so that step does not run twice; the agent does the task"
|
|
2405
|
+
: "the same plan was cancelled a moment ago after some of its steps had run — not armed again, so those steps do not run twice; the agent does the task, told what ran",
|
|
2406
|
+
});
|
|
2407
|
+
// Its note, for the model that is about to do the task by hand.
|
|
2408
|
+
if (!session.stashedNote && this.replay.hasUndeliveredNote(mark.state))
|
|
2409
|
+
session.stashedNote = mark.state;
|
|
2410
|
+
state.plan = undefined;
|
|
2411
|
+
state.mode = "none";
|
|
2412
|
+
state.stepsPlanned = 0;
|
|
2413
|
+
state.pinned.clear();
|
|
2414
|
+
state.declined = "after_cancel";
|
|
2415
|
+
return true;
|
|
2416
|
+
}
|
|
2417
|
+
/** Mark `state`'s scenario held after its caller cancelled it ({@link declineAfterCancel}). */
|
|
2418
|
+
holdAfterCancel(scenarioId, state) {
|
|
2419
|
+
const now = Date.now();
|
|
2420
|
+
// Marks long past their time, and not running, are dropped as new ones come.
|
|
2421
|
+
for (const [id, mark] of this.cancelledScenarios) {
|
|
2422
|
+
if (now - mark.at > AFTER_CANCEL_MS && !this.runningPlans.has(mark.state))
|
|
2423
|
+
this.cancelledScenarios.delete(id);
|
|
2424
|
+
}
|
|
2425
|
+
this.cancelledScenarios.set(scenarioId, { state, at: now });
|
|
2426
|
+
}
|
|
2262
2427
|
/**
|
|
2263
2428
|
* A `direct` plan is announced as "call mcp__bir__run_scenario". In a project
|
|
2264
2429
|
* with no `bir` server that is a tool the model does not have: it would say
|
|
@@ -2365,6 +2530,13 @@ export class ControlServer {
|
|
|
2365
2530
|
* whether the service took it (null while the send is on its way).
|
|
2366
2531
|
*/
|
|
2367
2532
|
toolListsSent = new Map();
|
|
2533
|
+
/**
|
|
2534
|
+
* What each wrapped server says of its tools (MCP `annotations`), by the
|
|
2535
|
+
* name the agent calls them: whether a call only looks, for a direct plan
|
|
2536
|
+
* waiting through it (B2 is11.md). A server's word beats any guess from the
|
|
2537
|
+
* name (replay/reads-only.ts). Kept from the lists proxies report.
|
|
2538
|
+
*/
|
|
2539
|
+
toolHints = new Map();
|
|
2368
2540
|
/**
|
|
2369
2541
|
* `POST /proxy/tools` — a proxy's server listed its tools (`tools/list`;
|
|
2370
2542
|
* the service's autocorrect.md AC12). Its names, descriptions, input
|
|
@@ -2384,12 +2556,26 @@ export class ControlServer {
|
|
|
2384
2556
|
if (this.isSessionOff(boundTo))
|
|
2385
2557
|
return { ok: true, off: true };
|
|
2386
2558
|
const serverName = typeof body.serverName === "string" ? body.serverName.trim().slice(0, 200) : "";
|
|
2387
|
-
if (!serverName || !Array.isArray(body.tools)
|
|
2559
|
+
if (!serverName || !Array.isArray(body.tools))
|
|
2388
2560
|
return { ok: true };
|
|
2389
2561
|
const tools = toolListForService(serverName, body.tools);
|
|
2390
2562
|
// A server still starting may list nothing: that replaces no list.
|
|
2391
2563
|
if (tools.length === 0)
|
|
2392
2564
|
return { ok: true };
|
|
2565
|
+
for (const t of tools) {
|
|
2566
|
+
if (t.annotations)
|
|
2567
|
+
this.toolHints.set(t.qualifiedName, t.annotations);
|
|
2568
|
+
else
|
|
2569
|
+
this.toolHints.delete(t.qualifiedName);
|
|
2570
|
+
}
|
|
2571
|
+
while (this.toolHints.size > 4_096) {
|
|
2572
|
+
const oldest = this.toolHints.keys().next().value;
|
|
2573
|
+
if (oldest === undefined)
|
|
2574
|
+
break;
|
|
2575
|
+
this.toolHints.delete(oldest);
|
|
2576
|
+
}
|
|
2577
|
+
if (!isToolListReporter(this.recorder))
|
|
2578
|
+
return { ok: true };
|
|
2393
2579
|
const project = this.projectKeyHere();
|
|
2394
2580
|
const key = `${accountOf(this.recorder) ?? ""}\u0000${project}\u0000${serverName}`;
|
|
2395
2581
|
const hash = createHash("sha256").update(JSON.stringify(tools)).digest("hex");
|
|
@@ -2797,7 +2983,7 @@ export class ControlServer {
|
|
|
2797
2983
|
* `reason` says why the run is closed. It decides something only in a
|
|
2798
2984
|
* session of another host (see {@link CloseReason}).
|
|
2799
2985
|
*/
|
|
2800
|
-
sealRun(session, reason = "stop") {
|
|
2986
|
+
sealRun(session, reason = "stop", settle = false) {
|
|
2801
2987
|
const run = session.run;
|
|
2802
2988
|
if (!run || run.finished)
|
|
2803
2989
|
return;
|
|
@@ -2811,7 +2997,16 @@ export class ControlServer {
|
|
|
2811
2997
|
// handed over first, before the report is built, so the report says what
|
|
2812
2998
|
// happened: `fell_back` when a step ran, `failed` when none did, and not
|
|
2813
2999
|
// `not_steered`, as if no plan had started.
|
|
2814
|
-
|
|
3000
|
+
//
|
|
3001
|
+
// A turn of a Claude Code session closed by anything but its own `Stop`
|
|
3002
|
+
// ended before its work did too: an API error (`StopFailure`), the
|
|
3003
|
+
// person's next prompt on a turn still open (Esc), a session that went
|
|
3004
|
+
// quiet, ended or was switched off, a recorder that stopped. What it cost
|
|
3005
|
+
// is no measure of the task. Its plan's report is unmeasured, and the turn
|
|
3006
|
+
// is no sample of the usual cost: one $0.01 beside a $2.00 recording made
|
|
3007
|
+
// the usual cost about $1.00, and every later saving about $1.00 too small
|
|
3008
|
+
// (the service's imp.md B2). The recording itself is left as it was.
|
|
3009
|
+
let cutShort = early || (!other && reason !== "stop");
|
|
2815
3010
|
if (other) {
|
|
2816
3011
|
const live = this.livePlan(run);
|
|
2817
3012
|
if (live && this.replay.hostHandOver(live, "closed"))
|
|
@@ -2832,15 +3027,91 @@ export class ControlServer {
|
|
|
2832
3027
|
this.shelveUnannounced(unannounced);
|
|
2833
3028
|
// A `run_scenario` still waiting for this run's plan will never get it.
|
|
2834
3029
|
this.directCalls = this.directCalls.filter((c) => c.run !== run);
|
|
2835
|
-
|
|
3030
|
+
// The turn ends now, whenever its books are closed below.
|
|
3031
|
+
const sealedAtMs = Date.now();
|
|
3032
|
+
const durationMs = sealedAtMs - run.startedAtMs;
|
|
3033
|
+
// A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
|
|
3034
|
+
// and duration are the model's own work, not the steps the scenario did.
|
|
3035
|
+
// So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
|
|
3036
|
+
// hands over one number for the whole run, and it is the fragment's: the
|
|
3037
|
+
// plan's own steps made no model call, so all of it was spent after the
|
|
3038
|
+
// hand-over.
|
|
3039
|
+
const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
|
|
3040
|
+
const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
|
|
3041
|
+
const ownWork = fragment ?? repeatAfterPlan;
|
|
3042
|
+
const recordedMs = ownWork?.startedAtMs ? sealedAtMs - ownWork.startedAtMs : durationMs;
|
|
3043
|
+
// How many prompts the transcript holds now: the turn's answer is what
|
|
3044
|
+
// stood before the next one (`turnAnswer`).
|
|
3045
|
+
const promptsAtSeal = other ? undefined : promptCount(session.transcriptPath);
|
|
3046
|
+
const closeBooks = () => this.closeBooks(session, run, {
|
|
3047
|
+
reason,
|
|
3048
|
+
early,
|
|
3049
|
+
cutShort,
|
|
3050
|
+
durationMs,
|
|
3051
|
+
recordedMs,
|
|
3052
|
+
sealedAtMs,
|
|
3053
|
+
ownWork,
|
|
3054
|
+
promptsAtSeal,
|
|
3055
|
+
});
|
|
3056
|
+
// THE COST IS READ ONCE THE TURN'S LAST MESSAGE IS WRITTEN. `Stop` comes
|
|
3057
|
+
// before Claude Code writes it, and it carries tokens of its own: read at
|
|
3058
|
+
// `Stop`, every turn was one model call short, and on a replay that call
|
|
3059
|
+
// is most of the turn (control/transcript.ts, `settledTurnEnd`). Only an
|
|
3060
|
+
// ordinary stop waits — an interrupted turn, a session that ended and a
|
|
3061
|
+
// recorder closing have no last message coming — and never on the hook's
|
|
3062
|
+
// path: the seal is done, and its drain waits for the books.
|
|
3063
|
+
const settleMs = settle && !other && run.usageMark !== undefined ? this.costSettleMs() : 0;
|
|
3064
|
+
if (settleMs > 0 && session.transcriptPath) {
|
|
3065
|
+
this.holdSeal(run, settledTurnEnd(session.transcriptPath, { timeoutMs: settleMs })
|
|
3066
|
+
.catch(() => false)
|
|
3067
|
+
.then(() => {
|
|
3068
|
+
try {
|
|
3069
|
+
closeBooks();
|
|
3070
|
+
}
|
|
3071
|
+
catch (err) {
|
|
3072
|
+
logLine("run.books_failed", { run: run.runId, error: errText(err) });
|
|
3073
|
+
}
|
|
3074
|
+
}));
|
|
3075
|
+
}
|
|
3076
|
+
else {
|
|
3077
|
+
closeBooks();
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
/** How long a turn's books wait for its last message: `BIR_COST_SETTLE_MS`, 0 for no wait. */
|
|
3081
|
+
costSettleMs() {
|
|
3082
|
+
if (this.opts.costSettleMs !== undefined)
|
|
3083
|
+
return Math.max(0, this.opts.costSettleMs);
|
|
3084
|
+
const raw = process.env.BIR_COST_SETTLE_MS;
|
|
3085
|
+
const n = raw === undefined || raw.trim() === "" ? Number.NaN : Number(raw);
|
|
3086
|
+
return Number.isFinite(n) && n >= 0 ? Math.min(n, COST_SETTLE_MAX_MS) : COST_SETTLE_MS;
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* `work` joins what a drain of `run` waits for ({@link drainRun}). Never
|
|
3090
|
+
* rejects: a seal's waits are best effort, and say so in the log themselves.
|
|
3091
|
+
*/
|
|
3092
|
+
holdSeal(run, work) {
|
|
3093
|
+
const done = work.then(() => undefined, () => undefined);
|
|
3094
|
+
run.sealing = run.sealing ? Promise.all([run.sealing, done]).then(() => undefined) : done;
|
|
3095
|
+
}
|
|
3096
|
+
/**
|
|
3097
|
+
* The second half of {@link sealRun}: what the turn cost, its execution
|
|
3098
|
+
* reports, and the finish of its recording. Run at the seal, or a moment
|
|
3099
|
+
* after it, once the turn's last message is in the transcript.
|
|
3100
|
+
*/
|
|
3101
|
+
closeBooks(session, run, sealed) {
|
|
3102
|
+
const { reason, early, cutShort, durationMs, recordedMs, sealedAtMs, ownWork, promptsAtSeal } = sealed;
|
|
3103
|
+
const other = otherHost(session);
|
|
3104
|
+
// A prompt that came while the books waited opened the session's next run:
|
|
3105
|
+
// this turn's tokens end at that run's mark, and what follows is its own.
|
|
3106
|
+
const until = run.endMark;
|
|
2836
3107
|
// What this turn cost the model. Computed once, used twice: it is this run's
|
|
2837
3108
|
// `originalCostUsd` when recorded, and the `sessionCostUsd` of a matched
|
|
2838
3109
|
// turn's execution report.
|
|
2839
|
-
const cost = this.runCost(session, run);
|
|
3110
|
+
const cost = this.runCost(session, run, until);
|
|
2840
3111
|
// A matched turn records nothing, but it still owes the ledger a number —
|
|
2841
3112
|
// including when it declined to steer, which is a *baseline* sample and is
|
|
2842
3113
|
// what every saving is measured against (docs/calculatedReplay.md §11).
|
|
2843
|
-
this.reportExecution(session, run, durationMs, cost, cutShort, reason);
|
|
3114
|
+
this.reportExecution(session, run, durationMs, cost, cutShort, reason, { sealedAtMs, until });
|
|
2844
3115
|
// The plan's calls kept for a redo went into the repeat when it opened
|
|
2845
3116
|
// (AC2); nothing reads them after the turn, and they can run to megabytes
|
|
2846
3117
|
// on a sealed run the session keeps as its previous one.
|
|
@@ -2848,27 +3119,22 @@ export class ControlServer {
|
|
|
2848
3119
|
s.turnCalls = undefined;
|
|
2849
3120
|
s.turnCallChars = 0;
|
|
2850
3121
|
}
|
|
2851
|
-
|
|
2852
|
-
// and duration are the model's own work, not the steps the scenario did.
|
|
2853
|
-
// So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
|
|
2854
|
-
// hands over one number for the whole run, and it is the fragment's: the
|
|
2855
|
-
// plan's own steps made no model call, so all of it was spent after the
|
|
2856
|
-
// hand-over.
|
|
2857
|
-
const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
|
|
2858
|
-
const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
|
|
2859
|
-
const ownWork = fragment ?? repeatAfterPlan;
|
|
2860
|
-
const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark) : cost;
|
|
2861
|
-
const recordedMs = ownWork?.startedAtMs ? Date.now() - ownWork.startedAtMs : durationMs;
|
|
3122
|
+
const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark, until) : cost;
|
|
2862
3123
|
if (run.recording) {
|
|
2863
3124
|
const transcriptPath = session.transcriptPath;
|
|
2864
3125
|
// The answer is the host's when it handed one over, and the
|
|
2865
3126
|
// transcript's otherwise — never both. A run that ended early has none:
|
|
2866
3127
|
// whatever text there is, it is not the answer to the prompt.
|
|
3128
|
+
// A prompt written since the seal opened the next turn: the answer is
|
|
3129
|
+
// what stood before it, never what that turn has written meanwhile.
|
|
3130
|
+
const beforeNext = promptsAtSeal === undefined ? undefined : answerBeforePrompt(transcriptPath, promptsAtSeal + 1);
|
|
2867
3131
|
const answerText = early
|
|
2868
3132
|
? Promise.resolve("")
|
|
2869
3133
|
: run.hostAnswer !== undefined
|
|
2870
3134
|
? Promise.resolve(run.hostAnswer)
|
|
2871
|
-
:
|
|
3135
|
+
: beforeNext !== undefined
|
|
3136
|
+
? Promise.resolve(beforeNext)
|
|
3137
|
+
: settledLastAssistantText(transcriptPath).catch(() => "");
|
|
2872
3138
|
// What the finish of another host's run says beside its answer and its
|
|
2873
3139
|
// cost: why it ended early, when it did, and whether it is whole. A
|
|
2874
3140
|
// run with either is never calculated and stops catching its prompt.
|
|
@@ -2883,7 +3149,6 @@ export class ControlServer {
|
|
|
2883
3149
|
// first step of the user's next turn by that much. Kept on the run, so a
|
|
2884
3150
|
// drain of this run waits for this seal and for nobody else's — beside a
|
|
2885
3151
|
// plan's report that waits for the answer too (AC11), never in its place.
|
|
2886
|
-
const before = run.sealing;
|
|
2887
3152
|
const finishing = answerText
|
|
2888
3153
|
.then((answer) => {
|
|
2889
3154
|
run.finishQueued = true;
|
|
@@ -2913,7 +3178,7 @@ export class ControlServer {
|
|
|
2913
3178
|
});
|
|
2914
3179
|
});
|
|
2915
3180
|
});
|
|
2916
|
-
|
|
3181
|
+
this.holdSeal(run, finishing);
|
|
2917
3182
|
}
|
|
2918
3183
|
logLine("run.finish", {
|
|
2919
3184
|
run: run.runId,
|
|
@@ -2949,7 +3214,7 @@ export class ControlServer {
|
|
|
2949
3214
|
* No watermark means the delta is unknowable, so this reports `measured:
|
|
2950
3215
|
* false` and zero rather than a number that is confidently wrong.
|
|
2951
3216
|
*/
|
|
2952
|
-
runCost(session, run) {
|
|
3217
|
+
runCost(session, run, until) {
|
|
2953
3218
|
// Another host has no transcript: its numbers are the ones it handed
|
|
2954
3219
|
// over with its stop, and they are looked at before any watermark — a
|
|
2955
3220
|
// run of such a host never has one.
|
|
@@ -2963,7 +3228,7 @@ export class ControlServer {
|
|
|
2963
3228
|
});
|
|
2964
3229
|
return { usd: 0, measured: false };
|
|
2965
3230
|
}
|
|
2966
|
-
const cost = this.costSince(session, run.usageMark);
|
|
3231
|
+
const cost = this.costSince(session, run.usageMark, until);
|
|
2967
3232
|
if (cost.unpriced?.length) {
|
|
2968
3233
|
logLine("run.unmeasured", {
|
|
2969
3234
|
run: run.runId,
|
|
@@ -2993,22 +3258,37 @@ export class ControlServer {
|
|
|
2993
3258
|
run: run.runId,
|
|
2994
3259
|
why: cost.unpriced?.length
|
|
2995
3260
|
? "the runner has no price for a model of this run"
|
|
2996
|
-
:
|
|
2997
|
-
|
|
3261
|
+
: cost.rateUnknown?.length
|
|
3262
|
+
? "the host sent one total for a model that prices a long prompt at other rates, and the total is over that length — which of its requests were long cannot be told"
|
|
3263
|
+
: "the host's usage holds no tokens — the run made no model call",
|
|
3264
|
+
models: cost.unpriced?.length
|
|
3265
|
+
? cost.unpriced.join(",")
|
|
3266
|
+
: cost.rateUnknown?.length
|
|
3267
|
+
? cost.rateUnknown.join(",")
|
|
3268
|
+
: undefined,
|
|
2998
3269
|
});
|
|
2999
3270
|
return { ...cost, measured: false };
|
|
3000
3271
|
}
|
|
3001
|
-
/**
|
|
3002
|
-
|
|
3272
|
+
/**
|
|
3273
|
+
* Transcript cost since `mark`; unmeasured (and zero) without one, or with a
|
|
3274
|
+
* model that has no price. Up to `until` — the mark of the session's next
|
|
3275
|
+
* run — when the turn's books are closed after that run has opened, and up
|
|
3276
|
+
* to now otherwise.
|
|
3277
|
+
*/
|
|
3278
|
+
costSince(session, mark, until) {
|
|
3003
3279
|
if (mark === undefined)
|
|
3004
3280
|
return { usd: 0, measured: false };
|
|
3005
|
-
return priceUsage(usageSince(session.transcriptPath, mark));
|
|
3281
|
+
return priceUsage(until ? usageBetween(mark, until) : usageSince(session.transcriptPath, mark));
|
|
3006
3282
|
}
|
|
3007
3283
|
reportExecution(session, run, durationMs, cost,
|
|
3008
3284
|
/** The run was closed before its task ended for the caller (another host only). */
|
|
3009
3285
|
cutShort = false,
|
|
3010
3286
|
/** Why the turn was sealed: only an ordinary `stop` ends with an answer (AC11). */
|
|
3011
|
-
sealedBy = "stop"
|
|
3287
|
+
sealedBy = "stop",
|
|
3288
|
+
/** When the turn was sealed, and where its tokens end ({@link closeBooks}). */
|
|
3289
|
+
at = {}) {
|
|
3290
|
+
const sealedAtMs = at.sealedAtMs ?? Date.now();
|
|
3291
|
+
const until = at.until;
|
|
3012
3292
|
if (!isScenarioReporter(this.recorder))
|
|
3013
3293
|
return;
|
|
3014
3294
|
const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
|
|
@@ -3071,27 +3351,34 @@ export class ControlServer {
|
|
|
3071
3351
|
// did afterwards, and its saving would read as a loss.
|
|
3072
3352
|
let end = nextMark;
|
|
3073
3353
|
if (state.armedBy === "intent") {
|
|
3074
|
-
windowCost = this.costSince(session, state.usageMark);
|
|
3075
|
-
windowMs = (state.retiredAt ?? next?.armedAt ??
|
|
3354
|
+
windowCost = this.costSince(session, state.usageMark, until);
|
|
3355
|
+
windowMs = (state.retiredAt ?? next?.armedAt ?? sealedAtMs) - state.armedAt;
|
|
3076
3356
|
end = state.retiredMark ?? nextMark;
|
|
3077
3357
|
}
|
|
3078
3358
|
else if (next) {
|
|
3079
3359
|
windowMs = next.armedAt - run.startedAtMs;
|
|
3080
3360
|
}
|
|
3081
3361
|
if (end !== undefined) {
|
|
3082
|
-
const later = this.costSince(session, end);
|
|
3362
|
+
const later = this.costSince(session, end, until);
|
|
3083
3363
|
windowCost = { usd: Math.max(0, windowCost.usd - later.usd), measured: windowCost.measured };
|
|
3084
3364
|
}
|
|
3085
3365
|
const report = this.replay.buildReport(state, {
|
|
3086
3366
|
sessionCostUsd: windowCost.usd,
|
|
3087
3367
|
// A direct run the user cancelled cut its turn short: its cost is no
|
|
3088
|
-
// baseline, and no saving is measured against it.
|
|
3089
|
-
|
|
3368
|
+
// baseline, and no saving is measured against it. The parent's report
|
|
3369
|
+
// bills the whole turn, its continuations' part included, so a
|
|
3370
|
+
// continuation the user cancelled makes the parent's unmeasured too.
|
|
3371
|
+
measured: windowCost.measured &&
|
|
3372
|
+
!this.cancelledPlans.has(state) &&
|
|
3373
|
+
!chain.some((continuation) => this.cancelledPlans.has(continuation)) &&
|
|
3374
|
+
!unsplit,
|
|
3090
3375
|
durationMs: Math.max(0, windowMs),
|
|
3091
3376
|
prompt: run.input || undefined,
|
|
3092
3377
|
siblings: states,
|
|
3093
3378
|
runId: run.runId,
|
|
3094
|
-
|
|
3379
|
+
// Not for a plan whose own window had closed before the turn was cut
|
|
3380
|
+
// short (an intent plan that ran to its end): what it cost is known.
|
|
3381
|
+
cutShort: (cutShort && end === undefined) || undefined,
|
|
3095
3382
|
redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
|
|
3096
3383
|
chainDeriveUsd,
|
|
3097
3384
|
booksOwnSaving: booksOwn(state),
|
|
@@ -3131,7 +3418,7 @@ export class ControlServer {
|
|
|
3131
3418
|
const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
|
|
3132
3419
|
this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
|
|
3133
3420
|
});
|
|
3134
|
-
|
|
3421
|
+
this.holdSeal(run, sent);
|
|
3135
3422
|
return;
|
|
3136
3423
|
}
|
|
3137
3424
|
this.queue.push(() => recorder.reportExecution(report));
|
|
@@ -3230,7 +3517,7 @@ export class ControlServer {
|
|
|
3230
3517
|
const run = session.run;
|
|
3231
3518
|
if (!run)
|
|
3232
3519
|
return;
|
|
3233
|
-
this.sealRun(session, opts.reason);
|
|
3520
|
+
this.sealRun(session, opts.reason, opts.settle === true);
|
|
3234
3521
|
if (opts.detach && session.run === run) {
|
|
3235
3522
|
session.run = undefined;
|
|
3236
3523
|
session.previous = run;
|
|
@@ -3255,7 +3542,14 @@ export class ControlServer {
|
|
|
3255
3542
|
*/
|
|
3256
3543
|
async drainRun(run, flushMs) {
|
|
3257
3544
|
try {
|
|
3258
|
-
|
|
3545
|
+
// The seal adds to what it waits for while it settles — the books wait
|
|
3546
|
+
// for the turn's last message, and then for the answer's text — so this
|
|
3547
|
+
// waits until nothing more was added.
|
|
3548
|
+
for (let sealing = run.sealing;; sealing = run.sealing) {
|
|
3549
|
+
await sealing;
|
|
3550
|
+
if (run.sealing === sealing)
|
|
3551
|
+
break;
|
|
3552
|
+
}
|
|
3259
3553
|
await this.queue.flush();
|
|
3260
3554
|
// Bounded, so an outage cannot pin every sealed turn in memory until the
|
|
3261
3555
|
// service returns; the recorder keeps sending on its own either way.
|
|
@@ -3414,6 +3708,10 @@ export class ControlServer {
|
|
|
3414
3708
|
open.promptSeen = true;
|
|
3415
3709
|
open.input = input;
|
|
3416
3710
|
open.promptDay = localDay();
|
|
3711
|
+
// A prompt all the same: the next one is a turn of its own. Not counted,
|
|
3712
|
+
// the session still looked as if it had had none, so the next prompt took
|
|
3713
|
+
// this branch again and was recorded into this run too.
|
|
3714
|
+
session.prompts += 1;
|
|
3417
3715
|
// This hook announces nothing, so no plan of this run may steer.
|
|
3418
3716
|
open.matchExpired = true;
|
|
3419
3717
|
this.retireUnannounced(session, open);
|
|
@@ -3579,6 +3877,8 @@ export class ControlServer {
|
|
|
3579
3877
|
const live = this.livePlan(run);
|
|
3580
3878
|
/** `run_scenario` for this session's own announced direct plan: allowed, see below. */
|
|
3581
3879
|
let allowDirect = false;
|
|
3880
|
+
/** A call that only looks, which a direct plan waits through (B2 is11.md): it runs as written. */
|
|
3881
|
+
let waitedRead = false;
|
|
3582
3882
|
if (this.replay.isDirectTool(toolName)) {
|
|
3583
3883
|
// `run_scenario` with no live plan does nothing but answer "no scenario
|
|
3584
3884
|
// is armed". Recorded, that answer is a failed tool call, and a recording
|
|
@@ -3607,19 +3907,43 @@ export class ControlServer {
|
|
|
3607
3907
|
// refused (the user said no, or a rule denied it) or it failed on the way.
|
|
3608
3908
|
// Refused means refused. The plan retires without running anything — it
|
|
3609
3909
|
// must not come back as a divergence that runs every step unattended.
|
|
3610
|
-
this.vetoPlan(run, live, "run_scenario was called but never reached the runner — refused or failed");
|
|
3910
|
+
this.vetoPlan(run, live, "run_scenario was called but never reached the runner — refused or failed", "run_refused");
|
|
3611
3911
|
}
|
|
3612
3912
|
else if (live?.mode === "direct" && !live.directRequested && !live.running && !this.runningPlans.has(live)) {
|
|
3613
|
-
// A direct plan whose
|
|
3614
|
-
// asked for it.
|
|
3615
|
-
// — the bir server failed to start, was
|
|
3616
|
-
// nothing —
|
|
3617
|
-
//
|
|
3913
|
+
// A direct plan whose next call is not `run_scenario`, and which never
|
|
3914
|
+
// asked for it. Nothing of the plan ever runs in this hook in its place:
|
|
3915
|
+
// when the model has no such tool — the bir server failed to start, was
|
|
3916
|
+
// disabled in /mcp, or lists nothing — a divergence would execute every
|
|
3917
|
+
// step, writes included, with no permission prompt, and deny the
|
|
3618
3918
|
// model's call: a broken delivery tool turned into an unattended run.
|
|
3619
|
-
//
|
|
3620
|
-
//
|
|
3621
|
-
|
|
3622
|
-
|
|
3919
|
+
//
|
|
3920
|
+
// A call that only looks does not retire the plan (B2 is11.md): it runs
|
|
3921
|
+
// as written, and the plan waits for `run_scenario`, told to the model
|
|
3922
|
+
// once on this call's PostToolUse. In is11 the model checked its own
|
|
3923
|
+
// notes first, because the note named the recording's person, and
|
|
3924
|
+
// losing the plan there cost a whole agent run for a value the steps
|
|
3925
|
+
// take from the request anyway. The first call that may change
|
|
3926
|
+
// something retires it, running nothing: run after the model's own
|
|
3927
|
+
// change, the plan could do the same work twice. That call runs as
|
|
3928
|
+
// written, and the model hears on its PostToolUse that the task is its
|
|
3929
|
+
// own.
|
|
3930
|
+
if (readsOnlyCall(toolName, modelArgs, this.toolHints.get(toolName))) {
|
|
3931
|
+
waitedRead = true;
|
|
3932
|
+
live.readsWaited = (live.readsWaited ?? 0) + 1;
|
|
3933
|
+
if (live.readsWaited === 1) {
|
|
3934
|
+
run.handback = directWaitingNote(live.plan?.requestValues());
|
|
3935
|
+
logLine("replay.direct_waits", {
|
|
3936
|
+
run: run.runId,
|
|
3937
|
+
scenario: live.scenarioId ?? undefined,
|
|
3938
|
+
tool: toolName,
|
|
3939
|
+
why: "the model's first call only reads — it runs as written, and the plan waits for run_scenario",
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
else {
|
|
3944
|
+
this.vetoPlan(run, live, `the model's first call that may change something was ${toolName}, not run_scenario${live.readsWaited ? ` (after ${live.readsWaited} that only read)` : ""}`, "changed_first");
|
|
3945
|
+
run.handback = DIRECT_SKIPPED_NOTE;
|
|
3946
|
+
}
|
|
3623
3947
|
}
|
|
3624
3948
|
const agentId = payload.agent_id ?? session.agentId;
|
|
3625
3949
|
// The real intent, read from every line sharing this message's id
|
|
@@ -3670,6 +3994,7 @@ export class ControlServer {
|
|
|
3670
3994
|
});
|
|
3671
3995
|
const action = await this.replay.preTool(steering, toolName, toolUseId, modelArgs, {
|
|
3672
3996
|
signal: answeredLate.signal,
|
|
3997
|
+
readsOnly: waitedRead && steering === live,
|
|
3673
3998
|
});
|
|
3674
3999
|
if (overran) {
|
|
3675
4000
|
// The host already has its answer. A divergence was answered with its
|
|
@@ -4211,20 +4536,28 @@ export class ControlServer {
|
|
|
4211
4536
|
}
|
|
4212
4537
|
/**
|
|
4213
4538
|
* Retire `state` without running anything: the model's `run_scenario` was
|
|
4214
|
-
* refused or failed
|
|
4215
|
-
* the task — never a
|
|
4539
|
+
* refused or failed, or the model changed something itself first. Books a
|
|
4540
|
+
* baseline sample (`not_steered`) — the agent did the task — never a
|
|
4541
|
+
* divergence. `code` goes with the report, so the recording page can say
|
|
4542
|
+
* why the plan did not run (B2 is11.md: it said "the runner did not say
|
|
4543
|
+
* why", and guessed wrong).
|
|
4216
4544
|
*/
|
|
4217
|
-
vetoPlan(run, state, why) {
|
|
4545
|
+
vetoPlan(run, state, why, code) {
|
|
4218
4546
|
if (state.retired)
|
|
4219
4547
|
return;
|
|
4220
4548
|
state.retired = true;
|
|
4221
4549
|
state.retiredAt = Date.now();
|
|
4222
4550
|
state.pinned.clear();
|
|
4223
4551
|
state.skipNote = undefined;
|
|
4552
|
+
// A continuation has a reason of its own for this, `not_taken`, which the
|
|
4553
|
+
// page words for a plan that follows a hand-over (restoreParkedFragment).
|
|
4554
|
+
if (code && !state.declined && !state.continuation)
|
|
4555
|
+
state.declined = code;
|
|
4224
4556
|
this.directCalls = this.directCalls.filter((c) => c.state !== state);
|
|
4225
4557
|
logLine("replay.vetoed", {
|
|
4226
4558
|
run: run.runId,
|
|
4227
4559
|
scenario: state.scenarioId ?? undefined,
|
|
4560
|
+
code,
|
|
4228
4561
|
why: `${why} — the plan is retired without running any step`,
|
|
4229
4562
|
});
|
|
4230
4563
|
}
|
|
@@ -4538,6 +4871,7 @@ export class ControlServer {
|
|
|
4538
4871
|
// session's own cwd from the hook payloads, never the daemon's.
|
|
4539
4872
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
4540
4873
|
this.declineWithoutDeliveryTool(state);
|
|
4874
|
+
this.declineAfterCancel(session, state);
|
|
4541
4875
|
if (!state.plan) {
|
|
4542
4876
|
// A declined intent hit reports nothing: the turn's cost is not a
|
|
4543
4877
|
// measurement of that scenario's task, so it is no baseline sample either.
|
|
@@ -4660,6 +4994,7 @@ export class ControlServer {
|
|
|
4660
4994
|
scenarioId: f.scenarioId,
|
|
4661
4995
|
stepIndex: f.stepIndex,
|
|
4662
4996
|
...(f.kind ? { kind: f.kind } : {}),
|
|
4997
|
+
...(f.stop ? { stop: f.stop } : {}),
|
|
4663
4998
|
...(f.handoverId ? { id: f.handoverId } : {}),
|
|
4664
4999
|
};
|
|
4665
5000
|
// Whether this hand-over is still the turn's: its fragment due, nothing
|
|
@@ -4996,6 +5331,7 @@ export class ControlServer {
|
|
|
4996
5331
|
// Which half the model's work after it is (the service's fallbk.md
|
|
4997
5332
|
// Part 3), and the id that makes the hand-over one hit.
|
|
4998
5333
|
kind: h.kind,
|
|
5334
|
+
...(h.stop ? { stop: h.stop } : {}),
|
|
4999
5335
|
handoverId: `ho_${randomUUID()}`,
|
|
5000
5336
|
};
|
|
5001
5337
|
}
|
|
@@ -5023,6 +5359,7 @@ export class ControlServer {
|
|
|
5023
5359
|
const sent = {
|
|
5024
5360
|
...fallbackOf,
|
|
5025
5361
|
...(f.kind ? { kind: f.kind } : {}),
|
|
5362
|
+
...(f.stop ? { stop: f.stop } : {}),
|
|
5026
5363
|
...(f.handoverId ? { handoverId: f.handoverId } : {}),
|
|
5027
5364
|
...(f.counted ? { counted: true } : {}),
|
|
5028
5365
|
};
|
|
@@ -5381,7 +5718,7 @@ export class ControlServer {
|
|
|
5381
5718
|
if (!this.runningPlans.has(waiting.state)) {
|
|
5382
5719
|
this.vetoPlan(waiting.run, waiting.state, failed
|
|
5383
5720
|
? "run_scenario failed before it reached the runner"
|
|
5384
|
-
: "run_scenario answered without ever reaching the runner");
|
|
5721
|
+
: "run_scenario answered without ever reaching the runner", "run_refused");
|
|
5385
5722
|
}
|
|
5386
5723
|
}
|
|
5387
5724
|
}
|
|
@@ -5484,7 +5821,21 @@ export class ControlServer {
|
|
|
5484
5821
|
correlation.fallback.unref?.();
|
|
5485
5822
|
return { pending: true };
|
|
5486
5823
|
}
|
|
5487
|
-
|
|
5824
|
+
// The step this answer completes. A host that sends no `tool_use_id` is
|
|
5825
|
+
// paired by the tool's name, oldest call first: the pre stored its call
|
|
5826
|
+
// under its step index (`anon_<index>`) and this looked it up under
|
|
5827
|
+
// `anon_<tool name>`, which never matched, so every answer of such a host
|
|
5828
|
+
// was recorded at an index of its own and the pairs shifted.
|
|
5829
|
+
let builtInKey = toolUseId && run.builtIns.has(toolUseId) ? toolUseId : undefined;
|
|
5830
|
+
if (builtInKey === undefined) {
|
|
5831
|
+
for (const [key, candidate] of run.builtIns) {
|
|
5832
|
+
if (key.startsWith("anon_") && candidate.toolName === toolName) {
|
|
5833
|
+
builtInKey = key;
|
|
5834
|
+
break;
|
|
5835
|
+
}
|
|
5836
|
+
}
|
|
5837
|
+
}
|
|
5838
|
+
const builtIn = builtInKey !== undefined ? run.builtIns.get(builtInKey) : undefined;
|
|
5488
5839
|
const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
|
|
5489
5840
|
if (run.recording) {
|
|
5490
5841
|
const output = serializeCapped(redact(payload.tool_response ?? {}));
|
|
@@ -5500,7 +5851,8 @@ export class ControlServer {
|
|
|
5500
5851
|
});
|
|
5501
5852
|
});
|
|
5502
5853
|
}
|
|
5503
|
-
|
|
5854
|
+
if (builtInKey !== undefined)
|
|
5855
|
+
run.builtIns.delete(builtInKey);
|
|
5504
5856
|
logDetail("tool.post", { run: run.runId, tool: toolName, step: responseIndex, failed });
|
|
5505
5857
|
return { stepIndex: responseIndex };
|
|
5506
5858
|
}
|
|
@@ -5562,8 +5914,18 @@ export class ControlServer {
|
|
|
5562
5914
|
// Another host hands the run's answer and its tokens over here — it has
|
|
5563
5915
|
// no transcript to read them from — and says when the run failed or its
|
|
5564
5916
|
// recording is not whole.
|
|
5565
|
-
|
|
5566
|
-
|
|
5917
|
+
// `StopFailure` arrives on this route too: the turn ended on an API error,
|
|
5918
|
+
// with no answer to check and no last message to wait for. Sealed as the
|
|
5919
|
+
// ordinary stop it was taken for, its cut answer went to the answer check
|
|
5920
|
+
// and its cost was reported as a whole turn's.
|
|
5921
|
+
const reason = otherHost(session)
|
|
5922
|
+
? this.noteHostStop(run, payload)
|
|
5923
|
+
: payload.hook_event_name === "StopFailure"
|
|
5924
|
+
? "stop_failure"
|
|
5925
|
+
: "stop";
|
|
5926
|
+
// Claude Code writes the turn's last message after this hook: the turn's
|
|
5927
|
+
// cost is read once it is there (`sealRun`).
|
|
5928
|
+
this.closeRun(session, { detach: false, reason, settle: reason === "stop" });
|
|
5567
5929
|
return {};
|
|
5568
5930
|
}
|
|
5569
5931
|
/**
|
|
@@ -5836,6 +6198,10 @@ export class ControlServer {
|
|
|
5836
6198
|
// prompt may seal the turn before the step in flight has finished.
|
|
5837
6199
|
const onCancel = () => {
|
|
5838
6200
|
this.cancelledPlans.add(state);
|
|
6201
|
+
// And the same plan is not run again at once, in any session, when its
|
|
6202
|
+
// run sent a call ({@link declineAfterCancel}).
|
|
6203
|
+
if (state.scenarioId)
|
|
6204
|
+
this.holdAfterCancel(state.scenarioId, state);
|
|
5839
6205
|
};
|
|
5840
6206
|
if (signal?.aborted)
|
|
5841
6207
|
onCancel();
|
|
@@ -5864,8 +6230,9 @@ export class ControlServer {
|
|
|
5864
6230
|
* run those steps a second time.
|
|
5865
6231
|
*/
|
|
5866
6232
|
stashCancelledNote(sessionId, state) {
|
|
5867
|
-
|
|
5868
|
-
|
|
6233
|
+
// A hand-over's note, or — when every step ran with nobody to read the
|
|
6234
|
+
// results — the note about those.
|
|
6235
|
+
if (!this.replay.hasUndeliveredNote(state))
|
|
5869
6236
|
return;
|
|
5870
6237
|
const session = this.sessions.get(sessionId);
|
|
5871
6238
|
if (!session)
|
|
@@ -6316,12 +6683,8 @@ export class ControlServer {
|
|
|
6316
6683
|
});
|
|
6317
6684
|
}
|
|
6318
6685
|
}
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
* ephemeral one only if it is taken — in which case the installed hooks point at
|
|
6324
|
-
* a port nobody is listening on, and both `bir-hooks` and `bir doctor` say so.
|
|
6325
|
-
*/
|
|
6326
|
-
export const DEFAULT_CONTROL_PORT = 53411;
|
|
6686
|
+
// The port `bir install` writes into the hook URLs now lives in control/paths.ts:
|
|
6687
|
+
// `bir` needed this one number, and loading this whole file for it added some
|
|
6688
|
+
// 50 ms to every command. Re-exported, so the server's own importers still find it.
|
|
6689
|
+
export { DEFAULT_CONTROL_PORT } from "./paths.js";
|
|
6327
6690
|
//# sourceMappingURL=server.js.map
|