@cat-factory/executor-harness 1.96.0 → 1.98.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.
@@ -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.
package/src/pi.ts CHANGED
@@ -20,6 +20,15 @@ import {
20
20
  toolCallResult,
21
21
  toolCallStart,
22
22
  } from './tool-trajectory.js'
23
+ import { BoundedTail, JsonlLineReader } from './jsonl-stream.js'
24
+ import {
25
+ PI_MAX_OUTPUT_TOKENS,
26
+ PiRunReducer,
27
+ isObject,
28
+ type PiRunStats,
29
+ type RunDiagnostics,
30
+ } from './pi-reduction.js'
31
+ import { NO_TOOL_WINDOW, type ToolProgressWindow } from './tool-silence.js'
23
32
 
24
33
  // Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
25
34
  // proxy via a custom provider in ~/.pi/agent/models.json, authenticated with the
@@ -27,17 +36,13 @@ import {
27
36
  // ever lives in the image or in Pi's config on disk.
28
37
 
29
38
  /**
30
- * Per-completion output-token ceiling Pi requests (its model-entry `maxTokens`).
31
- * Generous on purpose: a reasoning model (e.g. GLM-5.2) spends tokens on its
32
- * `<think>` trace before the answer + tool calls, so a tight cap truncates it
33
- * mid-reasoning and the agent never commits edits. It is a ceiling, not a target
34
- * unused output tokens are not billed and Workers AI clamps the request to the
35
- * model's real max — so erring high is safe. Raised to 32k after a spec-writer run
36
- * truncated an intermediate tool call at the old 16k cap; the document itself
37
- * stopped well under it, so this is headroom for larger specs/diffs, with
38
- * {@link runDiagnostics} flagging the rare case where even 32k is not enough.
39
+ * How much of Pi's raw stdout/stderr the run holds for diagnostics. Every consumer takes a tail
40
+ * of it (2 KB for the last-resort summary, 1.5 KB for a stderr quote, 500 B for a crash detail),
41
+ * so this is generous headroom over the largest of them rather than a number anything depends
42
+ * on. What it replaces is retaining the WHOLE of a chatty run's output to slice 2 KB off the end
43
+ * (stuck-run audit F6).
39
44
  */
40
- export const PI_MAX_OUTPUT_TOKENS = 32_768
45
+ const OUTPUT_TAIL_CHARS = 64 * 1024
41
46
 
42
47
  /**
43
48
  * Longest phase label the backend keeps. Mirrors kernel's `MAX_PHASE_CHARS`; see
@@ -542,43 +547,6 @@ export interface ToolSpan {
542
547
  resultDropped: number
543
548
  }
544
549
 
545
- function isObject(value: unknown): value is Record<string, unknown> {
546
- return typeof value === 'object' && value !== null
547
- }
548
-
549
- /**
550
- * What the agent actually did this run, independent of any file changes. Used to
551
- * tell a genuine no-op (the agent never reached the model / never acted) apart
552
- * from a real run, so a bootstrap that produced nothing is failed rather than
553
- * pushed as an empty repo. `toolCalls === 0 && assistantChars === 0` is the
554
- * signature of a run where Pi never made a successful model call.
555
- */
556
- export interface PiRunStats {
557
- /** Tool calls the assistant emitted across the transcript (0 ⇒ it never acted). */
558
- toolCalls: number
559
- /** Total characters of assistant text (0 ⇒ the model produced nothing). */
560
- assistantChars: number
561
- }
562
-
563
- /**
564
- * Output-quality signals lifted from the agent's transcript, so the harness can fail
565
- * LOUDLY on a malformed run instead of silently handing a half-baked artifact to the
566
- * structured-output repair (which would manufacture a doc from garbage — the trap
567
- * behind the spec-writer ⇄ companion rework loop). Two distinct invalid states, both
568
- * seen in production from `kimi-k2.7-code`:
569
- * - a completion that hit the output ceiling (its answer/tool call was cut off), and
570
- * - a FINAL turn that carried no text at all (an empty `content: []` despite spending
571
- * output tokens), so there is no answer to parse.
572
- */
573
- export interface RunDiagnostics {
574
- /** Some completion ended at the output-token ceiling — its content was cut off. */
575
- truncated: boolean
576
- /** The agent's FINAL completion hit the ceiling: its ANSWER (not a mid-run step) was cut off. */
577
- finalTruncated: boolean
578
- /** The agent's final turn carried no text content (e.g. an empty `content: []`). */
579
- finalAnswerEmpty: boolean
580
- }
581
-
582
550
  /**
583
551
  * One model call captured from a subscription harness's CLI event stream, shaped so
584
552
  * the backend can record it into the same `llm_call_metrics` telemetry the LLM proxy
@@ -885,6 +853,12 @@ export function runPi(opts: {
885
853
  * the container payload doesn't pass it, so production behaviour is unchanged.
886
854
  */
887
855
  onSpan?: (span: ToolSpan) => void
856
+ /**
857
+ * Opens this stream's tool-silence window (see `RunOptions.beginToolWindow`), closed when Pi
858
+ * exits. Pi reports every completed tool call, so the window it opens is one this run can
859
+ * always beat; a caller that passes nothing leaves the watchdog silent for the run.
860
+ */
861
+ beginToolWindow?: () => ToolProgressWindow
888
862
  /**
889
863
  * Called with every parsed Pi `--mode json` event, in stream order — the raw
890
864
  * observability seam over the run. Used by offline tooling (the smoketest
@@ -928,15 +902,25 @@ export function runPi(opts: {
928
902
  // 'close'/'error' handlers below own the actual failure reporting.
929
903
  child.stdin.on('error', () => {})
930
904
  child.stdin.end(opts.userPrompt)
931
- let stdout = ''
932
- let stderr = ''
905
+ // The close-of-run answers (summary, stats, diagnostics, terminal error), FOLDED as the
906
+ // records stream instead of re-parsing the whole of stdout two more times at close: those
907
+ // passes were O(entire output) on the event loop the watchdog timers and the poll endpoints
908
+ // share, at exactly the moment the job is settling (stuck-run audit F6). Folding rather than
909
+ // retaining the parsed records is the other half of that bound — it is what makes this not a
910
+ // second copy of the run, which an unbounded array of parsed objects would have been.
911
+ const reduction = new PiRunReducer()
912
+ // This stream's tool-silence window: Pi reports every completed tool call, so each one below
913
+ // beats it. Closed on BOTH terminal paths (`error` and `close`) — a window outliving the
914
+ // process it watches would expire against a run that is already over.
915
+ const toolWindow = opts.beginToolWindow?.() ?? NO_TOOL_WINDOW
916
+ // Raw output kept ONLY to quote on a failure, so a bounded tail is the whole requirement —
917
+ // the longest slice anyone takes below is 2 KB.
918
+ const stdout = new BoundedTail(OUTPUT_TAIL_CHARS)
919
+ const stderr = new BoundedTail(OUTPUT_TAIL_CHARS)
933
920
  let aborted = false
934
921
  // Set when the no-progress guard kills Pi; carries the diagnostic the run
935
922
  // fails with (distinct from an external watchdog abort).
936
923
  let guardReason: string | undefined
937
- // Pi's json mode is strict LF-framed JSONL; buffer partial lines across
938
- // chunks so we only ever parse complete records for progress + the guard.
939
- let lineBuffer = ''
940
924
  // Counters for silent losses, warned ONCE at close (not per-line, to avoid log
941
925
  // spam): `{`-leading lines that failed to JSON.parse, and observer-callback throws.
942
926
  let malformedLines = 0
@@ -961,14 +945,14 @@ export function runPi(opts: {
961
945
  // and the no-progress guard; the `close` handler turns it into a rejection.
962
946
  const killChild = (): void => killChildProcess(child)
963
947
 
964
- // Parse each complete JSONL record once, feeding both the todo-progress
965
- // emitter and the no-progress guard. A tripped guard kills Pi with a
966
- // diagnostic the run then fails on.
967
- // `runGuard` is false only for the at-close flush of a final unterminated line: the
968
- // process has already exited, so feeding that record to the no-progress guard could trip
969
- // it and turn a clean (code 0) exit into a spurious "no progress" rejection. The flush
970
- // still recovers the record's progress/span signal; only the kill decision is skipped.
971
- const processLine = (line: string, runGuard = true): void => {
948
+ // Parse each complete JSONL record once, retaining it for the close-of-run reductions and
949
+ // feeding the todo-progress emitter and the no-progress guard. A tripped guard kills Pi
950
+ // with a diagnostic the run then fails on.
951
+ // `final` marks the at-close flush of a final unterminated line: the process has already
952
+ // exited, so feeding that record to the no-progress guard could trip it and turn a clean
953
+ // (code 0) exit into a spurious "no progress" rejection. The flush still recovers the
954
+ // record's progress/span signal; only the kill decision is skipped.
955
+ const processLine = (line: string, final: boolean): void => {
972
956
  if (!line.startsWith('{')) return
973
957
  let event: Record<string, unknown>
974
958
  try {
@@ -979,6 +963,7 @@ export function runPi(opts: {
979
963
  malformedLines++
980
964
  return
981
965
  }
966
+ reduction.observe(event)
982
967
  if (opts.onEvent) {
983
968
  try {
984
969
  opts.onEvent(event)
@@ -991,11 +976,15 @@ export function runPi(opts: {
991
976
  const progress = parseTodoProgress(event)
992
977
  if (progress) opts.onProgress(progress)
993
978
  }
979
+ // A completed tool call is the progress the tool-silence watchdog measures. Detected
980
+ // OUTSIDE the span branch below: the trajectory is an observability opt-in, and a watchdog
981
+ // that only ran when someone wanted spans would be armed against a stream it could not see.
982
+ const signal = toolCallSignal(event)
983
+ if (signal?.name) toolWindow.toolCompleted()
994
984
  if (opts.onSpan) {
995
985
  const start = toolCallStart(event)
996
986
  if (start) tools.started(start.id, start.name, start.args)
997
- const signal = toolCallSignal(event)
998
- if (signal && signal.name) {
987
+ if (signal?.name) {
999
988
  const call = tools.finished(
1000
989
  readToolCallId(event),
1001
990
  signal.name,
@@ -1010,7 +999,7 @@ export function runPi(opts: {
1010
999
  }
1011
1000
  }
1012
1001
  }
1013
- if (runGuard && !guardReason && !aborted) {
1002
+ if (!final && !guardReason && !aborted) {
1014
1003
  const reason = guard.observe(event)
1015
1004
  if (reason) {
1016
1005
  guardReason = reason
@@ -1019,16 +1008,9 @@ export function runPi(opts: {
1019
1008
  }
1020
1009
  }
1021
1010
 
1022
- const consumeStdout = (text: string): void => {
1023
- lineBuffer += text
1024
- let nl = lineBuffer.indexOf('\n')
1025
- while (nl !== -1) {
1026
- const line = lineBuffer.slice(0, nl).trim()
1027
- lineBuffer = lineBuffer.slice(nl + 1)
1028
- nl = lineBuffer.indexOf('\n')
1029
- processLine(line)
1030
- }
1031
- }
1011
+ // Pi's json mode is strict LF-framed JSONL; the reader buffers partial records across
1012
+ // chunks (bounded — see `JsonlLineReader`) so we only ever parse complete ones.
1013
+ const reader = new JsonlLineReader(processLine)
1032
1014
 
1033
1015
  // When the watchdog aborts, terminate Pi: the `close` handler then rejects
1034
1016
  // with the abort reason.
@@ -1041,9 +1023,9 @@ export function runPi(opts: {
1041
1023
  const onChunk = (chunk: Buffer, sink: 'out' | 'err'): void => {
1042
1024
  const text = chunk.toString()
1043
1025
  if (sink === 'out') {
1044
- stdout += text
1045
- consumeStdout(text)
1046
- } else stderr += text
1026
+ stdout.push(text)
1027
+ reader.push(text)
1028
+ } else stderr.push(text)
1047
1029
  // Any output means progress: reset the inactivity watchdog.
1048
1030
  opts.onActivity?.()
1049
1031
  }
@@ -1051,63 +1033,108 @@ export function runPi(opts: {
1051
1033
  child.stderr.on('data', (chunk: Buffer) => onChunk(chunk, 'err'))
1052
1034
  child.on('error', (error) => {
1053
1035
  opts.signal?.removeEventListener('abort', onAbort)
1036
+ toolWindow.close()
1054
1037
  reject(error)
1055
1038
  })
1056
1039
  child.on('close', (code) => {
1057
1040
  opts.signal?.removeEventListener('abort', onAbort)
1041
+ toolWindow.close()
1058
1042
  // Flush a final record that arrived without a trailing newline: Pi usually LF-frames
1059
1043
  // every line, but a clean exit can leave the last event (often `agent_end`) unterminated
1060
1044
  // in the buffer, so without this its progress/span/guard signal would be silently lost.
1061
- if (lineBuffer.trim()) {
1062
- processLine(lineBuffer.trim(), false)
1063
- lineBuffer = ''
1064
- }
1045
+ reader.flush()
1065
1046
  // Surface any silent stream losses ONCE (counts, not per-line), so a corrupted JSONL
1066
- // stream or a throwing observer is diagnosable rather than invisible.
1067
- if (malformedLines > 0 || observerErrors > 0) {
1068
- log.warn('pi: skipped malformed JSONL lines / observer errors', {
1047
+ // stream, an oversized record the reader refused to buffer, or a throwing observer is
1048
+ // diagnosable rather than invisible.
1049
+ if (malformedLines > 0 || observerErrors > 0 || reader.droppedLines > 0) {
1050
+ log.warn('pi: skipped malformed/oversized JSONL lines or observer errors', {
1069
1051
  malformedLines,
1052
+ oversizedLines: reader.droppedLines,
1070
1053
  observerErrors,
1071
1054
  })
1072
1055
  }
1073
- if (guardReason) {
1074
- const tail = redactSecrets(stderr.trim()).slice(-700)
1075
- reject(new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason))
1076
- } else if (aborted) {
1077
- reject(
1078
- new Error(
1079
- opts.signal?.reason instanceof Error ? opts.signal.reason.message : 'pi aborted',
1080
- ),
1081
- )
1082
- } else if (code === 0) {
1083
- const tail = redactSecrets(stderr.trim()).slice(-1500)
1084
- // Pi can exit 0 even when the agent run ended in a hard error (e.g. every
1085
- // model call failed and its retries were exhausted): the process completed,
1086
- // but the agent did not. Exit code alone then reads as success, and a run
1087
- // that RESUMED a branch with prior commits would even open a PR off work this
1088
- // pass never produced. Inspect the terminal transcript and fail loudly so the
1089
- // step is marked failed instead of masking a total failure as green.
1090
- const runError = terminalRunError(stdout)
1091
- if (runError) {
1092
- const scrubbed = redactSecrets(runError).slice(0, 1000)
1093
- const detail = tail ? `${scrubbed} Agent stderr: ${tail}` : scrubbed
1094
- reject(piRunFailure(detail, runError))
1095
- } else {
1096
- resolve({ ...summarizePiRun(stdout), ...(tail ? { stderrTail: tail } : {}) })
1097
- }
1098
- } else {
1099
- // A non-zero exit is the OTHER way a proxy refusal can surface (Pi crashing rather
1100
- // than exiting 0 after exhausting retries), so classify it here too — otherwise a
1101
- // 401/402/429 that happens to crash Pi would read as a generic agent failure. Redact
1102
- // the transcript slice before it becomes the detail: unlike the exit-0 path above, the
1103
- // raw `stderr`/`stdout` here was previously interpolated unscrubbed.
1104
- const raw = (stderr || stdout).slice(-500)
1105
- reject(piRunFailure(`pi exited with code ${code}: ${redactSecrets(raw)}`, raw))
1106
- }
1056
+ const settled = settlePiRun({
1057
+ code,
1058
+ reduction,
1059
+ droppedLines: reader.droppedLines,
1060
+ aborted,
1061
+ stdoutTail: stdout.toString(),
1062
+ stderrTail: stderr.toString(),
1063
+ ...(guardReason ? { guardReason } : {}),
1064
+ ...(opts.signal?.reason instanceof Error
1065
+ ? { abortReason: opts.signal.reason.message }
1066
+ : {}),
1067
+ })
1068
+ if (settled.ok) resolve(settled.outcome)
1069
+ else reject(settled.error)
1107
1070
  })
1108
1071
  })
1109
1072
  }
1110
1073
 
1074
+ /**
1075
+ * Turn an EXITED Pi process into the run's outcome or its failure. Split out of {@link runPi} for
1076
+ * the per-function budget, and pure so the dispositions can be reasoned about (and tested) without
1077
+ * spawning anything: everything it needs is already reduced by the time the process closes.
1078
+ *
1079
+ * The four dispositions, in the order they win: the no-progress guard's own kill, the external
1080
+ * watchdog's abort, a crash, and a clean exit — which is where the run is CERTIFIED, below.
1081
+ */
1082
+ function settlePiRun(args: {
1083
+ code: number | null
1084
+ reduction: PiRunReducer
1085
+ /** Records the framing reader refused to buffer; see the certification note below. */
1086
+ droppedLines: number
1087
+ aborted: boolean
1088
+ guardReason?: string
1089
+ abortReason?: string
1090
+ stdoutTail: string
1091
+ stderrTail: string
1092
+ }): { ok: true; outcome: PiRunOutcome } | { ok: false; error: Error } {
1093
+ const { code, reduction, droppedLines, aborted, guardReason, stdoutTail, stderrTail } = args
1094
+ const fail = (error: Error): { ok: false; error: Error } => ({ ok: false, error })
1095
+ if (guardReason) {
1096
+ const guardTail = redactSecrets(stderrTail.trim()).slice(-700)
1097
+ return fail(new Error(guardTail ? `${guardReason} Agent stderr: ${guardTail}` : guardReason))
1098
+ }
1099
+ if (aborted) return fail(new Error(args.abortReason ?? 'pi aborted'))
1100
+ if (code !== 0) {
1101
+ // A non-zero exit is the OTHER way a proxy refusal can surface (Pi crashing rather than
1102
+ // exiting 0 after exhausting retries), so classify it here too — otherwise a 401/402/429 that
1103
+ // happens to crash Pi would read as a generic agent failure. Redact the transcript slice
1104
+ // before it becomes the detail: unlike the exit-0 path below, this was previously
1105
+ // interpolated unscrubbed.
1106
+ const raw = (stderrTail || stdoutTail).slice(-500)
1107
+ return fail(piRunFailure(`pi exited with code ${code}: ${redactSecrets(raw)}`, raw))
1108
+ }
1109
+ const tail = redactSecrets(stderrTail.trim()).slice(-1500)
1110
+ // Pi can exit 0 even when the agent run ended in a hard error (e.g. every model call failed and
1111
+ // its retries were exhausted): the process completed, but the agent did not. Exit code alone
1112
+ // then reads as success, and a run that RESUMED a branch with prior commits would even open a
1113
+ // PR off work this pass never produced.
1114
+ const runError = reduction.terminalError()
1115
+ if (runError) {
1116
+ const scrubbed = redactSecrets(runError).slice(0, 1000)
1117
+ return fail(piRunFailure(tail ? `${scrubbed} Agent stderr: ${tail}` : scrubbed, runError))
1118
+ }
1119
+ if (!reduction.sawTerminalRecord && droppedLines > 0) {
1120
+ // The check above answered "no terminal failure" from having seen no terminal record AT ALL,
1121
+ // and the reader dropped at least one oversized one — so the record that decides this
1122
+ // question is exactly the record most likely to have been dropped (`agent_end` carries the
1123
+ // run's whole transcript). Resolving here would report a hard-failed run as a success, which
1124
+ // is the case that check exists to prevent, so refuse to certify it instead.
1125
+ // `no-usable-output` because that is literally what happened: the run finished and its
1126
+ // terminal report never reached us.
1127
+ const detail =
1128
+ `pi exited 0 but its terminal record was dropped for exceeding the JSONL line cap ` +
1129
+ `(${droppedLines} oversized record(s)), so the run's outcome is unknown`
1130
+ return fail(new HarnessFailure('no-usable-output', tail ? `${detail}. ${tail}` : detail))
1131
+ }
1132
+ return {
1133
+ ok: true,
1134
+ outcome: { ...reduction.reduce(stdoutTail), ...(tail ? { stderrTail: tail } : {}) },
1135
+ }
1136
+ }
1137
+
1111
1138
  /**
1112
1139
  * Build the rejection for a failed Pi run: if its terminal text points at the LLM proxy
1113
1140
  * refusing every model call (auth/quota/rate-limit), stamp the structured `llm-upstream`
@@ -1121,51 +1148,6 @@ function piRunFailure(detail: string, sourceText: string): Error {
1121
1148
  return remedy ? new HarnessFailure('llm-upstream', `${detail}\n${remedy}`) : new Error(detail)
1122
1149
  }
1123
1150
 
1124
- /** Parse Pi's LF-framed JSONL stdout into its event records, skipping noise. */
1125
- function parsePiEvents(stdout: string): Record<string, unknown>[] {
1126
- const events: Record<string, unknown>[] = []
1127
- for (const raw of stdout.split('\n')) {
1128
- const line = raw.trim()
1129
- if (!line.startsWith('{')) continue
1130
- try {
1131
- events.push(JSON.parse(line) as Record<string, unknown>)
1132
- } catch {
1133
- // Not a JSON event line; skip.
1134
- }
1135
- }
1136
- return events
1137
- }
1138
-
1139
- /**
1140
- * The terminal-failure message when Pi's run ended in a hard error (the model was
1141
- * unreachable / refused, and Pi exhausted its auto-retries), else undefined. Only
1142
- * the FINAL outcome counts: a mid-run hiccup the agent recovered from leaves a clean
1143
- * terminal `agent_end`, so it returns undefined. Scans from the end and decides on
1144
- * the first terminal signal it meets — the trailing `auto_retry_end` (its `success`
1145
- * flag) or the last `agent_end` (its `stopReason`). Pure so it is unit-testable over
1146
- * a fixed event sequence.
1147
- */
1148
- export function terminalRunError(stdout: string): string | undefined {
1149
- const events = parsePiEvents(stdout)
1150
- for (let i = events.length - 1; i >= 0; i--) {
1151
- const e = events[i]!
1152
- if (e.type === 'auto_retry_end') {
1153
- if (e.success === false) {
1154
- return typeof e.finalError === 'string'
1155
- ? e.finalError
1156
- : 'the agent failed after exhausting its retries'
1157
- }
1158
- return undefined
1159
- }
1160
- if (e.type === 'agent_end') {
1161
- return e.stopReason === 'error' && typeof e.errorMessage === 'string'
1162
- ? e.errorMessage
1163
- : undefined
1164
- }
1165
- }
1166
- return undefined
1167
- }
1168
-
1169
1151
  /**
1170
1152
  * Classify a terminal run error whose text points at the LLM PROXY rejecting every model call
1171
1153
  * (auth / quota / rate-limit) into an actionable remedy, else undefined. All model traffic goes
@@ -1211,190 +1193,3 @@ export function classifyLlmUpstreamError(finalError: string): string | undefined
1211
1193
  }
1212
1194
  return undefined
1213
1195
  }
1214
-
1215
- /**
1216
- * Pi's assistant summary plus {@link PiRunStats}, derived from one pass over its
1217
- * output — the canonical close-of-run signal the harness uses both to report the
1218
- * answer and to detect a no-op run (the agent never acted).
1219
- */
1220
- export function summarizePiRun(stdout: string): PiRunOutcome {
1221
- const events = parsePiEvents(stdout)
1222
- return {
1223
- summary: summaryFromEvents(events, stdout),
1224
- stats: statsFromEvents(events),
1225
- diagnostics: diagnosticsFromEvents(events),
1226
- }
1227
- }
1228
-
1229
- /**
1230
- * Output-quality signals over the canonical `agent_end` transcript: whether any
1231
- * completion hit the output ceiling (its content was cut off), whether the FINAL
1232
- * completion did, and whether that final turn carried no text at all. Pure so it is
1233
- * unit-testable over a fixed event sequence. Defaults to all-false when there is no
1234
- * terminal transcript (a no-op run is already caught by {@link agentNeverActed}).
1235
- *
1236
- * `cap` is the per-completion ceiling Pi requested ({@link PI_MAX_OUTPUT_TOKENS});
1237
- * truncation is detected by an assistant message whose `usage.output` reached it,
1238
- * which is reliable even when the model reports a non-`length` stop reason (Workers
1239
- * AI labelled a cut-off tool call `tool_calls`, not `length`).
1240
- */
1241
- export function diagnosticsFromEvents(
1242
- events: Record<string, unknown>[],
1243
- cap: number = PI_MAX_OUTPUT_TOKENS,
1244
- ): RunDiagnostics {
1245
- let messages: unknown[] | undefined
1246
- for (let i = events.length - 1; i >= 0; i--) {
1247
- const e = events[i]!
1248
- if (e.type === 'agent_end' && Array.isArray(e.messages)) {
1249
- messages = e.messages as unknown[]
1250
- break
1251
- }
1252
- }
1253
- if (!messages) return { truncated: false, finalTruncated: false, finalAnswerEmpty: false }
1254
- const assistants = messages.filter(
1255
- (m): m is Record<string, unknown> => isObject(m) && m.role === 'assistant',
1256
- )
1257
- const truncated = assistants.some((m) => assistantOutputTokens(m) >= cap)
1258
- const last = assistants.at(-1)
1259
- return {
1260
- truncated,
1261
- finalTruncated: last ? assistantOutputTokens(last) >= cap : false,
1262
- finalAnswerEmpty: last ? messageText(last) === '' : false,
1263
- }
1264
- }
1265
-
1266
- /** `usage.output` (completion tokens) reported on a Pi assistant message, or 0. */
1267
- function assistantOutputTokens(message: Record<string, unknown>): number {
1268
- const usage = message.usage
1269
- if (!isObject(usage)) return 0
1270
- const output = usage.output
1271
- return typeof output === 'number' ? output : 0
1272
- }
1273
-
1274
- /** {@link RunDiagnostics} over Pi's raw `--mode json` stdout (see {@link diagnosticsFromEvents}). */
1275
- export function runDiagnostics(stdout: string, cap: number = PI_MAX_OUTPUT_TOKENS): RunDiagnostics {
1276
- return diagnosticsFromEvents(parsePiEvents(stdout), cap)
1277
- }
1278
-
1279
- /**
1280
- * Count what the agent actually did. Prefers the canonical `agent_end`
1281
- * transcript (assistant `toolCall` parts + text); falls back to the streamed
1282
- * `tool_execution_end` / `message_end` events when no terminal transcript was
1283
- * emitted, so a no-op is never mistaken for a real run because of a schema tweak.
1284
- */
1285
- function statsFromEvents(events: Record<string, unknown>[]): PiRunStats {
1286
- for (let i = events.length - 1; i >= 0; i--) {
1287
- const e = events[i]!
1288
- if (e.type === 'agent_end' && Array.isArray(e.messages)) {
1289
- return statsFromMessages(e.messages as unknown[])
1290
- }
1291
- }
1292
- let toolCalls = 0
1293
- let toolResults = 0
1294
- let assistantChars = 0
1295
- for (const e of events) {
1296
- if (e.type === 'tool_execution_end') {
1297
- toolCalls++
1298
- } else if (e.type === 'message_end' && isObject(e.message)) {
1299
- const m = e.message
1300
- if (m.role === 'assistant') assistantChars += messageText(m).length
1301
- else if (m.role === 'toolResult') toolResults++
1302
- }
1303
- }
1304
- // The same call can surface as both a `tool_execution_end` and a toolResult
1305
- // `message_end`; prefer the former and only fall back to toolResult counts.
1306
- return { toolCalls: toolCalls || toolResults, assistantChars }
1307
- }
1308
-
1309
- /** {@link PiRunStats} from a transcript: assistant `toolCall` parts + text length. */
1310
- function statsFromMessages(messages: unknown[]): PiRunStats {
1311
- let toolCalls = 0
1312
- let assistantChars = 0
1313
- for (const m of messages) {
1314
- if (!isObject(m) || m.role !== 'assistant') continue
1315
- const content = m.content
1316
- if (typeof content === 'string') {
1317
- assistantChars += content.trim().length
1318
- } else if (Array.isArray(content)) {
1319
- for (const part of content) {
1320
- if (!isObject(part)) continue
1321
- if (part.type === 'toolCall') toolCalls++
1322
- else if (typeof part.text === 'string') assistantChars += part.text.length
1323
- }
1324
- }
1325
- }
1326
- return { toolCalls, assistantChars }
1327
- }
1328
-
1329
- /**
1330
- * Extract the assistant's final summary from Pi's JSON-lines output. Pi emits a
1331
- * terminal `agent_end` event whose `messages` is the full transcript, so the
1332
- * last assistant message there is the canonical answer. Falls back to scanning
1333
- * `message_end` events, then to a raw tail, so a schema tweak never loses output.
1334
- */
1335
- export function parsePiOutput(stdout: string): string {
1336
- return summaryFromEvents(parsePiEvents(stdout), stdout)
1337
- }
1338
-
1339
- /** Shared summary extraction over already-parsed events (see {@link parsePiOutput}). */
1340
- function summaryFromEvents(events: Record<string, unknown>[], stdout: string): string {
1341
- // Preferred: the final transcript from the last agent_end event.
1342
- for (let i = events.length - 1; i >= 0; i--) {
1343
- const e = events[i]!
1344
- if (e.type === 'agent_end' && Array.isArray(e.messages)) {
1345
- const text = lastAssistantText(e.messages as unknown[])
1346
- if (text) return text
1347
- }
1348
- }
1349
-
1350
- // Fallback: assistant text accumulated from message_end events.
1351
- const parts: string[] = []
1352
- for (const e of events) {
1353
- if (
1354
- e.type === 'message_end' &&
1355
- typeof e.message === 'object' &&
1356
- e.message !== null &&
1357
- (e.message as { role?: unknown }).role === 'assistant'
1358
- ) {
1359
- const text = messageText(e.message)
1360
- if (text) parts.push(text)
1361
- }
1362
- }
1363
- const joined = parts.join('\n').trim()
1364
- if (joined) return joined
1365
-
1366
- // Nothing structured matched — return a trimmed tail of the raw output.
1367
- return stdout.trim().slice(-2000)
1368
- }
1369
-
1370
- /** The text of the last assistant message in a transcript, or '' if none. */
1371
- function lastAssistantText(messages: unknown[]): string {
1372
- for (let i = messages.length - 1; i >= 0; i--) {
1373
- const m = messages[i]
1374
- if (typeof m === 'object' && m !== null && (m as { role?: unknown }).role === 'assistant') {
1375
- const text = messageText(m)
1376
- if (text) return text
1377
- }
1378
- }
1379
- return ''
1380
- }
1381
-
1382
- /** Join the text parts of a Pi message whose content is a string or parts array. */
1383
- function messageText(message: unknown): string {
1384
- if (typeof message !== 'object' || message === null) return ''
1385
- const content = (message as { content?: unknown }).content
1386
- if (typeof content === 'string') return content.trim()
1387
- if (Array.isArray(content)) {
1388
- return content
1389
- .map((part) =>
1390
- typeof part === 'object' &&
1391
- part !== null &&
1392
- typeof (part as { text?: unknown }).text === 'string'
1393
- ? (part as { text: string }).text
1394
- : '',
1395
- )
1396
- .join('')
1397
- .trim()
1398
- }
1399
- return ''
1400
- }