@basein/runner 0.3.16 → 0.3.18
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/dist/bin/bir-scenario.d.ts +5 -1
- package/dist/bin/bir-scenario.js +155 -5
- package/dist/bin/bir.js +2 -2
- package/dist/bin/scenario-edit.d.ts +7 -0
- package/dist/bin/scenario-edit.js +9 -2
- package/dist/bin/scenario-liveness.d.ts +12 -1
- package/dist/bin/scenario-liveness.js +8 -4
- package/dist/bin/setup.js +3 -3
- package/dist/control/client.d.ts +5 -0
- package/dist/control/client.js +7 -0
- package/dist/control/ensure-hook.d.ts +14 -3
- package/dist/control/ensure-hook.js +18 -5
- package/dist/control/server.d.ts +165 -2
- package/dist/control/server.js +754 -24
- package/dist/control/transcript.d.ts +37 -0
- package/dist/control/transcript.js +112 -0
- package/dist/proxy/intercept.js +13 -0
- package/dist/proxy/session.d.ts +23 -0
- package/dist/proxy/session.js +71 -3
- package/dist/record/recorder.d.ts +154 -0
- package/dist/record/recorder.js +9 -0
- package/dist/record/remote-recorder.d.ts +61 -1
- package/dist/record/remote-recorder.js +303 -20
- package/dist/record/switchable-recorder.js +6 -0
- package/dist/replay/bundle.d.ts +8 -0
- package/dist/replay/bundle.js +20 -6
- package/dist/replay/controller.d.ts +33 -1
- package/dist/replay/controller.js +137 -12
- package/dist/replay/derive.d.ts +15 -0
- package/dist/replay/derive.js +5 -0
- package/dist/replay/plan.d.ts +90 -1
- package/dist/replay/plan.js +232 -10
- package/dist/replay/source-run.d.ts +2 -2
- package/dist/replay/source-run.js +3 -3
- package/dist/replay/targets.d.ts +40 -1
- package/dist/replay/targets.js +105 -3
- package/dist/replay/types.d.ts +6 -5
- package/package.json +1 -1
package/dist/control/server.js
CHANGED
|
@@ -26,19 +26,20 @@
|
|
|
26
26
|
import { createServer } from "node:http";
|
|
27
27
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
28
28
|
import { statSync } from "node:fs";
|
|
29
|
-
import { join } from "node:path";
|
|
29
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
30
30
|
import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
|
|
31
31
|
import { StepIndexAllocator } from "./ordering.js";
|
|
32
32
|
import { pidAlive } from "./discovery.js";
|
|
33
33
|
import { pinCompatible } from "./pin-guard.js";
|
|
34
|
-
import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, thinkingForToolUse, usageSince, } from "./transcript.js";
|
|
34
|
+
import { agentDayOf, contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, settledTextAfterToolUse, thinkingForToolUse, usageSince, } from "./transcript.js";
|
|
35
35
|
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
36
36
|
import { asSent, userText } from "../record/prompt-text.js";
|
|
37
37
|
import { redact, redactText } from "../record/redact.js";
|
|
38
38
|
import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
|
|
39
39
|
import { StepQueue } from "../record/queue.js";
|
|
40
|
-
import { NullRecorder, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioReporter, isServiceProber, } from "../record/recorder.js";
|
|
41
|
-
import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
|
|
40
|
+
import { NullRecorder, isAttentionReader, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioDisputer, isScenarioReporter, isServiceProber, isToolListReporter, } from "../record/recorder.js";
|
|
41
|
+
import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, } from "../replay/controller.js";
|
|
42
|
+
import { localDay } from "../replay/derive.js";
|
|
42
43
|
import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
|
|
43
44
|
import { logDetail, logLine, logOnce, errText } from "../util/log.js";
|
|
44
45
|
import { journal } from "../util/journal.js";
|
|
@@ -48,7 +49,7 @@ import { awaitWithin, serviceHealth } from "../util/service-health.js";
|
|
|
48
49
|
import { offSource } from "../util/off-switch.js";
|
|
49
50
|
import { claudeJsonPath, resolveServers } from "../config/resolve.js";
|
|
50
51
|
import { SCENARIO_SERVER_KEY, isScenarioServer, projectToken, readSidecar } from "../config/generate.js";
|
|
51
|
-
import { installedPath } from "./paths.js";
|
|
52
|
+
import { controlKey, installedPath } from "./paths.js";
|
|
52
53
|
/** How long a `/tool/post` waits for the proxy's own report before recording its own view. */
|
|
53
54
|
const PROXY_REPORT_GRACE_MS = 1_500;
|
|
54
55
|
/**
|
|
@@ -149,6 +150,218 @@ const DELIVERY_TOOL_REFRESH_MS = 5 * 60_000;
|
|
|
149
150
|
* is never near this; a process that died without a word is caught by its pid.
|
|
150
151
|
*/
|
|
151
152
|
const DELIVERY_HELLO_TTL_MS = 30 * 60_000;
|
|
153
|
+
/** A tool list the service took is sent again after this long (AC12): one it lost comes back. */
|
|
154
|
+
const TOOL_LIST_RESEND_MS = 6 * 60 * 60_000;
|
|
155
|
+
/** One whose send failed is not sent again sooner than this: a refusing service is not sent it at every session start. */
|
|
156
|
+
const TOOL_LIST_RETRY_MS = 10 * 60_000;
|
|
157
|
+
/** What the service keeps of a list (its agentTools.ts): beyond it a list would only be cut there, or refused. */
|
|
158
|
+
const TOOL_LIST_MAX = 300;
|
|
159
|
+
const TOOL_DESCRIPTION_MAX = 4_000;
|
|
160
|
+
const TOOL_SCHEMA_MAX = 16_000;
|
|
161
|
+
const TOOL_NAME_MAX = 300;
|
|
162
|
+
/** One send's body at most: the service takes up to 5 MB. */
|
|
163
|
+
const TOOL_LIST_BODY_MAX = 2_000_000;
|
|
164
|
+
/** Every string of a schema with secret-shaped runs taken out. Keys stay: a property named `token` is no secret. */
|
|
165
|
+
function redactStrings(value, depth = 0) {
|
|
166
|
+
if (depth > 24)
|
|
167
|
+
return null;
|
|
168
|
+
if (typeof value === "string")
|
|
169
|
+
return redactText(value);
|
|
170
|
+
if (Array.isArray(value))
|
|
171
|
+
return value.map((v) => redactStrings(v, depth + 1));
|
|
172
|
+
if (value && typeof value === "object") {
|
|
173
|
+
const out = {};
|
|
174
|
+
for (const [k, v] of Object.entries(value)) {
|
|
175
|
+
Object.defineProperty(out, k, { value: redactStrings(v, depth + 1), enumerable: true, writable: true, configurable: true });
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
/** A tool's `annotations.title`, at most this long. */
|
|
182
|
+
const TOOL_TITLE_MAX = 200;
|
|
183
|
+
/** The four hints of MCP `annotations` the service reads, all booleans. */
|
|
184
|
+
const TOOL_HINTS = ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"];
|
|
185
|
+
/**
|
|
186
|
+
* What a server says of one tool (MCP `annotations`; the service's review of
|
|
187
|
+
* calcscenarioautocorrect.md, items 4 and 16), as the service takes it: its
|
|
188
|
+
* `title`, redacted as a description is and cut to {@link TOOL_TITLE_MAX},
|
|
189
|
+
* and the four hints when each is a boolean. Anything else a server put there
|
|
190
|
+
* is dropped; undefined when nothing is left.
|
|
191
|
+
*/
|
|
192
|
+
function annotationsOf(raw) {
|
|
193
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
194
|
+
return undefined;
|
|
195
|
+
const a = raw;
|
|
196
|
+
const out = {};
|
|
197
|
+
if (typeof a.title === "string" && a.title.trim())
|
|
198
|
+
out.title = redactText(a.title.trim()).slice(0, TOOL_TITLE_MAX);
|
|
199
|
+
for (const hint of TOOL_HINTS) {
|
|
200
|
+
if (typeof a[hint] === "boolean")
|
|
201
|
+
out[hint] = a[hint];
|
|
202
|
+
}
|
|
203
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The project a run or a tool list belongs to (the service's review of
|
|
207
|
+
* calcscenarioautocorrect.md, item 16): the first 16 hex characters of the
|
|
208
|
+
* sha256 of its folder, made absolute and — on Windows — lower-cased with
|
|
209
|
+
* forward slashes, as the discovery file's key is (paths.ts, `controlKey`).
|
|
210
|
+
* The service only compares it, so a plan's recording finds its own
|
|
211
|
+
* project's tools and never another project's server of the same name.
|
|
212
|
+
*/
|
|
213
|
+
export function projectKey(dir) {
|
|
214
|
+
return controlKey(resolvePath(dir));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The plans that need their owner are asked of the service at most this
|
|
218
|
+
* often per account (`GET /scenarios/attention`, review item 8): a session
|
|
219
|
+
* start, a `/clear` and a resume each show the line, and none of them waits
|
|
220
|
+
* on the service again within it.
|
|
221
|
+
*/
|
|
222
|
+
const ATTENTION_CACHE_MS = 10 * 60_000;
|
|
223
|
+
/** How long a session start waits for that answer. A slower one only fills the cache for the next. */
|
|
224
|
+
const ATTENTION_WAIT_MS = 1_500;
|
|
225
|
+
/**
|
|
226
|
+
* How long "nothing can be said" is kept instead — no answer, a failure, a
|
|
227
|
+
* service failing just then, an older one: a minute, so a session start
|
|
228
|
+
* during a short outage does not keep the line from the next ten minutes'.
|
|
229
|
+
*/
|
|
230
|
+
const ATTENTION_RETRY_MS = 60_000;
|
|
231
|
+
/** A `report_wrong_result` call's `PreToolUse` is matched to its report for this long. */
|
|
232
|
+
const REPORT_CALL_TTL_MS = 10 * 60_000;
|
|
233
|
+
/** Without such a match, the one session whose latest plan ran this recently is the one meant. */
|
|
234
|
+
const REPORT_FALLBACK_MS = 30 * 60_000;
|
|
235
|
+
/** What the agent may say a replay got wrong, in characters. */
|
|
236
|
+
const REPORT_REASON_MAX = 2_000;
|
|
237
|
+
/** Why a plan is held, in the words of the session-start line. A correction waiting says that instead. */
|
|
238
|
+
const HELD_WHY = {
|
|
239
|
+
checking: ", while its result is checked",
|
|
240
|
+
rejected: ", the agent did not take its result",
|
|
241
|
+
rejected_again: ", the agent did not take its result again",
|
|
242
|
+
verifying: ", until the agent's next run checks its correction",
|
|
243
|
+
verify_failed: ", its correction did not match the agent's run",
|
|
244
|
+
verify_stale: ", its correction is still not checked",
|
|
245
|
+
};
|
|
246
|
+
/** A plan's title as the line quotes it: one line, no quote marks or control characters, 80 characters at most. */
|
|
247
|
+
function lineTitle(title) {
|
|
248
|
+
const one = title
|
|
249
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, " ")
|
|
250
|
+
.replace(/["“”]/g, "'")
|
|
251
|
+
.replace(/\s+/g, " ")
|
|
252
|
+
.trim();
|
|
253
|
+
return one.length > 80 ? `${one.slice(0, 79)}…` : one;
|
|
254
|
+
}
|
|
255
|
+
/** A link the line may show: http(s), with nothing in it a terminal would read as anything but a link. */
|
|
256
|
+
function lineUrl(url) {
|
|
257
|
+
const u = url.trim();
|
|
258
|
+
return u.length <= 500 && /^https?:\/\/[^\s"'<>\u0000-\u001f\u007f]+$/.test(u) ? u : undefined;
|
|
259
|
+
}
|
|
260
|
+
/** "3 days", "5 hours", "20 minutes" since `iso`; "" when it is not a time in the past. */
|
|
261
|
+
function heldFor(iso, now) {
|
|
262
|
+
const ms = now - Date.parse(iso);
|
|
263
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
264
|
+
return "";
|
|
265
|
+
const minutes = Math.max(1, Math.floor(ms / 60_000));
|
|
266
|
+
if (minutes < 60)
|
|
267
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
268
|
+
const hours = Math.floor(minutes / 60);
|
|
269
|
+
if (hours < 48)
|
|
270
|
+
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
|
271
|
+
return `${Math.floor(hours / 24)} days`;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The one line a session start shows the person (review item 8): which plan
|
|
275
|
+
* waits, for how long, why, and where to look —
|
|
276
|
+
* `bir: a plan needs you — "Check engaged users and reassign leads" held for 3 days, a correction is waiting: <url>`
|
|
277
|
+
* — and with more than one, how many, and the first one's page. A plan that
|
|
278
|
+
* is not held, whose correction waits, says only that.
|
|
279
|
+
*/
|
|
280
|
+
export function attentionText(plans, now = Date.now()) {
|
|
281
|
+
const first = plans[0];
|
|
282
|
+
if (!first)
|
|
283
|
+
return undefined;
|
|
284
|
+
const url = lineUrl(first.url);
|
|
285
|
+
const where = url ? `: ${url}` : "";
|
|
286
|
+
if (plans.length > 1)
|
|
287
|
+
return `bir: ${plans.length} plans need you${where}`;
|
|
288
|
+
const title = lineTitle(first.title);
|
|
289
|
+
const name = title ? ` "${title}"` : "";
|
|
290
|
+
if (!first.heldSince) {
|
|
291
|
+
return first.correction === "proposed"
|
|
292
|
+
? `bir: a plan needs you —${name} has a correction waiting${where}`
|
|
293
|
+
: `bir: a plan needs you —${name}${where}`;
|
|
294
|
+
}
|
|
295
|
+
const held = heldFor(first.heldSince, now);
|
|
296
|
+
const why = first.correction === "proposed" ? ", a correction is waiting" : first.reason ? HELD_WHY[first.reason] : "";
|
|
297
|
+
return `bir: a plan needs you —${name} held${held ? ` for ${held}` : ""}${why}${where}`;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* A server's tool list as the service takes it (AC12): each tool's name as the
|
|
301
|
+
* agent calls it, its description and input schema with secret-shaped text
|
|
302
|
+
* taken out, and what its server says of it (`annotations`, review item 4),
|
|
303
|
+
* within what the service keeps; a body too large loses schemas first, from
|
|
304
|
+
* the end, then descriptions.
|
|
305
|
+
*/
|
|
306
|
+
function toolListForService(serverName, raw) {
|
|
307
|
+
const tools = [];
|
|
308
|
+
for (const item of raw) {
|
|
309
|
+
if (tools.length >= TOOL_LIST_MAX)
|
|
310
|
+
break;
|
|
311
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
312
|
+
continue;
|
|
313
|
+
const t = item;
|
|
314
|
+
if (typeof t.name !== "string" || !t.name.trim())
|
|
315
|
+
continue;
|
|
316
|
+
const qualifiedName = qualifyToolName(serverName, t.name);
|
|
317
|
+
if (qualifiedName.length > TOOL_NAME_MAX)
|
|
318
|
+
continue;
|
|
319
|
+
const description = typeof t.description === "string" && t.description.trim()
|
|
320
|
+
? redactText(t.description).slice(0, TOOL_DESCRIPTION_MAX)
|
|
321
|
+
: undefined;
|
|
322
|
+
let inputSchema;
|
|
323
|
+
if (t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema)) {
|
|
324
|
+
const cleaned = redactStrings(t.inputSchema);
|
|
325
|
+
const text = JSON.stringify(cleaned);
|
|
326
|
+
if (text !== undefined && text.length <= TOOL_SCHEMA_MAX)
|
|
327
|
+
inputSchema = cleaned;
|
|
328
|
+
}
|
|
329
|
+
const annotations = annotationsOf(t.annotations);
|
|
330
|
+
tools.push({
|
|
331
|
+
name: t.name,
|
|
332
|
+
qualifiedName,
|
|
333
|
+
...(description ? { description } : {}),
|
|
334
|
+
...(inputSchema ? { inputSchema } : {}),
|
|
335
|
+
...(annotations ? { annotations } : {}),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
let size = JSON.stringify(tools).length;
|
|
339
|
+
for (let i = tools.length - 1; i >= 0 && size > TOOL_LIST_BODY_MAX; i--) {
|
|
340
|
+
const schema = tools[i].inputSchema;
|
|
341
|
+
if (schema) {
|
|
342
|
+
size -= JSON.stringify(schema).length;
|
|
343
|
+
delete tools[i].inputSchema;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (let i = tools.length - 1; i >= 0 && size > TOOL_LIST_BODY_MAX; i--) {
|
|
347
|
+
const description = tools[i].description;
|
|
348
|
+
if (description) {
|
|
349
|
+
size -= description.length;
|
|
350
|
+
delete tools[i].description;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return tools;
|
|
354
|
+
}
|
|
355
|
+
/** The account a recorder sends for, when it says. */
|
|
356
|
+
function accountOf(recorder) {
|
|
357
|
+
const fn = recorder.accountId;
|
|
358
|
+
try {
|
|
359
|
+
return typeof fn === "function" ? fn.call(recorder) : undefined;
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
152
365
|
/** At most this many `bir-scenario` processes are remembered, newest kept. */
|
|
153
366
|
const DELIVERY_HELLO_MAX = 256;
|
|
154
367
|
/*
|
|
@@ -263,6 +476,29 @@ function positiveEnv(name, fallback) {
|
|
|
263
476
|
const n = Number(process.env[name]);
|
|
264
477
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
265
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* A recorded row's metadata, with only what it has (review item 7): a plan
|
|
481
|
+
* that pinned it, the call's id — `""` is no id — and `isError` only when
|
|
482
|
+
* true. Undefined when there is nothing, so such a row is the body it always
|
|
483
|
+
* was.
|
|
484
|
+
*/
|
|
485
|
+
function stepMarks(m) {
|
|
486
|
+
const out = {};
|
|
487
|
+
if (m.pinnedBy)
|
|
488
|
+
out.pinnedBy = m.pinnedBy;
|
|
489
|
+
if (m.callId)
|
|
490
|
+
out.callId = m.callId.slice(0, 200);
|
|
491
|
+
if (m.isError === true)
|
|
492
|
+
out.isError = true;
|
|
493
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
494
|
+
}
|
|
495
|
+
/** Whether a tool's answer, as the hook saw it, is an MCP result that says it failed (`isError: true`). */
|
|
496
|
+
function resultSaysError(response) {
|
|
497
|
+
return (response !== null &&
|
|
498
|
+
typeof response === "object" &&
|
|
499
|
+
!Array.isArray(response) &&
|
|
500
|
+
response.isError === true);
|
|
501
|
+
}
|
|
266
502
|
/** A relayed flag that says yes: `true`, `1`, `"1"` or `"true"` (any case). */
|
|
267
503
|
function isOn(value) {
|
|
268
504
|
if (value === true || value === 1)
|
|
@@ -434,6 +670,20 @@ export class ControlServer {
|
|
|
434
670
|
proxySessions = new Map();
|
|
435
671
|
/** `run_scenario` pres not yet consumed by a `/scenario/run`, oldest first. */
|
|
436
672
|
directCalls = [];
|
|
673
|
+
/**
|
|
674
|
+
* `report_wrong_result` pres not yet consumed by a `/scenario/dispute`,
|
|
675
|
+
* oldest first (review item 19): how a report from `bir-scenario`, which is
|
|
676
|
+
* never told its session, finds the session — and so the plan — it is about.
|
|
677
|
+
*/
|
|
678
|
+
reportCalls = [];
|
|
679
|
+
/** Plans the agent said were wrong, and the service's answer — on its way, or in. */
|
|
680
|
+
disputed = new WeakMap();
|
|
681
|
+
/**
|
|
682
|
+
* The plans that need their owner, by account, as the service last listed
|
|
683
|
+
* them (`GET /scenarios/attention`, review item 8), and the ask still on its
|
|
684
|
+
* way. Null is "nothing can be said": no service, an older one, a failure.
|
|
685
|
+
*/
|
|
686
|
+
attention = new Map();
|
|
437
687
|
/** Plans a `/scenario/run` is executing right now: one run per plan. */
|
|
438
688
|
runningPlans = new Set();
|
|
439
689
|
/**
|
|
@@ -966,6 +1216,9 @@ export class ControlServer {
|
|
|
966
1216
|
case "/proxy/result":
|
|
967
1217
|
this.send(res, 200, this.onProxyResult(body));
|
|
968
1218
|
return;
|
|
1219
|
+
case "/proxy/tools":
|
|
1220
|
+
this.send(res, 200, this.onProxyTools(body));
|
|
1221
|
+
return;
|
|
969
1222
|
case "/scenario/run": {
|
|
970
1223
|
// Like a long poll, a run can outlive its caller: bir-scenario drops
|
|
971
1224
|
// the request when the host cancels the call or its timeout fires.
|
|
@@ -992,6 +1245,9 @@ export class ControlServer {
|
|
|
992
1245
|
case "/scenario/hello":
|
|
993
1246
|
this.send(res, 200, this.onScenarioHello(body));
|
|
994
1247
|
return;
|
|
1248
|
+
case "/scenario/dispute":
|
|
1249
|
+
this.send(res, 200, await this.onScenarioDispute(body));
|
|
1250
|
+
return;
|
|
995
1251
|
default:
|
|
996
1252
|
this.send(res, 404, { error: "not_found" });
|
|
997
1253
|
return;
|
|
@@ -1283,7 +1539,7 @@ export class ControlServer {
|
|
|
1283
1539
|
async dispatchHook(route, payload, onLate) {
|
|
1284
1540
|
switch (route) {
|
|
1285
1541
|
case "/session/start":
|
|
1286
|
-
return this.onSessionStart(payload);
|
|
1542
|
+
return await this.onSessionStart(payload);
|
|
1287
1543
|
case "/session/prompt": {
|
|
1288
1544
|
// What a cancelled direct run left this session to tell (see
|
|
1289
1545
|
// `SessionState.stashedNote`). Claimed before the wait, so the
|
|
@@ -1639,8 +1895,24 @@ export class ControlServer {
|
|
|
1639
1895
|
lossy: this.lossy,
|
|
1640
1896
|
sessionId: session.sessionId,
|
|
1641
1897
|
cwd: session.cwd ?? this.opts.cwd,
|
|
1898
|
+
// The project's key (review item 16): what the tool lists of this
|
|
1899
|
+
// project carry too, so the service offers a correction of this run's
|
|
1900
|
+
// plan the tools of this project only. See {@link projectKeyHere}.
|
|
1901
|
+
project: this.projectKeyHere(),
|
|
1642
1902
|
};
|
|
1643
1903
|
}
|
|
1904
|
+
/**
|
|
1905
|
+
* The project key of everything this recorder sends (review item 16): its
|
|
1906
|
+
* runs and its tool lists. The folder this recorder serves — the one its
|
|
1907
|
+
* discovery file is keyed by and its hooks are installed in — and not a
|
|
1908
|
+
* session's `cwd`: that follows the agent's `cd` from turn to turn, and a
|
|
1909
|
+
* tool list is sent when its proxy starts, before any of its calls ties it
|
|
1910
|
+
* to a session. The service matches a plan's recording to its tools by
|
|
1911
|
+
* equality, so both sides must hash the same folder.
|
|
1912
|
+
*/
|
|
1913
|
+
projectKeyHere() {
|
|
1914
|
+
return projectKey(this.opts.cwd);
|
|
1915
|
+
}
|
|
1644
1916
|
/**
|
|
1645
1917
|
* A similar-prompt hit means the service created no run, so every later step
|
|
1646
1918
|
* post would 404. Stop recording it — that half is v1 and is mandatory, and
|
|
@@ -1771,7 +2043,7 @@ export class ControlServer {
|
|
|
1771
2043
|
// Awaited: a scenario with a target derives before the directive goes
|
|
1772
2044
|
// out, so a plan that cannot find its target is never delivered
|
|
1773
2045
|
// (segmented.md R-PARAM-4). Inside the prompt's own budget.
|
|
1774
|
-
const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", { recentResults: this.recentResultsFor(session, "prompt") }, this.hostContext(session, run)));
|
|
2046
|
+
const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", { recentResults: this.recentResultsFor(session, "prompt"), agentDay: this.agentDayFor(session) }, this.hostContext(session, run)));
|
|
1775
2047
|
const remaining = Math.max(0, promptWaitMs - (Date.now() - startedAt));
|
|
1776
2048
|
const state = await this.withinBudget(arming, remaining);
|
|
1777
2049
|
if (state === undefined)
|
|
@@ -1813,9 +2085,15 @@ export class ControlServer {
|
|
|
1813
2085
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
1814
2086
|
// The service offered to record this turn as the recording's newest run:
|
|
1815
2087
|
// should the agent redo the task after the plan, the plan's own calls go
|
|
1816
|
-
// into that run first, so they are kept whole as they run (AC2).
|
|
1817
|
-
|
|
2088
|
+
// into that run first, so they are kept whole as they run (AC2). And in a
|
|
2089
|
+
// Claude Code session, should the agent only answer, they go whole beside
|
|
2090
|
+
// its answer (review item 12), where the report has 3,800 bytes of each.
|
|
2091
|
+
if (match.repeat?.runId || !otherHost(session))
|
|
1818
2092
|
state.keepCalls = true;
|
|
2093
|
+
// A bundle that forbids every tool call makes one exception for `bir`'s
|
|
2094
|
+
// report tool, where this session has it (review item 19).
|
|
2095
|
+
if (!otherHost(session))
|
|
2096
|
+
state.reportTool = () => this.reportToolServed();
|
|
1819
2097
|
this.declineWithoutDeliveryTool(state);
|
|
1820
2098
|
// A steer plan with a shell or file-write step can only be followed if the
|
|
1821
2099
|
// directive shows its computed arguments (the pin guard holds those steps
|
|
@@ -2009,6 +2287,20 @@ export class ControlServer {
|
|
|
2009
2287
|
return false;
|
|
2010
2288
|
return this.liveHellos(Date.now()).some(([, h]) => h.tools >= 1 && !h.off);
|
|
2011
2289
|
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Whether a running `bir-scenario` here serves `report_wrong_result` right
|
|
2292
|
+
* now (review item 19): one that is not off said so in its hello. What a
|
|
2293
|
+
* bundle delivered beside a call of the agent's own (a divergence) is laid
|
|
2294
|
+
* out by: such a bundle forbids every tool call but this one. A direct
|
|
2295
|
+
* plan's bundle goes by its own `bir-scenario`'s word instead, which came
|
|
2296
|
+
* with its `/scenario/run` (see {@link onScenarioRun}). Configured is not
|
|
2297
|
+
* connected, as for the delivery tool.
|
|
2298
|
+
*/
|
|
2299
|
+
reportToolServed() {
|
|
2300
|
+
if (!this.deliveryConfigured())
|
|
2301
|
+
return false;
|
|
2302
|
+
return this.liveHellos(Date.now()).some(([, h]) => h.report === true && !h.off);
|
|
2303
|
+
}
|
|
2012
2304
|
/**
|
|
2013
2305
|
* Whether this project's configuration has the `bir` server, as far as
|
|
2014
2306
|
* anything here knows: a hook that named one of its tools, the caller's
|
|
@@ -2049,6 +2341,73 @@ export class ControlServer {
|
|
|
2049
2341
|
}
|
|
2050
2342
|
return out.sort((a, b) => b[1].at - a[1].at);
|
|
2051
2343
|
}
|
|
2344
|
+
/**
|
|
2345
|
+
* The tool list of each wrapped server last sent to the service, by
|
|
2346
|
+
* account, project and server (AC12; review item 16): its hash, when, and
|
|
2347
|
+
* whether the service took it (null while the send is on its way).
|
|
2348
|
+
*/
|
|
2349
|
+
toolListsSent = new Map();
|
|
2350
|
+
/**
|
|
2351
|
+
* `POST /proxy/tools` — a proxy's server listed its tools (`tools/list`;
|
|
2352
|
+
* the service's autocorrect.md AC12). Its names, descriptions, input
|
|
2353
|
+
* schemas and what it says of each tool (`annotations`) go to the service —
|
|
2354
|
+
* secret-shaped text taken out, and within what the service keeps — with
|
|
2355
|
+
* the project they are of ({@link projectKeyHere}, the key every run here
|
|
2356
|
+
* carries). Sent when the list changed since that server's last one for
|
|
2357
|
+
* this account and project, when the last send failed ten minutes ago, or
|
|
2358
|
+
* six hours after the last one the service took. `bir off`, and a session
|
|
2359
|
+
* started with BIR_OFF=1, send nothing, as for a step. Always `{ ok: true }`;
|
|
2360
|
+
* never slow.
|
|
2361
|
+
*/
|
|
2362
|
+
onProxyTools(body) {
|
|
2363
|
+
if (offSource(this.opts.cwd))
|
|
2364
|
+
return { ok: true, off: true };
|
|
2365
|
+
const boundTo = typeof body.proxyId === "string" ? this.proxySessions.get(body.proxyId) : undefined;
|
|
2366
|
+
if (this.isSessionOff(boundTo))
|
|
2367
|
+
return { ok: true, off: true };
|
|
2368
|
+
const serverName = typeof body.serverName === "string" ? body.serverName.trim().slice(0, 200) : "";
|
|
2369
|
+
if (!serverName || !Array.isArray(body.tools) || !isToolListReporter(this.recorder))
|
|
2370
|
+
return { ok: true };
|
|
2371
|
+
const tools = toolListForService(serverName, body.tools);
|
|
2372
|
+
// A server still starting may list nothing: that replaces no list.
|
|
2373
|
+
if (tools.length === 0)
|
|
2374
|
+
return { ok: true };
|
|
2375
|
+
const project = this.projectKeyHere();
|
|
2376
|
+
const key = `${accountOf(this.recorder) ?? ""}\u0000${project}\u0000${serverName}`;
|
|
2377
|
+
const hash = createHash("sha256").update(JSON.stringify(tools)).digest("hex");
|
|
2378
|
+
const now = Date.now();
|
|
2379
|
+
const last = this.toolListsSent.get(key);
|
|
2380
|
+
if (last &&
|
|
2381
|
+
last.hash === hash &&
|
|
2382
|
+
(last.ok === null || now - last.at < (last.ok ? TOOL_LIST_RESEND_MS : TOOL_LIST_RETRY_MS))) {
|
|
2383
|
+
return { ok: true };
|
|
2384
|
+
}
|
|
2385
|
+
const entry = { hash, at: now, ok: null };
|
|
2386
|
+
this.toolListsSent.delete(key);
|
|
2387
|
+
this.toolListsSent.set(key, entry);
|
|
2388
|
+
while (this.toolListsSent.size > 512) {
|
|
2389
|
+
const oldest = this.toolListsSent.keys().next().value;
|
|
2390
|
+
if (oldest === undefined)
|
|
2391
|
+
break;
|
|
2392
|
+
this.toolListsSent.delete(oldest);
|
|
2393
|
+
}
|
|
2394
|
+
let sent;
|
|
2395
|
+
try {
|
|
2396
|
+
sent = Promise.resolve(this.recorder.reportTools(serverName, tools, project));
|
|
2397
|
+
}
|
|
2398
|
+
catch {
|
|
2399
|
+
sent = Promise.resolve(false);
|
|
2400
|
+
}
|
|
2401
|
+
void sent
|
|
2402
|
+
.catch(() => false)
|
|
2403
|
+
.then((ok) => {
|
|
2404
|
+
if (this.toolListsSent.get(key) !== entry)
|
|
2405
|
+
return;
|
|
2406
|
+
entry.ok = ok === true;
|
|
2407
|
+
entry.at = Date.now();
|
|
2408
|
+
});
|
|
2409
|
+
return { ok: true };
|
|
2410
|
+
}
|
|
2052
2411
|
/**
|
|
2053
2412
|
* `POST /scenario/hello` — a running `bir-scenario` saying what it serves
|
|
2054
2413
|
* (K3): on `initialize`, on `tools/list`, when its off state flips and every
|
|
@@ -2064,10 +2423,13 @@ export class ControlServer {
|
|
|
2064
2423
|
const tools = Number.isFinite(toolsRaw) && toolsRaw > 0 ? Math.floor(toolsRaw) : 0;
|
|
2065
2424
|
const off = body.off === true;
|
|
2066
2425
|
const version = typeof body.version === "string" ? body.version.slice(0, 40) : undefined;
|
|
2426
|
+
// It serves `report_wrong_result` (runner 0.3.18 on; review item 19). An
|
|
2427
|
+
// older `bir-scenario` says nothing of it, and serves none.
|
|
2428
|
+
const report = body.report === true;
|
|
2067
2429
|
const before = this.scenarioHellos.get(pid);
|
|
2068
2430
|
// Re-inserted, so the map's order is oldest hello first.
|
|
2069
2431
|
this.scenarioHellos.delete(pid);
|
|
2070
|
-
this.scenarioHellos.set(pid, { at: Date.now(), tools, off, version });
|
|
2432
|
+
this.scenarioHellos.set(pid, { at: Date.now(), tools, off, version, ...(report ? { report } : {}) });
|
|
2071
2433
|
while (this.scenarioHellos.size > DELIVERY_HELLO_MAX) {
|
|
2072
2434
|
const oldest = this.scenarioHellos.keys().next().value;
|
|
2073
2435
|
if (oldest === undefined)
|
|
@@ -2088,6 +2450,138 @@ export class ControlServer {
|
|
|
2088
2450
|
}
|
|
2089
2451
|
return { ok: true };
|
|
2090
2452
|
}
|
|
2453
|
+
/** Remember a `report_wrong_result` pre (see {@link reportCalls}): a few, and only for a while. */
|
|
2454
|
+
noteReportCall(sessionId, toolUseId) {
|
|
2455
|
+
const now = Date.now();
|
|
2456
|
+
this.reportCalls = this.reportCalls.filter((c) => now - c.at < REPORT_CALL_TTL_MS && c.toolUseId !== toolUseId);
|
|
2457
|
+
this.reportCalls.push({ sessionId, toolUseId, at: now });
|
|
2458
|
+
if (this.reportCalls.length > 64)
|
|
2459
|
+
this.reportCalls = this.reportCalls.slice(-64);
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* `POST /scenario/dispute` — the agent said, through `bir`'s
|
|
2463
|
+
* `report_wrong_result`, that a replay's results do not answer its request
|
|
2464
|
+
* (the service's review of calcscenarioautocorrect.md, item 19): the plan
|
|
2465
|
+
* took the wrong half of an "if … otherwise", or acted on the wrong item. A
|
|
2466
|
+
* wrong half is caught only when the agent says so, and until now there was
|
|
2467
|
+
* no way to say it but in an answer nobody might read.
|
|
2468
|
+
*
|
|
2469
|
+
* WHOSE PLAN. The session is the one whose `PreToolUse` for the tool carried
|
|
2470
|
+
* the request's `toolUseId` (`bir-scenario` forwards the host's id for the
|
|
2471
|
+
* call, as for `run_scenario`); without one, the one session where such a
|
|
2472
|
+
* call is waiting; without that either, the one session whose plan ran in
|
|
2473
|
+
* the last half hour. Never a guess between two. The plan is the newest one
|
|
2474
|
+
* that ran in that session's latest turn with a plan: this turn's, or the
|
|
2475
|
+
* one before it.
|
|
2476
|
+
*
|
|
2477
|
+
* WHAT GOES. `POST /scenarios/:scenarioId/dispute` with the reason, `by:
|
|
2478
|
+
* "agent"`, and the turn's execution ticket as `executionId` — the id its
|
|
2479
|
+
* report books under when the turn ends, so the service may well have the
|
|
2480
|
+
* ticket and not yet the row. Once per plan and turn: a second report of the
|
|
2481
|
+
* same plan is answered from the first. Nothing here undoes anything.
|
|
2482
|
+
*
|
|
2483
|
+
* Answers `{ ok, held?, why? }` for `bir-scenario` to put in one sentence.
|
|
2484
|
+
* Never throws, and is never slow beyond the service's own bound.
|
|
2485
|
+
*/
|
|
2486
|
+
async onScenarioDispute(body) {
|
|
2487
|
+
if (offSource(this.opts.cwd))
|
|
2488
|
+
return { ok: false, why: "off" };
|
|
2489
|
+
const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, REPORT_REASON_MAX) : "";
|
|
2490
|
+
if (!reason)
|
|
2491
|
+
return { ok: false, why: "no_reason" };
|
|
2492
|
+
const session = this.sessionForDispute(body);
|
|
2493
|
+
if (!session || this.isSessionOff(session.sessionId))
|
|
2494
|
+
return { ok: false, why: "no_plan" };
|
|
2495
|
+
const state = this.disputedPlan(session);
|
|
2496
|
+
if (!state?.scenarioId) {
|
|
2497
|
+
logLine("replay.dispute_unplaced", {
|
|
2498
|
+
sess: session.sessionId,
|
|
2499
|
+
why: "the agent reported a wrong replay, and no plan ran in this session's latest turns — nothing is sent",
|
|
2500
|
+
});
|
|
2501
|
+
return { ok: false, why: "no_plan" };
|
|
2502
|
+
}
|
|
2503
|
+
const scenarioId = state.scenarioId;
|
|
2504
|
+
// Once per plan and turn: a second report — also one sent while the first
|
|
2505
|
+
// is on its way — is answered from the first. One that was not taken may
|
|
2506
|
+
// be tried again.
|
|
2507
|
+
const earlier = this.disputed.get(state);
|
|
2508
|
+
if (earlier) {
|
|
2509
|
+
const first = await earlier;
|
|
2510
|
+
return first.ok
|
|
2511
|
+
? { ok: true, held: first.held === true, why: "already_reported", scenarioId }
|
|
2512
|
+
: { ok: false, held: false, ...(first.why ? { why: first.why } : {}), scenarioId };
|
|
2513
|
+
}
|
|
2514
|
+
const recorder = this.recorder;
|
|
2515
|
+
if (!isScenarioDisputer(recorder))
|
|
2516
|
+
return { ok: false, why: "no_service", scenarioId };
|
|
2517
|
+
const pending = recorder
|
|
2518
|
+
.disputeScenario(scenarioId, { reason, ...(state.ticket ? { executionId: state.ticket } : {}) }, { timeoutMs: 10_000 })
|
|
2519
|
+
.catch(() => ({ ok: false, why: "service_error" }));
|
|
2520
|
+
this.disputed.set(state, pending);
|
|
2521
|
+
const answer = await pending;
|
|
2522
|
+
if (!answer.ok && this.disputed.get(state) === pending)
|
|
2523
|
+
this.disputed.delete(state);
|
|
2524
|
+
logLine("replay.disputed", {
|
|
2525
|
+
sess: session.sessionId,
|
|
2526
|
+
scenario: scenarioId,
|
|
2527
|
+
ticket: state.ticket,
|
|
2528
|
+
ok: answer.ok,
|
|
2529
|
+
held: answer.held === true || undefined,
|
|
2530
|
+
why: answer.ok
|
|
2531
|
+
? "the agent said this replay's results do not answer its request — reported to the service"
|
|
2532
|
+
: `the agent's report that this replay was wrong was not taken: ${answer.why ?? "no reason given"}`,
|
|
2533
|
+
});
|
|
2534
|
+
return {
|
|
2535
|
+
ok: answer.ok,
|
|
2536
|
+
held: answer.held === true,
|
|
2537
|
+
...(answer.why ? { why: answer.why } : {}),
|
|
2538
|
+
scenarioId,
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
/** The session a `/scenario/dispute` is about (see {@link onScenarioDispute}), or undefined. */
|
|
2542
|
+
sessionForDispute(body) {
|
|
2543
|
+
const now = Date.now();
|
|
2544
|
+
this.reportCalls = this.reportCalls.filter((c) => now - c.at < REPORT_CALL_TTL_MS);
|
|
2545
|
+
const toolUseId = typeof body.toolUseId === "string" && body.toolUseId ? body.toolUseId : undefined;
|
|
2546
|
+
let pick = toolUseId ? this.reportCalls.find((c) => c.toolUseId === toolUseId) : undefined;
|
|
2547
|
+
if (!pick && !toolUseId && new Set(this.reportCalls.map((c) => c.sessionId)).size === 1) {
|
|
2548
|
+
pick = this.reportCalls[this.reportCalls.length - 1];
|
|
2549
|
+
}
|
|
2550
|
+
if (pick) {
|
|
2551
|
+
const chosen = pick;
|
|
2552
|
+
this.reportCalls = this.reportCalls.filter((c) => c !== chosen);
|
|
2553
|
+
const session = this.sessions.get(chosen.sessionId);
|
|
2554
|
+
if (session)
|
|
2555
|
+
return session;
|
|
2556
|
+
}
|
|
2557
|
+
// No pre seen for it — a hook that did not arrive. The one session whose
|
|
2558
|
+
// plan ran lately, or none: two would be a guess.
|
|
2559
|
+
const recent = [...this.sessions.values()].filter((s) => {
|
|
2560
|
+
if (otherHost(s))
|
|
2561
|
+
return false;
|
|
2562
|
+
const state = this.disputedPlan(s);
|
|
2563
|
+
return state !== undefined && now - state.armedAt < REPORT_FALLBACK_MS;
|
|
2564
|
+
});
|
|
2565
|
+
return recent.length === 1 ? recent[0] : undefined;
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* The plan a report of a wrong replay is about: the newest that ran — a
|
|
2569
|
+
* step of it did its work — in the session's latest turn with one, this
|
|
2570
|
+
* turn or the one before it.
|
|
2571
|
+
*/
|
|
2572
|
+
disputedPlan(session) {
|
|
2573
|
+
for (const run of [session.run, session.previous]) {
|
|
2574
|
+
if (!run)
|
|
2575
|
+
continue;
|
|
2576
|
+
const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
|
|
2577
|
+
for (let i = states.length - 1; i >= 0; i -= 1) {
|
|
2578
|
+
const s = states[i];
|
|
2579
|
+
if (s.scenarioId && (s.stepsPinned > 0 || s.ranToEnd === true))
|
|
2580
|
+
return s;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
return undefined;
|
|
2584
|
+
}
|
|
2091
2585
|
/**
|
|
2092
2586
|
* Re-read whether this project configures the `bir` server — only when
|
|
2093
2587
|
* ~/.claude.json or .mcp.json changed since the last read. A file the host is
|
|
@@ -2159,6 +2653,40 @@ export class ControlServer {
|
|
|
2159
2653
|
recordsOnly: this.recordsOnly(session, run) || undefined,
|
|
2160
2654
|
};
|
|
2161
2655
|
}
|
|
2656
|
+
/**
|
|
2657
|
+
* The day this session's agent was told it is (issue 1212), read from its
|
|
2658
|
+
* transcript each time it is asked. A session of another host has none.
|
|
2659
|
+
*/
|
|
2660
|
+
agentDayFor(session) {
|
|
2661
|
+
if (otherHost(session))
|
|
2662
|
+
return undefined;
|
|
2663
|
+
return () => agentDayOf(session.transcriptPath);
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* What a run's finish says of its agent's day (the service's review of
|
|
2667
|
+
* calcscenarioautocorrect.md, item 1): the line of the session's system
|
|
2668
|
+
* prompt that gives the date — never Claude Code's own date reminder, which
|
|
2669
|
+
* is only this machine's day — and this machine's day when the turn's prompt
|
|
2670
|
+
* came. The service checks a corrected plan against this run, and reads its
|
|
2671
|
+
* prompt with this day, as a replay would have. Undefined with neither.
|
|
2672
|
+
*/
|
|
2673
|
+
runAgentDay(session, run) {
|
|
2674
|
+
let said;
|
|
2675
|
+
if (!otherHost(session)) {
|
|
2676
|
+
try {
|
|
2677
|
+
const day = agentDayOf(session.transcriptPath);
|
|
2678
|
+
if (day?.source === "system" && day.line)
|
|
2679
|
+
said = day.line.slice(0, 400);
|
|
2680
|
+
}
|
|
2681
|
+
catch {
|
|
2682
|
+
// An unreadable transcript says no day; the machine's still goes.
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
const local = run.promptDay;
|
|
2686
|
+
if (!said && !local)
|
|
2687
|
+
return undefined;
|
|
2688
|
+
return { ...(said ? { said } : {}), ...(local ? { local } : {}) };
|
|
2689
|
+
}
|
|
2162
2690
|
/**
|
|
2163
2691
|
* The earlier tool results the reading of new values may look at: this
|
|
2164
2692
|
* prompt's own, from the transcript — and so none for a plan that is
|
|
@@ -2294,7 +2822,7 @@ export class ControlServer {
|
|
|
2294
2822
|
// A matched turn records nothing, but it still owes the ledger a number —
|
|
2295
2823
|
// including when it declined to steer, which is a *baseline* sample and is
|
|
2296
2824
|
// what every saving is measured against (docs/calculatedReplay.md §11).
|
|
2297
|
-
this.reportExecution(session, run, durationMs, cost, cutShort);
|
|
2825
|
+
this.reportExecution(session, run, durationMs, cost, cutShort, reason);
|
|
2298
2826
|
// The plan's calls kept for a redo went into the repeat when it opened
|
|
2299
2827
|
// (AC2); nothing reads them after the turn, and they can run to megabytes
|
|
2300
2828
|
// on a sealed run the session keeps as its previous one.
|
|
@@ -2335,8 +2863,10 @@ export class ControlServer {
|
|
|
2335
2863
|
// Resolve the answer *outside* the queue: the wait below is up to two
|
|
2336
2864
|
// seconds, and the queue is serial — blocking it here would delay the
|
|
2337
2865
|
// first step of the user's next turn by that much. Kept on the run, so a
|
|
2338
|
-
// drain of this run waits for this seal and for nobody else's
|
|
2339
|
-
|
|
2866
|
+
// drain of this run waits for this seal and for nobody else's — beside a
|
|
2867
|
+
// plan's report that waits for the answer too (AC11), never in its place.
|
|
2868
|
+
const before = run.sealing;
|
|
2869
|
+
const finishing = answerText
|
|
2340
2870
|
.then((answer) => {
|
|
2341
2871
|
run.finishQueued = true;
|
|
2342
2872
|
this.queue.push(() => {
|
|
@@ -2352,13 +2882,20 @@ export class ControlServer {
|
|
|
2352
2882
|
// "LLM $/run", and it is the service's fallback baseline for a
|
|
2353
2883
|
// scenario that has no samples yet. A run recorded at $0 makes
|
|
2354
2884
|
// every later saving measured against nothing.
|
|
2885
|
+
//
|
|
2886
|
+
// The agent's day is read here, at the finish (review item 1):
|
|
2887
|
+
// its host writes a system prompt's "Today's date is …" into the
|
|
2888
|
+
// session only after the prompt hook.
|
|
2889
|
+
const agentDay = this.runAgentDay(session, run);
|
|
2355
2890
|
this.recorder.finishRun(run.runId, answer || undefined, {
|
|
2356
2891
|
durationMs: recordedMs,
|
|
2357
2892
|
costUsd: recordedCost.measured ? recordedCost.usd : undefined,
|
|
2358
2893
|
...closing,
|
|
2894
|
+
...(agentDay ? { agentDay } : {}),
|
|
2359
2895
|
});
|
|
2360
2896
|
});
|
|
2361
2897
|
});
|
|
2898
|
+
run.sealing = before ? Promise.all([before, finishing]).then(() => undefined) : finishing;
|
|
2362
2899
|
}
|
|
2363
2900
|
logLine("run.finish", {
|
|
2364
2901
|
run: run.runId,
|
|
@@ -2451,7 +2988,9 @@ export class ControlServer {
|
|
|
2451
2988
|
}
|
|
2452
2989
|
reportExecution(session, run, durationMs, cost,
|
|
2453
2990
|
/** The run was closed before its task ended for the caller (another host only). */
|
|
2454
|
-
cutShort = false
|
|
2991
|
+
cutShort = false,
|
|
2992
|
+
/** Why the turn was sealed: only an ordinary `stop` ends with an answer (AC11). */
|
|
2993
|
+
sealedBy = "stop") {
|
|
2455
2994
|
if (!isScenarioReporter(this.recorder))
|
|
2456
2995
|
return;
|
|
2457
2996
|
const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
|
|
@@ -2515,6 +3054,35 @@ export class ControlServer {
|
|
|
2515
3054
|
billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
|
|
2516
3055
|
});
|
|
2517
3056
|
}
|
|
3057
|
+
// The plan ran to its end, and no redo of the model's own was recorded:
|
|
3058
|
+
// the model only answered. Its answer goes with the report, so that the
|
|
3059
|
+
// service can tell whether it took the plan's result (the service's
|
|
3060
|
+
// autocorrect.md AC11). Read off the queue, as a recording's answer is —
|
|
3061
|
+
// the wait is up to two seconds, and the queue is serial — and kept on
|
|
3062
|
+
// the run, so a drain of this run waits for it. Only after an ordinary
|
|
3063
|
+
// stop, and only the text written after the turn's last call: a turn cut
|
|
3064
|
+
// short has no answer, and an older text is none. Claude Code only: the
|
|
3065
|
+
// service corrects no other host's plan.
|
|
3066
|
+
if (state === prompt &&
|
|
3067
|
+
state.ranToEnd === true &&
|
|
3068
|
+
!redoneBy &&
|
|
3069
|
+
report.outcome === "steered_full" &&
|
|
3070
|
+
sealedBy === "stop" &&
|
|
3071
|
+
run.lastToolUseId !== undefined &&
|
|
3072
|
+
!otherHost(session)) {
|
|
3073
|
+
// The plan's calls whole, beside the answer (review item 12). Taken
|
|
3074
|
+
// now — the seal lets go of them as soon as this returns — and read
|
|
3075
|
+
// only once there is an answer to send them with, off the hook's path.
|
|
3076
|
+
const kept = this.replay.turnCallsOf(state);
|
|
3077
|
+
const sent = settledTextAfterToolUse(session.transcriptPath, run.lastToolUseId)
|
|
3078
|
+
.catch(() => "")
|
|
3079
|
+
.then((answer) => {
|
|
3080
|
+
const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
|
|
3081
|
+
this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
|
|
3082
|
+
});
|
|
3083
|
+
run.sealing = run.sealing ? Promise.all([run.sealing, sent]).then(() => undefined) : sent;
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
2518
3086
|
this.queue.push(() => recorder.reportExecution(report));
|
|
2519
3087
|
});
|
|
2520
3088
|
// The prompt match that landed too late (see `noteLateMatch`). Not when a
|
|
@@ -2537,6 +3105,44 @@ export class ControlServer {
|
|
|
2537
3105
|
this.queue.push(() => recorder.reportExecution(report));
|
|
2538
3106
|
}
|
|
2539
3107
|
}
|
|
3108
|
+
/**
|
|
3109
|
+
* The plan's own calls of the turn, whole, as an answered turn's report
|
|
3110
|
+
* carries them (the service's review of calcscenarioautocorrect.md, item
|
|
3111
|
+
* 12): what each step sent and what came back, as they were kept for the
|
|
3112
|
+
* turn (replay/controller.ts, `TurnCall`) — in a recording's own form,
|
|
3113
|
+
* redacted and capped at 64 KiB apiece, as a redo's copies are (AC2) —
|
|
3114
|
+
* where the report's own copies have 3,800 bytes. The calls of a called sub-task are
|
|
3115
|
+
* left out: their step numbers are the sub-task's own, and would read as the
|
|
3116
|
+
* plan's steps of the same number. `kept` is what the turn kept, taken while
|
|
3117
|
+
* the seal still had it; this runs after the answer's wait, off the hook's
|
|
3118
|
+
* path. The recorder cuts them to what one report may carry when it sends
|
|
3119
|
+
* them.
|
|
3120
|
+
*/
|
|
3121
|
+
answerCallsOf(scenarioId, kept) {
|
|
3122
|
+
const calls = [];
|
|
3123
|
+
for (const c of kept) {
|
|
3124
|
+
if (scenarioId && c.scenarioId !== scenarioId)
|
|
3125
|
+
continue;
|
|
3126
|
+
let input = {};
|
|
3127
|
+
try {
|
|
3128
|
+
const parsed = JSON.parse(c.input);
|
|
3129
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
3130
|
+
input = parsed;
|
|
3131
|
+
}
|
|
3132
|
+
catch {
|
|
3133
|
+
// Kept serialized and capped: it was JSON. Nothing to read otherwise.
|
|
3134
|
+
}
|
|
3135
|
+
const failed = c.error !== undefined;
|
|
3136
|
+
calls.push({
|
|
3137
|
+
stepIndex: c.stepIndex,
|
|
3138
|
+
toolName: c.toolName,
|
|
3139
|
+
input,
|
|
3140
|
+
output: (failed ? c.error : c.output) ?? "",
|
|
3141
|
+
...(failed ? { isError: true } : {}),
|
|
3142
|
+
});
|
|
3143
|
+
}
|
|
3144
|
+
return calls;
|
|
3145
|
+
}
|
|
2540
3146
|
/**
|
|
2541
3147
|
* Seal the session's run now, and drain it to the service in the background.
|
|
2542
3148
|
*
|
|
@@ -2602,7 +3208,7 @@ export class ControlServer {
|
|
|
2602
3208
|
}
|
|
2603
3209
|
}
|
|
2604
3210
|
// ── hook events ──────────────────────────────────────────────────────────
|
|
2605
|
-
onSessionStart(payload) {
|
|
3211
|
+
async onSessionStart(payload) {
|
|
2606
3212
|
const session = this.ensureSession(payload);
|
|
2607
3213
|
// A resumed session is a live one again, whatever its SessionEnd said.
|
|
2608
3214
|
session.endedAt = undefined;
|
|
@@ -2619,7 +3225,79 @@ export class ControlServer {
|
|
|
2619
3225
|
// in the background, whether the service answers again.
|
|
2620
3226
|
if (isServiceProber(this.recorder))
|
|
2621
3227
|
this.recorder.probeIfFailing();
|
|
2622
|
-
|
|
3228
|
+
// A plan held for its owner saves nothing until somebody looks (review
|
|
3229
|
+
// item 8): one line for the person, never for the model. `bir off` and
|
|
3230
|
+
// BIR_OFF=1 never reach here — their session start is answered `{}`.
|
|
3231
|
+
const line = await this.attentionLine(session, payload);
|
|
3232
|
+
return line ? { systemMessage: line } : {};
|
|
3233
|
+
}
|
|
3234
|
+
/**
|
|
3235
|
+
* The line a session start shows the person when plans of this account
|
|
3236
|
+
* wait for their owner (`GET /scenarios/attention`; the service's review of
|
|
3237
|
+
* calcscenarioautocorrect.md, item 8), or undefined. A `systemMessage`:
|
|
3238
|
+
* Claude Code shows it to the person and adds nothing to the model's
|
|
3239
|
+
* context. Asked at most once in {@link ATTENTION_CACHE_MS} per account (in
|
|
3240
|
+
* {@link ATTENTION_RETRY_MS} when it could say nothing), waited for at most
|
|
3241
|
+
* {@link ATTENTION_WAIT_MS}, and never while the service is failing; any
|
|
3242
|
+
* failure says nothing. Not after a compaction — the
|
|
3243
|
+
* session is mid-work, and was told at its start — and never in a session
|
|
3244
|
+
* of another host, whose adapter shows a person nothing.
|
|
3245
|
+
*/
|
|
3246
|
+
async attentionLine(session, payload) {
|
|
3247
|
+
if (payload.source === "compact" || otherHost(session))
|
|
3248
|
+
return undefined;
|
|
3249
|
+
const recorder = this.recorder;
|
|
3250
|
+
if (!isAttentionReader(recorder))
|
|
3251
|
+
return undefined;
|
|
3252
|
+
const account = accountOf(recorder);
|
|
3253
|
+
if (!account)
|
|
3254
|
+
return undefined;
|
|
3255
|
+
let plans = null;
|
|
3256
|
+
try {
|
|
3257
|
+
plans = await this.attentionFor(account, () => recorder.scenarioAttention({ timeoutMs: ATTENTION_WAIT_MS }));
|
|
3258
|
+
}
|
|
3259
|
+
catch {
|
|
3260
|
+
return undefined;
|
|
3261
|
+
}
|
|
3262
|
+
if (!plans || plans.length === 0)
|
|
3263
|
+
return undefined;
|
|
3264
|
+
const line = attentionText(plans);
|
|
3265
|
+
if (line)
|
|
3266
|
+
logLine("session.attention", { sess: session.sessionId, plans: plans.length, why: "plans wait for their owner — said to the person" });
|
|
3267
|
+
return line;
|
|
3268
|
+
}
|
|
3269
|
+
/**
|
|
3270
|
+
* This account's plans that need their owner: the cached answer while it is
|
|
3271
|
+
* fresh, else the service's, waited for no longer than
|
|
3272
|
+
* {@link ATTENTION_WAIT_MS}. An answer that comes later still fills the
|
|
3273
|
+
* cache for the next session start. One ask at a time per account.
|
|
3274
|
+
*/
|
|
3275
|
+
async attentionFor(account, ask) {
|
|
3276
|
+
const cached = this.attention.get(account);
|
|
3277
|
+
const fresh = cached?.plans === null ? ATTENTION_RETRY_MS : ATTENTION_CACHE_MS;
|
|
3278
|
+
if (cached && !cached.pending && Date.now() - cached.at < fresh)
|
|
3279
|
+
return cached.plans;
|
|
3280
|
+
let pending = cached?.pending;
|
|
3281
|
+
if (!pending) {
|
|
3282
|
+
const asked = Promise.resolve()
|
|
3283
|
+
.then(ask)
|
|
3284
|
+
.catch(() => null);
|
|
3285
|
+
pending = asked;
|
|
3286
|
+
this.attention.set(account, { at: cached?.at ?? 0, plans: cached?.plans ?? null, pending: asked });
|
|
3287
|
+
void asked.then((plans) => {
|
|
3288
|
+
if (this.attention.get(account)?.pending !== asked)
|
|
3289
|
+
return;
|
|
3290
|
+
this.attention.set(account, { at: Date.now(), plans });
|
|
3291
|
+
});
|
|
3292
|
+
// A recorder that signs in as one account after another keeps a few, not all.
|
|
3293
|
+
while (this.attention.size > 16) {
|
|
3294
|
+
const oldest = this.attention.keys().next().value;
|
|
3295
|
+
if (oldest === undefined || oldest === account)
|
|
3296
|
+
break;
|
|
3297
|
+
this.attention.delete(oldest);
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
return (await this.withinBudget(pending, ATTENTION_WAIT_MS)) ?? null;
|
|
2623
3301
|
}
|
|
2624
3302
|
async onPrompt(payload) {
|
|
2625
3303
|
const session = this.ensureSession(payload);
|
|
@@ -2667,6 +3345,7 @@ export class ControlServer {
|
|
|
2667
3345
|
// steps to gain a prompt is the wrong trade — and say so.
|
|
2668
3346
|
open.promptSeen = true;
|
|
2669
3347
|
open.input = input;
|
|
3348
|
+
open.promptDay = localDay();
|
|
2670
3349
|
// This hook announces nothing, so no plan of this run may steer.
|
|
2671
3350
|
open.matchExpired = true;
|
|
2672
3351
|
this.retireUnannounced(session, open);
|
|
@@ -2686,6 +3365,7 @@ export class ControlServer {
|
|
|
2686
3365
|
const run = this.ensureRun(session, input, { replay: other && payload.replay === true }, prompt);
|
|
2687
3366
|
run.promptSeen = true;
|
|
2688
3367
|
run.input = input;
|
|
3368
|
+
run.promptDay = localDay();
|
|
2689
3369
|
session.prompts += 1;
|
|
2690
3370
|
logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
|
|
2691
3371
|
// With replay off there is nothing this hook could say, so it says nothing
|
|
@@ -2781,6 +3461,11 @@ export class ControlServer {
|
|
|
2781
3461
|
if (toolUseId)
|
|
2782
3462
|
(run.housekeepingUses ??= new Set()).add(toolUseId);
|
|
2783
3463
|
logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
|
|
3464
|
+
// The agent is about to say a replay was wrong (review item 19): which
|
|
3465
|
+
// session asks is known here, and only here — `bir-scenario` is never
|
|
3466
|
+
// told its session. Its `/scenario/dispute` finds it by this id.
|
|
3467
|
+
if (toolName === REPORT_TOOL_NAME && toolUseId)
|
|
3468
|
+
this.noteReportCall(session.sessionId, toolUseId);
|
|
2784
3469
|
// `bir setup` lets Claude Code run every `bir` tool without asking — but a
|
|
2785
3470
|
// change saved or taken back with `force`, past the check that refused
|
|
2786
3471
|
// it, is the person's decision (editSteps.md D3), so Claude Code asks
|
|
@@ -3117,13 +3802,15 @@ export class ControlServer {
|
|
|
3117
3802
|
: undefined;
|
|
3118
3803
|
if (run.recording) {
|
|
3119
3804
|
this.noteOwnCall(run, pinnedBy);
|
|
3805
|
+
// The host's id for the call ties this row to its answer (review item 7).
|
|
3806
|
+
const metadata = stepMarks({ pinnedBy, callId: toolUseId });
|
|
3120
3807
|
this.queue.push(() => {
|
|
3121
3808
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3122
3809
|
toolName,
|
|
3123
3810
|
toolInput: serializeCapped(redact(args)),
|
|
3124
3811
|
context: context || undefined,
|
|
3125
3812
|
intent: thinkingIntent,
|
|
3126
|
-
metadata
|
|
3813
|
+
metadata,
|
|
3127
3814
|
});
|
|
3128
3815
|
});
|
|
3129
3816
|
}
|
|
@@ -3263,11 +3950,12 @@ export class ControlServer {
|
|
|
3263
3950
|
run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
|
|
3264
3951
|
if (run.recording) {
|
|
3265
3952
|
const toolInput = serializeCapped(redact(action.input));
|
|
3953
|
+
const metadata = stepMarks({ pinnedBy, callId: toolUseId });
|
|
3266
3954
|
this.queue.push(() => {
|
|
3267
3955
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3268
3956
|
toolName,
|
|
3269
3957
|
toolInput,
|
|
3270
|
-
metadata
|
|
3958
|
+
metadata,
|
|
3271
3959
|
});
|
|
3272
3960
|
});
|
|
3273
3961
|
}
|
|
@@ -3350,12 +4038,14 @@ export class ControlServer {
|
|
|
3350
4038
|
if (run.recording) {
|
|
3351
4039
|
this.noteOwnCall(run);
|
|
3352
4040
|
const toolInput = serializeCapped(redact(modelArgs));
|
|
4041
|
+
const metadata = stepMarks({ callId: toolUseId });
|
|
3353
4042
|
this.queue.push(() => {
|
|
3354
4043
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3355
4044
|
toolName,
|
|
3356
4045
|
toolInput,
|
|
3357
4046
|
context: words.context || undefined,
|
|
3358
4047
|
intent: words.thinking,
|
|
4048
|
+
metadata,
|
|
3359
4049
|
});
|
|
3360
4050
|
});
|
|
3361
4051
|
}
|
|
@@ -3812,7 +4502,11 @@ export class ControlServer {
|
|
|
3812
4502
|
// seconds, and this call is waiting. A plan not armed in time is dropped
|
|
3813
4503
|
// unused — it was never announced, so it never steers — and its ticket is
|
|
3814
4504
|
// swept like any unredeemed one (R-OUT-12).
|
|
3815
|
-
const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
|
|
4505
|
+
const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
|
|
4506
|
+
liveCall: { toolName, toolInput },
|
|
4507
|
+
recentResults: this.recentResultsFor(session, "call"),
|
|
4508
|
+
agentDay: this.agentDayFor(session),
|
|
4509
|
+
}, this.hostContext(session, run)));
|
|
3816
4510
|
const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
|
|
3817
4511
|
if (state === undefined)
|
|
3818
4512
|
return undefined; // it threw, and said so
|
|
@@ -4214,17 +4908,20 @@ export class ControlServer {
|
|
|
4214
4908
|
// service's row schema takes a number or nothing.
|
|
4215
4909
|
...(call.chainRevision !== null ? { chainRevision: call.chainRevision } : {}),
|
|
4216
4910
|
};
|
|
4911
|
+
// One id on both rows, and the failure marked (review item 7).
|
|
4912
|
+
const callId = newCallId();
|
|
4913
|
+
const failed = call.error !== undefined;
|
|
4217
4914
|
this.queue.push(() => {
|
|
4218
4915
|
this.recorder.recordToolSelected(id, pair.selected, {
|
|
4219
4916
|
toolName: call.toolName,
|
|
4220
4917
|
toolInput: call.input,
|
|
4221
|
-
metadata: { byPlan },
|
|
4918
|
+
metadata: { byPlan, callId },
|
|
4222
4919
|
});
|
|
4223
4920
|
this.recorder.recordToolResponse(id, pair.response, {
|
|
4224
4921
|
toolName: call.toolName,
|
|
4225
|
-
toolOutput:
|
|
4922
|
+
toolOutput: failed ? undefined : call.output,
|
|
4226
4923
|
toolError: call.error,
|
|
4227
|
-
metadata: { byPlan },
|
|
4924
|
+
metadata: failed ? { byPlan, callId, isError: true } : { byPlan, callId },
|
|
4228
4925
|
});
|
|
4229
4926
|
});
|
|
4230
4927
|
}
|
|
@@ -4282,6 +4979,9 @@ export class ControlServer {
|
|
|
4282
4979
|
context.push(stashed);
|
|
4283
4980
|
const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
|
|
4284
4981
|
if (run && !run.finished) {
|
|
4982
|
+
// The answer, if one comes, is written after this call (AC11).
|
|
4983
|
+
if (typeof payload.tool_use_id === "string" && payload.tool_use_id)
|
|
4984
|
+
run.lastToolUseId = payload.tool_use_id;
|
|
4285
4985
|
this.noteHandover(run);
|
|
4286
4986
|
const note = this.replay.takeNote(run.replay);
|
|
4287
4987
|
if (note)
|
|
@@ -4490,11 +5190,15 @@ export class ControlServer {
|
|
|
4490
5190
|
const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
|
|
4491
5191
|
if (run.recording) {
|
|
4492
5192
|
const output = serializeCapped(redact(payload.tool_response ?? {}));
|
|
5193
|
+
// Its call's id, and whether it failed — the host said so, or the
|
|
5194
|
+
// tool's own MCP result did (review item 7).
|
|
5195
|
+
const metadata = stepMarks({ callId: toolUseId, isError: failed || resultSaysError(payload.tool_response) });
|
|
4493
5196
|
this.queue.push(() => {
|
|
4494
5197
|
this.recorder.recordToolResponse(run.runId, responseIndex, {
|
|
4495
5198
|
toolName,
|
|
4496
5199
|
toolOutput: failed ? undefined : output,
|
|
4497
5200
|
toolError: failed ? this.errorText(payload) : undefined,
|
|
5201
|
+
metadata,
|
|
4498
5202
|
});
|
|
4499
5203
|
});
|
|
4500
5204
|
}
|
|
@@ -4818,6 +5522,13 @@ export class ControlServer {
|
|
|
4818
5522
|
const hostPid = Number(body.ppid);
|
|
4819
5523
|
if (Number.isInteger(hostPid) && hostPid > 0)
|
|
4820
5524
|
this.replay.work.bindSiblings(hostPid, call.sessionId);
|
|
5525
|
+
// The `bir-scenario` asking is this session's own: whether it serves the
|
|
5526
|
+
// report tool is its word, not any hello's (review item 19). An older one
|
|
5527
|
+
// says nothing, and serves none.
|
|
5528
|
+
if (!otherHost(this.sessions.get(call.sessionId))) {
|
|
5529
|
+
const reports = body.reports === true;
|
|
5530
|
+
state.reportTool = () => reports;
|
|
5531
|
+
}
|
|
4821
5532
|
this.runningPlans.add(state);
|
|
4822
5533
|
// The session is busy for as long as its plan runs, and sends no hook
|
|
4823
5534
|
// meanwhile: the host is waiting on this very call.
|
|
@@ -5002,6 +5713,8 @@ export class ControlServer {
|
|
|
5002
5713
|
result: report.result,
|
|
5003
5714
|
isError: report.isError,
|
|
5004
5715
|
errorMessage: report.errorMessage,
|
|
5716
|
+
// The id a hook injected, when one did; `record` mints one otherwise.
|
|
5717
|
+
callId: report.callId,
|
|
5005
5718
|
});
|
|
5006
5719
|
logDetail("proxy.step", {
|
|
5007
5720
|
run: run.runId,
|
|
@@ -5253,6 +5966,9 @@ export class ControlServer {
|
|
|
5253
5966
|
// from the map (segmented.md R-INTENT-7).
|
|
5254
5967
|
pinnedBy: correlation.pinnedBy,
|
|
5255
5968
|
intent: correlation.intent,
|
|
5969
|
+
// The host's id for the call when it named one, else the one minted for
|
|
5970
|
+
// it here: what ties the call to its answer (review item 7).
|
|
5971
|
+
callId: correlation.toolUseId || correlation.callId,
|
|
5256
5972
|
});
|
|
5257
5973
|
logDetail("proxy.step.merged", {
|
|
5258
5974
|
run: run.runId,
|
|
@@ -5262,7 +5978,16 @@ export class ControlServer {
|
|
|
5262
5978
|
});
|
|
5263
5979
|
return pair;
|
|
5264
5980
|
}
|
|
5265
|
-
/**
|
|
5981
|
+
/**
|
|
5982
|
+
* The one place a tool step pair is written. Redaction and caps live here.
|
|
5983
|
+
*
|
|
5984
|
+
* Both rows carry the call's id (`metadata.callId`, review item 7) — the
|
|
5985
|
+
* caller's, or one minted here for a call no hook named — and the answer
|
|
5986
|
+
* of a call that failed says so (`metadata.isError`). The service pairs a
|
|
5987
|
+
* call with its answer by that id, where it used to pair them by tool name
|
|
5988
|
+
* in the order they came: two calls of one tool at once, answered out of
|
|
5989
|
+
* order, took each other's answers.
|
|
5990
|
+
*/
|
|
5266
5991
|
record(run, pair, d) {
|
|
5267
5992
|
if (!run.recording)
|
|
5268
5993
|
return;
|
|
@@ -5272,18 +5997,23 @@ export class ControlServer {
|
|
|
5272
5997
|
const toolError = d.isError
|
|
5273
5998
|
? (d.errorMessage ?? serializeCapped(redact(d.result)))
|
|
5274
5999
|
: undefined;
|
|
6000
|
+
const callId = d.callId || newCallId();
|
|
6001
|
+
// Failed, as its reporter said or as its own MCP result says — a hook's
|
|
6002
|
+
// fallback view carries the result only. The row is otherwise as it was.
|
|
6003
|
+
const failedMark = d.isError || resultSaysError(d.result);
|
|
5275
6004
|
this.queue.push(() => {
|
|
5276
6005
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
5277
6006
|
toolName: d.toolName,
|
|
5278
6007
|
toolInput,
|
|
5279
6008
|
context: d.context,
|
|
5280
6009
|
intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
|
|
5281
|
-
metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } :
|
|
6010
|
+
metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy, callId } : { callId },
|
|
5282
6011
|
});
|
|
5283
6012
|
this.recorder.recordToolResponse(run.runId, pair.response, {
|
|
5284
6013
|
toolName: d.toolName,
|
|
5285
6014
|
toolOutput,
|
|
5286
6015
|
toolError,
|
|
6016
|
+
metadata: failedMark ? { callId, isError: true } : { callId },
|
|
5287
6017
|
});
|
|
5288
6018
|
});
|
|
5289
6019
|
}
|