@koda-sl/baker-bridge 0.154.0-dev.db7cf2f5d → 0.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +22 -2
  2. package/dist/hono/agent-session.d.ts +0 -1
  3. package/dist/hono/agent-session.d.ts.map +1 -1
  4. package/dist/hono/agent-session.js +0 -7
  5. package/dist/hono/agent-session.js.map +1 -1
  6. package/dist/hono/agent-session.test.js +1 -7
  7. package/dist/hono/agent-session.test.js.map +1 -1
  8. package/dist/hono/agent.d.ts.map +1 -1
  9. package/dist/hono/agent.js +7 -9
  10. package/dist/hono/agent.js.map +1 -1
  11. package/dist/hono/bridge-job.d.ts +15 -0
  12. package/dist/hono/bridge-job.d.ts.map +1 -1
  13. package/dist/hono/bridge-job.js +69 -5
  14. package/dist/hono/bridge-job.js.map +1 -1
  15. package/dist/hono/bridge-job.test.js +102 -0
  16. package/dist/hono/bridge-job.test.js.map +1 -1
  17. package/dist/hono/convex.d.ts +2 -1
  18. package/dist/hono/convex.d.ts.map +1 -1
  19. package/dist/hono/convex.js +4 -0
  20. package/dist/hono/convex.js.map +1 -1
  21. package/dist/hono/main-sync.d.ts +7 -0
  22. package/dist/hono/main-sync.d.ts.map +1 -1
  23. package/dist/hono/main-sync.js +39 -0
  24. package/dist/hono/main-sync.js.map +1 -1
  25. package/dist/hono/main-sync.test.js +29 -1
  26. package/dist/hono/main-sync.test.js.map +1 -1
  27. package/dist/hono/relay.d.ts +10 -1
  28. package/dist/hono/relay.d.ts.map +1 -1
  29. package/dist/hono/relay.js +4 -0
  30. package/dist/hono/relay.js.map +1 -1
  31. package/dist/hono/relay.test.js +11 -0
  32. package/dist/hono/relay.test.js.map +1 -1
  33. package/dist/hono/served-model.d.ts +23 -0
  34. package/dist/hono/served-model.d.ts.map +1 -0
  35. package/dist/hono/served-model.js +82 -0
  36. package/dist/hono/served-model.js.map +1 -0
  37. package/dist/hono/served-model.test.d.ts +2 -0
  38. package/dist/hono/served-model.test.d.ts.map +1 -0
  39. package/dist/hono/served-model.test.js +29 -0
  40. package/dist/hono/served-model.test.js.map +1 -0
  41. package/dist/hono/session-continuity.d.ts +24 -0
  42. package/dist/hono/session-continuity.d.ts.map +1 -1
  43. package/dist/hono/session-continuity.integration.test.js +201 -5
  44. package/dist/hono/session-continuity.integration.test.js.map +1 -1
  45. package/dist/hono/session-continuity.js +94 -7
  46. package/dist/hono/session-continuity.js.map +1 -1
  47. package/dist/hono/session-store.d.ts +2 -0
  48. package/dist/hono/session-store.d.ts.map +1 -1
  49. package/dist/hono/session-store.js +6 -0
  50. package/dist/hono/session-store.js.map +1 -1
  51. package/dist/hono/session-usage-baseline.d.ts +1 -0
  52. package/dist/hono/session-usage-baseline.d.ts.map +1 -1
  53. package/dist/hono/session-usage-baseline.js +17 -4
  54. package/dist/hono/session-usage-baseline.js.map +1 -1
  55. package/dist/hono/turn-events.d.ts +1 -1
  56. package/dist/hono/turn-events.d.ts.map +1 -1
  57. package/dist/hono/turn-events.js.map +1 -1
  58. package/dist/review/digest.d.ts +45 -1
  59. package/dist/review/digest.d.ts.map +1 -1
  60. package/dist/review/digest.js +394 -63
  61. package/dist/review/digest.js.map +1 -1
  62. package/dist/review/digest.test.js +239 -1
  63. package/dist/review/digest.test.js.map +1 -1
  64. package/dist/review/evidence.d.ts.map +1 -1
  65. package/dist/review/evidence.js +1 -1
  66. package/dist/review/evidence.js.map +1 -1
  67. package/package.json +1 -1
@@ -15,7 +15,9 @@
15
15
  * it could NOT find, so missing evidence is a fact before the model starts
16
16
  * rather than a silence it fills in.
17
17
  */
18
+ import { createHash } from "node:crypto";
18
19
  import { imageSize } from "image-size";
20
+ import { parse } from "unbash";
19
21
  import { languageMismatch, marketerWords, messageLanguage } from "./reply-language.js";
20
22
  /** Parse a `.jsonl` body, dropping lines that are not JSON rather than failing. */
