@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.
- package/README.md +5 -1
- package/dist/agent-runner.d.ts +14 -1
- package/dist/agent-runner.js +84 -28
- package/dist/bootstrap-mode.js +1 -0
- package/dist/coding-agent.d.ts +2 -1
- package/dist/embed.d.ts +2 -1
- package/dist/embed.js +2 -1
- package/dist/failure.d.ts +19 -1
- package/dist/failure.js +40 -0
- package/dist/git.d.ts +6 -0
- package/dist/git.js +16 -9
- package/dist/inline.d.ts +6 -0
- package/dist/inline.js +6 -0
- package/dist/job.d.ts +2 -1
- package/dist/jsonl-stream.d.ts +70 -0
- package/dist/jsonl-stream.js +149 -0
- package/dist/pi-reduction.d.ts +136 -0
- package/dist/pi-reduction.js +303 -0
- package/dist/pi-workspace.d.ts +2 -1
- package/dist/pi-workspace.js +6 -1
- package/dist/pi.d.ts +8 -81
- package/dist/pi.js +124 -310
- package/dist/runner.d.ts +31 -0
- package/dist/runner.js +50 -3
- package/dist/structured-output.js +2 -1
- package/dist/tool-silence.d.ts +74 -0
- package/dist/tool-silence.js +99 -0
- package/package.json +4 -4
- package/src/agent-runner.ts +100 -30
- package/src/agent.ts +1 -1
- package/src/bootstrap-mode.ts +2 -1
- package/src/coding-agent.ts +2 -1
- package/src/embed.ts +8 -5
- package/src/failure.ts +36 -9
- package/src/git.ts +17 -9
- package/src/inline.ts +6 -0
- package/src/job.ts +2 -1
- package/src/jsonl-stream.ts +149 -0
- package/src/pi-reduction.ts +359 -0
- package/src/pi-workspace.ts +7 -3
- package/src/pi.ts +144 -349
- package/src/runner.ts +91 -4
- package/src/structured-output.ts +2 -1
- package/src/tool-silence.ts +125 -0
package/src/pi-workspace.ts
CHANGED
|
@@ -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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
932
|
-
|
|
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,
|
|
965
|
-
// emitter and the no-progress guard. A tripped guard kills Pi
|
|
966
|
-
// diagnostic the run then fails on.
|
|
967
|
-
// `
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
const processLine = (line: string,
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
|
1045
|
-
|
|
1046
|
-
} else stderr
|
|
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
|
-
|
|
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
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
}
|