@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/embed.ts
CHANGED
|
@@ -6,19 +6,22 @@
|
|
|
6
6
|
// only the reusable primitives are exposed here.
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
|
-
PI_MAX_OUTPUT_TOKENS,
|
|
10
9
|
writePiModelsConfig,
|
|
11
10
|
writeAgentsContext,
|
|
12
11
|
runPi,
|
|
13
|
-
summarizePiRun,
|
|
14
|
-
parsePiOutput,
|
|
15
12
|
parseTodoProgress,
|
|
16
|
-
terminalRunError,
|
|
17
13
|
type PiRunOutcome,
|
|
18
|
-
type PiRunStats,
|
|
19
14
|
type TodoItem,
|
|
20
15
|
type TodoProgress,
|
|
21
16
|
} from './pi.js'
|
|
17
|
+
export {
|
|
18
|
+
PI_MAX_OUTPUT_TOKENS,
|
|
19
|
+
parsePiOutput,
|
|
20
|
+
summarizePiRun,
|
|
21
|
+
terminalRunError,
|
|
22
|
+
type PiRunReduction,
|
|
23
|
+
type PiRunStats,
|
|
24
|
+
} from './pi-reduction.js'
|
|
22
25
|
export {
|
|
23
26
|
DEFAULT_PROGRESS_GUARD_LIMITS,
|
|
24
27
|
progressGuardLimitsFromEnv,
|
package/src/failure.ts
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
*
|
|
21
21
|
* - `inactivity-timeout` — the inactivity watchdog fired (no agent output for the window).
|
|
22
22
|
* - `max-duration` — the overall wall-clock cap fired.
|
|
23
|
+
* - `no-tool-progress` — the tool-silence watchdog fired: the agent kept TALKING but completed
|
|
24
|
+
* no tool call for the window. Distinct from `inactivity-timeout` on
|
|
25
|
+
* purpose, because the two need different fixes: one says the container
|
|
26
|
+
* went quiet, this one says the model rabbit-holed while streaming.
|
|
23
27
|
* - `agent` — the agent ran but produced an unusable/failed result, or threw.
|
|
24
28
|
* - `git` — a git operation failed (clone/push/merge/PR).
|
|
25
29
|
* - `api` — an upstream API call failed (e.g. the GitHub/GitLab PR/MR REST call).
|
|
@@ -28,15 +32,25 @@
|
|
|
28
32
|
* - `no-usable-output` — the agent finished but returned no usable report / structured output.
|
|
29
33
|
* - `no-changes` — a coding agent finished without producing any change to push.
|
|
30
34
|
*/
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
export const FAILURE_CAUSES = [
|
|
36
|
+
'inactivity-timeout',
|
|
37
|
+
'max-duration',
|
|
38
|
+
'no-tool-progress',
|
|
39
|
+
'agent',
|
|
40
|
+
'git',
|
|
41
|
+
'api',
|
|
42
|
+
'llm-upstream',
|
|
43
|
+
'no-usable-output',
|
|
44
|
+
'no-changes',
|
|
45
|
+
] as const
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* See {@link FAILURE_CAUSES}. Derived from the array rather than declared beside it so the two
|
|
49
|
+
* cannot disagree, and so the list is ENUMERABLE at runtime — which is what lets
|
|
50
|
+
* `failure-cause.conformity.test.ts` check this image's vocabulary against the kernel union that
|
|
51
|
+
* has to classify it (the two are kept in step by hand; the image can carry no workspace dep).
|
|
52
|
+
*/
|
|
53
|
+
export type FailureCause = (typeof FAILURE_CAUSES)[number]
|
|
40
54
|
|
|
41
55
|
/**
|
|
42
56
|
* A thrown failure that carries a structured {@link FailureCause}, so a `git` / `api`
|
|
@@ -77,3 +91,16 @@ export function inactivityAbortMessage(inactivityMs: number): string {
|
|
|
77
91
|
export function maxDurationAbortMessage(maxDurationMs: number): string {
|
|
78
92
|
return `Aborted: exceeded max duration of ${Math.round(maxDurationMs / 1000)}s`
|
|
79
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The tool-silence-watchdog abort message. Human-readable only, like its two siblings — the
|
|
97
|
+
* backend reads the structured `no-tool-progress` {@link FailureCause}. Says what it observed
|
|
98
|
+
* (output, but no completed tool call) rather than "hung": the run was demonstrably alive, which
|
|
99
|
+
* is exactly why the inactivity watchdog never fired.
|
|
100
|
+
*/
|
|
101
|
+
export function toolSilenceAbortMessage(toolSilenceMs: number): string {
|
|
102
|
+
return (
|
|
103
|
+
`Aborted: the agent produced output but completed no tool call for ` +
|
|
104
|
+
`${Math.round(toolSilenceMs / 1000)}s`
|
|
105
|
+
)
|
|
106
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -1081,25 +1081,33 @@ export async function pushBranch(
|
|
|
1081
1081
|
* .gitignore and/or license picked on the new-repo page), so a fast-forward is
|
|
1082
1082
|
* impossible. The Worker pre-flights that the target is empty or holds only that
|
|
1083
1083
|
* boilerplate, so overwriting it is safe and intended.
|
|
1084
|
+
*
|
|
1085
|
+
* `signal` is the job watchdog's, and threading it is load-bearing rather than tidy: without
|
|
1086
|
+
* it the six commands below are bounded only by their own per-command timeouts, so an abort
|
|
1087
|
+
* raised during the push phase cannot interrupt them and the job keeps working for up to
|
|
1088
|
+
* ~6 × `GIT_TIMEOUT_MS` past its max-duration kill. Every other git helper here threads it.
|
|
1084
1089
|
*/
|
|
1085
1090
|
export async function reinitAndPush(opts: {
|
|
1086
1091
|
dir: string
|
|
1087
1092
|
target: BootstrapTargetSpec
|
|
1088
1093
|
ghToken: string
|
|
1089
1094
|
message: string
|
|
1095
|
+
signal?: AbortSignal
|
|
1090
1096
|
}): Promise<void> {
|
|
1091
|
-
|
|
1092
|
-
await
|
|
1097
|
+
const { dir, signal } = opts
|
|
1098
|
+
await rm(join(dir, '.git'), { recursive: true, force: true })
|
|
1099
|
+
await git(['init'], { cwd: dir, signal })
|
|
1093
1100
|
// Start the history on the target's default branch (init may default to master).
|
|
1094
|
-
await git(['checkout', '-b', opts.target.defaultBranch], { cwd:
|
|
1095
|
-
await git(['config', 'user.name', GIT_AUTHOR], { cwd:
|
|
1096
|
-
await git(['config', 'user.email', GIT_EMAIL], { cwd:
|
|
1097
|
-
await git(['add', '-A'], { cwd:
|
|
1098
|
-
await git(['commit', '-m', opts.message], { cwd:
|
|
1101
|
+
await git(['checkout', '-b', opts.target.defaultBranch], { cwd: dir, signal })
|
|
1102
|
+
await git(['config', 'user.name', GIT_AUTHOR], { cwd: dir, signal })
|
|
1103
|
+
await git(['config', 'user.email', GIT_EMAIL], { cwd: dir, signal })
|
|
1104
|
+
await git(['add', '-A'], { cwd: dir, signal })
|
|
1105
|
+
await git(['commit', '-m', opts.message], { cwd: dir, signal })
|
|
1099
1106
|
const url = authenticatedCloneUrl(opts.target.cloneUrl)
|
|
1100
|
-
await git(['remote', 'add', 'origin', url], { cwd:
|
|
1107
|
+
await git(['remote', 'add', 'origin', url], { cwd: dir, signal })
|
|
1101
1108
|
await git(['push', '--force', '-u', 'origin', opts.target.defaultBranch], {
|
|
1102
|
-
cwd:
|
|
1109
|
+
cwd: dir,
|
|
1110
|
+
signal,
|
|
1103
1111
|
env: await authEnv(opts.ghToken),
|
|
1104
1112
|
})
|
|
1105
1113
|
}
|
package/src/inline.ts
CHANGED
|
@@ -35,6 +35,12 @@ function deriveFinishReason(calls: HarnessCallMetric[] | undefined): 'stop' | 'l
|
|
|
35
35
|
* directory. The job's watchdog (inactivity + max-duration, see {@link JobRegistry}) bounds
|
|
36
36
|
* it through `opts.signal`; `opts.onActivity` keeps the inactivity timer alive while the CLI
|
|
37
37
|
* streams. The temp cwd is always removed.
|
|
38
|
+
*
|
|
39
|
+
* `opts.beginToolWindow` is deliberately NOT forwarded. An inline completion is a one-shot
|
|
40
|
+
* answer with no checkout and no tool loop, so the tool-silence watchdog would arm a window this
|
|
41
|
+
* run could never beat and could only ever expire — force-failing a healthy completion under a
|
|
42
|
+
* cause ("kept talking, completed no tool call") that misdescribes what it is. The inactivity and
|
|
43
|
+
* max-duration watchdogs still bound it, which is the whole bound this work ever had.
|
|
38
44
|
*/
|
|
39
45
|
export async function handleInline(job: InlineJob, opts: RunOptions): Promise<InlineResult> {
|
|
40
46
|
opts.onPhase?.('agent')
|
package/src/job.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { HarnessCallMetric
|
|
1
|
+
import type { HarnessCallMetric } from './pi.js'
|
|
2
|
+
import type { PiRunStats } from './pi-reduction.js'
|
|
2
3
|
import type { HarnessKind } from './pi-workspace.js'
|
|
3
4
|
import type { FailureCause } from './failure.js'
|
|
4
5
|
import type { EffortReport } from './effort.js'
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// BOUNDED reading of an agent CLI's child streams: LF-framing its JSONL stdout, and holding a
|
|
2
|
+
// capped tail of raw output for diagnostics.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS MODULE EXISTS — the harness's two watchdog timers and its `/health` + `/jobs` poll
|
|
5
|
+
// endpoints share ONE Node event loop with the stream-parsing hot path, so the advertised "a
|
|
6
|
+
// container can never run forever" guarantee only holds while that loop stays live (stuck-run
|
|
7
|
+
// audit F6). Both CLI readers (`runPi`, `runSubscriptionAgent`) had grown the same unbounded
|
|
8
|
+
// framing loop: a record with no terminating newline accumulated without limit, so a runaway
|
|
9
|
+
// producer could drive the buffer until a single `JSON.parse` (or the allocation behind it)
|
|
10
|
+
// stalled the loop past the abort timers and the poll handlers alike. The container then stops
|
|
11
|
+
// answering polls while its own watchdogs never fire — the exact wedge the timers exist to
|
|
12
|
+
// prevent, with only the engine-side poll-failure tolerance and the reaper left underneath.
|
|
13
|
+
//
|
|
14
|
+
// One definition of "how much of a child's output we are willing to hold" therefore serves both
|
|
15
|
+
// harnesses, for the same reason `ProgressGuard` does: two copies of a bound are two bounds.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Longest single JSONL record either CLI may emit before the reader stops buffering it.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately far above the largest LEGITIMATE record — the terminal `agent_end`, which carries
|
|
21
|
+
* the run's whole message transcript including tool results — because dropping that one costs the
|
|
22
|
+
* run its summary and stats. The cap is not a size policy, it is the ceiling that keeps a
|
|
23
|
+
* runaway producer from growing the buffer until parsing it wedges the event loop, so it only
|
|
24
|
+
* has to be low enough that one parse of it stays well inside the poll cadence.
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_JSONL_LINE_CHARS = 32 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A fixed-size tail of a text stream, for output kept ONLY to quote back on a failure.
|
|
30
|
+
*
|
|
31
|
+
* Retaining a whole run's stdout to slice the last 2 KB off it at close is the memory half of
|
|
32
|
+
* F6: a chatty agent's output is unbounded, and the container OOMing is another way for a job to
|
|
33
|
+
* stop answering polls with no watchdog having fired. The tail is trimmed lazily — only once it
|
|
34
|
+
* has grown past twice the bound — so a run that streams thousands of chunks pays an amortized
|
|
35
|
+
* O(1) copy per chunk rather than an O(maxChars) slice on every one of them.
|
|
36
|
+
*/
|
|
37
|
+
export class BoundedTail {
|
|
38
|
+
private text = ''
|
|
39
|
+
private total = 0
|
|
40
|
+
|
|
41
|
+
constructor(private readonly maxChars: number) {}
|
|
42
|
+
|
|
43
|
+
push(chunk: string): void {
|
|
44
|
+
this.text += chunk
|
|
45
|
+
this.total += chunk.length
|
|
46
|
+
if (this.text.length > this.maxChars * 2) this.text = this.text.slice(-this.maxChars)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The last `maxChars` characters seen. */
|
|
50
|
+
toString(): string {
|
|
51
|
+
return this.text.length > this.maxChars ? this.text.slice(-this.maxChars) : this.text
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Everything ever pushed, whether or not it is still retained. */
|
|
55
|
+
get totalChars(): number {
|
|
56
|
+
return this.total
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Characters dropped off the FRONT because the tail is bounded; 0 while everything still fits.
|
|
61
|
+
*
|
|
62
|
+
* A caller that renders the tail to a human owes them this: a bounded tail is the opposite of a
|
|
63
|
+
* prefix, so a reader who assumes one concludes the producer stopped where the text begins.
|
|
64
|
+
* Diagnostic quotes (a stderr tail) need no such note — being a tail is what they are for.
|
|
65
|
+
*/
|
|
66
|
+
get droppedChars(): number {
|
|
67
|
+
return this.total - this.toString().length
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Frames a child's LF-delimited JSONL stdout into complete records, bounding what it will buffer
|
|
73
|
+
* for any one of them.
|
|
74
|
+
*
|
|
75
|
+
* `onLine` is invoked per complete record with `final: false`, and once more from {@link flush}
|
|
76
|
+
* with `final: true` for a trailing record that arrived without its newline (a clean exit can
|
|
77
|
+
* leave the last event unterminated). `final` is what lets a caller deliver the record's
|
|
78
|
+
* progress/telemetry signal while suppressing any decision that would KILL the run: the process
|
|
79
|
+
* has already exited, so a guard tripping on that last buffered record would turn a clean exit
|
|
80
|
+
* into a spurious failure.
|
|
81
|
+
*
|
|
82
|
+
* A record that outgrows {@link MAX_JSONL_LINE_CHARS} is DROPPED, not truncated: a partial JSON
|
|
83
|
+
* document is not a record, and handing the parser half of one would report it as corrupt output
|
|
84
|
+
* rather than as the bound firing. The reader then resynchronises on the next newline, so the
|
|
85
|
+
* oversized record costs its own signal and nothing after it. Callers report {@link droppedLines}
|
|
86
|
+
* at close (never per line) so the loss is diagnosable instead of silent.
|
|
87
|
+
*/
|
|
88
|
+
export class JsonlLineReader {
|
|
89
|
+
private buffer = ''
|
|
90
|
+
/** True while discarding the tail of a record that already blew the cap. */
|
|
91
|
+
private skipping = false
|
|
92
|
+
private dropped = 0
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly onLine: (line: string, final: boolean) => void,
|
|
96
|
+
private readonly maxLineChars: number = MAX_JSONL_LINE_CHARS,
|
|
97
|
+
) {}
|
|
98
|
+
|
|
99
|
+
/** Feed one stdout chunk, emitting every complete record it finishes. */
|
|
100
|
+
push(text: string): void {
|
|
101
|
+
// Framing scans the incoming CHUNK, never the accumulated buffer. `buffer += chunk` is a
|
|
102
|
+
// cheap rope in V8 and `.length` reads off it in constant time, but ANY search over it
|
|
103
|
+
// flattens the rope — so scanning the buffer once per chunk costs O(record) per chunk, i.e.
|
|
104
|
+
// quadratic in a runaway record, paid on the very event loop this class exists to keep
|
|
105
|
+
// answering polls. Measured, a 32 MB unterminated record cost ~6s of solid blocking that
|
|
106
|
+
// way: the cap bounded the memory and handed back the stall in its place.
|
|
107
|
+
let rest = text
|
|
108
|
+
for (;;) {
|
|
109
|
+
const nl = rest.indexOf('\n')
|
|
110
|
+
if (nl === -1) break
|
|
111
|
+
if (this.skipping) {
|
|
112
|
+
// The newline that ends an oversized record ends the skip with it: everything buffered
|
|
113
|
+
// for that record is already gone, and what follows is a fresh one.
|
|
114
|
+
this.skipping = false
|
|
115
|
+
} else {
|
|
116
|
+
// The only place the buffer is materialised, and only for a record that COMPLETED —
|
|
117
|
+
// which the branch below has already kept under the cap.
|
|
118
|
+
const raw = this.buffer + rest.slice(0, nl)
|
|
119
|
+
this.buffer = ''
|
|
120
|
+
// The cap is on the RECORD, not on the leftover buffer: a record that arrived whole
|
|
121
|
+
// inside one chunk was never buffered across pushes, and dropping it only when it
|
|
122
|
+
// straddles a chunk boundary would make the bound depend on how the OS split the reads.
|
|
123
|
+
if (raw.length > this.maxLineChars) this.dropped++
|
|
124
|
+
else this.onLine(raw.trim(), false)
|
|
125
|
+
}
|
|
126
|
+
rest = rest.slice(nl + 1)
|
|
127
|
+
}
|
|
128
|
+
if (this.skipping) return
|
|
129
|
+
this.buffer += rest
|
|
130
|
+
if (this.buffer.length > this.maxLineChars) {
|
|
131
|
+
this.buffer = ''
|
|
132
|
+
// Count the record once, however many chunks it goes on to spill.
|
|
133
|
+
this.dropped++
|
|
134
|
+
this.skipping = true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Emit any trailing unterminated record (see the class doc); call once, after the child exits. */
|
|
139
|
+
flush(): void {
|
|
140
|
+
const line = this.buffer.trim()
|
|
141
|
+
this.buffer = ''
|
|
142
|
+
if (line && !this.skipping) this.onLine(line, true)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Records dropped for exceeding the line cap; 0 on every ordinary run. */
|
|
146
|
+
get droppedLines(): number {
|
|
147
|
+
return this.dropped
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -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
|
+
}
|