@cat-factory/executor-harness 1.88.0 → 1.92.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.
@@ -0,0 +1,239 @@
1
+ import { redact } from './redact.js'
2
+
3
+ // The TRAJECTORY capture: what the agent DID, one entry per tool call, in the order it made them.
4
+ //
5
+ // The harness has always buffered a compact span per tool call (name + timing + ok) for the run's
6
+ // trace. That is enough to draw a tree and not enough to answer the question anyone actually asks
7
+ // of a finished run — WHICH command, against WHAT, and what came back. The evidence standard for a
8
+ // merged PR is "how, not just the diff", and a span saying `bash` ran for 300ms is not evidence of
9
+ // anything. So each entry now carries the call's arguments and result, captured here because this
10
+ // is the only process that ever sees them: an agent CLI's tool loop is internal to the CLI, and
11
+ // the container is gone the moment the job settles.
12
+ //
13
+ // Two properties keep that affordable and safe:
14
+ //
15
+ // - **Bounded at capture.** Each body is capped (a build log is routinely megabytes) and the entry
16
+ // STATES what the cap dropped, so a reader can tell a short command from the head of a long one.
17
+ // The caps are what keep the drain buffer and the poll response small.
18
+ // - **Scrubbed at capture.** A tool's arguments and output routinely echo an env var, a clone URL
19
+ // or the leased subscription token, and these travel to a store and to external trace sinks.
20
+ //
21
+ // The RETENTION decision is the backend's, not this module's: entries carry `bodies: 'stored'` and
22
+ // the backend's double gate (`LLM_RECORD_PROMPTS` + the workspace's `storeAgentContext`) decides
23
+ // whether to keep them, exactly as it already does for the prompt bodies the call-metric
24
+ // reconstruction assembles here. Deciding it twice would mean the container had to be told the
25
+ // workspace's settings, and an image one release behind its backend would then be deciding it with
26
+ // stale ones.
27
+
28
+ /** Cap on a captured argument blob. Generous for a command line, far below a file body. */
29
+ export const MAX_TOOL_ARGS_CHARS = 2 * 1024
30
+ /** Cap on a captured result. Larger than the args cap: a result is where the bytes actually are. */
31
+ export const MAX_TOOL_RESULT_CHARS = 4 * 1024
32
+
33
+ /** A captured body plus what the cap dropped from it. */
34
+ export interface CapturedToolBody {
35
+ text: string
36
+ dropped: number
37
+ }
38
+
39
+ const EMPTY: CapturedToolBody = { text: '', dropped: 0 }
40
+
41
+ /**
42
+ * Serialise, scrub and cap one tool body.
43
+ *
44
+ * A non-string value is JSON-serialised, and a value that cannot be (a cycle, a `BigInt`, a
45
+ * throwing getter) is NAMED as unserialisable rather than dropped to `''`: an empty body means
46
+ * "the call carried none", and a capture failure is a different fact.
47
+ */
48
+ export function captureToolBody(
49
+ value: unknown,
50
+ max: number,
51
+ secrets: readonly string[],
52
+ ): CapturedToolBody {
53
+ if (value === undefined || value === null) return EMPTY
54
+ let text: string
55
+ if (typeof value === 'string') {
56
+ text = value
57
+ } else {
58
+ try {
59
+ text = JSON.stringify(value) ?? ''
60
+ } catch {
61
+ return { text: '[unserialisable]', dropped: 0 }
62
+ }
63
+ }
64
+ if (text === '') return EMPTY
65
+ const scrubbed = redact(text, secrets)
66
+ if (scrubbed.length <= max) return { text: scrubbed, dropped: 0 }
67
+ return { text: scrubbed.slice(0, max), dropped: scrubbed.length - max }
68
+ }
69
+
70
+ /** One tool call the tracker is holding open, between its start and its result. */
71
+ interface PendingCall {
72
+ tool: string
73
+ startedAt: number
74
+ args: CapturedToolBody
75
+ }
76
+
77
+ /** What {@link ToolCallTracker} emits per completed call — the fields a `ToolSpan` needs. */
78
+ export interface TrackedToolCall {
79
+ tool: string
80
+ seq: number
81
+ startedAt: number
82
+ endedAt: number
83
+ ok: boolean
84
+ args: string
85
+ result: string
86
+ argsDropped: number
87
+ resultDropped: number
88
+ }
89
+
90
+ /**
91
+ * Pairs each tool call's START with its RESULT and numbers the pairs, so the two agent CLIs feed
92
+ * one trajectory shape from two very different streams (Pi's flat `tool_execution_*` events, the
93
+ * claude-code stream's `tool_use` / `tool_result` content blocks).
94
+ *
95
+ * Correlation is BY ID where the stream supplies one, and by tool name otherwise, because that is
96
+ * the difference between the two producers: claude-code's blocks always carry a `tool_use_id`, and
97
+ * a parallel batch of calls is routine there, while Pi's stream is sequential. A result the tracker
98
+ * cannot pair with a start is still EMITTED (with no args and the previous call's end as its
99
+ * start): losing a step of the trajectory to a schema tweak would be worse than an entry that says
100
+ * less than its neighbours, and the `seq` it takes keeps every later entry's ordinal honest.
101
+ */
102
+ export class ToolCallTracker {
103
+ private seq = 0
104
+ private readonly byId = new Map<string, PendingCall>()
105
+ private readonly byTool = new Map<string, PendingCall[]>()
106
+ /**
107
+ * Start boundary for a call whose own start was never seen: the previous call's end, or the run
108
+ * start. Approximate but contiguous, which is the property the trace tree needs.
109
+ */
110
+ private boundary: number
111
+
112
+ constructor(
113
+ private readonly secrets: readonly string[] = [],
114
+ now: number = Date.now(),
115
+ ) {
116
+ this.boundary = now
117
+ }
118
+
119
+ /** Record that a call began, with the arguments the agent supplied. */
120
+ started(id: string | undefined, tool: string, args: unknown, at: number = Date.now()): void {
121
+ const pending: PendingCall = {
122
+ tool,
123
+ startedAt: at,
124
+ args: captureToolBody(args, MAX_TOOL_ARGS_CHARS, this.secrets),
125
+ }
126
+ if (id) {
127
+ this.byId.set(id, pending)
128
+ return
129
+ }
130
+ const queue = this.byTool.get(tool) ?? []
131
+ queue.push(pending)
132
+ this.byTool.set(tool, queue)
133
+ }
134
+
135
+ /** Record that a call finished, and return the completed entry. */
136
+ finished(
137
+ id: string | undefined,
138
+ tool: string,
139
+ result: unknown,
140
+ isError: boolean,
141
+ at: number = Date.now(),
142
+ ): TrackedToolCall {
143
+ const pending = this.take(id, tool)
144
+ const captured = captureToolBody(result, MAX_TOOL_RESULT_CHARS, this.secrets)
145
+ const call: TrackedToolCall = {
146
+ tool: pending?.tool ?? tool,
147
+ seq: this.seq++,
148
+ startedAt: pending?.startedAt ?? this.boundary,
149
+ endedAt: at,
150
+ ok: !isError,
151
+ args: pending?.args.text ?? '',
152
+ argsDropped: pending?.args.dropped ?? 0,
153
+ result: captured.text,
154
+ resultDropped: captured.dropped,
155
+ }
156
+ this.boundary = at
157
+ return call
158
+ }
159
+
160
+ private take(id: string | undefined, tool: string): PendingCall | undefined {
161
+ if (id) {
162
+ const byId = this.byId.get(id)
163
+ if (byId) {
164
+ this.byId.delete(id)
165
+ return byId
166
+ }
167
+ }
168
+ const queue = this.byTool.get(tool)
169
+ // FIFO, so a sequential stream pairs each result with the call that opened first. A stream
170
+ // that interleaves un-idded calls of one tool would mis-pair here; none of the two producers
171
+ // does, and the alternative (refusing to pair at all) loses the args on every ordinary run.
172
+ const pending = queue?.shift()
173
+ if (queue && queue.length === 0) this.byTool.delete(tool)
174
+ return pending
175
+ }
176
+ }
177
+
178
+ /** Read a tool-call id off a stream event, whatever the producer calls it. */
179
+ export function readToolCallId(event: Record<string, unknown>): string | undefined {
180
+ for (const key of ['toolCallId', 'tool_call_id', 'callId', 'id']) {
181
+ const value = event[key]
182
+ if (typeof value === 'string' && value !== '') return value
183
+ }
184
+ return undefined
185
+ }
186
+
187
+ /** A tool call BEGINNING, read off a Pi `--mode json` event, or undefined if it isn't one. */
188
+ export function toolCallStart(
189
+ event: Record<string, unknown>,
190
+ ): { id?: string; name: string; args: unknown } | undefined {
191
+ if (event.type !== 'tool_execution_start' && event.type !== 'tool_call') return undefined
192
+ const name = typeof event.toolName === 'string' ? event.toolName : ''
193
+ if (!name) return undefined
194
+ // Read every spelling the stream might use rather than pinning one: Pi's event schema has been
195
+ // renamed under us before (see `todoResultDetails`, which reads three shapes of one result), and
196
+ // a rename here costs the ARGUMENTS of every call, silently.
197
+ const args = event.args ?? event.arguments ?? event.input ?? event.parameters
198
+ const id = readToolCallId(event)
199
+ return { name, args, ...(id ? { id } : {}) }
200
+ }
201
+
202
+ /** The RESULT payload of a Pi `tool_execution_end` event, unwrapped from its envelope. */
203
+ export function toolCallResult(event: Record<string, unknown>): unknown {
204
+ const result = event.result
205
+ if (result && typeof result === 'object') {
206
+ const inner = result as Record<string, unknown>
207
+ // `details` is the structured payload Pi's own extensions return (the todo tool's shape), and
208
+ // `content`/`output` the free-text ones. Falling back to the whole envelope keeps a shape none
209
+ // of these match readable rather than empty.
210
+ return inner.details ?? inner.content ?? inner.output ?? result
211
+ }
212
+ return result ?? event.output ?? event.content
213
+ }
214
+
215
+ /**
216
+ * Feed one claude-code `user` turn's content blocks to the tracker, emitting an entry per
217
+ * `tool_result`.
218
+ *
219
+ * The CLI answers each `tool_use` with a `tool_result` carrying the same `tool_use_id`, so the
220
+ * pairing is exact even when the model fired a batch of calls in parallel — which it routinely
221
+ * does, and which is why the trajectory here is ordered by the ordinal the tracker stamps rather
222
+ * than by the turn the results arrived on.
223
+ */
224
+ export function recordClaudeToolResults(
225
+ tracker: ToolCallTracker,
226
+ content: readonly unknown[],
227
+ emit: (call: TrackedToolCall) => void,
228
+ ): void {
229
+ for (const block of content) {
230
+ if (!block || typeof block !== 'object') continue
231
+ const record = block as Record<string, unknown>
232
+ if (record.type !== 'tool_result') continue
233
+ const id = typeof record.tool_use_id === 'string' ? record.tool_use_id : undefined
234
+ // The block carries no tool NAME (only the id the assistant turn named), so an unpaired
235
+ // result falls back to a stated placeholder rather than an empty string that would render
236
+ // as a nameless step.
237
+ emit(tracker.finished(id, 'unknown', record.content, record.is_error === true))
238
+ }
239
+ }