@basein/runner 0.3.17 → 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 +719 -21
- package/dist/control/transcript.d.ts +19 -0
- package/dist/control/transcript.js +57 -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 +146 -0
- package/dist/record/recorder.js +9 -0
- package/dist/record/remote-recorder.d.ts +59 -1
- package/dist/record/remote-recorder.js +297 -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, settledTextAfterToolUse, 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
|
|
@@ -2354,10 +2882,16 @@ export class ControlServer {
|
|
|
2354
2882
|
// "LLM $/run", and it is the service's fallback baseline for a
|
|
2355
2883
|
// scenario that has no samples yet. A run recorded at $0 makes
|
|
2356
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);
|
|
2357
2890
|
this.recorder.finishRun(run.runId, answer || undefined, {
|
|
2358
2891
|
durationMs: recordedMs,
|
|
2359
2892
|
costUsd: recordedCost.measured ? recordedCost.usd : undefined,
|
|
2360
2893
|
...closing,
|
|
2894
|
+
...(agentDay ? { agentDay } : {}),
|
|
2361
2895
|
});
|
|
2362
2896
|
});
|
|
2363
2897
|
});
|
|
@@ -2536,10 +3070,15 @@ export class ControlServer {
|
|
|
2536
3070
|
sealedBy === "stop" &&
|
|
2537
3071
|
run.lastToolUseId !== undefined &&
|
|
2538
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);
|
|
2539
3077
|
const sent = settledTextAfterToolUse(session.transcriptPath, run.lastToolUseId)
|
|
2540
3078
|
.catch(() => "")
|
|
2541
3079
|
.then((answer) => {
|
|
2542
|
-
|
|
3080
|
+
const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
|
|
3081
|
+
this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
|
|
2543
3082
|
});
|
|
2544
3083
|
run.sealing = run.sealing ? Promise.all([run.sealing, sent]).then(() => undefined) : sent;
|
|
2545
3084
|
return;
|
|
@@ -2566,6 +3105,44 @@ export class ControlServer {
|
|
|
2566
3105
|
this.queue.push(() => recorder.reportExecution(report));
|
|
2567
3106
|
}
|
|
2568
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
|
+
}
|
|
2569
3146
|
/**
|
|
2570
3147
|
* Seal the session's run now, and drain it to the service in the background.
|
|
2571
3148
|
*
|
|
@@ -2631,7 +3208,7 @@ export class ControlServer {
|
|
|
2631
3208
|
}
|
|
2632
3209
|
}
|
|
2633
3210
|
// ── hook events ──────────────────────────────────────────────────────────
|
|
2634
|
-
onSessionStart(payload) {
|
|
3211
|
+
async onSessionStart(payload) {
|
|
2635
3212
|
const session = this.ensureSession(payload);
|
|
2636
3213
|
// A resumed session is a live one again, whatever its SessionEnd said.
|
|
2637
3214
|
session.endedAt = undefined;
|
|
@@ -2648,7 +3225,79 @@ export class ControlServer {
|
|
|
2648
3225
|
// in the background, whether the service answers again.
|
|
2649
3226
|
if (isServiceProber(this.recorder))
|
|
2650
3227
|
this.recorder.probeIfFailing();
|
|
2651
|
-
|
|
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;
|
|
2652
3301
|
}
|
|
2653
3302
|
async onPrompt(payload) {
|
|
2654
3303
|
const session = this.ensureSession(payload);
|
|
@@ -2696,6 +3345,7 @@ export class ControlServer {
|
|
|
2696
3345
|
// steps to gain a prompt is the wrong trade — and say so.
|
|
2697
3346
|
open.promptSeen = true;
|
|
2698
3347
|
open.input = input;
|
|
3348
|
+
open.promptDay = localDay();
|
|
2699
3349
|
// This hook announces nothing, so no plan of this run may steer.
|
|
2700
3350
|
open.matchExpired = true;
|
|
2701
3351
|
this.retireUnannounced(session, open);
|
|
@@ -2715,6 +3365,7 @@ export class ControlServer {
|
|
|
2715
3365
|
const run = this.ensureRun(session, input, { replay: other && payload.replay === true }, prompt);
|
|
2716
3366
|
run.promptSeen = true;
|
|
2717
3367
|
run.input = input;
|
|
3368
|
+
run.promptDay = localDay();
|
|
2718
3369
|
session.prompts += 1;
|
|
2719
3370
|
logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
|
|
2720
3371
|
// With replay off there is nothing this hook could say, so it says nothing
|
|
@@ -2810,6 +3461,11 @@ export class ControlServer {
|
|
|
2810
3461
|
if (toolUseId)
|
|
2811
3462
|
(run.housekeepingUses ??= new Set()).add(toolUseId);
|
|
2812
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);
|
|
2813
3469
|
// `bir setup` lets Claude Code run every `bir` tool without asking — but a
|
|
2814
3470
|
// change saved or taken back with `force`, past the check that refused
|
|
2815
3471
|
// it, is the person's decision (editSteps.md D3), so Claude Code asks
|
|
@@ -3146,13 +3802,15 @@ export class ControlServer {
|
|
|
3146
3802
|
: undefined;
|
|
3147
3803
|
if (run.recording) {
|
|
3148
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 });
|
|
3149
3807
|
this.queue.push(() => {
|
|
3150
3808
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3151
3809
|
toolName,
|
|
3152
3810
|
toolInput: serializeCapped(redact(args)),
|
|
3153
3811
|
context: context || undefined,
|
|
3154
3812
|
intent: thinkingIntent,
|
|
3155
|
-
metadata
|
|
3813
|
+
metadata,
|
|
3156
3814
|
});
|
|
3157
3815
|
});
|
|
3158
3816
|
}
|
|
@@ -3292,11 +3950,12 @@ export class ControlServer {
|
|
|
3292
3950
|
run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
|
|
3293
3951
|
if (run.recording) {
|
|
3294
3952
|
const toolInput = serializeCapped(redact(action.input));
|
|
3953
|
+
const metadata = stepMarks({ pinnedBy, callId: toolUseId });
|
|
3295
3954
|
this.queue.push(() => {
|
|
3296
3955
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3297
3956
|
toolName,
|
|
3298
3957
|
toolInput,
|
|
3299
|
-
metadata
|
|
3958
|
+
metadata,
|
|
3300
3959
|
});
|
|
3301
3960
|
});
|
|
3302
3961
|
}
|
|
@@ -3379,12 +4038,14 @@ export class ControlServer {
|
|
|
3379
4038
|
if (run.recording) {
|
|
3380
4039
|
this.noteOwnCall(run);
|
|
3381
4040
|
const toolInput = serializeCapped(redact(modelArgs));
|
|
4041
|
+
const metadata = stepMarks({ callId: toolUseId });
|
|
3382
4042
|
this.queue.push(() => {
|
|
3383
4043
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3384
4044
|
toolName,
|
|
3385
4045
|
toolInput,
|
|
3386
4046
|
context: words.context || undefined,
|
|
3387
4047
|
intent: words.thinking,
|
|
4048
|
+
metadata,
|
|
3388
4049
|
});
|
|
3389
4050
|
});
|
|
3390
4051
|
}
|
|
@@ -3841,7 +4502,11 @@ export class ControlServer {
|
|
|
3841
4502
|
// seconds, and this call is waiting. A plan not armed in time is dropped
|
|
3842
4503
|
// unused — it was never announced, so it never steers — and its ticket is
|
|
3843
4504
|
// swept like any unredeemed one (R-OUT-12).
|
|
3844
|
-
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)));
|
|
3845
4510
|
const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
|
|
3846
4511
|
if (state === undefined)
|
|
3847
4512
|
return undefined; // it threw, and said so
|
|
@@ -4243,17 +4908,20 @@ export class ControlServer {
|
|
|
4243
4908
|
// service's row schema takes a number or nothing.
|
|
4244
4909
|
...(call.chainRevision !== null ? { chainRevision: call.chainRevision } : {}),
|
|
4245
4910
|
};
|
|
4911
|
+
// One id on both rows, and the failure marked (review item 7).
|
|
4912
|
+
const callId = newCallId();
|
|
4913
|
+
const failed = call.error !== undefined;
|
|
4246
4914
|
this.queue.push(() => {
|
|
4247
4915
|
this.recorder.recordToolSelected(id, pair.selected, {
|
|
4248
4916
|
toolName: call.toolName,
|
|
4249
4917
|
toolInput: call.input,
|
|
4250
|
-
metadata: { byPlan },
|
|
4918
|
+
metadata: { byPlan, callId },
|
|
4251
4919
|
});
|
|
4252
4920
|
this.recorder.recordToolResponse(id, pair.response, {
|
|
4253
4921
|
toolName: call.toolName,
|
|
4254
|
-
toolOutput:
|
|
4922
|
+
toolOutput: failed ? undefined : call.output,
|
|
4255
4923
|
toolError: call.error,
|
|
4256
|
-
metadata: { byPlan },
|
|
4924
|
+
metadata: failed ? { byPlan, callId, isError: true } : { byPlan, callId },
|
|
4257
4925
|
});
|
|
4258
4926
|
});
|
|
4259
4927
|
}
|
|
@@ -4522,11 +5190,15 @@ export class ControlServer {
|
|
|
4522
5190
|
const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
|
|
4523
5191
|
if (run.recording) {
|
|
4524
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) });
|
|
4525
5196
|
this.queue.push(() => {
|
|
4526
5197
|
this.recorder.recordToolResponse(run.runId, responseIndex, {
|
|
4527
5198
|
toolName,
|
|
4528
5199
|
toolOutput: failed ? undefined : output,
|
|
4529
5200
|
toolError: failed ? this.errorText(payload) : undefined,
|
|
5201
|
+
metadata,
|
|
4530
5202
|
});
|
|
4531
5203
|
});
|
|
4532
5204
|
}
|
|
@@ -4850,6 +5522,13 @@ export class ControlServer {
|
|
|
4850
5522
|
const hostPid = Number(body.ppid);
|
|
4851
5523
|
if (Number.isInteger(hostPid) && hostPid > 0)
|
|
4852
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
|
+
}
|
|
4853
5532
|
this.runningPlans.add(state);
|
|
4854
5533
|
// The session is busy for as long as its plan runs, and sends no hook
|
|
4855
5534
|
// meanwhile: the host is waiting on this very call.
|
|
@@ -5034,6 +5713,8 @@ export class ControlServer {
|
|
|
5034
5713
|
result: report.result,
|
|
5035
5714
|
isError: report.isError,
|
|
5036
5715
|
errorMessage: report.errorMessage,
|
|
5716
|
+
// The id a hook injected, when one did; `record` mints one otherwise.
|
|
5717
|
+
callId: report.callId,
|
|
5037
5718
|
});
|
|
5038
5719
|
logDetail("proxy.step", {
|
|
5039
5720
|
run: run.runId,
|
|
@@ -5285,6 +5966,9 @@ export class ControlServer {
|
|
|
5285
5966
|
// from the map (segmented.md R-INTENT-7).
|
|
5286
5967
|
pinnedBy: correlation.pinnedBy,
|
|
5287
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,
|
|
5288
5972
|
});
|
|
5289
5973
|
logDetail("proxy.step.merged", {
|
|
5290
5974
|
run: run.runId,
|
|
@@ -5294,7 +5978,16 @@ export class ControlServer {
|
|
|
5294
5978
|
});
|
|
5295
5979
|
return pair;
|
|
5296
5980
|
}
|
|
5297
|
-
/**
|
|
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
|
+
*/
|
|
5298
5991
|
record(run, pair, d) {
|
|
5299
5992
|
if (!run.recording)
|
|
5300
5993
|
return;
|
|
@@ -5304,18 +5997,23 @@ export class ControlServer {
|
|
|
5304
5997
|
const toolError = d.isError
|
|
5305
5998
|
? (d.errorMessage ?? serializeCapped(redact(d.result)))
|
|
5306
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);
|
|
5307
6004
|
this.queue.push(() => {
|
|
5308
6005
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
5309
6006
|
toolName: d.toolName,
|
|
5310
6007
|
toolInput,
|
|
5311
6008
|
context: d.context,
|
|
5312
6009
|
intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
|
|
5313
|
-
metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } :
|
|
6010
|
+
metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy, callId } : { callId },
|
|
5314
6011
|
});
|
|
5315
6012
|
this.recorder.recordToolResponse(run.runId, pair.response, {
|
|
5316
6013
|
toolName: d.toolName,
|
|
5317
6014
|
toolOutput,
|
|
5318
6015
|
toolError,
|
|
6016
|
+
metadata: failedMark ? { callId, isError: true } : { callId },
|
|
5319
6017
|
});
|
|
5320
6018
|
});
|
|
5321
6019
|
}
|