@basein/runner 0.3.26 → 0.3.28

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.
@@ -31,8 +31,9 @@ import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolNam
31
31
  import { StepIndexAllocator } from "./ordering.js";
32
32
  import { pidAlive } from "./discovery.js";
33
33
  import { pinCompatible } from "./pin-guard.js";
34
- import { agentDayOf, answerBeforePrompt, contextForToolUse, intentForToolUse, markTranscriptUsage, promptCount, recentToolResults, sessionTranscriptPath, settledLastAssistantText, settledTextAfterToolUse, settledTurnEnd, thinkingForToolUse, usageBetween, usageSince, } from "./transcript.js";
34
+ import { agentDayOf, answerBeforePrompt, callsBetween, contextForToolUse, intentForToolUse, markTranscriptUsage, promptCount, recentToolResults, sessionTranscriptPath, settledLastAssistantText, settledTextAfterToolUse, settledTurnEnd, thinkingForToolUse, usageBetween, usageSince, } from "./transcript.js";
35
35
  import { isHousekeepingCall } from "../record/housekeeping.js";
36
+ import { readsOnlyCall } from "../replay/reads-only.js";
36
37
  import { asSent, userText } from "../record/prompt-text.js";
37
38
  import { redact, redactText } from "../record/redact.js";
38
39
  import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
@@ -458,6 +459,24 @@ const HEALTH_SESSIONS_MAX = 50;
458
459
  const HANDBACK_NOTE = "[BaseInstRunner calculated replay] The recorded sequence is no longer being supplied: carry on with the task yourself, choosing each tool call's arguments from what you have seen so far.";
459
460
  /** What the model hears once a direct plan it never asked to run is retired. */
460
461
  const DIRECT_SKIPPED_NOTE = "[BaseInstRunner calculated replay] The recorded scenario is not being run for this request: carry on with the task yourself.";
462
+ /**
463
+ * What the model hears, once, after its first call that only looks while a
464
+ * direct plan waits for `run_scenario` (B2 is11.md): the call did not cancel
465
+ * the plan, the steps take this request's values — named, where the reading
466
+ * named them — and a change made first would. In is11 the model had checked
467
+ * its notes because the note named the recording's person.
468
+ */
469
+ function directWaitingNote(values) {
470
+ return [
471
+ "[BaseInstRunner calculated replay] The recorded sequence is still ready for this request:",
472
+ "a call that only reads does not cancel it.",
473
+ values
474
+ ? `Its steps take this request's values, not the recording's: ${values}.`
475
+ : "Its steps take this request's values, not the recording's.",
476
+ "Call mcp__bir__run_scenario next, before any call that changes something:",
477
+ "after a change of your own it is not run.",
478
+ ].join("\n");
479
+ }
461
480
  /** What a second `run_scenario` for a plan that is already running is told. */
462
481
  const ALREADY_RUNNING = "this scenario is already running for this turn — its results come back on the first run_scenario call; do not call it again or repeat its steps";
463
482
  /** Request bodies past this are read and discarded, never buffered. */
@@ -690,6 +709,22 @@ function priceUsage(usage) {
690
709
  ...(rateUnknown.length > 0 ? { rateUnknown } : {}),
691
710
  };
692
711
  }
712
+ /**
713
+ * The tokens of a usage list — the one its cost was priced from — under the
714
+ * names an execution report's `turn` gives them (the service's
715
+ * learningPhase.md LP11): every cache write, the 1-hour ones among them, in
716
+ * `cacheWriteTokens`.
717
+ */
718
+ function turnTokens(usage) {
719
+ const sum = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
720
+ for (const u of usage) {
721
+ sum.inputTokens += u.inputTokens;
722
+ sum.outputTokens += u.outputTokens;
723
+ sum.cacheReadTokens += u.cacheReadTokens;
724
+ sum.cacheWriteTokens += u.cacheCreationTokens;
725
+ }
726
+ return sum;
727
+ }
693
728
  /**
694
729
  * What `/health` answers as `hostFields`: how much of another host's protocol
695
730
  * this recorder understands. An adapter reads it to know which fields it may
@@ -2511,6 +2546,13 @@ export class ControlServer {
2511
2546
  * whether the service took it (null while the send is on its way).
2512
2547
  */
