@basein/runner 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/bir-hooks.d.ts +12 -16
- package/dist/bin/bir-hooks.js +12 -16
- package/dist/bin/bir-scenario.js +1 -1
- package/dist/bin/bir.js +1 -21
- package/dist/bin/investigate.d.ts +0 -9
- package/dist/bin/investigate.js +14 -42
- package/dist/bin/scenario-edit.js +13 -44
- package/dist/control/pin-guard.d.ts +16 -0
- package/dist/control/pin-guard.js +36 -0
- package/dist/control/recorder-main.d.ts +0 -15
- package/dist/control/recorder-main.js +7 -39
- package/dist/control/server.d.ts +81 -131
- package/dist/control/server.js +460 -968
- package/dist/record/recorder.d.ts +47 -138
- package/dist/record/recorder.js +0 -3
- package/dist/record/remote-recorder.d.ts +1 -9
- package/dist/record/remote-recorder.js +11 -49
- package/dist/record/switchable-recorder.js +0 -2
- package/dist/replay/bundle.d.ts +2 -2
- package/dist/replay/bundle.js +3 -3
- package/dist/replay/controller.d.ts +75 -124
- package/dist/replay/controller.js +207 -529
- package/dist/replay/derive.d.ts +0 -10
- package/dist/replay/derive.js +0 -3
- package/dist/replay/handover.d.ts +2 -37
- package/dist/replay/handover.js +38 -106
- package/dist/replay/logic.d.ts +0 -11
- package/dist/replay/logic.js +0 -24
- package/dist/replay/plan.d.ts +43 -163
- package/dist/replay/plan.js +144 -556
- package/dist/replay/safety.d.ts +4 -5
- package/dist/replay/safety.js +7 -7
- package/dist/replay/source-run.d.ts +15 -24
- package/dist/replay/source-run.js +25 -58
- package/dist/replay/targets.d.ts +11 -21
- package/dist/replay/targets.js +30 -69
- package/dist/replay/types.d.ts +9 -96
- package/dist/replay/types.js +3 -16
- package/dist/util/service-headers.d.ts +1 -2
- package/dist/util/service-headers.js +1 -2
- package/docs/calculatedReplay.md +17 -74
- package/docs/calculatedReplayGuide.md +10 -30
- package/package.json +1 -1
- package/production.md +5 -35
- package/dist/replay/condition-words.d.ts +0 -28
- package/dist/replay/condition-words.js +0 -59
- package/dist/replay/flatten.d.ts +0 -161
- package/dist/replay/flatten.js +0 -222
- package/dist/replay/reads-only.d.ts +0 -29
- package/dist/replay/reads-only.js +0 -261
package/dist/control/server.js
CHANGED
|
@@ -30,19 +30,16 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
30
30
|
import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
|
|
31
31
|
import { StepIndexAllocator } from "./ordering.js";
|
|
32
32
|
import { pidAlive } from "./discovery.js";
|
|
33
|
-
import { pinCompatible } from "./pin-guard.js";
|
|
33
|
+
import { pinCompatible, sameFirstCall } from "./pin-guard.js";
|
|
34
34
|
import { agentDayOf, contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, settledTextAfterToolUse, thinkingForToolUse, usageSince, } from "./transcript.js";
|
|
35
35
|
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
36
36
|
import { asSent, userText } from "../record/prompt-text.js";
|
|
37
37
|
import { redact, redactText } from "../record/redact.js";
|
|
38
38
|
import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
|
|
39
39
|
import { StepQueue } from "../record/queue.js";
|
|
40
|
-
import { NullRecorder, isAttentionReader, isIntentMatcher, isMatchAware,
|
|
40
|
+
import { NullRecorder, isAttentionReader, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioDisputer, isScenarioReporter, isServiceProber, isToolListReporter, } from "../record/recorder.js";
|
|
41
41
|
import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, } from "../replay/controller.js";
|
|
42
42
|
import { localDay } from "../replay/derive.js";
|
|
43
|
-
import { RecordedValueError, markedKey } from "../replay/plan.js";
|
|
44
|
-
import { evalToolInputLogic } from "../replay/logic.js";
|
|
45
|
-
import { callMayChange, readsOnly } from "../replay/reads-only.js";
|
|
46
43
|
import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
|
|
47
44
|
import { logDetail, logLine, logOnce, errText } from "../util/log.js";
|
|
48
45
|
import { journal } from "../util/journal.js";
|
|
@@ -100,128 +97,36 @@ const PREVIEW_RESERVE_MS = 500;
|
|
|
100
97
|
*/
|
|
101
98
|
const PROMPT_WAIT_MS = 7_500;
|
|
102
99
|
/**
|
|
103
|
-
* How long a `PreToolUse` waits for the intent probe
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* question does not, and is counted without arming (segmented.md R-HIT-11).
|
|
100
|
+
* How long a `PreToolUse` waits for the intent probe, which may arm a plan in
|
|
101
|
+
* the call's place (`BIR_INTENT_ARM_BUDGET_MS`). A match or a miss answers
|
|
102
|
+
* well inside it; a slower answer arms nothing.
|
|
107
103
|
*/
|
|
108
104
|
const INTENT_ARM_PROBE_MS = 500;
|
|
109
105
|
/** What an intent-armed plan may spend arming — derivation — inside `PreToolUse`. */
|
|
110
106
|
const INTENT_ARM_DERIVE_MS = 1_500;
|
|
107
|
+
/** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
|
|
108
|
+
const FRAGMENT_CREATE_MS = 300;
|
|
111
109
|
/**
|
|
112
|
-
* How long the
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* agent nothing; it stays well inside the route's own deadline.
|
|
110
|
+
* How long the model's first call after a hand-over waits for the service to
|
|
111
|
+
* say whether the work recorded after an earlier hand-over at that step has a
|
|
112
|
+
* plan (the service's fallbk.md Part 3). One lookup, no words matched: it
|
|
113
|
+
* answers in a round trip. Asked once per hand-over, never per call.
|
|
117
114
|
*/
|
|
118
|
-
const
|
|
119
|
-
/** How long the question "what can follow this decision" may take. It is asked in the background. */
|
|
120
|
-
const NEXT_FETCH_MS = 5_000;
|
|
121
|
-
/** How long a next scenario's arm waits for its values to settle, in the background. */
|
|
122
|
-
const NEXT_READY_MS = 8_000;
|
|
123
|
-
/** At most this many scenarios follow one decision (the service sends no more either). */
|
|
124
|
-
const NEXT_MAX = 4;
|
|
125
|
-
/** The turn's request a decision fragment carries, in characters (offer1.md O2). */
|
|
126
|
-
const DECISION_REQUEST_MAX = 4_000;
|
|
115
|
+
const CONTINUATION_PROBE_MS = 1_500;
|
|
127
116
|
/**
|
|
128
|
-
* How long the
|
|
129
|
-
*
|
|
130
|
-
*
|
|
117
|
+
* How long the continuation request itself may take — longer than the call
|
|
118
|
+
* waits for it, so that an answer too late for the call still lands, and the
|
|
119
|
+
* ticket it may carry is reported as late instead of never.
|
|
131
120
|
*/
|
|
132
|
-
const
|
|
133
|
-
/** Tools whose call is its command: the same tool with another command is another call (offer1.md O8). */
|
|
134
|
-
const COMMAND_TOOLS = new Set(["Bash", "PowerShell"]);
|
|
135
|
-
/** A shell call's command, trimmed; empty when it has none. */
|
|
136
|
-
function commandOf(args) {
|
|
137
|
-
const c = args && typeof args === "object" ? args.command : undefined;
|
|
138
|
-
return typeof c === "string" ? c.trim() : "";
|
|
139
|
-
}
|
|
140
|
-
/** What `replay.next_miss` says for each way a decision's next scenarios end unused. */
|
|
141
|
-
const NEXT_MISS_WHY = {
|
|
142
|
-
other_values: "the agent's call has the tool a next scenario starts with, but not its values — it took that way its own way; that work is recorded, and becomes a scenario of its own",
|
|
143
|
-
own_way: "the agent's call may change something and starts none of them — it is doing a way of its own; that work is recorded, and becomes a scenario of its own, and none of these may start in the middle of it",
|
|
144
|
-
not_ready: "the scenario that starts with this call's tool was not armed in time, and the call may change something — the agent went on its own way",
|
|
145
|
-
calls_limit: "the agent's first calls after the decision started none of them — it is doing the work itself; that work is recorded, and becomes a scenario of its own",
|
|
146
|
-
turn_ended: "the turn ended before any of them was used",
|
|
147
|
-
no_delivery_tool: "the scenario would be delivered through mcp__bir__run_scenario, which no running bir server serves",
|
|
148
|
-
};
|
|
149
|
-
/** One argument as a call compares it: trimmed text for a scalar, canonical JSON for the rest. */
|
|
150
|
-
function argText(value) {
|
|
151
|
-
if (value === null || value === undefined)
|
|
152
|
-
return "";
|
|
153
|
-
if (typeof value === "string")
|
|
154
|
-
return value.trim();
|
|
155
|
-
if (typeof value === "number" || typeof value === "boolean")
|
|
156
|
-
return String(value);
|
|
157
|
-
return canonicalJson(value);
|
|
158
|
-
}
|
|
159
|
-
function canonicalJson(value) {
|
|
160
|
-
if (Array.isArray(value))
|
|
161
|
-
return `[${value.map(canonicalJson).join(",")}]`;
|
|
162
|
-
if (value && typeof value === "object") {
|
|
163
|
-
const o = value;
|
|
164
|
-
return `{${Object.keys(o)
|
|
165
|
-
.sort()
|
|
166
|
-
.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
|
|
167
|
-
.join(",")}}`;
|
|
168
|
-
}
|
|
169
|
-
return JSON.stringify(value ?? null) ?? "null";
|
|
170
|
-
}
|
|
121
|
+
const CONTINUATION_REQUEST_MS = 6_000;
|
|
171
122
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* plan does
|
|
175
|
-
*
|
|
123
|
+
* What a continuation may spend arming — reading its values — inside that
|
|
124
|
+
* `PreToolUse`. Longer than an intent arm may: it is asked once per hand-over,
|
|
125
|
+
* the plan it arms does the rest of the task, and a value the request names
|
|
126
|
+
* is read by the service, which gives a reading 6 s. With the probe it stays
|
|
127
|
+
* well inside the route's 12 s.
|
|
176
128
|
*/
|
|
177
|
-
|
|
178
|
-
if (!agent || typeof agent !== "object" || Array.isArray(agent))
|
|
179
|
-
return "other";
|
|
180
|
-
const a = agent;
|
|
181
|
-
let missing = false;
|
|
182
|
-
for (const key of new Set([...Object.keys(planned), ...Object.keys(a)])) {
|
|
183
|
-
if (key.startsWith("__bir"))
|
|
184
|
-
continue;
|
|
185
|
-
const p = argText(planned[key]);
|
|
186
|
-
const v = argText(a[key]);
|
|
187
|
-
if (p === v)
|
|
188
|
-
continue;
|
|
189
|
-
if (v !== "" && (p === "" || markedKey(planned[key]) !== undefined)) {
|
|
190
|
-
missing = true;
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
return "other";
|
|
194
|
-
}
|
|
195
|
-
return missing ? "missing" : "hit";
|
|
196
|
-
}
|
|
197
|
-
/** A next scenario's plan that was never announced: it never runs, and is never reported. */
|
|
198
|
-
function dropNextState(state) {
|
|
199
|
-
state.plan = undefined;
|
|
200
|
-
state.mode = "none";
|
|
201
|
-
state.retired = true;
|
|
202
|
-
state.pinned.clear();
|
|
203
|
-
state.reported = true;
|
|
204
|
-
}
|
|
205
|
-
/** Drop an entry's plan, now or when its arm lands — all but `kept`, the one that hit. */
|
|
206
|
-
function dropNextEntry(entry, kept) {
|
|
207
|
-
if (entry.state) {
|
|
208
|
-
if (entry.state !== kept)
|
|
209
|
-
dropNextState(entry.state);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
void entry.arming.then((late) => {
|
|
213
|
-
if (late && late !== kept)
|
|
214
|
-
dropNextState(late);
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* The agent's calls after a decision that are checked against its next
|
|
219
|
-
* scenarios: a scenario is a hit only on the way the agent starts, never in
|
|
220
|
-
* the middle of work it is already doing.
|
|
221
|
-
*/
|
|
222
|
-
const NEXT_CALLS_MAX = 5;
|
|
223
|
-
/** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
|
|
224
|
-
const FRAGMENT_CREATE_MS = 300;
|
|
129
|
+
const CONTINUATION_ARM_MS = 7_000;
|
|
225
130
|
/** What `close()` spends draining the last runs to the service before it lets go. */
|
|
226
131
|
const CLOSE_DRAIN_MS = 5_000;
|
|
227
132
|
/** How long a background drain follows one run before it stops waiting for it. */
|
|
@@ -587,6 +492,17 @@ function reportedArgs(args) {
|
|
|
587
492
|
function score(value) {
|
|
588
493
|
return typeof value === "number" && Number.isFinite(value) ? value.toFixed(3) : undefined;
|
|
589
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* The tool a served scenario starts with, or undefined when the payload does
|
|
497
|
+
* not say. Untyped JSON from the service, read without trusting its shape.
|
|
498
|
+
*/
|
|
499
|
+
function firstStepTool(scenario) {
|
|
500
|
+
const steps = scenario?.steps;
|
|
501
|
+
if (!Array.isArray(steps) || steps.length === 0)
|
|
502
|
+
return undefined;
|
|
503
|
+
const name = steps[0]?.toolName;
|
|
504
|
+
return typeof name === "string" ? name : undefined;
|
|
505
|
+
}
|
|
590
506
|
function positiveEnv(name, fallback) {
|
|
591
507
|
const n = Number(process.env[name]);
|
|
592
508
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
@@ -751,14 +667,6 @@ function priceUsage(usage) {
|
|
|
751
667
|
* through it, because the checks a replay in another host needs are here.
|
|
752
668
|
*/
|
|
753
669
|
export const HOST_FIELDS = 2;
|
|
754
|
-
/** One MCP call, as seen from up to two sides. */
|
|
755
|
-
/**
|
|
756
|
-
* Field separator for the probe's dedupe hash (segmented.md R-HIT-5). A NUL,
|
|
757
|
-
* so that a text ending where a tool name begins can never hash the same as the
|
|
758
|
-
* other way round. Built rather than written as an escape, because a literal
|
|
759
|
-
* NUL in source is a trap for every tool that reads the file.
|
|
760
|
-
*/
|
|
761
|
-
const PROBE_SEP = String.fromCharCode(0);
|
|
762
670
|
export class ControlServer {
|
|
763
671
|
token;
|
|
764
672
|
sessionId = "birsess_" + randomUUID();
|
|
@@ -908,8 +816,8 @@ export class ControlServer {
|
|
|
908
816
|
deriveRecentResults;
|
|
909
817
|
/**
|
|
910
818
|
* Which rungs of R-INTENT-1 the runner may send (`BIR_INTENT_SOURCES`). It
|
|
911
|
-
* governs only what is *sent*: with every source off,
|
|
912
|
-
*
|
|
819
|
+
* governs only what is *sent*: with every source off, a step goes out with no
|
|
820
|
+
* text, and no intent probe is sent for it.
|
|
913
821
|
*/
|
|
914
822
|
intentSources;
|
|
915
823
|
constructor(opts) {
|
|
@@ -1044,35 +952,33 @@ export class ControlServer {
|
|
|
1044
952
|
unannounced: s.run.pendingDirective?.state === s.run.replay ? true : undefined,
|
|
1045
953
|
running: this.runningPlans.has(s.run.replay) ? true : undefined,
|
|
1046
954
|
armedBy: s.run.replay.armedBy,
|
|
955
|
+
// A continuation (the service's fallbk.md Part 3): the plan it
|
|
956
|
+
// continues, and the step that plan stopped in front of.
|
|
957
|
+
continues: s.run.replay.continuation
|
|
958
|
+
? {
|
|
959
|
+
scenarioId: s.run.replay.continuation.ofScenarioId,
|
|
960
|
+
stepIndex: s.run.replay.continuation.stepIndex,
|
|
961
|
+
}
|
|
962
|
+
: null,
|
|
1047
963
|
handover: s.run.replay.handover
|
|
1048
964
|
? { kind: s.run.replay.handover.kind, stepIndex: s.run.replay.handover.stepIndex }
|
|
1049
965
|
: null,
|
|
1050
966
|
plans: s.run.replays.length,
|
|
1051
|
-
kind: s.run.replay.kind,
|
|
1052
967
|
}
|
|
1053
968
|
: null,
|
|
1054
|
-
// Present whether or not a plan is armed
|
|
1055
|
-
//
|
|
1056
|
-
// "nothing armed" and "nothing even probed" are different problems.
|
|
969
|
+
// Present whether or not a plan is armed: "nothing armed" and "nothing
|
|
970
|
+
// even probed" are different problems.
|
|
1057
971
|
intent: s.run
|
|
1058
972
|
? {
|
|
1059
973
|
probes: s.run.intent.requests,
|
|
1060
974
|
armed: s.run.intent.armed,
|
|
1061
|
-
lastStepHit: s.run.intent.lastStepHit ?? null,
|
|
1062
975
|
}
|
|
1063
976
|
: null,
|
|
1064
977
|
fragment: s.run?.fragment
|
|
1065
|
-
? { state: s.run.fragment.state, ...(s.run.fragment.decision ? { decision: true } : {}) }
|
|
1066
|
-
: null,
|
|
1067
|
-
// The calculated scenarios that can follow a decision of this turn
|
|
1068
|
-
// (offer1.md O3): how many are ready for the agent's next call, and why
|
|
1069
|
-
// the set ended once it has.
|
|
1070
|
-
next: s.run?.next
|
|
1071
978
|
? {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
ended: s.run.next.ended ?? null,
|
|
979
|
+
state: s.run.fragment.state,
|
|
980
|
+
...(s.run.fragment.asked ? { asked: true } : {}),
|
|
981
|
+
...(s.run.fragment.counted ? { counted: true } : {}),
|
|
1076
982
|
}
|
|
1077
983
|
: null,
|
|
1078
984
|
// The service's offer to record the turn as a recording's newest run,
|
|
@@ -1087,15 +993,6 @@ export class ControlServer {
|
|
|
1087
993
|
// The authoritative answer to "is anything actually being saved?".
|
|
1088
994
|
recording: this.opts.recording ?? !(this.recorder instanceof NullRecorder),
|
|
1089
995
|
account: this.opts.account ?? null,
|
|
1090
|
-
// Whether a handed-out segment may actually run mid-task, or whether the
|
|
1091
|
-
// runner is only watching (R-OUT-10, R-LIFE-8). Observe-only must never
|
|
1092
|
-
// be invisible: an operator has to be able to see which of the two this
|
|
1093
|
-
// machine is doing without reading a log file.
|
|
1094
|
-
segmentArm: this.replay.segmentArm,
|
|
1095
|
-
// And whether a step's intent may start a plan in its own PreToolUse,
|
|
1096
|
-
// which means waiting for the probe there (it follows segmentArm unless
|
|
1097
|
-
// BIR_INTENT_ARM says otherwise). For `bir doctor`.
|
|
1098
|
-
intentArm: this.intentArming(),
|
|
1099
996
|
authUrl: resolveAuthUrl() || null,
|
|
1100
997
|
sessionId: this.sessionId,
|
|
1101
998
|
pid: process.pid,
|
|
@@ -2227,12 +2124,6 @@ export class ControlServer {
|
|
|
2227
2124
|
if (!otherHost(session))
|
|
2228
2125
|
state.reportTool = () => this.reportToolServed();
|
|
2229
2126
|
this.declineWithoutDeliveryTool(state);
|
|
2230
|
-
// A plan that stops at its decision is billed only up to the stop (the
|
|
2231
|
-
// service's segmentsbreakdown.md SB-26): its hand-over takes this mark. A
|
|
2232
|
-
// plan of the prompt without one keeps the window it always had.
|
|
2233
|
-
if (!otherHost(session) && this.replay.stopsAtDecision(state)) {
|
|
2234
|
-
state.markUsage = () => markTranscriptUsage(session.transcriptPath);
|
|
2235
|
-
}
|
|
2236
2127
|
// A steer plan with a shell or file-write step can only be followed if the
|
|
2237
2128
|
// directive shows its computed arguments (the pin guard holds those steps
|
|
2238
2129
|
// to their exact shape), so give its parameters a moment to settle —
|
|
@@ -2950,9 +2841,6 @@ export class ControlServer {
|
|
|
2950
2841
|
run.pendingDirective = undefined;
|
|
2951
2842
|
if (unannounced)
|
|
2952
2843
|
this.shelveUnannounced(unannounced);
|
|
2953
|
-
// The scenarios that could have followed a decision and were never used:
|
|
2954
|
-
// never announced, never reported (offer1.md O7).
|
|
2955
|
-
this.endNext(run, "turn_ended");
|
|
2956
2844
|
// A `run_scenario` still waiting for this run's plan will never get it.
|
|
2957
2845
|
this.directCalls = this.directCalls.filter((c) => c.run !== run);
|
|
2958
2846
|
const durationMs = Date.now() - run.startedAtMs;
|
|
@@ -3147,36 +3035,50 @@ export class ControlServer {
|
|
|
3147
3035
|
? { runId: r.openedRunId, ownCalls: r.ownCalls ?? 0 }
|
|
3148
3036
|
: undefined;
|
|
3149
3037
|
const prompt = states.find((s) => s.armedBy === "prompt");
|
|
3038
|
+
// The plans that continued a plan in this turn (the service's fallbk.md
|
|
3039
|
+
// Part 3), directly or through one another: its hand-over's follow-on
|
|
3040
|
+
// work. Its baseline is the whole task, theirs only the part after the
|
|
3041
|
+
// hand-over, so its report bills the whole turn — its window runs through
|
|
3042
|
+
// theirs, and their readings are booked on it — and theirs book no saving.
|
|
3043
|
+
// Billed apart, the part after the hand-over would be saved twice.
|
|
3044
|
+
const continuedBy = (state, from) => {
|
|
3045
|
+
const chain = [];
|
|
3046
|
+
const ids = new Set(state.scenarioId ? [state.scenarioId] : []);
|
|
3047
|
+
for (const s of states.slice(from + 1)) {
|
|
3048
|
+
if (s.continuation && ids.has(s.continuation.ofScenarioId)) {
|
|
3049
|
+
chain.push(s);
|
|
3050
|
+
if (s.scenarioId)
|
|
3051
|
+
ids.add(s.scenarioId);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
// A continuation declined after its values were read spent that reading
|
|
3055
|
+
// on this turn too, and its own report books nothing.
|
|
3056
|
+
const declined = (run.declinedContinuations ?? []).filter((s) => s.continuation !== undefined && ids.has(s.continuation.ofScenarioId));
|
|
3057
|
+
const deriveUsd = [...chain, ...declined].reduce((usd, s) => usd + s.deriveCostUsd, 0);
|
|
3058
|
+
return { chain, deriveUsd };
|
|
3059
|
+
};
|
|
3150
3060
|
// One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
|
|
3151
3061
|
// for its own window of the transcript — from when it armed to when the next
|
|
3152
3062
|
// one did — so the turn's tokens are split between them, never counted twice.
|
|
3153
3063
|
// A turn with a single prompt-armed plan is exactly the report it always was.
|
|
3154
3064
|
states.forEach((state, i) => {
|
|
3155
|
-
const
|
|
3065
|
+
const { chain, deriveUsd: chainDeriveUsd } = continuedBy(state, i);
|
|
3066
|
+
// The plan whose arm ends this one's window: never one that continued it.
|
|
3067
|
+
const next = states.slice(i + 1).find((s) => !chain.includes(s));
|
|
3156
3068
|
const nextMark = next?.armedBy === "intent" ? next.usageMark : undefined;
|
|
3157
3069
|
let windowCost = cost;
|
|
3158
3070
|
let windowMs = durationMs;
|
|
3159
3071
|
// An intent-armed plan's window *ends* where its work ended (segmented.md
|
|
3160
3072
|
// R-MONEY-3): at its own retire mark when it completed, else at the next
|
|
3161
|
-
// plan's arm, else at the end of the turn. Without the first, a
|
|
3073
|
+
// plan's arm, else at the end of the turn. Without the first, a plan
|
|
3162
3074
|
// that finished in three steps would be billed for everything the agent
|
|
3163
3075
|
// did afterwards, and its saving would read as a loss.
|
|
3164
3076
|
let end = nextMark;
|
|
3165
|
-
// A plan of the prompt stopped at its decision after a step ran: its
|
|
3166
|
-
// window ends at the hand-over (the service's segmentsbreakdown.md
|
|
3167
|
-
// SB-26), and the agent's decision and the half it took are not on its
|
|
3168
|
-
// bill. Every other plan of the prompt keeps its window: the answer is
|
|
3169
|
-
// still the plan's work.
|
|
3170
|
-
const atDecision = state.handover?.kind === "decision" && state.retiredMark !== undefined;
|
|
3171
3077
|
if (state.armedBy === "intent") {
|
|
3172
3078
|
windowCost = this.costSince(session, state.usageMark);
|
|
3173
3079
|
windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
|
|
3174
3080
|
end = state.retiredMark ?? nextMark;
|
|
3175
3081
|
}
|
|
3176
|
-
else if (atDecision) {
|
|
3177
|
-
windowMs = (state.retiredAt ?? Date.now()) - run.startedAtMs;
|
|
3178
|
-
end = state.retiredMark;
|
|
3179
|
-
}
|
|
3180
3082
|
else if (next) {
|
|
3181
3083
|
windowMs = next.armedAt - run.startedAtMs;
|
|
3182
3084
|
}
|
|
@@ -3195,18 +3097,15 @@ export class ControlServer {
|
|
|
3195
3097
|
runId: run.runId,
|
|
3196
3098
|
cutShort: cutShort || undefined,
|
|
3197
3099
|
redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
|
|
3100
|
+
chainDeriveUsd,
|
|
3198
3101
|
});
|
|
3199
3102
|
if (!report)
|
|
3200
3103
|
return;
|
|
3201
|
-
if (state.armedBy === "intent"
|
|
3104
|
+
if (state.armedBy === "intent") {
|
|
3202
3105
|
logDetail("replay.done", {
|
|
3203
3106
|
scenario: state.scenarioId ?? undefined,
|
|
3204
3107
|
windowMs: Math.max(0, windowMs),
|
|
3205
|
-
billedTo:
|
|
3206
|
-
? "its decision"
|
|
3207
|
-
: state.retiredMark !== undefined
|
|
3208
|
-
? "its own retire"
|
|
3209
|
-
: "the next arm or the turn",
|
|
3108
|
+
billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
|
|
3210
3109
|
});
|
|
3211
3110
|
}
|
|
3212
3111
|
// The plan ran to its end, and no redo of the model's own was recorded:
|
|
@@ -3259,6 +3158,23 @@ export class ControlServer {
|
|
|
3259
3158
|
if (report)
|
|
3260
3159
|
this.queue.push(() => recorder.reportExecution(report));
|
|
3261
3160
|
}
|
|
3161
|
+
// Continuations handed out and not armed (the service's fallbk.md Part 3):
|
|
3162
|
+
// a report for each ticket, with the reason, so the console says why and
|
|
3163
|
+
// not "no report". Nothing of theirs ran: unmeasured and costless.
|
|
3164
|
+
for (const declined of run.declinedContinuations ?? []) {
|
|
3165
|
+
if (states.includes(declined))
|
|
3166
|
+
continue;
|
|
3167
|
+
const report = this.replay.buildReport(declined, {
|
|
3168
|
+
sessionCostUsd: 0,
|
|
3169
|
+
measured: false,
|
|
3170
|
+
durationMs: 0,
|
|
3171
|
+
prompt: run.input || undefined,
|
|
3172
|
+
siblings: [...states, declined],
|
|
3173
|
+
runId: run.runId,
|
|
3174
|
+
});
|
|
3175
|
+
if (report)
|
|
3176
|
+
this.queue.push(() => recorder.reportExecution(report));
|
|
3177
|
+
}
|
|
3262
3178
|
}
|
|
3263
3179
|
/**
|
|
3264
3180
|
* The plan's own calls of the turn, whole, as an answered turn's report
|
|
@@ -3767,7 +3683,6 @@ export class ControlServer {
|
|
|
3767
3683
|
// A hand-over inside `preTool` (an input logic that threw) must still
|
|
3768
3684
|
// schedule the fragment, even though this call ends here.
|
|
3769
3685
|
this.noteHandover(run);
|
|
3770
|
-
this.noteDecision(session, run);
|
|
3771
3686
|
if (action.kind === "pin") {
|
|
3772
3687
|
// The same tool is not the same call. A pin swaps the model's
|
|
3773
3688
|
// arguments for the step's and approves them, so it is kept only for
|
|
@@ -3830,45 +3745,62 @@ export class ControlServer {
|
|
|
3830
3745
|
const note = planning ? undefined : this.replay.takeNote(run.replay);
|
|
3831
3746
|
if (note) {
|
|
3832
3747
|
this.noteHandover(run);
|
|
3833
|
-
this.noteDecision(session, run);
|
|
3834
3748
|
return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
|
|
3835
3749
|
}
|
|
3836
|
-
//
|
|
3837
|
-
//
|
|
3750
|
+
// The model is doing the matched task itself, and the service asked for
|
|
3751
|
+
// that run to be recorded as the recording's newest (newestRun.md N1).
|
|
3752
|
+
// Opened before the probe below, so the turn is recorded from this call
|
|
3753
|
+
// on like any recording turn — a plan an intent match arms in it too.
|
|
3754
|
+
if (this.repeatDue(run))
|
|
3755
|
+
this.openRepeat(session, run);
|
|
3756
|
+
// 2. The model is driving: look for a scenario by this iteration's intent.
|
|
3757
|
+
// Never in plan mode, where nothing may run on the user's behalf.
|
|
3758
|
+
// If the deadline answers first, it answers `{}`: the call runs as
|
|
3759
|
+
// written, and a plan armed in its place was never announced.
|
|
3838
3760
|
if (!planning && !lateSet) {
|
|
3839
3761
|
onLate?.(() => {
|
|
3840
3762
|
overran = true;
|
|
3841
3763
|
return {};
|
|
3842
3764
|
});
|
|
3843
3765
|
}
|
|
3844
|
-
//
|
|
3845
|
-
//
|
|
3846
|
-
//
|
|
3847
|
-
//
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3766
|
+
// 2a. The model's first call after a plan handed over: the work recorded
|
|
3767
|
+
// after an earlier hand-over at the same step may have a plan of its
|
|
3768
|
+
// own (the service's fallbk.md Part 3). Asked with the hand-over, not
|
|
3769
|
+
// with words, and armed only when this call is its first step.
|
|
3770
|
+
const dueFragment = run.fragment;
|
|
3771
|
+
const before = run.replay;
|
|
3772
|
+
const continued = planning
|
|
3773
|
+
? undefined
|
|
3774
|
+
: await this.tryContinuation(session, run, toolName, intent, modelArgs);
|
|
3775
|
+
if (continued) {
|
|
3776
|
+
if (!overran)
|
|
3777
|
+
return continued;
|
|
3778
|
+
// The deadline answered first: the call ran as the model wrote it, and
|
|
3779
|
+
// the plan was never announced. The model's own work from this call is
|
|
3780
|
+
// the fragment, as it would have been with no plan.
|
|
3781
|
+
const late = run.replay;
|
|
3782
|
+
if (late && late !== before && late.continuation) {
|
|
3783
|
+
logLine("replay.decision", {
|
|
3784
|
+
verdict: "no-steer",
|
|
3785
|
+
code: "arm_unannounced",
|
|
3786
|
+
run: run.runId,
|
|
3787
|
+
scenario: late.scenarioId ?? undefined,
|
|
3788
|
+
why: "the call had to go ahead before this continuation's directive could replace it — retired unannounced, so never run",
|
|
3789
|
+
});
|
|
3790
|
+
const at = run.replays.indexOf(late);
|
|
3791
|
+
if (at >= 0)
|
|
3792
|
+
run.replays.splice(at, 1);
|
|
3793
|
+
run.replay = before;
|
|
3794
|
+
this.keepDeclinedContinuation(session, run, late, "arm_unannounced");
|
|
3795
|
+
run.fragment = dueFragment;
|
|
3796
|
+
}
|
|
3854
3797
|
}
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
// on like any recording turn — a plan an intent match arms in it too.
|
|
3859
|
-
if (this.repeatDue(run))
|
|
3860
|
-
this.openRepeat(session, run);
|
|
3861
|
-
// 3. The model is driving: look for a scenario by this iteration's intent.
|
|
3862
|
-
// Never in plan mode, where nothing may run on the user's behalf, and
|
|
3863
|
-
// not once the deadline has answered: nothing new may start then.
|
|
3864
|
-
const armed = planning || overran ? undefined : await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
|
|
3798
|
+
const armed = planning || continued
|
|
3799
|
+
? undefined
|
|
3800
|
+
: await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
|
|
3865
3801
|
if (armed) {
|
|
3866
|
-
if (!overran)
|
|
3867
|
-
// The plan answers in this call's place: the agent's work from here
|
|
3868
|
-
// is that plan's, never the other half after a decision (SB-16).
|
|
3869
|
-
this.dropDecisionFragment(run, run.replay);
|
|
3802
|
+
if (!overran)
|
|
3870
3803
|
return armed;
|
|
3871
|
-
}
|
|
3872
3804
|
const late = run.replay;
|
|
3873
3805
|
if (late && late.armedBy === "intent" && late.plan && !late.retired) {
|
|
3874
3806
|
logLine("replay.decision", {
|
|
@@ -3882,17 +3814,12 @@ export class ControlServer {
|
|
|
3882
3814
|
}
|
|
3883
3815
|
// The call ran as the model wrote it: recorded below as its own.
|
|
3884
3816
|
}
|
|
3885
|
-
//
|
|
3817
|
+
// 3. No scenario took over: the model's own work after a hand-over is
|
|
3886
3818
|
// recorded as a fragment, starting with this call.
|
|
3887
3819
|
// Not for the call an intent match just armed a plan beside: the plan
|
|
3888
|
-
// runs next, and the fragment opens on the model's own work after it
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
if (run.fragment?.state === "due") {
|
|
3892
|
-
if (!run.pendingDirective)
|
|
3893
|
-
await this.openFragment(session, run, reasoning, toolName, modelArgs);
|
|
3894
|
-
else
|
|
3895
|
-
this.dropDecisionFragment(run, run.pendingDirective.state);
|
|
3820
|
+
// runs next, and the fragment opens on the model's own work after it.
|
|
3821
|
+
if (run.fragment?.state === "due" && !run.pendingDirective) {
|
|
3822
|
+
await this.openFragment(session, run, reasoning, toolName, modelArgs);
|
|
3896
3823
|
}
|
|
3897
3824
|
}
|
|
3898
3825
|
// The arguments that will actually run — which is what must be correlated and
|
|
@@ -4465,104 +4392,17 @@ export class ControlServer {
|
|
|
4465
4392
|
return this.preToolAnswer(this.replay.deliverInstead(note, toolName)) ?? {};
|
|
4466
4393
|
}
|
|
4467
4394
|
/**
|
|
4468
|
-
*
|
|
4469
|
-
* R-HIT-7).
|
|
4470
|
-
*
|
|
4471
|
-
* A repeat of a recording's own prompt is *prompt* recurrence, counted as
|
|
4472
|
-
* `iterations`, never step recurrence. Without this, three prompt repeats
|
|
4473
|
-
* would reach `SEGMENT_MIN_HITS` on every step of the whole recording and the
|
|
4474
|
-
* detector would build a whole-run-sized segment beside the whole-run
|
|
4475
|
-
* scenario.
|
|
4476
|
-
*/
|
|
4477
|
-
excludeRunsFor(run) {
|
|
4478
|
-
const out = new Set();
|
|
4479
|
-
if (run.recording)
|
|
4480
|
-
out.add(run.runId);
|
|
4481
|
-
const prompt = run.replays.find((s) => s.armedBy === "prompt")?.matchedRunId;
|
|
4482
|
-
if (prompt)
|
|
4483
|
-
out.add(prompt);
|
|
4484
|
-
if (run.fragment?.hitRunId)
|
|
4485
|
-
out.add(run.fragment.hitRunId);
|
|
4486
|
-
// The recording the match offered a repeat of: the same recording, and
|
|
4487
|
-
// known even when the match landed too late to be armed (newestRun.md).
|
|
4488
|
-
if (run.repeat)
|
|
4489
|
-
out.add(run.repeat.runId);
|
|
4490
|
-
return [...out];
|
|
4491
|
-
}
|
|
4492
|
-
/**
|
|
4493
|
-
* Per plan of this turn, its recording and how far the plan got in its own
|
|
4494
|
-
* chain (segmented.md R-OUT-6).
|
|
4395
|
+
* Intent matching in the ReAct loop (fallbk.md §Runner 4).
|
|
4495
4396
|
*
|
|
4496
|
-
*
|
|
4497
|
-
*
|
|
4498
|
-
*
|
|
4499
|
-
*
|
|
4500
|
-
*
|
|
4501
|
-
*
|
|
4502
|
-
* service's segmentsbreakdown.md D18). One entry per plan, so that two plans
|
|
4503
|
-
* of one recording, whose own positions are on different scales, never
|
|
4504
|
-
* hide each other. A plan declined for anything but a known-bad first step
|
|
4505
|
-
* reports nothing — it ran none of the recording, so it blocks none of it.
|
|
4397
|
+
* Called only while the model is driving, and only when a match may arm a
|
|
4398
|
+
* plan in the call's place ({@link intentArming}, off unless
|
|
4399
|
+
* `BIR_INTENT_ARM=1`): the reasoning the call was made with is sent, and the
|
|
4400
|
+
* service may hand back a whole scenario whose intent it matches. A call made
|
|
4401
|
+
* with no reasoning sends nothing — there is no intent to match. Every
|
|
4402
|
+
* failure is a miss.
|
|
4506
4403
|
*
|
|
4507
|
-
*
|
|
4508
|
-
*
|
|
4509
|
-
*/
|
|
4510
|
-
ranThroughFor(run) {
|
|
4511
|
-
const best = new Map();
|
|
4512
|
-
for (const state of run.replays) {
|
|
4513
|
-
if (!state.scenarioId)
|
|
4514
|
-
continue;
|
|
4515
|
-
const recording = state.matchedRunId;
|
|
4516
|
-
if (!recording)
|
|
4517
|
-
continue;
|
|
4518
|
-
let position;
|
|
4519
|
-
if (!state.plan) {
|
|
4520
|
-
// Only a first step the plan cannot start on — parked, or a judgement
|
|
4521
|
-
// — means the turn is committed to doing this recording's work itself
|
|
4522
|
-
// from position 0 (R-HIT-14); every other decline leaves the recording
|
|
4523
|
-
// untouched.
|
|
4524
|
-
if (state.declined === "known_bad_first_step" ||
|
|
4525
|
-
state.declined === "nondeterministic_first_step") {
|
|
4526
|
-
position = 0;
|
|
4527
|
-
}
|
|
4528
|
-
}
|
|
4529
|
-
else if (state.handover) {
|
|
4530
|
-
position = state.handover.stepIndex;
|
|
4531
|
-
}
|
|
4532
|
-
else {
|
|
4533
|
-
// Ran to its end: the last position it planned.
|
|
4534
|
-
position = Math.max(0, state.plan.stepCount - 1);
|
|
4535
|
-
}
|
|
4536
|
-
if (position === undefined)
|
|
4537
|
-
continue;
|
|
4538
|
-
const seen = best.get(state.scenarioId);
|
|
4539
|
-
if (!seen || position > seen.position) {
|
|
4540
|
-
best.set(state.scenarioId, { runId: recording, position });
|
|
4541
|
-
}
|
|
4542
|
-
}
|
|
4543
|
-
return [...best].map(([scenarioId, v]) => ({
|
|
4544
|
-
runId: v.runId,
|
|
4545
|
-
position: v.position,
|
|
4546
|
-
scenarioId,
|
|
4547
|
-
}));
|
|
4548
|
-
}
|
|
4549
|
-
/**
|
|
4550
|
-
* Intent matching in the ReAct loop (fallbk.md §Runner 4, segmented.md 10.3).
|
|
4551
|
-
*
|
|
4552
|
-
* Called only while the model is driving. Every unsteered, non-housekeeping
|
|
4553
|
-
* tool call is sent: the service counts it as a hit on whatever recorded step
|
|
4554
|
-
* it matches, and may hand back a scenario or a segment to run in its place.
|
|
4555
|
-
*
|
|
4556
|
-
* A call made with **no reasoning at all** still probes (R-HIT-4): the server
|
|
4557
|
-
* builds an intent from the tool name and arguments, and a step the agent took
|
|
4558
|
-
* without narrating it is exactly as much a recurrence as one it explained.
|
|
4559
|
-
* Every failure is a miss.
|
|
4560
|
-
*
|
|
4561
|
-
* THE PROBE IS NOT WAITED ON unless it may arm ({@link intentArming}). Hit
|
|
4562
|
-
* counting needs no answer at all, and waiting for one put a service round
|
|
4563
|
-
* trip — 70-450 ms healthy, the whole 4 s budget on a hung service — in front
|
|
4564
|
-
* of every Read, Grep, Edit and Bash of every signed-in user. When it may arm,
|
|
4565
|
-
* the wait is {@link intentArmBudgetMs} at most, and none while the service is
|
|
4404
|
+
* The probe sits in front of the call, so it is waited on
|
|
4405
|
+
* {@link intentArmBudgetMs} at most, and not at all while the service is
|
|
4566
4406
|
* failing. Delivering a match on a *later* call instead is not an option: the
|
|
4567
4407
|
* matched call has run by then, and the plan's first step would repeat it.
|
|
4568
4408
|
*/
|
|
@@ -4570,122 +4410,82 @@ export class ControlServer {
|
|
|
4570
4410
|
const opts = this.replay.intentMatch;
|
|
4571
4411
|
if (!this.replay.enabled || !opts.enabled)
|
|
4572
4412
|
return undefined;
|
|
4573
|
-
//
|
|
4574
|
-
//
|
|
4413
|
+
// Off unless asked for: arming in a call's place means waiting in front of
|
|
4414
|
+
// it. Never in a session of another host either: a plan is started there by
|
|
4415
|
+
// the prompt, and stepped through by an adapter that was told of it then.
|
|
4416
|
+
// Nothing could announce one that armed in the middle of a run.
|
|
4417
|
+
if (!this.intentArming() || otherHost(session))
|
|
4418
|
+
return undefined;
|
|
4419
|
+
// A run of another host that only records is a run with replay off.
|
|
4575
4420
|
if (this.recordsOnly(session, run))
|
|
4576
4421
|
return undefined;
|
|
4577
4422
|
if (!isIntentMatcher(this.recorder))
|
|
4578
4423
|
return undefined;
|
|
4579
4424
|
if (this.replay.isDirectTool(toolName))
|
|
4580
4425
|
return undefined;
|
|
4426
|
+
// No reasoning, no intent to match.
|
|
4581
4427
|
const text = (intent?.text ?? "").trim().slice(0, 4_000);
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
//
|
|
4585
|
-
//
|
|
4586
|
-
|
|
4587
|
-
maxPayloadBytes: 2048,
|
|
4588
|
-
maxStringBytes: 256,
|
|
4589
|
-
});
|
|
4590
|
-
// A retried identical call is not sent again; two different calls under one
|
|
4591
|
-
// reasoning line both are (R-HIT-5).
|
|
4592
|
-
const probeHash = createHash("sha1")
|
|
4593
|
-
.update([text, toolName, toolInput].join(PROBE_SEP))
|
|
4594
|
-
.digest("hex");
|
|
4595
|
-
if (probeHash === run.intent.lastProbeHash)
|
|
4428
|
+
if (!text)
|
|
4429
|
+
return undefined;
|
|
4430
|
+
// The match depends on the text alone: the same reasoning line is not sent
|
|
4431
|
+
// twice in one turn.
|
|
4432
|
+
if (text === run.intent.lastText)
|
|
4596
4433
|
return undefined;
|
|
4597
4434
|
if (run.intent.requests >= opts.maxRequestsPerTurn)
|
|
4598
4435
|
return undefined;
|
|
4599
|
-
// A
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
//
|
|
4436
|
+
// A turn that has armed all it may arm asks for no more.
|
|
4437
|
+
if (run.intent.armed >= opts.maxPerTurn)
|
|
4438
|
+
return undefined;
|
|
4439
|
+
// A service in its cool-down is not asked at all: the probe would only add
|
|
4440
|
+
// a socket to an outage. Neither the text nor the cap is spent, so the same
|
|
4441
|
+
// call probes again once the service answers.
|
|
4603
4442
|
if (!this.service.canWait()) {
|
|
4604
4443
|
logDetail("replay.intent_skipped", { run: run.runId, tool: toolName, why: "service unhealthy" });
|
|
4605
4444
|
return undefined;
|
|
4606
4445
|
}
|
|
4607
|
-
run.intent.
|
|
4446
|
+
run.intent.lastText = text;
|
|
4608
4447
|
run.intent.requests += 1;
|
|
4609
4448
|
// Every scenario armed this turn, so a continuation cannot re-arm the plan
|
|
4610
4449
|
// that just handed over.
|
|
4611
|
-
const exclude = run
|
|
4612
|
-
.map((s) => s.scenarioId)
|
|
4613
|
-
.filter((id) => typeof id === "string");
|
|
4450
|
+
const exclude = this.armedScenarioIds(run);
|
|
4614
4451
|
const startedAt = Date.now();
|
|
4615
4452
|
const framework = frameworkOf(session);
|
|
4616
4453
|
const probe = this.recorder
|
|
4617
4454
|
.matchIntent({
|
|
4618
4455
|
text,
|
|
4619
|
-
source,
|
|
4620
|
-
// The same value twice: `currentToolName` is what today's server reads,
|
|
4621
|
-
// `toolName` is what the new one reads beside `toolInput`.
|
|
4622
4456
|
currentToolName: toolName,
|
|
4623
|
-
toolName,
|
|
4624
|
-
toolInput,
|
|
4625
4457
|
exclude,
|
|
4626
|
-
turnId: run.turnId,
|
|
4627
|
-
stepPosition: run.ordering.next,
|
|
4628
|
-
excludeRuns: this.excludeRunsFor(run),
|
|
4629
|
-
ranThrough: this.ranThroughFor(run),
|
|
4630
|
-
// Segments are asked for only where one can arm: a probe that is
|
|
4631
|
-
// waited on, in Claude Code (below). Any other probe gets the
|
|
4632
|
-
// service's `segmentWouldArm` instead, logged as observe-only.
|
|
4633
|
-
...(this.replay.segmentArm && this.intentArming() && !otherHost(session) ? { acceptSegments: true } : {}),
|
|
4634
|
-
supportsCalls: true,
|
|
4635
4458
|
// Left out for Claude Code: a missing framework means Claude Code to
|
|
4636
|
-
// the service, which matches a
|
|
4459
|
+
// the service, which matches a scenario only inside its own host.
|
|
4637
4460
|
...(framework ? { framework } : {}),
|
|
4638
4461
|
},
|
|
4639
4462
|
// The probe's own budget (`BIR_INTENT_MATCH_BUDGET_MS`) ends the request
|
|
4640
|
-
// itself
|
|
4641
|
-
//
|
|
4463
|
+
// itself, so an abandoned probe releases its socket rather than holding
|
|
4464
|
+
// it for as long as the service takes to answer.
|
|
4642
4465
|
{ timeoutMs: opts.budgetMs })
|
|
4643
4466
|
.catch(() => null);
|
|
4644
|
-
// In a session of another host a probe only counts: a plan is started
|
|
4645
|
-
// there by the prompt, and stepped through by an adapter that was told of
|
|
4646
|
-
// it then. Nothing could announce one that armed in the middle of a run.
|
|
4647
|
-
if (!this.intentArming() || otherHost(session)) {
|
|
4648
|
-
// Counted, never waited on: whatever the answer says is bookkeeping.
|
|
4649
|
-
void probe.then((late) => {
|
|
4650
|
-
if (late)
|
|
4651
|
-
this.noteProbe(run, toolName, source, late, startedAt, "not waited on — counting only");
|
|
4652
|
-
});
|
|
4653
|
-
return undefined;
|
|
4654
|
-
}
|
|
4655
4467
|
const answer = await this.withinBudget(probe, this.intentArmBudgetMs());
|
|
4656
4468
|
if (!answer) {
|
|
4469
|
+
// A miss, or too slow to arm here.
|
|
4657
4470
|
logDetail("replay.intent_miss", { run: run.runId, tool: toolName });
|
|
4658
|
-
// A miss, or too slow to arm here. A late answer's hit still counts
|
|
4659
|
-
// where health shows it; a miss resolves null and notes nothing.
|
|
4660
|
-
void probe.then((late) => {
|
|
4661
|
-
if (late)
|
|
4662
|
-
this.noteProbe(run, toolName, source, late, startedAt, "answered after the call went ahead");
|
|
4663
|
-
});
|
|
4664
4471
|
return undefined;
|
|
4665
4472
|
}
|
|
4666
|
-
this.noteProbe(run, toolName,
|
|
4473
|
+
this.noteProbe(run, toolName, answer, startedAt);
|
|
4667
4474
|
const match = answer.matched;
|
|
4668
4475
|
if (!match)
|
|
4669
4476
|
return undefined;
|
|
4670
4477
|
if (match.scenarioId && exclude.includes(match.scenarioId))
|
|
4671
4478
|
return undefined;
|
|
4672
|
-
//
|
|
4673
|
-
//
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
scenario: match.scenarioId ?? undefined,
|
|
4679
|
-
kind: match.kind ?? "scenario",
|
|
4680
|
-
similarity: score(match.similarity),
|
|
4681
|
-
tool: toolName,
|
|
4682
|
-
});
|
|
4683
|
-
return undefined;
|
|
4684
|
-
}
|
|
4479
|
+
// Valid JSON with the serializer's own truncation marker, never a raw
|
|
4480
|
+
// slice: the live call handed to derivation.
|
|
4481
|
+
const toolInput = serializeCapped(redact(modelArgs), {
|
|
4482
|
+
maxPayloadBytes: 2048,
|
|
4483
|
+
maxStringBytes: 256,
|
|
4484
|
+
});
|
|
4685
4485
|
// Bounded like the probe: derivation for a scenario with a target can take
|
|
4686
4486
|
// seconds, and this call is waiting. A plan not armed in time is dropped
|
|
4687
4487
|
// unused — it was never announced, so it never steers — and its ticket is
|
|
4688
|
-
// swept like any unredeemed one
|
|
4488
|
+
// swept like any unredeemed one.
|
|
4689
4489
|
const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
|
|
4690
4490
|
liveCall: { toolName, toolInput },
|
|
4691
4491
|
recentResults: this.recentResultsFor(session, "call"),
|
|
@@ -4700,7 +4500,6 @@ export class ControlServer {
|
|
|
4700
4500
|
code: "arm_timeout",
|
|
4701
4501
|
run: run.runId,
|
|
4702
4502
|
scenario: match.scenarioId ?? undefined,
|
|
4703
|
-
kind: match.kind ?? "scenario",
|
|
4704
4503
|
ms: INTENT_ARM_DERIVE_MS,
|
|
4705
4504
|
why: "the plan was not ready before this call had to go ahead — never delivered, so never run",
|
|
4706
4505
|
});
|
|
@@ -4718,9 +4517,6 @@ export class ControlServer {
|
|
|
4718
4517
|
// what its relative paths resolve against (replay/builtins.ts). The
|
|
4719
4518
|
// session's own cwd from the hook payloads, never the daemon's.
|
|
4720
4519
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
4721
|
-
// The request this plan serves, for a note at its decision (SB-18): the
|
|
4722
|
-
// turn's prompt. `arm` was given this step's intent instead.
|
|
4723
|
-
state.request = run.input || undefined;
|
|
4724
4520
|
this.declineWithoutDeliveryTool(state);
|
|
4725
4521
|
if (!state.plan) {
|
|
4726
4522
|
// A declined intent hit reports nothing: the turn's cost is not a
|
|
@@ -4728,6 +4524,14 @@ export class ControlServer {
|
|
|
4728
4524
|
state.reported = true;
|
|
4729
4525
|
return undefined;
|
|
4730
4526
|
}
|
|
4527
|
+
state.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
4528
|
+
// The closure, not the mark: only the control server holds the transcript
|
|
4529
|
+
// path, and a completing retire happens in the controller (R-MONEY-3).
|
|
4530
|
+
state.markUsage = () => markTranscriptUsage(session.transcriptPath);
|
|
4531
|
+
run.replays.push(state);
|
|
4532
|
+
run.replay = state;
|
|
4533
|
+
run.intent.armed += 1;
|
|
4534
|
+
const directive = this.replay.directiveFor(state) ?? "";
|
|
4731
4535
|
// HOW THE DIRECTIVE REACHES THE MODEL. Mid-task, the call that matched is
|
|
4732
4536
|
// usually the model's own ordinary work — a `git status`, a file read —
|
|
4733
4537
|
// and denying it, or swapping its output for a directive, shows the user a
|
|
@@ -4741,50 +4545,6 @@ export class ControlServer {
|
|
|
4741
4545
|
// it is held to the pin guard's rule (control/pin-guard.ts), so a `git
|
|
4742
4546
|
// status` is never swapped for a deploy that happens to be a Bash step.
|
|
4743
4547
|
const inPlace = this.isFirstStep(state, toolName, modelArgs);
|
|
4744
|
-
// NEVER TWICE (the service's offer1.md O8). A plan that starts after this
|
|
4745
|
-
// call — not in its place — and calls the same tool somewhere would make
|
|
4746
|
-
// that call again: the agent's e-mail, then the plan's own e-mail (issue
|
|
4747
|
-
// fx2). It is not started; the agent goes on. A read the runner knows is
|
|
4748
|
-
// only a read may repeat: reading twice changes nothing.
|
|
4749
|
-
if (!inPlace && this.planCallsTool(state, toolName, modelArgs)) {
|
|
4750
|
-
logLine("replay.decision", {
|
|
4751
|
-
verdict: "no-steer",
|
|
4752
|
-
code: "would_repeat",
|
|
4753
|
-
run: run.runId,
|
|
4754
|
-
scenario: state.scenarioId ?? undefined,
|
|
4755
|
-
kind: match.kind ?? "scenario",
|
|
4756
|
-
tool: toolName,
|
|
4757
|
-
why: "the plan would start after this call and make a call of the same tool again — never twice; the agent goes on",
|
|
4758
|
-
});
|
|
4759
|
-
state.plan = undefined;
|
|
4760
|
-
state.mode = "none";
|
|
4761
|
-
state.retired = true;
|
|
4762
|
-
state.pinned.clear();
|
|
4763
|
-
state.reported = true;
|
|
4764
|
-
return undefined;
|
|
4765
|
-
}
|
|
4766
|
-
state.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
4767
|
-
// The closure, not the mark: only the control server holds the transcript
|
|
4768
|
-
// path, and a completing retire happens in the controller (R-MONEY-3).
|
|
4769
|
-
state.markUsage = () => markTranscriptUsage(session.transcriptPath);
|
|
4770
|
-
// A segment of a recording whose whole-run plan already armed this turn
|
|
4771
|
-
// takes its share off that plan's baseline (R-MONEY-4). One recording's
|
|
4772
|
-
// steps are never counted in two baselines of one turn.
|
|
4773
|
-
if (state.kind === "segment" && state.segment && state.scenarioId) {
|
|
4774
|
-
for (const earlier of run.replays) {
|
|
4775
|
-
if (earlier === state || !earlier.plan)
|
|
4776
|
-
continue;
|
|
4777
|
-
if (earlier.kind !== "scenario")
|
|
4778
|
-
continue;
|
|
4779
|
-
if (earlier.matchedRunId !== state.segment.runId)
|
|
4780
|
-
continue;
|
|
4781
|
-
earlier.sharedWith.push(state.scenarioId);
|
|
4782
|
-
}
|
|
4783
|
-
}
|
|
4784
|
-
run.replays.push(state);
|
|
4785
|
-
run.replay = state;
|
|
4786
|
-
run.intent.armed += 1;
|
|
4787
|
-
const directive = this.replay.directiveFor(state) ?? "";
|
|
4788
4548
|
logLine("replay.intent_armed", {
|
|
4789
4549
|
run: run.runId,
|
|
4790
4550
|
scenario: state.scenarioId ?? undefined,
|
|
@@ -4792,9 +4552,6 @@ export class ControlServer {
|
|
|
4792
4552
|
mode: state.mode,
|
|
4793
4553
|
instead: inPlace ? toolName : undefined,
|
|
4794
4554
|
after: inPlace ? undefined : toolName,
|
|
4795
|
-
kind: match.kind ?? "scenario",
|
|
4796
|
-
key: match.key,
|
|
4797
|
-
verified: match.verified?.verdict,
|
|
4798
4555
|
why: "the model's reasoning matched a calculated scenario's intent",
|
|
4799
4556
|
});
|
|
4800
4557
|
if (inPlace)
|
|
@@ -4803,526 +4560,318 @@ export class ControlServer {
|
|
|
4803
4560
|
return undefined;
|
|
4804
4561
|
}
|
|
4805
4562
|
/**
|
|
4806
|
-
*
|
|
4807
|
-
*
|
|
4808
|
-
*
|
|
4809
|
-
*
|
|
4810
|
-
*
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
*
|
|
4830
|
-
*
|
|
4831
|
-
*
|
|
4832
|
-
*
|
|
4833
|
-
*
|
|
4563
|
+
* A continuation (the service's fallbk.md Part 3), on the model's first call
|
|
4564
|
+
* after a plan handed over.
|
|
4565
|
+
*
|
|
4566
|
+
* The model's work after an earlier hand-over of the same plan at the same
|
|
4567
|
+
* step was recorded once, as a fragment, and the service may have calculated
|
|
4568
|
+
* it into a plan. Until 0.3.23 nothing asked for that plan: the only way a
|
|
4569
|
+
* plan started mid-task was a match of the model's words against plan
|
|
4570
|
+
* intents ({@link tryIntentMatch}, off by default), and a one-line note
|
|
4571
|
+
* after a hand-over does not read like a plan's intent paragraph. Here the
|
|
4572
|
+
* hand-over itself is the question — the plan and the step — so the service
|
|
4573
|
+
* names one recording and matches no words.
|
|
4574
|
+
*
|
|
4575
|
+
* It arms only when the call the model is making is that plan's first step —
|
|
4576
|
+
* its tool, and arguments the pin guard would pin to the step's input — and
|
|
4577
|
+
* is then answered in that call's place, as an intent plan whose first step
|
|
4578
|
+
* the call is. The plan before it stopped because the next step needed a
|
|
4579
|
+
* judgement. This call is the model's judgement; a plan that starts with
|
|
4580
|
+
* another call is a way the model did not take, and must not run.
|
|
4581
|
+
*
|
|
4582
|
+
* Its values are read from the turn's own request — the rest of the task is
|
|
4583
|
+
* the one the user asked for — with the model's words, the call it was about
|
|
4584
|
+
* to make and this turn's tool results beside it, as for any plan started at
|
|
4585
|
+
* a tool call.
|
|
4586
|
+
*
|
|
4587
|
+
* Asked once per hand-over, waited on {@link CONTINUATION_PROBE_MS} and
|
|
4588
|
+
* {@link CONTINUATION_ARM_MS} at most, and not at all while the service is
|
|
4589
|
+
* failing. Every way it does not arm leaves the turn as it was: the model's
|
|
4590
|
+
* call runs, and its work is recorded as the fragment. A plan handed out and
|
|
4591
|
+
* not armed is reported for its ticket, with the reason.
|
|
4834
4592
|
*/
|
|
4835
|
-
|
|
4836
|
-
const
|
|
4837
|
-
if (!
|
|
4838
|
-
return
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
logDetail("replay.
|
|
4852
|
-
return
|
|
4593
|
+
async tryContinuation(session, run, toolName, intent, modelArgs) {
|
|
4594
|
+
const f = run.fragment;
|
|
4595
|
+
if (!f || f.state !== "due" || f.asked)
|
|
4596
|
+
return undefined;
|
|
4597
|
+
if (!this.replay.enabled || !this.replay.intentMatch.enabled || !this.continuationsOn())
|
|
4598
|
+
return undefined;
|
|
4599
|
+
// Never in a session of another host: a plan is started there by the
|
|
4600
|
+
// prompt, and stepped through by an adapter that was told of it then.
|
|
4601
|
+
if (otherHost(session) || this.recordsOnly(session, run))
|
|
4602
|
+
return undefined;
|
|
4603
|
+
if (!isIntentMatcher(this.recorder))
|
|
4604
|
+
return undefined;
|
|
4605
|
+
// The model calling the delivery tool itself is no step of a plan.
|
|
4606
|
+
if (this.replay.isDirectTool(toolName))
|
|
4607
|
+
return undefined;
|
|
4608
|
+
if (!this.service.canWait()) {
|
|
4609
|
+
logDetail("replay.continuation_skipped", { run: run.runId, tool: toolName, why: "service unhealthy" });
|
|
4610
|
+
return undefined;
|
|
4853
4611
|
}
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
.filter((e) => e.step.toolName === toolName);
|
|
4872
|
-
if (same.length === 0)
|
|
4873
|
-
return false;
|
|
4874
|
-
if (!COMMAND_TOOLS.has(toolName))
|
|
4875
|
-
return true;
|
|
4876
|
-
// A shell's call is its command: a `git status` is not repeated by a plan
|
|
4877
|
-
// that deploys. Only a step that would run this very command repeats it —
|
|
4878
|
-
// judged on the values the plan has now; a step whose command needs a
|
|
4879
|
-
// result first cannot be told, and does not count.
|
|
4880
|
-
const live = commandOf(modelArgs);
|
|
4881
|
-
if (!live)
|
|
4882
|
-
return false;
|
|
4883
|
-
const params = plan.parameters;
|
|
4884
|
-
return same.some((e) => {
|
|
4885
|
-
try {
|
|
4886
|
-
return commandOf(evalToolInputLogic(e.step.toolInputLogic ?? "return {};", params, e.frame.intent ?? "", {})) === live;
|
|
4887
|
-
}
|
|
4888
|
-
catch {
|
|
4889
|
-
return false;
|
|
4890
|
-
}
|
|
4612
|
+
f.asked = true;
|
|
4613
|
+
const text = (intent?.text ?? "").trim().slice(0, 4_000);
|
|
4614
|
+
const framework = frameworkOf(session);
|
|
4615
|
+
const handover = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
|
|
4616
|
+
let landed = false;
|
|
4617
|
+
const probe = this.recorder
|
|
4618
|
+
.matchIntent({
|
|
4619
|
+
text,
|
|
4620
|
+
currentToolName: toolName,
|
|
4621
|
+
exclude: this.armedScenarioIds(run),
|
|
4622
|
+
handover,
|
|
4623
|
+
...(framework ? { framework } : {}),
|
|
4624
|
+
}, { timeoutMs: CONTINUATION_REQUEST_MS })
|
|
4625
|
+
.catch(() => null)
|
|
4626
|
+
.then((answer) => {
|
|
4627
|
+
landed = true;
|
|
4628
|
+
return answer;
|
|
4891
4629
|
});
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
*/
|
|
4901
|
-
noteDecision(session, run) {
|
|
4902
|
-
if (run.finished || otherHost(session))
|
|
4903
|
-
return;
|
|
4904
|
-
if (!this.replay.enabled || !this.intentArming())
|
|
4905
|
-
return;
|
|
4906
|
-
if (!isNextScenarioSource(this.recorder))
|
|
4907
|
-
return;
|
|
4908
|
-
for (const state of run.replays) {
|
|
4909
|
-
const h = state.handover;
|
|
4910
|
-
if (h?.kind !== "decision" || !state.scenarioId)
|
|
4911
|
-
continue;
|
|
4912
|
-
const next = (run.next ??= { asked: new Set(), round: 0, answered: false, entries: [], calls: 0 });
|
|
4913
|
-
if (next.asked.has(state.scenarioId))
|
|
4914
|
-
continue;
|
|
4915
|
-
next.asked.add(state.scenarioId);
|
|
4916
|
-
// A new decision starts a new round: what could follow an earlier one is
|
|
4917
|
-
// no longer checked (its unused plans are dropped), and the agent's next
|
|
4918
|
-
// calls are checked against what can follow this one.
|
|
4919
|
-
for (const e of next.entries)
|
|
4920
|
-
dropNextEntry(e);
|
|
4921
|
-
next.entries = [];
|
|
4922
|
-
next.round += 1;
|
|
4923
|
-
next.answered = false;
|
|
4924
|
-
next.ended = undefined;
|
|
4925
|
-
next.calls = 0;
|
|
4926
|
-
next.asking = this.fetchNext(session, run, state.scenarioId, h.stepIndex, next.round);
|
|
4927
|
-
}
|
|
4928
|
-
}
|
|
4929
|
-
/**
|
|
4930
|
-
* Ask for a decision's next scenarios (offer1.md O3): each is checkable as
|
|
4931
|
-
* soon as the service answers, and armed in the background, each on its own
|
|
4932
|
-
* (O4). Resolves when the service has answered, not when the arms have.
|
|
4933
|
-
* Never rejects.
|
|
4934
|
-
*/
|
|
4935
|
-
async fetchNext(session, run, planId, stepIndex, round) {
|
|
4936
|
-
const startedAt = Date.now();
|
|
4937
|
-
let answer = null;
|
|
4938
|
-
try {
|
|
4939
|
-
if (isNextScenarioSource(this.recorder)) {
|
|
4940
|
-
answer = await this.recorder.nextScenarios(planId, { turnId: run.turnId, supportsCalls: true }, { timeoutMs: NEXT_FETCH_MS });
|
|
4630
|
+
const answer = await this.withinBudget(probe, CONTINUATION_PROBE_MS);
|
|
4631
|
+
if (!answer) {
|
|
4632
|
+
if (!landed) {
|
|
4633
|
+
// Too slow, not failed: the service most likely counted the hand-over
|
|
4634
|
+
// already, so the fragment says so. A plan its late answer hands out
|
|
4635
|
+
// is reported as late, for its ticket.
|
|
4636
|
+
f.counted = true;
|
|
4637
|
+
void probe.then((late) => this.noteLateContinuation(run, handover, late));
|
|
4941
4638
|
}
|
|
4942
|
-
|
|
4943
|
-
catch {
|
|
4944
|
-
answer = null;
|
|
4945
|
-
}
|
|
4946
|
-
const next = run.next;
|
|
4947
|
-
// A later decision of the turn has its own round: this answer is no one's.
|
|
4948
|
-
if (!next || next.round !== round || next.ended || run.finished)
|
|
4949
|
-
return;
|
|
4950
|
-
next.answered = true;
|
|
4951
|
-
next.asking = undefined;
|
|
4952
|
-
const offered = (answer?.next ?? []).filter((m) => !!m && typeof m.scenarioId === "string");
|
|
4953
|
-
// A plan already armed this turn is never armed again from here (the
|
|
4954
|
-
// probe's own `exclude`): the plan that just stopped, above all.
|
|
4955
|
-
const taken = new Set(run.replays.map((s) => s.scenarioId).filter((id) => typeof id === "string"));
|
|
4956
|
-
const fresh = [];
|
|
4957
|
-
for (const match of offered) {
|
|
4958
|
-
if (next.entries.length + fresh.length >= NEXT_MAX)
|
|
4959
|
-
break;
|
|
4960
|
-
if (taken.has(match.scenarioId))
|
|
4961
|
-
continue;
|
|
4962
|
-
if ([...next.entries, ...fresh].some((e) => e.match.scenarioId === match.scenarioId))
|
|
4963
|
-
continue;
|
|
4964
|
-
const entry = {
|
|
4965
|
-
planId,
|
|
4966
|
-
stepIndex,
|
|
4967
|
-
// `arm` reads a numeric similarity, and the key says how it was found.
|
|
4968
|
-
match: { ...match, similarity: typeof match.similarity === "number" ? match.similarity : 1, key: "decision" },
|
|
4969
|
-
firstTool: typeof match.firstTool === "string" && match.firstTool ? match.firstTool : null,
|
|
4970
|
-
arming: Promise.resolve(undefined),
|
|
4971
|
-
};
|
|
4972
|
-
entry.arming = this.armNext(session, run, entry);
|
|
4973
|
-
fresh.push(entry);
|
|
4974
|
-
}
|
|
4975
|
-
// Checkable at once: a call waits only for the arm of a scenario that
|
|
4976
|
-
// starts with its own tool, never for the others.
|
|
4977
|
-
next.entries.push(...fresh);
|
|
4978
|
-
if (fresh.length === 0) {
|
|
4979
|
-
logDetail("replay.next_none", {
|
|
4980
|
-
run: run.runId,
|
|
4981
|
-
plan: planId,
|
|
4982
|
-
offered: offered.length,
|
|
4983
|
-
why: answer
|
|
4984
|
-
? "no calculated scenario follows this decision yet — what the agent does next is recorded, and becomes one"
|
|
4985
|
-
: "the service did not say what follows this decision — the agent goes on",
|
|
4986
|
-
});
|
|
4987
|
-
return;
|
|
4988
|
-
}
|
|
4989
|
-
// The audit line once every arm has answered; nobody waits for it.
|
|
4990
|
-
void Promise.all(fresh.map((e) => e.arming)).then(() => {
|
|
4991
|
-
if (run.finished || next.round !== round)
|
|
4992
|
-
return;
|
|
4993
|
-
logLine("replay.next_ready", {
|
|
4639
|
+
logDetail("replay.continuation_miss", {
|
|
4994
4640
|
run: run.runId,
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
of: fresh.length,
|
|
4998
|
-
ms: Date.now() - startedAt,
|
|
4999
|
-
scenarios: fresh
|
|
5000
|
-
.map((e) => `${e.match.scenarioId}:${e.firstTool ?? "?"}:${e.state?.plan ? "ready" : (e.state?.declined ?? "failed")}`)
|
|
5001
|
-
.join(","),
|
|
5002
|
-
why: "the calculated scenarios that can follow this decision are ready for the agent's next call",
|
|
4641
|
+
tool: toolName,
|
|
4642
|
+
why: landed ? "the request failed" : "no answer in time",
|
|
5003
4643
|
});
|
|
5004
|
-
});
|
|
5005
|
-
}
|
|
5006
|
-
/**
|
|
5007
|
-
* Arm one next scenario in the background (offer1.md O4): its values read
|
|
5008
|
-
* from the turn's request and its results so far — or, once more, with the
|
|
5009
|
-
* agent's own call (O5) — and settled, so a call is compared with the first
|
|
5010
|
-
* step as it would really run. Never rejects.
|
|
5011
|
-
*/
|
|
5012
|
-
async armNext(session, run, entry, liveCall) {
|
|
5013
|
-
try {
|
|
5014
|
-
const state = await this.replay.arm(entry.match, run.input, this.wrapped, "intent", {
|
|
5015
|
-
...(liveCall ? { liveCall } : {}),
|
|
5016
|
-
recentResults: this.recentResultsFor(session, "call"),
|
|
5017
|
-
agentDay: this.agentDayFor(session),
|
|
5018
|
-
atDecision: { scenarioId: entry.planId, stepIndex: entry.stepIndex },
|
|
5019
|
-
}, this.hostContext(session, run));
|
|
5020
|
-
if (state.plan)
|
|
5021
|
-
await this.withinBudget(state.plan.ready().catch(() => undefined), NEXT_READY_MS);
|
|
5022
|
-
entry.state = state;
|
|
5023
|
-
return state;
|
|
5024
|
-
}
|
|
5025
|
-
catch (err) {
|
|
5026
|
-
logDetail("replay.next_arm_failed", { scenario: entry.match.scenarioId ?? undefined, error: errText(err) });
|
|
5027
4644
|
return undefined;
|
|
5028
4645
|
}
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
* it (offer1.md O5, O6). A hit — the call is a scenario's first step, value
|
|
5033
|
-
* for value — is answered in the call's place with that scenario's directive:
|
|
5034
|
-
* the agent calls `run_scenario`, and the scenario runs every step, this one
|
|
5035
|
-
* included, once. Undefined otherwise: the call runs as the agent wrote it.
|
|
5036
|
-
*/
|
|
5037
|
-
async tryNextScenarios(session, run, toolName, modelArgs, overran) {
|
|
5038
|
-
const next = run.next;
|
|
5039
|
-
if (!next || next.ended || run.finished || otherHost(session))
|
|
4646
|
+
// Only a service that knows continuations answers one. An older service
|
|
4647
|
+
// matched the text by words instead, and that is no continuation.
|
|
4648
|
+
if (answer.continuation === undefined)
|
|
5040
4649
|
return undefined;
|
|
5041
|
-
|
|
4650
|
+
f.counted = answer.continuation?.counted === true;
|
|
4651
|
+
const match = answer.matched;
|
|
4652
|
+
if (!answer.continuation || !match) {
|
|
4653
|
+
logDetail("replay.continuation_none", {
|
|
4654
|
+
run: run.runId,
|
|
4655
|
+
continues: `${f.scenarioId}@${f.stepIndex}`,
|
|
4656
|
+
recording: answer.continuation?.runId,
|
|
4657
|
+
why: answer.continuation
|
|
4658
|
+
? "the work recorded after an earlier hand-over here has no plan ready"
|
|
4659
|
+
: "no hand-over at this step was recorded before",
|
|
4660
|
+
});
|
|
5042
4661
|
return undefined;
|
|
5043
|
-
const deadline = Date.now() + positiveEnv("BIR_NEXT_HIT_WAIT_MS", NEXT_HIT_WAIT_MS);
|
|
5044
|
-
const left = () => Math.max(0, deadline - Date.now());
|
|
5045
|
-
// The service has not answered yet — the agent decided quickly. Its answer
|
|
5046
|
-
// only is waited for, and briefly: not the arms that follow it.
|
|
5047
|
-
if (!next.answered && next.asking) {
|
|
5048
|
-
await this.withinBudget(next.asking, Math.min(left(), NEXT_ROUTE_WAIT_MS));
|
|
5049
|
-
if (overran() || next.ended || run.finished)
|
|
5050
|
-
return undefined;
|
|
5051
4662
|
}
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
let notReady = false;
|
|
5057
|
-
/** Read the scenario's values once more, with the agent's call (O5); undefined when not ready in time. */
|
|
5058
|
-
const readAgain = async (entry, old) => {
|
|
5059
|
-
entry.rearmed = true;
|
|
5060
|
-
dropNextState(old);
|
|
5061
|
-
entry.state = undefined;
|
|
5062
|
-
const toolInput = serializeCapped(redact(modelArgs), { maxPayloadBytes: 2048, maxStringBytes: 256 });
|
|
5063
|
-
entry.arming = this.armNext(session, run, entry, { toolName, toolInput });
|
|
5064
|
-
return (await this.withinBudget(entry.arming, left())) ?? undefined;
|
|
4663
|
+
const continuation = {
|
|
4664
|
+
ofScenarioId: f.scenarioId,
|
|
4665
|
+
stepIndex: f.stepIndex,
|
|
4666
|
+
runId: answer.continuation.runId,
|
|
5065
4667
|
};
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
// step; any other is compared only once it is armed.
|
|
5073
|
-
if (entry.firstTool !== toolName)
|
|
5074
|
-
continue;
|
|
5075
|
-
state = (await this.withinBudget(entry.arming, left())) ?? undefined;
|
|
5076
|
-
if (overran() || next.ended)
|
|
5077
|
-
return undefined;
|
|
5078
|
-
if (!state) {
|
|
5079
|
-
notReady = true;
|
|
5080
|
-
continue;
|
|
5081
|
-
}
|
|
5082
|
-
}
|
|
5083
|
-
if (!state.plan || state.retired) {
|
|
5084
|
-
// Declined at the decision for a value the request never names (an
|
|
5085
|
-
// address a look-up returned): the agent's own call may hold it.
|
|
5086
|
-
if (entry.rearmed || state.declined !== "missing_target" || entry.firstTool !== toolName)
|
|
5087
|
-
continue;
|
|
5088
|
-
const again = await readAgain(entry, state);
|
|
5089
|
-
if (overran() || next.ended)
|
|
5090
|
-
return undefined;
|
|
5091
|
-
if (!again)
|
|
5092
|
-
notReady = true;
|
|
5093
|
-
else if (again.plan && !again.retired && this.firstStepVerdict(again, toolName, modelArgs) === "hit") {
|
|
5094
|
-
return this.nextHit(session, run, entry, again, toolName);
|
|
5095
|
-
}
|
|
5096
|
-
else if (again.plan?.expectedTool() === toolName)
|
|
5097
|
-
compared = true;
|
|
5098
|
-
continue;
|
|
5099
|
-
}
|
|
5100
|
-
if (state.plan.expectedTool() !== toolName)
|
|
5101
|
-
continue;
|
|
5102
|
-
// A first step inside a sub-task (a call row) is never the agent's call in place.
|
|
5103
|
-
const first = state.plan.currentEntry();
|
|
5104
|
-
if (!first || first.depth !== 0) {
|
|
5105
|
-
entry.unhittable = true;
|
|
5106
|
-
continue;
|
|
5107
|
-
}
|
|
5108
|
-
// Value for value (O5): the pin guard's "plausibly the step" is not
|
|
5109
|
-
// enough here — the scenario would run its own values, and an e-mail to
|
|
5110
|
-
// carlos must never become the recorded e-mail to olga.
|
|
5111
|
-
const verdict = this.firstStepVerdict(state, toolName, modelArgs);
|
|
5112
|
-
if (verdict === "hit")
|
|
5113
|
-
return this.nextHit(session, run, entry, state, toolName);
|
|
5114
|
-
// Only a value the plan lacks is read again, never one it read differently.
|
|
5115
|
-
if (verdict === "missing" && !entry.rearmed) {
|
|
5116
|
-
const again = await readAgain(entry, state);
|
|
5117
|
-
if (overran() || next.ended)
|
|
5118
|
-
return undefined;
|
|
5119
|
-
if (!again) {
|
|
5120
|
-
notReady = true;
|
|
5121
|
-
continue;
|
|
5122
|
-
}
|
|
5123
|
-
if (again.plan && !again.retired && this.firstStepVerdict(again, toolName, modelArgs) === "hit") {
|
|
5124
|
-
return this.nextHit(session, run, entry, again, toolName);
|
|
5125
|
-
}
|
|
5126
|
-
}
|
|
5127
|
-
compared = true;
|
|
5128
|
-
}
|
|
5129
|
-
// No hit.
|
|
5130
|
-
if (compared) {
|
|
5131
|
-
// The agent took a way a scenario starts with, with other values: its
|
|
5132
|
-
// own way, recorded, and a scenario of its own later (O7).
|
|
5133
|
-
this.endNext(run, "other_values", toolName);
|
|
5134
|
-
}
|
|
5135
|
-
else if (callMayChange(toolName, modelArgs)) {
|
|
5136
|
-
// Its first call that may change something and starts none of them: the
|
|
5137
|
-
// agent is doing a way of its own (O7), and from here a scenario must
|
|
5138
|
-
// never start in the middle of it.
|
|
5139
|
-
this.endNext(run, notReady ? "not_ready" : "own_way", toolName);
|
|
4668
|
+
// Before anything is read: the plan starts with another tool than the
|
|
4669
|
+
// model's call, so the model chose another way.
|
|
4670
|
+
const first = firstStepTool(match.scenario);
|
|
4671
|
+
if (match.scenario && first !== undefined && first !== toolName) {
|
|
4672
|
+
this.keepDeclinedContinuation(session, run, this.replay.declinedContinuation(match, "not_first_step", continuation));
|
|
4673
|
+
return undefined;
|
|
5140
4674
|
}
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
4675
|
+
const startedAt = Date.now();
|
|
4676
|
+
// Valid JSON with the serializer's own truncation marker, never a raw
|
|
4677
|
+
// slice: the live call handed to the reading.
|
|
4678
|
+
const toolInput = serializeCapped(redact(modelArgs), {
|
|
4679
|
+
maxPayloadBytes: 2048,
|
|
4680
|
+
maxStringBytes: 256,
|
|
4681
|
+
});
|
|
4682
|
+
// The rest of the task the user asked for: its values are in the turn's
|
|
4683
|
+
// request, and the model's words say what it is doing next.
|
|
4684
|
+
const prompt = [run.input, text].filter((s) => s.trim().length > 0).join("\n\n");
|
|
4685
|
+
const arming = this.armOrLog(run, this.replay.arm(match, prompt, this.wrapped, "intent", {
|
|
4686
|
+
liveCall: { toolName, toolInput },
|
|
4687
|
+
recentResults: this.recentResultsFor(session, "call"),
|
|
4688
|
+
agentDay: this.agentDayFor(session),
|
|
4689
|
+
}, undefined, { continuation }));
|
|
4690
|
+
const state = await this.withinBudget(arming, CONTINUATION_ARM_MS);
|
|
4691
|
+
if (state === undefined)
|
|
4692
|
+
return undefined; // it threw, and said so
|
|
4693
|
+
if (state === null) {
|
|
4694
|
+
logLine("replay.decision", {
|
|
4695
|
+
verdict: "no-steer",
|
|
4696
|
+
code: "arm_timeout",
|
|
4697
|
+
run: run.runId,
|
|
4698
|
+
scenario: match.scenarioId ?? undefined,
|
|
4699
|
+
continues: `${f.scenarioId}@${f.stepIndex}`,
|
|
4700
|
+
ms: CONTINUATION_ARM_MS,
|
|
4701
|
+
why: "the continuation's values were not read before this call had to go ahead — never delivered, so never run",
|
|
4702
|
+
});
|
|
4703
|
+
void arming.then((late) => {
|
|
4704
|
+
if (late)
|
|
4705
|
+
this.keepDeclinedContinuation(session, run, late, "arm_timeout");
|
|
4706
|
+
});
|
|
4707
|
+
return undefined;
|
|
5152
4708
|
}
|
|
5153
|
-
return undefined;
|
|
5154
|
-
}
|
|
5155
|
-
/** A next scenario's hit: bookkeeping as for any plan armed mid-task, then its directive in the call's place. */
|
|
5156
|
-
nextHit(session, run, entry, state, toolName) {
|
|
5157
4709
|
state.sessionId = session.sessionId;
|
|
5158
4710
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
5159
|
-
|
|
5160
|
-
state.
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
4711
|
+
this.declineWithoutDeliveryTool(state);
|
|
4712
|
+
if (!state.plan) {
|
|
4713
|
+
// A gate declined it, and said why (`state.declined`).
|
|
4714
|
+
this.keepDeclinedContinuation(session, run, state);
|
|
4715
|
+
return undefined;
|
|
4716
|
+
}
|
|
4717
|
+
// Its values are in: the model's call must be its first step, arguments
|
|
4718
|
+
// and all — the same call, not merely one the pin guard would take over
|
|
4719
|
+
// (pin-guard.ts `sameFirstCall`).
|
|
4720
|
+
await this.withinBudget(state.plan.ready().catch(() => undefined), Math.max(0, CONTINUATION_ARM_MS - (Date.now() - startedAt)));
|
|
4721
|
+
if (!this.isContinuationFirstCall(state, toolName, modelArgs)) {
|
|
4722
|
+
this.keepDeclinedContinuation(session, run, state, "not_first_step");
|
|
5164
4723
|
return undefined;
|
|
5165
4724
|
}
|
|
5166
4725
|
state.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
5167
4726
|
state.markUsage = () => markTranscriptUsage(session.transcriptPath);
|
|
5168
|
-
if (state.kind === "segment" && state.segment && state.scenarioId) {
|
|
5169
|
-
for (const earlier of run.replays) {
|
|
5170
|
-
if (earlier === state || !earlier.plan || earlier.kind !== "scenario")
|
|
5171
|
-
continue;
|
|
5172
|
-
if (earlier.matchedRunId !== state.segment.runId)
|
|
5173
|
-
continue;
|
|
5174
|
-
earlier.sharedWith.push(state.scenarioId);
|
|
5175
|
-
}
|
|
5176
|
-
}
|
|
5177
4727
|
run.replays.push(state);
|
|
5178
4728
|
run.replay = state;
|
|
5179
|
-
|
|
5180
|
-
//
|
|
5181
|
-
//
|
|
5182
|
-
|
|
5183
|
-
this.closeDecisionFragment(session, run, state);
|
|
5184
|
-
this.dropDecisionFragment(run, state);
|
|
5185
|
-
this.endNext(run, "hit", toolName, state);
|
|
4729
|
+
// From here the continuation is this turn's plan. A hand-over of its own,
|
|
4730
|
+
// if it makes one, schedules its own fragment: the one the plan before it
|
|
4731
|
+
// left due is not opened.
|
|
4732
|
+
run.fragment = undefined;
|
|
5186
4733
|
const directive = this.replay.directiveFor(state) ?? "";
|
|
5187
|
-
logLine("replay.
|
|
4734
|
+
logLine("replay.continuation_armed", {
|
|
5188
4735
|
run: run.runId,
|
|
5189
4736
|
scenario: state.scenarioId ?? undefined,
|
|
5190
|
-
|
|
5191
|
-
|
|
4737
|
+
continues: `${continuation.ofScenarioId}@${continuation.stepIndex}`,
|
|
4738
|
+
recording: continuation.runId,
|
|
5192
4739
|
mode: state.mode,
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
why: "the
|
|
4740
|
+
instead: toolName,
|
|
4741
|
+
ms: Date.now() - startedAt,
|
|
4742
|
+
why: "the model's call after the hand-over is the first step of the plan calculated from the work recorded after an earlier hand-over at this step",
|
|
5196
4743
|
});
|
|
5197
4744
|
return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
|
|
5198
4745
|
}
|
|
5199
4746
|
/**
|
|
5200
|
-
* A
|
|
5201
|
-
*
|
|
5202
|
-
*
|
|
5203
|
-
* The scenario's own calls, pinned in steer mode or run in direct mode, are
|
|
5204
|
-
* not the agent's work, and would make the fragment a copy of that scenario
|
|
5205
|
-
* (offer1.md O7). A fragment still due is dropped instead
|
|
5206
|
-
* ({@link dropDecisionFragment}).
|
|
4747
|
+
* A continuation handed out and not armed: kept, retired, for its ticket's
|
|
4748
|
+
* report at the seal (`not_steered`, with the reason, unmeasured). `code`
|
|
4749
|
+
* says why when no gate did.
|
|
5207
4750
|
*/
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
4751
|
+
keepDeclinedContinuation(session, run, state, code) {
|
|
4752
|
+
if (code) {
|
|
4753
|
+
state.declined = code;
|
|
4754
|
+
logLine("replay.decision", {
|
|
4755
|
+
verdict: "no-steer",
|
|
4756
|
+
code,
|
|
4757
|
+
run: run.runId,
|
|
4758
|
+
scenario: state.scenarioId ?? undefined,
|
|
4759
|
+
continues: state.continuation
|
|
4760
|
+
? `${state.continuation.ofScenarioId}@${state.continuation.stepIndex}`
|
|
4761
|
+
: undefined,
|
|
4762
|
+
why: code === "not_first_step"
|
|
4763
|
+
? "the model's call after the hand-over is not this continuation's first step — the model chose another way"
|
|
4764
|
+
: "the continuation was not armed in time",
|
|
5220
4765
|
});
|
|
4766
|
+
}
|
|
4767
|
+
state.sessionId = session.sessionId;
|
|
4768
|
+
state.plan = undefined;
|
|
4769
|
+
state.mode = "none";
|
|
4770
|
+
state.stepsPlanned = 0;
|
|
4771
|
+
state.retired = true;
|
|
4772
|
+
state.pinned.clear();
|
|
4773
|
+
// A seal has reported already: nothing more can be said for this turn.
|
|
4774
|
+
if (run.finished)
|
|
4775
|
+
return;
|
|
4776
|
+
(run.declinedContinuations ??= []).push(state);
|
|
4777
|
+
}
|
|
4778
|
+
/** A continuation request's answer that came after the call it was for went ahead. */
|
|
4779
|
+
noteLateContinuation(run, handover, late) {
|
|
4780
|
+
const match = late?.matched;
|
|
4781
|
+
if (!late?.continuation || !match?.executionTicket || run.finished)
|
|
4782
|
+
return;
|
|
4783
|
+
const state = this.replay.declinedContinuation(match, "match_late", {
|
|
4784
|
+
ofScenarioId: handover.scenarioId,
|
|
4785
|
+
stepIndex: handover.stepIndex,
|
|
4786
|
+
runId: late.continuation.runId,
|
|
5221
4787
|
});
|
|
5222
|
-
run.
|
|
5223
|
-
f.state = "closed";
|
|
5224
|
-
logLine("run.fragment_closed", {
|
|
5225
|
-
run: runId,
|
|
5226
|
-
scenario: f.scenarioId,
|
|
5227
|
-
step: f.stepIndex,
|
|
5228
|
-
by: by.scenarioId ?? undefined,
|
|
5229
|
-
why: "a scenario that follows the decision takes over after the agent's own first calls — the fragment ends with what the agent did itself",
|
|
5230
|
-
});
|
|
4788
|
+
(run.declinedContinuations ??= []).push(state);
|
|
5231
4789
|
}
|
|
5232
4790
|
/**
|
|
5233
|
-
*
|
|
5234
|
-
*
|
|
4791
|
+
* Every scenario this turn was handed and armed or declined: never handed
|
|
4792
|
+
* out again in it. A plan that handed over is among them, so its own
|
|
4793
|
+
* continuation request can never return it.
|
|
5235
4794
|
*/
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
4795
|
+
armedScenarioIds(run) {
|
|
4796
|
+
return [...run.replays, ...(run.declinedContinuations ?? [])]
|
|
4797
|
+
.map((s) => s.scenarioId)
|
|
4798
|
+
.filter((id) => typeof id === "string");
|
|
4799
|
+
}
|
|
4800
|
+
/**
|
|
4801
|
+
* Whether the model's call is a continuation's first step: its tool, and
|
|
4802
|
+
* the step's own input in every argument the two share (pin-guard.ts
|
|
4803
|
+
* `sameFirstCall`). Anything that cannot be computed is "no".
|
|
4804
|
+
*/
|
|
4805
|
+
isContinuationFirstCall(state, toolName, modelArgs) {
|
|
4806
|
+
const plan = state.plan;
|
|
4807
|
+
if (!plan || plan.expectedTool() !== toolName)
|
|
4808
|
+
return false;
|
|
4809
|
+
try {
|
|
4810
|
+
const verdict = sameFirstCall(toolName, modelArgs, plan.toolInputForCurrentStep());
|
|
4811
|
+
if (!verdict.ok) {
|
|
4812
|
+
logDetail("replay.continuation_other_call", { tool: toolName, why: verdict.why });
|
|
4813
|
+
}
|
|
4814
|
+
return verdict.ok;
|
|
5252
4815
|
}
|
|
5253
|
-
|
|
4816
|
+
catch (err) {
|
|
4817
|
+
logDetail("replay.first_step_unknown", { tool: toolName, error: errText(err) });
|
|
4818
|
+
return false;
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
/** Whether continuations are asked for: on unless `BIR_CONTINUATIONS=0`. */
|
|
4822
|
+
continuationsOn() {
|
|
4823
|
+
if (this.opts.continuations !== undefined)
|
|
4824
|
+
return this.opts.continuations;
|
|
4825
|
+
return process.env.BIR_CONTINUATIONS !== "0";
|
|
5254
4826
|
}
|
|
5255
4827
|
/**
|
|
5256
|
-
* Whether
|
|
5257
|
-
*
|
|
4828
|
+
* Whether the model's call is plausibly `state`'s first step: its tool, and
|
|
4829
|
+
* arguments the pin guard would pin to that step's input. Anything that
|
|
4830
|
+
* cannot be computed is "no" — the directive then rides on the call's
|
|
4831
|
+
* PostToolUse.
|
|
4832
|
+
*/
|
|
4833
|
+
isFirstStep(state, toolName, modelArgs) {
|
|
4834
|
+
const plan = state.plan;
|
|
4835
|
+
if (!plan || plan.expectedTool() !== toolName)
|
|
4836
|
+
return false;
|
|
4837
|
+
try {
|
|
4838
|
+
const first = plan.toolInputForCurrentStep();
|
|
4839
|
+
return pinCompatible(toolName, modelArgs, first).ok;
|
|
4840
|
+
}
|
|
4841
|
+
catch (err) {
|
|
4842
|
+
logDetail("replay.first_step_unknown", { tool: toolName, error: errText(err) });
|
|
4843
|
+
return false;
|
|
4844
|
+
}
|
|
4845
|
+
}
|
|
4846
|
+
/**
|
|
4847
|
+
* Whether a match found mid-turn may arm a plan in its own `PreToolUse` — and
|
|
4848
|
+
* so whether a probe is sent and waited for at all. Off unless
|
|
4849
|
+
* `BIR_INTENT_ARM=1`. See {@link ControlServerOptions.intentArm}.
|
|
5258
4850
|
*/
|
|
5259
4851
|
intentArming() {
|
|
5260
4852
|
if (this.opts.intentArm !== undefined)
|
|
5261
4853
|
return this.opts.intentArm;
|
|
5262
|
-
|
|
5263
|
-
if (env === "1")
|
|
5264
|
-
return true;
|
|
5265
|
-
if (env === "0")
|
|
5266
|
-
return false;
|
|
5267
|
-
return this.replay.segmentArm;
|
|
4854
|
+
return process.env.BIR_INTENT_ARM === "1";
|
|
5268
4855
|
}
|
|
5269
4856
|
/** How long an arming probe is waited on: `BIR_INTENT_ARM_BUDGET_MS`, never past the probe's own budget. */
|
|
5270
4857
|
intentArmBudgetMs() {
|
|
5271
4858
|
return Math.min(this.replay.intentMatch.budgetMs, positiveEnv("BIR_INTENT_ARM_BUDGET_MS", INTENT_ARM_PROBE_MS));
|
|
5272
4859
|
}
|
|
5273
|
-
/**
|
|
5274
|
-
noteProbe(run, toolName,
|
|
5275
|
-
// Bookkeeping only, and often run from a floating `.then` after the hook
|
|
5276
|
-
// answered: a malformed answer is a log line, never an unhandled rejection
|
|
5277
|
-
// (which crashes any embedder without a handler) or a failed route.
|
|
4860
|
+
/** A probe's answer, for the log. Never throws: a malformed answer is a log line. */
|
|
4861
|
+
noteProbe(run, toolName, answer, startedAt) {
|
|
5278
4862
|
try {
|
|
5279
|
-
|
|
4863
|
+
logDetail("replay.intent_probe", {
|
|
4864
|
+
run: run.runId,
|
|
4865
|
+
tool: toolName,
|
|
4866
|
+
elapsedMs: Date.now() - startedAt,
|
|
4867
|
+
matched: answer.matched?.scenarioId ?? "none",
|
|
4868
|
+
bestSimilarity: typeof answer.bestSimilarity === "number" ? score(answer.bestSimilarity) : undefined,
|
|
4869
|
+
});
|
|
5280
4870
|
}
|
|
5281
4871
|
catch (err) {
|
|
5282
4872
|
logDetail("replay.intent_note_failed", { run: run.runId, tool: toolName, error: errText(err) });
|
|
5283
4873
|
}
|
|
5284
4874
|
}
|
|
5285
|
-
noteProbeUnsafe(run, toolName, source, answer, startedAt, late) {
|
|
5286
|
-
if (answer.stepHit) {
|
|
5287
|
-
run.intent.lastStepHit = { ...answer.stepHit, at: Date.now() };
|
|
5288
|
-
}
|
|
5289
|
-
// What *would* have armed, had segments been armed (segmented.md R-OUT-10).
|
|
5290
|
-
// Logged at line level, not detail: this is the whole observe-only period,
|
|
5291
|
-
// and the decision to turn arming on is made by reading these. The turn
|
|
5292
|
-
// then carries on with whatever else the answer holds (R-OUT-15).
|
|
5293
|
-
if (answer.segmentWouldArm) {
|
|
5294
|
-
const w = answer.segmentWouldArm;
|
|
5295
|
-
logLine("replay.segment_would_arm", {
|
|
5296
|
-
scenario: w.scenarioId,
|
|
5297
|
-
run: w.runId,
|
|
5298
|
-
key: w.key,
|
|
5299
|
-
similarity: score(w.similarity),
|
|
5300
|
-
band: w.band,
|
|
5301
|
-
verified: w.verified
|
|
5302
|
-
? w.verified.verdict === "skipped"
|
|
5303
|
-
? `skipped:${w.verified.reason}`
|
|
5304
|
-
: `${w.verified.verdict}: ${w.verified.reason}`
|
|
5305
|
-
: undefined,
|
|
5306
|
-
tool: w.tool ?? toolName,
|
|
5307
|
-
firstTool: w.firstTool,
|
|
5308
|
-
stepFrom: w.stepFrom,
|
|
5309
|
-
stepTo: w.stepTo,
|
|
5310
|
-
hitCount: w.hitCount,
|
|
5311
|
-
});
|
|
5312
|
-
}
|
|
5313
|
-
const match = answer.matched;
|
|
5314
|
-
logDetail("replay.intent_probe", {
|
|
5315
|
-
run: run.runId,
|
|
5316
|
-
tool: toolName,
|
|
5317
|
-
source,
|
|
5318
|
-
stepHit: answer.stepHit ? `${answer.stepHit.runId}:${answer.stepHit.stepIndex}` : "none",
|
|
5319
|
-
bestStepSimilarity: answer.bestStepSimilarity,
|
|
5320
|
-
elapsedMs: Date.now() - startedAt,
|
|
5321
|
-
matched: match?.scenarioId ?? "none",
|
|
5322
|
-
kind: match?.kind,
|
|
5323
|
-
late,
|
|
5324
|
-
});
|
|
5325
|
-
}
|
|
5326
4875
|
/**
|
|
5327
4876
|
* Schedule a fragment once a matched turn's plan has handed over (fallbk.md
|
|
5328
4877
|
* D7). Only a turn that is not already recording needs one: an unmatched turn
|
|
@@ -5335,35 +4884,7 @@ export class ControlServer {
|
|
|
5335
4884
|
const h = state?.handover;
|
|
5336
4885
|
if (!h || !state?.scenarioId)
|
|
5337
4886
|
return;
|
|
5338
|
-
run.fragment = {
|
|
5339
|
-
scenarioId: state.scenarioId,
|
|
5340
|
-
stepIndex: h.stepIndex,
|
|
5341
|
-
state: "due",
|
|
5342
|
-
...(h.kind === "decision" ? { decision: true } : {}),
|
|
5343
|
-
};
|
|
5344
|
-
}
|
|
5345
|
-
/**
|
|
5346
|
-
* Drop a fragment scheduled at a decision (the service's
|
|
5347
|
-
* segmentsbreakdown.md SB-16) when a plan the intent check armed takes the
|
|
5348
|
-
* agent's next call: what follows is that plan's half, not the other one,
|
|
5349
|
-
* and recorded as the other half it would become a wrong segment C. Cleared
|
|
5350
|
-
* rather than ended, so a later hand-over of that plan schedules its own
|
|
5351
|
-
* fragment, as any plan's does in a turn with none. Any other fragment is
|
|
5352
|
-
* left as it is.
|
|
5353
|
-
*/
|
|
5354
|
-
dropDecisionFragment(run, by) {
|
|
5355
|
-
const f = run.fragment;
|
|
5356
|
-
if (!f || f.state !== "due" || !f.decision)
|
|
5357
|
-
return;
|
|
5358
|
-
run.fragment = undefined;
|
|
5359
|
-
logLine("run.fragment_dropped", {
|
|
5360
|
-
run: run.runId,
|
|
5361
|
-
scenario: f.scenarioId,
|
|
5362
|
-
step: f.stepIndex,
|
|
5363
|
-
by: by?.scenarioId ?? undefined,
|
|
5364
|
-
kind: by?.kind,
|
|
5365
|
-
why: "a plan the intent check armed takes the agent's next step after the decision — that work is not the other half",
|
|
5366
|
-
});
|
|
4887
|
+
run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
|
|
5367
4888
|
}
|
|
5368
4889
|
/**
|
|
5369
4890
|
* Open the fragment run and record from this call on (fallbk.md D7).
|
|
@@ -5383,21 +4904,12 @@ export class ControlServer {
|
|
|
5383
4904
|
return;
|
|
5384
4905
|
const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
|
|
5385
4906
|
const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
|
|
5386
|
-
// The
|
|
5387
|
-
//
|
|
5388
|
-
|
|
5389
|
-
// service's segmentsbreakdown.md D14).
|
|
5390
|
-
// With the turn's own request (offer1.md O2): the service calculates what
|
|
5391
|
-
// the agent does here with it, so the values the request names are read
|
|
5392
|
-
// from the next request instead of being fixed. Redacted and cut, as the
|
|
5393
|
-
// prompt itself is wherever it leaves this machine.
|
|
5394
|
-
const request = run.input ? redactText(run.input).slice(0, DECISION_REQUEST_MAX) : "";
|
|
5395
|
-
const decisionOf = f.decision
|
|
5396
|
-
? { scenarioId: f.scenarioId, stepIndex: f.stepIndex, ...(request ? { request } : {}) }
|
|
5397
|
-
: undefined;
|
|
4907
|
+
// The service counted this hand-over already, when it was asked for its
|
|
4908
|
+
// continuation (Part 3): the create says so, and is not counted again.
|
|
4909
|
+
const sent = f.counted ? { ...fallbackOf, counted: true } : fallbackOf;
|
|
5398
4910
|
// The fragment is a run of the same host as the turn it continues.
|
|
5399
4911
|
const framework = frameworkOf(session);
|
|
5400
|
-
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf
|
|
4912
|
+
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf: sent, framework } : { fallbackOf: sent });
|
|
5401
4913
|
for (const c of run.correlations.values()) {
|
|
5402
4914
|
c.settled = true;
|
|
5403
4915
|
if (c.fallback)
|
|
@@ -5414,7 +4926,6 @@ export class ControlServer {
|
|
|
5414
4926
|
run: id,
|
|
5415
4927
|
scenario: f.scenarioId,
|
|
5416
4928
|
step: f.stepIndex,
|
|
5417
|
-
decision: f.decision || undefined,
|
|
5418
4929
|
why: "recording the agent's own work after the scenario handed over",
|
|
5419
4930
|
});
|
|
5420
4931
|
if (!isRunCreationAware(this.recorder))
|
|
@@ -5431,21 +4942,12 @@ export class ControlServer {
|
|
|
5431
4942
|
/** What the fragment's create call said: `created: false` is a hit, not a recording. */
|
|
5432
4943
|
applyFragmentCreation(run, id, created) {
|
|
5433
4944
|
const f = run.fragment;
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
if (created.created)
|
|
5437
|
-
// Recorded as the newest run of the fragment it is a hit on (the
|
|
5438
|
-
// service's newestRun.md N2): still that fragment's repeat, so no step
|
|
5439
|
-
// hit may be counted against it (R-HIT-7).
|
|
5440
|
-
if (created.fragmentOf)
|
|
5441
|
-
f.hitRunId = created.fragmentOf.runId;
|
|
4945
|
+
// Recorded — as a fragment of its own, or as the newest run of the
|
|
4946
|
+
// fragment it is a hit on (the service's newestRun.md N2): it records on.
|
|
4947
|
+
if (!f || run.runId !== id || created.created)
|
|
5442
4948
|
return;
|
|
5443
|
-
}
|
|
5444
4949
|
run.recording = false;
|
|
5445
4950
|
f.state = "hit";
|
|
5446
|
-
// Which run it hit matters from here on: this turn must not count step
|
|
5447
|
-
// hits against the fragment it is a repeat of (segmented.md R-HIT-7).
|
|
5448
|
-
f.hitRunId = created.fragmentOf?.runId;
|
|
5449
4951
|
logLine("run.fragment_hit", {
|
|
5450
4952
|
run: id,
|
|
5451
4953
|
scenario: f.scenarioId,
|
|
@@ -5654,17 +5156,12 @@ export class ControlServer {
|
|
|
5654
5156
|
const stashed = this.claimStashedNote(payload.session_id);
|
|
5655
5157
|
if (stashed)
|
|
5656
5158
|
context.push(stashed);
|
|
5657
|
-
const
|
|
5658
|
-
const run = postSession?.run;
|
|
5159
|
+
const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
|
|
5659
5160
|
if (run && !run.finished) {
|
|
5660
5161
|
// The answer, if one comes, is written after this call (AC11).
|
|
5661
5162
|
if (typeof payload.tool_use_id === "string" && payload.tool_use_id)
|
|
5662
5163
|
run.lastToolUseId = payload.tool_use_id;
|
|
5663
5164
|
this.noteHandover(run);
|
|
5664
|
-
// A plan that stopped at its decision on this call: what can follow it is
|
|
5665
|
-
// asked for now, while the agent reads the note and decides (offer1.md O4).
|
|
5666
|
-
if (postSession)
|
|
5667
|
-
this.noteDecision(postSession, run);
|
|
5668
5165
|
const note = this.replay.takeNote(run.replay);
|
|
5669
5166
|
if (note)
|
|
5670
5167
|
context.push(note);
|
|
@@ -6229,11 +5726,6 @@ export class ControlServer {
|
|
|
6229
5726
|
const result = await this.replay.runArmed(state, signal);
|
|
6230
5727
|
if (signal?.aborted)
|
|
6231
5728
|
this.stashCancelledNote(call.sessionId, state);
|
|
6232
|
-
// A direct plan that stopped at its decision: what can follow it is asked
|
|
6233
|
-
// for at once, before the agent has even read the results (offer1.md O4).
|
|
6234
|
-
const runSession = this.sessions.get(call.sessionId);
|
|
6235
|
-
if (runSession && runSession.run === call.run && !signal?.aborted)
|
|
6236
|
-
this.noteDecision(runSession, call.run);
|
|
6237
5729
|
return result;
|
|
6238
5730
|
}
|
|
6239
5731
|
finally {
|