@basein/runner 0.1.0
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/LICENSE +201 -0
- package/README.md +276 -0
- package/dist/auth/client.d.ts +85 -0
- package/dist/auth/client.js +284 -0
- package/dist/bin/bir-hooks.d.ts +48 -0
- package/dist/bin/bir-hooks.js +201 -0
- package/dist/bin/bir-proxy.d.ts +45 -0
- package/dist/bin/bir-proxy.js +207 -0
- package/dist/bin/bir-scenario.d.ts +24 -0
- package/dist/bin/bir-scenario.js +177 -0
- package/dist/bin/bir.d.ts +21 -0
- package/dist/bin/bir.js +876 -0
- package/dist/config/adapters/claude-code.d.ts +76 -0
- package/dist/config/adapters/claude-code.js +181 -0
- package/dist/config/adapters/generic.d.ts +17 -0
- package/dist/config/adapters/generic.js +36 -0
- package/dist/config/generate.d.ts +127 -0
- package/dist/config/generate.js +114 -0
- package/dist/config/resolve.d.ts +68 -0
- package/dist/config/resolve.js +132 -0
- package/dist/control/client.d.ts +56 -0
- package/dist/control/client.js +86 -0
- package/dist/control/correlation.d.ts +86 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/discovery.d.ts +50 -0
- package/dist/control/discovery.js +123 -0
- package/dist/control/ordering.d.ts +38 -0
- package/dist/control/ordering.js +44 -0
- package/dist/control/paths.d.ts +32 -0
- package/dist/control/paths.js +56 -0
- package/dist/control/server.d.ts +272 -0
- package/dist/control/server.js +1131 -0
- package/dist/control/transcript.d.ts +75 -0
- package/dist/control/transcript.js +241 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +32 -0
- package/dist/jsonrpc/framing.d.ts +49 -0
- package/dist/jsonrpc/framing.js +143 -0
- package/dist/jsonrpc/types.d.ts +52 -0
- package/dist/jsonrpc/types.js +46 -0
- package/dist/proxy/intercept.d.ts +55 -0
- package/dist/proxy/intercept.js +147 -0
- package/dist/proxy/relay.d.ts +97 -0
- package/dist/proxy/relay.js +166 -0
- package/dist/proxy/session.d.ts +116 -0
- package/dist/proxy/session.js +319 -0
- package/dist/record/housekeeping.d.ts +34 -0
- package/dist/record/housekeeping.js +39 -0
- package/dist/record/queue.d.ts +48 -0
- package/dist/record/queue.js +96 -0
- package/dist/record/recorder.d.ts +111 -0
- package/dist/record/recorder.js +39 -0
- package/dist/record/redact.d.ts +37 -0
- package/dist/record/redact.js +119 -0
- package/dist/record/remote-recorder.d.ts +110 -0
- package/dist/record/remote-recorder.js +301 -0
- package/dist/record/truncate.d.ts +36 -0
- package/dist/record/truncate.js +85 -0
- package/dist/replay/bundle.d.ts +36 -0
- package/dist/replay/bundle.js +89 -0
- package/dist/replay/controller.d.ts +300 -0
- package/dist/replay/controller.js +807 -0
- package/dist/replay/coverage.d.ts +41 -0
- package/dist/replay/coverage.js +56 -0
- package/dist/replay/derive.d.ts +58 -0
- package/dist/replay/derive.js +166 -0
- package/dist/replay/executor.d.ts +78 -0
- package/dist/replay/executor.js +233 -0
- package/dist/replay/logic.d.ts +31 -0
- package/dist/replay/logic.js +50 -0
- package/dist/replay/plan.d.ts +181 -0
- package/dist/replay/plan.js +397 -0
- package/dist/replay/pricing.d.ts +41 -0
- package/dist/replay/pricing.js +76 -0
- package/dist/replay/source-run.d.ts +50 -0
- package/dist/replay/source-run.js +98 -0
- package/dist/replay/tool-error.d.ts +22 -0
- package/dist/replay/tool-error.js +60 -0
- package/dist/replay/types.d.ts +116 -0
- package/dist/replay/types.js +35 -0
- package/dist/upstream/client.d.ts +78 -0
- package/dist/upstream/client.js +114 -0
- package/dist/upstream/http-client.d.ts +78 -0
- package/dist/upstream/http-client.js +261 -0
- package/dist/upstream/lazy-client.d.ts +31 -0
- package/dist/upstream/lazy-client.js +53 -0
- package/dist/upstream/stdio-client.d.ts +57 -0
- package/dist/upstream/stdio-client.js +203 -0
- package/dist/util/log.d.ts +27 -0
- package/dist/util/log.js +51 -0
- package/dist/util/version.d.ts +2 -0
- package/dist/util/version.js +40 -0
- package/docs/BaseInstRunner.md +621 -0
- package/docs/calculatedReplay.md +1185 -0
- package/docs/calculatedReplayGuide.md +448 -0
- package/docs/installRun.md +413 -0
- package/docs/mcpmark.md +752 -0
- package/docs/quickstart.md +201 -0
- package/docs/t-bench.md +394 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
# Calculated Replay — running a scenario on a similar-meaning match
|
|
2
|
+
|
|
3
|
+
> Design doc for BaseInstRunnerMCP **v2** ([BaseInstRunner.md](BaseInstRunner.md) §12), the
|
|
4
|
+
> milestone the v1 design deferred: *"Serve recorded results; port RRepeat's divergence handling."*
|
|
5
|
+
>
|
|
6
|
+
> **What it adds.** When the user's prompt means the same thing as a prompt they have run before,
|
|
7
|
+
> the BaseIn service says so and hands back the *calculated scenario* derived from that earlier run.
|
|
8
|
+
> This document specifies what BaseInstRunnerMCP does with it: derive the new prompt's parameters,
|
|
9
|
+
> run the scenario's tool sequence against live data, and let the model answer from genuine results —
|
|
10
|
+
> instead of rediscovering the same plan token by token.
|
|
11
|
+
>
|
|
12
|
+
> **Status:** implemented. Phases R0–R7 (§17) are in `src/replay/`, wired through the control server
|
|
13
|
+
> and the proxies, covered by `test/replay.test.ts` and `test/replay-server.test.ts`, and proved end
|
|
14
|
+
> to end with real processes by `test/smoke-replay.mjs`. Three notes below are marked **[built]**
|
|
15
|
+
> where the implementation settled something this design left open.
|
|
16
|
+
> **Reference implementation:** RRepeat's [`docs/replay.md`](../../../BasInstRunner/RRepeat/docs/replay.md)
|
|
17
|
+
> §3, §12, §13 and [`docs/savingCalcRunner.md`](../../../BasInstRunner/RRepeat/docs/savingCalcRunner.md).
|
|
18
|
+
> **Server contract:** BaseIn `src/recordings/routes.ts`, `src/scenarios/{routes,repo,replay,logic}.ts`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 0. The one fact that makes this different from RRepeat
|
|
23
|
+
|
|
24
|
+
RRepeat and BaseInstRunnerMCP are solving the same problem from opposite sides of the tool call.
|
|
25
|
+
|
|
26
|
+
RRepeat **observes** a Claude Code session through hooks. It has no execution path of its own, so
|
|
27
|
+
when a replay diverges it must spawn a *second* Claude Agent SDK session to run the remaining tools
|
|
28
|
+
for real (`sdk-tool-session.ts`, `replay.md` §13). Its own savings doc names the consequence:
|
|
29
|
+
|
|
30
|
+
> *"the **dominant** cost of today's calculated run is a full second agent session, and it is the one
|
|
31
|
+
> cost nobody counts."* — `savingCalcRunner.md` §4
|
|
32
|
+
|
|
33
|
+
BaseInstRunnerMCP **is** the execution path for MCP. Every wrapped server is reached through a
|
|
34
|
+
`bir-proxy` process that already holds an initialized, live `UpstreamClient` to it
|
|
35
|
+
([`src/upstream/client.ts`](../src/upstream/client.ts)). Issuing a `tools/call` on that connection
|
|
36
|
+
costs **zero tokens** and requires no second agent, no subprocess, and no model in the loop.
|
|
37
|
+
|
|
38
|
+
Everything below follows from that. Where RRepeat has one replay mode and an expensive fallback,
|
|
39
|
+
this design has a ladder whose cheapest rung is free — and the more of a stack sits behind
|
|
40
|
+
`bir-proxy`, the further down the ladder a scenario lands.
|
|
41
|
+
|
|
42
|
+
| | RRepeat | BaseInstRunnerMCP |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Runs MCP tools itself | No (`replay.md` §12: *"rrepeat never executes tools — not even MCP"*) | **Yes**, on the connection it already owns |
|
|
45
|
+
| Divergence fallback | A second Agent SDK session — the dominant cost | Direct `tools/call` through the proxies — **$0** |
|
|
46
|
+
| Reaches built-ins (`Bash`, `Read`) | Only by steering the live session | Same — only by steering the live session |
|
|
47
|
+
| Needs `@anthropic-ai/*` at runtime | Yes | No (§16.3) |
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 1. Decisions locked
|
|
52
|
+
|
|
53
|
+
Continuing v1's D1–D8.
|
|
54
|
+
|
|
55
|
+
| # | Decision | Choice | Consequence |
|
|
56
|
+
|---|---|---|---|
|
|
57
|
+
| **D9** | What a match buys | **Execute the scenario**, do not merely stop recording | v1's `watchForMatch` already detects the hit and stops; this replaces the no-op with work |
|
|
58
|
+
| **D10** | Who decides the mode | **Tool coverage**, computed per scenario at arm time (§5) | A fully-wrapped scenario replays for free; a mixed one steers the live session |
|
|
59
|
+
| **D11** | Execution ownership for MCP steps | **The proxy that owns the upstream** | No second connection, no double-spawned browser, no Agent SDK dependency |
|
|
60
|
+
| **D12** | Control → proxy channel | **Long-poll**, proxy dials out | No new listeners, no new ports, no new tokens (§13.3, §19.2) |
|
|
61
|
+
| **D13** | Result delivery in direct mode | A **first-party MCP server** (`bir`), one tool | The model reads a genuine `tool_result`, never a `deny` reason it distrusts (§19.1) |
|
|
62
|
+
| **D14** | Default state | **Off.** `BIR_REPLAY=1` plus `bir install --replay` | Replay bypasses permission prompts (§13.2); opt-in is the only defensible default |
|
|
63
|
+
| **D15** | Parameter derivation | One Haiku call over raw `fetch`; **no API key ⇒ recorded sample values** | Preserves v1's zero-runtime-dependency property, and degrades to a free replay |
|
|
64
|
+
| **D16** | Savings reporting | Every armed match reports to `POST /scenarios/:id/executions`, including declines | A decline is a *baseline sample*, not silence — it is what keeps the ledger honest |
|
|
65
|
+
|
|
66
|
+
### 1.1 Non-goals
|
|
67
|
+
|
|
68
|
+
- **Replay in Tier 2.** A match is a match *on the prompt*, and a standalone proxy never sees one
|
|
69
|
+
(§0.1 of the v1 design). Tier 2 gets the manual `bir replay` command (§12.1) and nothing automatic.
|
|
70
|
+
- **Chaining scenarios.** RRepeat's `next`/`getScenarioChain` has no counterpart in BaseIn's
|
|
71
|
+
`serializeScenario` payload, which serializes exactly one scenario. Out of scope.
|
|
72
|
+
- **Editing tool descriptions to steer selection.** Still a non-goal, unchanged. §6's `bir` server
|
|
73
|
+
adds one *first-party* tool and touches no upstream schema — and it costs context in every session,
|
|
74
|
+
which is why D14 makes it opt-in.
|
|
75
|
+
- **Recording the replayed turn.** The service created no run for it, by design. §12.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 2. The match, and where it already works
|
|
80
|
+
|
|
81
|
+
Nothing in this section is new. It is already implemented and already runs on every prompt.
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
UserPromptSubmit
|
|
85
|
+
└─ ControlServer.onPrompt src/control/server.ts
|
|
86
|
+
└─ ensureRun → recorder.startRun(input, metadata)
|
|
87
|
+
└─ RemoteRecorder.startRun POST /recordings/runs { id, framework, input, metadata }
|
|
88
|
+
└─ BaseIn recordings/routes.ts:
|
|
89
|
+
findSimilarRun(userId, input) ← embed + cosine over the user's runs
|
|
90
|
+
├─ miss → 201 { id, matched: null }
|
|
91
|
+
└─ hit → 200 { id: null, matched: {...} } ← no run is created
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
On a hit the server answers with the whole thing — there is no follow-up fetch:
|
|
95
|
+
|
|
96
|
+
```jsonc
|
|
97
|
+
{
|
|
98
|
+
"id": null,
|
|
99
|
+
"matched": {
|
|
100
|
+
"runId": "run_…", // the canonical earlier run
|
|
101
|
+
"scenarioId": "scn_…", // its calculated scenario, when one is ready
|
|
102
|
+
"similarity": 0.94, // cosine, ≥ SIMILARITY_THRESHOLD (server default 0.9)
|
|
103
|
+
"executionTicket": "tkt_…", // redeem exactly once at POST /scenarios/:id/executions
|
|
104
|
+
"scenario": { /* serializeScenario(): intent, paramsObject, paramsLogic,
|
|
105
|
+
responseParamsLogic, steps[{ stepIndex, toolName,
|
|
106
|
+
toolInputLogic, toolOutputLogic, reasoning }] */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
[`RemoteRecorder.getMatch(runId)`](../src/record/remote-recorder.ts) already exposes it, and
|
|
112
|
+
[`ControlServer.watchForMatch`](../src/control/server.ts) already consumes it — to do exactly one
|
|
113
|
+
thing:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
run.recording = false;
|
|
117
|
+
logLine("run.matched", { …, why: "similar prompt — the service kept its own run; not recording this one" });
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**That is the whole of v1's replay support: a correct stop.** This design replaces the stop with a
|
|
121
|
+
plan, and keeps the stop.
|
|
122
|
+
|
|
123
|
+
### 2.1 What "similar meaning" means, precisely
|
|
124
|
+
|
|
125
|
+
Server-side, and worth stating because the threshold in §4 is a judgement about it:
|
|
126
|
+
|
|
127
|
+
- The embedding is of the run's **`input`** — the user's prompt — computed by
|
|
128
|
+
`Xenova/all-MiniLM-L6-v2` (`EMBEDDING_MODEL`), mean-pooled and normalized
|
|
129
|
+
(`recordings/embeddings.ts`).
|
|
130
|
+
- Candidates are **the caller's own canonical runs only** (`listUserRunEmbeddings(userId)`);
|
|
131
|
+
duplicates and sub-threshold runs are excluded from the pool at record time.
|
|
132
|
+
- The match is the single best cosine ≥ `SIMILARITY_THRESHOLD` (default **0.9**).
|
|
133
|
+
- A matched run only carries a `scenarioId` when its scenario exists **and** is `state: 'ready'`.
|
|
134
|
+
|
|
135
|
+
So "similar meaning" is: *the same user asked for something that embeds within 0.1 cosine of this,
|
|
136
|
+
and we finished calculating a reusable plan for it.* It is not "the same words", and it is not
|
|
137
|
+
cross-tenant.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 3. Architecture
|
|
142
|
+
|
|
143
|
+
```mermaid
|
|
144
|
+
graph TB
|
|
145
|
+
subgraph host["Claude Code session"]
|
|
146
|
+
M[model loop]
|
|
147
|
+
H[hooks]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
subgraph bir["BaseInstRunnerMCP"]
|
|
151
|
+
C["bir-hooks — control server<br/>ScenarioReplayPlan lives here"]
|
|
152
|
+
S["bir-scenario<br/>MCP server: run_scenario"]
|
|
153
|
+
P1["bir-proxy — chrome-devtools"]
|
|
154
|
+
P2["bir-proxy — github"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
U1[chrome-devtools-mcp]
|
|
158
|
+
U2[github mcp]
|
|
159
|
+
R[("BaseIn /recordings/* /scenarios/*")]
|
|
160
|
+
|
|
161
|
+
H -->|"UserPromptSubmit → directive"| C
|
|
162
|
+
H -->|"PreToolUse → pinned updatedInput"| C
|
|
163
|
+
H -->|"PostToolUse → real output"| C
|
|
164
|
+
M -->|"mcp__bir__run_scenario"| S
|
|
165
|
+
S -->|"POST /scenario/run"| C
|
|
166
|
+
C -.->|"work: tools/call"| P1
|
|
167
|
+
C -.->|"work: tools/call"| P2
|
|
168
|
+
P1 -->|"bir: id request"| U1
|
|
169
|
+
P2 -->|"bir: id request"| U2
|
|
170
|
+
C -->|"match + execution report"| R
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Three additions to the v1 topology, and nothing else moves:
|
|
174
|
+
|
|
175
|
+
| Component | New? | Role in replay |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `bir-hooks` control server | extended | Holds the `ScenarioReplayPlan` on `RunState`. Arms it, pins inputs, threads outputs, dispatches direct steps, redeems the ticket |
|
|
178
|
+
| `bir-proxy` | extended | Long-polls for work; executes a `tools/call` on the upstream it already owns |
|
|
179
|
+
| `bir-scenario` | **new**, ~120 lines | A stdio MCP server exposing one tool, `run_scenario`. The delivery channel for direct mode (§6) |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 4. The gate ladder
|
|
184
|
+
|
|
185
|
+
Ported from RRepeat's `applyMatch`, with one gate this repo needs and RRepeat cannot have.
|
|
186
|
+
Every gate that declines logs **one** `replay.decision` line carrying the reason — because
|
|
187
|
+
*"a scenario existed and was not used"* is a fact an auditor needs as much as *"a scenario ran"*.
|
|
188
|
+
|
|
189
|
+
| # | Gate | Declines when | Outcome floor |
|
|
190
|
+
|---|---|---|---|
|
|
191
|
+
| 1 | **A match arrived** | `getMatch()` resolved `null`, or the budget expired (§10) | (no report — nothing matched) |
|
|
192
|
+
| 2 | **Replay enabled** | `BIR_REPLAY !== "1"` | `not_steered` |
|
|
193
|
+
| 3 | **Scenario ready** | `scenario == null`, `state !== "ready"`, or `steps.length === 0` | `not_steered` |
|
|
194
|
+
| 4 | **Similarity** | `similarity < BIR_MIN_STEER_SIMILARITY` (default **0.92**) | `not_steered` |
|
|
195
|
+
| 5 | **Tool coverage** | No step is executable anywhere (§5 → `mode: "none"`) | `not_steered` |
|
|
196
|
+
|
|
197
|
+
Gate 4 deserves its own note. The server's own detection threshold is 0.9, and it uses it to decide
|
|
198
|
+
*"do not record this again"* — a cheap claim: the worst case is a lost recording. Steering makes a
|
|
199
|
+
much stronger claim: *"do not think about this again."* A prompt that is 0.90-similar usually covers
|
|
200
|
+
only part of the live task, and steering it produces a confident, thin, wrong answer. So the steer
|
|
201
|
+
threshold sits **above** the detection threshold by default, and `BIR_MIN_STEER_SIMILARITY` is the
|
|
202
|
+
single knob an operator turns after watching `replay.decision` lines for a week.
|
|
203
|
+
|
|
204
|
+
`not_steered` as the floor is not a failure code. It is the **control group**: the prompt matched,
|
|
205
|
+
the agent ran the task the ordinary way, and the cost of doing so is a fresh measurement of what this
|
|
206
|
+
task costs — which is exactly what a baseline is made of. BaseIn's
|
|
207
|
+
`POST /scenarios/:id/executions` routes `not_steered` to `addBaselineSample` rather than the
|
|
208
|
+
savings ledger, on purpose (`scenarios/routes.ts`). §11.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 5. Tool coverage decides the mode
|
|
213
|
+
|
|
214
|
+
The classification is a pure function of the scenario's steps and the set of server keys registered
|
|
215
|
+
with this control server (`ControlServer.wrapped`, grown by `/proxy/register`).
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
// src/replay/coverage.ts
|
|
219
|
+
export type StepReach = "direct" | "live";
|
|
220
|
+
export type ReplayMode = "direct" | "steer" | "none";
|
|
221
|
+
|
|
222
|
+
export function reachOf(toolName: string, wrapped: ReadonlySet<string>): StepReach {
|
|
223
|
+
const mcp = parseQualifiedName(toolName); // src/control/correlation.ts
|
|
224
|
+
return mcp && wrapped.has(mcp.serverName) ? "direct" : "live";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function modeFor(steps: SerializedScenarioStep[], wrapped: ReadonlySet<string>): ReplayMode {
|
|
228
|
+
if (steps.length === 0) return "none";
|
|
229
|
+
return steps.every((s) => reachOf(s.toolName, wrapped) === "direct") ? "direct" : "steer";
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
| Every step | Mode | Where the tools run | Model spend |
|
|
234
|
+
|---|---|---|---|
|
|
235
|
+
| a wrapped MCP tool | **`direct`** (§6) | The proxies, on connections already open | one Haiku derivation + one turn that reads the results |
|
|
236
|
+
| anything else, or mixed | **`steer`** (§7) | The live session, inputs pinned | one Haiku derivation + the live turn |
|
|
237
|
+
| — | `none` | nowhere | declines at gate 5 |
|
|
238
|
+
|
|
239
|
+
`live` covers three genuinely different cases and treats them identically, correctly: a built-in
|
|
240
|
+
(`Bash`, `Read`, `Edit`, `Grep`, `Task`, `WebFetch`), an MCP server the user chose not to wrap, and
|
|
241
|
+
`claude-in-chrome` — which is `scope: "dynamic"`, appears in no config file, and therefore can never
|
|
242
|
+
be wrapped (v1 design §1). None of them can be reached except through the session.
|
|
243
|
+
|
|
244
|
+
A **mixed** scenario in `steer` mode still gets direct execution for its wrapped steps once it
|
|
245
|
+
diverges (§8), so coverage is not all-or-nothing at the divergence boundary — only at the arm-time
|
|
246
|
+
mode choice.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 6. Mode A — direct execution
|
|
251
|
+
|
|
252
|
+
The cheap rung. Every step is a wrapped MCP tool, so the model never has to emit a single tool call.
|
|
253
|
+
|
|
254
|
+
### 6.1 Sequence
|
|
255
|
+
|
|
256
|
+
```mermaid
|
|
257
|
+
sequenceDiagram
|
|
258
|
+
participant CC as Claude Code (model)
|
|
259
|
+
participant CS as bir-hooks (control server)
|
|
260
|
+
participant SC as bir-scenario (MCP)
|
|
261
|
+
participant PX as bir-proxy
|
|
262
|
+
participant UP as upstream MCP server
|
|
263
|
+
|
|
264
|
+
CC->>CS: UserPromptSubmit
|
|
265
|
+
CS->>CS: gates pass, mode=direct → arm plan; start derivation (not awaited)
|
|
266
|
+
CS-->>CC: additionalContext: "call mcp__bir__run_scenario once"
|
|
267
|
+
CC->>SC: tools/call run_scenario {}
|
|
268
|
+
SC->>CS: POST /scenario/run
|
|
269
|
+
CS->>CS: await plan.ready() (derivation lands)
|
|
270
|
+
loop each step
|
|
271
|
+
CS->>CS: toolInputForCurrentStep()
|
|
272
|
+
CS-->>PX: work { workId, toolName, arguments } (answers an open long-poll)
|
|
273
|
+
PX->>UP: tools/call (bir: id, on the open connection)
|
|
274
|
+
UP-->>PX: CallToolResult
|
|
275
|
+
PX->>CS: POST /proxy/result { workId, result }
|
|
276
|
+
CS->>CS: applyOutput(JSON.stringify(result)) → respParams
|
|
277
|
+
end
|
|
278
|
+
CS->>CS: responseParamsLogic → response model; assembleBundle()
|
|
279
|
+
CS-->>SC: { text, responseModel, steps }
|
|
280
|
+
SC-->>CC: tool_result (genuine)
|
|
281
|
+
CC->>CC: answers from the results
|
|
282
|
+
CC->>CS: Stop → seal, report execution (ticket)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### 6.2 Why a first-party MCP server, and not `additionalContext`
|
|
286
|
+
|
|
287
|
+
Executing inside `UserPromptSubmit` and returning the results as `additionalContext` is the obvious
|
|
288
|
+
shortcut, and it is wrong for three separate reasons:
|
|
289
|
+
|
|
290
|
+
1. **Budget.** `UserPromptSubmit` has a hook timeout (§10). N live MCP round trips do not fit inside
|
|
291
|
+
one, and if it expires Claude Code proceeds *without* the context — so the work is burned and the
|
|
292
|
+
user waits for nothing.
|
|
293
|
+
2. **Trust.** RRepeat learned this expensively. Its `deny`-with-a-reason delivery is *"inherently
|
|
294
|
+
read as a denial by the model"* (`hook-server.ts`), which is why it grew a Bash-rewrite path and
|
|
295
|
+
then a whole MCP bridge (`mcp-bridge.ts`) to hand the same bytes back as a genuine `tool_result`.
|
|
296
|
+
A first-party MCP tool is that bridge, without the shadowing.
|
|
297
|
+
3. **Truth.** A `tool_result` in the transcript is a real record of a real call. `additionalContext`
|
|
298
|
+
asserting that tools ran is a claim the transcript cannot corroborate.
|
|
299
|
+
|
|
300
|
+
The cost is honest and worth naming: one extra tool in the model's tool list, in **every** session
|
|
301
|
+
where `bir install --replay` ran. That is why it is a separate install flag and why `bir status`
|
|
302
|
+
prints it.
|
|
303
|
+
|
|
304
|
+
### 6.3 `run_scenario`
|
|
305
|
+
|
|
306
|
+
```jsonc
|
|
307
|
+
// tools/list, from bir-scenario
|
|
308
|
+
{
|
|
309
|
+
"name": "run_scenario",
|
|
310
|
+
"description":
|
|
311
|
+
"Run the pre-calculated tool sequence BaseInstRunner has armed for the current request, and return its results. Call once, with no arguments, when instructed to.",
|
|
312
|
+
"inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
No arguments **by design**: the armed plan is server-side state keyed by the control server's open
|
|
317
|
+
run, so there is nothing for the model to get wrong and nothing it can point at a different scenario.
|
|
318
|
+
When no plan is armed the tool returns `isError: true` with `"no scenario is armed for this turn"`,
|
|
319
|
+
which is a tool failure the model reacts to normally.
|
|
320
|
+
|
|
321
|
+
`bir-scenario` is a client of the control server, discovered the same way a proxy discovers it —
|
|
322
|
+
`~/.baseinstrunner/control/<key>.json`, mode `0600`, bearer token
|
|
323
|
+
([`src/control/discovery.ts`](../src/control/discovery.ts)). It holds no state, no credentials and
|
|
324
|
+
no upstream connections.
|
|
325
|
+
|
|
326
|
+
### 6.4 The steering directive (direct)
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
[BaseInstRunner] A known-good tool sequence was recorded for this request
|
|
330
|
+
(intent: <scenario.intent>). Call mcp__bir__run_scenario once, with no
|
|
331
|
+
arguments, before any other tool. It runs the sequence and returns the
|
|
332
|
+
results. Answer the user from those results.
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
If the model calls something else first, the turn falls to §8 — the plan is still armed and the
|
|
336
|
+
divergence path still executes it.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## 7. Mode B — model-steered live replay
|
|
341
|
+
|
|
342
|
+
The mixed rung. Ported from RRepeat's `replay.md` §3/§12, which is the right design and is already
|
|
343
|
+
proven: the model chooses *which* tool and *when*; the control server overrides the *arguments* and
|
|
344
|
+
threads state between steps. The session never leaves the model's hands, so every
|
|
345
|
+
`tool_use`/`tool_result` in the transcript is genuine and continuation is automatic.
|
|
346
|
+
|
|
347
|
+
**Arm** at `UserPromptSubmit` — plan on `run.plan`, `run.planToolUse = new Set()`, directive via
|
|
348
|
+
`additionalContext`, **no `decision`** so the prompt still reaches the model:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
[BaseInstRunner calculated replay] A known-good tool sequence was previously
|
|
352
|
+
recorded for this request. Call exactly these tools, in order, one at a time:
|
|
353
|
+
|
|
354
|
+
1. mcp__chrome-devtools__navigate_page — open the fleet dashboard
|
|
355
|
+
2. mcp__chrome-devtools__take_snapshot — read the device table
|
|
356
|
+
3. Read — load the alert thresholds
|
|
357
|
+
|
|
358
|
+
The system supplies the exact arguments for each call — you do not need to
|
|
359
|
+
compute them. Do not call any other tools until this sequence is complete,
|
|
360
|
+
then answer the user's request from the tool results.
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**Pin** at `PreToolUse`, while a plan is active and the call is not `mcp__bir__run_scenario`:
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
if (toolName === plan.expectedTool()) {
|
|
367
|
+
const input = plan.toolInputForCurrentStep(); // may throw → §8
|
|
368
|
+
run.planToolUse.add(toolUseId);
|
|
369
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse",
|
|
370
|
+
permissionDecision: "allow",
|
|
371
|
+
updatedInput: withCallId(input) } }; // §7.1
|
|
372
|
+
}
|
|
373
|
+
return divergence(…); // §8
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Thread** at `PostToolUse`, for a `tool_use_id ∈ run.planToolUse` — but *from the right source*
|
|
377
|
+
(§7.2). When `plan.isDone()`, retire the plan; the model then writes its answer from the real tool
|
|
378
|
+
history.
|
|
379
|
+
|
|
380
|
+
### 7.1 Steering and correlation both write `updatedInput` — they must merge
|
|
381
|
+
|
|
382
|
+
This collision does not exist in RRepeat and is easy to miss here.
|
|
383
|
+
|
|
384
|
+
`onToolPre` already returns an `updatedInput` for every **wrapped MCP** call: it is how the
|
|
385
|
+
correlation id rides along to the proxy ([`src/control/correlation.ts`](../src/control/correlation.ts)).
|
|
386
|
+
A pinned step whose tool is a wrapped MCP tool therefore needs *both* payloads in one object, and the
|
|
387
|
+
order is not arbitrary:
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
/** Pinned arguments plus, for a wrapped MCP tool, the correlation id. */
|
|
391
|
+
function withCallId(pinned: Record<string, unknown>): Record<string, unknown> {
|
|
392
|
+
const mcp = parseQualifiedName(toolName);
|
|
393
|
+
if (!mcp || !this.wrapped.has(mcp.serverName) || this.opts.noCorrelation) return pinned;
|
|
394
|
+
const correlation = mintCorrelation(run, mcp, pinned, context); // hookArgs = the PINNED args
|
|
395
|
+
return { ...pinned, [BIR_CALL_ID]: correlation.callId };
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Three consequences, all load-bearing:
|
|
400
|
+
|
|
401
|
+
- **The correlation's `hookArgs` must be the pinned arguments, not the model's.** `settle()` records
|
|
402
|
+
`correlation.hookArgs ?? report.args`; recording the arguments the model *proposed* rather than the
|
|
403
|
+
ones that *ran* would put a lie in the corpus. (In a replayed turn nothing is recorded — §12 — but
|
|
404
|
+
the same code path serves recorded turns, and the invariant must hold there.)
|
|
405
|
+
- **Schema relaxation must already be active**, or the host may reject the extra key. It is, whenever
|
|
406
|
+
Tier 1 correlation is on (`ProxySession.correlationActive`). Under `--no-correlation`, `withCallId`
|
|
407
|
+
is a no-op and pinning still works — only the *result* join degrades to fingerprints.
|
|
408
|
+
- **`permissionDecision: "allow"` auto-approves.** Already true for every correlated call today
|
|
409
|
+
(`BIR_CORRELATION_DECISION`), and now true for pinned built-ins too. §13.2.
|
|
410
|
+
|
|
411
|
+
### 7.2 Thread from the same source that recorded
|
|
412
|
+
|
|
413
|
+
`toolOutputLogic` was authored by BaseIn's `analyzeRun` against the exact bytes stored in that step's
|
|
414
|
+
`tool_output`. Feeding it a differently-shaped rendering of the same result does not fail loudly — it
|
|
415
|
+
silently derives nothing, and every later step reading `respParams` produces a wrong input.
|
|
416
|
+
|
|
417
|
+
In this repo those bytes have **two different origins**:
|
|
418
|
+
|
|
419
|
+
| Step's tool | What was recorded | Therefore thread from |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| Wrapped MCP | `serializeCapped(redact(report.result))` — the proxy's whole `CallToolResult`, via `settle()` → `record()` | the **proxy's `/proxy/step` report** |
|
|
422
|
+
| Built-in / unwrapped MCP | `serializeCapped(redact(payload.tool_response))` — Claude Code's rendering, via `onToolPost` | the **hook's `tool_response`** |
|
|
423
|
+
|
|
424
|
+
So `/tool/post` must not thread unconditionally. It threads for a `live` step, and for a `direct`
|
|
425
|
+
step it does what it already does for a correlated call: **arm the existing
|
|
426
|
+
`PROXY_REPORT_GRACE_MS` fallback**, and let `onProxyStep` thread when the report lands.
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
// onToolPost, for a pinned step
|
|
430
|
+
if (reachOf(step.toolName, this.wrapped) === "live") {
|
|
431
|
+
plan.applyOutput(serializeCapped(payload.tool_response ?? {}));
|
|
432
|
+
} else {
|
|
433
|
+
correlation.threadOnReport = true; // onProxyStep calls plan.applyOutput(report.result)
|
|
434
|
+
correlation.fallback = setTimeout(() => { // …unless the proxy never reports
|
|
435
|
+
logLine("replay.thread_fallback", { why: "no proxy report — threading the hook's view; shapes may differ" });
|
|
436
|
+
plan.applyOutput(serializeCapped(payload.tool_response ?? {}));
|
|
437
|
+
}, PROXY_REPORT_GRACE_MS);
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
This reuses machinery that exists and is already correct. It also means the §7.3 ordering constraint
|
|
442
|
+
binds a little harder: the proxy report must land before the next `PreToolUse`, and
|
|
443
|
+
`PROXY_REPORT_GRACE_MS` (1500 ms) is the bound on how long that can take before we degrade.
|
|
444
|
+
|
|
445
|
+
### 7.3 Synchronous `PostToolUse` is a requirement, not a preference
|
|
446
|
+
|
|
447
|
+
Step *N*'s `/tool/post` must finish updating `respParams` before step *N+1*'s `/tool/pre` reads it.
|
|
448
|
+
In [`HOOK_ROUTES`](../src/config/adapters/claude-code.ts) neither `PreToolUse` nor `PostToolUse`
|
|
449
|
+
carries `async: true`, so this already holds — **and must never be relaxed**. Add it to the comment
|
|
450
|
+
block there, next to the `SessionEnd` note that records the same class of lesson.
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
## 8. Divergence
|
|
455
|
+
|
|
456
|
+
The model called a tool the plan did not expect, or `toolInputLogic` threw, or the tool had no
|
|
457
|
+
`tool_use_id`. Three tiers, tried in order. Here is where the §0 asymmetry pays.
|
|
458
|
+
|
|
459
|
+
**Tier 1 — direct-execute the remainder (new; the common case).**
|
|
460
|
+
Take every not-yet-run step, execute it, and hand the composed result back. Steps with
|
|
461
|
+
`reach === "direct"` run through their proxy. Steps with `reach === "live"` cannot run here at all —
|
|
462
|
+
a hook process is not the session — and fall back to the step's **recorded output** (§8.1), or are
|
|
463
|
+
skipped with a logged warning. RRepeat's rule, and its reasoning holds verbatim: a *skipped* step
|
|
464
|
+
also stops threading `respParams`, so every later step reading from it fails too, and a five-step
|
|
465
|
+
scenario degrades to nothing. A recorded result is marked as such in the bundle, so the model is
|
|
466
|
+
never told a stale result is fresh.
|
|
467
|
+
|
|
468
|
+
Delivery, best channel first:
|
|
469
|
+
|
|
470
|
+
1. **The `bir` tool** — if the divergent call *is* `mcp__bir__run_scenario`, or the model can be
|
|
471
|
+
nudged to it. Genuine `tool_result`.
|
|
472
|
+
2. **Bash-clean** — if the divergent call is `Bash`, rewrite the command to `cat` a heredoc of the
|
|
473
|
+
bundle. The model sees ordinary command output. (RRepeat's `bashCleanPath`; neutralize the
|
|
474
|
+
delimiter first.)
|
|
475
|
+
3. **Deny-inject** — `permissionDecision: "deny"`, bundle as `permissionDecisionReason`, capped at
|
|
476
|
+
`MAX_REPLAY_REASON` (60 000 chars) with the same fair-share truncation
|
|
477
|
+
(`assembleBundle`/`truncate`, portable verbatim from `payload-executor.ts`). Works for any tool,
|
|
478
|
+
and is read as adversarial — hence last.
|
|
479
|
+
|
|
480
|
+
**Tier 2 — abort.** Composition itself threw. Retire the plan, return `{}`, continue as an ordinary
|
|
481
|
+
turn. Outcome `failed`.
|
|
482
|
+
|
|
483
|
+
**The cost line.** RRepeat's Tier 1 is an Agent SDK session, and its own doc calls that the dominant
|
|
484
|
+
cost of a calculated run. Here Tier 1 is N `tools/call`s on connections that are already open:
|
|
485
|
+
**`fallbackCostUsd` is 0.00 by construction**, for every step the proxies can reach.
|
|
486
|
+
|
|
487
|
+
> **Do not port the unconditional-divergence line.** RRepeat's `steerPreTool` ends with a deliberate
|
|
488
|
+
> `return await injectBundle(...)` that bypasses the pin branch on *every* armed plan
|
|
489
|
+
> (`savingCalcRunner.md` §4 documents the consequence: `stepsPinned` is 0 on every row). That was a
|
|
490
|
+
> choice made when the bundle was the only path that ran real tools. It is not this design's choice:
|
|
491
|
+
> pinning is tried first, always, and divergence is what its name says.
|
|
492
|
+
|
|
493
|
+
### 8.1 Where a recorded output actually comes from
|
|
494
|
+
|
|
495
|
+
RRepeat reads it from `SerializedScenarioStep.recordedOutput`, whose own comment says the field is
|
|
496
|
+
optional because *"it is the server's payload that decides whether to send it, and older payloads do
|
|
497
|
+
not."* Verify this before relying on it: **no payload does.** BaseIn's `scenario_steps` table has
|
|
498
|
+
columns `tool_input_logic`, `tool_output_logic`, `reasoning`, `reasoning_vector` — and no
|
|
499
|
+
`recorded_output`; `serializeScenario` emits no such field. RRepeat's fallback is unreachable in
|
|
500
|
+
production today.
|
|
501
|
+
|
|
502
|
+
There is a path that needs no server change, and the match payload already carries its key:
|
|
503
|
+
|
|
504
|
+
```
|
|
505
|
+
GET /recordings/runs/:runId → { run, steps } // steps carry tool_name + tool_output
|
|
506
|
+
↑ matched.runId — the canonical source run this scenario was calculated from
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
So: fetch the source run **lazily**, on the first `live` step of the first divergence, and index its
|
|
510
|
+
`tool_response` steps by tool name into the same FIFO queue BaseIn's own `dryReplay` builds
|
|
511
|
+
(`recordedOutputsByTool` in `scenarios/replay.ts` — a `Map<toolName, string[]>` drawn in order, with
|
|
512
|
+
a per-tool cursor). One authenticated GET, cached for the turn, and the queue semantics match the
|
|
513
|
+
server's dry replay exactly — which matters, because a scenario that behaves one way under
|
|
514
|
+
`bir scenario replay --dry` and another way here would be untestable.
|
|
515
|
+
|
|
516
|
+
Adding `recorded_output` to `scenario_steps` would make this a field read instead of a fetch, and is
|
|
517
|
+
a reasonable server-side follow-up. It is not a prerequisite.
|
|
518
|
+
|
|
519
|
+
> **[built]** [`src/replay/source-run.ts`](../src/replay/source-run.ts). The type still carries the
|
|
520
|
+
> optional `recordedOutput` field and prefers it when present, so a server that starts sending it
|
|
521
|
+
> needs no change here — but nothing depends on it.
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## 9. The plan, and state threading across hook calls
|
|
526
|
+
|
|
527
|
+
`ScenarioReplayPlan` ports from RRepeat's [`payload-executor.ts`](../../../BasInstRunner/RRepeat/src/server/payload-executor.ts)
|
|
528
|
+
with three changes.
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
// src/replay/plan.ts
|
|
532
|
+
export class ScenarioReplayPlan {
|
|
533
|
+
readonly scenarioId: string;
|
|
534
|
+
readonly mode: ReplayMode;
|
|
535
|
+
readonly stepCount: number;
|
|
536
|
+
|
|
537
|
+
/** Resolves when derivation has landed and paramsLogic has been applied. */
|
|
538
|
+
ready(): Promise<void>;
|
|
539
|
+
|
|
540
|
+
currentStepIndex: number;
|
|
541
|
+
expectedTool(): string | undefined;
|
|
542
|
+
isDone(): boolean;
|
|
543
|
+
|
|
544
|
+
/** Pinned input for the current step. Throws → divergence. */
|
|
545
|
+
toolInputForCurrentStep(): Record<string, unknown>;
|
|
546
|
+
/** Thread a real output into respParams and advance. Throws → abort. */
|
|
547
|
+
applyOutput(realOutput: string): void;
|
|
548
|
+
|
|
549
|
+
steeringDirective(): string;
|
|
550
|
+
/** Execute every remaining step through `execute`, and lay the results out under `maxChars`. */
|
|
551
|
+
composeBundle(maxChars: number, execute: ExecuteStep): Promise<BundleResult>;
|
|
552
|
+
/** responseParamsLogic over the accumulated respParams. */
|
|
553
|
+
responseModel(): Record<string, unknown>;
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
**Change 1 — lifetime is a run, not a session.** The plan hangs off `RunState`, not `SessionState`.
|
|
558
|
+
In this codebase a run *is* a turn (`UserPromptSubmit → Stop`, `server.ts` §onPrompt), which is
|
|
559
|
+
exactly the plan's lifetime. `sealRun` retires it; a second prompt in the same session cannot inherit
|
|
560
|
+
a stale plan, which in RRepeat requires care.
|
|
561
|
+
|
|
562
|
+
**Change 2 — `params` are a promise.** The constructor takes `Promise<Record<string, unknown>>`, not
|
|
563
|
+
a value, so arming does not wait for the Haiku call (§10). `paramsLogic` is applied once, inside
|
|
564
|
+
`ready()`, the first time it resolves.
|
|
565
|
+
|
|
566
|
+
**Change 3 — `execute` is a step, not a tool name.** `composeBundle` takes
|
|
567
|
+
`(step, input) => Promise<string>` rather than `(toolName, input)`, because the executor must know
|
|
568
|
+
the step's `reach` to decide between the proxy, the recorded output, and skipping.
|
|
569
|
+
|
|
570
|
+
The four `new Function` shims (`evalParamsLogic`, `evalToolInputLogic`, `evalToolOutputLogic`,
|
|
571
|
+
`evalResponseParamsLogic`) go in `src/replay/logic.ts`, ported from BaseIn's
|
|
572
|
+
[`src/scenarios/logic.ts`](../../../BaseIntSever/BaseIn/src/scenarios/logic.ts) — which is the same
|
|
573
|
+
code with better comments and an `assertPlainObject` guard the other copies lack. **Port BaseIn's
|
|
574
|
+
version**, not RRepeat's: it is the one the server evaluates against, so a divergence between them is
|
|
575
|
+
a divergence between what a scenario does in a dry replay and what it does here.
|
|
576
|
+
|
|
577
|
+
### 9.1 What flows between steps
|
|
578
|
+
|
|
579
|
+
- **`params`** — computed once (derivation → `paramsLogic`), read by every `toolInputLogic`.
|
|
580
|
+
- **`respParams`** — starts `{}`; each step's `toolOutputLogic` merges keys in
|
|
581
|
+
(`respParams = { ...respParams, ...derived }`); later steps' `toolInputLogic` read the accumulation.
|
|
582
|
+
This is how step 1's `highestRiskDeviceId` reaches step 2's input.
|
|
583
|
+
|
|
584
|
+
In direct mode both live inside one `for` loop. In steer mode they are split across *separate HTTP
|
|
585
|
+
requests* — which is what §7.3's ordering rule protects.
|
|
586
|
+
|
|
587
|
+
---
|
|
588
|
+
|
|
589
|
+
## 10. Timing: what may block, and for how long
|
|
590
|
+
|
|
591
|
+
The hook is on the user's critical path. Every await here is a stall the user watches.
|
|
592
|
+
|
|
593
|
+
| Moment | Route | Hook timeout | What may be awaited | Budget |
|
|
594
|
+
|---|---|---|---|---|
|
|
595
|
+
| `UserPromptSubmit` | `/session/prompt` | **5 s → raise to 15 s** | `getMatch()` only | `BIR_MATCH_BUDGET_MS` = **2500** |
|
|
596
|
+
| `PreToolUse` | `/tool/pre` | 30 s (unchanged) | `plan.ready()` (derivation) | `BIR_DERIVE_BUDGET_MS` = **8000** |
|
|
597
|
+
| `run_scenario` | `/scenario/run` | MCP client's own | `ready()` + N steps | `BIR_REPLAY_BUDGET_MS` = **120000**, `BIR_STEP_TIMEOUT_MS` = **60000** |
|
|
598
|
+
| `Stop` | `/session/stop` | 10 s | nothing (report is queued) | — |
|
|
599
|
+
|
|
600
|
+
**Derivation is deliberately not awaited at the prompt.** The steering directive needs only the
|
|
601
|
+
scenario's ordered tool names and reasonings — it does not need parameter values. So the prompt hook
|
|
602
|
+
returns the directive the moment the match lands, and the Haiku call runs un-awaited; the *first*
|
|
603
|
+
`PreToolUse` awaits it, inside a 30-second budget instead of a 15-second one. On a mode-`direct` turn
|
|
604
|
+
the wait lands inside `run_scenario` instead, where there is no hook timeout at all.
|
|
605
|
+
|
|
606
|
+
**Budget expiry is never an error.** Match budget expired → no plan, ordinary turn, one
|
|
607
|
+
`replay.decision` line. Derivation budget expired → fall back to the scenario's recorded
|
|
608
|
+
`sampleValue`s (D15) and keep going; a replay with stale parameters is worth more than no replay, and
|
|
609
|
+
the audit line says which happened.
|
|
610
|
+
|
|
611
|
+
**The one hook-config change** is `UserPromptSubmit`'s timeout, `5` → `15`, in
|
|
612
|
+
[`HOOK_ROUTES`](../src/config/adapters/claude-code.ts). It must stay non-`async`: an async hook is
|
|
613
|
+
fire-and-forget and cannot return `additionalContext` at all.
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## 11. Reporting: outcome, ticket, cost
|
|
618
|
+
|
|
619
|
+
This is the half that makes replay *provable* rather than merely fast, and v1 does none of it.
|
|
620
|
+
|
|
621
|
+
### 11.1 The outcome ladder
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
// Upgrade-only. `not_steered` is the floor and the safe answer if the turn dies mid-flight.
|
|
625
|
+
const OUTCOME_RANK = { not_steered: 0, failed: 1, diverged: 2, steered_full: 3 } as const;
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
| Outcome | Means | Server-side effect |
|
|
629
|
+
|---|---|---|
|
|
630
|
+
| `not_steered` | Matched, declined at a gate; the agent ran the task | **baseline sample** (`addBaselineSample`) |
|
|
631
|
+
| `failed` | Armed, then the machinery gave up; the agent ran the task | **baseline sample** |
|
|
632
|
+
| `diverged` | The model went off-script; the bundle answered | ledger row, `saved_usd` computed |
|
|
633
|
+
| `steered_full` | Every planned step ran with scenario-derived inputs | ledger row, `saved_usd` computed |
|
|
634
|
+
|
|
635
|
+
Direct mode reports `steered_full`. BaseIn's zod enum
|
|
636
|
+
(`['steered_full','diverged','not_steered','failed']`) has no `direct` member, and `steered_full`
|
|
637
|
+
means exactly what happened: every planned step executed under the scenario's control and the
|
|
638
|
+
response model was produced. A `direct` discriminator would be a *server* change and is §20's
|
|
639
|
+
question 2, not a blocker.
|
|
640
|
+
|
|
641
|
+
### 11.2 The ticket
|
|
642
|
+
|
|
643
|
+
`matched.executionTicket` is the server's claim token, and **it is the execution row's id** — which
|
|
644
|
+
is what makes a doubled report book once (`scenarios/routes.ts`: a `claimed` ticket returns
|
|
645
|
+
`200 { duplicate: true }`, not an error). Redeem it exactly once, in `sealRun`, pushed onto
|
|
646
|
+
`pendingSeals` alongside the answer resolution so `Stop` never blocks on the network:
|
|
647
|
+
|
|
648
|
+
```
|
|
649
|
+
POST /scenarios/:scenarioId/executions
|
|
650
|
+
{ ticket, outcome, deriveCostUsd, sessionCostUsd, fallbackCostUsd,
|
|
651
|
+
durationMs, stepsPlanned, stepsPinned, measured, pricingVersion, prompt,
|
|
652
|
+
steps, error, errorStage, errorStepIndex, errorToolName }
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
An **unredeemed** ticket leaves no execution row at all. That asymmetry is intentional and worth
|
|
656
|
+
preserving: silence loses a saving; it never invents one.
|
|
657
|
+
|
|
658
|
+
### 11.2a What each step did (the service's errorshandling.md)
|
|
659
|
+
|
|
660
|
+
The last five fields are the diagnosis, and none of the money depends on them. The service's
|
|
661
|
+
recording page draws the scenario's chain step by step; without these, a scenario whose step 2
|
|
662
|
+
throws on every replay renders as four healthy-looking steps beside a saving that never grows. The
|
|
663
|
+
error exists — in a log line, on the operator's machine. The person who owns the scenario is looking
|
|
664
|
+
at a web page.
|
|
665
|
+
|
|
666
|
+
```ts
|
|
667
|
+
steps: Array<{
|
|
668
|
+
stepIndex: number; // the scenario's own index, not a position in this array
|
|
669
|
+
toolName: string;
|
|
670
|
+
status: "ok" | "recorded" | "skipped" | "failed";
|
|
671
|
+
stage?: ExecutionStage; // where in the step, for `failed` / `skipped`
|
|
672
|
+
error?: string; // message only — this is rendered in a web page
|
|
673
|
+
durationMs?: number;
|
|
674
|
+
}>
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
`ok` and `recorded` are passes (`recorded` with an asterisk: the values came from the source run).
|
|
678
|
+
`skipped` did not run. **`failed` is the only one that means the scenario is broken** rather than
|
|
679
|
+
the environment — it is the scenario's own logic throwing, and it is what `Recalculate` fixes.
|
|
680
|
+
|
|
681
|
+
Three rules this must keep:
|
|
682
|
+
|
|
683
|
+
1. **A step the turn never reached is absent**, not `skipped`. The page shows those as "not run",
|
|
684
|
+
and a turn that dies at step 2 of 5 must not paint steps 3-5 red.
|
|
685
|
+
2. **Every field is optional on the wire.** An older service ignores them; the costs still book.
|
|
686
|
+
3. **The headline and the steps are one statement.** `error*` is lifted from the first `failed`
|
|
687
|
+
step (`blameStep`), never assembled independently, so the page's card cannot contradict the step
|
|
688
|
+
list underneath it.
|
|
689
|
+
|
|
690
|
+
The verdicts come from `plan.ts`'s `onStep`, which has always known them — with one addition: a
|
|
691
|
+
logic failure is announced *before* the throw propagates (`reportingStage`), because after it
|
|
692
|
+
nothing knows which step the stack trace belonged to. In steer mode there is no loop to observe, so
|
|
693
|
+
the controller records the verdict at both hooks: `PreToolUse` for an input-logic throw, `PostToolUse`
|
|
694
|
+
for the pass or the output-logic throw. `ReplayState.stepResults` is keyed by step index so the
|
|
695
|
+
second word on a step replaces the first.
|
|
696
|
+
|
|
697
|
+
One consequence for the rule that a costless decline is not worth sending (`buildReport`, and the
|
|
698
|
+
service's matching `202 { recorded: false }`): it now *is* worth sending when a step broke. No cost
|
|
699
|
+
**and** no error remains nothing to report.
|
|
700
|
+
|
|
701
|
+
### 11.3 Cost, and a live pricing hazard
|
|
702
|
+
|
|
703
|
+
| Component | Source | Notes |
|
|
704
|
+
|---|---|---|
|
|
705
|
+
| `deriveCostUsd` | our own Haiku call's `usage`, priced locally | the only tokens replay itself spends |
|
|
706
|
+
| `sessionCostUsd` | transcript usage **delta** for this run (§11.4) | the live turn: prompt, tool calls, answer |
|
|
707
|
+
| `fallbackCostUsd` | **0.00** | direct execution spends no tokens (§8) |
|
|
708
|
+
|
|
709
|
+
> ### ⚠ Verify the pricing table before copying it
|
|
710
|
+
>
|
|
711
|
+
> A saving is `baseline − actual`, where the baseline is priced by BaseIn's
|
|
712
|
+
> [`src/scenarios/cost.ts`](../../../BaseIntSever/BaseIn/src/scenarios/cost.ts) and the actual by the
|
|
713
|
+
> runner's copy. Both currently declare `PRICING_VERSION = '2026-08-30'`, and **the two tables
|
|
714
|
+
> already disagree**: RRepeat prices `claude-opus-4-6` at `3.0 / 15.0`, BaseIn at `5.0 / 25.0`. Equal
|
|
715
|
+
> versions are supposed to mean *"these are comparable"*. Right now, for that row, they are not.
|
|
716
|
+
>
|
|
717
|
+
> So: **copy BaseIn's table, not RRepeat's** — the server owns the baseline, and the baseline is the
|
|
718
|
+
> number a saving is measured *against*. Copy `PRICING_VERSION` with it. Treat any price change as a
|
|
719
|
+
> three-repository change, and file the drift above against RRepeat rather than reproducing it.
|
|
720
|
+
>
|
|
721
|
+
> Practical exposure here is small — derivation runs on `claude-haiku-4-5-20251001`, which both
|
|
722
|
+
> tables price identically at `1.0 / 5.0` — but the invariant is what matters, not today's blast
|
|
723
|
+
> radius.
|
|
724
|
+
|
|
725
|
+
### 11.4 The per-run usage watermark
|
|
726
|
+
|
|
727
|
+
`savingCalcRunner.md` §3 documents this trap in RRepeat, and **BaseInstRunnerMCP has it too**, for
|
|
728
|
+
the same structural reason: Claude Code writes one transcript per *session*, and `onPrompt` rolls a
|
|
729
|
+
new run on every prompt. Summing the whole file gives run 3 the cost of runs 1+2+3.
|
|
730
|
+
|
|
731
|
+
Every one of those numbers is a **baseline** on the server. Do not ship the whole-file version.
|
|
732
|
+
|
|
733
|
+
```ts
|
|
734
|
+
// src/control/transcript.ts — two new exports beside contextForToolUse / settledLastAssistantText
|
|
735
|
+
/** Per-model cumulative usage, captured when a run starts. */
|
|
736
|
+
export function markTranscriptUsage(path: string | undefined): UsageMark;
|
|
737
|
+
/** Per-model delta since `mark`, clamped at 0 per field. */
|
|
738
|
+
export function usageSince(path: string | undefined, mark: UsageMark): ModelUsage[];
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
Take the mark in `ensureRun`, beside `startedAtMs`, so the cost window and the duration window are
|
|
742
|
+
the same window. Clamp per field, so a compacted or rotated transcript degrades to *"no usage"*
|
|
743
|
+
rather than to a negative cost. A run with **no mark** (one opened lazily by a proxy step before any
|
|
744
|
+
prompt) reports `measured: false` — better to say "unmeasured" than to hand the ledger an inflated
|
|
745
|
+
baseline.
|
|
746
|
+
|
|
747
|
+
`measured` is `true` only when a mark existed **and** `pricingVersion` matches the server's. The
|
|
748
|
+
server enforces the second half itself and stores a mismatched report as unmeasured rather than
|
|
749
|
+
trusting it.
|
|
750
|
+
|
|
751
|
+
---
|
|
752
|
+
|
|
753
|
+
## 12. What the replayed turn records
|
|
754
|
+
|
|
755
|
+
**Nothing, and that is correct.** The service answered `{ id: null }`; no run exists; every step post
|
|
756
|
+
would 404. `run.recording = false` already handles it — `record()` early-returns and `sealRun` skips
|
|
757
|
+
both the final answer and `finishRun`.
|
|
758
|
+
|
|
759
|
+
Three consequences to keep straight:
|
|
760
|
+
|
|
761
|
+
1. **Step indices are still allocated.** `ordering` runs regardless, correlations are still minted
|
|
762
|
+
and settled, and `bir doctor` still shows a live step count. Only the *sending* stops.
|
|
763
|
+
2. **The matched run is the canonical one.** The server bumped its `iterations` at match time. The
|
|
764
|
+
corpus does not grow on a replay, by design — that is what stops one popular task from flooding
|
|
765
|
+
the similarity pool with near-duplicates.
|
|
766
|
+
3. **Direct-mode calls bypass the recorder entirely.** They are issued via `UpstreamClient.request`,
|
|
767
|
+
which allocates a `bir:` id and consumes its own response in `accept()` before the relay's
|
|
768
|
+
handlers ever see it ([`src/upstream/client.ts`](../src/upstream/client.ts)). So
|
|
769
|
+
`RecordingInterceptor` never observes them and cannot double-record. That interface comment says
|
|
770
|
+
`request()` is *"Never used by the relay"* — it was built for `bir doctor` and tests, and this is
|
|
771
|
+
its second, load-bearing user.
|
|
772
|
+
|
|
773
|
+
### 12.1 `bir replay` — the Tier 2 and debugging path
|
|
774
|
+
|
|
775
|
+
```bash
|
|
776
|
+
bir replay --scenario scn_… --prompt "check the fleet for high-risk devices" # run it
|
|
777
|
+
bir replay --scenario scn_… --prompt "…" --dry # derive + plan only
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
Runs the same plan through the same executor, against the same live proxies, printing the per-step
|
|
781
|
+
input/output and the response model. It is how you test a scenario without a session, how a Tier 2
|
|
782
|
+
or non-Claude-Code client gets any replay at all, and the first thing to reach for when a steered
|
|
783
|
+
turn behaves oddly.
|
|
784
|
+
|
|
785
|
+
---
|
|
786
|
+
|
|
787
|
+
## 13. Security
|
|
788
|
+
|
|
789
|
+
v1 §9 holds unchanged. Replay adds two exposures, and neither is small.
|
|
790
|
+
|
|
791
|
+
### 13.1 The scenario payload is executable code
|
|
792
|
+
|
|
793
|
+
`toolInputLogic`, `toolOutputLogic`, `paramsLogic` and `responseParamsLogic` are JavaScript function
|
|
794
|
+
*bodies*, compiled with `new Function` and run **in the control server process** — a process holding
|
|
795
|
+
the user's BaseIn credentials and, through the proxies, live authenticated access to every wrapped
|
|
796
|
+
MCP server.
|
|
797
|
+
|
|
798
|
+
BaseIn's own `logic.ts` argues the eval is safe because *"these run only over data the owner already
|
|
799
|
+
produced… so the dynamic eval is scoped to first-party content."* That argument holds here too, and
|
|
800
|
+
the blast radius does not: BaseIn evaluates them against a database row, we evaluate them next to a
|
|
801
|
+
browser and a GitHub token.
|
|
802
|
+
|
|
803
|
+
What actually constrains it:
|
|
804
|
+
|
|
805
|
+
- The payload arrives **only** in the response to an authenticated `POST /recordings/runs` against
|
|
806
|
+
the configured `BIR_AUTH_URL`, over the user's own token, describing the user's own runs. It is not
|
|
807
|
+
attacker-supplied in any ordinary threat model.
|
|
808
|
+
- A `new Function` body gets its declared arguments **and the global scope**. `require` and `import`
|
|
809
|
+
are not reachable (ESM, no CommonJS wrapper), but `fetch`, `process` and `globalThis` are. Say this
|
|
810
|
+
plainly rather than implying a sandbox: **there is no sandbox.**
|
|
811
|
+
- `BIR_REPLAY` is opt-in (D14), so a compromised or misconfigured `BIR_AUTH_URL` cannot start
|
|
812
|
+
executing logic on a machine whose operator never asked for replay.
|
|
813
|
+
|
|
814
|
+
If that is not enough for a deployment, the mitigation is a real one and is out of scope here:
|
|
815
|
+
evaluate the logic in a `node:vm` context with a frozen, minimal global. Filed as §20 question 3.
|
|
816
|
+
|
|
817
|
+
### 13.2 Replay bypasses permission prompts
|
|
818
|
+
|
|
819
|
+
This is the sharpest edge in the whole design, and it must not be buried.
|
|
820
|
+
|
|
821
|
+
- **Steer mode** answers `PreToolUse` with `permissionDecision: "allow"`, so a pinned call is
|
|
822
|
+
auto-approved. The `BIR_CORRELATION_DECISION` note in `bir-hooks.ts` already documents this for
|
|
823
|
+
correlated MCP calls; steering extends it to **built-ins** — including `Bash` and `Edit`.
|
|
824
|
+
- **Direct mode** never reaches `PreToolUse` at all. A `tools/call` dispatched to a proxy is not
|
|
825
|
+
visible to Claude Code's permission system in any form.
|
|
826
|
+
|
|
827
|
+
So a scenario is, in effect, a **pre-approved list of tool calls with computed arguments**. That is
|
|
828
|
+
precisely what makes it valuable, and precisely what makes it dangerous. Four mitigations, all
|
|
829
|
+
required:
|
|
830
|
+
|
|
831
|
+
1. `BIR_REPLAY` off by default; `bir install --replay` a distinct, non-default flag.
|
|
832
|
+
2. `BIR_REPLAY_ALLOW_SERVERS` — a comma-separated allowlist of server keys eligible for **direct**
|
|
833
|
+
execution. Unset means *all wrapped servers*; the guide (§4 of `calculatedReplayGuide.md`)
|
|
834
|
+
recommends setting it.
|
|
835
|
+
3. `bir doctor` prints `replay: on` and the effective allowlist, loudly, at the top. A capability this
|
|
836
|
+
sharp must never be discoverable only by reading a log.
|
|
837
|
+
4. Every armed plan logs `plan.armed` with the scenario id, mode, step count and the ordered tool
|
|
838
|
+
names **before** the first call runs. The audit line precedes the action.
|
|
839
|
+
|
|
840
|
+
### 13.3 The control plane
|
|
841
|
+
|
|
842
|
+
`/scenario/run`, `/proxy/poll` and `/proxy/result` are ordinary control-server routes: loopback only,
|
|
843
|
+
bearer token from the `0600` discovery file, same as `/proxy/step`. `/proxy/poll` holds a connection
|
|
844
|
+
open for up to 25 s — a long-lived authenticated request, not a new trust boundary.
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
## 14. Degradation matrix
|
|
849
|
+
|
|
850
|
+
The governing rule is unchanged and outranks every optimisation here: **the host session must never
|
|
851
|
+
fail because of BaseInstRunner.**
|
|
852
|
+
|
|
853
|
+
| Failure | Behaviour |
|
|
854
|
+
|---|---|
|
|
855
|
+
| `getMatch()` slower than the match budget | No plan. Ordinary turn. One `replay.decision` line |
|
|
856
|
+
| No `ANTHROPIC_API_KEY` | Derivation skipped; the scenario's recorded `sampleValue`s are used. `deriveCostUsd: 0` |
|
|
857
|
+
| Derivation call fails or times out | Same as above, plus a `replay.derive_failed` line |
|
|
858
|
+
| `toolInputLogic` throws | Divergence (§8) — never a crash |
|
|
859
|
+
| `toolOutputLogic` throws | Retire the plan, abort to an ordinary turn, outcome `failed` |
|
|
860
|
+
| Model calls an unexpected tool | Divergence (§8) |
|
|
861
|
+
| A proxy has no open long-poll (dead, or `BIR_REPLAY` unset in its env) | That step falls back to `recordedOutput`, else is skipped with a warning |
|
|
862
|
+
| A direct step exceeds `BIR_STEP_TIMEOUT_MS` | Same as above; the work item is abandoned server-side |
|
|
863
|
+
| Whole plan exceeds `BIR_REPLAY_BUDGET_MS` | Return what completed, marked partial; outcome `diverged` |
|
|
864
|
+
| `bir-scenario` cannot find the control server | `run_scenario` returns `isError: true`; the model proceeds normally |
|
|
865
|
+
| Ticket redemption fails (offline, 409) | Logged; the turn is unaffected. An unredeemed ticket books nothing |
|
|
866
|
+
| Two reports for one ticket (retry, `Stop` racing `SessionEnd`) | Server books once, answers `200 { duplicate: true }` |
|
|
867
|
+
| `BIR_REPLAY` unset | Exactly v1: stop recording on a match, do nothing else |
|
|
868
|
+
|
|
869
|
+
---
|
|
870
|
+
|
|
871
|
+
## 15. Solution layout
|
|
872
|
+
|
|
873
|
+
```
|
|
874
|
+
src/
|
|
875
|
+
├─ replay/ ← new
|
|
876
|
+
│ ├─ plan.ts # ScenarioReplayPlan (§9)
|
|
877
|
+
│ ├─ logic.ts # the four new Function shims — port BaseIn's (§9)
|
|
878
|
+
│ ├─ derive.ts # Haiku parameter derivation over raw fetch (§16.3)
|
|
879
|
+
│ ├─ pricing.ts # MODEL_PRICING + PRICING_VERSION — copy BaseIn's (§11.3)
|
|
880
|
+
│ ├─ coverage.ts # reachOf / modeFor (§5)
|
|
881
|
+
│ ├─ bundle.ts # assembleBundle / truncate — port verbatim
|
|
882
|
+
│ ├─ executor.ts # direct step dispatch + the work queue (§16.2)
|
|
883
|
+
│ ├─ source-run.ts # lazy GET /recordings/runs/:id → recorded outputs by tool (§8.1)
|
|
884
|
+
│ └─ types.ts # SerializedScenario, SerializedScenarioStep
|
|
885
|
+
├─ bin/
|
|
886
|
+
│ ├─ bir-scenario.ts ← new: the one-tool MCP server (§6.3)
|
|
887
|
+
│ └─ bir.ts # + `--replay`, `bir scenario …`, `bir replay` (§16.5)
|
|
888
|
+
├─ control/
|
|
889
|
+
│ ├─ server.ts # + arm / pin / thread / dispatch / report
|
|
890
|
+
│ └─ transcript.ts # + markTranscriptUsage / usageSince (§11.4)
|
|
891
|
+
├─ proxy/
|
|
892
|
+
│ └─ session.ts # + the long-poll work loop (§16.2)
|
|
893
|
+
├─ record/
|
|
894
|
+
│ ├─ recorder.ts # + reportExecution() on the Recorder surface
|
|
895
|
+
│ └─ remote-recorder.ts # + POST /scenarios/:id/executions
|
|
896
|
+
└─ config/adapters/claude-code.ts # UserPromptSubmit timeout 5 → 15; the `bir` MCP entry
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
`package.json` gains one bin: `"bir-scenario": "dist/bin/bir-scenario.js"`. Runtime dependencies stay
|
|
900
|
+
at **zero** (§16.3).
|
|
901
|
+
|
|
902
|
+
---
|
|
903
|
+
|
|
904
|
+
## 16. Contracts
|
|
905
|
+
|
|
906
|
+
### 16.1 Control-plane routes (new)
|
|
907
|
+
|
|
908
|
+
```ts
|
|
909
|
+
// bir-scenario → control server
|
|
910
|
+
POST /scenario/run {}
|
|
911
|
+
→ 200 { ok: true, text: string, responseModel: object, steps: number, partial?: boolean }
|
|
912
|
+
→ 200 { ok: false, why: "no_plan" | "not_ready" | "budget_exceeded" }
|
|
913
|
+
|
|
914
|
+
// proxy → control server (long-poll; holds up to 25 s, then answers empty)
|
|
915
|
+
POST /proxy/poll { serverName: string }
|
|
916
|
+
→ 200 {} | 200 { work: { workId: string; toolName: string; arguments: unknown; timeoutMs: number } }
|
|
917
|
+
|
|
918
|
+
// proxy → control server
|
|
919
|
+
POST /proxy/result { workId: string, result?: unknown, error?: string }
|
|
920
|
+
→ 200 { ok: true }
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
### 16.2 The direct executor
|
|
924
|
+
|
|
925
|
+
```ts
|
|
926
|
+
// src/replay/executor.ts
|
|
927
|
+
export type ExecuteStep = (step: SerializedScenarioStep, input: Record<string, unknown>) => Promise<string>;
|
|
928
|
+
|
|
929
|
+
/** Hands work to the proxy that owns `serverName` and awaits its result. */
|
|
930
|
+
export class ProxyWorkQueue {
|
|
931
|
+
/** Called by POST /proxy/poll. Resolves with work, or with undefined at the poll deadline. */
|
|
932
|
+
waitForWork(serverName: string, pollDeadlineMs: number): Promise<Work | undefined>;
|
|
933
|
+
/** Called by POST /proxy/result. */
|
|
934
|
+
complete(workId: string, result?: unknown, error?: string): void;
|
|
935
|
+
/** Dispatch and await one call. Rejects on timeout or on no registered proxy. */
|
|
936
|
+
call(serverName: string, toolName: string, args: unknown, timeoutMs: number): Promise<unknown>;
|
|
937
|
+
}
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
> **[built] "No poller parked" is not the same as "no proxy".** The first implementation rejected
|
|
941
|
+
> `call` immediately whenever no poller was waiting, on the reasoning that queued work could stall a
|
|
942
|
+
> turn past its budget. That reasoning holds for a server no proxy serves — and is wrong for the case
|
|
943
|
+
> that actually dominates: a proxy is *between* polls precisely while it is POSTing one step's result
|
|
944
|
+
> and re-polling, which is exactly the gap between two steps of a multi-step plan. The end-to-end test
|
|
945
|
+
> caught it as "step 2 of 2 never ran".
|
|
946
|
+
>
|
|
947
|
+
> So the queue tracks `lastSeen` per server. Work dispatched while a recently-seen proxy is
|
|
948
|
+
> mid-round-trip is **held** for its next poll; the per-call timeout still bounds the wait, and a
|
|
949
|
+
> server nothing has served for a minute still fails fast into the recorded-output fallback.
|
|
950
|
+
|
|
951
|
+
Proxy side, in `ProxySession` — started after a successful `register()`, only when `BIR_REPLAY=1`:
|
|
952
|
+
|
|
953
|
+
```ts
|
|
954
|
+
while (!closed) {
|
|
955
|
+
const work = await control.poll(this.serverName); // 25 s long-poll
|
|
956
|
+
if (!work) continue;
|
|
957
|
+
try {
|
|
958
|
+
// The reserved `bir:` id space, built for exactly this and never used by the relay.
|
|
959
|
+
const result = await this.upstream.request("tools/call",
|
|
960
|
+
{ name: work.toolName, arguments: work.arguments },
|
|
961
|
+
AbortSignal.timeout(work.timeoutMs));
|
|
962
|
+
await control.result(work.workId, result);
|
|
963
|
+
} catch (err) {
|
|
964
|
+
await control.result(work.workId, undefined, errText(err));
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
Note `work.toolName` is the **upstream-local** name (`navigate_page`), not the qualified one —
|
|
970
|
+
`parseQualifiedName` splits it at dispatch. Storing both parts is what v1 §5 bought.
|
|
971
|
+
|
|
972
|
+
> **[built] The proxy needs no replay configuration of its own.** `/proxy/register` answers
|
|
973
|
+
> `{ replay: boolean, pollHoldMs }`, and the loop opens only if that says so. `BIR_REPLAY` is
|
|
974
|
+
> therefore a single switch on the control server, with no second copy in each wrapped entry's `env`
|
|
975
|
+
> to drift out of step with it.
|
|
976
|
+
|
|
977
|
+
### 16.3 Derivation without a dependency
|
|
978
|
+
|
|
979
|
+
```ts
|
|
980
|
+
// src/replay/derive.ts — no SDK; ~50 lines of fetch.
|
|
981
|
+
export async function deriveParameters(opts: {
|
|
982
|
+
prompt: string;
|
|
983
|
+
intent: string;
|
|
984
|
+
paramsObject: Record<string, { sampleValue: unknown; description: string }> | null;
|
|
985
|
+
apiKey?: string; // ANTHROPIC_API_KEY
|
|
986
|
+
model?: string; // default claude-haiku-4-5-20251001
|
|
987
|
+
signal?: AbortSignal;
|
|
988
|
+
}): Promise<{ params: Record<string, unknown>; costUsd: number; derived: boolean }>;
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
Behaviour, matching RRepeat's `deriveParametersFromScenarioPayload` exactly so the two produce the
|
|
992
|
+
same parameters from the same prompt:
|
|
993
|
+
|
|
994
|
+
- No `paramsObject` keys → `{}`, `costUsd: 0`.
|
|
995
|
+
- **No `apiKey` → return every key's recorded `sampleValue`**, `costUsd: 0`, `derived: false`. A plain
|
|
996
|
+
replay of the recorded parameters, free, and the documented default for anyone without a key.
|
|
997
|
+
- Otherwise `POST https://api.anthropic.com/v1/messages` with the same extraction prompt, tolerant
|
|
998
|
+
JSON extraction (first balanced `{…}`, `undefined` → `null`), and any `null`/missing key filled
|
|
999
|
+
from its `sampleValue`.
|
|
1000
|
+
|
|
1001
|
+
Keeping this dependency-free is not purity. `bir-proxy` is spawned inside the host's process tree for
|
|
1002
|
+
*every* wrapped server; an npm install that pulls a model SDK into that path is a startup-latency and
|
|
1003
|
+
supply-chain cost paid N times per session, for a feature most sessions never arm.
|
|
1004
|
+
|
|
1005
|
+
### 16.4 `Recorder` gains one method
|
|
1006
|
+
|
|
1007
|
+
```ts
|
|
1008
|
+
export interface ScenarioReporter {
|
|
1009
|
+
reportExecution(r: {
|
|
1010
|
+
scenarioId: string; ticket?: string;
|
|
1011
|
+
outcome: "steered_full" | "diverged" | "not_steered" | "failed";
|
|
1012
|
+
deriveCostUsd: number; sessionCostUsd: number; fallbackCostUsd: number;
|
|
1013
|
+
durationMs: number; stepsPlanned: number; stepsPinned: number;
|
|
1014
|
+
measured: boolean; pricingVersion: string; prompt?: string;
|
|
1015
|
+
}): void;
|
|
1016
|
+
}
|
|
1017
|
+
export function isScenarioReporter(r: Recorder): r is Recorder & ScenarioReporter;
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
Optional capability, discovered the same way `isMatchAware` is — so `NullRecorder` needs no change
|
|
1021
|
+
and a session with no BaseIn credentials still replays (it just books nothing).
|
|
1022
|
+
|
|
1023
|
+
### 16.5 CLI surface
|
|
1024
|
+
|
|
1025
|
+
`bir` gains one flag and one command group. Everything here is a thin client over endpoints that
|
|
1026
|
+
already exist server-side, except `bir replay`, which drives §16.2's executor directly.
|
|
1027
|
+
|
|
1028
|
+
```
|
|
1029
|
+
bir install --replay register the `bir` MCP server; raise the prompt-hook timeout to 15 s
|
|
1030
|
+
bir uninstall --replay reverse exactly that, leaving the rest of the install alone
|
|
1031
|
+
|
|
1032
|
+
bir scenario list GET /recordings/runs runs, iterations, scenario state
|
|
1033
|
+
bir scenario show <runId> GET /recordings/runs/:id/scenario intent, params, steps, logic
|
|
1034
|
+
bir scenario calc <runId> POST /recordings/runs/:id/calculate [--force to re-derive in place]
|
|
1035
|
+
bir scenario replay <scnId> --prompt "…" [--dry]
|
|
1036
|
+
|
|
1037
|
+
bir replay --scenario <scnId> --prompt "…" [--dry] alias; the Tier 2 / debugging path (§12.1)
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
`--dry` maps to BaseIn's `POST /scenarios/:id/replay`, which evaluates the stored logic against the
|
|
1041
|
+
source run's *recorded* outputs — no real tools, no side effects, one Haiku call. Without `--dry`,
|
|
1042
|
+
`bir replay` runs the same plan through the same executor against the live proxies. `bir doctor`
|
|
1043
|
+
gains a `replay` block: on/off, the effective `BIR_REPLAY_ALLOW_SERVERS` allowlist,
|
|
1044
|
+
`BIR_MIN_STEER_SIMILARITY`, and whether an `ANTHROPIC_API_KEY` was found (§13.2, mitigation 3).
|
|
1045
|
+
|
|
1046
|
+
---
|
|
1047
|
+
|
|
1048
|
+
## 17. Implementation phases
|
|
1049
|
+
|
|
1050
|
+
Each phase ends in something runnable. Do not start a phase before its predecessor's acceptance test
|
|
1051
|
+
passes.
|
|
1052
|
+
|
|
1053
|
+
### Phase R0 — Plan, logic, coverage (no wiring)
|
|
1054
|
+
Port `logic.ts` from BaseIn, `bundle.ts` and `ScenarioReplayPlan` from RRepeat, write `coverage.ts`.
|
|
1055
|
+
Pure, dependency-free, no I/O.
|
|
1056
|
+
**Acceptance:** unit tests replay a fixture `SerializedScenario` end to end against a stub executor
|
|
1057
|
+
and reproduce the recorded outputs byte for byte.
|
|
1058
|
+
|
|
1059
|
+
### Phase R1 — Derivation and pricing
|
|
1060
|
+
`derive.ts` + `pricing.ts`. Both fully testable against a fake `fetch`.
|
|
1061
|
+
**Acceptance:** with no key, derivation returns sample values at zero cost; with a stubbed key, it
|
|
1062
|
+
parses fenced JSON, bare JSON and prose-wrapped JSON, and prices a known `usage` to the same cent as
|
|
1063
|
+
BaseIn's `cost.ts`.
|
|
1064
|
+
|
|
1065
|
+
### Phase R2 — Arming and the decision log
|
|
1066
|
+
`onPrompt` awaits `getMatch()` under budget, runs the gate ladder, computes the mode, arms the plan,
|
|
1067
|
+
returns the directive. **No execution yet.**
|
|
1068
|
+
**Acceptance:** a fake recorder returns a match; assert exactly one `plan.armed` or one
|
|
1069
|
+
`replay.decision` line per prompt, the right mode, and that the prompt hook returns inside the
|
|
1070
|
+
budget when `getMatch` never resolves.
|
|
1071
|
+
|
|
1072
|
+
### Phase R3 — Steer mode
|
|
1073
|
+
`/tool/pre` pinning (with the §7.1 `updatedInput` merge), `/tool/post` threading (with the §7.2
|
|
1074
|
+
source split), retire on done.
|
|
1075
|
+
**Acceptance:** a scripted hook sequence drives a 3-step plan to `steered_full`; a wrapped-MCP step
|
|
1076
|
+
threads from `/proxy/step` and not from the hook; reordering the model's calls produces exactly one
|
|
1077
|
+
divergence and no crash.
|
|
1078
|
+
|
|
1079
|
+
### Phase R4 — The work queue and direct execution
|
|
1080
|
+
`ProxyWorkQueue`, `/proxy/poll`, `/proxy/result`, the proxy-side loop.
|
|
1081
|
+
**Acceptance:** against `test/fake-upstream.ts`, a 3-step direct plan executes with `respParams`
|
|
1082
|
+
threaded between steps; killing the proxy mid-plan degrades to `recordedOutput`/skip and the session
|
|
1083
|
+
survives.
|
|
1084
|
+
|
|
1085
|
+
### Phase R5 — `bir-scenario` and direct delivery
|
|
1086
|
+
The MCP server, `/scenario/run`, `bir install --replay`.
|
|
1087
|
+
**Acceptance:** a real Claude Code session on a fully-wrapped scenario answers from one
|
|
1088
|
+
`run_scenario` call, and the transcript shows a genuine `tool_result`.
|
|
1089
|
+
|
|
1090
|
+
### Phase R6 — Divergence
|
|
1091
|
+
Direct-execute the remainder; the lazy source-run fetch for `live` steps (§8.1); Bash-clean,
|
|
1092
|
+
`bir`-tool and deny delivery; the fair-share bundle cap.
|
|
1093
|
+
**Acceptance:** forcing an off-script call produces a bundle under `MAX_REPLAY_REASON` containing
|
|
1094
|
+
every remaining step; a `live` step is served its recorded output, marked `(recorded)`, and threads
|
|
1095
|
+
`respParams` for the steps after it; a `live` step with no recorded output is skipped with a warning
|
|
1096
|
+
and later steps still resolve.
|
|
1097
|
+
|
|
1098
|
+
### Phase R7 — Reporting
|
|
1099
|
+
The usage watermark, `reportExecution`, ticket redemption in `sealRun`, `bir doctor` fields.
|
|
1100
|
+
**Acceptance:** three prompts in one session produce three *non-cumulative* `sessionCostUsd` values;
|
|
1101
|
+
a declined match books a baseline sample; a doubled report books once.
|
|
1102
|
+
|
|
1103
|
+
---
|
|
1104
|
+
|
|
1105
|
+
## 18. Testing
|
|
1106
|
+
|
|
1107
|
+
Extends v1 §11; same `node:test` harness, same `test/fake-upstream.ts`.
|
|
1108
|
+
|
|
1109
|
+
1. **Plan semantics** — `respParams` threading, `paramsLogic` applied once, `isDone` after the last
|
|
1110
|
+
step, throw-propagation for each of the four logic hooks.
|
|
1111
|
+
2. **Coverage** — every combination of wrapped/unwrapped MCP and built-in maps to the right mode,
|
|
1112
|
+
including the empty-steps and all-live cases.
|
|
1113
|
+
3. **The `updatedInput` merge (§7.1)** — a pinned wrapped-MCP call carries both the pinned arguments
|
|
1114
|
+
and `__bir_call_id__`; the correlation's `hookArgs` are the *pinned* ones; `--no-correlation`
|
|
1115
|
+
still pins.
|
|
1116
|
+
4. **The threading source (§7.2)** — a wrapped step threads the proxy's `result`, not the hook's
|
|
1117
|
+
`tool_response`, and threads the hook's view only after the grace window, with the warning.
|
|
1118
|
+
5. **Budgets** — a `getMatch` that never resolves still returns the prompt hook inside its budget;
|
|
1119
|
+
a derivation that never resolves still pins, using sample values.
|
|
1120
|
+
6. **Direct execution** — end to end through `fake-upstream`, including a step timeout, a dead proxy
|
|
1121
|
+
and a `tools/call` that returns `isError: true` (which is a *result*, not a failure — it must
|
|
1122
|
+
thread).
|
|
1123
|
+
7. **Divergence** — each of the three delivery channels; delimiter neutralisation in the Bash path;
|
|
1124
|
+
the fair-share cap with one enormous response and four small ones.
|
|
1125
|
+
8. **Reporting** — the watermark across three prompts; each outcome's server-side effect against a
|
|
1126
|
+
fake BaseIn; ticket idempotence.
|
|
1127
|
+
9. **Integration** — a real Claude Code session against `fake-upstream` with a hand-written scenario,
|
|
1128
|
+
asserting a `steered_full` report. The only test that catches host-behaviour drift.
|
|
1129
|
+
|
|
1130
|
+
---
|
|
1131
|
+
|
|
1132
|
+
## 19. Rejected alternatives
|
|
1133
|
+
|
|
1134
|
+
**19.1 Deliver direct-mode results as `additionalContext`.** No extra MCP server, no extra tool in the
|
|
1135
|
+
list. Rejected: it must execute inside `UserPromptSubmit`'s timeout (§10), and it asserts to the model
|
|
1136
|
+
that tools ran without a transcript record that they did. §6.2.
|
|
1137
|
+
|
|
1138
|
+
**19.2 A callback URL per proxy instead of long-polling.** Faster dispatch, no idle connection.
|
|
1139
|
+
Rejected: N new loopback listeners, N new tokens, N new things `bir doctor` must verify — for a
|
|
1140
|
+
latency saving of milliseconds on a path already dominated by the upstream's own round trip. Dialling
|
|
1141
|
+
out is also the posture v1 §9 already chose everywhere else. D12.
|
|
1142
|
+
|
|
1143
|
+
**19.3 Give `bir-scenario` its own upstream connections.** Then no work queue at all. Rejected
|
|
1144
|
+
outright: it double-spawns every upstream. Two `chrome-devtools-mcp` processes means two browsers,
|
|
1145
|
+
two profiles, and a scenario that navigates one and screenshots the other.
|
|
1146
|
+
|
|
1147
|
+
**19.4 Port RRepeat's Agent-SDK divergence fallback.** Rejected: it is the dominant cost of RRepeat's
|
|
1148
|
+
calculated run (`savingCalcRunner.md` §4), it adds `@anthropic-ai/claude-agent-sdk` to a
|
|
1149
|
+
zero-dependency package, it needs anti-recursion guards, and here it buys nothing the proxies do not
|
|
1150
|
+
already buy for free. §8.
|
|
1151
|
+
|
|
1152
|
+
**19.5 Block the prompt and answer from the scenario alone.** The original RRepeat design, superseded
|
|
1153
|
+
there (`replay.md` §1, §10). Rejected for the same reasons plus one of ours: it forfeits the live
|
|
1154
|
+
session's built-in tools entirely, which is exactly the half of a mixed scenario the proxy cannot
|
|
1155
|
+
reach.
|
|
1156
|
+
|
|
1157
|
+
**19.6 Match on the whole conversation rather than the prompt.** Arguably better "similar meaning".
|
|
1158
|
+
Rejected as a *server* concern: `findSimilarRun` embeds `run.input`, and changing what is embedded
|
|
1159
|
+
changes the corpus, the threshold and every stored vector. §20 question 1.
|
|
1160
|
+
|
|
1161
|
+
---
|
|
1162
|
+
|
|
1163
|
+
## 20. Open questions
|
|
1164
|
+
|
|
1165
|
+
1. **Should the match embed more than the first prompt?** A turn's meaning often lives in the
|
|
1166
|
+
preceding turn. Changing it is a BaseIn-side decision with a corpus migration attached, and it
|
|
1167
|
+
would let `bir` match mid-conversation rather than only at a turn boundary.
|
|
1168
|
+
2. **Does BaseIn want a `direct` outcome?** Today direct mode reports `steered_full`, which is
|
|
1169
|
+
accurate but hides that the run cost no session tokens at all. A fifth enum member would let the
|
|
1170
|
+
ledger separate "the model was steered cheaply" from "the model did not run the task."
|
|
1171
|
+
3. **`node:vm` for the logic bodies?** §13.1 states plainly that there is no sandbox. A frozen,
|
|
1172
|
+
minimal context would cost little and close the gap between "first-party by provenance" and
|
|
1173
|
+
"first-party by construction."
|
|
1174
|
+
4. **Should a declined match still arm a *dry* plan?** Deriving parameters and computing the first
|
|
1175
|
+
step's input without executing anything would make `bir doctor` able to say *"this scenario would
|
|
1176
|
+
have run these three calls"* — excellent for tuning `BIR_MIN_STEER_SIMILARITY`, at the price of a
|
|
1177
|
+
Haiku call on turns that replay nothing.
|
|
1178
|
+
5. **Should `scenario_steps` carry `recorded_output`?** §8.1 works around its absence with one lazy
|
|
1179
|
+
`GET /recordings/runs/:runId`. A column would make it a field read, would let a scenario outlive
|
|
1180
|
+
the deletion of its source run, and would finally make RRepeat's `recordedOutput` field mean
|
|
1181
|
+
something. Cost: the scenario row grows by the size of every step's output.
|
|
1182
|
+
6. **Cwd fit.** A scenario recorded in project A may pin paths that do not exist in project B, and
|
|
1183
|
+
nothing currently checks. A cheap sixth gate — compare `run.metadata.cwd` — would catch the common
|
|
1184
|
+
case; the general case (relative paths, monorepos) is harder and probably belongs in
|
|
1185
|
+
`toolInputLogic` generation server-side.
|