21
23
  export function parseTranscript(body) {
@@ -289,6 +291,49 @@ function clip(text, max = TRUNCATE) {
289
291
  function currentTurn(acc) {
290
292
  return acc.turns[acc.turns.length - 1];
291
293
  }
294
+ /** One conversation inside a thread: the main agent, or one subagent. */
295
+ function conversationKey(threadId, agentId) {
296
+ return `${threadId ?? ""}\u0000${agentId ?? ""}`;
297
+ }
298
+ function isSubagentLine(line) {
299
+ return line.isSidechain === true || line.isSubagent === true;
300
+ }
301
+ /**
302
+ * The turn a line belongs to.
303
+ *
304
+ * A subagent's own file is read after the whole session file it was spawned
305
+ * in, so the turn open by then is that session's last one. Charged to it, a
306
+ * fan-out in turn 1 was listed under the re-render turn 2 — its refusals, its
307
+ * denials and its parent's closing recap as their intent. A subagent belongs to
308
+ * the last turn of its thread that began before the prompt that started it, or
309
+ * before the later prompt that continued it.
310
+ */
311
+ function turnIndexOf(acc, line) {
312
+ const current = currentTurn(acc)?.index;
313
+ if (!isSubagentLine(line)) {
314
+ return current;
315
+ }
316
+ const key = line.agentId === undefined ? undefined : conversationKey(line.threadId, line.agentId);
317
+ const known = key === undefined ? undefined : acc.subagentTurns.get(key);
318
+ if (known !== undefined) {
319
+ return known;
320
+ }
321
+ const at = stampOf(line.timestamp);
322
+ if (at === undefined) {
323
+ return current;
324
+ }
325
+ let spawned;
326
+ for (const turn of acc.turns) {
327
+ const began = stampOf(turn.timestamp);
328
+ if (turn.threadId === line.threadId && began !== undefined && began <= at) {
329
+ spawned = turn.index;
330
+ }
331
+ }
332
+ if (key !== undefined && spawned !== undefined) {
333
+ acc.subagentTurns.set(key, spawned);
334
+ }
335
+ return spawned ?? current;
336
+ }
292
337
  /**
293
338
  * Close the open turn at the latest line that still belongs to it.
294
339
  *
@@ -302,7 +347,8 @@ function currentTurn(acc) {
302
347
  */
303
348
  function noteTurnEnd(acc, line) {
304
349
  const at = stampOf(line.timestamp);
305
- const turn = currentTurn(acc);
350
+ const index = turnIndexOf(acc, line);
351
+ const turn = index === undefined ? undefined : acc.turns[index];
306
352
  if (at === undefined || turn === undefined) {
307
353
  return;
308
354
  }
@@ -420,26 +466,38 @@ export function namedGuard(message) {
420
466
  }
421
467
  return undefined;
422
468
  }
423
- /** Ledger a failed call: its class, the guard row, and the turn's denial count when a guard refused it. */
424
- function absorbFailure(acc, call, block) {
469
+ /**
470
+ * Ledger a failure: its class, the guard row, and the turn's denial count when a
471
+ * guard refused it. A call can fail more than once — each `baker` call one
472
+ * command line ran — and its own class and message are its first failure's.
473
+ */
474
+ function absorbFailure(acc, call, message, failureClass = classifyFailure(message)) {
425
475
  const turn = acc.turns[call.turn];
426
- const message = typeof block.content === "string" ? block.content : String(block.content ?? "");
427
- call.failureClass = classifyFailure(message);
428
- if (turn && call.failureClass === "refused") {
476
+ call.isError = true;
477
+ call.failureClass ??= failureClass;
478
+ if (turn && failureClass === "refused") {
429
479
  turn.denials += 1;
430
480
  }
431
- acc.failureMessages.set(call.toolUseId, clip(message, 400));
481
+ if (!acc.failureMessages.has(call.toolUseId)) {
482
+ acc.failureMessages.set(call.toolUseId, clip(message, 400));
483
+ }
432
484
  const guard = namedGuard(message);
485
+ // A subagent's words, never the turn's: the turn's are its parent's.
486
+ const intent = call.isSidechain
487
+ ? acc.subagentWords.get(conversationKey(call.threadId, call.agentId))
488
+ : turn?.assistantText || undefined;
433
489
  acc.guards.push({
434
490
  turn: call.turn,
435
491
  ...(call.threadId === undefined ? {} : { threadId: call.threadId }),
436
492
  toolUseId: call.toolUseId,
437
493
  tool: call.tool,
438
494
  attempted: call.summary,
495
+ inputKey: call.inputKey,
439
496
  message: clip(message, 400),
440
- refusal: call.failureClass === "refused",
497
+ refusal: failureClass === "refused",
441
498
  ...(guard === undefined ? {} : { guard }),
442
- ...(turn?.assistantText ? { intent: clip(turn.assistantText, 300) } : {}),
499
+ ...(intent ? { intent: clip(intent, 300) } : {}),
500
+ ...(call.isSidechain && call.agentId !== undefined ? { agentId: call.agentId } : {}),
443
501
  repeats: 1,
444
502
  });
445
503
  }
@@ -457,12 +515,11 @@ function absorbToolResults(acc, line) {
457
515
  const seen = measureToolResult(block.content);
458
516
  call.resultBytes = seen.bytes;
459
517
  call.resultTokens = seen.tokens;
460
- call.isError = block.is_error === true;
461
518
  if (line.timestamp !== undefined) {
462
519
  acc.answeredAt.set(call.toolUseId, line.timestamp);
463
520
  }
464
- if (call.isError) {
465
- absorbFailure(acc, call, block);
521
+ if (block.is_error === true) {
522
+ absorbFailure(acc, call, typeof block.content === "string" ? block.content : String(block.content ?? ""));
466
523
  }
467
524
  }
468
525
  }
@@ -490,6 +547,11 @@ function absorbUserLine(acc, line) {
490
547
  absorbToolResults(acc, line);
491
548
  const spoken = raw.replace(SYSTEM_REMINDER, "").trim();
492
549
  const isToolResult = blocks(line).some((block) => block.type === "tool_result");
550
+ if (isSubagentLine(line) && !isToolResult && spoken.length > 0) {
551
+ // A prompt to a subagent: its spawn, or a later turn continuing it (`SendMessage`
552
+ // keeps the id), so the turn it belongs to is read again from this line.
553
+ acc.subagentTurns.delete(conversationKey(line.threadId, line.agentId));
554
+ }
493
555
  if (line.isSidechain || line.isSubagent || isToolResult || spoken.length === 0) {
494
556
  return;
495
557
  }
@@ -509,6 +571,7 @@ function absorbUserLine(acc, line) {
509
571
  outputTokens: 0,
510
572
  cacheReadTokens: 0,
511
573
  cacheWriteTokens: 0,
574
+ subagent: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
512
575
  denials: 0,
513
576
  compacted: false,
514
577
  });
@@ -535,21 +598,25 @@ function absorbRequestUsage(acc, line, turnAt) {
535
598
  acc.countedRequests.add(requestId);
536
599
  }
537
600
  const usage = line.message?.usage ?? {};
601
+ const subagent = isSubagentLine(line);
538
602
  const request = {
539
603
  turn: turnAt,
540
604
  ...(line.message?.model === undefined ? {} : { model: line.message.model }),
605
+ subagent,
606
+ ...(subagent && line.agentId !== undefined ? { agentId: line.agentId } : {}),
541
607
  inputTokens: usage.input_tokens ?? 0,
542
608
  outputTokens: usage.output_tokens ?? 0,
543
609
  cacheReadTokens: usage.cache_read_input_tokens ?? 0,
544
610
  cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
545
611
  };
546
612
  acc.requests.push(request);
547
- const turn = currentTurn(acc);
613
+ const turn = acc.turns[turnAt];
548
614
  if (turn) {
549
- turn.inputTokens += request.inputTokens;
550
- turn.outputTokens += request.outputTokens;
551
- turn.cacheReadTokens += request.cacheReadTokens;
552
- turn.cacheWriteTokens += request.cacheWriteTokens;
615
+ const usage = subagent ? turn.subagent : turn;
616
+ usage.inputTokens += request.inputTokens;
617
+ usage.outputTokens += request.outputTokens;
618
+ usage.cacheReadTokens += request.cacheReadTokens;
619
+ usage.cacheWriteTokens += request.cacheWriteTokens;
553
620
  }
554
621
  }
555
622
  function buildToolCall(input) {
@@ -561,6 +628,10 @@ function buildToolCall(input) {
561
628
  toolUseId: input.block.id ?? `${input.turnAt}:${input.index}`,
562
629
  tool,
563
630
  summary: clip(summarizeInput(tool, input.block.input), 400),
631
+ inputKey: createHash("sha256")
632
+ .update(JSON.stringify(input.block.input ?? {}))
633
+ .digest("hex")
634
+ .slice(0, 16),
564
635
  ...(bakerVerb ? { bakerVerb } : {}),
565
636
  resultBytes: 0,
566
637
  resultTokens: 0,
@@ -581,6 +652,12 @@ function userFacingMessage(turn, text, marketerLanguage) {
581
652
  ...(mismatch === undefined ? {} : { languageMismatch: mismatch }),
582
653
  };
583
654
  }
655
+ /** A Bash call's whole command, which placing a failed `baker` call on it reads. */
656
+ function rememberCommand(acc, call, use) {
657
+ if (call.tool === "Bash" && typeof use.input?.command === "string") {
658
+ acc.commands.set(call.toolUseId, use.input.command);
659
+ }
660
+ }
584
661
  /** Ledger each tool call a response asked for, under the response it came in. */
585
662
  function absorbToolUses(acc, line, turnAt, isSidechain) {
586
663
  const uses = blocks(line).filter((block) => block.type === "tool_use");
@@ -597,6 +674,7 @@ function absorbToolUses(acc, line, turnAt, isSidechain) {
597
674
  });
598
675
  acc.toolCalls.push(call);
599
676
  acc.pending.set(call.toolUseId, call);
677
+ rememberCommand(acc, call, use);
600
678
  if (requestId !== undefined) {
601
679
  acc.batches.set(requestId, [...(acc.batches.get(requestId) ?? []), call]);
602
680
  }
@@ -608,12 +686,16 @@ function absorbToolUses(acc, line, turnAt, isSidechain) {
608
686
  return uses.length;
609
687
  }
610
688
  function absorbAssistantLine(acc, line) {
611
- const turnAt = currentTurn(acc)?.index ?? 0;
689
+ const turnAt = turnIndexOf(acc, line) ?? 0;
612
690
  absorbRequestUsage(acc, line, turnAt);
613
- const isSidechain = line.isSidechain === true || line.isSubagent === true;
691
+ const isSidechain = isSubagentLine(line);
614
692
  const uses = absorbToolUses(acc, line, turnAt, isSidechain);
615
693
  const text = textOf(line);
616
- if (text.trim().length === 0 || isSidechain) {
694
+ if (text.trim().length === 0) {
695
+ return;
696
+ }
697
+ if (isSidechain) {
698
+ acc.subagentWords.set(conversationKey(line.threadId, line.agentId), text);
617
699
  return;
618
700
  }
619
701
  const turn = currentTurn(acc);
@@ -667,6 +749,131 @@ function timeCalls(acc) {
667
749
  call.durationMs = took;
668
750
  }
669
751
  }
752
+ return startOf;
753
+ }
754
+ /** Whether the CLI says an invocation failed: a non-zero exit, or an `ok: false` envelope it printed. */
755
+ function cliFailed(invocation) {
756
+ return (invocation.exitCode !== undefined && invocation.exitCode !== 0) || invocation.error !== undefined;
757
+ }
758
+ /**
759
+ * What the CLI said about a failed invocation, and nothing the agent typed: the
760
+ * class is read from this, so `--title "Fix checkout timeout"` is not a timeout.
761
+ */
762
+ function cliErrorOf(invocation) {
763
+ const exit = invocation.exitCode === undefined ? "failed" : `exited ${invocation.exitCode}`;
764
+ return invocation.error === undefined ? exit : `${invocation.error.code}: ${invocation.error.message} — ${exit}`;
765
+ }
766
+ /**
767
+ * Its class, from the error code when the code names one: the CLI's message
768
+ * often echoes what the agent passed (`Invalid ID format: "zz-timeout-check"`),
769
+ * and read whole that `VALIDATION_ERROR` is a timeout.
770
+ */
771
+ function classifyCliFailure(invocation) {
772
+ const byCode = invocation.error === undefined ? "other" : classifyFailure(invocation.error.code);
773
+ return byCode === "other" ? classifyFailure(cliErrorOf(invocation)) : byCode;
774
+ }
775
+ /** The error first, so clipping a long command never takes the code with it. */
776
+ function describeCliFailure(invocation) {
777
+ return invocation.error === undefined
778
+ ? `\`baker ${invocation.argv.join(" ")}\` ${cliErrorOf(invocation)}`
779
+ : `${cliErrorOf(invocation)} — \`baker ${invocation.argv.join(" ")}\``;
780
+ }
781
+ /**
782
+ * The arguments of every `baker` command a shell command line runs, read the way
783
+ * the shell reads it: dequoted, inside pipes, lists, loops and substitutions,
784
+ * never inside a heredoc body or a quoted string.
785
+ */
786
+ function bakerArgvsOf(command) {
787
+ const script = parse(command);
788
+ if (script.errors && script.errors.length > 0) {
789
+ return [];
790
+ }
791
+ const argvs = [];
792
+ // Serialised because unbash materialises a word's parts only in `toJSON`.
793
+ JSON.stringify(script, (_key, value) => {
794
+ const node = value;
795
+ if (node?.type === "Command" && typeof node.name?.value === "string" && /(?:^|\/)baker$/.test(node.name.value)) {
796
+ argvs.push((node.suffix ?? []).map((word) => String(word.value ?? "")));
797
+ }
798
+ return value;
799
+ });
800
+ return argvs;
801
+ }
802
+ /** A word the shell expands before `baker` sees it, and so matches whatever argument it became. */
803
+ const EXPANDED_WORD = /[$*?[`]/;
804
+ /** Whether a command's own `baker` words are the ones an invocation received. */
805
+ function ranInvocation(words, argv) {
806
+ return words.length === argv.length && words.every((word, index) => word === argv[index] || EXPANDED_WORD.test(word));
807
+ }
808
+ /**
809
+ * The Bash call a `baker` invocation ran inside: the one running when it
810
+ * started whose command runs exactly that `baker` call.
811
+ *
812
+ * Nothing else places it. Subagents run side by side, so two running calls can
813
+ * share a platform (`baker ads meta campaigns`, `baker ads meta ads pause`), and
814
+ * a watcher or a hook calls `baker` while an unrelated command runs; a failure
815
+ * pinned on whichever call was running reports a success as failed and the
816
+ * failed call as a success. When no running call ran it, or more than one did,
817
+ * it is left unplaced.
818
+ */
819
+ function callRunning(invocation, acc, startOf, argvsOf) {
820
+ const at = stampOf(invocation.startedAt);
821
+ if (at === undefined) {
822
+ return undefined;
823
+ }
824
+ const ran = acc.toolCalls.filter((call) => {
825
+ const from = stampOf(startOf.get(call.toolUseId));
826
+ const to = stampOf(acc.answeredAt.get(call.toolUseId));
827
+ return (call.tool === "Bash" &&
828
+ from !== undefined &&
829
+ to !== undefined &&
830
+ from <= at &&
831
+ at <= to &&
832
+ argvsOf(call).some((words) => ranInvocation(words, invocation.argv)));
833
+ });
834
+ return ran.length === 1 ? ran[0] : undefined;
835
+ }
836
+ /**
837
+ * Count a `baker` call that failed as a failure, whatever its shell returned.
838
+ *
839
+ * A Bash result is an error only when the whole command line exited non-zero,
840
+ * and `| head`, `> file` or a later `; echo` make that zero. Read from the tool
841
+ * result alone, a Session whose every refused or timed-out `baker` call was
842
+ * piped reported "none of them failed" — while the CLI's own record said why
843
+ * each one did. A failure no call can be placed on is returned, never dropped.
844
+ */
845
+ function absorbCliFailures(acc, cli, startOf) {
846
+ const unplaced = [];
847
+ const failedIn = new Map();
848
+ const argvs = new Map();
849
+ const argvsOf = (call) => {
850
+ const known = argvs.get(call.toolUseId) ?? bakerArgvsOf(acc.commands.get(call.toolUseId) ?? "");
851
+ argvs.set(call.toolUseId, known);
852
+ return known;
853
+ };
854
+ for (const invocation of cli.filter(cliFailed)) {
855
+ const call = callRunning(invocation, acc, startOf, argvsOf);
856
+ if (call === undefined) {
857
+ unplaced.push(invocation);
858
+ }
859
+ else if (!call.isError) {
860
+ failedIn.set(call, [...(failedIn.get(call) ?? []), invocation]);
861
+ }
862
+ }
863
+ for (const [call, invocations] of failedIn) {
864
+ const failures = invocations.map((invocation) => ({
865
+ failureClass: classifyCliFailure(invocation),
866
+ message: clip(describeCliFailure(invocation), 400),
867
+ durationMs: invocation.durationMs ?? 0,
868
+ }));
869
+ for (const failure of failures) {
870
+ absorbFailure(acc, call, failure.message, failure.failureClass);
871
+ }
872
+ acc.cliFailures.set(call.toolUseId, failures);
873
+ }
874
+ const order = new Map(acc.toolCalls.map((call, index) => [call.toolUseId, index]));
875
+ acc.guards.sort((a, b) => (order.get(a.toolUseId) ?? 0) - (order.get(b.toolUseId) ?? 0));
876
+ return unplaced;
670
877
  }
671
878
  /** The latest of some ISO timestamps, or nothing when none is given. */
672
879
  function latest(stamps) {
@@ -698,7 +905,7 @@ function attributeGuardFollowUps(guards, calls) {
698
905
  .find((call) => call.threadId === refused.threadId &&
699
906
  call.isSidechain === refused.isSidechain &&
700
907
  call.agentId === refused.agentId &&
701
- call.summary !== guard.attempted);
908
+ call.inputKey !== refused.inputKey);
702
909
  if (after) {
703
910
  guard.nextAction = `${after.tool}: ${clip(after.summary, 160)}`;
704
911
  }
@@ -715,15 +922,15 @@ function attributeGuardFollowUps(guards, calls) {
715
922
  function countGuardRepeats(guards) {
716
923
  const counts = new Map();
717
924
  for (const guard of guards) {
718
- const key = `${guard.tool}\u0000${guard.attempted}\u0000${guard.guard ?? guard.message}`;
925
+ const key = `${guard.tool}\u0000${guard.inputKey}\u0000${guard.guard ?? guard.message}`;
719
926
  counts.set(key, (counts.get(key) ?? 0) + 1);
720
927
  }
721
928
  for (const guard of guards) {
722
- const key = `${guard.tool}\u0000${guard.attempted}\u0000${guard.guard ?? guard.message}`;
929
+ const key = `${guard.tool}\u0000${guard.inputKey}\u0000${guard.guard ?? guard.message}`;
723
930
  guard.repeats = counts.get(key) ?? 1;
724
931
  }
725
932
  }
726
- export function buildTranscriptDigest(lines) {
933
+ export function buildTranscriptDigest(lines, cli = []) {
727
934
  const acc = {
728
935
  turns: [],
729
936
  toolCalls: [],
@@ -739,6 +946,10 @@ export function buildTranscriptDigest(lines) {
739
946
  marketerLanguage: new Map(),
740
947
  batches: new Map(),
741
948
  answeredAt: new Map(),
949
+ subagentTurns: new Map(),
950
+ subagentWords: new Map(),
951
+ commands: new Map(),
952
+ cliFailures: new Map(),
742
953
  };
743
954
  for (const line of lines) {
744
955
  const openTurns = acc.turns.length;
@@ -770,7 +981,8 @@ export function buildTranscriptDigest(lines) {
770
981
  call.failureClass = "timeout";
771
982
  acc.failureMessages.set(call.toolUseId, "No result was ever recorded for this call.");
772
983
  }
773
- timeCalls(acc);
984
+ const startOf = timeCalls(acc);
985
+ const unplacedCliFailures = absorbCliFailures(acc, cli, startOf);
774
986
  attributeGuardFollowUps(acc.guards, acc.toolCalls);
775
987
  countGuardRepeats(acc.guards);
776
988
  const threadStamps = stampsByThread(lines);
@@ -789,7 +1001,8 @@ export function buildTranscriptDigest(lines) {
789
1001
  .filter((call) => call.durationMs !== undefined)
790
1002
  .sort((a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0))
791
1003
  .slice(0, 15),
792
- failures: aggregateFailures(acc.toolCalls, acc.failureMessages),
1004
+ failures: aggregateFailures(acc.toolCalls, acc.failureMessages, acc.cliFailures),
1005
+ unplacedCliFailures: unplacedCliFailures.map(describeCliFailure),
793
1006
  flaky: findFlakyCalls(acc.toolCalls),
794
1007
  introspectionCalls: acc.toolCalls.filter((call) => call.bakerVerb?.startsWith("baker schema") || /--help\b/.test(call.summary)),
795
1008
  hooks: [...acc.hooks.values()].sort((a, b) => b.fired - a.fired),
@@ -809,30 +1022,37 @@ export function buildTranscriptDigest(lines) {
809
1022
  * a bad moment, eleven of them at the same bound is a default set for a network
810
1023
  * this sandbox does not have.
811
1024
  */
812
- function aggregateFailures(calls, messages) {
1025
+ function ownFailure(call, failureClass, messages) {
1026
+ return { failureClass, message: messages.get(call.toolUseId) ?? "", durationMs: call.durationMs ?? 0 };
1027
+ }
1028
+ function aggregateFailures(calls, messages, cliFailures) {
813
1029
  const map = new Map();
814
1030
  for (const call of calls) {
815
1031
  if (!call.isError || call.failureClass === undefined) {
816
1032
  continue;
817
1033
  }
818
- const key = `${call.failureClass}::${call.tool}`;
819
- const entry = map.get(key) ?? {
820
- failureClass: call.failureClass,
821
- tool: call.tool,
822
- calls: 0,
823
- totalMs: 0,
824
- example: messages.get(call.toolUseId) ?? "",
825
- turns: [],
826
- };
827
- entry.calls += 1;
828
- entry.totalMs += call.durationMs ?? 0;
829
- if (entry.example.length === 0) {
830
- entry.example = messages.get(call.toolUseId) ?? "";
831
- }
832
- if (!entry.turns.includes(call.turn)) {
833
- entry.turns.push(call.turn);
1034
+ // A command line that ran several failed `baker` calls counts each of them.
1035
+ const failures = cliFailures.get(call.toolUseId) ?? [ownFailure(call, call.failureClass, messages)];
1036
+ for (const failure of failures) {
1037
+ const key = `${failure.failureClass}::${call.tool}`;
1038
+ const entry = map.get(key) ?? {
1039
+ failureClass: failure.failureClass,
1040
+ tool: call.tool,
1041
+ calls: 0,
1042
+ totalMs: 0,
1043
+ example: failure.message,
1044
+ turns: [],
1045
+ };
1046
+ entry.calls += 1;
1047
+ entry.totalMs += failure.durationMs;
1048
+ if (entry.example.length === 0) {
1049
+ entry.example = failure.message;
1050
+ }
1051
+ if (!entry.turns.includes(call.turn)) {
1052
+ entry.turns.push(call.turn);
1053
+ }
1054
+ map.set(key, entry);
834
1055
  }
835
- map.set(key, entry);
836
1056
  }
837
1057
  return [...map.values()].sort((a, b) => b.calls - a.calls);
838
1058
  }
@@ -847,7 +1067,7 @@ function aggregateFailures(calls, messages) {
847
1067
  function findFlakyCalls(calls) {
848
1068
  const groups = new Map();
849
1069
  for (const call of calls) {
850
- const key = `${call.tool}\u0000${call.summary}`;
1070
+ const key = `${call.tool}\u0000${call.inputKey}`;
851
1071
  groups.set(key, [...(groups.get(key) ?? []), call]);
852
1072
  }
853
1073
  const out = [];
@@ -877,7 +1097,18 @@ function findFlakyCalls(calls) {
877
1097
  * and this is what says where in the Session to look.
878
1098
  */
879
1099
  function emptyThread(threadId) {
880
- return { threadId, turns: 0, toolCalls: 0, resultBytes: 0, resultTokens: 0, errors: 0, denials: 0, workingMs: 0 };
1100
+ return {
1101
+ threadId,
1102
+ turns: 0,
1103
+ toolCalls: 0,
1104
+ resultBytes: 0,
1105
+ resultTokens: 0,
1106
+ subagentToolCalls: 0,
1107
+ subagentResultTokens: 0,
1108
+ errors: 0,
1109
+ denials: 0,
1110
+ workingMs: 0,
1111
+ };
881
1112
  }
882
1113
  /** Milliseconds parsed off an ISO timestamp, or nothing. */
883
1114
  function stampOf(value) {
@@ -914,6 +1145,8 @@ function aggregateByThread(turns, calls, stamps) {
914
1145
  entry.toolCalls += 1;
915
1146
  entry.resultBytes += call.resultBytes;
916
1147
  entry.resultTokens += call.resultTokens;
1148
+ entry.subagentToolCalls += call.isSidechain ? 1 : 0;
1149
+ entry.subagentResultTokens += call.isSidechain ? call.resultTokens : 0;
917
1150
  entry.errors += call.isError ? 1 : 0;
918
1151
  }
919
1152
  for (const [threadId, values] of stamps) {
@@ -930,14 +1163,18 @@ function aggregateByTool(calls) {
930
1163
  const entry = map.get(call.tool) ?? {
931
1164
  tool: call.tool,
932
1165
  calls: 0,
1166
+ subagentCalls: 0,
933
1167
  totalBytes: 0,
934
1168
  totalTokens: 0,
1169
+ subagentTokens: 0,
935
1170
  maxBytes: 0,
936
1171
  errors: 0,
937
1172
  };
938
1173
  entry.calls += 1;
1174
+ entry.subagentCalls += call.isSidechain ? 1 : 0;
939
1175
  entry.totalBytes += call.resultBytes;
940
1176
  entry.totalTokens += call.resultTokens;
1177
+ entry.subagentTokens += call.isSidechain ? call.resultTokens : 0;
941
1178
  entry.maxBytes = Math.max(entry.maxBytes, call.resultBytes);
942
1179
  entry.errors += call.isError ? 1 : 0;
943
1180
  map.set(call.tool, entry);
@@ -974,7 +1211,7 @@ function aggregateByVerb(calls) {
974
1211
  function findRepeats(calls) {
975
1212
  const seen = new Map();
976
1213
  for (const call of calls) {
977
- const key = `${call.tool}\u0000${call.summary}`;
1214
+ const key = `${call.tool}\u0000${call.inputKey}`;
978
1215
  const entry = seen.get(key);
979
1216
  if (entry) {
980
1217
  entry.extra += 1;
@@ -1029,6 +1266,9 @@ function absorbInvocation(entry, line) {
1029
1266
  return;
1030
1267
  }
1031
1268
  entry.argv = line.argv;
1269
+ if (line.ts !== undefined) {
1270
+ entry.startedAt = line.ts;
1271
+ }
1032
1272
  const verb = bakerVerbOf(`baker ${line.argv.join(" ")}`);
1033
1273
  if (verb) {
1034
1274
  entry.verb = verb;
@@ -1053,14 +1293,38 @@ function absorbExit(entry, line) {
1053
1293
  entry.durationMs = line.durationMs;
1054
1294
  }
1055
1295
  }
1296
+ /** What it printed, and the error it reported when the text is an `ok: false` envelope. */
1297
+ function absorbOutput(entry, line) {
1298
+ if (typeof line.text !== "string") {
1299
+ return;
1300
+ }
1301
+ entry.printedBytes += Buffer.byteLength(line.text);
1302
+ const error = printedError(line.text);
1303
+ if (error !== undefined && entry.error === undefined) {
1304
+ entry.error = error;
1305
+ }
1306
+ }
1307
+ function printedError(text) {
1308
+ let parsed;
1309
+ try {
1310
+ parsed = JSON.parse(text);
1311
+ }
1312
+ catch {
1313
+ return undefined;
1314
+ }
1315
+ if (typeof parsed !== "object" || parsed === null || parsed.ok !== false) {
1316
+ return undefined;
1317
+ }
1318
+ const error = parsed.error;
1319
+ return {
1320
+ code: typeof error?.code === "string" ? error.code : "ERROR",
1321
+ message: typeof error?.message === "string" ? error.message : "",
1322
+ };
1323
+ }
1056
1324
  const DEBUG_LINE_HANDLERS = {
1057
1325
  invocation: absorbInvocation,
1058
1326
  http: absorbHttpCall,
1059
- output: (entry, line) => {
1060
- if (typeof line.text === "string") {
1061
- entry.printedBytes += Buffer.byteLength(line.text);
1062
- }
1063
- },
1327
+ output: absorbOutput,
1064
1328
  exit: absorbExit,
1065
1329
  };
1066
1330
  /**
@@ -1141,12 +1405,25 @@ export function renderTurns(digest) {
1141
1405
  "",
1142
1406
  digest.threads.length === 0
1143
1407
  ? "No thread could be attributed — the transcripts carried no thread id."
1144
- : table(["thread", "turns", "tool calls", "result bytes", "~result tokens", "errors", "denials", "running"], digest.threads.map((thread) => [
1408
+ : table([
1409
+ "thread",
1410
+ "turns",
1411
+ "tool calls",
1412
+ "subagent calls",
1413
+ "result bytes",
1414
+ "~main result tokens",
1415
+ "~subagent result tokens",
1416
+ "errors",
1417
+ "denials",
1418
+ "running",
1419
+ ], digest.threads.map((thread) => [
1145
1420
  thread.threadId,
1146
1421
  thread.turns,
1147
1422
  thread.toolCalls,
1423
+ thread.subagentToolCalls,
1148
1424
  thread.resultBytes,
1149
- thread.resultTokens,
1425
+ thread.resultTokens - thread.subagentResultTokens,
1426
+ thread.subagentResultTokens,
1150
1427
  thread.errors,
1151
1428
  thread.denials,
1152
1429
  `${Math.round(thread.workingMs / 1000)}s`,
@@ -1154,7 +1431,25 @@ export function renderTurns(digest) {
1154
1431
  "",
1155
1432
  "## Every turn",
1156
1433
  "",
1157
- table(["#", "thread", "at", "user", "assistant", "tools", "in", "out", "cache read", "denials", "compacted"], digest.turns.map((turn) => [
1434
+ "`in`, `out` and `cache read` are the main agent's context; `subagent` columns",
1435
+ "are what that turn's subagents spent, which never reached it.",
1436
+ "",
1437
+ table([
1438
+ "#",
1439
+ "thread",
1440
+ "at",
1441
+ "user",
1442
+ "assistant",
1443
+ "tools",
1444
+ "in",
1445
+ "out",
1446
+ "cache read",
1447
+ "subagent in",
1448
+ "subagent out",
1449
+ "subagent cache read",
1450
+ "denials",
1451
+ "compacted",
1452
+ ], digest.turns.map((turn) => [
1158
1453
  turn.index,
1159
1454
  turn.threadId ?? "",
1160
1455
  turn.timestamp ?? "",
@@ -1164,23 +1459,36 @@ export function renderTurns(digest) {
1164
1459
  turn.inputTokens,
1165
1460
  turn.outputTokens,
1166
1461
  turn.cacheReadTokens,
1462
+ turn.subagent.inputTokens,
1463
+ turn.subagent.outputTokens,
1464
+ turn.subagent.cacheReadTokens,
1167
1465
  turn.denials,
1168
1466
  turn.compacted ? "yes" : "",
1169
1467
  ])),
1170
1468
  "",
1171
1469
  ].join("\n");
1172
1470
  }
1471
+ /** Who made a call: the main agent, or the subagent by its id. */
1472
+ function agentOf(call) {
1473
+ return call.isSidechain ? `subagent ${call.agentId ?? "(unnamed)"}` : "main";
1474
+ }
1173
1475
  export function renderTools(digest) {
1174
1476
  const sections = [
1175
1477
  "# Tool calls",
1176
1478
  "",
1177
1479
  "## Per tool",
1178
1480
  "",
1179
- table(["tool", "calls", "total bytes", "~total tokens", "max bytes", "errors"], digest.byTool.map((entry) => [
1481
+ "`main` is what the agent the marketer talks to called; `subagent` ran in a",
1482
+ "subagent, and its results never reached the main agent's context.",
1483
+ "",
1484
+ table(["tool", "calls", "main", "subagent", "total bytes", "~main tokens", "~subagent tokens", "max bytes", "errors"], digest.byTool.map((entry) => [
1180
1485
  entry.tool,
1181
1486
  entry.calls,
1487
+ entry.calls - entry.subagentCalls,
1488
+ entry.subagentCalls,
1182
1489
  entry.totalBytes,
1183
- entry.totalTokens,
1490
+ entry.totalTokens - entry.subagentTokens,
1491
+ entry.subagentTokens,
1184
1492
  entry.maxBytes,
1185
1493
  entry.errors,
1186
1494
  ])),
@@ -1208,7 +1516,14 @@ export function renderTools(digest) {
1208
1516
  "",
1209
1517
  "## Ten largest single results (by what the model received)",
1210
1518
  "",
1211
- table(["turn", "tool", "input", "bytes", "~tokens"], digest.largestResults.map((call) => [call.turn, call.tool, call.summary, call.resultBytes, call.resultTokens])),
1519
+ table(["turn", "agent", "tool", "input", "bytes", "~tokens"], digest.largestResults.map((call) => [
1520
+ call.turn,
1521
+ agentOf(call),
1522
+ call.tool,
1523
+ call.summary,
1524
+ call.resultBytes,
1525
+ call.resultTokens,
1526
+ ])),
1212
1527
  "",
1213
1528
  "## Introspection calls (`baker schema`, `--help`)",
1214
1529
  "",
@@ -1258,6 +1573,17 @@ export function renderFailures(digest) {
1258
1573
  clip(entry.example, 160),
1259
1574
  ])),
1260
1575
  "",
1576
+ ...(digest.unplacedCliFailures.length === 0
1577
+ ? []
1578
+ : [
1579
+ "## Failed `baker` calls no tool call can be placed on",
1580
+ "",
1581
+ "The CLI recorded these as failed, and when each started no loaded transcript",
1582
+ "had exactly one running Bash call whose command ran that `baker` call.",
1583
+ "",
1584
+ ...digest.unplacedCliFailures.map((failure) => `- ${clip(failure, 300)}`),
1585
+ "",
1586
+ ]),
1261
1587
  "## The same call, two answers",
1262
1588
  "",
1263
1589
  "Identical tool and input, one attempt failing and another succeeding, with",
@@ -1279,8 +1605,9 @@ export function renderFailures(digest) {
1279
1605
  "",
1280
1606
  digest.slowest.length === 0
1281
1607
  ? "No call carried both a request and a result timestamp, so nothing here can be timed."
1282
- : table(["turn", "tool", "took", "failed", "input"], digest.slowest.map((call) => [
1608
+ : table(["turn", "agent", "tool", "took", "failed", "input"], digest.slowest.map((call) => [
1283
1609
  call.turn,
1610
+ agentOf(call),
1284
1611
  call.tool,
1285
1612
  secs(call.durationMs ?? 0),
1286
1613
  call.isError ? (call.failureClass ?? "yes") : "",
@@ -1304,6 +1631,9 @@ export function renderHooks(digest) {
1304
1631
  "",
1305
1632
  ].join("\n");
1306
1633
  }
1634
+ function guardAgent(guard) {
1635
+ return guard.agentId === undefined ? "" : ` · subagent ${guard.agentId}`;
1636
+ }
1307
1637
  export function renderGuards(digest) {
1308
1638
  const refusals = digest.guards.filter((guard) => guard.refusal);
1309
1639
  const errors = digest.guards.filter((guard) => !guard.refusal);
@@ -1323,13 +1653,14 @@ export function renderGuards(digest) {
1323
1653
  : [
1324
1654
  `${refusals.length} refusals.`,
1325
1655
  "",
1326
- "`Intent` is the agent's own words in that turn — what it thought it",
1327
- "was doing. It is the transcript's only answer to why it went there;",
1656
+ "`Intent` is the refused agent's own words — the main agent's in that",
1657
+ "turn, or the subagent's own when a subagent was refused: what it thought",
1658
+ "it was doing. It is the transcript's only answer to why it went there;",
1328
1659
  "the rest of the answer is in whatever doc, skill, hint or example",
1329
1660
  "pointed it that way, which is yours to go and open.",
1330
1661
  "",
1331
1662
  ...refusals.map((guard) => [
1332
- `### Turn ${guard.turn} · ${guard.tool}${guard.guard ? ` · ${guard.guard}` : ""}${guard.repeats > 1 ? ` · refused ${guard.repeats}× this Session` : ""}`,
1663
+ `### Turn ${guard.turn}${guardAgent(guard)} · ${guard.tool}${guard.guard ? ` · ${guard.guard}` : ""}${guard.repeats > 1 ? ` · refused ${guard.repeats}× this Session` : ""}`,
1333
1664
  "",
1334
1665
  `Attempted: \`${guard.attempted}\``,
1335
1666
  "",
@@ -1348,7 +1679,7 @@ export function renderGuards(digest) {
1348
1679
  ? "No tool returned an error."
1349
1680
  : errors
1350
1681
  .map((guard) => [
1351
- `### Turn ${guard.turn} · ${guard.tool}${guard.repeats > 1 ? ` · ${guard.repeats}×` : ""}`,
1682
+ `### Turn ${guard.turn}${guardAgent(guard)} · ${guard.tool}${guard.repeats > 1 ? ` · ${guard.repeats}×` : ""}`,
1352
1683
  "",
1353
1684
  `Attempted: \`${guard.attempted}\``,
1354
1685
  "",