@cat-factory/executor-harness 1.96.0 → 1.100.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 (46) hide show
  1. package/README.md +5 -1
  2. package/dist/agent-capabilities.js +25 -11
  3. package/dist/agent-runner.d.ts +14 -1
  4. package/dist/agent-runner.js +84 -28
  5. package/dist/bootstrap-mode.js +1 -0
  6. package/dist/coding-agent.d.ts +2 -1
  7. package/dist/embed.d.ts +2 -1
  8. package/dist/embed.js +2 -1
  9. package/dist/failure.d.ts +19 -1
  10. package/dist/failure.js +40 -0
  11. package/dist/git.d.ts +6 -0
  12. package/dist/git.js +16 -9
  13. package/dist/inline.d.ts +6 -0
  14. package/dist/inline.js +6 -0
  15. package/dist/job.d.ts +2 -1
  16. package/dist/jsonl-stream.d.ts +70 -0
  17. package/dist/jsonl-stream.js +149 -0
  18. package/dist/pi-reduction.d.ts +136 -0
  19. package/dist/pi-reduction.js +303 -0
  20. package/dist/pi-workspace.d.ts +2 -1
  21. package/dist/pi-workspace.js +6 -1
  22. package/dist/pi.d.ts +8 -81
  23. package/dist/pi.js +124 -310
  24. package/dist/runner.d.ts +31 -0
  25. package/dist/runner.js +50 -3
  26. package/dist/structured-output.js +2 -1
  27. package/dist/tool-silence.d.ts +74 -0
  28. package/dist/tool-silence.js +99 -0
  29. package/package.json +4 -4
  30. package/src/agent-capabilities.ts +22 -13
  31. package/src/agent-runner.ts +100 -30
  32. package/src/agent.ts +1 -1
  33. package/src/bootstrap-mode.ts +2 -1
  34. package/src/coding-agent.ts +2 -1
  35. package/src/embed.ts +8 -5
  36. package/src/failure.ts +36 -9
  37. package/src/git.ts +17 -9
  38. package/src/inline.ts +6 -0
  39. package/src/job.ts +2 -1
  40. package/src/jsonl-stream.ts +149 -0
  41. package/src/pi-reduction.ts +359 -0
  42. package/src/pi-workspace.ts +7 -3
  43. package/src/pi.ts +144 -349
  44. package/src/runner.ts +91 -4
  45. package/src/structured-output.ts +2 -1
  46. package/src/tool-silence.ts +125 -0
