@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.
- package/README.md +64 -49
- package/dist/agent-capabilities.d.ts +20 -3
- package/dist/agent-capabilities.js +32 -4
- package/dist/agent-runner.d.ts +8 -1
- package/dist/agent-runner.js +27 -0
- package/dist/job.d.ts +2 -0
- package/dist/job.js +6 -0
- package/dist/pi-workspace.js +3 -0
- package/dist/pi.d.ts +26 -5
- package/dist/pi.js +18 -13
- package/dist/progress-guard.d.ts +29 -0
- package/dist/progress-guard.js +82 -8
- package/dist/tool-trajectory.d.ts +78 -0
- package/dist/tool-trajectory.js +194 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +34 -4
- package/src/agent-runner.ts +48 -0
- package/src/job.ts +6 -0
- package/src/pi-workspace.ts +3 -0
- package/src/pi.ts +53 -18
- package/src/progress-guard.ts +129 -10
- package/src/tool-trajectory.ts +239 -0
|
@@ -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
|
+
}
|