@basein/runner 0.2.0 → 0.2.2
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 +4 -2
- package/dist/auth/client.d.ts +12 -0
- package/dist/auth/client.js +41 -0
- package/dist/bin/bir-hooks.d.ts +18 -3
- package/dist/bin/bir-hooks.js +70 -4
- package/dist/bin/bir.js +56 -3
- package/dist/control/server.d.ts +84 -1
- package/dist/control/server.js +555 -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 +179 -6
- package/dist/replay/controller.js +584 -55
- package/dist/replay/coverage.js +2 -2
- package/dist/replay/derive.d.ts +54 -7
- package/dist/replay/derive.js +174 -17
- 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/docs/calculatedReplay.md +16 -8
- package/docs/calculatedReplayGuide.md +4 -4
- 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,159 @@ 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");
|
|
132
184
|
}
|
|
133
|
-
//
|
|
134
|
-
|
|
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");
|
|
201
|
+
}
|
|
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
|
+
// Nobody read the turn — no key here and no service to ask, or the
|
|
250
|
+
// service declined. Settings can still take their recorded values, but a
|
|
251
|
+
// target is a guess nobody is allowed to make (R-PARAM-5). The reason
|
|
252
|
+
// travels into the line, because "it did not run" without one is how
|
|
253
|
+
// this stayed invisible before.
|
|
254
|
+
const first = firstTargetKey(scenario.paramsObject);
|
|
255
|
+
return decline(`${result.reason ?? "no_derive_key"}: target ${first ?? "(unknown)"}`, "no_derive_key");
|
|
256
|
+
}
|
|
257
|
+
if (result.missing.length > 0) {
|
|
258
|
+
return decline(`target ${result.missing[0]} not found`, "missing_target");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// A plan armed by intent runs inside a task the agent is already doing, so
|
|
262
|
+
// its directive and its bundles say "continue", never "answer the user"
|
|
263
|
+
// (segmented.md R-OUT-13).
|
|
264
|
+
state.plan = new ScenarioReplayPlan({
|
|
265
|
+
scenario,
|
|
266
|
+
params,
|
|
267
|
+
mode,
|
|
268
|
+
stopAt,
|
|
269
|
+
entries,
|
|
270
|
+
subTask: armedBy === "intent",
|
|
271
|
+
});
|
|
139
272
|
state.mode = mode;
|
|
140
|
-
state.stepsPlanned =
|
|
273
|
+
state.stepsPlanned = entries.length;
|
|
274
|
+
// Why the plan stops, when a call is what stops it: the note names the
|
|
275
|
+
// sub-task rather than a tool (R-CALL-36).
|
|
276
|
+
state.flatStop =
|
|
277
|
+
typeof stopAt === "number" && flat.stopAt !== undefined && stopAt <= flat.stopAt
|
|
278
|
+
? flat.stopReason
|
|
279
|
+
: undefined;
|
|
141
280
|
if (this.opts.authUrl && this.opts.authToken) {
|
|
142
281
|
state.sourceRun = new SourceRunOutputs({
|
|
143
282
|
baseUrl: this.opts.authUrl,
|
|
@@ -151,16 +290,54 @@ export class ReplayController {
|
|
|
151
290
|
run: match.runId,
|
|
152
291
|
scenario: scenario.id,
|
|
153
292
|
mode,
|
|
154
|
-
steps:
|
|
293
|
+
steps: entries.length,
|
|
294
|
+
stopsAt: stopAt,
|
|
295
|
+
by: armedBy === "intent" ? "intent" : undefined,
|
|
296
|
+
kind: state.kind,
|
|
297
|
+
key: state.key,
|
|
298
|
+
verified: state.verified?.verdict,
|
|
155
299
|
similarity: match.similarity.toFixed(3),
|
|
156
|
-
|
|
157
|
-
|
|
300
|
+
// What this chain calls, and what answers each call right now (R-CALL-32).
|
|
301
|
+
calls: callLines(scenario),
|
|
302
|
+
unusable: flat.stopReason?.reason,
|
|
303
|
+
coverage: coverageOf(entries.map((e) => e.step), wrapped, this.opts.allowServers).join(","),
|
|
304
|
+
tools: entries.map((e) => e.step.toolName ?? "").join(","),
|
|
158
305
|
});
|
|
159
306
|
return state;
|
|
160
307
|
}
|
|
161
308
|
/** The directive to inject via `additionalContext`, or undefined when declined. */
|
|
162
309
|
directiveFor(state) {
|
|
163
|
-
return state.plan?.steeringDirective(DIRECT_TOOL_NAME
|
|
310
|
+
return state.plan?.steeringDirective(DIRECT_TOOL_NAME, {
|
|
311
|
+
subTask: state.armedBy === "intent",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* A hand-over note not yet delivered, claimed for delivery (fallbk.md D4).
|
|
316
|
+
* Returns it once and marks it delivered; undefined when there is none.
|
|
317
|
+
*/
|
|
318
|
+
takeNote(state) {
|
|
319
|
+
const h = state?.handover;
|
|
320
|
+
if (!h || h.delivered)
|
|
321
|
+
return undefined;
|
|
322
|
+
h.delivered = true;
|
|
323
|
+
logDetail("replay.handover_delivered", {
|
|
324
|
+
scenario: state.scenarioId ?? undefined,
|
|
325
|
+
bytes: h.note.length,
|
|
326
|
+
});
|
|
327
|
+
return h.note;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Deliver text to the model in place of the tool call it was about to make:
|
|
331
|
+
* as genuine command output for `Bash`, as the denial reason otherwise — the
|
|
332
|
+
* two channels a divergence bundle already uses.
|
|
333
|
+
*/
|
|
334
|
+
deliverInstead(text, toolName) {
|
|
335
|
+
if (toolName === "Bash") {
|
|
336
|
+
const delimiter = "BIR_EOF";
|
|
337
|
+
const safe = text.split(delimiter).join("BIR_EOF_");
|
|
338
|
+
return { kind: "bash", command: `cat <<'${delimiter}'\n${safe}\n${delimiter}` };
|
|
339
|
+
}
|
|
340
|
+
return { kind: "deny", reason: text };
|
|
164
341
|
}
|
|
165
342
|
/**
|
|
166
343
|
* `PreToolUse`, while a plan is active.
|
|
@@ -210,6 +387,11 @@ export class ReplayController {
|
|
|
210
387
|
// step and will throw on the same logic — and its own report would then
|
|
211
388
|
// be the only trace, attributed to a re-execution rather than to the
|
|
212
389
|
// steered call that actually hit it first.
|
|
390
|
+
//
|
|
391
|
+
// Not a divergence any more (fallbk.md): recovering by executing the
|
|
392
|
+
// rest would start at this very step and throw on the same logic. The
|
|
393
|
+
// step is recorded as failed, and the task is handed to the model with
|
|
394
|
+
// a note, delivered in place of this call.
|
|
213
395
|
const step = plan.currentStep();
|
|
214
396
|
if (step) {
|
|
215
397
|
this.recordStep(state, {
|
|
@@ -218,8 +400,18 @@ export class ReplayController {
|
|
|
218
400
|
stage: "tool_input_logic",
|
|
219
401
|
error: errText(err),
|
|
220
402
|
});
|
|
403
|
+
this.handOver(state, {
|
|
404
|
+
kind: "step_failed",
|
|
405
|
+
step,
|
|
406
|
+
position: plan.currentStepIndex,
|
|
407
|
+
error: errText(err),
|
|
408
|
+
delivery: "above",
|
|
409
|
+
});
|
|
410
|
+
const note = this.takeNote(state);
|
|
411
|
+
if (note)
|
|
412
|
+
return this.deliverInstead(note, toolName);
|
|
221
413
|
}
|
|
222
|
-
return
|
|
414
|
+
return { kind: "abort" };
|
|
223
415
|
}
|
|
224
416
|
}
|
|
225
417
|
// HOUSEKEEPING IS NOT DIVERGENCE. See `record/housekeeping.ts`.
|
|
@@ -270,8 +462,22 @@ export class ReplayController {
|
|
|
270
462
|
error: errText(err),
|
|
271
463
|
ms: Date.now() - pin.pinnedAt,
|
|
272
464
|
});
|
|
465
|
+
// The tool ran, so the step's call and result are in front of the model;
|
|
466
|
+
// what failed is threading its output into the plan. The rest is the
|
|
467
|
+
// model's (fallbk.md). `stepsPinned` counts it: its work happened.
|
|
468
|
+
state.stepsPinned += 1;
|
|
469
|
+
this.handOver(state, {
|
|
470
|
+
kind: "step_failed",
|
|
471
|
+
step,
|
|
472
|
+
position: pin.stepIndex,
|
|
473
|
+
error: errText(err),
|
|
474
|
+
delivery: "above",
|
|
475
|
+
ranThisStep: true,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
this.retire(state, "failed");
|
|
273
480
|
}
|
|
274
|
-
this.retire(state, "failed");
|
|
275
481
|
return false;
|
|
276
482
|
}
|
|
277
483
|
// One more step ran under the plan. Counted here, as each pin threads, and
|
|
@@ -293,7 +499,15 @@ export class ReplayController {
|
|
|
293
499
|
};
|
|
294
500
|
this.logStep(info);
|
|
295
501
|
this.recordStep(state, info);
|
|
296
|
-
|
|
502
|
+
// Uncount it: it was counted above as threaded, but its work did not happen.
|
|
503
|
+
state.stepsPinned -= 1;
|
|
504
|
+
this.handOver(state, {
|
|
505
|
+
kind: "step_failed",
|
|
506
|
+
step,
|
|
507
|
+
position: pin.stepIndex,
|
|
508
|
+
error: toolError,
|
|
509
|
+
delivery: "above",
|
|
510
|
+
});
|
|
297
511
|
return false;
|
|
298
512
|
}
|
|
299
513
|
if (step) {
|
|
@@ -310,9 +524,25 @@ export class ReplayController {
|
|
|
310
524
|
emitted: derivedKeys.join(",") || undefined,
|
|
311
525
|
done,
|
|
312
526
|
});
|
|
527
|
+
if (done && plan.stopsEarly()) {
|
|
528
|
+
// Every planned step ran, and the next one is parked (fallbk.md D3) — or
|
|
529
|
+
// is a call the service could not answer (segmented.md R-CALL-29).
|
|
530
|
+
const parked = plan.stopStep();
|
|
531
|
+
this.handOver(state, {
|
|
532
|
+
kind: state.flatStop ? "unusable_call" : "known_bad_step",
|
|
533
|
+
step: parked,
|
|
534
|
+
entry: plan.stopEntry(),
|
|
535
|
+
position: plan.stepCount,
|
|
536
|
+
delivery: "above",
|
|
537
|
+
});
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
313
540
|
if (done) {
|
|
314
541
|
this.upgrade(state, "steered_full");
|
|
315
|
-
|
|
542
|
+
// A completing retire: the last step threaded and the plan's work is over
|
|
543
|
+
// (segmented.md R-MONEY-3). Everything the agent spends after this is the
|
|
544
|
+
// agent's own, not this plan's.
|
|
545
|
+
this.retire(state, undefined, true);
|
|
316
546
|
logLine("replay.done", {
|
|
317
547
|
scenario: state.scenarioId ?? undefined,
|
|
318
548
|
mode: state.mode,
|
|
@@ -354,7 +584,7 @@ export class ReplayController {
|
|
|
354
584
|
const deadline = Date.now() + this.budgets.planMs;
|
|
355
585
|
let result;
|
|
356
586
|
try {
|
|
357
|
-
result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (
|
|
587
|
+
result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, this.observeStep(state), deadline, MAX_REPLAY_REASON, { stopOnFailure: true });
|
|
358
588
|
}
|
|
359
589
|
catch (err) {
|
|
360
590
|
// The scenario's own logic failed. Retire and let the model do the work.
|
|
@@ -366,6 +596,37 @@ export class ReplayController {
|
|
|
366
596
|
this.retire(state, "failed");
|
|
367
597
|
return { ok: false, why: "not_ready" };
|
|
368
598
|
}
|
|
599
|
+
// The plan stopped part-way (fallbk.md §Runner 3): a step broke, or the
|
|
600
|
+
// next one is parked. The steps that ran are real, so their bundle goes to
|
|
601
|
+
// the model — behind a note saying the rest of the task is its own.
|
|
602
|
+
if (result.stopped) {
|
|
603
|
+
const stop = result.stopped;
|
|
604
|
+
const ranOk = result.executed + result.recorded - result.errored;
|
|
605
|
+
state.stepsPinned += Math.max(0, ranOk);
|
|
606
|
+
const position = stop.flatIndex ?? plan.stepCount;
|
|
607
|
+
const step = plan.allSteps()[position];
|
|
608
|
+
this.handOver(state, {
|
|
609
|
+
kind: stop.kind,
|
|
610
|
+
step,
|
|
611
|
+
entry: plan.allEntries()[position],
|
|
612
|
+
position,
|
|
613
|
+
error: stop.error,
|
|
614
|
+
delivery: "below",
|
|
615
|
+
});
|
|
616
|
+
const note = this.takeNote(state) ?? state.handover?.note ?? "";
|
|
617
|
+
if (result.executed + result.recorded === 0) {
|
|
618
|
+
// Nothing ran: there is no bundle, only the task. The tool answers with
|
|
619
|
+
// an error that says so, which is what a model reacts to best.
|
|
620
|
+
return { ok: false, why: note };
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
ok: true,
|
|
624
|
+
text: `${note}\n\n${result.text}`,
|
|
625
|
+
responseModel: {},
|
|
626
|
+
steps: result.executed + result.recorded,
|
|
627
|
+
partial: result.partial,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
369
630
|
state.stepsPinned += result.executed + result.recorded;
|
|
370
631
|
let responseModel = {};
|
|
371
632
|
try {
|
|
@@ -382,7 +643,10 @@ export class ReplayController {
|
|
|
382
643
|
: result.partial || result.skipped > 0
|
|
383
644
|
? "diverged"
|
|
384
645
|
: "steered_full");
|
|
385
|
-
|
|
646
|
+
// `runToCompletion` returned without a stop: the plan ran to its end, so
|
|
647
|
+
// this is a completing retire (R-MONEY-3). The stopped path handed over
|
|
648
|
+
// above and took no mark.
|
|
649
|
+
this.retire(state, undefined, true);
|
|
386
650
|
logLine("replay.done", {
|
|
387
651
|
scenario: state.scenarioId ?? undefined,
|
|
388
652
|
mode: state.mode,
|
|
@@ -445,6 +709,23 @@ export class ReplayController {
|
|
|
445
709
|
errorStage: blame?.stage,
|
|
446
710
|
errorStepIndex: blame?.stepIndex,
|
|
447
711
|
errorToolName: blame?.toolName,
|
|
712
|
+
// Where the plan handed the task to the model (fallbk.md). Only on
|
|
713
|
+
// `fell_back`: a hand-over before any step ran is `failed`, and a parked
|
|
714
|
+
// step found after a divergence is still a divergence.
|
|
715
|
+
fallbackStepIndex: state.outcome === "fell_back" ? state.handover?.stepIndex : undefined,
|
|
716
|
+
fallbackKind: state.outcome === "fell_back" ? state.handover?.kind : undefined,
|
|
717
|
+
// Segments of this plan's recording that armed later in the turn
|
|
718
|
+
// (segmented.md R-MONEY-4): their share comes off this plan's baseline.
|
|
719
|
+
sharedWith: state.sharedWith.length > 0 ? [...state.sharedWith] : undefined,
|
|
720
|
+
// A turn in which another plan ran is not a measurement of what this task
|
|
721
|
+
// costs unaided, so it is no baseline sample (segmented.md R-MONEY-5).
|
|
722
|
+
// This covers two plans that both armed, and — the case that was silently
|
|
723
|
+
// wrong before — a declined prompt scenario followed by an intent-armed
|
|
724
|
+
// plan, whose "baseline" was really the turn cut short at the second arm.
|
|
725
|
+
// A lone plan, armed or declined, sends nothing: the default is eligible.
|
|
726
|
+
baselineEligible: d.siblings?.some((s) => s !== state && s.plan !== undefined)
|
|
727
|
+
? false
|
|
728
|
+
: undefined,
|
|
448
729
|
};
|
|
449
730
|
}
|
|
450
731
|
/**
|
|
@@ -476,7 +757,18 @@ export class ReplayController {
|
|
|
476
757
|
async runAdHoc(scenario, prompt, wrapped) {
|
|
477
758
|
if (!isReadyScenario(scenario))
|
|
478
759
|
return { ok: false, why: "scenario is not ready" };
|
|
479
|
-
|
|
760
|
+
// A chain with calls is run by hand the same way it is run by a match: one
|
|
761
|
+
// flat list, one frame per call (segmented.md R-CALL-37).
|
|
762
|
+
let flat;
|
|
763
|
+
try {
|
|
764
|
+
flat = flattenChain(scenario);
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
return { ok: false, why: `the chain could not be flattened: ${errText(err)}` };
|
|
768
|
+
}
|
|
769
|
+
if (flat.entries.length === 0)
|
|
770
|
+
return { ok: false, why: "the chain has no runnable step" };
|
|
771
|
+
const mode = modeFor(flat.entries.map((e) => e.step), wrapped, this.opts.allowServers);
|
|
480
772
|
if (mode === "none")
|
|
481
773
|
return { ok: false, why: "no step is executable" };
|
|
482
774
|
const state = {
|
|
@@ -497,10 +789,43 @@ export class ReplayController {
|
|
|
497
789
|
// an ad-hoc run anyway.
|
|
498
790
|
reported: true,
|
|
499
791
|
retired: false,
|
|
792
|
+
armedBy: "prompt",
|
|
793
|
+
// `bir replay` runs a scenario an operator named by id: never a segment
|
|
794
|
+
// hand-out, and nothing to share a baseline with.
|
|
795
|
+
kind: "scenario",
|
|
796
|
+
sharedWith: [],
|
|
500
797
|
};
|
|
501
|
-
|
|
502
|
-
|
|
798
|
+
// `bir replay` keeps its own contract on a missing target (segmented.md
|
|
799
|
+
// R-CALL-37): an operator asked for this run by id, so a sample stands in
|
|
800
|
+
// and it never declines. It says which, as the console's dry replay does
|
|
801
|
+
// (R-PARAM-7), so nobody reads the output as proof the values were derived.
|
|
802
|
+
const schema = scenario.paramsObject ?? {};
|
|
803
|
+
const params = this.startDerivation(scenario, prompt, state).then((r) => {
|
|
804
|
+
const out = { ...r.params };
|
|
805
|
+
const fromSamples = [];
|
|
806
|
+
for (const key of Object.keys(schema)) {
|
|
807
|
+
if (out[key] !== null && out[key] !== undefined)
|
|
808
|
+
continue;
|
|
809
|
+
out[key] = schema[key].sampleValue;
|
|
810
|
+
fromSamples.push(key);
|
|
811
|
+
}
|
|
812
|
+
if (fromSamples.length > 0) {
|
|
813
|
+
logLine("replay.derived", {
|
|
814
|
+
scenario: scenario.id,
|
|
815
|
+
why: `targets from samples: ${fromSamples.join(", ")}`,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
return out;
|
|
819
|
+
});
|
|
820
|
+
const plan = new ScenarioReplayPlan({
|
|
821
|
+
scenario,
|
|
822
|
+
params,
|
|
823
|
+
mode,
|
|
824
|
+
entries: flat.entries,
|
|
825
|
+
stopAt: flat.stopAt,
|
|
826
|
+
});
|
|
503
827
|
state.plan = plan;
|
|
828
|
+
state.flatStop = flat.stopReason;
|
|
504
829
|
if (this.opts.authUrl && this.opts.authToken) {
|
|
505
830
|
state.sourceRun = new SourceRunOutputs({
|
|
506
831
|
baseUrl: this.opts.authUrl,
|
|
@@ -524,11 +849,11 @@ export class ReplayController {
|
|
|
524
849
|
}
|
|
525
850
|
const trace = [];
|
|
526
851
|
try {
|
|
527
|
-
const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (
|
|
852
|
+
const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, (info) => {
|
|
528
853
|
this.observeStep(state)(info);
|
|
529
854
|
trace.push({
|
|
530
855
|
step: info.step.stepIndex,
|
|
531
|
-
tool: info.step.toolName,
|
|
856
|
+
tool: info.step.toolName ?? "",
|
|
532
857
|
input: info.input,
|
|
533
858
|
outcome: info.outcome,
|
|
534
859
|
ms: info.ms,
|
|
@@ -564,14 +889,35 @@ export class ReplayController {
|
|
|
564
889
|
* directive the moment the match lands; the first `PreToolUse` — or
|
|
565
890
|
* `/scenario/run`, which has no hook timeout at all — is where the wait lands.
|
|
566
891
|
*/
|
|
567
|
-
|
|
892
|
+
/**
|
|
893
|
+
* Where to ask the service to read this turn (segmented.md R-PARAM-5).
|
|
894
|
+
*
|
|
895
|
+
* Undefined when there is nobody to ask — an unauthenticated session, or one
|
|
896
|
+
* whose token has gone. `derive` then falls back to the recorded samples, and
|
|
897
|
+
* a scenario with a target declines, exactly as a keyless runner always did.
|
|
898
|
+
*
|
|
899
|
+
* The token is read here rather than captured, because the recorder refreshes
|
|
900
|
+
* it as a session outlives it.
|
|
901
|
+
*/
|
|
902
|
+
deriveService(scenarioId) {
|
|
903
|
+
const token = this.opts.authToken?.();
|
|
904
|
+
if (!this.opts.authUrl || !token)
|
|
905
|
+
return undefined;
|
|
906
|
+
return { baseUrl: this.opts.authUrl, token, scenarioId };
|
|
907
|
+
}
|
|
908
|
+
startDerivation(scenario, prompt, state, ctx = {}) {
|
|
568
909
|
const derive = this.opts.deriveImpl ?? deriveParameters;
|
|
569
910
|
return derive({
|
|
570
911
|
prompt,
|
|
571
912
|
intent: scenario.intent ?? "",
|
|
572
913
|
paramsObject: scenario.paramsObject,
|
|
573
914
|
apiKey: this.opts.apiKey ?? process.env.ANTHROPIC_API_KEY,
|
|
915
|
+
// Where to ask when there is no key here, which is the ordinary case
|
|
916
|
+
// (segmented.md R-PARAM-5).
|
|
917
|
+
service: this.deriveService(scenario.id),
|
|
574
918
|
fetchImpl: this.opts.fetchImpl,
|
|
919
|
+
liveCall: ctx.liveCall,
|
|
920
|
+
recentResults: ctx.recentResults,
|
|
575
921
|
})
|
|
576
922
|
.then((r) => {
|
|
577
923
|
state.deriveCostUsd += r.costUsd;
|
|
@@ -579,21 +925,42 @@ export class ReplayController {
|
|
|
579
925
|
scenario: scenario.id,
|
|
580
926
|
params: Object.keys(r.params).length,
|
|
581
927
|
costUsd: r.costUsd.toFixed(4),
|
|
582
|
-
source: r.derived
|
|
928
|
+
source: r.derived
|
|
929
|
+
? r.via === "service"
|
|
930
|
+
? "the service"
|
|
931
|
+
: "prompt"
|
|
932
|
+
: "recorded samples",
|
|
933
|
+
why: r.derived ? undefined : r.reason,
|
|
934
|
+
missing: r.missing.length > 0 ? r.missing.join(",") : undefined,
|
|
935
|
+
sampled: r.sampled.length > 0 ? r.sampled.join(",") : undefined,
|
|
583
936
|
});
|
|
584
|
-
return r
|
|
937
|
+
return r;
|
|
585
938
|
})
|
|
586
939
|
.catch((err) => {
|
|
587
940
|
logLine("replay.derive_failed", {
|
|
588
941
|
scenario: scenario.id,
|
|
589
|
-
why: "
|
|
942
|
+
why: "settings fall back to their recorded samples; a target does not",
|
|
590
943
|
error: errText(err),
|
|
591
944
|
});
|
|
945
|
+
// A throw is a missing target when the scenario has one: gate 7 rethrows
|
|
946
|
+
// on `missing`, and this shape says so without the caller having to know
|
|
947
|
+
// whether the failure was a timeout or a 500. Settings still take their
|
|
948
|
+
// samples, so an all-settings scenario is unaffected by a broken key.
|
|
949
|
+
const schema = scenario.paramsObject ?? {};
|
|
592
950
|
const fallback = {};
|
|
593
|
-
|
|
594
|
-
|
|
951
|
+
const missing = [];
|
|
952
|
+
const sampled = [];
|
|
953
|
+
for (const [key, entry] of Object.entries(schema)) {
|
|
954
|
+
if (entry?.kind === "setting") {
|
|
955
|
+
fallback[key] = entry.sampleValue;
|
|
956
|
+
sampled.push(key);
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
fallback[key] = null;
|
|
960
|
+
missing.push(key);
|
|
961
|
+
}
|
|
595
962
|
}
|
|
596
|
-
return fallback;
|
|
963
|
+
return { params: fallback, costUsd: 0, derived: true, missing, sampled };
|
|
597
964
|
});
|
|
598
965
|
}
|
|
599
966
|
/**
|
|
@@ -616,7 +983,9 @@ export class ReplayController {
|
|
|
616
983
|
this.retire(state, undefined);
|
|
617
984
|
let composed;
|
|
618
985
|
try {
|
|
619
|
-
composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state)
|
|
986
|
+
composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state),
|
|
987
|
+
// A parked step ends the bundle short of the task (fallbk.md D3).
|
|
988
|
+
{ handover: plan.stopsEarly() });
|
|
620
989
|
}
|
|
621
990
|
catch (err) {
|
|
622
991
|
this.upgrade(state, "failed");
|
|
@@ -653,17 +1022,110 @@ export class ReplayController {
|
|
|
653
1022
|
// Nothing survived; a bundle of nothing is worse than no bundle.
|
|
654
1023
|
return { kind: "abort" };
|
|
655
1024
|
}
|
|
1025
|
+
// The bundle stops in front of a parked step: say so ahead of it, and the
|
|
1026
|
+
// model carries on from there. The outcome stays `diverged` — the model
|
|
1027
|
+
// went off-script first — so the report carries no fallback fields.
|
|
1028
|
+
let text = composed.text;
|
|
1029
|
+
if (plan.stopsEarly()) {
|
|
1030
|
+
const parked = plan.stopStep();
|
|
1031
|
+
const stopKind = state.flatStop ? "unusable_call" : "known_bad_step";
|
|
1032
|
+
state.handover = {
|
|
1033
|
+
kind: stopKind,
|
|
1034
|
+
stepIndex: parked.stepIndex,
|
|
1035
|
+
toolName: parked.toolName ?? undefined,
|
|
1036
|
+
note: this.noteFor(state, {
|
|
1037
|
+
kind: stopKind,
|
|
1038
|
+
step: parked,
|
|
1039
|
+
entry: plan.stopEntry(),
|
|
1040
|
+
position: plan.stepCount,
|
|
1041
|
+
delivery: "below",
|
|
1042
|
+
}),
|
|
1043
|
+
delivered: true,
|
|
1044
|
+
};
|
|
1045
|
+
text = `${state.handover.note}\n\n${composed.text}`;
|
|
1046
|
+
}
|
|
656
1047
|
// Bash-clean delivery: the model reaches for `Bash` when it cannot call a
|
|
657
1048
|
// scripted tool, and a genuine command output is trusted where a `deny`
|
|
658
1049
|
// reason is read as adversarial interception. Neutralise the delimiter first.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
1050
|
+
const action = this.deliverInstead(text, toolName);
|
|
1051
|
+
logDetail("replay.inject", { channel: action.kind, bytes: text.length });
|
|
1052
|
+
return action;
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Hand the rest of the task to the model (fallbk.md D4/D5).
|
|
1056
|
+
*
|
|
1057
|
+
* Retires the plan and builds the note; delivery is the caller's, because the
|
|
1058
|
+
* channel depends on the hook that noticed. The outcome is `fell_back` when at
|
|
1059
|
+
* least one step did its work under the plan, and `failed` when none did — a
|
|
1060
|
+
* scenario that did nothing has nothing to book, and is a baseline sample.
|
|
1061
|
+
*/
|
|
1062
|
+
handOver(state, h) {
|
|
1063
|
+
if (state.handover)
|
|
1064
|
+
return;
|
|
1065
|
+
const ran = state.stepsPinned;
|
|
1066
|
+
this.retire(state, ran > 0 ? "fell_back" : "failed");
|
|
1067
|
+
state.handover = {
|
|
1068
|
+
kind: h.kind,
|
|
1069
|
+
// The *caller's* index: a fragment opens at the call row, not at a step
|
|
1070
|
+
// number the caller's own chain does not have (R-CALL-32, R-FALL-7).
|
|
1071
|
+
stepIndex: callerStepIndexOf(h.entry) ?? h.step?.stepIndex ?? h.position,
|
|
1072
|
+
toolName: h.step?.toolName ?? undefined,
|
|
1073
|
+
error: h.error,
|
|
1074
|
+
note: this.noteFor(state, h),
|
|
1075
|
+
delivered: false,
|
|
1076
|
+
};
|
|
1077
|
+
logLine("replay.handover", {
|
|
1078
|
+
scenario: state.scenarioId ?? undefined,
|
|
1079
|
+
kind: h.kind,
|
|
1080
|
+
step: `${h.position}/${state.plan?.totalSteps ?? state.stepsPlanned}`,
|
|
1081
|
+
tool: h.step?.toolName ?? undefined,
|
|
1082
|
+
// The scenario that owns the stopped step, when it is not the matched one.
|
|
1083
|
+
owner: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
|
|
1084
|
+
unusable: state.flatStop?.reason,
|
|
1085
|
+
callIntent: state.flatStop?.callIntent ?? undefined,
|
|
1086
|
+
segment: state.flatStop?.segmentId ?? undefined,
|
|
1087
|
+
ran,
|
|
1088
|
+
outcome: state.outcome,
|
|
1089
|
+
why: h.kind === "unusable_call"
|
|
1090
|
+
? "a sub-task of this chain cannot run — the agent does that part itself"
|
|
1091
|
+
: h.kind === "known_bad_step"
|
|
1092
|
+
? "the next step has failed too often — the agent continues from here"
|
|
1093
|
+
: "a step broke — the agent continues from here",
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
noteFor(state, h) {
|
|
1097
|
+
const plan = state.plan;
|
|
1098
|
+
const executed = h.ranThisStep ? h.position + 1 : h.position;
|
|
1099
|
+
let params;
|
|
1100
|
+
try {
|
|
1101
|
+
params = plan?.parameters;
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
params = undefined;
|
|
664
1105
|
}
|
|
665
|
-
|
|
666
|
-
|
|
1106
|
+
return buildHandoverNote({
|
|
1107
|
+
intent: plan?.intent ?? "",
|
|
1108
|
+
kind: h.kind,
|
|
1109
|
+
stepIndex: h.position,
|
|
1110
|
+
stepToolName: h.step?.toolName ?? undefined,
|
|
1111
|
+
failureCount: h.step?.failureCount,
|
|
1112
|
+
error: h.error,
|
|
1113
|
+
unusable: state.flatStop
|
|
1114
|
+
? {
|
|
1115
|
+
reason: state.flatStop.reason,
|
|
1116
|
+
callIntent: state.flatStop.callIntent,
|
|
1117
|
+
segmentId: state.flatStop.segmentId,
|
|
1118
|
+
ownerScenarioId: state.flatStop.scenarioId,
|
|
1119
|
+
}
|
|
1120
|
+
: undefined,
|
|
1121
|
+
ownerScenarioId: h.entry && h.entry.depth > 0 ? h.entry.scenarioId : undefined,
|
|
1122
|
+
inSubTask: h.entry && h.entry.depth > 0 ? (h.entry.frame.callIntent ?? null) : null,
|
|
1123
|
+
executed: Math.max(0, Math.min(executed, plan?.totalSteps ?? executed)),
|
|
1124
|
+
totalSteps: plan?.totalSteps ?? state.stepsPlanned,
|
|
1125
|
+
remainingTools: plan ? plan.toolsFrom(h.ranThisStep ? h.position + 1 : h.position) : [],
|
|
1126
|
+
params,
|
|
1127
|
+
delivery: h.delivery,
|
|
1128
|
+
});
|
|
667
1129
|
}
|
|
668
1130
|
/**
|
|
669
1131
|
* The executor handed to a plan: dispatch a step to the proxy that owns its
|
|
@@ -675,9 +1137,10 @@ export class ReplayController {
|
|
|
675
1137
|
*/
|
|
676
1138
|
executeStep() {
|
|
677
1139
|
return async (step, input) => {
|
|
678
|
-
const mcp = parseQualifiedName(step.toolName);
|
|
679
|
-
if (!mcp)
|
|
1140
|
+
const mcp = parseQualifiedName(step.toolName ?? "");
|
|
1141
|
+
if (!mcp) {
|
|
680
1142
|
throw new Error(`${step.toolName} is not an MCP tool — it can only run in the session`);
|
|
1143
|
+
}
|
|
681
1144
|
const result = await this.work.call(mcp.serverName, mcp.toolName, input, this.budgets.stepMs);
|
|
682
1145
|
// Serialize exactly as the proxy records it, or `toolOutputLogic` — which
|
|
683
1146
|
// was authored against that shape — silently derives nothing (§7.2).
|
|
@@ -697,7 +1160,7 @@ export class ReplayController {
|
|
|
697
1160
|
* hook's differently-shaped view would derive nothing at all.
|
|
698
1161
|
*/
|
|
699
1162
|
reachFor(state, step) {
|
|
700
|
-
return reachOf(step.toolName, state.wrapped);
|
|
1163
|
+
return reachOf(step.toolName ?? "", state.wrapped);
|
|
701
1164
|
}
|
|
702
1165
|
/**
|
|
703
1166
|
* Log a step and remember its verdict, in that order.
|
|
@@ -715,7 +1178,13 @@ export class ReplayController {
|
|
|
715
1178
|
/** Upsert one step's verdict. Later news about a step replaces earlier news. */
|
|
716
1179
|
recordStep(state, info) {
|
|
717
1180
|
const status = info.outcome === "executed" ? "ok" : info.outcome;
|
|
718
|
-
|
|
1181
|
+
// Keyed by the *flat* position, not by the step's own index: two scenarios
|
|
1182
|
+
// in one plan both have a step 0, and one would otherwise overwrite the
|
|
1183
|
+
// other's verdict (segmented.md R-CALL-13).
|
|
1184
|
+
const key = info.entry
|
|
1185
|
+
? `${info.entry.scenarioId}#${info.entry.stepIndex}`
|
|
1186
|
+
: `#${info.step.stepIndex}`;
|
|
1187
|
+
if (state.stepResults.size >= MAX_STEP_RESULTS && !state.stepResults.has(key)) {
|
|
719
1188
|
return;
|
|
720
1189
|
}
|
|
721
1190
|
const error = info.error
|
|
@@ -723,29 +1192,35 @@ export class ReplayController {
|
|
|
723
1192
|
? `${info.error.slice(0, MAX_STEP_ERROR_CHARS)}…`
|
|
724
1193
|
: info.error
|
|
725
1194
|
: undefined;
|
|
726
|
-
state.stepResults.set(
|
|
1195
|
+
state.stepResults.set(key, {
|
|
727
1196
|
stepIndex: info.step.stepIndex,
|
|
728
|
-
toolName: info.step.toolName,
|
|
1197
|
+
toolName: info.step.toolName ?? "",
|
|
729
1198
|
status,
|
|
730
1199
|
// A stage is only meaningful when something went wrong. `skipped` broke at
|
|
731
1200
|
// the call itself — its tool could not run here.
|
|
732
1201
|
stage: info.stage ?? (status === "skipped" ? "tool_call" : undefined),
|
|
733
1202
|
error,
|
|
734
1203
|
durationMs: info.ms,
|
|
1204
|
+
// A verdict about a called segment's step is counted on that segment, at
|
|
1205
|
+
// the revision it was served (R-CALL-13).
|
|
1206
|
+
scenarioId: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
|
|
1207
|
+
chainRevision: info.entry && info.entry.depth > 0 ? info.entry.frame.chainRevision : undefined,
|
|
735
1208
|
});
|
|
736
1209
|
}
|
|
737
1210
|
/** The step verdicts, in chain order, for the execution report. */
|
|
738
1211
|
stepResultsOf(state) {
|
|
739
1212
|
if (state.stepResults.size === 0)
|
|
740
1213
|
return undefined;
|
|
741
|
-
return [...state.stepResults.values()]
|
|
1214
|
+
return [...state.stepResults.values()];
|
|
742
1215
|
}
|
|
743
1216
|
logStep(info) {
|
|
744
|
-
const mcp = parseQualifiedName(info.step.toolName);
|
|
1217
|
+
const mcp = parseQualifiedName(info.step.toolName ?? "");
|
|
745
1218
|
if (info.outcome === "failed") {
|
|
746
1219
|
logLine("replay.step_failed", {
|
|
747
1220
|
n: info.step.stepIndex,
|
|
748
|
-
|
|
1221
|
+
scenario: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
|
|
1222
|
+
frame: info.entry?.frame.id,
|
|
1223
|
+
tool: info.step.toolName ?? undefined,
|
|
749
1224
|
stage: info.stage,
|
|
750
1225
|
why: info.stage === "tool_call"
|
|
751
1226
|
? "its tool ran and reported an error — the step's work did not happen"
|
|
@@ -786,9 +1261,22 @@ export class ReplayController {
|
|
|
786
1261
|
if (OUTCOME_RANK[outcome] > OUTCOME_RANK[state.outcome])
|
|
787
1262
|
state.outcome = outcome;
|
|
788
1263
|
}
|
|
789
|
-
retire(state, outcome) {
|
|
1264
|
+
retire(state, outcome, completing = false) {
|
|
790
1265
|
if (outcome)
|
|
791
1266
|
this.upgrade(state, outcome);
|
|
1267
|
+
// Only a plan that *finished* stops the clock (segmented.md R-MONEY-3). A
|
|
1268
|
+
// hand-over or a divergence takes no mark: the agent's next tokens are the
|
|
1269
|
+
// consequence of this plan stopping, and belong to its bill.
|
|
1270
|
+
if (completing) {
|
|
1271
|
+
state.retiredAt = Date.now();
|
|
1272
|
+
try {
|
|
1273
|
+
state.retiredMark = state.markUsage?.();
|
|
1274
|
+
}
|
|
1275
|
+
catch {
|
|
1276
|
+
// An unreadable transcript leaves the mark undefined, and the window
|
|
1277
|
+
// falls back to the next arm or the end of the turn.
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
792
1280
|
state.retired = true;
|
|
793
1281
|
state.pinned.clear();
|
|
794
1282
|
}
|
|
@@ -804,4 +1292,45 @@ export class ReplayController {
|
|
|
804
1292
|
});
|
|
805
1293
|
}
|
|
806
1294
|
}
|
|
1295
|
+
/** The first target of a schema, for the sentence a decline logs. */
|
|
1296
|
+
function firstTargetKey(schema) {
|
|
1297
|
+
if (!schema)
|
|
1298
|
+
return undefined;
|
|
1299
|
+
return Object.keys(schema).find((k) => schema[k]?.kind !== "setting");
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Position of the first step whose failure count is above `maxStepFailures`
|
|
1303
|
+
* (fallbk.md D3), or undefined when none is — including when the service sent
|
|
1304
|
+
* no policy, which an older service never does.
|
|
1305
|
+
*/
|
|
1306
|
+
/**
|
|
1307
|
+
* The index a hand-over reports, in the *caller's* chain (R-CALL-32).
|
|
1308
|
+
*
|
|
1309
|
+
* A step inside a called segment has no position in the caller's own chain; the
|
|
1310
|
+
* call row that opened its frame does. Reporting the segment's own index would
|
|
1311
|
+
* line the execution up against a step the caller's card does not have.
|
|
1312
|
+
*/
|
|
1313
|
+
function callerStepIndexOf(entry) {
|
|
1314
|
+
if (!entry || entry.depth === 0)
|
|
1315
|
+
return entry?.stepIndex;
|
|
1316
|
+
let frame = entry.frame;
|
|
1317
|
+
while (frame.parent && frame.depth > 1)
|
|
1318
|
+
frame = frame.parent;
|
|
1319
|
+
return frame.callerStepIndex;
|
|
1320
|
+
}
|
|
1321
|
+
/** The calls in a served chain, for the `plan.armed` line (R-CALL-32). */
|
|
1322
|
+
function callLines(scenario) {
|
|
1323
|
+
const calls = (scenario.steps ?? []).filter((s) => s.kind === "segment");
|
|
1324
|
+
if (calls.length === 0)
|
|
1325
|
+
return undefined;
|
|
1326
|
+
return calls
|
|
1327
|
+
.map((s) => `${s.segmentId ?? "-"}:${s.unusable ?? s.resolution?.state ?? "resolved"}`)
|
|
1328
|
+
.join(",");
|
|
1329
|
+
}
|
|
1330
|
+
export function knownBadStepIndex(steps, maxStepFailures) {
|
|
1331
|
+
if (typeof maxStepFailures !== "number" || !Number.isFinite(maxStepFailures))
|
|
1332
|
+
return undefined;
|
|
1333
|
+
const i = steps.findIndex((s) => (s.failureCount ?? 0) > maxStepFailures);
|
|
1334
|
+
return i < 0 ? undefined : i;
|
|
1335
|
+
}
|
|
807
1336
|
//# sourceMappingURL=controller.js.map
|