@basein/runner 0.3.20 → 0.3.22
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 +6 -5
- package/dist/bin/bir-hooks.d.ts +17 -19
- package/dist/bin/bir-hooks.js +17 -19
- 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 +15 -43
- package/dist/bin/scenario-edit.js +13 -44
- package/dist/control/recorder-main.d.ts +0 -15
- package/dist/control/recorder-main.js +22 -42
- package/dist/control/server.d.ts +24 -137
- package/dist/control/server.js +107 -1001
- package/dist/record/recorder.d.ts +11 -142
- package/dist/record/recorder.js +0 -3
- package/dist/record/remote-recorder.d.ts +1 -9
- package/dist/record/remote-recorder.js +3 -49
- package/dist/record/switchable-recorder.js +0 -2
- package/dist/replay/builtins.d.ts +45 -9
- package/dist/replay/builtins.js +131 -59
- package/dist/replay/bundle.d.ts +2 -2
- package/dist/replay/bundle.js +3 -3
- package/dist/replay/controller.d.ts +34 -125
- package/dist/replay/controller.js +171 -528
- 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 +30 -81
- package/docs/calculatedReplayGuide.md +17 -34
- package/package.json +1 -1
- package/production.md +7 -36
- 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
|
@@ -37,12 +37,9 @@ 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,126 +97,13 @@ 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;
|
|
111
|
-
/**
|
|
112
|
-
* How long the agent's call after a decision may wait for a next scenario's
|
|
113
|
-
* values (the service's offer1.md O5): they are read while the agent decides,
|
|
114
|
-
* so this wait is only ever for a reading still on its way, or one read again
|
|
115
|
-
* with the call itself. The call is about to be replaced, so waiting costs the
|
|
116
|
-
* agent nothing; it stays well inside the route's own deadline.
|
|
117
|
-
*/
|
|
118
|
-
const NEXT_HIT_WAIT_MS = 6_000;
|
|
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;
|
|
127
|
-
/**
|
|
128
|
-
* How long the agent's call after a decision waits for the service's answer
|
|
129
|
-
* to "what can follow it" when it has not come yet. Only that answer: never
|
|
130
|
-
* the arms behind it.
|
|
131
|
-
*/
|
|
132
|
-
const NEXT_ROUTE_WAIT_MS = 1_000;
|
|
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
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* A planned first step's input against the agent's own arguments, value for
|
|
173
|
-
* value (offer1.md O5). `missing` when the only differences are values the
|
|
174
|
-
* plan does not have — empty, or held by a mark — that the agent gave. The
|
|
175
|
-
* runner's reserved arguments (`__bir…`) are never compared.
|
|
176
|
-
*/
|
|
177
|
-
function sameArguments(planned, agent) {
|
|
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
107
|
/** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
|
|
224
108
|
const FRAGMENT_CREATE_MS = 300;
|
|
225
109
|
/** What `close()` spends draining the last runs to the service before it lets go. */
|
|
@@ -751,14 +635,6 @@ function priceUsage(usage) {
|
|
|
751
635
|
* through it, because the checks a replay in another host needs are here.
|
|
752
636
|
*/
|
|
753
637
|
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
638
|
export class ControlServer {
|
|
763
639
|
token;
|
|
764
640
|
sessionId = "birsess_" + randomUUID();
|
|
@@ -908,8 +784,8 @@ export class ControlServer {
|
|
|
908
784
|
deriveRecentResults;
|
|
909
785
|
/**
|
|
910
786
|
* 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
|
-
*
|
|
787
|
+
* governs only what is *sent*: with every source off, a step goes out with no
|
|
788
|
+
* text, and no intent probe is sent for it.
|
|
913
789
|
*/
|
|
914
790
|
intentSources;
|
|
915
791
|
constructor(opts) {
|
|
@@ -1048,33 +924,17 @@ export class ControlServer {
|
|
|
1048
924
|
? { kind: s.run.replay.handover.kind, stepIndex: s.run.replay.handover.stepIndex }
|
|
1049
925
|
: null,
|
|
1050
926
|
plans: s.run.replays.length,
|
|
1051
|
-
kind: s.run.replay.kind,
|
|
1052
927
|
}
|
|
1053
928
|
: null,
|
|
1054
|
-
// Present whether or not a plan is armed
|
|
1055
|
-
//
|
|
1056
|
-
// "nothing armed" and "nothing even probed" are different problems.
|
|
929
|
+
// Present whether or not a plan is armed: "nothing armed" and "nothing
|
|
930
|
+
// even probed" are different problems.
|
|
1057
931
|
intent: s.run
|
|
1058
932
|
? {
|
|
1059
933
|
probes: s.run.intent.requests,
|
|
1060
934
|
armed: s.run.intent.armed,
|
|
1061
|
-
lastStepHit: s.run.intent.lastStepHit ?? null,
|
|
1062
|
-
}
|
|
1063
|
-
: null,
|
|
1064
|
-
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
|
-
? {
|
|
1072
|
-
entries: s.run.next.entries.length,
|
|
1073
|
-
armed: s.run.next.entries.filter((e) => e.state?.plan && !e.state.retired).length,
|
|
1074
|
-
calls: s.run.next.calls,
|
|
1075
|
-
ended: s.run.next.ended ?? null,
|
|
1076
935
|
}
|
|
1077
936
|
: null,
|
|
937
|
+
fragment: s.run?.fragment ? { state: s.run.fragment.state } : null,
|
|
1078
938
|
// The service's offer to record the turn as a recording's newest run,
|
|
1079
939
|
// and what became of it (newestRun.md).
|
|
1080
940
|
repeat: s.run?.repeat ? { state: s.run.repeat.state, of: s.run.repeat.runId } : null,
|
|
@@ -1087,15 +947,6 @@ export class ControlServer {
|
|
|
1087
947
|
// The authoritative answer to "is anything actually being saved?".
|
|
1088
948
|
recording: this.opts.recording ?? !(this.recorder instanceof NullRecorder),
|
|
1089
949
|
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
950
|
authUrl: resolveAuthUrl() || null,
|
|
1100
951
|
sessionId: this.sessionId,
|
|
1101
952
|
pid: process.pid,
|
|
@@ -2212,8 +2063,8 @@ export class ControlServer {
|
|
|
2212
2063
|
}
|
|
2213
2064
|
state.sessionId = session.sessionId;
|
|
2214
2065
|
// The project directory an allow-listed built-in step executes in, and
|
|
2215
|
-
//
|
|
2216
|
-
// from the hook payloads, never the daemon's.
|
|
2066
|
+
// what its relative paths resolve against (replay/builtins.ts). The
|
|
2067
|
+
// session's own cwd from the hook payloads, never the daemon's.
|
|
2217
2068
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
2218
2069
|
// The service offered to record this turn as the recording's newest run:
|
|
2219
2070
|
// should the agent redo the task after the plan, the plan's own calls go
|
|
@@ -2227,12 +2078,6 @@ export class ControlServer {
|
|
|
2227
2078
|
if (!otherHost(session))
|
|
2228
2079
|
state.reportTool = () => this.reportToolServed();
|
|
2229
2080
|
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
2081
|
// A steer plan with a shell or file-write step can only be followed if the
|
|
2237
2082
|
// directive shows its computed arguments (the pin guard holds those steps
|
|
2238
2083
|
// to their exact shape), so give its parameters a moment to settle —
|
|
@@ -2372,8 +2217,8 @@ export class ControlServer {
|
|
|
2372
2217
|
}
|
|
2373
2218
|
state.sessionId = session.sessionId;
|
|
2374
2219
|
// The project directory an allow-listed built-in step executes in, and
|
|
2375
|
-
//
|
|
2376
|
-
// from the hook payloads, never the daemon's.
|
|
2220
|
+
// what its relative paths resolve against (replay/builtins.ts). The
|
|
2221
|
+
// session's own cwd from the hook payloads, never the daemon's.
|
|
2377
2222
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
2378
2223
|
run.replay = state;
|
|
2379
2224
|
run.replays.push(state);
|
|
@@ -2950,9 +2795,6 @@ export class ControlServer {
|
|
|
2950
2795
|
run.pendingDirective = undefined;
|
|
2951
2796
|
if (unannounced)
|
|
2952
2797
|
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
2798
|
// A `run_scenario` still waiting for this run's plan will never get it.
|
|
2957
2799
|
this.directCalls = this.directCalls.filter((c) => c.run !== run);
|
|
2958
2800
|
const durationMs = Date.now() - run.startedAtMs;
|
|
@@ -3158,25 +3000,15 @@ export class ControlServer {
|
|
|
3158
3000
|
let windowMs = durationMs;
|
|
3159
3001
|
// An intent-armed plan's window *ends* where its work ended (segmented.md
|
|
3160
3002
|
// 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
|
|
3003
|
+
// plan's arm, else at the end of the turn. Without the first, a plan
|
|
3162
3004
|
// that finished in three steps would be billed for everything the agent
|
|
3163
3005
|
// did afterwards, and its saving would read as a loss.
|
|
3164
3006
|
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
3007
|
if (state.armedBy === "intent") {
|
|
3172
3008
|
windowCost = this.costSince(session, state.usageMark);
|
|
3173
3009
|
windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
|
|
3174
3010
|
end = state.retiredMark ?? nextMark;
|
|
3175
3011
|
}
|
|
3176
|
-
else if (atDecision) {
|
|
3177
|
-
windowMs = (state.retiredAt ?? Date.now()) - run.startedAtMs;
|
|
3178
|
-
end = state.retiredMark;
|
|
3179
|
-
}
|
|
3180
3012
|
else if (next) {
|
|
3181
3013
|
windowMs = next.armedAt - run.startedAtMs;
|
|
3182
3014
|
}
|
|
@@ -3198,15 +3030,11 @@ export class ControlServer {
|
|
|
3198
3030
|
});
|
|
3199
3031
|
if (!report)
|
|
3200
3032
|
return;
|
|
3201
|
-
if (state.armedBy === "intent"
|
|
3033
|
+
if (state.armedBy === "intent") {
|
|
3202
3034
|
logDetail("replay.done", {
|
|
3203
3035
|
scenario: state.scenarioId ?? undefined,
|
|
3204
3036
|
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",
|
|
3037
|
+
billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
|
|
3210
3038
|
});
|
|
3211
3039
|
}
|
|
3212
3040
|
// The plan ran to its end, and no redo of the model's own was recorded:
|
|
@@ -3767,7 +3595,6 @@ export class ControlServer {
|
|
|
3767
3595
|
// A hand-over inside `preTool` (an input logic that threw) must still
|
|
3768
3596
|
// schedule the fragment, even though this call ends here.
|
|
3769
3597
|
this.noteHandover(run);
|
|
3770
|
-
this.noteDecision(session, run);
|
|
3771
3598
|
if (action.kind === "pin") {
|
|
3772
3599
|
// The same tool is not the same call. A pin swaps the model's
|
|
3773
3600
|
// arguments for the step's and approves them, so it is kept only for
|
|
@@ -3830,45 +3657,30 @@ export class ControlServer {
|
|
|
3830
3657
|
const note = planning ? undefined : this.replay.takeNote(run.replay);
|
|
3831
3658
|
if (note) {
|
|
3832
3659
|
this.noteHandover(run);
|
|
3833
|
-
this.noteDecision(session, run);
|
|
3834
3660
|
return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
|
|
3835
3661
|
}
|
|
3836
|
-
// If the deadline answers first, it answers `{}`: the call runs as
|
|
3837
|
-
// written, and a plan armed in its place below was never announced.
|
|
3838
|
-
if (!planning && !lateSet) {
|
|
3839
|
-
onLate?.(() => {
|
|
3840
|
-
overran = true;
|
|
3841
|
-
return {};
|
|
3842
|
-
});
|
|
3843
|
-
}
|
|
3844
|
-
// 2. The agent's call after a plan's decision: the first step of a
|
|
3845
|
-
// calculated scenario that can follow it (the service's offer1.md
|
|
3846
|
-
// O4–O6)? Then that scenario runs in this call's place. Before the
|
|
3847
|
-
// probe below: this check is exact (the same tool, the same values)
|
|
3848
|
-
// and asks the service nothing at this call. Never in plan mode.
|
|
3849
|
-
if (!planning) {
|
|
3850
|
-
this.noteDecision(session, run);
|
|
3851
|
-
const hit = await this.tryNextScenarios(session, run, toolName, modelArgs, () => overran);
|
|
3852
|
-
if (hit)
|
|
3853
|
-
return hit;
|
|
3854
|
-
}
|
|
3855
3662
|
// The model is doing the matched task itself, and the service asked for
|
|
3856
3663
|
// that run to be recorded as the recording's newest (newestRun.md N1).
|
|
3857
3664
|
// Opened before the probe below, so the turn is recorded from this call
|
|
3858
3665
|
// on like any recording turn — a plan an intent match arms in it too.
|
|
3859
3666
|
if (this.repeatDue(run))
|
|
3860
3667
|
this.openRepeat(session, run);
|
|
3861
|
-
//
|
|
3862
|
-
// Never in plan mode, where nothing may run on the user's behalf
|
|
3863
|
-
//
|
|
3864
|
-
|
|
3668
|
+
// 2. The model is driving: look for a scenario by this iteration's intent.
|
|
3669
|
+
// Never in plan mode, where nothing may run on the user's behalf.
|
|
3670
|
+
// If the deadline answers first, it answers `{}`: the call runs as
|
|
3671
|
+
// written, and a plan armed in its place was never announced.
|
|
3672
|
+
if (!planning && !lateSet) {
|
|
3673
|
+
onLate?.(() => {
|
|
3674
|
+
overran = true;
|
|
3675
|
+
return {};
|
|
3676
|
+
});
|
|
3677
|
+
}
|
|
3678
|
+
const armed = planning
|
|
3679
|
+
? undefined
|
|
3680
|
+
: await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
|
|
3865
3681
|
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);
|
|
3682
|
+
if (!overran)
|
|
3870
3683
|
return armed;
|
|
3871
|
-
}
|
|
3872
3684
|
const late = run.replay;
|
|
3873
3685
|
if (late && late.armedBy === "intent" && late.plan && !late.retired) {
|
|
3874
3686
|
logLine("replay.decision", {
|
|
@@ -3882,17 +3694,12 @@ export class ControlServer {
|
|
|
3882
3694
|
}
|
|
3883
3695
|
// The call ran as the model wrote it: recorded below as its own.
|
|
3884
3696
|
}
|
|
3885
|
-
//
|
|
3697
|
+
// 3. No scenario took over: the model's own work after a hand-over is
|
|
3886
3698
|
// recorded as a fragment, starting with this call.
|
|
3887
3699
|
// 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);
|
|
3700
|
+
// runs next, and the fragment opens on the model's own work after it.
|
|
3701
|
+
if (run.fragment?.state === "due" && !run.pendingDirective) {
|
|
3702
|
+
await this.openFragment(session, run, reasoning, toolName, modelArgs);
|
|
3896
3703
|
}
|
|
3897
3704
|
}
|
|
3898
3705
|
// The arguments that will actually run — which is what must be correlated and
|
|
@@ -4465,104 +4272,17 @@ export class ControlServer {
|
|
|
4465
4272
|
return this.preToolAnswer(this.replay.deliverInstead(note, toolName)) ?? {};
|
|
4466
4273
|
}
|
|
4467
4274
|
/**
|
|
4468
|
-
*
|
|
4469
|
-
* R-HIT-7).
|
|
4275
|
+
* Intent matching in the ReAct loop (fallbk.md §Runner 4).
|
|
4470
4276
|
*
|
|
4471
|
-
*
|
|
4472
|
-
*
|
|
4473
|
-
*
|
|
4474
|
-
*
|
|
4475
|
-
*
|
|
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).
|
|
4277
|
+
* Called only while the model is driving, and only when a match may arm a
|
|
4278
|
+
* plan in the call's place ({@link intentArming}, off unless
|
|
4279
|
+
* `BIR_INTENT_ARM=1`): the reasoning the call was made with is sent, and the
|
|
4280
|
+
* service may hand back a whole scenario whose intent it matches. A call made
|
|
4281
|
+
* with no reasoning sends nothing — there is no intent to match. Every
|
|
4282
|
+
* failure is a miss.
|
|
4495
4283
|
*
|
|
4496
|
-
* The
|
|
4497
|
-
*
|
|
4498
|
-
* replaying at all. The position is the plan's own: the step it handed over
|
|
4499
|
-
* at (its decision included), or its last step when it ran to its end. The
|
|
4500
|
-
* server maps it onto the recording — a segment's first position, a
|
|
4501
|
-
* decision's step before it — and takes the largest per recording (the
|
|
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.
|
|
4506
|
-
*
|
|
4507
|
-
* Rebuilt on every probe rather than cached: a plan that hands over between
|
|
4508
|
-
* two calls changes the answer.
|
|
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
|
|
4284
|
+
* The probe sits in front of the call, so it is waited on
|
|
4285
|
+
* {@link intentArmBudgetMs} at most, and not at all while the service is
|
|
4566
4286
|
* failing. Delivering a match on a *later* call instead is not an option: the
|
|
4567
4287
|
* matched call has run by then, and the plan's first step would repeat it.
|
|
4568
4288
|
*/
|
|
@@ -4570,41 +4290,40 @@ export class ControlServer {
|
|
|
4570
4290
|
const opts = this.replay.intentMatch;
|
|
4571
4291
|
if (!this.replay.enabled || !opts.enabled)
|
|
4572
4292
|
return undefined;
|
|
4573
|
-
//
|
|
4574
|
-
//
|
|
4293
|
+
// Off unless asked for: arming in a call's place means waiting in front of
|
|
4294
|
+
// it. Never in a session of another host either: a plan is started there by
|
|
4295
|
+
// the prompt, and stepped through by an adapter that was told of it then.
|
|
4296
|
+
// Nothing could announce one that armed in the middle of a run.
|
|
4297
|
+
if (!this.intentArming() || otherHost(session))
|
|
4298
|
+
return undefined;
|
|
4299
|
+
// A run of another host that only records is a run with replay off.
|
|
4575
4300
|
if (this.recordsOnly(session, run))
|
|
4576
4301
|
return undefined;
|
|
4577
4302
|
if (!isIntentMatcher(this.recorder))
|
|
4578
4303
|
return undefined;
|
|
4579
4304
|
if (this.replay.isDirectTool(toolName))
|
|
4580
4305
|
return undefined;
|
|
4306
|
+
// No reasoning, no intent to match.
|
|
4581
4307
|
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)
|
|
4308
|
+
if (!text)
|
|
4309
|
+
return undefined;
|
|
4310
|
+
// The match depends on the text alone: the same reasoning line is not sent
|
|
4311
|
+
// twice in one turn.
|
|
4312
|
+
if (text === run.intent.lastText)
|
|
4596
4313
|
return undefined;
|
|
4597
4314
|
if (run.intent.requests >= opts.maxRequestsPerTurn)
|
|
4598
4315
|
return undefined;
|
|
4599
|
-
// A
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
//
|
|
4316
|
+
// A turn that has armed all it may arm asks for no more.
|
|
4317
|
+
if (run.intent.armed >= opts.maxPerTurn)
|
|
4318
|
+
return undefined;
|
|
4319
|
+
// A service in its cool-down is not asked at all: the probe would only add
|
|
4320
|
+
// a socket to an outage. Neither the text nor the cap is spent, so the same
|
|
4321
|
+
// call probes again once the service answers.
|
|
4603
4322
|
if (!this.service.canWait()) {
|
|
4604
4323
|
logDetail("replay.intent_skipped", { run: run.runId, tool: toolName, why: "service unhealthy" });
|
|
4605
4324
|
return undefined;
|
|
4606
4325
|
}
|
|
4607
|
-
run.intent.
|
|
4326
|
+
run.intent.lastText = text;
|
|
4608
4327
|
run.intent.requests += 1;
|
|
4609
4328
|
// Every scenario armed this turn, so a continuation cannot re-arm the plan
|
|
4610
4329
|
// that just handed over.
|
|
@@ -4616,76 +4335,39 @@ export class ControlServer {
|
|
|
4616
4335
|
const probe = this.recorder
|
|
4617
4336
|
.matchIntent({
|
|
4618
4337
|
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
4338
|
currentToolName: toolName,
|
|
4623
|
-
toolName,
|
|
4624
|
-
toolInput,
|
|
4625
4339
|
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
4340
|
// Left out for Claude Code: a missing framework means Claude Code to
|
|
4636
|
-
// the service, which matches a
|
|
4341
|
+
// the service, which matches a scenario only inside its own host.
|
|
4637
4342
|
...(framework ? { framework } : {}),
|
|
4638
4343
|
},
|
|
4639
4344
|
// The probe's own budget (`BIR_INTENT_MATCH_BUDGET_MS`) ends the request
|
|
4640
|
-
// itself
|
|
4641
|
-
//
|
|
4345
|
+
// itself, so an abandoned probe releases its socket rather than holding
|
|
4346
|
+
// it for as long as the service takes to answer.
|
|
4642
4347
|
{ timeoutMs: opts.budgetMs })
|
|
4643
4348
|
.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
4349
|
const answer = await this.withinBudget(probe, this.intentArmBudgetMs());
|
|
4656
4350
|
if (!answer) {
|
|
4351
|
+
// A miss, or too slow to arm here.
|
|
4657
4352
|
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
4353
|
return undefined;
|
|
4665
4354
|
}
|
|
4666
|
-
this.noteProbe(run, toolName,
|
|
4355
|
+
this.noteProbe(run, toolName, answer, startedAt);
|
|
4667
4356
|
const match = answer.matched;
|
|
4668
4357
|
if (!match)
|
|
4669
4358
|
return undefined;
|
|
4670
4359
|
if (match.scenarioId && exclude.includes(match.scenarioId))
|
|
4671
4360
|
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
|
-
}
|
|
4361
|
+
// Valid JSON with the serializer's own truncation marker, never a raw
|
|
4362
|
+
// slice: the live call handed to derivation.
|
|
4363
|
+
const toolInput = serializeCapped(redact(modelArgs), {
|
|
4364
|
+
maxPayloadBytes: 2048,
|
|
4365
|
+
maxStringBytes: 256,
|
|
4366
|
+
});
|
|
4685
4367
|
// Bounded like the probe: derivation for a scenario with a target can take
|
|
4686
4368
|
// seconds, and this call is waiting. A plan not armed in time is dropped
|
|
4687
4369
|
// unused — it was never announced, so it never steers — and its ticket is
|
|
4688
|
-
// swept like any unredeemed one
|
|
4370
|
+
// swept like any unredeemed one.
|
|
4689
4371
|
const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
|
|
4690
4372
|
liveCall: { toolName, toolInput },
|
|
4691
4373
|
recentResults: this.recentResultsFor(session, "call"),
|
|
@@ -4700,7 +4382,6 @@ export class ControlServer {
|
|
|
4700
4382
|
code: "arm_timeout",
|
|
4701
4383
|
run: run.runId,
|
|
4702
4384
|
scenario: match.scenarioId ?? undefined,
|
|
4703
|
-
kind: match.kind ?? "scenario",
|
|
4704
4385
|
ms: INTENT_ARM_DERIVE_MS,
|
|
4705
4386
|
why: "the plan was not ready before this call had to go ahead — never delivered, so never run",
|
|
4706
4387
|
});
|
|
@@ -4715,12 +4396,9 @@ export class ControlServer {
|
|
|
4715
4396
|
}
|
|
4716
4397
|
state.sessionId = session.sessionId;
|
|
4717
4398
|
// The project directory an allow-listed built-in step executes in, and
|
|
4718
|
-
//
|
|
4719
|
-
// from the hook payloads, never the daemon's.
|
|
4399
|
+
// what its relative paths resolve against (replay/builtins.ts). The
|
|
4400
|
+
// session's own cwd from the hook payloads, never the daemon's.
|
|
4720
4401
|
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
4402
|
this.declineWithoutDeliveryTool(state);
|
|
4725
4403
|
if (!state.plan) {
|
|
4726
4404
|
// A declined intent hit reports nothing: the turn's cost is not a
|
|
@@ -4728,6 +4406,14 @@ export class ControlServer {
|
|
|
4728
4406
|
state.reported = true;
|
|
4729
4407
|
return undefined;
|
|
4730
4408
|
}
|
|
4409
|
+
state.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
4410
|
+
// The closure, not the mark: only the control server holds the transcript
|
|
4411
|
+
// path, and a completing retire happens in the controller (R-MONEY-3).
|
|
4412
|
+
state.markUsage = () => markTranscriptUsage(session.transcriptPath);
|
|
4413
|
+
run.replays.push(state);
|
|
4414
|
+
run.replay = state;
|
|
4415
|
+
run.intent.armed += 1;
|
|
4416
|
+
const directive = this.replay.directiveFor(state) ?? "";
|
|
4731
4417
|
// HOW THE DIRECTIVE REACHES THE MODEL. Mid-task, the call that matched is
|
|
4732
4418
|
// usually the model's own ordinary work — a `git status`, a file read —
|
|
4733
4419
|
// and denying it, or swapping its output for a directive, shows the user a
|
|
@@ -4741,50 +4427,6 @@ export class ControlServer {
|
|
|
4741
4427
|
// it is held to the pin guard's rule (control/pin-guard.ts), so a `git
|
|
4742
4428
|
// status` is never swapped for a deploy that happens to be a Bash step.
|
|
4743
4429
|
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
4430
|
logLine("replay.intent_armed", {
|
|
4789
4431
|
run: run.runId,
|
|
4790
4432
|
scenario: state.scenarioId ?? undefined,
|
|
@@ -4792,9 +4434,6 @@ export class ControlServer {
|
|
|
4792
4434
|
mode: state.mode,
|
|
4793
4435
|
instead: inPlace ? toolName : undefined,
|
|
4794
4436
|
after: inPlace ? undefined : toolName,
|
|
4795
|
-
kind: match.kind ?? "scenario",
|
|
4796
|
-
key: match.key,
|
|
4797
|
-
verified: match.verified?.verdict,
|
|
4798
4437
|
why: "the model's reasoning matched a calculated scenario's intent",
|
|
4799
4438
|
});
|
|
4800
4439
|
if (inPlace)
|
|
@@ -4804,19 +4443,15 @@ export class ControlServer {
|
|
|
4804
4443
|
}
|
|
4805
4444
|
/**
|
|
4806
4445
|
* Whether the model's call is plausibly `state`'s first step: its tool, and
|
|
4807
|
-
* arguments the pin guard would pin to that step's input.
|
|
4808
|
-
*
|
|
4809
|
-
*
|
|
4810
|
-
* computed is "no" — the directive then rides on the call's PostToolUse.
|
|
4446
|
+
* arguments the pin guard would pin to that step's input. Anything that
|
|
4447
|
+
* cannot be computed is "no" — the directive then rides on the call's
|
|
4448
|
+
* PostToolUse.
|
|
4811
4449
|
*/
|
|
4812
4450
|
isFirstStep(state, toolName, modelArgs) {
|
|
4813
4451
|
const plan = state.plan;
|
|
4814
4452
|
if (!plan || plan.expectedTool() !== toolName)
|
|
4815
4453
|
return false;
|
|
4816
4454
|
try {
|
|
4817
|
-
const entry = plan.currentEntry();
|
|
4818
|
-
if (!entry || entry.depth !== 0)
|
|
4819
|
-
return false;
|
|
4820
4455
|
const first = plan.toolInputForCurrentStep();
|
|
4821
4456
|
return pinCompatible(toolName, modelArgs, first).ok;
|
|
4822
4457
|
}
|
|
@@ -4826,503 +4461,34 @@ export class ControlServer {
|
|
|
4826
4461
|
}
|
|
4827
4462
|
}
|
|
4828
4463
|
/**
|
|
4829
|
-
*
|
|
4830
|
-
*
|
|
4831
|
-
* `
|
|
4832
|
-
* (a target the reading left empty, which the agent's call may hold), and
|
|
4833
|
-
* `other` otherwise.
|
|
4834
|
-
*/
|
|
4835
|
-
firstStepVerdict(state, toolName, modelArgs) {
|
|
4836
|
-
const plan = state.plan;
|
|
4837
|
-
if (!plan || plan.expectedTool() !== toolName)
|
|
4838
|
-
return "other";
|
|
4839
|
-
let first;
|
|
4840
|
-
try {
|
|
4841
|
-
const entry = plan.currentEntry();
|
|
4842
|
-
if (!entry || entry.depth !== 0)
|
|
4843
|
-
return "other";
|
|
4844
|
-
first = plan.toolInputForCurrentStep();
|
|
4845
|
-
}
|
|
4846
|
-
catch (err) {
|
|
4847
|
-
// A value the request did not give, or one only a step works out: the
|
|
4848
|
-
// agent's call may hold it.
|
|
4849
|
-
if (err instanceof RecordedValueError)
|
|
4850
|
-
return "missing";
|
|
4851
|
-
logDetail("replay.first_step_unknown", { tool: toolName, error: errText(err) });
|
|
4852
|
-
return "other";
|
|
4853
|
-
}
|
|
4854
|
-
return sameArguments(first, modelArgs);
|
|
4855
|
-
}
|
|
4856
|
-
/**
|
|
4857
|
-
* Whether `state`'s plan calls `toolName` in a step it would run: what makes
|
|
4858
|
-
* a plan started after a call of that tool make it a second time (the
|
|
4859
|
-
* service's offer1.md O8). A tool that only reads never counts — reading
|
|
4860
|
-
* twice changes nothing — by the service's own rule ({@link readsOnly}): a
|
|
4861
|
-
* `crm_search_customers` mid-task never keeps a plan that searches later from
|
|
4862
|
-
* starting.
|
|
4863
|
-
*/
|
|
4864
|
-
planCallsTool(state, toolName, modelArgs) {
|
|
4865
|
-
const plan = state.plan;
|
|
4866
|
-
if (!plan || readsOnly(toolName))
|
|
4867
|
-
return false;
|
|
4868
|
-
const same = plan
|
|
4869
|
-
.allEntries()
|
|
4870
|
-
.slice(0, plan.stepCount)
|
|
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
|
-
}
|
|
4891
|
-
});
|
|
4892
|
-
}
|
|
4893
|
-
// ── what can follow a decision (the service's offer1.md) ──────────────────
|
|
4894
|
-
/**
|
|
4895
|
-
* A plan of this turn stopped at its decision: ask the service, once per
|
|
4896
|
-
* plan, which calculated scenarios can follow it, and arm each in the
|
|
4897
|
-
* background while the agent reads the note and decides (offer1.md O3, O4).
|
|
4898
|
-
* Called wherever a hand-over is first seen; a second call for the same plan
|
|
4899
|
-
* does nothing.
|
|
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 });
|
|
4941
|
-
}
|
|
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", {
|
|
4994
|
-
run: run.runId,
|
|
4995
|
-
plan: planId,
|
|
4996
|
-
n: fresh.filter((e) => e.state?.plan).length,
|
|
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",
|
|
5003
|
-
});
|
|
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
|
-
return undefined;
|
|
5028
|
-
}
|
|
5029
|
-
}
|
|
5030
|
-
/**
|
|
5031
|
-
* The agent's call after a decision, against the scenarios that can follow
|
|
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))
|
|
5040
|
-
return undefined;
|
|
5041
|
-
if (this.replay.isDirectTool(toolName))
|
|
5042
|
-
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
|
-
}
|
|
5052
|
-
next.calls += 1;
|
|
5053
|
-
/** A scenario that starts with this call's tool was compared, value for value, and missed. */
|
|
5054
|
-
let compared = false;
|
|
5055
|
-
/** One that starts with this call's tool was not armed in time: it misses this call only. */
|
|
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;
|
|
5065
|
-
};
|
|
5066
|
-
for (const entry of next.entries) {
|
|
5067
|
-
if (entry.unhittable)
|
|
5068
|
-
continue;
|
|
5069
|
-
let state = entry.state;
|
|
5070
|
-
if (!state) {
|
|
5071
|
-
// Waited for only when the service named this call's tool as its first
|
|
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);
|
|
5140
|
-
}
|
|
5141
|
-
else {
|
|
5142
|
-
if (notReady) {
|
|
5143
|
-
logDetail("replay.next_not_ready", {
|
|
5144
|
-
run: run.runId,
|
|
5145
|
-
tool: toolName,
|
|
5146
|
-
why: "a scenario that starts with this tool was not armed in time — it misses this call only",
|
|
5147
|
-
});
|
|
5148
|
-
}
|
|
5149
|
-
// A look leaves them in place, for a while.
|
|
5150
|
-
if (next.calls >= NEXT_CALLS_MAX)
|
|
5151
|
-
this.endNext(run, "calls_limit", toolName);
|
|
5152
|
-
}
|
|
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
|
-
state.sessionId = session.sessionId;
|
|
5158
|
-
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
5159
|
-
// The request this plan serves, for a note at a decision of its own (SB-18).
|
|
5160
|
-
state.request = run.input || undefined;
|
|
5161
|
-
if (this.declineWithoutDeliveryTool(state)) {
|
|
5162
|
-
state.reported = true;
|
|
5163
|
-
this.endNext(run, "no_delivery_tool", toolName);
|
|
5164
|
-
return undefined;
|
|
5165
|
-
}
|
|
5166
|
-
state.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
5167
|
-
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
|
-
run.replays.push(state);
|
|
5178
|
-
run.replay = state;
|
|
5179
|
-
run.intent.armed += 1;
|
|
5180
|
-
// The agent's work from here is this scenario's, never recorded as the
|
|
5181
|
-
// agent's own after the decision: a fragment still due is dropped, and
|
|
5182
|
-
// one the agent's own looks opened ends with them.
|
|
5183
|
-
this.closeDecisionFragment(session, run, state);
|
|
5184
|
-
this.dropDecisionFragment(run, state);
|
|
5185
|
-
this.endNext(run, "hit", toolName, state);
|
|
5186
|
-
const directive = this.replay.directiveFor(state) ?? "";
|
|
5187
|
-
logLine("replay.next_hit", {
|
|
5188
|
-
run: run.runId,
|
|
5189
|
-
scenario: state.scenarioId ?? undefined,
|
|
5190
|
-
plan: entry.planId,
|
|
5191
|
-
kind: state.kind,
|
|
5192
|
-
mode: state.mode,
|
|
5193
|
-
tool: toolName,
|
|
5194
|
-
rearmed: entry.rearmed || undefined,
|
|
5195
|
-
why: "the agent's call is the first step of a calculated scenario that follows the decision — it runs in the call's place",
|
|
5196
|
-
});
|
|
5197
|
-
return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
|
|
5198
|
-
}
|
|
5199
|
-
/**
|
|
5200
|
-
* A next scenario hit after the agent's own calls — looks — opened the
|
|
5201
|
-
* fragment of its work after the decision: the fragment ends here, with what
|
|
5202
|
-
* the agent did itself, and nothing more of this turn is recorded into it.
|
|
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}).
|
|
5207
|
-
*/
|
|
5208
|
-
closeDecisionFragment(session, run, by) {
|
|
5209
|
-
const f = run.fragment;
|
|
5210
|
-
if (!f || f.state !== "open" || !f.decision || !run.recording)
|
|
5211
|
-
return;
|
|
5212
|
-
const runId = run.runId;
|
|
5213
|
-
const cost = this.costSince(session, f.usageMark);
|
|
5214
|
-
const durationMs = f.startedAtMs ? Date.now() - f.startedAtMs : 0;
|
|
5215
|
-
// Behind the fragment's own steps in the queue, as every finish is.
|
|
5216
|
-
this.queue.push(() => {
|
|
5217
|
-
this.recorder.finishRun(runId, undefined, {
|
|
5218
|
-
durationMs,
|
|
5219
|
-
costUsd: cost.measured ? cost.usd : undefined,
|
|
5220
|
-
});
|
|
5221
|
-
});
|
|
5222
|
-
run.recording = false;
|
|
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
|
-
});
|
|
5231
|
-
}
|
|
5232
|
-
/**
|
|
5233
|
-
* End this turn's next scenarios (offer1.md O6): every one but the hit is
|
|
5234
|
-
* never announced, so never runs and is never reported.
|
|
5235
|
-
*/
|
|
5236
|
-
endNext(run, why, toolName, kept) {
|
|
5237
|
-
const next = run.next;
|
|
5238
|
-
if (!next || next.ended)
|
|
5239
|
-
return;
|
|
5240
|
-
next.ended = why;
|
|
5241
|
-
for (const e of next.entries)
|
|
5242
|
-
dropNextEntry(e, kept);
|
|
5243
|
-
if (why !== "hit" && next.entries.length > 0) {
|
|
5244
|
-
logLine("replay.next_miss", {
|
|
5245
|
-
run: run.runId,
|
|
5246
|
-
reason: why,
|
|
5247
|
-
tool: toolName,
|
|
5248
|
-
calls: next.calls,
|
|
5249
|
-
scenarios: next.entries.map((e) => e.match.scenarioId).join(","),
|
|
5250
|
-
why: NEXT_MISS_WHY[why] ?? "none of the scenarios that follow the decision was used",
|
|
5251
|
-
});
|
|
5252
|
-
}
|
|
5253
|
-
next.entries = [];
|
|
5254
|
-
}
|
|
5255
|
-
/**
|
|
5256
|
-
* Whether an intent probe may arm a plan in its own `PreToolUse` — and so
|
|
5257
|
-
* whether that hook waits for it. See {@link ControlServerOptions.intentArm}.
|
|
4464
|
+
* Whether a match found mid-turn may arm a plan in its own `PreToolUse` — and
|
|
4465
|
+
* so whether a probe is sent and waited for at all. Off unless
|
|
4466
|
+
* `BIR_INTENT_ARM=1`. See {@link ControlServerOptions.intentArm}.
|
|
5258
4467
|
*/
|
|
5259
4468
|
intentArming() {
|
|
5260
4469
|
if (this.opts.intentArm !== undefined)
|
|
5261
4470
|
return this.opts.intentArm;
|
|
5262
|
-
|
|
5263
|
-
if (env === "1")
|
|
5264
|
-
return true;
|
|
5265
|
-
if (env === "0")
|
|
5266
|
-
return false;
|
|
5267
|
-
return this.replay.segmentArm;
|
|
4471
|
+
return process.env.BIR_INTENT_ARM === "1";
|
|
5268
4472
|
}
|
|
5269
4473
|
/** How long an arming probe is waited on: `BIR_INTENT_ARM_BUDGET_MS`, never past the probe's own budget. */
|
|
5270
4474
|
intentArmBudgetMs() {
|
|
5271
4475
|
return Math.min(this.replay.intentMatch.budgetMs, positiveEnv("BIR_INTENT_ARM_BUDGET_MS", INTENT_ARM_PROBE_MS));
|
|
5272
4476
|
}
|
|
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.
|
|
4477
|
+
/** A probe's answer, for the log. Never throws: a malformed answer is a log line. */
|
|
4478
|
+
noteProbe(run, toolName, answer, startedAt) {
|
|
5278
4479
|
try {
|
|
5279
|
-
|
|
4480
|
+
logDetail("replay.intent_probe", {
|
|
4481
|
+
run: run.runId,
|
|
4482
|
+
tool: toolName,
|
|
4483
|
+
elapsedMs: Date.now() - startedAt,
|
|
4484
|
+
matched: answer.matched?.scenarioId ?? "none",
|
|
4485
|
+
bestSimilarity: typeof answer.bestSimilarity === "number" ? score(answer.bestSimilarity) : undefined,
|
|
4486
|
+
});
|
|
5280
4487
|
}
|
|
5281
4488
|
catch (err) {
|
|
5282
4489
|
logDetail("replay.intent_note_failed", { run: run.runId, tool: toolName, error: errText(err) });
|
|
5283
4490
|
}
|
|
5284
4491
|
}
|
|
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
4492
|
/**
|
|
5327
4493
|
* Schedule a fragment once a matched turn's plan has handed over (fallbk.md
|
|
5328
4494
|
* D7). Only a turn that is not already recording needs one: an unmatched turn
|
|
@@ -5335,35 +4501,7 @@ export class ControlServer {
|
|
|
5335
4501
|
const h = state?.handover;
|
|
5336
4502
|
if (!h || !state?.scenarioId)
|
|
5337
4503
|
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
|
-
});
|
|
4504
|
+
run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
|
|
5367
4505
|
}
|
|
5368
4506
|
/**
|
|
5369
4507
|
* Open the fragment run and record from this call on (fallbk.md D7).
|
|
@@ -5383,21 +4521,9 @@ export class ControlServer {
|
|
|
5383
4521
|
return;
|
|
5384
4522
|
const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
|
|
5385
4523
|
const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
|
|
5386
|
-
// The agent's own call after a plan's decision (SB-16): this runner's word
|
|
5387
|
-
// that the work that follows is the agent's choice, not a plan's half.
|
|
5388
|
-
// The service reads it beside its own mark of the fragment (the
|
|
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;
|
|
5398
4524
|
// The fragment is a run of the same host as the turn it continues.
|
|
5399
4525
|
const framework = frameworkOf(session);
|
|
5400
|
-
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf
|
|
4526
|
+
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf, framework } : { fallbackOf });
|
|
5401
4527
|
for (const c of run.correlations.values()) {
|
|
5402
4528
|
c.settled = true;
|
|
5403
4529
|
if (c.fallback)
|
|
@@ -5414,7 +4540,6 @@ export class ControlServer {
|
|
|
5414
4540
|
run: id,
|
|
5415
4541
|
scenario: f.scenarioId,
|
|
5416
4542
|
step: f.stepIndex,
|
|
5417
|
-
decision: f.decision || undefined,
|
|
5418
4543
|
why: "recording the agent's own work after the scenario handed over",
|
|
5419
4544
|
});
|
|
5420
4545
|
if (!isRunCreationAware(this.recorder))
|
|
@@ -5431,21 +4556,12 @@ export class ControlServer {
|
|
|
5431
4556
|
/** What the fragment's create call said: `created: false` is a hit, not a recording. */
|
|
5432
4557
|
applyFragmentCreation(run, id, created) {
|
|
5433
4558
|
const f = run.fragment;
|
|
5434
|
-
|
|
4559
|
+
// Recorded — as a fragment of its own, or as the newest run of the
|
|
4560
|
+
// fragment it is a hit on (the service's newestRun.md N2): it records on.
|
|
4561
|
+
if (!f || run.runId !== id || created.created)
|
|
5435
4562
|
return;
|
|
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;
|
|
5442
|
-
return;
|
|
5443
|
-
}
|
|
5444
4563
|
run.recording = false;
|
|
5445
4564
|
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
4565
|
logLine("run.fragment_hit", {
|
|
5450
4566
|
run: id,
|
|
5451
4567
|
scenario: f.scenarioId,
|
|
@@ -5654,17 +4770,12 @@ export class ControlServer {
|
|
|
5654
4770
|
const stashed = this.claimStashedNote(payload.session_id);
|
|
5655
4771
|
if (stashed)
|
|
5656
4772
|
context.push(stashed);
|
|
5657
|
-
const
|
|
5658
|
-
const run = postSession?.run;
|
|
4773
|
+
const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
|
|
5659
4774
|
if (run && !run.finished) {
|
|
5660
4775
|
// The answer, if one comes, is written after this call (AC11).
|
|
5661
4776
|
if (typeof payload.tool_use_id === "string" && payload.tool_use_id)
|
|
5662
4777
|
run.lastToolUseId = payload.tool_use_id;
|
|
5663
4778
|
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
4779
|
const note = this.replay.takeNote(run.replay);
|
|
5669
4780
|
if (note)
|
|
5670
4781
|
context.push(note);
|
|
@@ -6229,11 +5340,6 @@ export class ControlServer {
|
|
|
6229
5340
|
const result = await this.replay.runArmed(state, signal);
|
|
6230
5341
|
if (signal?.aborted)
|
|
6231
5342
|
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
5343
|
return result;
|
|
6238
5344
|
}
|
|
6239
5345
|
finally {
|