@basein/runner 0.2.0 → 0.2.1
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 +2 -0
- package/dist/auth/client.d.ts +12 -0
- package/dist/auth/client.js +41 -0
- package/dist/bin/bir-hooks.d.ts +13 -0
- package/dist/bin/bir-hooks.js +58 -0
- package/dist/bin/bir.js +42 -2
- package/dist/control/server.d.ts +84 -1
- package/dist/control/server.js +546 -51
- package/dist/control/transcript.d.ts +40 -0
- package/dist/control/transcript.js +105 -0
- package/dist/record/recorder.d.ts +178 -4
- package/dist/record/recorder.js +6 -0
- package/dist/record/remote-recorder.d.ts +20 -2
- package/dist/record/remote-recorder.js +66 -6
- package/dist/replay/bundle.d.ts +10 -1
- package/dist/replay/bundle.js +41 -3
- package/dist/replay/controller.d.ts +164 -5
- package/dist/replay/controller.js +556 -54
- package/dist/replay/coverage.js +2 -2
- package/dist/replay/derive.d.ts +20 -1
- package/dist/replay/derive.js +69 -12
- package/dist/replay/flatten.d.ts +125 -0
- package/dist/replay/flatten.js +182 -0
- package/dist/replay/handover.d.ts +60 -0
- package/dist/replay/handover.js +82 -0
- package/dist/replay/logic.d.ts +11 -0
- package/dist/replay/logic.js +17 -0
- package/dist/replay/plan.d.ts +105 -8
- package/dist/replay/plan.js +309 -47
- package/dist/replay/source-run.d.ts +24 -10
- package/dist/replay/source-run.js +65 -30
- package/dist/replay/types.d.ts +108 -5
- package/dist/replay/types.js +33 -3
- package/package.json +1 -1
|
@@ -18,13 +18,15 @@ import { serializeCapped } from "../record/truncate.js";
|
|
|
18
18
|
import { logDetail, logLine, errText } from "../util/log.js";
|
|
19
19
|
import { MAX_REPLAY_REASON } from "./bundle.js";
|
|
20
20
|
import { coverageOf, modeFor, reachOf } from "./coverage.js";
|
|
21
|
-
import { deriveParameters } from "./derive.js";
|
|
21
|
+
import { deriveParameters, } from "./derive.js";
|
|
22
|
+
import { buildHandoverNote } from "./handover.js";
|
|
22
23
|
import { ProxyWorkQueue } from "./executor.js";
|
|
23
24
|
import { ScenarioReplayPlan } from "./plan.js";
|
|
24
25
|
import { PRICING_VERSION } from "./pricing.js";
|
|
25
26
|
import { SourceRunOutputs } from "./source-run.js";
|
|
27
|
+
import { clampToFrameStart, flattenChain, } from "./flatten.js";
|
|
26
28
|
import { toolResultError } from "./tool-error.js";
|
|
27
|
-
import { OUTCOME_RANK, isReadyScenario, } from "./types.js";
|
|
29
|
+
import { OUTCOME_RANK, hasTargets, isReadyScenario, } from "./types.js";
|
|
28
30
|
/** The first-party tool a `direct` plan is delivered through (§6.3). */
|
|
29
31
|
export const DIRECT_TOOL_NAME = "mcp__bir__run_scenario";
|
|
30
32
|
/** How long a `/proxy/poll` is held open before it answers empty. */
|
|
@@ -35,6 +37,12 @@ export const DEFAULT_BUDGETS = {
|
|
|
35
37
|
planMs: 120_000,
|
|
36
38
|
stepMs: 60_000,
|
|
37
39
|
};
|
|
40
|
+
export const DEFAULT_INTENT_MATCH = {
|
|
41
|
+
enabled: true,
|
|
42
|
+
budgetMs: 4_000,
|
|
43
|
+
maxPerTurn: 3,
|
|
44
|
+
maxRequestsPerTurn: 40,
|
|
45
|
+
};
|
|
38
46
|
/** How many step verdicts one report carries. A chain longer than this is not
|
|
39
47
|
* a chain anybody is reading step by step, and the report has to stay small. */
|
|
40
48
|
const MAX_STEP_RESULTS = 200;
|
|
@@ -44,11 +52,23 @@ export class ReplayController {
|
|
|
44
52
|
enabled;
|
|
45
53
|
work = new ProxyWorkQueue();
|
|
46
54
|
budgets;
|
|
55
|
+
intentMatch;
|
|
47
56
|
opts;
|
|
48
57
|
constructor(opts) {
|
|
49
58
|
this.opts = opts;
|
|
50
59
|
this.enabled = opts.enabled;
|
|
51
60
|
this.budgets = { ...DEFAULT_BUDGETS, ...(opts.budgets ?? {}) };
|
|
61
|
+
this.intentMatch = { ...DEFAULT_INTENT_MATCH, ...(opts.intentMatch ?? {}) };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
|
|
65
|
+
*
|
|
66
|
+
* Off by default: until an operator has read the observe-only logs and turned
|
|
67
|
+
* `BIR_SEGMENT_ARM=1` on, the runner asks for no segment and arms none. The
|
|
68
|
+
* server enforces the same thing from its side (R-OUT-9).
|
|
69
|
+
*/
|
|
70
|
+
get segmentArm() {
|
|
71
|
+
return this.opts.segmentArm === true;
|
|
52
72
|
}
|
|
53
73
|
/** The plan's own delivery vehicle is never a scenario step. */
|
|
54
74
|
isDirectTool(toolName) {
|
|
@@ -86,7 +106,7 @@ export class ReplayController {
|
|
|
86
106
|
* baseline sample to contribute, and losing that is how a savings ledger ends
|
|
87
107
|
* up with a denominator nobody measured.
|
|
88
108
|
*/
|
|
89
|
-
arm(match, prompt, wrapped) {
|
|
109
|
+
async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}) {
|
|
90
110
|
const state = {
|
|
91
111
|
scenarioId: match.scenarioId,
|
|
92
112
|
ticket: match.executionTicket,
|
|
@@ -104,40 +124,156 @@ export class ReplayController {
|
|
|
104
124
|
armedAt: Date.now(),
|
|
105
125
|
reported: false,
|
|
106
126
|
retired: false,
|
|
127
|
+
armedBy,
|
|
128
|
+
// An older server sends no `kind`, and a whole-run scenario is the safe
|
|
129
|
+
// reading: it is what every hand-out was before segments existed.
|
|
130
|
+
kind: match.kind ?? "scenario",
|
|
131
|
+
segment: match.segment,
|
|
132
|
+
key: match.key,
|
|
133
|
+
verified: match.verified,
|
|
134
|
+
sharedWith: [],
|
|
107
135
|
};
|
|
108
|
-
|
|
136
|
+
// A segment is judged against its own gate, and a `same` verdict stands in
|
|
137
|
+
// for the score (segmented.md R-OUT-11): the question was asked *because*
|
|
138
|
+
// the score sat below the threshold, so a numeric gate would decline every
|
|
139
|
+
// verified hand-out.
|
|
140
|
+
const isSegment = state.kind === "segment";
|
|
141
|
+
const threshold = isSegment
|
|
142
|
+
? (this.opts.minSegmentSimilarity ?? 0.95)
|
|
143
|
+
: this.opts.minSimilarity;
|
|
144
|
+
const decline = (why, code) => {
|
|
145
|
+
state.declined = code;
|
|
109
146
|
logLine("replay.decision", {
|
|
110
147
|
verdict: "no-steer",
|
|
111
148
|
run: match.runId,
|
|
112
149
|
scenario: match.scenarioId ?? undefined,
|
|
113
150
|
similarity: match.similarity.toFixed(3),
|
|
114
|
-
threshold
|
|
151
|
+
threshold,
|
|
152
|
+
kind: state.kind,
|
|
153
|
+
key: state.key,
|
|
154
|
+
verified: state.verified?.verdict,
|
|
115
155
|
why,
|
|
116
156
|
});
|
|
117
157
|
return state;
|
|
118
158
|
};
|
|
159
|
+
// The server never sends a segment to a runner that did not ask for one
|
|
160
|
+
// (R-OUT-9). One arriving anyway is declined here rather than run: an
|
|
161
|
+
// observe-only period that quietly armed something would be worthless.
|
|
162
|
+
if (isSegment && !this.segmentArm) {
|
|
163
|
+
return decline("segment hand-out while BIR_SEGMENT_ARM is off", "replay_disabled");
|
|
164
|
+
}
|
|
119
165
|
// Gate 2 — replay enabled.
|
|
120
166
|
if (!this.enabled)
|
|
121
|
-
return decline("BIR_REPLAY is not set");
|
|
167
|
+
return decline("BIR_REPLAY is not set", "replay_disabled");
|
|
122
168
|
// Gate 3 — a ready scenario with steps.
|
|
123
169
|
const scenario = match.scenario;
|
|
124
170
|
if (!match.scenarioId || !isReadyScenario(scenario)) {
|
|
125
|
-
return decline(match.scenarioId ? "scenario is not ready" : "no scenario for the matched run");
|
|
171
|
+
return decline(match.scenarioId ? "scenario is not ready" : "no scenario for the matched run", "not_ready");
|
|
126
172
|
}
|
|
127
173
|
// Gate 4 — similarity. Detection decides "don't record this again"; steering
|
|
128
174
|
// decides "don't think about this again", which is a much stronger claim and
|
|
129
175
|
// deserves a stronger threshold.
|
|
130
|
-
|
|
131
|
-
|
|
176
|
+
//
|
|
177
|
+
// For a segment, a `same` verdict from the live question stands in for the
|
|
178
|
+
// score (R-OUT-11). Only `same` ever reaches a runner with a ticket, so it
|
|
179
|
+
// is the only verdict this sees.
|
|
180
|
+
const scoreOk = match.similarity >= threshold;
|
|
181
|
+
const verifiedOk = isSegment && match.verified?.verdict === "same";
|
|
182
|
+
if (!scoreOk && !verifiedOk) {
|
|
183
|
+
return decline("similarity below threshold", "similarity");
|
|
184
|
+
}
|
|
185
|
+
// Flatten the chain before the remaining gates, so every one of them judges
|
|
186
|
+
// the steps that will *really* run — the caller's own and, in order, those
|
|
187
|
+
// of every segment it calls (segmented.md R-CALL-29, R-CALL-30).
|
|
188
|
+
let flat;
|
|
189
|
+
try {
|
|
190
|
+
flat = flattenChain(scenario, {
|
|
191
|
+
range: match.segment
|
|
192
|
+
? { stepFrom: match.segment.stepFrom, stepTo: match.segment.stepTo }
|
|
193
|
+
: null,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
// A payload we cannot lay out is a payload we cannot run. It costs the
|
|
198
|
+
// turn nothing: the agent does the task (R-FALL-12).
|
|
199
|
+
logLine("replay.flatten_failed", { scenario: scenario.id, error: errText(err) });
|
|
200
|
+
return decline("the chain could not be flattened", "flatten_failed");
|
|
132
201
|
}
|
|
133
|
-
|
|
134
|
-
|
|
202
|
+
const entries = flat.entries;
|
|
203
|
+
if (entries.length === 0)
|
|
204
|
+
return decline("the chain has no runnable step", "not_ready");
|
|
205
|
+
// Gate 5 — known-bad steps (fallbk.md D3). The plan ends in front of the
|
|
206
|
+
// first step the service has seen fail more than its limit — the counter on
|
|
207
|
+
// whichever scenario owns that step (R-CALL-30). A call the service could
|
|
208
|
+
// not answer stops the plan the same way, and the earlier of the two wins.
|
|
209
|
+
const badAt = knownBadStepIndex(entries.map((e) => e.step), match.fallback?.maxStepFailures);
|
|
210
|
+
const rawStop = [flat.stopAt, badAt].filter((n) => typeof n === "number");
|
|
211
|
+
// Clamped to the start of the outermost called frame it falls in: a plan
|
|
212
|
+
// never runs half a sub-task and leaves its result mapping unevaluated.
|
|
213
|
+
const stopAt = rawStop.length > 0 ? clampToFrameStart(entries, Math.min(...rawStop)) : undefined;
|
|
214
|
+
if (stopAt === 0) {
|
|
215
|
+
const why = flat.stopAt === 0 && flat.stopReason
|
|
216
|
+
? `its first step is a call that cannot run: ${flat.stopReason.reason}`
|
|
217
|
+
: `its first step has failed ${entries[0].step.failureCount ?? 0} times — the agent does this task`;
|
|
218
|
+
return decline(why, flat.stopAt === 0 && flat.stopReason ? "unusable_first_step" : "known_bad_first_step");
|
|
219
|
+
}
|
|
220
|
+
const planned = stopAt === undefined ? entries : entries.slice(0, stopAt);
|
|
221
|
+
// Gate 6 — tool coverage, over the steps that will actually run.
|
|
222
|
+
const mode = modeFor(planned.map((e) => e.step), wrapped, this.opts.allowServers);
|
|
135
223
|
if (mode === "none")
|
|
136
|
-
return decline("no step is executable");
|
|
137
|
-
const
|
|
138
|
-
|
|
224
|
+
return decline("no step is executable", "coverage");
|
|
225
|
+
const derivation = this.startDerivation(scenario, prompt, state, ctx);
|
|
226
|
+
const params = derivation.then((r) => r.params);
|
|
227
|
+
// `params` is awaited by the plan, and by gate 7 below when there is a
|
|
228
|
+
// target. Without this, the losing race in gate 7 leaves `derivation`
|
|
229
|
+
// rejected and unhandled, which takes the process down on an unhandled
|
|
230
|
+
// rejection rather than declining one plan.
|
|
231
|
+
params.catch(() => undefined);
|
|
232
|
+
// Gate 7 — targets (segmented.md R-PARAM-3, R-PARAM-4). The last gate,
|
|
233
|
+
// because it is the only one that spends a model call and waits. A scenario
|
|
234
|
+
// whose parameters are all settings arms at once, as before: there is
|
|
235
|
+
// nothing the turn has to supply.
|
|
236
|
+
if (hasTargets(scenario.paramsObject)) {
|
|
237
|
+
let result;
|
|
238
|
+
try {
|
|
239
|
+
result = await this.withBudget(derivation, this.budgets.deriveMs, "derivation");
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
// Out of budget, or the call threw. Either way the turn has not said
|
|
243
|
+
// what this task is to act on, and running it would act on something
|
|
244
|
+
// else (R-PARAM-3).
|
|
245
|
+
const first = firstTargetKey(scenario.paramsObject);
|
|
246
|
+
return decline(`target ${first ?? "(unknown)"} not found — ${errText(err)}`, "missing_target");
|
|
247
|
+
}
|
|
248
|
+
if (!result.derived) {
|
|
249
|
+
// No key: settings can still take their recorded values, but a target
|
|
250
|
+
// is a guess nobody is allowed to make (R-PARAM-5).
|
|
251
|
+
const first = firstTargetKey(scenario.paramsObject);
|
|
252
|
+
return decline(`no_derive_key: target ${first ?? "(unknown)"}`, "no_derive_key");
|
|
253
|
+
}
|
|
254
|
+
if (result.missing.length > 0) {
|
|
255
|
+
return decline(`target ${result.missing[0]} not found`, "missing_target");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// A plan armed by intent runs inside a task the agent is already doing, so
|
|
259
|
+
// its directive and its bundles say "continue", never "answer the user"
|
|
260
|
+
// (segmented.md R-OUT-13).
|
|
261
|
+
state.plan = new ScenarioReplayPlan({
|
|
262
|
+
scenario,
|
|
263
|
+
params,
|
|
264
|
+
mode,
|
|
265
|
+
stopAt,
|
|
266
|
+
entries,
|
|
267
|
+
subTask: armedBy === "intent",
|
|
268
|
+
});
|
|
139
269
|
state.mode = mode;
|
|
140
|
-
state.stepsPlanned =
|
|
270
|
+
state.stepsPlanned = entries.length;
|
|
271
|
+
// Why the plan stops, when a call is what stops it: the note names the
|
|
272
|
+
// sub-task rather than a tool (R-CALL-36).
|
|
273
|
+
state.flatStop =
|
|
274
|
+
typeof stopAt === "number" && flat.stopAt !== undefined && stopAt <= flat.stopAt
|
|
275
|
+
? flat.stopReason
|
|
276
|
+
: undefined;
|
|
141
277
|
if (this.opts.authUrl && this.opts.authToken) {
|
|
142
278
|
state.sourceRun = new SourceRunOutputs({
|
|
143
279
|
baseUrl: this.opts.authUrl,
|
|
@@ -151,16 +287,54 @@ export class ReplayController {
|
|
|
151
287
|
run: match.runId,
|
|
152
288
|
scenario: scenario.id,
|
|
153
289
|
mode,
|
|
154
|
-
steps:
|
|
290
|
+
steps: entries.length,
|
|
291
|
+
stopsAt: stopAt,
|
|
292
|
+
by: armedBy === "intent" ? "intent" : undefined,
|
|
293
|
+
kind: state.kind,
|
|
294
|
+
key: state.key,
|
|
295
|
+
verified: state.verified?.verdict,
|
|
155
296
|
similarity: match.similarity.toFixed(3),
|
|
156
|
-
|
|
157
|
-
|
|
297
|
+
// What this chain calls, and what answers each call right now (R-CALL-32).
|
|
298
|
+
calls: callLines(scenario),
|
|
299
|
+
unusable: flat.stopReason?.reason,
|
|
300
|
+
coverage: coverageOf(entries.map((e) => e.step), wrapped, this.opts.allowServers).join(","),
|
|
301
|
+
tools: entries.map((e) => e.step.toolName ?? "").join(","),
|
|
158
302
|
});
|
|
159
303
|
return state;
|
|
160
304
|
}
|
|
161
305
|
/** The directive to inject via `additionalContext`, or undefined when declined. */
|
|
162
306
|
directiveFor(state) {
|
|
163
|
-
return state.plan?.steeringDirective(DIRECT_TOOL_NAME
|
|
307
|
+
return state.plan?.steeringDirective(DIRECT_TOOL_NAME, {
|
|
308
|
+
subTask: state.armedBy === "intent",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* A hand-over note not yet delivered, claimed for delivery (fallbk.md D4).
|
|
313
|
+
* Returns it once and marks it delivered; undefined when there is none.
|
|
314
|
+
*/
|
|
315
|
+
takeNote(state) {
|
|
316
|
+
const h = state?.handover;
|
|
317
|
+
if (!h || h.delivered)
|
|
318
|
+
return undefined;
|
|
319
|
+
h.delivered = true;
|
|
320
|
+
logDetail("replay.handover_delivered", {
|
|
321
|
+
scenario: state.scenarioId ?? undefined,
|
|
322
|
+
bytes: h.note.length,
|
|
323
|
+
});
|
|
324
|
+
return h.note;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Deliver text to the model in place of the tool call it was about to make:
|
|
328
|
+
* as genuine command output for `Bash`, as the denial reason otherwise — the
|
|
329
|
+
* two channels a divergence bundle already uses.
|
|
330
|
+
*/
|
|
331
|
+
deliverInstead(text, toolName) {
|
|
332
|
+
if (toolName === "Bash") {
|
|
333
|
+
const delimiter = "BIR_EOF";
|
|
334
|
+
const safe = text.split(delimiter).join("BIR_EOF_");
|
|
335
|
+
return { kind: "bash", command: `cat <<'${delimiter}'\n${safe}\n${delimiter}` };
|
|
336
|
+
}
|
|
337
|
+
return { kind: "deny", reason: text };
|
|
164
338
|
}
|
|
165
339
|
/**
|
|
166
340
|
* `PreToolUse`, while a plan is active.
|
|
@@ -210,6 +384,11 @@ export class ReplayController {
|
|
|
210
384
|
// step and will throw on the same logic — and its own report would then
|
|
211
385
|
// be the only trace, attributed to a re-execution rather than to the
|
|
212
386
|
// steered call that actually hit it first.
|
|
387
|
+
//
|
|
388
|
+
// Not a divergence any more (fallbk.md): recovering by executing the
|
|
389
|
+
// rest would start at this very step and throw on the same logic. The
|
|
390
|
+
// step is recorded as failed, and the task is handed to the model with
|
|
391
|
+
// a note, delivered in place of this call.
|
|
213
392
|
const step = plan.currentStep();
|
|
214
393
|
if (step) {
|
|
215
394
|
this.recordStep(state, {
|
|
@@ -218,8 +397,18 @@ export class ReplayController {
|
|
|
218
397
|
stage: "tool_input_logic",
|
|
219
398
|
error: errText(err),
|
|
220
399
|
});
|
|
400
|
+
this.handOver(state, {
|
|
401
|
+
kind: "step_failed",
|
|
402
|
+
step,
|
|
403
|
+
position: plan.currentStepIndex,
|
|
404
|
+
error: errText(err),
|
|
405
|
+
delivery: "above",
|
|
406
|
+
});
|
|
407
|
+
const note = this.takeNote(state);
|
|
408
|
+
if (note)
|
|
409
|
+
return this.deliverInstead(note, toolName);
|
|
221
410
|
}
|
|
222
|
-
return
|
|
411
|
+
return { kind: "abort" };
|
|
223
412
|
}
|
|
224
413
|
}
|
|
225
414
|
// HOUSEKEEPING IS NOT DIVERGENCE. See `record/housekeeping.ts`.
|
|
@@ -270,8 +459,22 @@ export class ReplayController {
|
|
|
270
459
|
error: errText(err),
|
|
271
460
|
ms: Date.now() - pin.pinnedAt,
|
|
272
461
|
});
|
|
462
|
+
// The tool ran, so the step's call and result are in front of the model;
|
|
463
|
+
// what failed is threading its output into the plan. The rest is the
|
|
464
|
+
// model's (fallbk.md). `stepsPinned` counts it: its work happened.
|
|
465
|
+
state.stepsPinned += 1;
|
|
466
|
+
this.handOver(state, {
|
|
467
|
+
kind: "step_failed",
|
|
468
|
+
step,
|
|
469
|
+
position: pin.stepIndex,
|
|
470
|
+
error: errText(err),
|
|
471
|
+
delivery: "above",
|
|
472
|
+
ranThisStep: true,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
this.retire(state, "failed");
|
|
273
477
|
}
|
|
274
|
-
this.retire(state, "failed");
|
|
275
478
|
return false;
|
|
276
479
|
}
|
|
277
480
|
// One more step ran under the plan. Counted here, as each pin threads, and
|
|
@@ -293,7 +496,15 @@ export class ReplayController {
|
|
|
293
496
|
};
|
|
294
497
|
this.logStep(info);
|
|
295
498
|
this.recordStep(state, info);
|
|
296
|
-
|
|
499
|
+
// Uncount it: it was counted above as threaded, but its work did not happen.
|
|
500
|
+
state.stepsPinned -= 1;
|
|
501
|
+
this.handOver(state, {
|
|
502
|
+
kind: "step_failed",
|
|
503
|
+
step,
|
|
504
|
+
position: pin.stepIndex,
|
|
505
|
+
error: toolError,
|
|
506
|
+
delivery: "above",
|
|
507
|
+
});
|
|
297
508
|
return false;
|
|
298
509
|
}
|
|
299
510
|
if (step) {
|
|
@@ -310,9 +521,25 @@ export class ReplayController {
|
|
|
310
521
|
emitted: derivedKeys.join(",") || undefined,
|
|
311
522
|
done,
|
|
312
523
|
});
|
|
524
|
+
if (done && plan.stopsEarly()) {
|
|
525
|
+
// Every planned step ran, and the next one is parked (fallbk.md D3) — or
|
|
526
|
+
// is a call the service could not answer (segmented.md R-CALL-29).
|
|
527
|
+
const parked = plan.stopStep();
|
|
528
|
+
this.handOver(state, {
|
|
529
|
+
kind: state.flatStop ? "unusable_call" : "known_bad_step",
|
|
530
|
+
step: parked,
|
|
531
|
+
entry: plan.stopEntry(),
|
|
532
|
+
position: plan.stepCount,
|
|
533
|
+
delivery: "above",
|
|
534
|
+
});
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
313
537
|
if (done) {
|
|
314
538
|
this.upgrade(state, "steered_full");
|
|
315
|
-
|
|
539
|
+
// A completing retire: the last step threaded and the plan's work is over
|
|
540
|
+
// (segmented.md R-MONEY-3). Everything the agent spends after this is the
|
|
541
|
+
// agent's own, not this plan's.
|
|
542
|
+
this.retire(state, undefined, true);
|
|
316
543
|
logLine("replay.done", {
|
|
317
544
|
scenario: state.scenarioId ?? undefined,
|
|
318
545
|
mode: state.mode,
|
|
@@ -354,7 +581,7 @@ export class ReplayController {
|
|
|
354
581
|
const deadline = Date.now() + this.budgets.planMs;
|
|
355
582
|
let result;
|
|
356
583
|
try {
|
|
357
|
-
result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (
|
|
584
|
+
result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, this.observeStep(state), deadline, MAX_REPLAY_REASON, { stopOnFailure: true });
|
|
358
585
|
}
|
|
359
586
|
catch (err) {
|
|
360
587
|
// The scenario's own logic failed. Retire and let the model do the work.
|
|
@@ -366,6 +593,37 @@ export class ReplayController {
|
|
|
366
593
|
this.retire(state, "failed");
|
|
367
594
|
return { ok: false, why: "not_ready" };
|
|
368
595
|
}
|
|
596
|
+
// The plan stopped part-way (fallbk.md §Runner 3): a step broke, or the
|
|
597
|
+
// next one is parked. The steps that ran are real, so their bundle goes to
|
|
598
|
+
// the model — behind a note saying the rest of the task is its own.
|
|
599
|
+
if (result.stopped) {
|
|
600
|
+
const stop = result.stopped;
|
|
601
|
+
const ranOk = result.executed + result.recorded - result.errored;
|
|
602
|
+
state.stepsPinned += Math.max(0, ranOk);
|
|
603
|
+
const position = stop.flatIndex ?? plan.stepCount;
|
|
604
|
+
const step = plan.allSteps()[position];
|
|
605
|
+
this.handOver(state, {
|
|
606
|
+
kind: stop.kind,
|
|
607
|
+
step,
|
|
608
|
+
entry: plan.allEntries()[position],
|
|
609
|
+
position,
|
|
610
|
+
error: stop.error,
|
|
611
|
+
delivery: "below",
|
|
612
|
+
});
|
|
613
|
+
const note = this.takeNote(state) ?? state.handover?.note ?? "";
|
|
614
|
+
if (result.executed + result.recorded === 0) {
|
|
615
|
+
// Nothing ran: there is no bundle, only the task. The tool answers with
|
|
616
|
+
// an error that says so, which is what a model reacts to best.
|
|
617
|
+
return { ok: false, why: note };
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
ok: true,
|
|
621
|
+
text: `${note}\n\n${result.text}`,
|
|
622
|
+
responseModel: {},
|
|
623
|
+
steps: result.executed + result.recorded,
|
|
624
|
+
partial: result.partial,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
369
627
|
state.stepsPinned += result.executed + result.recorded;
|
|
370
628
|
let responseModel = {};
|
|
371
629
|
try {
|
|
@@ -382,7 +640,10 @@ export class ReplayController {
|
|
|
382
640
|
: result.partial || result.skipped > 0
|
|
383
641
|
? "diverged"
|
|
384
642
|
: "steered_full");
|
|
385
|
-
|
|
643
|
+
// `runToCompletion` returned without a stop: the plan ran to its end, so
|
|
644
|
+
// this is a completing retire (R-MONEY-3). The stopped path handed over
|
|
645
|
+
// above and took no mark.
|
|
646
|
+
this.retire(state, undefined, true);
|
|
386
647
|
logLine("replay.done", {
|
|
387
648
|
scenario: state.scenarioId ?? undefined,
|
|
388
649
|
mode: state.mode,
|
|
@@ -445,6 +706,23 @@ export class ReplayController {
|
|
|
445
706
|
errorStage: blame?.stage,
|
|
446
707
|
errorStepIndex: blame?.stepIndex,
|
|
447
708
|
errorToolName: blame?.toolName,
|
|
709
|
+
// Where the plan handed the task to the model (fallbk.md). Only on
|
|
710
|
+
// `fell_back`: a hand-over before any step ran is `failed`, and a parked
|
|
711
|
+
// step found after a divergence is still a divergence.
|
|
712
|
+
fallbackStepIndex: state.outcome === "fell_back" ? state.handover?.stepIndex : undefined,
|
|
713
|
+
fallbackKind: state.outcome === "fell_back" ? state.handover?.kind : undefined,
|
|
714
|
+
// Segments of this plan's recording that armed later in the turn
|
|
715
|
+
// (segmented.md R-MONEY-4): their share comes off this plan's baseline.
|
|
716
|
+
sharedWith: state.sharedWith.length > 0 ? [...state.sharedWith] : undefined,
|
|
717
|
+
// A turn in which another plan ran is not a measurement of what this task
|
|
718
|
+
// costs unaided, so it is no baseline sample (segmented.md R-MONEY-5).
|
|
719
|
+
// This covers two plans that both armed, and — the case that was silently
|
|
720
|
+
// wrong before — a declined prompt scenario followed by an intent-armed
|
|
721
|
+
// plan, whose "baseline" was really the turn cut short at the second arm.
|
|
722
|
+
// A lone plan, armed or declined, sends nothing: the default is eligible.
|
|
723
|
+
baselineEligible: d.siblings?.some((s) => s !== state && s.plan !== undefined)
|
|
724
|
+
? false
|
|
725
|
+
: undefined,
|
|
448
726
|
};
|
|
449
727
|
}
|
|
450
728
|
/**
|
|
@@ -476,7 +754,18 @@ export class ReplayController {
|
|
|
476
754
|
async runAdHoc(scenario, prompt, wrapped) {
|
|
477
755
|
if (!isReadyScenario(scenario))
|
|
478
756
|
return { ok: false, why: "scenario is not ready" };
|
|
479
|
-
|
|
757
|
+
// A chain with calls is run by hand the same way it is run by a match: one
|
|
758
|
+
// flat list, one frame per call (segmented.md R-CALL-37).
|
|
759
|
+
let flat;
|
|
760
|
+
try {
|
|
761
|
+
flat = flattenChain(scenario);
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
return { ok: false, why: `the chain could not be flattened: ${errText(err)}` };
|
|
765
|
+
}
|
|
766
|
+
if (flat.entries.length === 0)
|
|
767
|
+
return { ok: false, why: "the chain has no runnable step" };
|
|
768
|
+
const mode = modeFor(flat.entries.map((e) => e.step), wrapped, this.opts.allowServers);
|
|
480
769
|
if (mode === "none")
|
|
481
770
|
return { ok: false, why: "no step is executable" };
|
|
482
771
|
const state = {
|
|
@@ -497,10 +786,43 @@ export class ReplayController {
|
|
|
497
786
|
// an ad-hoc run anyway.
|
|
498
787
|
reported: true,
|
|
499
788
|
retired: false,
|
|
789
|
+
armedBy: "prompt",
|
|
790
|
+
// `bir replay` runs a scenario an operator named by id: never a segment
|
|
791
|
+
// hand-out, and nothing to share a baseline with.
|
|
792
|
+
kind: "scenario",
|
|
793
|
+
sharedWith: [],
|
|
500
794
|
};
|
|
501
|
-
|
|
502
|
-
|
|
795
|
+
// `bir replay` keeps its own contract on a missing target (segmented.md
|
|
796
|
+
// R-CALL-37): an operator asked for this run by id, so a sample stands in
|
|
797
|
+
// and it never declines. It says which, as the console's dry replay does
|
|
798
|
+
// (R-PARAM-7), so nobody reads the output as proof the values were derived.
|
|
799
|
+
const schema = scenario.paramsObject ?? {};
|
|
800
|
+
const params = this.startDerivation(scenario, prompt, state).then((r) => {
|
|
801
|
+
const out = { ...r.params };
|
|
802
|
+
const fromSamples = [];
|
|
803
|
+
for (const key of Object.keys(schema)) {
|
|
804
|
+
if (out[key] !== null && out[key] !== undefined)
|
|
805
|
+
continue;
|
|
806
|
+
out[key] = schema[key].sampleValue;
|
|
807
|
+
fromSamples.push(key);
|
|
808
|
+
}
|
|
809
|
+
if (fromSamples.length > 0) {
|
|
810
|
+
logLine("replay.derived", {
|
|
811
|
+
scenario: scenario.id,
|
|
812
|
+
why: `targets from samples: ${fromSamples.join(", ")}`,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
return out;
|
|
816
|
+
});
|
|
817
|
+
const plan = new ScenarioReplayPlan({
|
|
818
|
+
scenario,
|
|
819
|
+
params,
|
|
820
|
+
mode,
|
|
821
|
+
entries: flat.entries,
|
|
822
|
+
stopAt: flat.stopAt,
|
|
823
|
+
});
|
|
503
824
|
state.plan = plan;
|
|
825
|
+
state.flatStop = flat.stopReason;
|
|
504
826
|
if (this.opts.authUrl && this.opts.authToken) {
|
|
505
827
|
state.sourceRun = new SourceRunOutputs({
|
|
506
828
|
baseUrl: this.opts.authUrl,
|
|
@@ -524,11 +846,11 @@ export class ReplayController {
|
|
|
524
846
|
}
|
|
525
847
|
const trace = [];
|
|
526
848
|
try {
|
|
527
|
-
const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (
|
|
849
|
+
const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, (info) => {
|
|
528
850
|
this.observeStep(state)(info);
|
|
529
851
|
trace.push({
|
|
530
852
|
step: info.step.stepIndex,
|
|
531
|
-
tool: info.step.toolName,
|
|
853
|
+
tool: info.step.toolName ?? "",
|
|
532
854
|
input: info.input,
|
|
533
855
|
outcome: info.outcome,
|
|
534
856
|
ms: info.ms,
|
|
@@ -564,7 +886,7 @@ export class ReplayController {
|
|
|
564
886
|
* directive the moment the match lands; the first `PreToolUse` — or
|
|
565
887
|
* `/scenario/run`, which has no hook timeout at all — is where the wait lands.
|
|
566
888
|
*/
|
|
567
|
-
startDerivation(scenario, prompt, state) {
|
|
889
|
+
startDerivation(scenario, prompt, state, ctx = {}) {
|
|
568
890
|
const derive = this.opts.deriveImpl ?? deriveParameters;
|
|
569
891
|
return derive({
|
|
570
892
|
prompt,
|
|
@@ -572,6 +894,8 @@ export class ReplayController {
|
|
|
572
894
|
paramsObject: scenario.paramsObject,
|
|
573
895
|
apiKey: this.opts.apiKey ?? process.env.ANTHROPIC_API_KEY,
|
|
574
896
|
fetchImpl: this.opts.fetchImpl,
|
|
897
|
+
liveCall: ctx.liveCall,
|
|
898
|
+
recentResults: ctx.recentResults,
|
|
575
899
|
})
|
|
576
900
|
.then((r) => {
|
|
577
901
|
state.deriveCostUsd += r.costUsd;
|
|
@@ -580,20 +904,36 @@ export class ReplayController {
|
|
|
580
904
|
params: Object.keys(r.params).length,
|
|
581
905
|
costUsd: r.costUsd.toFixed(4),
|
|
582
906
|
source: r.derived ? "prompt" : "recorded samples",
|
|
907
|
+
missing: r.missing.length > 0 ? r.missing.join(",") : undefined,
|
|
908
|
+
sampled: r.sampled.length > 0 ? r.sampled.join(",") : undefined,
|
|
583
909
|
});
|
|
584
|
-
return r
|
|
910
|
+
return r;
|
|
585
911
|
})
|
|
586
912
|
.catch((err) => {
|
|
587
913
|
logLine("replay.derive_failed", {
|
|
588
914
|
scenario: scenario.id,
|
|
589
|
-
why: "
|
|
915
|
+
why: "settings fall back to their recorded samples; a target does not",
|
|
590
916
|
error: errText(err),
|
|
591
917
|
});
|
|
918
|
+
// A throw is a missing target when the scenario has one: gate 7 rethrows
|
|
919
|
+
// on `missing`, and this shape says so without the caller having to know
|
|
920
|
+
// whether the failure was a timeout or a 500. Settings still take their
|
|
921
|
+
// samples, so an all-settings scenario is unaffected by a broken key.
|
|
922
|
+
const schema = scenario.paramsObject ?? {};
|
|
592
923
|
const fallback = {};
|
|
593
|
-
|
|
594
|
-
|
|
924
|
+
const missing = [];
|
|
925
|
+
const sampled = [];
|
|
926
|
+
for (const [key, entry] of Object.entries(schema)) {
|
|
927
|
+
if (entry?.kind === "setting") {
|
|
928
|
+
fallback[key] = entry.sampleValue;
|
|
929
|
+
sampled.push(key);
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
fallback[key] = null;
|
|
933
|
+
missing.push(key);
|
|
934
|
+
}
|
|
595
935
|
}
|
|
596
|
-
return fallback;
|
|
936
|
+
return { params: fallback, costUsd: 0, derived: true, missing, sampled };
|
|
597
937
|
});
|
|
598
938
|
}
|
|
599
939
|
/**
|
|
@@ -616,7 +956,9 @@ export class ReplayController {
|
|
|
616
956
|
this.retire(state, undefined);
|
|
617
957
|
let composed;
|
|
618
958
|
try {
|
|
619
|
-
composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state)
|
|
959
|
+
composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state),
|
|
960
|
+
// A parked step ends the bundle short of the task (fallbk.md D3).
|
|
961
|
+
{ handover: plan.stopsEarly() });
|
|
620
962
|
}
|
|
621
963
|
catch (err) {
|
|
622
964
|
this.upgrade(state, "failed");
|
|
@@ -653,17 +995,110 @@ export class ReplayController {
|
|
|
653
995
|
// Nothing survived; a bundle of nothing is worse than no bundle.
|
|
654
996
|
return { kind: "abort" };
|
|
655
997
|
}
|
|
998
|
+
// The bundle stops in front of a parked step: say so ahead of it, and the
|
|
999
|
+
// model carries on from there. The outcome stays `diverged` — the model
|
|
1000
|
+
// went off-script first — so the report carries no fallback fields.
|
|
1001
|
+
let text = composed.text;
|
|
1002
|
+
if (plan.stopsEarly()) {
|
|
1003
|
+
const parked = plan.stopStep();
|
|
1004
|
+
const stopKind = state.flatStop ? "unusable_call" : "known_bad_step";
|
|
1005
|
+
state.handover = {
|
|
1006
|
+
kind: stopKind,
|
|
1007
|
+
stepIndex: parked.stepIndex,
|
|
1008
|
+
toolName: parked.toolName ?? undefined,
|
|
1009
|
+
note: this.noteFor(state, {
|
|
1010
|
+
kind: stopKind,
|
|
1011
|
+
step: parked,
|
|
1012
|
+
entry: plan.stopEntry(),
|
|
1013
|
+
position: plan.stepCount,
|
|
1014
|
+
delivery: "below",
|
|
1015
|
+
}),
|
|
1016
|
+
delivered: true,
|
|
1017
|
+
};
|
|
1018
|
+
text = `${state.handover.note}\n\n${composed.text}`;
|
|
1019
|
+
}
|
|
656
1020
|
// Bash-clean delivery: the model reaches for `Bash` when it cannot call a
|
|
657
1021
|
// scripted tool, and a genuine command output is trusted where a `deny`
|
|
658
1022
|
// reason is read as adversarial interception. Neutralise the delimiter first.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
1023
|
+
const action = this.deliverInstead(text, toolName);
|
|
1024
|
+
logDetail("replay.inject", { channel: action.kind, bytes: text.length });
|
|
1025
|
+
return action;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Hand the rest of the task to the model (fallbk.md D4/D5).
|
|
1029
|
+
*
|
|
1030
|
+
* Retires the plan and builds the note; delivery is the caller's, because the
|
|
1031
|
+
* channel depends on the hook that noticed. The outcome is `fell_back` when at
|
|
1032
|
+
* least one step did its work under the plan, and `failed` when none did — a
|
|
1033
|
+
* scenario that did nothing has nothing to book, and is a baseline sample.
|
|
1034
|
+
*/
|
|
1035
|
+
handOver(state, h) {
|
|
1036
|
+
if (state.handover)
|
|
1037
|
+
return;
|
|
1038
|
+
const ran = state.stepsPinned;
|
|
1039
|
+
this.retire(state, ran > 0 ? "fell_back" : "failed");
|
|
1040
|
+
state.handover = {
|
|
1041
|
+
kind: h.kind,
|
|
1042
|
+
// The *caller's* index: a fragment opens at the call row, not at a step
|
|
1043
|
+
// number the caller's own chain does not have (R-CALL-32, R-FALL-7).
|
|
1044
|
+
stepIndex: callerStepIndexOf(h.entry) ?? h.step?.stepIndex ?? h.position,
|
|
1045
|
+
toolName: h.step?.toolName ?? undefined,
|
|
1046
|
+
error: h.error,
|
|
1047
|
+
note: this.noteFor(state, h),
|
|
1048
|
+
delivered: false,
|
|
1049
|
+
};
|
|
1050
|
+
logLine("replay.handover", {
|
|
1051
|
+
scenario: state.scenarioId ?? undefined,
|
|
1052
|
+
kind: h.kind,
|
|
1053
|
+
step: `${h.position}/${state.plan?.totalSteps ?? state.stepsPlanned}`,
|
|
1054
|
+
tool: h.step?.toolName ?? undefined,
|
|
1055
|
+
// The scenario that owns the stopped step, when it is not the matched one.
|
|
1056
|
+
owner: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
|
|
1057
|
+
unusable: state.flatStop?.reason,
|
|
1058
|
+
callIntent: state.flatStop?.callIntent ?? undefined,
|
|
1059
|
+
segment: state.flatStop?.segmentId ?? undefined,
|
|
1060
|
+
ran,
|
|
1061
|
+
outcome: state.outcome,
|
|
1062
|
+
why: h.kind === "unusable_call"
|
|
1063
|
+
? "a sub-task of this chain cannot run — the agent does that part itself"
|
|
1064
|
+
: h.kind === "known_bad_step"
|
|
1065
|
+
? "the next step has failed too often — the agent continues from here"
|
|
1066
|
+
: "a step broke — the agent continues from here",
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
noteFor(state, h) {
|
|
1070
|
+
const plan = state.plan;
|
|
1071
|
+
const executed = h.ranThisStep ? h.position + 1 : h.position;
|
|
1072
|
+
let params;
|
|
1073
|
+
try {
|
|
1074
|
+
params = plan?.parameters;
|
|
1075
|
+
}
|
|
1076
|
+
catch {
|
|
1077
|
+
params = undefined;
|
|
664
1078
|
}
|
|
665
|
-
|
|
666
|
-
|
|
1079
|
+
return buildHandoverNote({
|
|
1080
|
+
intent: plan?.intent ?? "",
|
|
1081
|
+
kind: h.kind,
|
|
1082
|
+
stepIndex: h.position,
|
|
1083
|
+
stepToolName: h.step?.toolName ?? undefined,
|
|
1084
|
+
failureCount: h.step?.failureCount,
|
|
1085
|
+
error: h.error,
|
|
1086
|
+
unusable: state.flatStop
|
|
1087
|
+
? {
|
|
1088
|
+
reason: state.flatStop.reason,
|
|
1089
|
+
callIntent: state.flatStop.callIntent,
|
|
1090
|
+
segmentId: state.flatStop.segmentId,
|
|
1091
|
+
ownerScenarioId: state.flatStop.scenarioId,
|
|
1092
|
+
}
|
|
1093
|
+
: undefined,
|
|
1094
|
+
ownerScenarioId: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
|
|
1095
|
+
inSubTask: h.entry && h.entry.depth > 0 ? (h.entry.frame.callIntent ?? null) : null,
|
|
1096
|
+
executed: Math.max(0, Math.min(executed, plan?.totalSteps ?? executed)),
|
|
1097
|
+
totalSteps: plan?.totalSteps ?? state.stepsPlanned,
|
|
1098
|
+
remainingTools: plan ? plan.toolsFrom(h.ranThisStep ? h.position + 1 : h.position) : [],
|
|
1099
|
+
params,
|
|
1100
|
+
delivery: h.delivery,
|
|
1101
|
+
});
|
|
667
1102
|
}
|
|
668
1103
|
/**
|
|
669
1104
|
* The executor handed to a plan: dispatch a step to the proxy that owns its
|
|
@@ -675,9 +1110,10 @@ export class ReplayController {
|
|
|
675
1110
|
*/
|
|
676
1111
|
executeStep() {
|
|
677
1112
|
return async (step, input) => {
|
|
678
|
-
const mcp = parseQualifiedName(step.toolName);
|
|
679
|
-
if (!mcp)
|
|
1113
|
+
const mcp = parseQualifiedName(step.toolName ?? "");
|
|
1114
|
+
if (!mcp) {
|
|
680
1115
|
throw new Error(`${step.toolName} is not an MCP tool — it can only run in the session`);
|
|
1116
|
+
}
|
|
681
1117
|
const result = await this.work.call(mcp.serverName, mcp.toolName, input, this.budgets.stepMs);
|
|
682
1118
|
// Serialize exactly as the proxy records it, or `toolOutputLogic` — which
|
|
683
1119
|
// was authored against that shape — silently derives nothing (§7.2).
|
|
@@ -697,7 +1133,7 @@ export class ReplayController {
|
|
|
697
1133
|
* hook's differently-shaped view would derive nothing at all.
|
|
698
1134
|
*/
|
|
699
1135
|
reachFor(state, step) {
|
|
700
|
-
return reachOf(step.toolName, state.wrapped);
|
|
1136
|
+
return reachOf(step.toolName ?? "", state.wrapped);
|
|
701
1137
|
}
|
|
702
1138
|
/**
|
|
703
1139
|
* Log a step and remember its verdict, in that order.
|
|
@@ -715,7 +1151,13 @@ export class ReplayController {
|
|
|
715
1151
|
/** Upsert one step's verdict. Later news about a step replaces earlier news. */
|
|
716
1152
|
recordStep(state, info) {
|
|
717
1153
|
const status = info.outcome === "executed" ? "ok" : info.outcome;
|
|
718
|
-
|
|
1154
|
+
// Keyed by the *flat* position, not by the step's own index: two scenarios
|
|
1155
|
+
// in one plan both have a step 0, and one would otherwise overwrite the
|
|
1156
|
+
// other's verdict (segmented.md R-CALL-13).
|
|
1157
|
+
const key = info.entry
|
|
1158
|
+
? `${info.entry.scenarioId}#${info.entry.stepIndex}`
|
|
1159
|
+
: `#${info.step.stepIndex}`;
|
|
1160
|
+
if (state.stepResults.size >= MAX_STEP_RESULTS && !state.stepResults.has(key)) {
|
|
719
1161
|
return;
|
|
720
1162
|
}
|
|
721
1163
|
const error = info.error
|
|
@@ -723,29 +1165,35 @@ export class ReplayController {
|
|
|
723
1165
|
? `${info.error.slice(0, MAX_STEP_ERROR_CHARS)}…`
|
|
724
1166
|
: info.error
|
|
725
1167
|
: undefined;
|
|
726
|
-
state.stepResults.set(
|
|
1168
|
+
state.stepResults.set(key, {
|
|
727
1169
|
stepIndex: info.step.stepIndex,
|
|
728
|
-
toolName: info.step.toolName,
|
|
1170
|
+
toolName: info.step.toolName ?? "",
|
|
729
1171
|
status,
|
|
730
1172
|
// A stage is only meaningful when something went wrong. `skipped` broke at
|
|
731
1173
|
// the call itself — its tool could not run here.
|
|
732
1174
|
stage: info.stage ?? (status === "skipped" ? "tool_call" : undefined),
|
|
733
1175
|
error,
|
|
734
1176
|
durationMs: info.ms,
|
|
1177
|
+
// A verdict about a called segment's step is counted on that segment, at
|
|
1178
|
+
// the revision it was served (R-CALL-13).
|
|
1179
|
+
scenarioId: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
|
|
1180
|
+
chainRevision: info.entry && info.entry.depth > 0 ? info.entry.frame.chainRevision : undefined,
|
|
735
1181
|
});
|
|
736
1182
|
}
|
|
737
1183
|
/** The step verdicts, in chain order, for the execution report. */
|
|
738
1184
|
stepResultsOf(state) {
|
|
739
1185
|
if (state.stepResults.size === 0)
|
|
740
1186
|
return undefined;
|
|
741
|
-
return [...state.stepResults.values()]
|
|
1187
|
+
return [...state.stepResults.values()];
|
|
742
1188
|
}
|
|
743
1189
|
logStep(info) {
|
|
744
|
-
const mcp = parseQualifiedName(info.step.toolName);
|
|
1190
|
+
const mcp = parseQualifiedName(info.step.toolName ?? "");
|
|
745
1191
|
if (info.outcome === "failed") {
|
|
746
1192
|
logLine("replay.step_failed", {
|
|
747
1193
|
n: info.step.stepIndex,
|
|
748
|
-
|
|
1194
|
+
scenario: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
|
|
1195
|
+
frame: info.entry?.frame.id,
|
|
1196
|
+
tool: info.step.toolName ?? undefined,
|
|
749
1197
|
stage: info.stage,
|
|
750
1198
|
why: info.stage === "tool_call"
|
|
751
1199
|
? "its tool ran and reported an error — the step's work did not happen"
|
|
@@ -786,9 +1234,22 @@ export class ReplayController {
|
|
|
786
1234
|
if (OUTCOME_RANK[outcome] > OUTCOME_RANK[state.outcome])
|
|
787
1235
|
state.outcome = outcome;
|
|
788
1236
|
}
|
|
789
|
-
retire(state, outcome) {
|
|
1237
|
+
retire(state, outcome, completing = false) {
|
|
790
1238
|
if (outcome)
|
|
791
1239
|
this.upgrade(state, outcome);
|
|
1240
|
+
// Only a plan that *finished* stops the clock (segmented.md R-MONEY-3). A
|
|
1241
|
+
// hand-over or a divergence takes no mark: the agent's next tokens are the
|
|
1242
|
+
// consequence of this plan stopping, and belong to its bill.
|
|
1243
|
+
if (completing) {
|
|
1244
|
+
state.retiredAt = Date.now();
|
|
1245
|
+
try {
|
|
1246
|
+
state.retiredMark = state.markUsage?.();
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
// An unreadable transcript leaves the mark undefined, and the window
|
|
1250
|
+
// falls back to the next arm or the end of the turn.
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
792
1253
|
state.retired = true;
|
|
793
1254
|
state.pinned.clear();
|
|
794
1255
|
}
|
|
@@ -804,4 +1265,45 @@ export class ReplayController {
|
|
|
804
1265
|
});
|
|
805
1266
|
}
|
|
806
1267
|
}
|
|
1268
|
+
/** The first target of a schema, for the sentence a decline logs. */
|
|
1269
|
+
function firstTargetKey(schema) {
|
|
1270
|
+
if (!schema)
|
|
1271
|
+
return undefined;
|
|
1272
|
+
return Object.keys(schema).find((k) => schema[k]?.kind !== "setting");
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Position of the first step whose failure count is above `maxStepFailures`
|
|
1276
|
+
* (fallbk.md D3), or undefined when none is — including when the service sent
|
|
1277
|
+
* no policy, which an older service never does.
|
|
1278
|
+
*/
|
|
1279
|
+
/**
|
|
1280
|
+
* The index a hand-over reports, in the *caller's* chain (R-CALL-32).
|
|
1281
|
+
*
|
|
1282
|
+
* A step inside a called segment has no position in the caller's own chain; the
|
|
1283
|
+
* call row that opened its frame does. Reporting the segment's own index would
|
|
1284
|
+
* line the execution up against a step the caller's card does not have.
|
|
1285
|
+
*/
|
|
1286
|
+
function callerStepIndexOf(entry) {
|
|
1287
|
+
if (!entry || entry.depth === 0)
|
|
1288
|
+
return entry?.stepIndex;
|
|
1289
|
+
let frame = entry.frame;
|
|
1290
|
+
while (frame.parent && frame.depth > 1)
|
|
1291
|
+
frame = frame.parent;
|
|
1292
|
+
return frame.callerStepIndex;
|
|
1293
|
+
}
|
|
1294
|
+
/** The calls in a served chain, for the `plan.armed` line (R-CALL-32). */
|
|
1295
|
+
function callLines(scenario) {
|
|
1296
|
+
const calls = (scenario.steps ?? []).filter((s) => s.kind === "segment");
|
|
1297
|
+
if (calls.length === 0)
|
|
1298
|
+
return undefined;
|
|
1299
|
+
return calls
|
|
1300
|
+
.map((s) => `${s.segmentId ?? "-"}:${s.unusable ?? s.resolution?.state ?? "resolved"}`)
|
|
1301
|
+
.join(",");
|
|
1302
|
+
}
|
|
1303
|
+
export function knownBadStepIndex(steps, maxStepFailures) {
|
|
1304
|
+
if (typeof maxStepFailures !== "number" || !Number.isFinite(maxStepFailures))
|
|
1305
|
+
return undefined;
|
|
1306
|
+
const i = steps.findIndex((s) => (s.failureCount ?? 0) > maxStepFailures);
|
|
1307
|
+
return i < 0 ? undefined : i;
|
|
1308
|
+
}
|
|
807
1309
|
//# sourceMappingURL=controller.js.map
|