2513
2548
  toolListsSent = new Map();
2549
+ /**
2550
+ * What each wrapped server says of its tools (MCP `annotations`), by the
2551
+ * name the agent calls them: whether a call only looks, for a direct plan
2552
+ * waiting through it (B2 is11.md). A server's word beats any guess from the
2553
+ * name (replay/reads-only.ts). Kept from the lists proxies report.
2554
+ */
2555
+ toolHints = new Map();
2514
2556
  /**
2515
2557
  * `POST /proxy/tools` — a proxy's server listed its tools (`tools/list`;
2516
2558
  * the service's autocorrect.md AC12). Its names, descriptions, input
@@ -2530,12 +2572,26 @@ export class ControlServer {
2530
2572
  if (this.isSessionOff(boundTo))
2531
2573
  return { ok: true, off: true };
2532
2574
  const serverName = typeof body.serverName === "string" ? body.serverName.trim().slice(0, 200) : "";
2533
- if (!serverName || !Array.isArray(body.tools) || !isToolListReporter(this.recorder))
2575
+ if (!serverName || !Array.isArray(body.tools))
2534
2576
  return { ok: true };
2535
2577
  const tools = toolListForService(serverName, body.tools);
2536
2578
  // A server still starting may list nothing: that replaces no list.
2537
2579
  if (tools.length === 0)
2538
2580
  return { ok: true };
2581
+ for (const t of tools) {
2582
+ if (t.annotations)
2583
+ this.toolHints.set(t.qualifiedName, t.annotations);
2584
+ else
2585
+ this.toolHints.delete(t.qualifiedName);
2586
+ }
2587
+ while (this.toolHints.size > 4_096) {
2588
+ const oldest = this.toolHints.keys().next().value;
2589
+ if (oldest === undefined)
2590
+ break;
2591
+ this.toolHints.delete(oldest);
2592
+ }
2593
+ if (!isToolListReporter(this.recorder))
2594
+ return { ok: true };
2539
2595
  const project = this.projectKeyHere();
2540
2596
  const key = `${accountOf(this.recorder) ?? ""}\u0000${project}\u0000${serverName}`;
2541
2597
  const hash = createHash("sha256").update(JSON.stringify(tools)).digest("hex");
@@ -3240,6 +3296,51 @@ export class ControlServer {
3240
3296
  return { usd: 0, measured: false };
3241
3297
  return priceUsage(until ? usageBetween(mark, until) : usageSince(session.transcriptPath, mark));
3242
3298
  }
3299
+ /**
3300
+ * Where a turn's tokens end, for its reports' `turn` (the service's
3301
+ * learningPhase.md LP11): `until`, the next turn's mark, when that turn has
3302
+ * begun; a mark taken now otherwise, as {@link costSince} reads the
3303
+ * transcript now. Undefined for another host, which has no transcript, and
3304
+ * when the transcript cannot be read. Never throws.
3305
+ */
3306
+ tokensEnd(session, until) {
3307
+ if (otherHost(session))
3308
+ return undefined;
3309
+ if (until)
3310
+ return until;
3311
+ try {
3312
+ return markTranscriptUsage(session.transcriptPath);
3313
+ }
3314
+ catch {
3315
+ return undefined;
3316
+ }
3317
+ }
3318
+ /**
3319
+ * The model calls and tokens of one report's window, for its `turn` (the
3320
+ * service's learningPhase.md LP11): the window its cost is read for, from
3321
+ * `from` to `end` — the plan's own retire, or the next plan's arm — and
3322
+ * otherwise to `to` ({@link tokensEnd}). Another host's run has the tokens
3323
+ * it handed over and no count of its calls, and only `whole`: for the
3324
+ * report that bills the run, when one report does. Undefined when nothing
3325
+ * can be said — no mark, no transcript — and then the report's `turn` says
3326
+ * null. Never throws.
3327
+ */
3328
+ turnUsage(session, run, from, end, to, whole) {
3329
+ try {
3330
+ if (otherHost(session)) {
3331
+ if (!whole || !run.hostUsage?.length)
3332
+ return undefined;
3333
+ return { modelCalls: null, ...turnTokens(run.hostUsage) };
3334
+ }
3335
+ const last = end ?? to;
3336
+ if (from === undefined || last === undefined)
3337
+ return undefined;
3338
+ return { modelCalls: callsBetween(from, last) ?? null, ...turnTokens(usageBetween(from, last)) };
3339
+ }
3340
+ catch {
3341
+ return undefined;
3342
+ }
3343
+ }
3243
3344
  reportExecution(session, run, durationMs, cost,
3244
3345
  /** The run was closed before its task ended for the caller (another host only). */
3245
3346
  cutShort = false,
@@ -3256,6 +3357,11 @@ export class ControlServer {
3256
3357
  // Another host hands over one number for the whole run. It cannot be
3257
3358
  // split between two plans, so with more than one neither is measured.
3258
3359
  const unsplit = otherHost(session) && states.length > 1;
3360
+ // Where the turn's tokens end, read once for every report's `turn` (LP11):
3361
+ // the next turn's mark when that turn has begun, the transcript as it is
3362
+ // now otherwise — as the cost was read a moment ago. Only for a turn that
3363
+ // has a report to send it with.
3364
+ const tokensTo = states.length > 0 || run.lateMatch ? this.tokensEnd(session, until) : undefined;
3259
3365
  // The plan ran to its end and the agent redid the task in a repeat the
3260
3366
  // service took (newestRun.md N7): the prompt plan's report says so, with
3261
3367
  // the repeat's id and the agent's own calls in it (autocorrect.md AC1).
@@ -3342,6 +3448,8 @@ export class ControlServer {
3342
3448
  redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
3343
3449
  chainDeriveUsd,
3344
3450
  booksOwnSaving: booksOwn(state),
3451
+ // The model calls and tokens of the same window as the cost above.
3452
+ usage: this.turnUsage(session, run, state.armedBy === "intent" ? state.usageMark : run.usageMark, end, tokensTo, state.armedBy !== "intent" && !unsplit),
3345
3453
  });
3346
3454
  if (!report)
3347
3455
  return;
@@ -3376,7 +3484,15 @@ export class ControlServer {
3376
3484
  .catch(() => "")
3377
3485
  .then((answer) => {
3378
3486
  const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
3379
- this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
3487
+ this.queue.push(() => recorder.reportExecution(answer
3488
+ ? {
3489
+ ...report,
3490
+ answer,
3491
+ ...(calls.length > 0 ? { answerCalls: calls } : {}),
3492
+ // How long the answer is, whole: the report carries 8 KiB of it (LP11).
3493
+ ...(report.turn ? { turn: { ...report.turn, answerChars: answer.length } } : {}),
3494
+ }
3495
+ : report));
3380
3496
  });
3381
3497
  this.holdSeal(run, sent);
3382
3498
  return;
@@ -3398,6 +3514,8 @@ export class ControlServer {
3398
3514
  siblings: [...states, late],
3399
3515
  runId: run.runId,
3400
3516
  cutShort: cutShort || undefined,
3517
+ // The turn's own tokens only when its cost is this report's too.
3518
+ usage: alone ? this.turnUsage(session, run, run.usageMark, undefined, tokensTo, !unsplit) : undefined,
3401
3519
  });
3402
3520
  if (report)
3403
3521
  this.queue.push(() => recorder.reportExecution(report));
@@ -3837,6 +3955,8 @@ export class ControlServer {
3837
3955
  const live = this.livePlan(run);
3838
3956
  /** `run_scenario` for this session's own announced direct plan: allowed, see below. */
3839
3957
  let allowDirect = false;
3958
+ /** A call that only looks, which a direct plan waits through (B2 is11.md): it runs as written. */
3959
+ let waitedRead = false;
3840
3960
  if (this.replay.isDirectTool(toolName)) {
3841
3961
  // `run_scenario` with no live plan does nothing but answer "no scenario
3842
3962
  // is armed". Recorded, that answer is a failed tool call, and a recording
@@ -3865,19 +3985,43 @@ export class ControlServer {
3865
3985
  // refused (the user said no, or a rule denied it) or it failed on the way.
3866
3986
  // Refused means refused. The plan retires without running anything — it
3867
3987
  // must not come back as a divergence that runs every step unattended.
3868
- this.vetoPlan(run, live, "run_scenario was called but never reached the runner — refused or failed");
3988
+ this.vetoPlan(run, live, "run_scenario was called but never reached the runner — refused or failed", "run_refused");
3869
3989
  }
3870
3990
  else if (live?.mode === "direct" && !live.directRequested && !live.running && !this.runningPlans.has(live)) {
3871
- // A direct plan whose first call is not `run_scenario`, and which never
3872
- // asked for it. The likeliest reason is that the model has no such tool
3873
- // — the bir server failed to start, was disabled in /mcp, or lists
3874
- // nothing — and a divergence would then execute every step, writes
3875
- // included, inside this hook with no permission prompt, and deny the
3991
+ // A direct plan whose next call is not `run_scenario`, and which never
3992
+ // asked for it. Nothing of the plan ever runs in this hook in its place:
3993
+ // when the model has no such tool — the bir server failed to start, was
3994
+ // disabled in /mcp, or lists nothing — a divergence would execute every
3995
+ // step, writes included, with no permission prompt, and deny the
3876
3996
  // model's call: a broken delivery tool turned into an unattended run.
3877
- // The plan retires running nothing; this call runs as written, and the
3878
- // model hears on its PostToolUse that the task is its own.
3879
- this.vetoPlan(run, live, `the model's first call was ${toolName}, not run_scenario`);
3880
- run.handback = DIRECT_SKIPPED_NOTE;
3997
+ //
3998
+ // A call that only looks does not retire the plan (B2 is11.md): it runs
3999
+ // as written, and the plan waits for `run_scenario`, told to the model
4000
+ // once on this call's PostToolUse. In is11 the model checked its own
4001
+ // notes first, because the note named the recording's person, and
4002
+ // losing the plan there cost a whole agent run for a value the steps
4003
+ // take from the request anyway. The first call that may change
4004
+ // something retires it, running nothing: run after the model's own
4005
+ // change, the plan could do the same work twice. That call runs as
4006
+ // written, and the model hears on its PostToolUse that the task is its
4007
+ // own.
4008
+ if (readsOnlyCall(toolName, modelArgs, this.toolHints.get(toolName))) {
4009
+ waitedRead = true;
4010
+ live.readsWaited = (live.readsWaited ?? 0) + 1;
4011
+ if (live.readsWaited === 1) {
4012
+ run.handback = directWaitingNote(live.plan?.requestValues());
4013
+ logLine("replay.direct_waits", {
4014
+ run: run.runId,
4015
+ scenario: live.scenarioId ?? undefined,
4016
+ tool: toolName,
4017
+ why: "the model's first call only reads — it runs as written, and the plan waits for run_scenario",
4018
+ });
4019
+ }
4020
+ }
4021
+ else {
4022
+ this.vetoPlan(run, live, `the model's first call that may change something was ${toolName}, not run_scenario${live.readsWaited ? ` (after ${live.readsWaited} that only read)` : ""}`, "changed_first");
4023
+ run.handback = DIRECT_SKIPPED_NOTE;
4024
+ }
3881
4025
  }
3882
4026
  const agentId = payload.agent_id ?? session.agentId;
3883
4027
  // The real intent, read from every line sharing this message's id
@@ -3928,6 +4072,7 @@ export class ControlServer {
3928
4072
  });
3929
4073
  const action = await this.replay.preTool(steering, toolName, toolUseId, modelArgs, {
3930
4074
  signal: answeredLate.signal,
4075
+ readsOnly: waitedRead && steering === live,
3931
4076
  });
3932
4077
  if (overran) {
3933
4078
  // The host already has its answer. A divergence was answered with its
@@ -4469,20 +4614,28 @@ export class ControlServer {
4469
4614
  }
4470
4615
  /**
4471
4616
  * Retire `state` without running anything: the model's `run_scenario` was
4472
- * refused or failed. Books a baseline sample (`not_steered`) — the agent did
4473
- * the task — never a divergence.
4617
+ * refused or failed, or the model changed something itself first. Books a
4618
+ * baseline sample (`not_steered`) — the agent did the task — never a
4619
+ * divergence. `code` goes with the report, so the recording page can say
4620
+ * why the plan did not run (B2 is11.md: it said "the runner did not say
4621
+ * why", and guessed wrong).
4474
4622
  */
4475
- vetoPlan(run, state, why) {
4623
+ vetoPlan(run, state, why, code) {
4476
4624
  if (state.retired)
4477
4625
  return;
4478
4626
  state.retired = true;
4479
4627
  state.retiredAt = Date.now();
4480
4628
  state.pinned.clear();
4481
4629
  state.skipNote = undefined;
4630
+ // A continuation has a reason of its own for this, `not_taken`, which the
4631
+ // page words for a plan that follows a hand-over (restoreParkedFragment).
4632
+ if (code && !state.declined && !state.continuation)
4633
+ state.declined = code;
4482
4634
  this.directCalls = this.directCalls.filter((c) => c.state !== state);
4483
4635
  logLine("replay.vetoed", {
4484
4636
  run: run.runId,
4485
4637
  scenario: state.scenarioId ?? undefined,
4638
+ code,
4486
4639
  why: `${why} — the plan is retired without running any step`,
4487
4640
  });
4488
4641
  }
@@ -5643,7 +5796,7 @@ export class ControlServer {
5643
5796
  if (!this.runningPlans.has(waiting.state)) {
5644
5797
  this.vetoPlan(waiting.run, waiting.state, failed
5645
5798
  ? "run_scenario failed before it reached the runner"
5646
- : "run_scenario answered without ever reaching the runner");
5799
+ : "run_scenario answered without ever reaching the runner", "run_refused");
5647
5800
  }
5648
5801
  }
5649
5802
  }
@@ -217,6 +217,15 @@ export interface ModelUsage extends RateFlags {
217
217
  * of pricing, under the model's name with its rate key after it.
218
218
  */
219
219
  export type UsageMark = Map<string, ModelUsage>;
220
+ /**
221
+ * The model calls between two marks of one transcript, `from` the earlier:
222
+ * assistant messages, each counted once by its `message.id` as its tokens are
223
+ * ({@link totalUsage}), the sub-agents' files included — the calls behind the
224
+ * tokens {@link usageBetween} gives for the same two marks. No `mark` counts
225
+ * from the start, as there. Clamped at 0, as the tokens are, for a transcript
226
+ * rewritten between the two. Undefined when either mark was not made here.
227
+ */
228
+ export declare function callsBetween(mark: UsageMark | undefined, now: UsageMark): number | undefined;
220
229
  /**
221
230
  * Snapshot cumulative usage at the moment a run starts.
222
231
  *
@@ -126,7 +126,8 @@ const indexes = new Map();
126
126
  * ({@link subagentUsage}): transcript path → the main index they were read
127
127
  * beside, one usage-only index per file, and `gone`: what the files whose
128
128
  * index was let go of had used (one past {@link MAX_SUBAGENT_FILES}, one read
129
- * again from its start after a rewrite). Dropped with the main index.
129
+ * again from its start after a rewrite), and `goneCalls`: their model calls.
130
+ * Dropped with the main index.
130
131
  */
131
132
  const subagentIndexes = new Map();
132
133
  /** Sub-agent files read per transcript, at most. */
@@ -150,6 +151,7 @@ function freshIndex(st, usageOnly) {
150
151
  // A sub-agent's file shares its transcript's set: a message that a host
151
152
  // writes into both files is counted once, whichever is read first.
152
153
  usageSeen: usageOnly?.seen ?? new Set(),
154
+ calls: 0,
153
155
  lastAssistantText: "",
154
156
  firstUserPrompt: "",
155
157
  sawPrompt: false,
@@ -388,6 +390,8 @@ function subagentFiles(transcriptPath) {
388
390
  * read beside the transcript's set of counted messages, so nothing counts
389
391
  * twice. A total that fell made the turn across the fall cost $0 for that
390
392
  * model, the main transcript's own tokens with it, and the file $0 for good.
393
+ *
394
+ * `calls` is the same files' model calls, on the same terms.
391
395
  */
392
396
  function subagentUsage(transcriptPath, main) {
393
397
  const files = subagentFiles(transcriptPath);
@@ -397,16 +401,26 @@ function subagentUsage(transcriptPath, main) {
397
401
  known = undefined;
398
402
  }
399
403
  if (files.length === 0 && !known)
400
- return [];
404
+ return { usage: [], calls: 0 };
401
405
  if (!known) {
402
- known = { beside: main, files: new Map(), gone: new Map() };
406
+ known = { beside: main, files: new Map(), gone: new Map(), goneCalls: 0 };
403
407
  subagentIndexes.set(transcriptPath, known);
404
408
  }
405
- const gone = known.gone;
406
- const usageOnly = { seen: main.usageSeen, retire: (old) => addUsage(gone, old.usage) };
409
+ const store = known;
410
+ const usageOnly = {
411
+ seen: main.usageSeen,
412
+ retire: (old) => {
413
+ addUsage(store.gone, old.usage);
414
+ store.goneCalls += old.calls;
415
+ },
416
+ };
407
417
  for (const file of files)
408
- indexIn(known.files, file, MAX_SUBAGENT_FILES, usageOnly);
409
- return [gone, ...[...known.files.values()].map((idx) => idx.usage)];
418
+ indexIn(store.files, file, MAX_SUBAGENT_FILES, usageOnly);
419
+ const read = [...store.files.values()];
420
+ return {
421
+ usage: [store.gone, ...read.map((idx) => idx.usage)],
422
+ calls: read.reduce((n, idx) => n + idx.calls, store.goneCalls),
423
+ };
410
424
  }
411
425
  /** `from`'s tokens added into `into`, key by key. */
412
426
  function addUsage(into, from) {
@@ -478,6 +492,9 @@ function foldUsage(idx, message) {
478
492
  return;
479
493
  if (id)
480
494
  idx.usageSeen.add(id);
495
+ // One message is one request: a model call (LP11), on the very terms its
496
+ // tokens are counted.
497
+ idx.calls += 1;
481
498
  const model = message?.model ?? "unknown";
482
499
  const input = count(usage.input_tokens);
483
500
  const read = count(usage.cache_read_input_tokens);
@@ -938,19 +955,49 @@ const count = (value) => typeof value === "number" && Number.isFinite(value) &&
938
955
  *
939
956
  * The running totals live in the index ({@link foldLine}); this returns a copy,
940
957
  * because a mark is kept and compared long after the index has moved on.
958
+ *
959
+ * The model calls those totals were counted from ride beside the copy, in
960
+ * {@link MARK_CALLS}: a key of the map would be read as a model, and priced.
941
961
  */
942
962
  function totalUsage(transcriptPath) {
943
963
  const totals = new Map();
944
964
  const idx = indexFor(transcriptPath);
945
- if (!idx)
965
+ if (!idx) {
966
+ MARK_CALLS.set(totals, 0);
946
967
  return totals;
968
+ }
947
969
  for (const [model, u] of idx.usage)
948
970
  totals.set(model, { ...u });
949
971
  // And what the session's sub-agents used, each in a file of its own.
950
- for (const usage of subagentUsage(transcriptPath, idx))
972
+ const subagents = subagentUsage(transcriptPath, idx);
973
+ for (const usage of subagents.usage)
951
974
  addUsage(totals, usage);
975
+ MARK_CALLS.set(totals, idx.calls + subagents.calls);
952
976
  return totals;
953
977
  }
978
+ /**
979
+ * Model calls counted into a mark, by the mark: every mark this module makes
980
+ * has its entry, and a map made anywhere else has none ({@link callsBetween}).
981
+ * Weak, so a mark nobody holds any more takes its count with it.
982
+ */
983
+ const MARK_CALLS = new WeakMap();
984
+ /**
985
+ * The model calls between two marks of one transcript, `from` the earlier:
986
+ * assistant messages, each counted once by its `message.id` as its tokens are
987
+ * ({@link totalUsage}), the sub-agents' files included — the calls behind the
988
+ * tokens {@link usageBetween} gives for the same two marks. No `mark` counts
989
+ * from the start, as there. Clamped at 0, as the tokens are, for a transcript
990
+ * rewritten between the two. Undefined when either mark was not made here.
991
+ */
992
+ export function callsBetween(mark, now) {
993
+ const to = MARK_CALLS.get(now);
994
+ if (to === undefined)
995
+ return undefined;
996
+ if (mark === undefined)
997
+ return to;
998
+ const from = MARK_CALLS.get(mark);
999
+ return from === undefined ? undefined : Math.max(0, to - from);
1000
+ }
954
1001
  /**
955
1002
  * Snapshot cumulative usage at the moment a run starts.
956
1003
  *
@@ -471,7 +471,57 @@ export interface ExecutionReport {
471
471
  * (record/remote-recorder.ts, `answerCallsBody`).
472
472
  */
473
473
  answerCalls?: AnswerCall[];
474
+ /**
475
+ * What the runner can say of the turn beyond its cost (the service's
476
+ * learningPhase.md LP11): how the plan ran, the model calls and tokens of
477
+ * the window its cost was read for, how much the plan handed the agent to
478
+ * read, and why it handed over. The learning phase's rules about cost and
479
+ * time read it (P7, P8). Additive: an older service drops it, and a report
480
+ * without it is the body it always was.
481
+ */
482
+ turn?: ExecutionTurn;
483
+ }
484
+ /**
485
+ * {@link ExecutionReport.turn}. Every key is always there, `null` when the
486
+ * runner does not know it: no transcript mark, another host's run, nothing
487
+ * handed to the agent, no answer read.
488
+ */
489
+ export interface ExecutionTurn {
490
+ /** How the plan ran, as coverage.ts decided it when it armed; null for a plan that never armed. */
491
+ mode: "direct" | "steer" | null;
492
+ /** The window's model calls: assistant messages, counted once per `message.id`, sub-agents' files included. */
493
+ modelCalls: number | null;
494
+ inputTokens: number | null;
495
+ outputTokens: number | null;
496
+ cacheReadTokens: number | null;
497
+ /** Every cache write, the 1-hour ones among them. */
498
+ cacheWriteTokens: number | null;
499
+ /**
500
+ * Characters of what the plan handed the agent to read: `run_scenario`'s
501
+ * answer (the bundle, or the hand-over note), a divergence's bundle, and
502
+ * the notes told in place of a call or beside its result. The announcement
503
+ * the prompt carries is not counted. Null for a plan that never armed.
504
+ */
505
+ bundleChars: number | null;
506
+ /** The brief bundle (0.3.25) was handed; false for a full one; null when no bundle was. */
507
+ brief: boolean | null;
508
+ /** Characters of the agent's final answer, when the runner read it (AC11); else null. */
509
+ answerChars: number | null;
510
+ /**
511
+ * On a hand-over: where the stopped step stood (`stop`, as a fragment's
512
+ * create call sends it — `not_run`, `failed`, `ran`, `uncertain`, …; null
513
+ * for a stop in front of a parked step or the other half) and why it
514
+ * stopped (`reason`: `budget`, `cancelled`, `test_failed`, `concurrent`,
515
+ * `failed`, … for a step that stopped, else the hand-over's kind). Null
516
+ * when the plan did not hand over.
517
+ */
518
+ handover: {
519
+ stop: string | null;
520
+ reason: string | null;
521
+ } | null;
474
522
  }
523
+ /** The part of {@link ExecutionTurn} that only the transcript can say: the control server reads it. */
524
+ export type TurnUsage = Pick<ExecutionTurn, "modelCalls" | "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens">;
475
525
  /** One call of a plan, whole, as {@link ExecutionReport.answerCalls} carries it. */
476
526
  export interface AnswerCall {
477
527
  /** The step's own `stepIndex`. */
@@ -61,7 +61,7 @@
61
61
  */
62
62
  import { type AuthSession } from "../auth/client.js";
63
63
  import { type ServiceHealth } from "../util/service-health.js";
64
- import type { AnswerCall, AttentionPlan, DisputeAnswer, ExecutionReport, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions, StepMetadata, ToolListEntry } from "./recorder.js";
64
+ import type { AnswerCall, AttentionPlan, DisputeAnswer, ExecutionReport, ExecutionTurn, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions, StepMetadata, ToolListEntry } from "./recorder.js";
65
65
  /**
66
66
  * Caps for the free text this recorder sends, in UTF-8 bytes. Tool payloads
67
67
  * arrive capped at 64 KiB by their producers; text never passed through one,
@@ -129,6 +129,14 @@ export interface RemoteRecorderOptions {
129
129
  /** The longest one queued send waits for a failing service ({@link COOLDOWN_WAIT_MAX_MS}). Tests shorten it. */
130
130
  cooldownWaitMaxMs?: number;
131
131
  }
132
+ /**
133
+ * A report's `turn` as it is sent (the service's learningPhase.md LP11): its
134
+ * ten keys and no other, each a whole count of at least 0, a short code or
135
+ * null — so that no value a service's schema could refuse costs the report
136
+ * that carries it, and with it the turn's cost. Undefined, and so left out of
137
+ * the body, when the report has none.
138
+ */
139
+ export declare function turnBody(turn: ExecutionTurn | undefined): ExecutionTurn | undefined;
132
140
  /** What one answered turn's call may carry of its output, in UTF-8 bytes (review item 12). */
133
141
  export declare const ANSWER_CALL_OUTPUT_MAX_BYTES: number;
134
142
  /** All of one report's answer calls together, serialized, in UTF-8 bytes. */
@@ -274,6 +274,39 @@ function reportBody(fields, steps, extra = {}) {
274
274
  add(i, "toolOutput");
275
275
  return JSON.stringify({ ...fields, steps: sent, ...extra });
276
276
  }
277
+ /** Longest `stop` or `reason` a report's `turn` carries: they are codes. */
278
+ const TURN_CODE_MAX = 64;
279
+ /**
280
+ * A report's `turn` as it is sent (the service's learningPhase.md LP11): its
281
+ * ten keys and no other, each a whole count of at least 0, a short code or
282
+ * null — so that no value a service's schema could refuse costs the report
283
+ * that carries it, and with it the turn's cost. Undefined, and so left out of
284
+ * the body, when the report has none.
285
+ */
286
+ export function turnBody(turn) {
287
+ if (!turn || typeof turn !== "object")
288
+ return undefined;
289
+ const count = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
290
+ const code = (s) => (typeof s === "string" && s !== "" ? s.slice(0, TURN_CODE_MAX) : null);
291
+ try {
292
+ const h = turn.handover;
293
+ return {
294
+ mode: turn.mode === "direct" || turn.mode === "steer" ? turn.mode : null,
295
+ modelCalls: count(turn.modelCalls),
296
+ inputTokens: count(turn.inputTokens),
297
+ outputTokens: count(turn.outputTokens),
298
+ cacheReadTokens: count(turn.cacheReadTokens),
299
+ cacheWriteTokens: count(turn.cacheWriteTokens),
300
+ bundleChars: count(turn.bundleChars),
301
+ brief: typeof turn.brief === "boolean" ? turn.brief : null,
302
+ answerChars: count(turn.answerChars),
303
+ handover: h && typeof h === "object" ? { stop: code(h.stop), reason: code(h.reason) } : null,
304
+ };
305
+ }
306
+ catch {
307
+ return undefined;
308
+ }
309
+ }
277
310
  /** What one answered turn's call may carry of its output, in UTF-8 bytes (review item 12). */
278
311
  export const ANSWER_CALL_OUTPUT_MAX_BYTES = 64 * 1024;
279
312
  /** All of one report's answer calls together, serialized, in UTF-8 bytes. */
@@ -1139,6 +1172,11 @@ export class RemoteRecorder {
1139
1172
  // ...or only answered: its answer, capped and redacted like any
1140
1173
  // text (AC11). Additive, like `redoneBy`.
1141
1174
  answer: out(answer),
1175
+ // How the plan ran, its model calls and tokens, what the agent
1176
+ // read and why it handed over (the service's learningPhase.md
1177
+ // LP11). A few hundred bytes, inside the budget the payloads give
1178
+ // way to. Additive: an older service drops it.
1179
+ turn: turnBody(report.turn),
1142
1180
  }, steps,
1143
1181
  // Beside the answer, the plan's calls whole (review item 12),
1144
1182
  // outside the steps' budget. Additive: an older service drops them.