@@ -0,0 +1,359 @@
1
+ import { BoundedTail } from './jsonl-stream.js'
2
+
3
+ // Reducing a Pi `--mode json` event stream to what the run PRODUCED: the assistant's answer, what
4
+ // it actually did, its output-quality signals, and whether it ended in a hard error.
5
+ //
6
+ // WHY THIS IS ITS OWN MODULE — every one of these answers used to be computed by scanning a
7
+ // retained array of every record the run emitted, and `runPi` held that array for the whole run
8
+ // (stuck-run audit F6). Bounding the JSONL FRAMING while retaining an unbounded number of parsed
9
+ // records only moves the heap-exhaustion mode: a parsed object is typically larger than the raw
10
+ // text it replaced, and a container that OOMs is another way for a job to stop answering polls
11
+ // with no watchdog having fired.
12
+ //
13
+ // So the reduction FOLDS instead. {@link PiRunReducer} observes each record as it streams and
14
+ // retains only what the close-of-run answers actually need: the terminal record, the one
15
+ // transcript they read, running counters, and a bounded tail of streamed assistant text. Its
16
+ // memory is O(largest single record), not O(records).
17
+ //
18
+ // The array-shaped entry points below (used by offline tooling over a captured stdout) are
19
+ // DEFINED in terms of the same reducer rather than keeping their own scans, so the live path and
20
+ // the offline one cannot drift into disagreeing about what a run produced.
21
+
22
+ /**
23
+ * Per-completion output-token ceiling Pi requests (its model-entry `maxTokens`).
24
+ * Generous on purpose: a reasoning model (e.g. GLM-5.2) spends tokens on its
25
+ * `<think>` trace before the answer + tool calls, so a tight cap truncates it
26
+ * mid-reasoning and the agent never commits edits. It is a ceiling, not a target
27
+ * — unused output tokens are not billed and Workers AI clamps the request to the
28
+ * model's real max — so erring high is safe. Raised to 32k after a spec-writer run
29
+ * truncated an intermediate tool call at the old 16k cap; the document itself
30
+ * stopped well under it, so this is headroom for larger specs/diffs, with
31
+ * {@link runDiagnostics} flagging the rare case where even 32k is not enough.
32
+ */
33
+ export const PI_MAX_OUTPUT_TOKENS = 32_768
34
+
35
+ /**
36
+ * How much streamed assistant text the fallback summary holds when a run emitted no terminal
37
+ * transcript. Far above any real answer, because this is a bound on a runaway producer rather
38
+ * than a size policy — and what it drops is REPORTED (see {@link PiRunReducer.reduce}), since a
39
+ * tail read as a whole answer would look like a model that stopped mid-sentence.
40
+ */
41
+ const FALLBACK_SUMMARY_CHARS = 256 * 1024
42
+
43
+ export function isObject(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === 'object' && value !== null
45
+ }
46
+
47
+ /**
48
+ * What the agent actually did this run, independent of any file changes. Used to
49
+ * tell a genuine no-op (the agent never reached the model / never acted) apart
50
+ * from a real run, so a bootstrap that produced nothing is failed rather than
51
+ * pushed as an empty repo. `toolCalls === 0 && assistantChars === 0` is the
52
+ * signature of a run where Pi never made a successful model call.
53
+ */
54
+ export interface PiRunStats {
55
+ /** Tool calls the assistant emitted across the transcript (0 ⇒ it never acted). */
56
+ toolCalls: number
57
+ /** Total characters of assistant text (0 ⇒ the model produced nothing). */
58
+ assistantChars: number
59
+ }
60
+
61
+ /**
62
+ * Output-quality signals lifted from the agent's transcript, so the harness can fail
63
+ * LOUDLY on a malformed run instead of silently handing a half-baked artifact to the
64
+ * structured-output repair (which would manufacture a doc from garbage — the trap
65
+ * behind the spec-writer ⇄ companion rework loop). Two distinct invalid states, both
66
+ * seen in production from `kimi-k2.7-code`:
67
+ * - a completion that hit the output ceiling (its answer/tool call was cut off), and
68
+ * - a FINAL turn that carried no text at all (an empty `content: []` despite spending
69
+ * output tokens), so there is no answer to parse.
70
+ */
71
+ export interface RunDiagnostics {
72
+ /** Some completion ended at the output-token ceiling — its content was cut off. */
73
+ truncated: boolean
74
+ /** The agent's FINAL completion hit the ceiling: its ANSWER (not a mid-run step) was cut off. */
75
+ finalTruncated: boolean
76
+ /** The agent's final turn carried no text content (e.g. an empty `content: []`). */
77
+ finalAnswerEmpty: boolean
78
+ }
79
+
80
+ /** What a Pi run's event stream reduces to (the run's product, before any process-level detail). */
81
+ export interface PiRunReduction {
82
+ summary: string
83
+ stats: PiRunStats
84
+ diagnostics: RunDiagnostics
85
+ }
86
+
87
+ /**
88
+ * Folds a Pi event stream into {@link PiRunReduction} plus the run's terminal-failure signal.
89
+ *
90
+ * Feed every parsed record to {@link observe} in stream order, then read the answers at close.
91
+ * What it keeps, and why that is all of it:
92
+ * - the LAST `agent_end` / `auto_retry_end` record, which is exactly what a scan-from-the-end
93
+ * for the terminal signal would have stopped on;
94
+ * - the LAST `agent_end` transcript, the canonical source for the summary, the stats and the
95
+ * diagnostics alike (all three scanned back to the same record);
96
+ * - running counters and a bounded text tail, which are the FALLBACKS those three use when a
97
+ * run emitted no terminal transcript at all.
98
+ */
99
+ export class PiRunReducer {
100
+ /** The last terminal record seen (`agent_end` or `auto_retry_end`), whichever came last. */
101
+ private terminal: Record<string, unknown> | undefined
102
+ /** Messages of the last `agent_end` that carried a transcript. */
103
+ private transcript: unknown[] | undefined
104
+ private streamedToolCalls = 0
105
+ private streamedToolResults = 0
106
+ private streamedAssistantChars = 0
107
+ private readonly streamedText = new BoundedTail(FALLBACK_SUMMARY_CHARS)
108
+
109
+ /** Fold one parsed record. */
110
+ observe(event: Record<string, unknown>): void {
111
+ const type = event.type
112
+ if (type === 'agent_end' || type === 'auto_retry_end') {
113
+ this.terminal = event
114
+ if (type === 'agent_end' && Array.isArray(event.messages)) {
115
+ this.transcript = event.messages as unknown[]
116
+ }
117
+ return
118
+ }
119
+ if (type === 'tool_execution_end') {
120
+ this.streamedToolCalls++
121
+ return
122
+ }
123
+ if (type === 'message_end' && isObject(event.message)) {
124
+ const message = event.message
125
+ if (message.role === 'assistant') {
126
+ const text = messageText(message)
127
+ this.streamedAssistantChars += text.length
128
+ if (text) this.streamedText.push(this.streamedText.totalChars ? `\n${text}` : text)
129
+ } else if (message.role === 'toolResult') {
130
+ this.streamedToolResults++
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Whether the run emitted a terminal record at all. False means {@link terminalError} answered
137
+ * from nothing rather than from a clean ending, which a caller deciding whether the run
138
+ * SUCCEEDED has to tell apart (see `runPi`'s exit-0 path).
139
+ */
140
+ get sawTerminalRecord(): boolean {
141
+ return this.terminal !== undefined
142
+ }
143
+
144
+ /**
145
+ * The terminal-failure message when the run ended in a hard error (the model was unreachable /
146
+ * refused, and Pi exhausted its auto-retries), else undefined. Only the FINAL outcome counts: a
147
+ * mid-run hiccup the agent recovered from leaves a clean terminal `agent_end`.
148
+ */
149
+ terminalError(): string | undefined {
150
+ const e = this.terminal
151
+ if (!e) return undefined
152
+ if (e.type === 'auto_retry_end') {
153
+ if (e.success === false) {
154
+ return typeof e.finalError === 'string'
155
+ ? e.finalError
156
+ : 'the agent failed after exhausting its retries'
157
+ }
158
+ return undefined
159
+ }
160
+ return e.stopReason === 'error' && typeof e.errorMessage === 'string'
161
+ ? e.errorMessage
162
+ : undefined
163
+ }
164
+
165
+ /**
166
+ * The run's product. `stdoutTail` backs the last-resort summary for a run whose output matched
167
+ * nothing structured, and a TAIL is all that fallback ever wanted: it slices the final 2 KB.
168
+ */
169
+ reduce(stdoutTail: string, cap: number = PI_MAX_OUTPUT_TOKENS): PiRunReduction {
170
+ return {
171
+ summary: this.summary(stdoutTail),
172
+ stats: this.stats(),
173
+ diagnostics: this.diagnostics(cap),
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Preferred: the last assistant message of the terminal transcript. Falls back to the streamed
179
+ * assistant text, then to a raw tail, so a schema tweak never loses output.
180
+ */
181
+ private summary(stdoutTail: string): string {
182
+ if (this.transcript) {
183
+ const text = lastAssistantText(this.transcript)
184
+ if (text) return text
185
+ }
186
+ const streamed = this.streamedText.toString().trim()
187
+ if (streamed) {
188
+ const dropped = this.streamedText.droppedChars
189
+ // Say so when this is a tail rather than the whole answer: a reader who took it for a
190
+ // prefix would conclude the model stopped where the text begins.
191
+ return dropped > 0
192
+ ? `[earlier assistant output omitted: ${dropped} characters]\n${streamed}`
193
+ : streamed
194
+ }
195
+ return stdoutTail.trim().slice(-2000)
196
+ }
197
+
198
+ /**
199
+ * Count what the agent actually did. Prefers the terminal transcript (assistant `toolCall`
200
+ * parts + text); falls back to the streamed `tool_execution_end` / `message_end` counters, so a
201
+ * no-op is never mistaken for a real run because of a schema tweak.
202
+ */
203
+ private stats(): PiRunStats {
204
+ if (this.transcript) return statsFromMessages(this.transcript)
205
+ return {
206
+ // The same call can surface as both a `tool_execution_end` and a toolResult `message_end`;
207
+ // prefer the former and only fall back to toolResult counts.
208
+ toolCalls: this.streamedToolCalls || this.streamedToolResults,
209
+ assistantChars: this.streamedAssistantChars,
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Output-quality signals over the terminal transcript: whether any completion hit the output
215
+ * ceiling (its content was cut off), whether the FINAL completion did, and whether that final
216
+ * turn carried no text at all. Defaults to all-false with no terminal transcript (a no-op run
217
+ * is already caught by `agentNeverActed`).
218
+ *
219
+ * `cap` is the per-completion ceiling Pi requested ({@link PI_MAX_OUTPUT_TOKENS}); truncation is
220
+ * detected by an assistant message whose `usage.output` reached it, which is reliable even when
221
+ * the model reports a non-`length` stop reason (Workers AI labelled a cut-off tool call
222
+ * `tool_calls`, not `length`).
223
+ */
224
+ private diagnostics(cap: number): RunDiagnostics {
225
+ if (!this.transcript) {
226
+ return { truncated: false, finalTruncated: false, finalAnswerEmpty: false }
227
+ }
228
+ const assistants = this.transcript.filter(
229
+ (m): m is Record<string, unknown> => isObject(m) && m.role === 'assistant',
230
+ )
231
+ const last = assistants.at(-1)
232
+ return {
233
+ truncated: assistants.some((m) => assistantOutputTokens(m) >= cap),
234
+ finalTruncated: last ? assistantOutputTokens(last) >= cap : false,
235
+ finalAnswerEmpty: last ? messageText(last) === '' : false,
236
+ }
237
+ }
238
+ }
239
+
240
+ /** Fold an already-parsed event array through a fresh {@link PiRunReducer}. */
241
+ function reducerOver(events: Record<string, unknown>[]): PiRunReducer {
242
+ const reducer = new PiRunReducer()
243
+ for (const event of events) reducer.observe(event)
244
+ return reducer
245
+ }
246
+
247
+ /** Parse Pi's LF-framed JSONL stdout into its event records, skipping noise. */
248
+ export function parsePiEvents(stdout: string): Record<string, unknown>[] {
249
+ const events: Record<string, unknown>[] = []
250
+ for (const line of stdout.split('\n')) {
251
+ const trimmed = line.trim()
252
+ if (!trimmed.startsWith('{')) continue
253
+ try {
254
+ events.push(JSON.parse(trimmed) as Record<string, unknown>)
255
+ } catch {
256
+ // Skip a corrupted/truncated record; the surrounding stream is still usable.
257
+ }
258
+ }
259
+ return events
260
+ }
261
+
262
+ /** {@link PiRunReducer.terminalError} over Pi's raw `--mode json` stdout. */
263
+ export function terminalRunError(stdout: string): string | undefined {
264
+ return terminalErrorFromEvents(parsePiEvents(stdout))
265
+ }
266
+
267
+ /** {@link PiRunReducer.terminalError} over records already parsed from the stream. */
268
+ export function terminalErrorFromEvents(events: Record<string, unknown>[]): string | undefined {
269
+ return reducerOver(events).terminalError()
270
+ }
271
+
272
+ /** {@link PiRunReducer.reduce} over Pi's raw `--mode json` stdout. */
273
+ export function summarizePiRun(stdout: string): PiRunReduction {
274
+ return summarizeFromEvents(parsePiEvents(stdout), stdout)
275
+ }
276
+
277
+ /** {@link PiRunReducer.reduce} over records already parsed from the stream. */
278
+ export function summarizeFromEvents(
279
+ events: Record<string, unknown>[],
280
+ stdoutTail: string,
281
+ ): PiRunReduction {
282
+ return reducerOver(events).reduce(stdoutTail)
283
+ }
284
+
285
+ /** {@link RunDiagnostics} over records already parsed from the stream. */
286
+ export function diagnosticsFromEvents(
287
+ events: Record<string, unknown>[],
288
+ cap: number = PI_MAX_OUTPUT_TOKENS,
289
+ ): RunDiagnostics {
290
+ return reducerOver(events).reduce('', cap).diagnostics
291
+ }
292
+
293
+ /** {@link RunDiagnostics} over Pi's raw `--mode json` stdout. */
294
+ export function runDiagnostics(stdout: string, cap: number = PI_MAX_OUTPUT_TOKENS): RunDiagnostics {
295
+ return diagnosticsFromEvents(parsePiEvents(stdout), cap)
296
+ }
297
+
298
+ /**
299
+ * Extract the assistant's final summary from Pi's JSON-lines output. Pi emits a terminal
300
+ * `agent_end` event whose `messages` is the full transcript, so the last assistant message there
301
+ * is the canonical answer (see {@link PiRunReducer.reduce} for the fallbacks).
302
+ */
303
+ export function parsePiOutput(stdout: string): string {
304
+ return summarizePiRun(stdout).summary
305
+ }
306
+
307
+ /** `usage.output` (completion tokens) reported on a Pi assistant message, or 0. */
308
+ function assistantOutputTokens(message: Record<string, unknown>): number {
309
+ const usage = message.usage
310
+ if (!isObject(usage)) return 0
311
+ const output = usage.output
312
+ return typeof output === 'number' ? output : 0
313
+ }
314
+
315
+ /** {@link PiRunStats} from a transcript: assistant `toolCall` parts + text length. */
316
+ function statsFromMessages(messages: unknown[]): PiRunStats {
317
+ let toolCalls = 0
318
+ let assistantChars = 0
319
+ for (const m of messages) {
320
+ if (!isObject(m) || m.role !== 'assistant') continue
321
+ const content = m.content
322
+ if (typeof content === 'string') {
323
+ assistantChars += content.trim().length
324
+ } else if (Array.isArray(content)) {
325
+ for (const part of content) {
326
+ if (!isObject(part)) continue
327
+ if (part.type === 'toolCall') toolCalls++
328
+ else if (typeof part.text === 'string') assistantChars += part.text.length
329
+ }
330
+ }
331
+ }
332
+ return { toolCalls, assistantChars }
333
+ }
334
+
335
+ /** The text of the last assistant message in a transcript, or '' if none. */
336
+ function lastAssistantText(messages: unknown[]): string {
337
+ for (let i = messages.length - 1; i >= 0; i--) {
338
+ const m = messages[i]
339
+ if (isObject(m) && m.role === 'assistant') {
340
+ const text = messageText(m)
341
+ if (text) return text
342
+ }
343
+ }
344
+ return ''
345
+ }
346
+
347
+ /** Join the text parts of a Pi message whose content is a string or parts array. */
348
+ export function messageText(message: unknown): string {
349
+ if (!isObject(message)) return ''
350
+ const content = message.content
351
+ if (typeof content === 'string') return content.trim()
352
+ if (Array.isArray(content)) {
353
+ return content
354
+ .map((part) => (isObject(part) && typeof part.text === 'string' ? part.text : ''))
355
+ .join('')
356
+ .trim()
357
+ }
358
+ return ''
359
+ }
@@ -8,8 +8,6 @@ import { log } from './logger.js'
8
8
  import {
9
9
  type ContextFileInfo,
10
10
  type PiRunOutcome,
11
- type PiRunStats,
12
- type RunDiagnostics,
13
11
  CONTEXT_DIR,
14
12
  materializeContextFiles,
15
13
  materializeSkillResources,
@@ -21,6 +19,7 @@ import {
21
19
  writePiModelsConfig,
22
20
  writeWebToolsConfig,
23
21
  } from './pi.js'
22
+ import type { PiRunStats, RunDiagnostics } from './pi-reduction.js'
24
23
  import {
25
24
  type ProgressGuardLimits,
26
25
  mergeGuardLimits,
@@ -328,6 +327,10 @@ export async function runAgentInWorkspace(
328
327
  // The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
329
328
  // and a proxied one produce the same evidence rather than one of them producing none.
330
329
  onSpan: opts.onSpan,
330
+ // The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
331
+ // Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
332
+ // each can beat the window it opens.
333
+ beginToolWindow: opts.beginToolWindow,
331
334
  // Per-slice review capture, so a parallel review's finished slices are persisted as they
332
335
  // land rather than only in the terminal output. Only the subscription runners fan work out
333
336
  // across subagents, so this is the only path that can produce it.
@@ -384,7 +387,7 @@ export async function runAgentInWorkspace(
384
387
  model: spec.model,
385
388
  proxyBaseUrl: phasedProxyBaseUrl(proxyBaseUrl, opts.currentPhase?.(), spec.proxyPhasePath),
386
389
  })
387
- const { signal, onActivity, onProgress, onSpan } = opts
390
+ const { signal, onActivity, onProgress, onSpan, beginToolWindow } = opts
388
391
  const piOutcome = await runPi({
389
392
  cwd: spec.dir,
390
393
  model: spec.model,
@@ -394,6 +397,7 @@ export async function runAgentInWorkspace(
394
397
  onActivity,
395
398
  onProgress,
396
399
  onSpan,
400
+ beginToolWindow,
397
401
  expectsEdits: spec.expectsEdits ?? true,
398
402
  // Start from the env/built-in defaults and apply only the per-knob overrides the
399
403
  // backend set for this kind (loosen-only), so an unspecified knob keeps its default.