@cat-factory/executor-harness 1.64.0 → 1.64.4
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/agent-runner.js +63 -43
- package/dist/claude-call-aggregator.js +229 -0
- package/dist/progress.js +122 -13
- package/package.json +4 -4
- package/src/agent-runner.ts +73 -48
- package/src/claude-call-aggregator.ts +327 -0
- package/src/progress.ts +138 -14
- package/src/subagents.ts +9 -3
package/src/agent-runner.ts
CHANGED
|
@@ -2,13 +2,8 @@ import { spawn } from 'node:child_process'
|
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
claudeCallUsage,
|
|
8
|
-
isObject,
|
|
9
|
-
numberOf,
|
|
10
|
-
redactBody,
|
|
11
|
-
} from './claude-stream.js'
|
|
5
|
+
import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude-stream.js'
|
|
6
|
+
import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js'
|
|
12
7
|
import type { Logger } from './logger.js'
|
|
13
8
|
import {
|
|
14
9
|
createCallMetricPublisher,
|
|
@@ -25,6 +20,7 @@ import { redact, secretsToRedact } from './redact.js'
|
|
|
25
20
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
26
21
|
import {
|
|
27
22
|
createTaskPlanTracker,
|
|
23
|
+
mergeProgress,
|
|
28
24
|
normalizeStatus,
|
|
29
25
|
pickProgress,
|
|
30
26
|
toProgress,
|
|
@@ -358,44 +354,54 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
358
354
|
}
|
|
359
355
|
|
|
360
356
|
// Reconstruct the full per-call request/response bodies for telemetry from the
|
|
361
|
-
// stream. `--output-format stream-json --verbose` emits
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// credential-scrubbed (they can echo the leased token).
|
|
357
|
+
// stream. `--output-format stream-json --verbose` emits a near-verbatim Anthropic
|
|
358
|
+
// Messages envelope per response CONTENT BLOCK (not per call), so the aggregator below
|
|
359
|
+
// folds the envelopes sharing a `message.id` back into one call and buffers that call's
|
|
360
|
+
// `user` tool_result turns — together the growing prompt transcript, in the shape the
|
|
361
|
+
// model was actually sent. We seed it with the inputs the harness supplies (they never
|
|
362
|
+
// appear in the stream): the system + first user message when the prompt rides argv, or
|
|
363
|
+
// a single folded user turn when it doesn't — so the reconstruction never shows a system
|
|
364
|
+
// turn that was never sent. Bodies are credential-scrubbed (they can echo the leased token).
|
|
369
365
|
const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : []
|
|
370
|
-
const messages: Array<{ role: string; content: unknown }> = folded
|
|
371
|
-
? [{ role: 'user', content: prompt }]
|
|
372
|
-
: [
|
|
373
|
-
{ role: 'system', content: opts.systemPrompt },
|
|
374
|
-
{ role: 'user', content: opts.userPrompt },
|
|
375
|
-
]
|
|
376
366
|
const calls: HarnessCallMetric[] = []
|
|
377
367
|
// Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
|
|
378
368
|
// may still rewrite below (a published call must be final — see the publisher).
|
|
379
369
|
const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
|
|
370
|
+
// `watcherOwnsSubagents` tracks the `startSubagentWatcher` wiring below: it is started only when
|
|
371
|
+
// the CLI has an isolated config home to watch, which an `ambientAuth` run does not have. The
|
|
372
|
+
// telemetry routes the CLI's tagged subagent turns accordingly — see `createClaudeRunTelemetry`.
|
|
373
|
+
const telemetry = createClaudeRunTelemetry({
|
|
374
|
+
seed: folded
|
|
375
|
+
? [{ role: 'user', content: prompt }]
|
|
376
|
+
: [
|
|
377
|
+
{ role: 'system', content: opts.systemPrompt },
|
|
378
|
+
{ role: 'user', content: opts.userPrompt },
|
|
379
|
+
],
|
|
380
|
+
secrets,
|
|
381
|
+
watcherOwnsSubagents: !opts.ambientAuth,
|
|
382
|
+
publish: (metric) => publisher.publish(metric),
|
|
383
|
+
})
|
|
380
384
|
|
|
381
|
-
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from
|
|
382
|
-
//
|
|
383
|
-
// stream (
|
|
384
|
-
//
|
|
385
|
-
// by `planTracker` + `lastTodo
|
|
386
|
-
//
|
|
387
|
-
// marks it done, which used to gate the slice signal off and pin progress at 0%.
|
|
385
|
+
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
|
|
386
|
+
// produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
|
|
387
|
+
// appear on this stream (as do the subagents' own intermediate turns, tagged with the dispatch
|
|
388
|
+
// that spawned them — see `isSubagentEvent`), so `sliceTracker` knows which slices are in flight
|
|
389
|
+
// and which have returned; the parent's own plan (tracked by `planTracker` + `lastTodo`) is the
|
|
390
|
+
// only place a not-yet-dispatched slice is named at all.
|
|
388
391
|
//
|
|
389
392
|
// The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
|
|
390
393
|
// `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
|
|
391
394
|
// `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
|
|
392
|
-
// because the task id is minted there). Both are read
|
|
395
|
+
// because the task id is minted there). Both are read, and `pickProgress` resolves that
|
|
396
|
+
// either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
|
|
397
|
+
// competing with it — picking the further-along view collapsed the list to the dispatched
|
|
398
|
+
// slices alone the moment the first subagent returned. See ./progress.ts.
|
|
393
399
|
const sliceTracker = createSliceTracker()
|
|
394
400
|
const planTracker = createTaskPlanTracker()
|
|
395
401
|
let lastTodo: TodoProgress | undefined
|
|
396
402
|
const emitProgress = (): void => {
|
|
397
403
|
if (!opts.onProgress) return
|
|
398
|
-
const progress =
|
|
404
|
+
const progress = mergeProgress(
|
|
399
405
|
pickProgress(lastTodo, planTracker.progress()),
|
|
400
406
|
sliceTracker.progress(),
|
|
401
407
|
)
|
|
@@ -439,12 +445,19 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
439
445
|
|
|
440
446
|
const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
|
|
441
447
|
const type = event.type
|
|
448
|
+
// A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
|
|
449
|
+
// `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
|
|
450
|
+
// tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
|
|
451
|
+
// errors should trip the guard exactly as the parent would, and whether the agent acted at all
|
|
452
|
+
// does not depend on which channel billed it.
|
|
453
|
+
const dispatchId = subagentDispatchId(event)
|
|
442
454
|
if (type === 'assistant' && isObject(event.message)) {
|
|
443
455
|
const message = event.message as Record<string, unknown>
|
|
444
456
|
const content = Array.isArray(message.content) ? message.content : []
|
|
445
|
-
const { text,
|
|
457
|
+
const { text, toolUses } = claudeAssistantContent(content)
|
|
446
458
|
stats.assistantChars += text.length
|
|
447
459
|
stats.toolCalls += toolUses
|
|
460
|
+
telemetry.onAssistant(dispatchId, message)
|
|
448
461
|
for (const block of content) {
|
|
449
462
|
if (!isObject(block) || block.type !== 'tool_use') continue
|
|
450
463
|
// Remember each call's name against its id so the guard can pair it with the
|
|
@@ -460,22 +473,6 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
460
473
|
sliceTracker.onAssistant(content)
|
|
461
474
|
planTracker.onAssistant(content)
|
|
462
475
|
emitProgress()
|
|
463
|
-
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
464
|
-
// produced this response. The append-only array keeps each call's prompt a strict
|
|
465
|
-
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
466
|
-
const u = claudeCallUsage(message.usage)
|
|
467
|
-
publisher.publish({
|
|
468
|
-
...(typeof message.model === 'string' ? { model: message.model } : {}),
|
|
469
|
-
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
470
|
-
messageCount: messages.length,
|
|
471
|
-
responseText: redactBody(text, secrets),
|
|
472
|
-
reasoningText: redactBody(reasoning, secrets),
|
|
473
|
-
inputTokens: u.inputTokens,
|
|
474
|
-
cachedInputTokens: u.cachedInputTokens,
|
|
475
|
-
outputTokens: u.outputTokens,
|
|
476
|
-
finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
|
|
477
|
-
})
|
|
478
|
-
messages.push({ role: 'assistant', content })
|
|
479
476
|
} else if (type === 'user' && isObject(event.message)) {
|
|
480
477
|
// tool_result blocks the harness fed back to the model — part of the next prompt.
|
|
481
478
|
const content = (event.message as Record<string, unknown>).content
|
|
@@ -486,7 +483,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
486
483
|
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
487
484
|
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
488
485
|
if (!meta?.final) feedGuard(content)
|
|
489
|
-
|
|
486
|
+
telemetry.onToolResult(dispatchId, content)
|
|
490
487
|
}
|
|
491
488
|
} else if (type === 'result') {
|
|
492
489
|
if (typeof event.result === 'string') summary = event.result
|
|
@@ -585,6 +582,8 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
585
582
|
onEvent,
|
|
586
583
|
)
|
|
587
584
|
|
|
585
|
+
// The stream has ended, so the last call has no successor envelope to complete it.
|
|
586
|
+
telemetry.flush()
|
|
588
587
|
return await assembleClaudeOutcome({
|
|
589
588
|
summary,
|
|
590
589
|
stats,
|
|
@@ -593,8 +592,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
593
592
|
publisher,
|
|
594
593
|
usage,
|
|
595
594
|
subagents,
|
|
595
|
+
expectSubagentCalls: telemetry.expectsWatcherCalls(),
|
|
596
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
596
597
|
})
|
|
597
598
|
} catch (err) {
|
|
599
|
+
// The stream ended abnormally (guard trip, watchdog kill, CLI crash). Complete the call in
|
|
600
|
+
// flight anyway, and release whatever the publisher was withholding: a killed run never
|
|
601
|
+
// returns an outcome, so the live channel is the ONLY record of what it spent, and dropping
|
|
602
|
+
// its last turn is what the streaming exists to avoid.
|
|
603
|
+
telemetry.flush()
|
|
604
|
+
publisher.flush()
|
|
598
605
|
// A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
|
|
599
606
|
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
|
|
600
607
|
// it attached, since that is usually the only evidence of what the CLI was doing when it was
|
|
@@ -653,6 +660,10 @@ function buildClaudeEnv(
|
|
|
653
660
|
* terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
|
|
654
661
|
* exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
|
|
655
662
|
* {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
|
|
663
|
+
*
|
|
664
|
+
* The invariant is about the aggregate `usage` only. `calls` was NEVER disjoint from the watcher's
|
|
665
|
+
* on its own: the CLI streams a subagent's turns onto the parent's stdout as well, so the parent
|
|
666
|
+
* loop's telemetry must filter them (`subagentDispatchId`) for this concatenation to hold.
|
|
656
667
|
*/
|
|
657
668
|
async function assembleClaudeOutcome(args: {
|
|
658
669
|
summary: string
|
|
@@ -663,6 +674,14 @@ async function assembleClaudeOutcome(args: {
|
|
|
663
674
|
publisher: CallMetricPublisher
|
|
664
675
|
usage: { inputTokens: number; outputTokens: number } | undefined
|
|
665
676
|
subagents: ReturnType<typeof startSubagentWatcher> | undefined
|
|
677
|
+
/**
|
|
678
|
+
* The parent stream carried subagent turns AND the watcher was the channel meant to record them.
|
|
679
|
+
* A watcher that then yields nothing means the run lost its subagent rows entirely — the CLI's
|
|
680
|
+
* transcript layout is not a stable contract (ADR 0027 Defect A moved it once already), so say
|
|
681
|
+
* so rather than under-reporting the spend in silence.
|
|
682
|
+
*/
|
|
683
|
+
expectSubagentCalls: boolean
|
|
684
|
+
log?: Logger
|
|
666
685
|
}): Promise<PiRunOutcome> {
|
|
667
686
|
const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args
|
|
668
687
|
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
@@ -677,6 +696,12 @@ async function assembleClaudeOutcome(args: {
|
|
|
677
696
|
await subagents?.stop()
|
|
678
697
|
const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 }
|
|
679
698
|
const subCalls = subagents?.calls() ?? []
|
|
699
|
+
if (args.expectSubagentCalls && !subCalls.length) {
|
|
700
|
+
args.log?.warn(
|
|
701
|
+
'subagent turns were streamed but the transcript watcher captured no calls; their token ' +
|
|
702
|
+
'spend is missing from this run’s telemetry (check the CLI’s subagents/*.jsonl layout)',
|
|
703
|
+
)
|
|
704
|
+
}
|
|
680
705
|
const mergedCalls = [...calls, ...subCalls]
|
|
681
706
|
const mergedUsage =
|
|
682
707
|
usage || subUsage.inputTokens || subUsage.outputTokens
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
|
|
2
|
+
import type { HarnessCallMetric } from './pi.js'
|
|
3
|
+
|
|
4
|
+
// Claude Code's `stream-json` does NOT emit one `assistant` envelope per model call. It emits one
|
|
5
|
+
// per CONTENT BLOCK of a call's response — a turn that answers with text and then fires five
|
|
6
|
+
// parallel tool calls arrives as six envelopes, each carrying that ONE call's `usage`, with the
|
|
7
|
+
// `user` tool_result turns interleaved between them. Treating an envelope as a call therefore
|
|
8
|
+
// counted a single request once per block: a measured pr-review recorded 575 rows and 39.4M summed
|
|
9
|
+
// prompt tokens for ~230 real calls and ~16.3M, which is why the burn instrumentation could not be
|
|
10
|
+
// trusted (docs/initiatives/token-burn-instrumentation.md).
|
|
11
|
+
//
|
|
12
|
+
// This aggregator folds every envelope sharing a `message.id` back into the one call it belongs to,
|
|
13
|
+
// and buffers that call's tool_result turns so the reconstructed prompt chain keeps the shape the
|
|
14
|
+
// model was actually sent: one assistant turn holding all its blocks, then the results.
|
|
15
|
+
|
|
16
|
+
/** One model call, assembled from every stream envelope that carried a piece of it. */
|
|
17
|
+
export interface AggregatedClaudeCall {
|
|
18
|
+
model?: string
|
|
19
|
+
/** Every content block of the response, in arrival order. */
|
|
20
|
+
content: unknown[]
|
|
21
|
+
text: string
|
|
22
|
+
reasoning: string
|
|
23
|
+
stopReason: string | null
|
|
24
|
+
inputTokens: number
|
|
25
|
+
cachedInputTokens: number
|
|
26
|
+
outputTokens: number
|
|
27
|
+
/** The `user` turns carrying this call's tool_result blocks, in arrival order. */
|
|
28
|
+
toolResults: unknown[][]
|
|
29
|
+
/** tool_use blocks across the whole response (the run's `stats.toolCalls` term). */
|
|
30
|
+
toolUses: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ClaudeCallAggregator {
|
|
34
|
+
/**
|
|
35
|
+
* Fold one `assistant` envelope in. A new `message.id` completes the call in flight first, so
|
|
36
|
+
* `onCallStart` for the new call always runs after `onCall` for the previous one.
|
|
37
|
+
*/
|
|
38
|
+
onAssistant(message: Record<string, unknown>): void
|
|
39
|
+
/** Buffer a `user` turn's content against the call in flight (dropped when none is). */
|
|
40
|
+
onToolResult(content: unknown[]): void
|
|
41
|
+
/** Complete the call still in flight, if any. Call once the stream has ended. */
|
|
42
|
+
flush(): void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Pending extends AggregatedClaudeCall {
|
|
46
|
+
id: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Assemble per-call telemetry out of Claude Code's per-block stream envelopes.
|
|
51
|
+
*
|
|
52
|
+
* `onCallStart` fires when a call's FIRST envelope arrives, which is the moment the caller must
|
|
53
|
+
* snapshot the prompt: the history at that point is what produced the response. `onCall` fires
|
|
54
|
+
* once the call is complete (a different `message.id` began, or the stream ended).
|
|
55
|
+
*
|
|
56
|
+
* Usage is merged as the MAXIMUM of each bucket across the call's envelopes rather than the last
|
|
57
|
+
* one seen. The envelopes carry a snapshot of the same call's usage, and which of them holds the
|
|
58
|
+
* final output count is a CLI detail we should not depend on; a max is right whether the value is
|
|
59
|
+
* repeated verbatim or grows.
|
|
60
|
+
*
|
|
61
|
+
* An envelope with no `message.id` cannot be attributed, so it is treated as a call of its own —
|
|
62
|
+
* the pre-aggregation behaviour, kept so a CLI build (or a transcript) that omits the id degrades
|
|
63
|
+
* to over-counting rather than to silently merging unrelated calls.
|
|
64
|
+
*/
|
|
65
|
+
export function createClaudeCallAggregator(handlers: {
|
|
66
|
+
onCallStart?: () => void
|
|
67
|
+
onCall: (call: AggregatedClaudeCall) => void
|
|
68
|
+
}): ClaudeCallAggregator {
|
|
69
|
+
let pending: Pending | undefined
|
|
70
|
+
let anonymous = 0
|
|
71
|
+
|
|
72
|
+
const complete = (): void => {
|
|
73
|
+
if (!pending) return
|
|
74
|
+
const { id: _id, ...call } = pending
|
|
75
|
+
pending = undefined
|
|
76
|
+
handlers.onCall(call)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
onAssistant(message) {
|
|
81
|
+
// `#anon-<n>` cannot collide with a real id (the API mints `msg_…`), so an envelope
|
|
82
|
+
// with no id keeps its own call rather than merging into whatever came before it.
|
|
83
|
+
const id = typeof message.id === 'string' && message.id ? message.id : `#anon-${anonymous++}`
|
|
84
|
+
if (pending && pending.id !== id) complete()
|
|
85
|
+
const content = Array.isArray(message.content) ? message.content : []
|
|
86
|
+
const { text, reasoning, toolUses } = claudeAssistantContent(content)
|
|
87
|
+
const usage = claudeCallUsage(message.usage)
|
|
88
|
+
const stopReason = typeof message.stop_reason === 'string' ? message.stop_reason : null
|
|
89
|
+
const model = typeof message.model === 'string' ? message.model : undefined
|
|
90
|
+
if (!pending) {
|
|
91
|
+
pending = {
|
|
92
|
+
id,
|
|
93
|
+
content: [],
|
|
94
|
+
text: '',
|
|
95
|
+
reasoning: '',
|
|
96
|
+
stopReason: null,
|
|
97
|
+
inputTokens: 0,
|
|
98
|
+
cachedInputTokens: 0,
|
|
99
|
+
outputTokens: 0,
|
|
100
|
+
toolResults: [],
|
|
101
|
+
toolUses: 0,
|
|
102
|
+
}
|
|
103
|
+
handlers.onCallStart?.()
|
|
104
|
+
}
|
|
105
|
+
pending.content.push(...content)
|
|
106
|
+
pending.text += text
|
|
107
|
+
pending.reasoning += reasoning
|
|
108
|
+
pending.toolUses += toolUses
|
|
109
|
+
pending.inputTokens = Math.max(pending.inputTokens, usage.inputTokens)
|
|
110
|
+
pending.cachedInputTokens = Math.max(pending.cachedInputTokens, usage.cachedInputTokens)
|
|
111
|
+
pending.outputTokens = Math.max(pending.outputTokens, usage.outputTokens)
|
|
112
|
+
// A block-split response reports its stop reason on the envelope that carries the end of the
|
|
113
|
+
// message; earlier ones report none. Keep the first non-null rather than the last seen.
|
|
114
|
+
if (stopReason && !pending.stopReason) pending.stopReason = stopReason
|
|
115
|
+
if (model && !pending.model) pending.model = model
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
onToolResult(content) {
|
|
119
|
+
// Results can only belong to the tool_use blocks of the call in flight. Before the first
|
|
120
|
+
// assistant envelope there is nothing they could attach to.
|
|
121
|
+
if (pending) pending.toolResults.push(content)
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
flush: complete,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One turn of the reconstructed request transcript, in the proxy's chat-array shape. */
|
|
129
|
+
interface TranscriptTurn {
|
|
130
|
+
role: string
|
|
131
|
+
content: unknown
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The per-call telemetry the Claude Code stream yields, assembled behind one small surface. */
|
|
135
|
+
export interface ClaudeStreamTelemetry {
|
|
136
|
+
/** Fold an `assistant` envelope in (parent-loop turns only — see {@link isSubagentEvent}). */
|
|
137
|
+
onAssistant(message: Record<string, unknown>): void
|
|
138
|
+
/** Fold a `user` turn's tool_result content in, against the call in flight. */
|
|
139
|
+
onToolResult(content: unknown[]): void
|
|
140
|
+
/** Publish the call still in flight. Idempotent; safe to call on both the clean and error path. */
|
|
141
|
+
flush(): void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Assemble ONE conversation's per-call telemetry from the CLI stream: the growing request
|
|
146
|
+
* transcript and the per-call token/body metrics.
|
|
147
|
+
*
|
|
148
|
+
* Owns the transcript because the two are one concern — a call's `promptText` is the transcript as
|
|
149
|
+
* of that call, and its turns may only be appended once the call that produced them is complete.
|
|
150
|
+
* `seed` is what the harness supplied and the stream therefore never shows (the system + first user
|
|
151
|
+
* message, or the single folded user turn), so the reconstruction never claims a system turn that
|
|
152
|
+
* was not sent. A subagent's conversation seeds EMPTY — its prompt was minted by the CLI and never
|
|
153
|
+
* crosses this stream — which is also why its first call carries `messageCount: 0` (the backend's
|
|
154
|
+
* `latestChainTip` skips those on purpose: there is no re-sendable chain to delta against).
|
|
155
|
+
* Bodies are credential-scrubbed; they can echo the leased token.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately does NOT touch {@link PiRunStats}: the run's tool/output counters describe whether
|
|
158
|
+
* the agent ACTED at all (`agentNeverActed`), which is true of a subagent's turns whichever channel
|
|
159
|
+
* ends up owning their telemetry rows. The caller accumulates them off the raw stream instead.
|
|
160
|
+
*/
|
|
161
|
+
export function createClaudeStreamTelemetry(opts: {
|
|
162
|
+
seed: TranscriptTurn[]
|
|
163
|
+
secrets: string[]
|
|
164
|
+
publish: (metric: HarnessCallMetric) => void
|
|
165
|
+
}): ClaudeStreamTelemetry {
|
|
166
|
+
const messages: TranscriptTurn[] = [...opts.seed]
|
|
167
|
+
let callPrompt = ''
|
|
168
|
+
let callMessageCount = 0
|
|
169
|
+
|
|
170
|
+
// The aggregator IS the surface: the transcript and metric work happens in its callbacks, so
|
|
171
|
+
// there is nothing to wrap it in.
|
|
172
|
+
return createClaudeCallAggregator({
|
|
173
|
+
// Snapshotted when a call's FIRST envelope arrives: the history at that moment is what
|
|
174
|
+
// produced the response, and later envelopes of the same call must not see the turns it
|
|
175
|
+
// went on to add.
|
|
176
|
+
onCallStart: () => {
|
|
177
|
+
callPrompt = redactBody(JSON.stringify(messages), opts.secrets)
|
|
178
|
+
callMessageCount = messages.length
|
|
179
|
+
},
|
|
180
|
+
onCall: (call) => {
|
|
181
|
+
opts.publish({
|
|
182
|
+
...(call.model ? { model: call.model } : {}),
|
|
183
|
+
promptText: callPrompt,
|
|
184
|
+
messageCount: callMessageCount,
|
|
185
|
+
responseText: redactBody(call.text, opts.secrets),
|
|
186
|
+
reasoningText: redactBody(call.reasoning, opts.secrets),
|
|
187
|
+
inputTokens: call.inputTokens,
|
|
188
|
+
cachedInputTokens: call.cachedInputTokens,
|
|
189
|
+
outputTokens: call.outputTokens,
|
|
190
|
+
finishReason: call.stopReason,
|
|
191
|
+
})
|
|
192
|
+
// Appended only now, so each call's prompt stays a strict prefix of the next and the
|
|
193
|
+
// backend's telemetry chain delta-compresses cleanly.
|
|
194
|
+
messages.push({ role: 'assistant', content: call.content })
|
|
195
|
+
for (const result of call.toolResults) messages.push({ role: 'tool', content: result })
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The dispatch (`Agent`/`Task` tool_use) id a stream envelope is tagged with, or `undefined` for a
|
|
202
|
+
* parent-loop turn.
|
|
203
|
+
*
|
|
204
|
+
* Claude Code streams the turns of the subagents it dispatches onto the parent's stdout, tagged
|
|
205
|
+
* with the tool_use id that spawned them. Those same turns are also written to the per-session
|
|
206
|
+
* `subagents/*.jsonl` transcripts the watcher reads, so recording both channels counted every
|
|
207
|
+
* subagent call twice — and splicing them into the parent's message reconstruction produced a
|
|
208
|
+
* `promptText` chain that interleaves several conversations and therefore matches no real request.
|
|
209
|
+
*
|
|
210
|
+
* The id is what makes the fallback below possible: concurrent subagents interleave on one stdout,
|
|
211
|
+
* so it is the ONLY thing separating their conversations.
|
|
212
|
+
*/
|
|
213
|
+
export function subagentDispatchId(event: Record<string, unknown>): string | undefined {
|
|
214
|
+
if (!isObject(event)) return undefined
|
|
215
|
+
const id = event.parent_tool_use_id
|
|
216
|
+
return typeof id === 'string' && id ? id : undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Whether a stream envelope describes a SUBAGENT's turn rather than the parent loop's. */
|
|
220
|
+
export function isSubagentEvent(event: Record<string, unknown>): boolean {
|
|
221
|
+
return subagentDispatchId(event) !== undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Per-call telemetry for the subagents whose turns ride the parent's stdout — the FALLBACK channel,
|
|
226
|
+
* used only when no `subagents/*.jsonl` watcher will run (see `startSubagentWatcher`, which is
|
|
227
|
+
* wired only when the CLI has an isolated config home; an `ambientAuth` run has none).
|
|
228
|
+
*
|
|
229
|
+
* Without this, filtering tagged events out of the parent's telemetry leaves a subagent-heavy run
|
|
230
|
+
* with its spend recorded by NEITHER channel — an under-count, which reads as a cheap run and is
|
|
231
|
+
* the worse failure direction than the double-count the filter exists to fix.
|
|
232
|
+
*
|
|
233
|
+
* Each dispatch id gets its OWN transcript, because concurrent subagents interleave arbitrarily on
|
|
234
|
+
* one stream: folding them into a single chain is exactly the defect this whole module removes,
|
|
235
|
+
* one level down.
|
|
236
|
+
*/
|
|
237
|
+
function createSubagentStreamTelemetry(opts: {
|
|
238
|
+
secrets: string[]
|
|
239
|
+
publish: (metric: HarnessCallMetric) => void
|
|
240
|
+
}): {
|
|
241
|
+
onAssistant(dispatchId: string, message: Record<string, unknown>): void
|
|
242
|
+
onToolResult(dispatchId: string, content: unknown[]): void
|
|
243
|
+
flush(): void
|
|
244
|
+
} {
|
|
245
|
+
const perDispatch = new Map<string, ClaudeStreamTelemetry>()
|
|
246
|
+
const forDispatch = (dispatchId: string): ClaudeStreamTelemetry => {
|
|
247
|
+
let telemetry = perDispatch.get(dispatchId)
|
|
248
|
+
if (!telemetry) {
|
|
249
|
+
// Seeded EMPTY: the CLI minted this subagent's prompt and it never crossed the stream.
|
|
250
|
+
telemetry = createClaudeStreamTelemetry({
|
|
251
|
+
seed: [],
|
|
252
|
+
secrets: opts.secrets,
|
|
253
|
+
publish: opts.publish,
|
|
254
|
+
})
|
|
255
|
+
perDispatch.set(dispatchId, telemetry)
|
|
256
|
+
}
|
|
257
|
+
return telemetry
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
onAssistant: (dispatchId, message) => forDispatch(dispatchId).onAssistant(message),
|
|
261
|
+
// Only against a dispatch already seen: a result for a subagent whose assistant turns never
|
|
262
|
+
// reached us has no conversation to attach to, and minting one would publish a call that is
|
|
263
|
+
// all tool output and no request.
|
|
264
|
+
onToolResult: (dispatchId, content) => perDispatch.get(dispatchId)?.onToolResult(content),
|
|
265
|
+
flush: () => {
|
|
266
|
+
for (const telemetry of perDispatch.values()) telemetry.flush()
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** All per-call telemetry for ONE claude-code run: the parent loop, and whoever bills the subagents. */
|
|
272
|
+
export interface ClaudeRunTelemetry {
|
|
273
|
+
/** Fold an `assistant` envelope in, routed by its dispatch tag (`undefined` ⇒ the parent loop). */
|
|
274
|
+
onAssistant(dispatchId: string | undefined, message: Record<string, unknown>): void
|
|
275
|
+
/** Fold a `user` turn's tool_result content in, against the same conversation. */
|
|
276
|
+
onToolResult(dispatchId: string | undefined, content: unknown[]): void
|
|
277
|
+
/** Publish every conversation's call in flight. Idempotent; safe on the clean and error paths. */
|
|
278
|
+
flush(): void
|
|
279
|
+
/**
|
|
280
|
+
* Subagent turns crossed the stream AND the watcher was the channel meant to record them — so a
|
|
281
|
+
* watcher that captured nothing means this run's subagent rows are simply missing.
|
|
282
|
+
*/
|
|
283
|
+
expectsWatcherCalls(): boolean
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Assemble a run's per-call telemetry, routing each envelope to the conversation it belongs to.
|
|
288
|
+
*
|
|
289
|
+
* The routing is the whole point. A subagent's turns ride the parent's stdout tagged with the
|
|
290
|
+
* dispatch that spawned them, and they must never join the PARENT's chain — that splice produced a
|
|
291
|
+
* `promptText` interleaving several conversations, matching no request that was ever sent.
|
|
292
|
+
*
|
|
293
|
+
* Who RECORDS them is a separate question, decided once per run rather than per event:
|
|
294
|
+
* `watcherOwnsSubagents` says a `subagents/*.jsonl` watcher will run, and it is the better source
|
|
295
|
+
* (it reads the settled transcript, so its usage and stop reason are final). With no watcher — an
|
|
296
|
+
* `ambientAuth` run has no isolated config home to watch — the tagged turns are recorded here
|
|
297
|
+
* instead, on per-dispatch transcripts of their own. Dropping them in that case would leave the run
|
|
298
|
+
* billed by neither channel, and an under-count reads as a cheap run rather than as an error.
|
|
299
|
+
*/
|
|
300
|
+
export function createClaudeRunTelemetry(opts: {
|
|
301
|
+
seed: TranscriptTurn[]
|
|
302
|
+
secrets: string[]
|
|
303
|
+
watcherOwnsSubagents: boolean
|
|
304
|
+
publish: (metric: HarnessCallMetric) => void
|
|
305
|
+
}): ClaudeRunTelemetry {
|
|
306
|
+
const parent = createClaudeStreamTelemetry(opts)
|
|
307
|
+
const subagents = opts.watcherOwnsSubagents ? undefined : createSubagentStreamTelemetry(opts)
|
|
308
|
+
let sawSubagentTurn = false
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
onAssistant(dispatchId, message) {
|
|
312
|
+
if (!dispatchId) return parent.onAssistant(message)
|
|
313
|
+
sawSubagentTurn = true
|
|
314
|
+
subagents?.onAssistant(dispatchId, message)
|
|
315
|
+
},
|
|
316
|
+
onToolResult(dispatchId, content) {
|
|
317
|
+
if (!dispatchId) return parent.onToolResult(content)
|
|
318
|
+
sawSubagentTurn = true
|
|
319
|
+
subagents?.onToolResult(dispatchId, content)
|
|
320
|
+
},
|
|
321
|
+
flush() {
|
|
322
|
+
parent.flush()
|
|
323
|
+
subagents?.flush()
|
|
324
|
+
},
|
|
325
|
+
expectsWatcherCalls: () => opts.watcherOwnsSubagents && sawSubagentTurn,
|
|
326
|
+
}
|
|
327
|
+
}
|