@basein/runner 0.3.7 → 0.3.9
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 +10 -6
- package/dist/bin/bir-hooks.d.ts +8 -8
- package/dist/bin/bir-hooks.js +8 -8
- package/dist/bin/investigate.js +20 -6
- package/dist/control/recorder-main.js +4 -4
- package/dist/control/server.js +7 -4
- package/dist/control/transcript.d.ts +9 -0
- package/dist/control/transcript.js +26 -7
- package/dist/record/remote-recorder.d.ts +9 -1
- package/dist/record/remote-recorder.js +77 -10
- package/dist/record/truncate.d.ts +11 -0
- package/dist/record/truncate.js +11 -0
- package/dist/replay/builtins.d.ts +21 -16
- package/dist/replay/builtins.js +38 -21
- package/dist/replay/controller.d.ts +22 -9
- package/dist/replay/controller.js +103 -12
- package/dist/replay/coverage.d.ts +8 -7
- package/dist/replay/coverage.js +8 -7
- package/dist/replay/plan.d.ts +6 -0
- package/dist/replay/plan.js +13 -3
- package/dist/replay/types.d.ts +16 -0
- package/docs/calculatedReplay.md +48 -8
- package/docs/calculatedReplayGuide.md +11 -5
- package/package.json +1 -1
- package/production.md +3 -2
package/dist/replay/builtins.js
CHANGED
|
@@ -10,18 +10,21 @@
|
|
|
10
10
|
*
|
|
11
11
|
* THE RULES, because this bypasses the host's permission prompts entirely:
|
|
12
12
|
*
|
|
13
|
-
* - **
|
|
14
|
-
* `BIR_BUILTIN_TOOLS` ({@link parseBuiltinTools}),
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* - **All six by default, each a switch.** Which built-ins may run at all is
|
|
14
|
+
* `BIR_BUILTIN_TOOLS` ({@link parseBuiltinTools}); unset, it is every one
|
|
15
|
+
* this module implements (up to 0.3.7 it was the read-only set,
|
|
16
|
+
* `Read,Glob,Grep`). The permission story is the one a steered step
|
|
17
|
+
* already has — its pinned call is answered `allow` too. What running it
|
|
18
|
+
* here changes is that no model sees the call before it runs, so an
|
|
19
|
+
* operator who wants one in the loop takes the tool off the list.
|
|
18
20
|
* - **Path jail.** Every path is resolved against, and confined to, the
|
|
19
21
|
* session's project directory ({@link jailed}), symlinks included. The
|
|
20
22
|
* daemon's own cwd is never used: it is `~/.baseinstrunner`, not the
|
|
21
23
|
* project (§5.1 of the change plan).
|
|
22
|
-
* - **Bash runs
|
|
23
|
-
* match one of its prefixes ({@link bashAllowed});
|
|
24
|
-
*
|
|
24
|
+
* - **Bash runs any command unless `BIR_BASH_ALLOW` narrows it.** Set, a
|
|
25
|
+
* command must match one of its prefixes ({@link bashAllowed}); `none`
|
|
26
|
+
* allows no command, and then `Bash` counts as off the list, so its steps
|
|
27
|
+
* are steered in the session (controller.ts).
|
|
25
28
|
* - **Same failure contract as an MCP step.** A step that *cannot* run here
|
|
26
29
|
* throws {@link BuiltinNotRunnable} (→ `not_run`: the session can still do
|
|
27
30
|
* it, or a recorded output may stand in under
|
|
@@ -42,12 +45,6 @@ import { existsSync } from "node:fs";
|
|
|
42
45
|
import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
43
46
|
import { dirname, join, resolve, sep } from "node:path";
|
|
44
47
|
// ── configuration ────────────────────────────────────────────────────────────
|
|
45
|
-
/**
|
|
46
|
-
* The built-ins bir may stand in for or execute when `BIR_BUILTIN_TOOLS` is
|
|
47
|
-
* unset: the read-only set. Reading a file the session itself would read
|
|
48
|
-
* without a prompt is the risk floor; anything that writes or runs is not.
|
|
49
|
-
*/
|
|
50
|
-
export const DEFAULT_BUILTIN_TOOLS = ["Read", "Glob", "Grep"];
|
|
51
48
|
/** The built-ins {@link runBuiltin} actually implements. */
|
|
52
49
|
export const EXECUTABLE_BUILTINS = new Set([
|
|
53
50
|
"Read",
|
|
@@ -57,6 +54,13 @@ export const EXECUTABLE_BUILTINS = new Set([
|
|
|
57
54
|
"Write",
|
|
58
55
|
"Bash",
|
|
59
56
|
]);
|
|
57
|
+
/**
|
|
58
|
+
* The built-ins bir may stand in for or execute when `BIR_BUILTIN_TOOLS` is
|
|
59
|
+
* unset: every one it implements, so a chain of built-ins arms `direct` just
|
|
60
|
+
* as a chain of wrapped MCP calls does. Up to 0.3.7 this was the read-only
|
|
61
|
+
* set (`Read,Glob,Grep`).
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_BUILTIN_TOOLS = [...EXECUTABLE_BUILTINS];
|
|
60
64
|
/** `read` → `Read`: the vocabulary is small enough to forgive casing. */
|
|
61
65
|
const CANONICAL_BUILTIN = new Map([...EXECUTABLE_BUILTINS].map((name) => [name.toLowerCase(), name]));
|
|
62
66
|
/**
|
|
@@ -78,15 +82,17 @@ export function parseBuiltinTools(value) {
|
|
|
78
82
|
}
|
|
79
83
|
/**
|
|
80
84
|
* Parse `BIR_BASH_ALLOW`: comma-separated command prefixes, `*` for
|
|
81
|
-
* everything.
|
|
82
|
-
*
|
|
85
|
+
* everything. Unset means everything, because `Bash` is on the default list.
|
|
86
|
+
* Set, it is the whole list, as `BIR_BUILTIN_TOOLS` is: `none` (or an empty
|
|
87
|
+
* value) allows no command.
|
|
83
88
|
*/
|
|
84
89
|
export function parseBashAllow(value) {
|
|
85
|
-
|
|
90
|
+
if (value === undefined)
|
|
91
|
+
return ["*"];
|
|
92
|
+
return value
|
|
86
93
|
.split(",")
|
|
87
94
|
.map((s) => s.trim())
|
|
88
|
-
.filter(
|
|
89
|
-
return prefixes.length > 0 ? prefixes : undefined;
|
|
95
|
+
.filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()));
|
|
90
96
|
}
|
|
91
97
|
/**
|
|
92
98
|
* Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
|
|
@@ -485,13 +491,24 @@ async function runBash(input, ctx) {
|
|
|
485
491
|
if (typeof command !== "string" || command.trim() === "") {
|
|
486
492
|
throw new BuiltinNotRunnable("command is required");
|
|
487
493
|
}
|
|
488
|
-
if (!ctx.bashAllow) {
|
|
489
|
-
throw new BuiltinNotRunnable("BIR_BASH_ALLOW
|
|
494
|
+
if (!ctx.bashAllow?.length) {
|
|
495
|
+
throw new BuiltinNotRunnable("BIR_BASH_ALLOW allows no command, so bir runs no Bash command unattended");
|
|
490
496
|
}
|
|
491
497
|
if (!bashAllowed(command, ctx.bashAllow)) {
|
|
492
498
|
throw new BuiltinNotRunnable("the command does not match BIR_BASH_ALLOW, so bir does not run it unattended");
|
|
493
499
|
}
|
|
500
|
+
// What the host would run differently stays the session's, never started
|
|
501
|
+
// here: a command it leaves in the background (there is no shell here to
|
|
502
|
+
// leave it in, so it would be stopped with the step), and one that asks for
|
|
503
|
+
// more time than this step has. Cut off part-way, an `npm i -g` or a
|
|
504
|
+
// migration can leave things half done, which is worse than a hand-over.
|
|
505
|
+
if (input.run_in_background === true) {
|
|
506
|
+
throw new BuiltinNotRunnable("the command runs in the background, which only the session can do");
|
|
507
|
+
}
|
|
494
508
|
const timeoutMs = ctx.timeoutMs ?? 120_000;
|
|
509
|
+
if (typeof input.timeout === "number" && input.timeout > timeoutMs) {
|
|
510
|
+
throw new BuiltinNotRunnable(`the command asks for up to ${Math.ceil(input.timeout / 1000)} s and this step has ${Math.floor(timeoutMs / 1000)} s, so the session runs it`);
|
|
511
|
+
}
|
|
495
512
|
// The host's Bash semantics, which the recorded chain was written against:
|
|
496
513
|
// the working directory carries over from the plan's previous Bash step,
|
|
497
514
|
// variables and functions do not (a fresh `-l` shell each call).
|
|
@@ -191,17 +191,17 @@ export interface ReplayOptions {
|
|
|
191
191
|
* `BIR_BUILTIN_TOOLS` — the built-ins the runner may execute itself, from
|
|
192
192
|
* the session's own project directory (replay/builtins.ts), or stand in
|
|
193
193
|
* for with a recorded output under `BIR_REPLAY_RECORDED_OUTPUTS=1`.
|
|
194
|
-
* Omitted, the environment decides; the default is
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* {@link ReplayOptions.bashAllow}.
|
|
194
|
+
* Omitted, the environment decides; the default is all six (`Read`,
|
|
195
|
+
* `Glob`, `Grep`, `Edit`, `Write`, `Bash`), and `Bash` runs only the
|
|
196
|
+
* commands {@link ReplayOptions.bashAllow} allows.
|
|
198
197
|
*/
|
|
199
198
|
builtinTools?: ReadonlySet<string>;
|
|
200
199
|
/**
|
|
201
200
|
* `BIR_BASH_ALLOW` — command prefixes a runner-side `Bash` step may run
|
|
202
|
-
* (`*` for all).
|
|
203
|
-
*
|
|
204
|
-
*
|
|
201
|
+
* (`*` for all). Omitted, the environment decides; the default is `*`.
|
|
202
|
+
* Empty allows no command, and then `Bash` counts as off
|
|
203
|
+
* {@link ReplayOptions.builtinTools}: its steps are steered in the session,
|
|
204
|
+
* rather than armed `direct` and stopped at the first one.
|
|
205
205
|
*/
|
|
206
206
|
bashAllow?: readonly string[];
|
|
207
207
|
budgets?: Partial<ReplayBudgets>;
|
|
@@ -273,6 +273,12 @@ interface PinnedCall {
|
|
|
273
273
|
toolName: string;
|
|
274
274
|
/** When it was pinned — a steered step's duration is measured across hooks. */
|
|
275
275
|
pinnedAt: number;
|
|
276
|
+
/**
|
|
277
|
+
* The arguments the pin gave the call, for the step's verdict: the plan
|
|
278
|
+
* never sees a steered call, so this is the only place they are known when
|
|
279
|
+
* its result lands.
|
|
280
|
+
*/
|
|
281
|
+
input?: Record<string, unknown>;
|
|
276
282
|
/**
|
|
277
283
|
* When the host said the call finished (`PostToolUse`), if the control
|
|
278
284
|
* server told us ({@link ReplayController.hostCompleted}). A wrapped step
|
|
@@ -475,7 +481,7 @@ export declare class ReplayController {
|
|
|
475
481
|
/** See {@link ReplayOptions.builtinTools}. */
|
|
476
482
|
private readonly builtinTools;
|
|
477
483
|
/** See {@link ReplayOptions.bashAllow}. */
|
|
478
|
-
private readonly bashAllow
|
|
484
|
+
private readonly bashAllow;
|
|
479
485
|
/** Calls waiting for a plan's pins to change (see `awaitPins`). */
|
|
480
486
|
private readonly pinWaiters;
|
|
481
487
|
/**
|
|
@@ -854,7 +860,14 @@ export declare class ReplayController {
|
|
|
854
860
|
* never be told something the log does not also say.
|
|
855
861
|
*/
|
|
856
862
|
private observeStep;
|
|
857
|
-
/**
|
|
863
|
+
/**
|
|
864
|
+
* Upsert one step's verdict. Later news about a step replaces earlier news.
|
|
865
|
+
*
|
|
866
|
+
* The verdict keeps what the step sent and what came back, when the caller
|
|
867
|
+
* has them. They are this run's, which the service can learn nowhere else:
|
|
868
|
+
* a matched turn is not recorded, so without them the scenario's page shows
|
|
869
|
+
* the recording's input and output beside this run's verdict.
|
|
870
|
+
*/
|
|
858
871
|
private recordStep;
|
|
859
872
|
/** The step verdicts, in chain order, for the execution report. */
|
|
860
873
|
private stepResultsOf;
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { parseQualifiedName } from "../control/correlation.js";
|
|
15
15
|
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
16
|
-
import { redact } from "../record/redact.js";
|
|
17
|
-
import { serializeCapped } from "../record/truncate.js";
|
|
16
|
+
import { REDACT_SCAN_MAX_CHARS, redact, redactJsonText, redactText } from "../record/redact.js";
|
|
17
|
+
import { capText, REPORT_PAYLOAD_MAX_BYTES, serializeCapped } from "../record/truncate.js";
|
|
18
18
|
import { logDetail, logLine, errText } from "../util/log.js";
|
|
19
19
|
import { serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
|
|
20
20
|
import { bashCommandFor } from "./bash-channel.js";
|
|
@@ -250,8 +250,14 @@ export class ReplayController {
|
|
|
250
250
|
this.intentMatch = { ...DEFAULT_INTENT_MATCH, ...(opts.intentMatch ?? {}) };
|
|
251
251
|
this.recordedFallback =
|
|
252
252
|
opts.recordedFallback ?? process.env.BIR_REPLAY_RECORDED_OUTPUTS === "1";
|
|
253
|
-
this.builtinTools = opts.builtinTools ?? parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
|
|
254
253
|
this.bashAllow = opts.bashAllow ?? parseBashAllow(process.env.BIR_BASH_ALLOW);
|
|
254
|
+
const builtins = opts.builtinTools ?? parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
|
|
255
|
+
// A `Bash` that may run no command is not reachable here. Left on the
|
|
256
|
+
// list, a chain of Bash steps would arm `direct` and stop at its first.
|
|
257
|
+
this.builtinTools =
|
|
258
|
+
this.bashAllow.length > 0 || !builtins.has("Bash")
|
|
259
|
+
? builtins
|
|
260
|
+
: new Set([...builtins].filter((name) => name !== "Bash"));
|
|
255
261
|
}
|
|
256
262
|
/**
|
|
257
263
|
* Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
|
|
@@ -773,7 +779,13 @@ export class ReplayController {
|
|
|
773
779
|
// The model does the task itself — told so in place of this call.
|
|
774
780
|
const step = plan.currentStep();
|
|
775
781
|
if (step && ready.blame) {
|
|
776
|
-
this.recordStep(state, {
|
|
782
|
+
this.recordStep(state, {
|
|
783
|
+
step,
|
|
784
|
+
entry: plan.currentEntry(),
|
|
785
|
+
outcome: "failed",
|
|
786
|
+
stage: ready.stage,
|
|
787
|
+
error: ready.error,
|
|
788
|
+
});
|
|
777
789
|
}
|
|
778
790
|
this.handOver(state, {
|
|
779
791
|
kind: "step_failed",
|
|
@@ -823,6 +835,7 @@ export class ReplayController {
|
|
|
823
835
|
reach: this.reachFor(state, step),
|
|
824
836
|
toolName,
|
|
825
837
|
pinnedAt: Date.now(),
|
|
838
|
+
input,
|
|
826
839
|
});
|
|
827
840
|
logDetail("replay.pin", { n: plan.currentStepIndex, tool: toolName });
|
|
828
841
|
return { kind: "pin", input, stepIndex: plan.currentStepIndex };
|
|
@@ -844,6 +857,7 @@ export class ReplayController {
|
|
|
844
857
|
if (step) {
|
|
845
858
|
this.recordStep(state, {
|
|
846
859
|
step,
|
|
860
|
+
entry: plan.currentEntry(),
|
|
847
861
|
outcome: "failed",
|
|
848
862
|
stage: "tool_input_logic",
|
|
849
863
|
error: errText(err),
|
|
@@ -1037,6 +1051,10 @@ export class ReplayController {
|
|
|
1037
1051
|
// reading it after the call would judge the *next* step, and reading it
|
|
1038
1052
|
// before assumes no other pin ever moved it.
|
|
1039
1053
|
const step = plan.allSteps()[pin.stepIndex];
|
|
1054
|
+
// The entry it ran as, so its verdict is kept under the scenario that owns
|
|
1055
|
+
// the step: a called segment numbers its steps from zero too, and without
|
|
1056
|
+
// this its step 0 replaced the caller's (segmented.md R-CALL-13).
|
|
1057
|
+
const entry = plan.allEntries()[pin.stepIndex];
|
|
1040
1058
|
// The output threads into the step at the cursor. One pin at a time
|
|
1041
1059
|
// (`preTool`) keeps that the pin's own step; if it ever is not, threading
|
|
1042
1060
|
// would feed one step's output to another's logic, so the plan stops.
|
|
@@ -1051,6 +1069,9 @@ export class ReplayController {
|
|
|
1051
1069
|
`the plan's cursor is at step ${plan.currentStepIndex}, not the pinned step ${pin.stepIndex}`;
|
|
1052
1070
|
const info = {
|
|
1053
1071
|
step,
|
|
1072
|
+
entry,
|
|
1073
|
+
input: pin.input,
|
|
1074
|
+
output: answerOf(output),
|
|
1054
1075
|
outcome: "failed",
|
|
1055
1076
|
stage: "tool_call",
|
|
1056
1077
|
error,
|
|
@@ -1062,7 +1083,7 @@ export class ReplayController {
|
|
|
1062
1083
|
this.handOver(state, {
|
|
1063
1084
|
kind: "step_failed",
|
|
1064
1085
|
step,
|
|
1065
|
-
entry
|
|
1086
|
+
entry,
|
|
1066
1087
|
position: pin.stepIndex,
|
|
1067
1088
|
error,
|
|
1068
1089
|
delivery: "above",
|
|
@@ -1084,6 +1105,10 @@ export class ReplayController {
|
|
|
1084
1105
|
if (step) {
|
|
1085
1106
|
this.recordStep(state, {
|
|
1086
1107
|
step,
|
|
1108
|
+
entry,
|
|
1109
|
+
input: pin.input,
|
|
1110
|
+
// What the logic threw on — the output is usually the explanation.
|
|
1111
|
+
output,
|
|
1087
1112
|
outcome: "failed",
|
|
1088
1113
|
stage: "tool_output_logic",
|
|
1089
1114
|
error: errText(err),
|
|
@@ -1119,6 +1144,9 @@ export class ReplayController {
|
|
|
1119
1144
|
if (step && toolError) {
|
|
1120
1145
|
const info = {
|
|
1121
1146
|
step,
|
|
1147
|
+
entry,
|
|
1148
|
+
input: pin.input,
|
|
1149
|
+
output,
|
|
1122
1150
|
outcome: "failed",
|
|
1123
1151
|
stage: "tool_call",
|
|
1124
1152
|
error: toolError,
|
|
@@ -1140,6 +1168,9 @@ export class ReplayController {
|
|
|
1140
1168
|
if (step) {
|
|
1141
1169
|
this.recordStep(state, {
|
|
1142
1170
|
step,
|
|
1171
|
+
entry,
|
|
1172
|
+
input: pin.input,
|
|
1173
|
+
output,
|
|
1143
1174
|
outcome: "executed",
|
|
1144
1175
|
ms: Date.now() - pin.pinnedAt,
|
|
1145
1176
|
});
|
|
@@ -1204,13 +1235,23 @@ export class ReplayController {
|
|
|
1204
1235
|
? `${failure.error} — it was sent, and may or may not have completed`
|
|
1205
1236
|
: failure.error
|
|
1206
1237
|
: toolResultError(output);
|
|
1238
|
+
const entry = plan.allEntries()[pin.stepIndex];
|
|
1207
1239
|
if (error !== undefined) {
|
|
1208
|
-
const info = {
|
|
1240
|
+
const info = {
|
|
1241
|
+
step,
|
|
1242
|
+
entry,
|
|
1243
|
+
input: pin.input,
|
|
1244
|
+
output: answerOf(output),
|
|
1245
|
+
outcome: "failed",
|
|
1246
|
+
stage: "tool_call",
|
|
1247
|
+
error,
|
|
1248
|
+
ms,
|
|
1249
|
+
};
|
|
1209
1250
|
this.logStep(info);
|
|
1210
1251
|
this.recordStep(state, info);
|
|
1211
1252
|
return;
|
|
1212
1253
|
}
|
|
1213
|
-
this.recordStep(state, { step, outcome: "executed", ms });
|
|
1254
|
+
this.recordStep(state, { step, entry, input: pin.input, output, outcome: "executed", ms });
|
|
1214
1255
|
state.stepsPinned += 1;
|
|
1215
1256
|
this.upgrade(state, "fell_back");
|
|
1216
1257
|
logDetail("replay.thread_late", {
|
|
@@ -2067,10 +2108,10 @@ export class ReplayController {
|
|
|
2067
2108
|
// An allow-listed built-in runs *here*, against the session's own
|
|
2068
2109
|
// project directory (replay/builtins.ts). Everything else about it —
|
|
2069
2110
|
// no session cwd (`bir replay`, an older server), off the list, no
|
|
2070
|
-
// runner-side implementation, a path that leaves the project, `Bash`
|
|
2071
|
-
//
|
|
2072
|
-
// the session can do the step itself or its recorded
|
|
2073
|
-
// stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
|
|
2111
|
+
// runner-side implementation, a path that leaves the project, a `Bash`
|
|
2112
|
+
// command `BIR_BASH_ALLOW` does not allow — stays the old contract:
|
|
2113
|
+
// `not_run`, so the session can do the step itself or its recorded
|
|
2114
|
+
// output can stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
|
|
2074
2115
|
if (this.builtinTools.has(name) && state.sessionCwd) {
|
|
2075
2116
|
return this.executeBuiltin(name, input, state, opts.deadline);
|
|
2076
2117
|
}
|
|
@@ -2302,7 +2343,14 @@ export class ReplayController {
|
|
|
2302
2343
|
this.recordStep(state, info);
|
|
2303
2344
|
};
|
|
2304
2345
|
}
|
|
2305
|
-
/**
|
|
2346
|
+
/**
|
|
2347
|
+
* Upsert one step's verdict. Later news about a step replaces earlier news.
|
|
2348
|
+
*
|
|
2349
|
+
* The verdict keeps what the step sent and what came back, when the caller
|
|
2350
|
+
* has them. They are this run's, which the service can learn nowhere else:
|
|
2351
|
+
* a matched turn is not recorded, so without them the scenario's page shows
|
|
2352
|
+
* the recording's input and output beside this run's verdict.
|
|
2353
|
+
*/
|
|
2306
2354
|
recordStep(state, info) {
|
|
2307
2355
|
const status = info.outcome === "executed" ? "ok" : info.outcome;
|
|
2308
2356
|
// Keyed by the *flat* position, not by the step's own index: two scenarios
|
|
@@ -2319,6 +2367,16 @@ export class ReplayController {
|
|
|
2319
2367
|
? `${info.error.slice(0, MAX_STEP_ERROR_CHARS)}…`
|
|
2320
2368
|
: info.error
|
|
2321
2369
|
: undefined;
|
|
2370
|
+
// Payloads only for a step of the reported scenario's own chain. The
|
|
2371
|
+
// console still files an execution's verdicts by step number alone
|
|
2372
|
+
// (plan-services.md W1.9), and a called segment numbers its steps from
|
|
2373
|
+
// zero as well: a called step's call would be shown under the caller's
|
|
2374
|
+
// step of the same number — the very mix-up the payloads are there to end.
|
|
2375
|
+
const own = !info.entry || info.entry.depth === 0;
|
|
2376
|
+
// A step whose input logic threw never had an input: the `{}` the plan
|
|
2377
|
+
// reports there is a placeholder, not a call with no arguments.
|
|
2378
|
+
const sent = own && info.stage !== "tool_input_logic" ? info.input : undefined;
|
|
2379
|
+
const received = own ? info.output : undefined;
|
|
2322
2380
|
state.stepResults.set(key, {
|
|
2323
2381
|
stepIndex: info.step.stepIndex,
|
|
2324
2382
|
toolName: info.step.toolName ?? "",
|
|
@@ -2328,6 +2386,8 @@ export class ReplayController {
|
|
|
2328
2386
|
stage: info.stage ?? (status === "skipped" ? "tool_call" : undefined),
|
|
2329
2387
|
error,
|
|
2330
2388
|
durationMs: info.ms,
|
|
2389
|
+
toolInput: sent === undefined ? undefined : reportPayload(serializeCapped(redact(sent)), true),
|
|
2390
|
+
toolOutput: received === undefined ? undefined : reportPayload(received),
|
|
2331
2391
|
// The tool is gone from its server: the service repairs the plan without
|
|
2332
2392
|
// waiting out the grace (plan-services.md D8, kind 2).
|
|
2333
2393
|
toolMissing: info.toolMissing || undefined,
|
|
@@ -2501,6 +2561,37 @@ function pinFailure(pin, output, failed, how) {
|
|
|
2501
2561
|
: "the tool reported an error");
|
|
2502
2562
|
return { error, reason };
|
|
2503
2563
|
}
|
|
2564
|
+
/**
|
|
2565
|
+
* What a call that did not thread answered with, if it answered anything.
|
|
2566
|
+
* `null` is a proxy that lost the call (tool-error.ts, `isNoResult`), and `{}`
|
|
2567
|
+
* is how a hook that carried no `tool_response` reaches here: neither is an
|
|
2568
|
+
* output anybody received.
|
|
2569
|
+
*/
|
|
2570
|
+
function answerOf(output) {
|
|
2571
|
+
return isNoResult(output) || output.trim() === "{}" ? undefined : output;
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* A step's input or output as its verdict carries it: redacted as a recorded
|
|
2575
|
+
* step's is, and cut — visibly — to what a report has room for.
|
|
2576
|
+
*
|
|
2577
|
+
* Cut as text rather than kept as JSON: these are read on the scenario's page,
|
|
2578
|
+
* never parsed, and the first kilobytes of the payload itself read better than
|
|
2579
|
+
* a preview of it inside a second layer of escapes.
|
|
2580
|
+
*
|
|
2581
|
+
* `clean` says it was redacted a moment ago — the input, serialized by the
|
|
2582
|
+
* caller. An output may not have been: the hook's view of a steered built-in
|
|
2583
|
+
* is threaded as the host gave it. So an output is redacted here, and as JSON,
|
|
2584
|
+
* where a secret is judged by its key as well as by its shape.
|
|
2585
|
+
*/
|
|
2586
|
+
function reportPayload(json, clean = false) {
|
|
2587
|
+
if (clean)
|
|
2588
|
+
return capText(json, REPORT_PAYLOAD_MAX_BYTES);
|
|
2589
|
+
// Larger than any producer here makes: only what a report keeps is scanned.
|
|
2590
|
+
if (json.length > REDACT_SCAN_MAX_CHARS) {
|
|
2591
|
+
return capText(json, REPORT_PAYLOAD_MAX_BYTES, redactText);
|
|
2592
|
+
}
|
|
2593
|
+
return capText(redactJsonText(json), REPORT_PAYLOAD_MAX_BYTES);
|
|
2594
|
+
}
|
|
2504
2595
|
/**
|
|
2505
2596
|
* The index a hand-over reports, in the *caller's* chain (R-CALL-32).
|
|
2506
2597
|
*
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* only happen inside the live session.
|
|
9
9
|
*
|
|
10
10
|
* `live` covers three genuinely different cases and treats them identically,
|
|
11
|
-
* correctly: a built-in
|
|
12
|
-
*
|
|
13
|
-
* — which is `scope: "dynamic"`, appears in no config
|
|
14
|
-
* never be wrapped at all.
|
|
11
|
+
* correctly: a built-in outside the built-in allow-list (`WebFetch`, `Task`,
|
|
12
|
+
* or one the operator took off it), an MCP server the user chose not to wrap,
|
|
13
|
+
* and `claude-in-chrome` — which is `scope: "dynamic"`, appears in no config
|
|
14
|
+
* file, and therefore can never be wrapped at all.
|
|
15
15
|
*
|
|
16
|
-
* A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, default
|
|
17
|
-
*
|
|
18
|
-
* under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves
|
|
16
|
+
* A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, by default all six the
|
|
17
|
+
* runner implements) is `direct`: the runner executes it itself
|
|
18
|
+
* (replay/builtins.ts), or — under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves
|
|
19
|
+
* its recorded output. The
|
|
19
20
|
* callers that ask a *different* question, "will a proxy report this call"
|
|
20
21
|
* (`ReplayController.reachFor`), deliberately pass no allow-list: a built-in
|
|
21
22
|
* is never proxied, whoever executes it.
|
package/dist/replay/coverage.js
CHANGED
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* only happen inside the live session.
|
|
9
9
|
*
|
|
10
10
|
* `live` covers three genuinely different cases and treats them identically,
|
|
11
|
-
* correctly: a built-in
|
|
12
|
-
*
|
|
13
|
-
* — which is `scope: "dynamic"`, appears in no config
|
|
14
|
-
* never be wrapped at all.
|
|
11
|
+
* correctly: a built-in outside the built-in allow-list (`WebFetch`, `Task`,
|
|
12
|
+
* or one the operator took off it), an MCP server the user chose not to wrap,
|
|
13
|
+
* and `claude-in-chrome` — which is `scope: "dynamic"`, appears in no config
|
|
14
|
+
* file, and therefore can never be wrapped at all.
|
|
15
15
|
*
|
|
16
|
-
* A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, default
|
|
17
|
-
*
|
|
18
|
-
* under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves
|
|
16
|
+
* A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, by default all six the
|
|
17
|
+
* runner implements) is `direct`: the runner executes it itself
|
|
18
|
+
* (replay/builtins.ts), or — under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves
|
|
19
|
+
* its recorded output. The
|
|
19
20
|
* callers that ask a *different* question, "will a proxy report this call"
|
|
20
21
|
* (`ReplayController.reachFor`), deliberately pass no allow-list: a built-in
|
|
21
22
|
* is never proxied, whoever executes it.
|
package/dist/replay/plan.d.ts
CHANGED
|
@@ -105,6 +105,12 @@ export interface StepInfo {
|
|
|
105
105
|
*/
|
|
106
106
|
entry?: FlatEntry;
|
|
107
107
|
input: Record<string, unknown>;
|
|
108
|
+
/**
|
|
109
|
+
* What the step's output logic was given: the tool's answer, or for
|
|
110
|
+
* `recorded` the source run's output that stood in for it. Absent when the
|
|
111
|
+
* call produced none — `skipped`, or a failure before the tool ran.
|
|
112
|
+
*/
|
|
113
|
+
output?: string;
|
|
108
114
|
outcome: "executed" | "recorded" | "skipped" | "failed";
|
|
109
115
|
/** Which part of the step broke. Only set for `failed`. */
|
|
110
116
|
stage?: ExecutionStage;
|
package/dist/replay/plan.js
CHANGED
|
@@ -215,8 +215,11 @@ function settingsOf(paramsObject, mapped) {
|
|
|
215
215
|
* still abandons the whole bundle rather than shipping a half-computed one. All
|
|
216
216
|
* this adds is that the failure is now *attributable* — "step 2's tool input
|
|
217
217
|
* logic", rather than a stack trace the console cannot line up against a chain.
|
|
218
|
+
*
|
|
219
|
+
* `output` is what the body was given, for a stage that runs on one: a throw
|
|
220
|
+
* in the output logic is only readable next to the output it threw on.
|
|
218
221
|
*/
|
|
219
|
-
function reportingStage(onStep, entry, stage, input, startedAt, fn) {
|
|
222
|
+
function reportingStage(onStep, entry, stage, input, startedAt, fn, output) {
|
|
220
223
|
try {
|
|
221
224
|
return fn();
|
|
222
225
|
}
|
|
@@ -225,6 +228,7 @@ function reportingStage(onStep, entry, stage, input, startedAt, fn) {
|
|
|
225
228
|
step: entry.step,
|
|
226
229
|
entry,
|
|
227
230
|
input,
|
|
231
|
+
output,
|
|
228
232
|
outcome: "failed",
|
|
229
233
|
stage,
|
|
230
234
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -817,6 +821,7 @@ export class ScenarioReplayPlan {
|
|
|
817
821
|
step,
|
|
818
822
|
entry,
|
|
819
823
|
input: computed,
|
|
824
|
+
output: response,
|
|
820
825
|
outcome: "failed",
|
|
821
826
|
stage: "tool_call",
|
|
822
827
|
error: toolError,
|
|
@@ -845,7 +850,7 @@ export class ScenarioReplayPlan {
|
|
|
845
850
|
Object.assign(resp.get(entry.frame.id) ?? {}, derived);
|
|
846
851
|
}
|
|
847
852
|
this.leaveFrame(entry, resp);
|
|
848
|
-
});
|
|
853
|
+
}, response);
|
|
849
854
|
}
|
|
850
855
|
catch (err) {
|
|
851
856
|
if (!stopOnFailure)
|
|
@@ -872,6 +877,7 @@ export class ScenarioReplayPlan {
|
|
|
872
877
|
step,
|
|
873
878
|
entry,
|
|
874
879
|
input: computed,
|
|
880
|
+
output: response,
|
|
875
881
|
outcome: error ? "failed" : "executed",
|
|
876
882
|
stage: error ? "tool_call" : undefined,
|
|
877
883
|
error,
|
|
@@ -977,6 +983,7 @@ export class ScenarioReplayPlan {
|
|
|
977
983
|
step,
|
|
978
984
|
entry,
|
|
979
985
|
input: computed,
|
|
986
|
+
output: response,
|
|
980
987
|
outcome: "failed",
|
|
981
988
|
stage: "tool_call",
|
|
982
989
|
error: toolError,
|
|
@@ -997,7 +1004,7 @@ export class ScenarioReplayPlan {
|
|
|
997
1004
|
}
|
|
998
1005
|
let derivedKeys;
|
|
999
1006
|
try {
|
|
1000
|
-
derivedKeys = reportingStage(onStep, entry, "tool_output_logic", computed, startedAt, () => this.applyOutput(response));
|
|
1007
|
+
derivedKeys = reportingStage(onStep, entry, "tool_output_logic", computed, startedAt, () => this.applyOutput(response), response);
|
|
1001
1008
|
}
|
|
1002
1009
|
catch (err) {
|
|
1003
1010
|
if (!stopOnFailure)
|
|
@@ -1024,6 +1031,7 @@ export class ScenarioReplayPlan {
|
|
|
1024
1031
|
step,
|
|
1025
1032
|
entry,
|
|
1026
1033
|
input: computed,
|
|
1034
|
+
output: response,
|
|
1027
1035
|
outcome: error ? "failed" : "executed",
|
|
1028
1036
|
stage: error ? "tool_call" : undefined,
|
|
1029
1037
|
error,
|
|
@@ -1095,6 +1103,8 @@ export class ScenarioReplayPlan {
|
|
|
1095
1103
|
step,
|
|
1096
1104
|
entry,
|
|
1097
1105
|
input: computed,
|
|
1106
|
+
// The stand-in is what the step's output logic runs on from here.
|
|
1107
|
+
output: standIn,
|
|
1098
1108
|
outcome: standIn !== undefined ? "recorded" : "skipped",
|
|
1099
1109
|
error,
|
|
1100
1110
|
toolMissing: isMissingToolError(error) || undefined,
|
package/dist/replay/types.d.ts
CHANGED
|
@@ -249,6 +249,22 @@ export interface ExecutionStepResult {
|
|
|
249
249
|
/** Message only — never a tool payload; this is rendered in a web page. */
|
|
250
250
|
error?: string;
|
|
251
251
|
durationMs?: number;
|
|
252
|
+
/**
|
|
253
|
+
* What the step sent its tool on this run: the arguments its input logic
|
|
254
|
+
* computed, serialized and redacted as a recorded step's are, and cut to
|
|
255
|
+
* what a report carries. Absent when the step broke before it had any.
|
|
256
|
+
*
|
|
257
|
+
* Without the two payloads the scenario's page can only put the recording's
|
|
258
|
+
* sample under this run's verdict and time — a call that was not made, and
|
|
259
|
+
* an answer that was not given, on a card that says "passed".
|
|
260
|
+
*/
|
|
261
|
+
toolInput?: string;
|
|
262
|
+
/**
|
|
263
|
+
* What the step's output logic was given, on the same terms: the tool's
|
|
264
|
+
* answer, or for `recorded` the source run's output that stood in for it.
|
|
265
|
+
* Absent on a step that got none.
|
|
266
|
+
*/
|
|
267
|
+
toolOutput?: string;
|
|
252
268
|
/**
|
|
253
269
|
* The step's tool no longer exists on its server, read from the error text
|
|
254
270
|
* (plan-services.md D8, kind 2). The service then repairs the plan without
|