@basein/runner 0.3.24 → 0.3.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.js +5 -0
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +63 -9
- package/dist/control/server.js +350 -62
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +80 -0
- package/dist/replay/bundle.js +455 -24
- package/dist/replay/controller.d.ts +64 -1
- package/dist/replay/controller.js +238 -38
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/plan.d.ts +15 -0
- package/dist/replay/plan.js +48 -30
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/docs/calculatedReplay.md +32 -0
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { ExecutionReport } from "../record/recorder.js";
|
|
15
15
|
import { type ReplayMode, type StepReach } from "./coverage.js";
|
|
16
16
|
import { deriveParameters, type AgentDay, type DeriveContext, type DeriveResult } from "./derive.js";
|
|
17
|
+
import { type HandoverStopReason } from "./handover.js";
|
|
17
18
|
import { ProxyWorkQueue } from "./executor.js";
|
|
18
19
|
import { ScenarioReplayPlan } from "./plan.js";
|
|
19
20
|
import { type HostSinks } from "./safety.js";
|
|
@@ -199,7 +200,8 @@ export declare function clampDirectRun(budgets: ReplayBudgets, logicMs?: number)
|
|
|
199
200
|
*
|
|
200
201
|
* - **ok** — the plan ran to its end: `{ ok: true, text, responseModel, steps,
|
|
201
202
|
* partial: false }`. `text` is the bundle; `responseModel` what the
|
|
202
|
-
* scenario's own logic built from it (`{}` when it has none
|
|
203
|
+
* scenario's own logic built from it (`{}` when it has none, and when the
|
|
204
|
+
* bundle is brief and carries it already — plan.ts, `DriveOptions.brief`).
|
|
203
205
|
* - **partial** — the plan stopped part-way after running steps here: `{ ok:
|
|
204
206
|
* true, text, responseModel: {}, steps, partial }`. `text` opens with the
|
|
205
207
|
* hand-over note (text beginning `[BaseInstRunner calculated replay]`) and
|
|
@@ -309,6 +311,39 @@ export interface IntentMatchOptions {
|
|
|
309
311
|
maxRequestsPerTurn: number;
|
|
310
312
|
}
|
|
311
313
|
export declare const DEFAULT_INTENT_MATCH: IntentMatchOptions;
|
|
314
|
+
/**
|
|
315
|
+
* Where the stopped step stood when a plan handed over, in the words the
|
|
316
|
+
* service reads (its recordings/repo.ts `handoverFamily`): did the step
|
|
317
|
+
* happen. Its tool ran and only its output could not be read: `ran`. It was
|
|
318
|
+
* sent and nothing came back: `uncertain`. Stopped in front of it — the plan's
|
|
319
|
+
* time ran out (`budget`), its caller left (`cancelled`), its test could not
|
|
320
|
+
* say whether this request needs it (`test_failed`) — it was never sent:
|
|
321
|
+
* `not_run`. Otherwise the stop's own reason (`not_run`, `failed`,
|
|
322
|
+
* `concurrent`, `host_rejected`, `host_edited`), and a plain failure, which
|
|
323
|
+
* carries none, reads `failed`.
|
|
324
|
+
*
|
|
325
|
+
* The service continues a hand-over only with work that was recorded where the
|
|
326
|
+
* step had not happened (`not_run`, `failed`): the agent's work there starts
|
|
327
|
+
* with that very step. Continued after a step that ran, or may have, the plan
|
|
328
|
+
* made of it ran the step a second time. The three stops in front of a step
|
|
329
|
+
* went out under their own names, which the service reads as "may have run":
|
|
330
|
+
* a plan stopped by its time budget was never continued.
|
|
331
|
+
*/
|
|
332
|
+
export declare function stopOf(h: {
|
|
333
|
+
ranThisStep?: boolean;
|
|
334
|
+
inFlight?: unknown;
|
|
335
|
+
stopReason?: HandoverStopReason;
|
|
336
|
+
}): string;
|
|
337
|
+
/**
|
|
338
|
+
* Whether a run of the plan sent at least one of its calls: a step did its
|
|
339
|
+
* work, its tool answered with an error, its output could not be read after
|
|
340
|
+
* the tool ran, or it was sent and has not been seen through (a pin still out,
|
|
341
|
+
* a stop that may have happened). A step its test left out, one whose logic
|
|
342
|
+
* broke before its call, one that could not be sent and a recorded stand-in
|
|
343
|
+
* sent nothing — a plan that only did those has nothing a second run could
|
|
344
|
+
* repeat.
|
|
345
|
+
*/
|
|
346
|
+
export declare function sentACall(state: ReplayState): boolean;
|
|
312
347
|
/** The turn handed back to the model part-way (fallbk.md). */
|
|
313
348
|
export interface Handover {
|
|
314
349
|
kind: FallbackKind;
|
|
@@ -316,6 +351,8 @@ export interface Handover {
|
|
|
316
351
|
stepIndex: number;
|
|
317
352
|
toolName?: string;
|
|
318
353
|
error?: string;
|
|
354
|
+
/** For a `step_failed`: where the stopped step stood ({@link stopOf}). */
|
|
355
|
+
stop?: string;
|
|
319
356
|
/** What the model is told. Always built, even when delivery fails. */
|
|
320
357
|
note: string;
|
|
321
358
|
/** Set once the note reached the model, so it is never delivered twice. */
|
|
@@ -483,6 +520,16 @@ export interface ReplayState {
|
|
|
483
520
|
* the execution report says `measured: false`.
|
|
484
521
|
*/
|
|
485
522
|
callerCancelled?: boolean;
|
|
523
|
+
/**
|
|
524
|
+
* The plan ran to its end while its caller was gone, so its results reached
|
|
525
|
+
* nobody and no hand-over note exists: this says so, once, to the model
|
|
526
|
+
* ({@link ReplayController.takeNote}), so that it does not run the steps
|
|
527
|
+
* again.
|
|
528
|
+
*/
|
|
529
|
+
unseen?: {
|
|
530
|
+
note: string;
|
|
531
|
+
delivered: boolean;
|
|
532
|
+
};
|
|
486
533
|
/**
|
|
487
534
|
* The derivation is the service's to answer (no key here), so waiting on it
|
|
488
535
|
* is waiting on the service, and obeys its breaker (util/service-health.ts).
|
|
@@ -617,6 +664,14 @@ export type DeclineCode = "replay_disabled" | "not_ready" | "known_bad_first_ste
|
|
|
617
664
|
* arms on the model's first call after the hand-over, whatever that is.)
|
|
618
665
|
*/
|
|
619
666
|
| "not_taken"
|
|
667
|
+
/**
|
|
668
|
+
* The plan was cancelled by its caller (Esc on `run_scenario`, or a timeout)
|
|
669
|
+
* a few minutes ago, after at least one step had run, and the same plan came
|
|
670
|
+
* back: not armed again, so the steps whose side effects are in place do not
|
|
671
|
+
* run a second time. The agent does the task, told what ran. Set by the
|
|
672
|
+
* control server.
|
|
673
|
+
*/
|
|
674
|
+
| "after_cancel"
|
|
620
675
|
/**
|
|
621
676
|
* A continuation whose own code for working out its values (`paramsLogic`)
|
|
622
677
|
* failed on this request, so none of its steps could be computed. Set by
|
|
@@ -773,6 +828,12 @@ export declare class ReplayController {
|
|
|
773
828
|
* against the plan; the outcome is `fell_back` when a step ran and `failed`
|
|
774
829
|
* when none did, as for every hand-over. The note is the caller's to
|
|
775
830
|
* deliver ({@link takeNote}). Returns false when there was no live plan.
|
|
831
|
+
*
|
|
832
|
+
* A step the adapter was given and has not reported back on may be running:
|
|
833
|
+
* the plan stops after it, as for a call beside a pin ({@link concurrentCall}),
|
|
834
|
+
* and the hand-over says the step may have happened — never "did not run",
|
|
835
|
+
* which the service would continue with work that starts by running it
|
|
836
|
+
* again. Its result is still read when it lands.
|
|
776
837
|
*/
|
|
777
838
|
hostHandOver(state: ReplayState, why: "own_call" | "closed" | "late"): boolean;
|
|
778
839
|
/**
|
|
@@ -813,6 +874,8 @@ export declare class ReplayController {
|
|
|
813
874
|
* Returns it once and marks it delivered; undefined when there is none.
|
|
814
875
|
*/
|
|
815
876
|
takeNote(state: ReplayState | undefined): string | undefined;
|
|
877
|
+
/** Whether a note about this plan is still to be told to the model: a hand-over's, or the unseen results'. */
|
|
878
|
+
hasUndeliveredNote(state: ReplayState | undefined): boolean;
|
|
816
879
|
/**
|
|
817
880
|
* Deliver text to the model in place of the tool call it was about to make:
|
|
818
881
|
* as genuine command output for `Bash`, as the denial reason otherwise — the
|
|
@@ -22,7 +22,7 @@ import { BuiltinNotRunnable, BuiltinTimeout, FILE_BUILTINS, parseBashAllow, pars
|
|
|
22
22
|
import { BASH_CHANNEL_MAX, MAX_REPLAY_REASON, testWords, truncate } from "./bundle.js";
|
|
23
23
|
import { coverageOf, modeFor, reachOf } from "./coverage.js";
|
|
24
24
|
import { deriveParameters, localDay, } from "./derive.js";
|
|
25
|
-
import { buildHandoverNote, MAX_HANDOVER_NOTE, } from "./handover.js";
|
|
25
|
+
import { buildHandoverNote, buildUnseenResultNote, MAX_HANDOVER_NOTE, } from "./handover.js";
|
|
26
26
|
import { NO_PROXY, NoSessionProxyError, ProxyWorkQueue, QUEUE_CLOSED } from "./executor.js";
|
|
27
27
|
import { logicTimeoutMs, MAX_LOGIC_TIMEOUT_MS } from "./logic.js";
|
|
28
28
|
import { PREVIEW_BUDGET_MS, RecordedValueError, ScenarioReplayPlan, StepRunError, } from "./plan.js";
|
|
@@ -171,6 +171,56 @@ export const DEFAULT_INTENT_MATCH = {
|
|
|
171
171
|
maxPerTurn: 3,
|
|
172
172
|
maxRequestsPerTurn: 40,
|
|
173
173
|
};
|
|
174
|
+
/**
|
|
175
|
+
* Where the stopped step stood when a plan handed over, in the words the
|
|
176
|
+
* service reads (its recordings/repo.ts `handoverFamily`): did the step
|
|
177
|
+
* happen. Its tool ran and only its output could not be read: `ran`. It was
|
|
178
|
+
* sent and nothing came back: `uncertain`. Stopped in front of it — the plan's
|
|
179
|
+
* time ran out (`budget`), its caller left (`cancelled`), its test could not
|
|
180
|
+
* say whether this request needs it (`test_failed`) — it was never sent:
|
|
181
|
+
* `not_run`. Otherwise the stop's own reason (`not_run`, `failed`,
|
|
182
|
+
* `concurrent`, `host_rejected`, `host_edited`), and a plain failure, which
|
|
183
|
+
* carries none, reads `failed`.
|
|
184
|
+
*
|
|
185
|
+
* The service continues a hand-over only with work that was recorded where the
|
|
186
|
+
* step had not happened (`not_run`, `failed`): the agent's work there starts
|
|
187
|
+
* with that very step. Continued after a step that ran, or may have, the plan
|
|
188
|
+
* made of it ran the step a second time. The three stops in front of a step
|
|
189
|
+
* went out under their own names, which the service reads as "may have run":
|
|
190
|
+
* a plan stopped by its time budget was never continued.
|
|
191
|
+
*/
|
|
192
|
+
export function stopOf(h) {
|
|
193
|
+
if (h.ranThisStep)
|
|
194
|
+
return "ran";
|
|
195
|
+
if (h.inFlight)
|
|
196
|
+
return "uncertain";
|
|
197
|
+
if (h.stopReason === "budget" || h.stopReason === "cancelled" || h.stopReason === "test_failed")
|
|
198
|
+
return "not_run";
|
|
199
|
+
return h.stopReason ?? "failed";
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether a run of the plan sent at least one of its calls: a step did its
|
|
203
|
+
* work, its tool answered with an error, its output could not be read after
|
|
204
|
+
* the tool ran, or it was sent and has not been seen through (a pin still out,
|
|
205
|
+
* a stop that may have happened). A step its test left out, one whose logic
|
|
206
|
+
* broke before its call, one that could not be sent and a recorded stand-in
|
|
207
|
+
* sent nothing — a plan that only did those has nothing a second run could
|
|
208
|
+
* repeat.
|
|
209
|
+
*/
|
|
210
|
+
export function sentACall(state) {
|
|
211
|
+
if (state.stepsPinned > 0 || state.pinned.size > 0 || (state.lingering?.size ?? 0) > 0)
|
|
212
|
+
return true;
|
|
213
|
+
const stop = state.handover?.stop;
|
|
214
|
+
if (stop === "ran" || stop === "uncertain" || stop === "host_edited")
|
|
215
|
+
return true;
|
|
216
|
+
for (const r of state.stepResults.values()) {
|
|
217
|
+
if (r.status === "ok")
|
|
218
|
+
return true;
|
|
219
|
+
if (r.status === "failed" && (r.stage === "tool_call" || r.stage === "tool_output_logic"))
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
174
224
|
/**
|
|
175
225
|
* How long a call waits for the previous pin to thread before it is judged to
|
|
176
226
|
* be running beside it: the control server's hook-view fallback
|
|
@@ -527,12 +577,23 @@ export class ReplayController {
|
|
|
527
577
|
// reading that did not answer carries every target at its recorded
|
|
528
578
|
// sample (derive.ts), and one of these must be the mark there too — never
|
|
529
579
|
// last week's task name under a step that would create the task.
|
|
530
|
-
const
|
|
531
|
-
|
|
580
|
+
const allTargets = hasTargets(scenario.paramsObject) ? targetKeys(scenario.paramsObject) : [];
|
|
581
|
+
const oneHalf = allTargets.length > 0
|
|
582
|
+
? testedOnlyTargets(allTargets, scenario.paramsObject, planned, scenario.paramsLogic)
|
|
532
583
|
: [];
|
|
533
|
-
|
|
584
|
+
// A reading that did not answer (no key, the service down, out of budget,
|
|
585
|
+
// too slow) names no target at all. Settings may stand on their recorded
|
|
586
|
+
// samples; a target never does, whichever way the plan reads it. Only the
|
|
587
|
+
// ones a step would act on outright make the gate below decline, and the
|
|
588
|
+
// rest are what targets.ts counts on: a chain that finds its own value
|
|
589
|
+
// (`(respParams.ranked || [])[0] ?? parameters.id`) keeps the caller's
|
|
590
|
+
// copy as a bare fallback, and that fallback is `null` there — as it is
|
|
591
|
+
// when the reading answered and found nothing. Left at its sample, the
|
|
592
|
+
// fallback was last week's device, and the step acted on it whenever the
|
|
593
|
+
// chain found nothing.
|
|
594
|
+
const params = derivation.then((r) => r.derived || allTargets.length === 0
|
|
534
595
|
? r.params
|
|
535
|
-
: { ...r.params, ...Object.fromEntries(
|
|
596
|
+
: { ...r.params, ...Object.fromEntries(allTargets.map((key) => [key, null])) });
|
|
536
597
|
// `params` is awaited by the plan, and by gate 7 below when there is a
|
|
537
598
|
// target. Without this, the losing race in gate 7 leaves `derivation`
|
|
538
599
|
// rejected and unhandled, which takes the process down on an unhandled
|
|
@@ -665,7 +726,10 @@ export class ReplayController {
|
|
|
665
726
|
if (this.recordedFallback && this.opts.authUrl && this.opts.authToken) {
|
|
666
727
|
state.sourceRun = new SourceRunOutputs({
|
|
667
728
|
baseUrl: this.opts.authUrl,
|
|
668
|
-
|
|
729
|
+
// The run the current chain was calculated from: for a plan repaired
|
|
730
|
+
// from a fresh recording that is not the run it is stored under, and a
|
|
731
|
+
// stand-in taken from the old one is the output of another chain.
|
|
732
|
+
runId: scenario.sourceRunId ?? scenario.runId,
|
|
669
733
|
token: this.opts.authToken,
|
|
670
734
|
fetchImpl: this.opts.fetchImpl,
|
|
671
735
|
});
|
|
@@ -759,24 +823,63 @@ export class ReplayController {
|
|
|
759
823
|
* against the plan; the outcome is `fell_back` when a step ran and `failed`
|
|
760
824
|
* when none did, as for every hand-over. The note is the caller's to
|
|
761
825
|
* deliver ({@link takeNote}). Returns false when there was no live plan.
|
|
826
|
+
*
|
|
827
|
+
* A step the adapter was given and has not reported back on may be running:
|
|
828
|
+
* the plan stops after it, as for a call beside a pin ({@link concurrentCall}),
|
|
829
|
+
* and the hand-over says the step may have happened — never "did not run",
|
|
830
|
+
* which the service would continue with work that starts by running it
|
|
831
|
+
* again. Its result is still read when it lands.
|
|
762
832
|
*/
|
|
763
833
|
hostHandOver(state, why) {
|
|
764
834
|
const plan = state.plan;
|
|
765
835
|
if (!plan || state.retired)
|
|
766
836
|
return false;
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
837
|
+
const reason = why === "own_call"
|
|
838
|
+
? "the model made a call of its own, so the scenario stopped"
|
|
839
|
+
: why === "late"
|
|
840
|
+
? "its arguments were not ready in time"
|
|
841
|
+
: "the run ended before this step";
|
|
842
|
+
const outstanding = [...state.pinned.values()][0];
|
|
843
|
+
if (!outstanding) {
|
|
844
|
+
this.handOver(state, {
|
|
845
|
+
kind: "step_failed",
|
|
846
|
+
step: plan.currentStep(),
|
|
847
|
+
position: plan.currentStepIndex,
|
|
848
|
+
error: reason,
|
|
849
|
+
delivery: "above",
|
|
850
|
+
stopReason: "not_run",
|
|
851
|
+
});
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
const inFlight = new Map(state.pinned);
|
|
855
|
+
const next = outstanding.stepIndex + 1;
|
|
856
|
+
if (next < plan.stepCount) {
|
|
857
|
+
this.handOver(state, {
|
|
858
|
+
kind: "step_failed",
|
|
859
|
+
step: plan.allSteps()[next],
|
|
860
|
+
position: next,
|
|
861
|
+
error: reason,
|
|
862
|
+
delivery: "above",
|
|
863
|
+
stopReason: "not_run",
|
|
864
|
+
ran: outstanding.stepIndex,
|
|
865
|
+
inFlight: { stepIndex: outstanding.stepIndex, toolName: outstanding.toolName },
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
// The step out is the plan's last: the stop is that step, sent and not answered.
|
|
870
|
+
this.handOver(state, {
|
|
871
|
+
kind: "step_failed",
|
|
872
|
+
step: plan.allSteps()[outstanding.stepIndex],
|
|
873
|
+
position: outstanding.stepIndex,
|
|
874
|
+
error: why === "own_call"
|
|
875
|
+
? "it had not reported back when the model made a call of its own"
|
|
876
|
+
: "it had not reported back when the run ended",
|
|
877
|
+
delivery: "above",
|
|
878
|
+
stopReason: "uncertain",
|
|
879
|
+
ran: outstanding.stepIndex,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
state.lingering = inFlight;
|
|
780
883
|
return true;
|
|
781
884
|
}
|
|
782
885
|
/**
|
|
@@ -858,14 +961,27 @@ export class ReplayController {
|
|
|
858
961
|
*/
|
|
859
962
|
takeNote(state) {
|
|
860
963
|
const h = state?.handover;
|
|
861
|
-
if (
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
964
|
+
if (h && !h.delivered) {
|
|
965
|
+
h.delivered = true;
|
|
966
|
+
logDetail("replay.handover_delivered", {
|
|
967
|
+
scenario: state.scenarioId ?? undefined,
|
|
968
|
+
bytes: h.note.length,
|
|
969
|
+
});
|
|
970
|
+
return h.note;
|
|
971
|
+
}
|
|
972
|
+
// A plan that ran to its end with nobody there to read it has no
|
|
973
|
+
// hand-over, and this one note instead.
|
|
974
|
+
const u = state?.unseen;
|
|
975
|
+
if (u && !u.delivered) {
|
|
976
|
+
u.delivered = true;
|
|
977
|
+
logDetail("replay.unseen_delivered", { scenario: state.scenarioId ?? undefined, bytes: u.note.length });
|
|
978
|
+
return u.note;
|
|
979
|
+
}
|
|
980
|
+
return undefined;
|
|
981
|
+
}
|
|
982
|
+
/** Whether a note about this plan is still to be told to the model: a hand-over's, or the unseen results'. */
|
|
983
|
+
hasUndeliveredNote(state) {
|
|
984
|
+
return Boolean((state?.handover && !state.handover.delivered) || (state?.unseen && !state.unseen.delivered));
|
|
869
985
|
}
|
|
870
986
|
/**
|
|
871
987
|
* Deliver text to the model in place of the tool call it was about to make:
|
|
@@ -1709,6 +1825,12 @@ export class ReplayController {
|
|
|
1709
1825
|
const plan = state.plan;
|
|
1710
1826
|
if (!plan || state.retired)
|
|
1711
1827
|
return { ok: false, why: RUN_NO_PLAN };
|
|
1828
|
+
// Only a direct plan is run here, and only one with no step pinned for the
|
|
1829
|
+
// model's own call. A steer plan is stepped through by the model's calls,
|
|
1830
|
+
// and a step it pinned and that is still in flight would run a second time
|
|
1831
|
+
// under this run: the model told to call `run_scenario` by hand, beside it.
|
|
1832
|
+
if (state.mode !== "direct" || state.pinned.size > 0)
|
|
1833
|
+
return { ok: false, why: RUN_NO_PLAN };
|
|
1712
1834
|
if (state.running)
|
|
1713
1835
|
return state.running;
|
|
1714
1836
|
const running = this.runArmedOnce(state, plan, signal).catch((err) => {
|
|
@@ -1757,7 +1879,15 @@ export class ReplayController {
|
|
|
1757
1879
|
const deadline = Date.now() + this.budgets.planMs;
|
|
1758
1880
|
let result;
|
|
1759
1881
|
try {
|
|
1760
|
-
result = await plan.runToCompletion(this.executeStep(state, { deadline }), this.recordedSource(state), this.observeStep(state), deadline, MAX_REPLAY_REASON, {
|
|
1882
|
+
result = await plan.runToCompletion(this.executeStep(state, { deadline }), this.recordedSource(state), this.observeStep(state), deadline, MAX_REPLAY_REASON, {
|
|
1883
|
+
stopOnFailure: true,
|
|
1884
|
+
recordedFallback: this.recordedFallback,
|
|
1885
|
+
signal,
|
|
1886
|
+
reportTool: this.reportToolFor(state),
|
|
1887
|
+
// The agent answers from the bundle alone: a run that went entirely
|
|
1888
|
+
// well is laid out briefly, with the plan's summary in it.
|
|
1889
|
+
brief: true,
|
|
1890
|
+
});
|
|
1761
1891
|
}
|
|
1762
1892
|
catch (err) {
|
|
1763
1893
|
// The scenario's own logic failed. Retire and let the model do the work.
|
|
@@ -1791,7 +1921,14 @@ export class ReplayController {
|
|
|
1791
1921
|
// out — is no saving at all: the turn it ended cost next to nothing and
|
|
1792
1922
|
// says nothing about what the task costs. `handOver` books `fell_back`
|
|
1793
1923
|
// (steps did run) or `failed`, and the report is not a measurement.
|
|
1794
|
-
|
|
1924
|
+
//
|
|
1925
|
+
// Whether the caller is gone is read here, after the run, and not only
|
|
1926
|
+
// from the stop's own reason: a caller who leaves during a step sees the
|
|
1927
|
+
// run go on to that step's end, and a step that then fails stops the run
|
|
1928
|
+
// for its own reason. The note about it went to a closed request, and the
|
|
1929
|
+
// model was never told which steps had run.
|
|
1930
|
+
const callerGone = stop.reason === "cancelled" || signal?.aborted === true;
|
|
1931
|
+
if (callerGone)
|
|
1795
1932
|
state.callerCancelled = true;
|
|
1796
1933
|
// Nothing ran *here*: there is no bundle, only the task — a recorded
|
|
1797
1934
|
// output is not a result that happened (plan.ts, DriveOptions).
|
|
@@ -1807,7 +1944,7 @@ export class ReplayController {
|
|
|
1807
1944
|
// A cancelled run's note is kept for the model's next call, alone —
|
|
1808
1945
|
// the bundle went to a caller who is gone — so it points at side
|
|
1809
1946
|
// effects in place, never at results "below" that nobody receives.
|
|
1810
|
-
delivery: live &&
|
|
1947
|
+
delivery: live && !callerGone ? "below" : "none",
|
|
1811
1948
|
stopReason: stop.reason,
|
|
1812
1949
|
ran: live ? undefined : 0,
|
|
1813
1950
|
// An output-logic failure stops *after* its step's tool ran: the step
|
|
@@ -1816,7 +1953,7 @@ export class ReplayController {
|
|
|
1816
1953
|
// The other half (AC5): what the steps from here are for.
|
|
1817
1954
|
whenText: stop.whenText,
|
|
1818
1955
|
});
|
|
1819
|
-
if (
|
|
1956
|
+
if (callerGone) {
|
|
1820
1957
|
// The caller is gone and reads nothing. The note stays undelivered: a
|
|
1821
1958
|
// client that timed out hands the task back to the model in this same
|
|
1822
1959
|
// turn, and the model's next call is where it learns which steps
|
|
@@ -1875,12 +2012,18 @@ export class ReplayController {
|
|
|
1875
2012
|
}
|
|
1876
2013
|
// Live steps only, as above: a recorded stand-in did no work here.
|
|
1877
2014
|
state.stepsPinned += result.executed;
|
|
2015
|
+
// A brief bundle carries the plan's summary itself, in a few words, so it
|
|
2016
|
+
// is not delivered a second time beside it.
|
|
1878
2017
|
let responseModel = {};
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
2018
|
+
if (result.summaryError)
|
|
2019
|
+
logDetail("replay.response_model_failed", { error: result.summaryError });
|
|
2020
|
+
if (!result.brief) {
|
|
2021
|
+
try {
|
|
2022
|
+
responseModel = plan.responseModel();
|
|
2023
|
+
}
|
|
2024
|
+
catch (err) {
|
|
2025
|
+
logDetail("replay.response_model_failed", { error: errText(err) });
|
|
2026
|
+
}
|
|
1884
2027
|
}
|
|
1885
2028
|
// A plan whose tools errored ran to the end and did nothing. `steered_full`
|
|
1886
2029
|
// would book the largest saving available for it; `failed` books a baseline
|
|
@@ -1908,6 +2051,29 @@ export class ReplayController {
|
|
|
1908
2051
|
partial: result.partial || undefined,
|
|
1909
2052
|
ms: Date.now() - state.armedAt,
|
|
1910
2053
|
});
|
|
2054
|
+
// The caller left during the last step, which the run does not interrupt:
|
|
2055
|
+
// every step ran, and the answer goes to a closed request. Nothing stopped,
|
|
2056
|
+
// so there is no hand-over note, and the model was never told that the
|
|
2057
|
+
// steps ran — the next request of the same task ran them again, a second
|
|
2058
|
+
// row, a second mail. So the turn is no measurement of the task, and the
|
|
2059
|
+
// model is told once, at its next call or prompt (the server keeps the note
|
|
2060
|
+
// for it: `stashCancelledNote`).
|
|
2061
|
+
if (signal?.aborted) {
|
|
2062
|
+
state.callerCancelled = true;
|
|
2063
|
+
state.unseen = {
|
|
2064
|
+
note: buildUnseenResultNote({
|
|
2065
|
+
intent: plan.intent ?? "",
|
|
2066
|
+
executed: result.executed,
|
|
2067
|
+
totalSteps: plan.totalSteps ?? state.stepsPlanned,
|
|
2068
|
+
}),
|
|
2069
|
+
delivered: false,
|
|
2070
|
+
};
|
|
2071
|
+
logLine("replay.cancelled", {
|
|
2072
|
+
scenario: state.scenarioId ?? undefined,
|
|
2073
|
+
ran: `${result.executed}/${plan.stepCount}`,
|
|
2074
|
+
why: "the caller went away during the last step — every step ran and nobody read the results",
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
1911
2077
|
return {
|
|
1912
2078
|
ok: true,
|
|
1913
2079
|
text: result.text,
|
|
@@ -2011,8 +2177,15 @@ export class ReplayController {
|
|
|
2011
2177
|
// After a person's "no" the task ended early; after a changed call that
|
|
2012
2178
|
// was the plan's first step, the turn made one model call fewer than
|
|
2013
2179
|
// the task needs — the one the plan's call replaced.
|
|
2180
|
+
//
|
|
2181
|
+
// Nor one its caller cancelled, or one this plan was not armed for
|
|
2182
|
+
// because it had been cancelled a moment before: the steps it did
|
|
2183
|
+
// already ran in the turn before, so the agent's work is the rest of the
|
|
2184
|
+
// task, and its cost is no measure of the whole.
|
|
2014
2185
|
baselineEligible: d.siblings?.some((s) => s !== state && s.plan !== undefined) ||
|
|
2015
2186
|
state.hostStop !== undefined ||
|
|
2187
|
+
state.callerCancelled === true ||
|
|
2188
|
+
state.declined === "after_cancel" ||
|
|
2016
2189
|
d.cutShort === true
|
|
2017
2190
|
? false
|
|
2018
2191
|
: undefined,
|
|
@@ -2092,17 +2265,30 @@ export class ReplayController {
|
|
|
2092
2265
|
}
|
|
2093
2266
|
return out;
|
|
2094
2267
|
});
|
|
2268
|
+
// The plan stops in front of a step the service parked, or whose owner
|
|
2269
|
+
// froze it by hand (plan-services.md D8). An operator who asks for the run
|
|
2270
|
+
// by id turns the gates off, not that: a frozen step is the one that must
|
|
2271
|
+
// not run unattended — an install, a deploy — and the run ended up running
|
|
2272
|
+
// it, and every step after it. It stops there, and says so (`stopped`).
|
|
2273
|
+
const stopAt = stopIndex(scenario.steps, undefined);
|
|
2274
|
+
if (stopAt === 0) {
|
|
2275
|
+
return {
|
|
2276
|
+
ok: false,
|
|
2277
|
+
why: "the plan's first step is parked or frozen by hand, so it stops in front of it: nothing was run",
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2095
2280
|
const plan = new ScenarioReplayPlan({
|
|
2096
2281
|
scenario,
|
|
2097
2282
|
params,
|
|
2098
2283
|
mode,
|
|
2099
2284
|
fallbackTargets: "samples",
|
|
2285
|
+
stopAt,
|
|
2100
2286
|
});
|
|
2101
2287
|
state.plan = plan;
|
|
2102
2288
|
if (this.opts.authUrl && this.opts.authToken) {
|
|
2103
2289
|
state.sourceRun = new SourceRunOutputs({
|
|
2104
2290
|
baseUrl: this.opts.authUrl,
|
|
2105
|
-
runId: scenario.runId,
|
|
2291
|
+
runId: scenario.sourceRunId ?? scenario.runId,
|
|
2106
2292
|
token: this.opts.authToken,
|
|
2107
2293
|
fetchImpl: this.opts.fetchImpl,
|
|
2108
2294
|
});
|
|
@@ -2352,7 +2538,10 @@ export class ReplayController {
|
|
|
2352
2538
|
// it as that half's evidence. Booked `diverged`, it never did.
|
|
2353
2539
|
const otherHalf = stop?.kind === "other_branch";
|
|
2354
2540
|
this.upgrade(state, broke || !didWork ? "failed" : otherHalf ? "fell_back" : "diverged");
|
|
2355
|
-
|
|
2541
|
+
// A step whose tool answered with an error did not do its work: it is not
|
|
2542
|
+
// among the steps the plan "ran", whose saving is booked and whose note
|
|
2543
|
+
// tells the model not to run them again (as `runArmedOnce` counts them).
|
|
2544
|
+
state.stepsPinned += Math.max(0, composed.executed - composed.errored);
|
|
2356
2545
|
// The headline number of this whole design: recovering from a divergence
|
|
2357
2546
|
// costs nothing, because the proxies were already connected.
|
|
2358
2547
|
logLine("replay.compose", {
|
|
@@ -2470,6 +2659,9 @@ export class ReplayController {
|
|
|
2470
2659
|
stepIndex: step?.stepIndex ?? position,
|
|
2471
2660
|
toolName: step?.toolName ?? undefined,
|
|
2472
2661
|
error: stop.error,
|
|
2662
|
+
...(stop.kind === "step_failed"
|
|
2663
|
+
? { stop: stopOf({ ranThisStep: stop.stage === "tool_output_logic", stopReason: stop.reason }) }
|
|
2664
|
+
: {}),
|
|
2473
2665
|
note,
|
|
2474
2666
|
delivered: true,
|
|
2475
2667
|
};
|
|
@@ -2493,6 +2685,7 @@ export class ReplayController {
|
|
|
2493
2685
|
stepIndex: h.step?.stepIndex ?? h.position,
|
|
2494
2686
|
toolName: h.step?.toolName ?? undefined,
|
|
2495
2687
|
error: h.error,
|
|
2688
|
+
...(h.kind === "step_failed" ? { stop: stopOf(h) } : {}),
|
|
2496
2689
|
note: this.noteFor(state, h),
|
|
2497
2690
|
delivered: false,
|
|
2498
2691
|
};
|
|
@@ -3138,6 +3331,13 @@ function classifyWorkError(err, serverName) {
|
|
|
3138
3331
|
if (message === QUEUE_CLOSED || /\bupstream is not available\b/i.test(message)) {
|
|
3139
3332
|
return new StepRunError(message, "not_run");
|
|
3140
3333
|
}
|
|
3334
|
+
// A timeout, an interrupt, a dropped connection: sent, and not seen through,
|
|
3335
|
+
// so its work may have happened — before the "MCP error" rule below, which
|
|
3336
|
+
// would call `MCP error -32001: Request timed out` a failure the tool
|
|
3337
|
+
// answered. The pinned path words it the same way ({@link pinFailure}). A
|
|
3338
|
+
// plan told "failed" there let the agent run the step again.
|
|
3339
|
+
if (UNCERTAIN_TEXT.test(message))
|
|
3340
|
+
return new StepRunError(message, "uncertain");
|
|
3141
3341
|
if (/^MCP error -?\d+/i.test(message))
|
|
3142
3342
|
return new StepRunError(message, "failed");
|
|
3143
3343
|
return new StepRunError(message, "uncertain");
|
package/dist/replay/derive.js
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import { redactText } from "../record/redact.js";
|
|
34
34
|
import { isUnhealthyStatus, serviceHealth, timeoutSignal } from "../util/service-health.js";
|
|
35
35
|
import { serviceHeaders } from "../util/service-headers.js";
|
|
36
|
-
import { calculateCostUsd } from "./pricing.js";
|
|
36
|
+
import { calculateCostUsd, requestRates } from "./pricing.js";
|
|
37
37
|
export const DEFAULT_DERIVE_MODEL = "claude-haiku-4-5-20251001";
|
|
38
38
|
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
39
39
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
@@ -133,6 +133,44 @@ export function extractJsonObject(text) {
|
|
|
133
133
|
}
|
|
134
134
|
return undefined;
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* `text` with each bare `undefined` in its JSON made `null`, when that JSON
|
|
138
|
+
* does not parse as it came. `undefined` is not JSON, but models emit it
|
|
139
|
+
* (`{"a": undefined}`), and one stray token should not cost the extraction.
|
|
140
|
+
* A value that says it in words — `TypeError: undefined is not a function` —
|
|
141
|
+
* is a value, and was rewritten along with the rest: the plan was handed
|
|
142
|
+
* `TypeError: null is not a function`, silently. So the JSON is read as it
|
|
143
|
+
* came first, and only a token outside every string literal is changed: the
|
|
144
|
+
* walk takes each string from its opening quote to its closing one, so a
|
|
145
|
+
* quote inside a string never ends it early.
|
|
146
|
+
*/
|
|
147
|
+
function nullUndefined(text) {
|
|
148
|
+
const json = extractJsonObject(text);
|
|
149
|
+
if (json === undefined)
|
|
150
|
+
return text;
|
|
151
|
+
try {
|
|
152
|
+
JSON.parse(json);
|
|
153
|
+
return text;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// not JSON as it came: repaired below
|
|
157
|
+
}
|
|
158
|
+
let fixed = "";
|
|
159
|
+
let at = 0;
|
|
160
|
+
while (at < json.length) {
|
|
161
|
+
const open = json.indexOf('"', at);
|
|
162
|
+
fixed += json.slice(at, open < 0 ? json.length : open).replace(/\bundefined\b/g, "null");
|
|
163
|
+
if (open < 0)
|
|
164
|
+
break;
|
|
165
|
+
let end = open + 1;
|
|
166
|
+
while (end < json.length && json[end] !== '"')
|
|
167
|
+
end += json[end] === "\\" ? 2 : 1;
|
|
168
|
+
fixed += json.slice(open, end + 1);
|
|
169
|
+
at = end + 1;
|
|
170
|
+
}
|
|
171
|
+
const start = text.indexOf(json);
|
|
172
|
+
return text.slice(0, start) + fixed + text.slice(start + json.length);
|
|
173
|
+
}
|
|
136
174
|
export function buildExtractionPrompt(prompt, intent, schema, ctx = {}) {
|
|
137
175
|
// Each parameter wears its kind, because the last rule below treats the two
|
|
138
176
|
// differently and the model cannot tell them apart from the name alone
|
|
@@ -232,15 +270,19 @@ export async function deriveParameters(opts) {
|
|
|
232
270
|
.join("");
|
|
233
271
|
const inputTokens = body.usage?.input_tokens ?? 0;
|
|
234
272
|
const outputTokens = body.usage?.output_tokens ?? 0;
|
|
273
|
+
const cacheReadTokens = body.usage?.cache_read_input_tokens ?? 0;
|
|
274
|
+
const cacheCreationTokens = body.usage?.cache_creation_input_tokens ?? 0;
|
|
235
275
|
const costUsd = calculateCostUsd(model, {
|
|
236
276
|
inputTokens,
|
|
237
277
|
outputTokens,
|
|
238
|
-
cacheReadTokens
|
|
239
|
-
cacheCreationTokens
|
|
278
|
+
cacheReadTokens,
|
|
279
|
+
cacheCreationTokens,
|
|
280
|
+
// One request: priced by its own prompt length where the model is (pricing.ts).
|
|
281
|
+
...requestRates(model, { promptTokens: inputTokens + cacheReadTokens + cacheCreationTokens }),
|
|
240
282
|
});
|
|
241
283
|
// `undefined` is not JSON, but models emit it; normalise before parsing so one
|
|
242
284
|
// stray token does not cost the whole extraction.
|
|
243
|
-
const normalized = text
|
|
285
|
+
const normalized = nullUndefined(text);
|
|
244
286
|
const cleaned = extractJsonObject(normalized) ??
|
|
245
287
|
normalized
|
|
246
288
|
.replace(/^```(?:json)?\s*/m, "")
|
|
@@ -130,6 +130,19 @@ export interface HandoverNoteInput {
|
|
|
130
130
|
whenText?: string | null;
|
|
131
131
|
}
|
|
132
132
|
export declare function buildHandoverNote(n: HandoverNoteInput): string;
|
|
133
|
+
/**
|
|
134
|
+
* The note for a plan that ran every one of its steps while its caller was
|
|
135
|
+
* gone (Esc on `run_scenario`, or the client's timeout), so that the results
|
|
136
|
+
* reached nobody. Nothing stopped, so no hand-over note exists; without this
|
|
137
|
+
* one the model was never told the steps ran, and the next request of the same
|
|
138
|
+
* task ran them again — a row added twice, a mail sent twice.
|
|
139
|
+
*/
|
|
140
|
+
export declare function buildUnseenResultNote(n: {
|
|
141
|
+
intent: string;
|
|
142
|
+
/** How many steps ran, of how many the plan has. */
|
|
143
|
+
executed: number;
|
|
144
|
+
totalSteps: number;
|
|
145
|
+
}): string;
|
|
133
146
|
/** "Step 2", "Steps 1–3", "Steps 1, 3 and 5–6": 1-based numbers in order, runs joined. */
|
|
134
147
|
export declare function stepsPhrase(numbers: readonly number[]): string;
|
|
135
148
|
//# sourceMappingURL=handover.d.ts.map
|