@cat-factory/executor-harness 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/agent-runner.js +389 -0
  4. package/dist/agent.js +810 -0
  5. package/dist/blueprint.js +367 -0
  6. package/dist/bootstrap.js +99 -0
  7. package/dist/ci-fixer.js +46 -0
  8. package/dist/coding-agent.js +285 -0
  9. package/dist/conflict-resolver.js +138 -0
  10. package/dist/embed.js +8 -0
  11. package/dist/explore.js +74 -0
  12. package/dist/failure.js +47 -0
  13. package/dist/fixer.js +44 -0
  14. package/dist/follow-ups.js +103 -0
  15. package/dist/frontend-infra.js +283 -0
  16. package/dist/fs-utils.js +11 -0
  17. package/dist/git.js +778 -0
  18. package/dist/job.js +409 -0
  19. package/dist/logger.js +27 -0
  20. package/dist/merger.js +135 -0
  21. package/dist/on-call.js +126 -0
  22. package/dist/pi-workspace.js +237 -0
  23. package/dist/pi.js +971 -0
  24. package/dist/process.js +25 -0
  25. package/dist/redact.js +109 -0
  26. package/dist/runner.js +228 -0
  27. package/dist/server.js +135 -0
  28. package/dist/spec.js +754 -0
  29. package/dist/structured-output.js +431 -0
  30. package/dist/tester.js +191 -0
  31. package/package.json +35 -0
  32. package/src/agent-runner.ts +484 -0
  33. package/src/agent.ts +948 -0
  34. package/src/coding-agent.ts +393 -0
  35. package/src/embed.ts +32 -0
  36. package/src/failure.ts +73 -0
  37. package/src/follow-ups.ts +106 -0
  38. package/src/frontend-infra.ts +340 -0
  39. package/src/fs-utils.ts +11 -0
  40. package/src/git.ts +955 -0
  41. package/src/job.ts +766 -0
  42. package/src/logger.ts +45 -0
  43. package/src/pi-workspace.ts +348 -0
  44. package/src/pi.ts +1236 -0
  45. package/src/process.ts +33 -0
  46. package/src/redact.ts +109 -0
  47. package/src/runner.ts +384 -0
  48. package/src/server.ts +153 -0
  49. package/src/structured-output.ts +524 -0
package/dist/pi.js ADDED
@@ -0,0 +1,971 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { appendFile, mkdir, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { killChildProcess } from './process.js';
6
+ import { pathExists } from './fs-utils.js';
7
+ import { redactSecrets } from './redact.js';
8
+ import { log } from './logger.js';
9
+ // Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
10
+ // proxy via a custom provider in ~/.pi/agent/models.json, authenticated with the
11
+ // per-job session token (interpolated from $PI_PROXY_TOKEN) — so no provider key
12
+ // ever lives in the image or in Pi's config on disk.
13
+ /**
14
+ * Per-completion output-token ceiling Pi requests (its model-entry `maxTokens`).
15
+ * Generous on purpose: a reasoning model (e.g. GLM-5.2) spends tokens on its
16
+ * `<think>` trace before the answer + tool calls, so a tight cap truncates it
17
+ * mid-reasoning and the agent never commits edits. It is a ceiling, not a target
18
+ * — unused output tokens are not billed and Workers AI clamps the request to the
19
+ * model's real max — so erring high is safe. Raised to 32k after a spec-writer run
20
+ * truncated an intermediate tool call at the old 16k cap; the document itself
21
+ * stopped well under it, so this is headroom for larger specs/diffs, with
22
+ * {@link runDiagnostics} flagging the rare case where even 32k is not enough.
23
+ */
24
+ export const PI_MAX_OUTPUT_TOKENS = 32_768;
25
+ /** Write the Pi provider config that routes all model calls through the proxy. */
26
+ export async function writePiModelsConfig(opts) {
27
+ const dir = join(homedir(), '.pi', 'agent');
28
+ await mkdir(dir, { recursive: true });
29
+ const config = {
30
+ providers: {
31
+ proxy: {
32
+ baseUrl: opts.proxyBaseUrl,
33
+ api: 'openai-completions',
34
+ // Interpolated by Pi from the environment at run time.
35
+ apiKey: '$PI_PROXY_TOKEN',
36
+ // OpenAI-compatible upstreams behind the proxy don't all accept the
37
+ // `developer` role or `reasoning_effort`; send a plain system message.
38
+ compat: { supportsDeveloperRole: false, supportsReasoningEffort: false },
39
+ // `maxTokens` is Pi's per-completion output ceiling — set it generously so
40
+ // a reasoning model isn't cut off mid-think (see PI_MAX_OUTPUT_TOKENS).
41
+ models: [
42
+ { id: opts.model, name: opts.model, maxTokens: opts.maxTokens ?? PI_MAX_OUTPUT_TOKENS },
43
+ ],
44
+ },
45
+ },
46
+ };
47
+ const path = join(dir, 'models.json');
48
+ await writeFile(path, JSON.stringify(config, null, 2), 'utf8');
49
+ return path;
50
+ }
51
+ // Appended to every AGENTS.md so the model maintains the `todo` tool the image
52
+ // installs (rpiv-todo). Without a nudge a model may skip the tool, which would
53
+ // leave the run with no subtask progress to report; keeping the list current is
54
+ // what makes the board's "N/M done" move.
55
+ const TODO_GUIDANCE = `
56
+
57
+ ## Progress tracking (required)
58
+
59
+ You have a \`todo\` tool. For any multi-step task, before you start coding, break
60
+ the work into concrete subtasks with \`todo\` (action "create"). As you work, mark
61
+ each one \`in_progress\` when you begin it and \`completed\` when it's done (action
62
+ "update"). Keep the list accurate — it is the only signal the system has for how
63
+ far along the run is.`;
64
+ // Appended to AGENTS.md only when the rpiv-web-tools extension is configured (an
65
+ // active web-search provider is set — see `webSearchConfigFromEnv`). Without a
66
+ // nudge a model rarely reaches for the tools, so it would keep relying on stale
67
+ // training data. Kept deliberately conservative: search is for facts that genuinely
68
+ // change or that the agent is unsure of, NOT a substitute for reading the repo.
69
+ const WEB_TOOLS_GUIDANCE = `
70
+
71
+ ## Web search & fetch (use sparingly)
72
+
73
+ You have \`web_search\` (returns titled result snippets for a query) and \`web_fetch\`
74
+ (reads a URL as text) tools. Reach for them ONLY when the repository itself can't
75
+ answer the question: to confirm a current library/API signature, a breaking change,
76
+ an exact error message, or a security advisory. Prefer first-party documentation,
77
+ and cite the source URL when a decision rests on what you found. Do NOT browse for
78
+ anything already discoverable in the checkout, and don't let searching replace
79
+ reading the code.`;
80
+ // Appended to every AGENTS.md so an agent orients off the persisted service
81
+ // blueprint before touching code, but stays shallow by default: read the
82
+ // high-level overview first, and only open a module's deep-dive when the task
83
+ // actually touches it. Harmless when no blueprint exists yet (e.g. a fresh
84
+ // bootstrap) — the files simply aren't there to read.
85
+ const BLUEPRINT_GUIDANCE = `
86
+
87
+ ## Service blueprint (read first, stay shallow)
88
+
89
+ If a \`blueprints/\` folder exists, it is the map of this service. **Before you start,
90
+ read \`blueprints/overview.md\`** for the high-level structure (the service and its
91
+ modules). Do NOT read every module file. Only open \`blueprints/modules/<name>.md\`
92
+ for a module that is directly relevant to your task, when you need its summary and
93
+ exact code references. \`blueprints/version.json\` is a tiny manifest for quick
94
+ staleness checks. Treat the blueprint as orientation, not a task list.`;
95
+ // Appended to every AGENTS.md so an agent treats the persisted spec as the
96
+ // PRESCRIPTIVE source (what must be true) and the acceptance scenarios its work must
97
+ // satisfy. Harmless when no spec exists yet — the files simply aren't there.
98
+ const SPEC_GUIDANCE = `
99
+
100
+ ## Service specification (the prescriptive spec)
101
+
102
+ If a \`spec/\` folder exists, it is the specification for this service. It is sharded
103
+ by a module (domain) → feature (group) taxonomy. **Read \`spec/overview.md\` first** —
104
+ it states what MUST be true and indexes the modules and their features (with links).
105
+ Open \`spec/modules/<module>/<feature>.md\` (or its \`.json\` for exact detail) for the
106
+ feature you are working on — it carries that feature's requirements AND the domain
107
+ rules scoped to it. \`spec/features/<module>/<feature>.feature\` are the Gherkin
108
+ acceptance scenarios your work must satisfy — treat them as the source of truth for
109
+ behaviour and tests. Read only the modules/features relevant to your task.`;
110
+ /**
111
+ * Write the composed system prompt as Pi's GLOBAL agent context
112
+ * (`~/.pi/agent/AGENTS.md`), which Pi reads automatically and concatenates with
113
+ * any `AGENTS.md`/`CLAUDE.md` the repo itself ships (global file first, then the
114
+ * ones walked up from the run cwd). Deliberately OUTSIDE the checkout (the same
115
+ * `~/.pi/agent` dir `writePiModelsConfig` already uses) so the harness's
116
+ * instructions never enter the git working tree — they can't be committed into a
117
+ * PR and they never clobber a repo's own committed `AGENTS.md`.
118
+ *
119
+ * This relies on Pi's context-file resolution: the global `~/.pi/agent/AGENTS.md`
120
+ * is loaded before the project-trust decision, so it applies in non-interactive
121
+ * (`-p`) runs without a trust prompt. That contract is pinned by `PI_VERSION` in
122
+ * the Dockerfile — revisit this if that bump changes context-file resolution.
123
+ */
124
+ export async function writeAgentsContext(systemPrompt, opts = {}) {
125
+ const dir = join(homedir(), '.pi', 'agent');
126
+ await mkdir(dir, { recursive: true });
127
+ // Only nudge towards the web tools when they're actually configured, so an agent is
128
+ // never told about tools that would error (no provider key) the moment it calls them.
129
+ // `guidance` is the backend's per-kind nudge; fall back to the generic blurb for jobs
130
+ // that don't carry one (e.g. bootstrap, or an older dispatcher).
131
+ const webTools = opts.webSearch ? (opts.guidance ?? WEB_TOOLS_GUIDANCE) : '';
132
+ // Tell the agent it's in a monorepo and which subtree is its service, so it scopes
133
+ // its work (and its build/test commands) there. Only present when the dispatcher
134
+ // resolved a monorepo service directory; the agent's cwd already points at it.
135
+ const monorepo = opts.serviceDirectory ? monorepoGuidance(opts.serviceDirectory) : '';
136
+ // Point the agent at any linked context the backend materialised into the checkout
137
+ // (requirements / RFCs / PRDs / tracker issues) so it reads them on demand.
138
+ const context = contextGuidance(opts.contextFiles ?? []);
139
+ await writeFile(join(dir, 'AGENTS.md'), `${systemPrompt}${BLUEPRINT_GUIDANCE}${SPEC_GUIDANCE}${TODO_GUIDANCE}${monorepo}${webTools}${context}`, 'utf8');
140
+ }
141
+ /** Directory in the checkout where linked-context files are materialised (see CONTEXT_DIR in agents). */
142
+ export const CONTEXT_DIR = '.cat-context';
143
+ /** The AGENTS.md block enumerating the materialised linked-context files, or '' when none. */
144
+ function contextGuidance(files) {
145
+ if (!files.length)
146
+ return '';
147
+ const list = files
148
+ .map((f) => `- \`${CONTEXT_DIR}/${f.path}\` — ${f.title}${f.url ? ` (${f.url})` : ''}`)
149
+ .join('\n');
150
+ return `
151
+
152
+ ## Linked context (read on demand)
153
+ Requirements / RFCs / PRDs / tracker issues relevant to this task are in the \`${CONTEXT_DIR}/\`
154
+ directory of your checkout. Open a file when it is relevant. Do NOT attempt to reach external
155
+ systems (Jira / Confluence / GitHub) — everything available has already been placed on disk:
156
+ ${list}`;
157
+ }
158
+ /**
159
+ * Write the backend-prepared linked-context files into {@link CONTEXT_DIR} in the
160
+ * checkout so the agent can read them on demand, and add a LOCAL git exclude entry so
161
+ * even `git add -A` never commits them into the agent's PR. Best-effort on the exclude
162
+ * (a scaffold-from-scratch checkout has no `.git` yet — the files just stay untracked).
163
+ */
164
+ export async function materializeContextFiles(cwd, files) {
165
+ if (!files.length)
166
+ return;
167
+ const dir = join(cwd, CONTEXT_DIR);
168
+ await mkdir(dir, { recursive: true });
169
+ for (const f of files)
170
+ await writeFile(join(dir, f.path), f.content, 'utf8');
171
+ // The exclude pattern has no leading slash, so it matches `.cat-context/` at any depth
172
+ // — covering the monorepo case where cwd is a service subdirectory below the repo root.
173
+ // Walk up to find the repo's `.git` (best-effort; a from-scratch scaffold has none).
174
+ const gitRoot = await findGitRoot(cwd);
175
+ if (!gitRoot)
176
+ return;
177
+ try {
178
+ await appendFile(join(gitRoot, '.git', 'info', 'exclude'), `\n${CONTEXT_DIR}/\n`, 'utf8');
179
+ }
180
+ catch {
181
+ // No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
182
+ }
183
+ }
184
+ /** Walk up from `dir` (bounded) to the directory containing a `.git` folder, or null. */
185
+ async function findGitRoot(dir) {
186
+ let current = dir;
187
+ for (let i = 0; i < 8; i++) {
188
+ if (await pathExists(join(current, '.git')))
189
+ return current;
190
+ const parent = dirname(current);
191
+ if (parent === current)
192
+ break;
193
+ current = parent;
194
+ }
195
+ return null;
196
+ }
197
+ /** The monorepo note appended to AGENTS.md when a run is scoped to a service subdirectory. */
198
+ function monorepoGuidance(serviceDirectory) {
199
+ return `
200
+
201
+ ## Monorepo service (work within your subdirectory)
202
+
203
+ This repository is a **monorepo** hosting more than one service. The service you are
204
+ working on lives in \`${serviceDirectory}/\` (relative to the repo root), and your
205
+ working directory is already set there. Confine your changes to that subtree — create
206
+ and edit files under \`${serviceDirectory}/\`, and run that service's own build/test/lint
207
+ commands (defined by the manifest in \`${serviceDirectory}/\`, e.g. its \`package.json\`).
208
+ Do not modify other services' directories, and only touch shared/root files (workspace
209
+ manifests, root config) when the task genuinely requires it.`;
210
+ }
211
+ /**
212
+ * The env var whose presence configures each rpiv-web-tools provider, in selection
213
+ * priority order. Used to AUTO-ENABLE web search whenever a deployment has wired up
214
+ * a provider — there's no separate on/off flag, mirroring how Claude Code / Codex
215
+ * turn search on once a backend is configured. `brave` leads (it's what Claude Code
216
+ * uses); the self-hosted backends (searxng/ollama) come last. For the keyless
217
+ * backends it is the base-URL var that signals "configured".
218
+ */
219
+ const WEB_SEARCH_PROVIDER_ENV = [
220
+ { provider: 'brave', envVar: 'BRAVE_SEARCH_API_KEY' },
221
+ { provider: 'tavily', envVar: 'TAVILY_API_KEY' },
222
+ { provider: 'exa', envVar: 'EXA_API_KEY' },
223
+ { provider: 'serper', envVar: 'SERPER_API_KEY' },
224
+ { provider: 'perplexity', envVar: 'PERPLEXITY_API_KEY' },
225
+ { provider: 'youcom', envVar: 'YOUCOM_API_KEY' },
226
+ { provider: 'jina', envVar: 'JINA_API_KEY' },
227
+ { provider: 'firecrawl', envVar: 'FIRECRAWL_API_KEY' },
228
+ { provider: 'searxng', envVar: 'SEARXNG_URL' },
229
+ { provider: 'ollama', envVar: 'OLLAMA_HOST' },
230
+ ];
231
+ /**
232
+ * Resolve the web-search configuration from the environment, or undefined when no
233
+ * provider is configured (⇒ the harness writes no rpiv-web-tools config and never
234
+ * nudges the agent towards the tools, so runs behave exactly as before). Enablement
235
+ * is CONDITIONAL on a provider being configured: if any provider's credential/URL
236
+ * env var is present, web search turns on with that provider (highest-priority one
237
+ * when several are set). `WEB_SEARCH_PROVIDER` is an explicit override that pins the
238
+ * active provider regardless of detection — but only when that provider's own
239
+ * credential/URL is also present, so a pin without a key never nudges the agent
240
+ * towards a tool that would error the moment it's called. No key passes through here
241
+ * — the extension reads each provider's own env var directly.
242
+ */
243
+ export function webSearchConfigFromEnv(env = process.env) {
244
+ const explicit = env.WEB_SEARCH_PROVIDER?.trim().toLowerCase();
245
+ if (explicit) {
246
+ // A pinned provider still needs its credential/URL present. For a provider we
247
+ // know the env var for, require it; an unknown provider id is taken on trust
248
+ // (its env var isn't in our table, so we can't validate it).
249
+ const known = WEB_SEARCH_PROVIDER_ENV.find((p) => p.provider === explicit);
250
+ if (known && !env[known.envVar]?.trim())
251
+ return undefined;
252
+ return { provider: explicit };
253
+ }
254
+ for (const { provider, envVar } of WEB_SEARCH_PROVIDER_ENV) {
255
+ if (env[envVar]?.trim())
256
+ return { provider };
257
+ }
258
+ return undefined;
259
+ }
260
+ /**
261
+ * The env that points the rpiv-web-tools SearXNG provider at the backend's
262
+ * search proxy: `SEARXNG_URL` = `${proxyBaseUrl}/web-search` (the controller mounted
263
+ * under the LLM proxy's `/v1`), and `SEARXNG_API_KEY` = the per-job session token,
264
+ * which the proxy verifies exactly like the LLM proxy. Handed to Pi's child via
265
+ * `runPi`'s `extraEnv`, so the search key never has to enter the sandbox — the search
266
+ * runs server-side under the deployment's own provider key.
267
+ */
268
+ export function webSearchProxyEnv(proxyBaseUrl, sessionToken) {
269
+ return {
270
+ SEARXNG_URL: `${proxyBaseUrl.replace(/\/+$/, '')}/web-search`,
271
+ SEARXNG_API_KEY: sessionToken,
272
+ };
273
+ }
274
+ /**
275
+ * Select the active rpiv-web-tools provider by writing
276
+ * `~/.config/rpiv-web-tools/config.json` (the file the extension reads, falling
277
+ * back to `brave` when `provider` is absent). Only the provider id is written —
278
+ * credentials and base URLs come from the environment (env wins over the file in
279
+ * the extension's own resolution order), so no secret is committed to disk. Written
280
+ * 0600 to match the extension's own permissions for that path.
281
+ */
282
+ export async function writeWebToolsConfig(config) {
283
+ const dir = join(homedir(), '.config', 'rpiv-web-tools');
284
+ await mkdir(dir, { recursive: true });
285
+ const path = join(dir, 'config.json');
286
+ await writeFile(path, JSON.stringify({ provider: config.provider }, null, 2), { mode: 0o600 });
287
+ return path;
288
+ }
289
+ function isObject(value) {
290
+ return typeof value === 'object' && value !== null;
291
+ }
292
+ /**
293
+ * Pull the `todo` tool's result `details` out of a Pi `--mode json` event, or
294
+ * undefined if the event isn't a successful `todo` tool result.
295
+ *
296
+ * The same tool result surfaces on the stream as two raw agent events, both of
297
+ * which we read (whichever Pi emits/orders first wins; the counts are identical):
298
+ * - `message_end` with a `toolResult` message — `message.details`
299
+ * - `tool_execution_end` — `result.details`
300
+ * A top-level `tool_result` shape is also accepted defensively. Pi has no
301
+ * built-in todo tool, so this only ever matches the installed extension's calls.
302
+ */
303
+ function todoResultDetails(event) {
304
+ if (event.type === 'message_end' && isObject(event.message)) {
305
+ const m = event.message;
306
+ if (m.role === 'toolResult' &&
307
+ m.toolName === 'todo' &&
308
+ m.isError !== true &&
309
+ isObject(m.details)) {
310
+ return m.details;
311
+ }
312
+ return undefined;
313
+ }
314
+ if (event.type === 'tool_execution_end' && event.toolName === 'todo' && event.isError !== true) {
315
+ return isObject(event.result) && isObject(event.result.details)
316
+ ? event.result.details
317
+ : undefined;
318
+ }
319
+ if (event.type === 'tool_result' && event.toolName === 'todo' && event.isError !== true) {
320
+ return isObject(event.details) ? event.details : undefined;
321
+ }
322
+ return undefined;
323
+ }
324
+ /**
325
+ * Derive {@link TodoProgress} from a single Pi `--mode json` event, or undefined
326
+ * if the event isn't a successful `todo` tool result we can read.
327
+ *
328
+ * Pi has no built-in todo tool; the image installs the `@juicesharp/rpiv-todo`
329
+ * extension, whose every successful call returns `details.tasks[]` with a
330
+ * per-task `status` (pending | in_progress | completed | deleted). We also accept
331
+ * the simpler `details.todos[].done` shape of Pi's bundled example extension, so
332
+ * swapping the extension never silently drops progress.
333
+ */
334
+ /**
335
+ * Best-effort subject for a todo task. rpiv-todo creates tasks with a `subject`
336
+ * (see the `todo` `create` action); we also accept the common alternates so a
337
+ * minor extension change never blanks the label. Falls back to "Untitled task".
338
+ */
339
+ function taskLabel(task) {
340
+ if (task && typeof task === 'object') {
341
+ const t = task;
342
+ for (const key of ['subject', 'title', 'content', 'text', 'name', 'task']) {
343
+ const v = t[key];
344
+ if (typeof v === 'string' && v.trim())
345
+ return v.trim();
346
+ }
347
+ }
348
+ return 'Untitled task';
349
+ }
350
+ export function parseTodoProgress(event) {
351
+ const d = todoResultDetails(event);
352
+ if (!d)
353
+ return undefined;
354
+ if (Array.isArray(d.tasks)) {
355
+ let total = 0;
356
+ let completed = 0;
357
+ let inProgress = 0;
358
+ const items = [];
359
+ for (const task of d.tasks) {
360
+ const status = task?.status;
361
+ if (status === 'deleted')
362
+ continue;
363
+ total++;
364
+ if (status === 'completed')
365
+ completed++;
366
+ else if (status === 'in_progress')
367
+ inProgress++;
368
+ items.push({
369
+ label: taskLabel(task),
370
+ status: status === 'completed'
371
+ ? 'completed'
372
+ : status === 'in_progress'
373
+ ? 'in_progress'
374
+ : 'pending',
375
+ });
376
+ }
377
+ return { completed, inProgress, total, items };
378
+ }
379
+ if (Array.isArray(d.todos)) {
380
+ const completed = d.todos.filter((t) => t?.done === true).length;
381
+ return { completed, inProgress: 0, total: d.todos.length };
382
+ }
383
+ return undefined;
384
+ }
385
+ /** Tool-call signal read off a streamed Pi event, or undefined if not a tool call. */
386
+ function toolCallSignal(event) {
387
+ // `tool_execution_end` is the canonical per-call stream event (statsFromEvents
388
+ // counts the same one), so the guard reads it and nothing else — no double count.
389
+ if (event.type !== 'tool_execution_end')
390
+ return undefined;
391
+ const name = typeof event.toolName === 'string' ? event.toolName : '';
392
+ return { name, isError: event.isError === true };
393
+ }
394
+ // `satisfies` (not a type annotation) so each property keeps its concrete `number`
395
+ // type — `maxConsecutiveWebCalls` is optional on the interface (callers may omit it),
396
+ // but the defaults always define it, so consumers reading it off here get a `number`.
397
+ export const DEFAULT_PROGRESS_GUARD_LIMITS = {
398
+ // Counts only non-exploration, non-planning calls (see EXPLORATION_TOOLS), so the
399
+ // ceiling can be generous without risking a false kill on a read-heavy large task.
400
+ maxToolCallsWithoutEdit: 40,
401
+ maxConsecutiveErrors: 12,
402
+ // A genuine research burst is a handful of searches; an uninterrupted run of this
403
+ // many web calls (with no read/edit/bash between) is a search loop, not progress.
404
+ maxConsecutiveWebCalls: 25,
405
+ };
406
+ // Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
407
+ // broad on purpose: different models/extensions name the same capability differently
408
+ // (`edit`/`write`, but also `apply_patch`/`patch`/`str_replace`/`multiedit`/`create`),
409
+ // and a false "no edits" reading would kill a run that IS making changes. Matched
410
+ // case-insensitively. NOTE: a file written purely via `bash` (e.g. a heredoc) is not
411
+ // recognised here — broaden or move to a working-tree signal if that becomes common.
412
+ const FILE_EDIT_TOOLS = new Set([
413
+ 'edit',
414
+ 'write',
415
+ 'apply_patch',
416
+ 'patch',
417
+ 'str_replace',
418
+ 'multiedit',
419
+ 'create',
420
+ ]);
421
+ // Planning/bookkeeping tools that are neither file edits nor the environment-probing
422
+ // the no-edit bound targets — the todo list the agent maintains as it works. These do
423
+ // NOT count toward `maxToolCallsWithoutEdit`: a run that diligently updates a long
424
+ // todo list before its first edit (common on a large task) would otherwise be killed
425
+ // for "no edits" purely from planning calls. They still reset the consecutive-error
426
+ // streak (a successful call means the agent isn't wedged). Matched case-insensitively.
427
+ const PLANNING_TOOLS = new Set(['todo']);
428
+ // Read-only exploration tools: reading/searching the repo is legitimate work-up to an
429
+ // edit, NOT the environment-probing the no-edit bound targets, so they don't count
430
+ // toward `maxToolCallsWithoutEdit` (a large task may read/search dozens of files
431
+ // before its first edit). The bound thus counts only "action" calls — chiefly `bash`
432
+ // (the credential rabbit-hole's vector) — that have yet to produce an edit. Kept broad
433
+ // since models/extensions name the same capability differently. Matched case-insensitively.
434
+ const EXPLORATION_TOOLS = new Set([
435
+ 'read',
436
+ 'grep',
437
+ 'search',
438
+ 'glob',
439
+ 'ls',
440
+ 'list',
441
+ 'find',
442
+ 'tree',
443
+ 'cat',
444
+ 'view',
445
+ 'head',
446
+ 'tail',
447
+ 'stat',
448
+ // rpiv-web-tools: querying/reading the web is read-only research up to an edit,
449
+ // not the environment-probing the no-edit bound targets, so it doesn't count.
450
+ 'web_search',
451
+ 'web_fetch',
452
+ ]);
453
+ // The rpiv-web-tools calls, tracked separately so an unbounded run of them (with no
454
+ // other tool call between) can be caught as a search loop — see `maxConsecutiveWebCalls`.
455
+ const WEB_TOOLS = new Set(['web_search', 'web_fetch']);
456
+ /** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
457
+ export function progressGuardLimitsFromEnv(env = process.env) {
458
+ const num = (raw, fallback) => {
459
+ const n = Number(raw);
460
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
461
+ };
462
+ return {
463
+ maxToolCallsWithoutEdit: num(env.JOB_MAX_TOOLCALLS_WITHOUT_EDIT, DEFAULT_PROGRESS_GUARD_LIMITS.maxToolCallsWithoutEdit),
464
+ maxConsecutiveErrors: num(env.JOB_MAX_CONSECUTIVE_TOOL_ERRORS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveErrors),
465
+ maxConsecutiveWebCalls: num(env.JOB_MAX_CONSECUTIVE_WEB_CALLS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls),
466
+ };
467
+ }
468
+ /**
469
+ * Apply per-knob overrides onto a base set of guard limits, ENFORCING loosen-only: an
470
+ * override can only RAISE a knob (more headroom), never lower it below the base. A
471
+ * larger value is more lenient for every knob (more no-edit tool calls / errors / web
472
+ * calls tolerated), so each result is `max(base, override)`. This is a hard guarantee,
473
+ * not a convention — a tuning entry (built-in or a custom kind's, which reaches this via
474
+ * an untrusted job body) that supplies a value TIGHTER than the base is clamped back up
475
+ * to the base rather than aborting a legitimately-progressing run. An absent/undefined
476
+ * knob keeps the base value untouched.
477
+ */
478
+ export function mergeGuardLimits(base, overrides) {
479
+ if (!overrides)
480
+ return base;
481
+ const loosen = (b, o) => typeof o === 'number' ? Math.max(b, o) : b;
482
+ return {
483
+ maxToolCallsWithoutEdit: loosen(base.maxToolCallsWithoutEdit, overrides.maxToolCallsWithoutEdit),
484
+ maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
485
+ // `maxConsecutiveWebCalls` is optional on the interface (callers may omit it), so
486
+ // fall back to the default before loosening — keeps `loosen`'s base a concrete number.
487
+ maxConsecutiveWebCalls: loosen(base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls, overrides.maxConsecutiveWebCalls),
488
+ };
489
+ }
490
+ /**
491
+ * Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
492
+ * reason the moment a run has plainly stopped making progress, so the harness can
493
+ * kill Pi early instead of letting it burn the whole budget (and then surface a
494
+ * useful failure instead of a generic "no file changes"). Pure and incremental so
495
+ * it can be unit-tested over a fixed event sequence.
496
+ */
497
+ export class ProgressGuard {
498
+ limits;
499
+ expectsEdits;
500
+ toolCalls = 0;
501
+ edits = 0;
502
+ consecutiveErrors = 0;
503
+ consecutiveWebCalls = 0;
504
+ constructor(limits,
505
+ /** When false (assess-only runs like the merger), the no-edit bound is skipped. */
506
+ expectsEdits = true) {
507
+ this.limits = limits;
508
+ this.expectsEdits = expectsEdits;
509
+ }
510
+ /** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
511
+ observe(event) {
512
+ const tool = toolCallSignal(event);
513
+ if (!tool)
514
+ return null;
515
+ const name = tool.name.toLowerCase();
516
+ // The error streak tracks ANY tool call (a planning call still proves the agent
517
+ // isn't wedged in a failing-op loop), so it's updated before the planning skip.
518
+ this.consecutiveErrors = tool.isError ? this.consecutiveErrors + 1 : 0;
519
+ if (this.consecutiveErrors >= this.limits.maxConsecutiveErrors) {
520
+ return (`no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
521
+ `retrying a failing operation rather than making progress. Aborting.`);
522
+ }
523
+ // Web search/fetch loop: web tools are read-only (they don't count toward the
524
+ // no-edit bound), so guard them separately — an uninterrupted streak of them is a
525
+ // research rabbit-hole. Any non-web tool call resets the streak.
526
+ if (WEB_TOOLS.has(name)) {
527
+ this.consecutiveWebCalls++;
528
+ const webCap = this.limits.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls;
529
+ if (this.consecutiveWebCalls >= webCap) {
530
+ return (`no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
531
+ `any other action — the agent is stuck researching instead of doing the work. Aborting.`);
532
+ }
533
+ }
534
+ else {
535
+ this.consecutiveWebCalls = 0;
536
+ }
537
+ // Planning and read-only exploration calls don't count toward the no-edit bound
538
+ // (see PLANNING_TOOLS / EXPLORATION_TOOLS) — only "action" calls without an edit do.
539
+ if (PLANNING_TOOLS.has(name) || EXPLORATION_TOOLS.has(name))
540
+ return null;
541
+ this.toolCalls++;
542
+ if (FILE_EDIT_TOOLS.has(name))
543
+ this.edits++;
544
+ if (this.expectsEdits &&
545
+ this.edits === 0 &&
546
+ this.toolCalls >= this.limits.maxToolCallsWithoutEdit) {
547
+ return (`no progress: ${this.toolCalls} tool calls and not one file edit — the agent is exploring or ` +
548
+ `probing the environment without implementing anything. Aborting before it burns the whole run.`);
549
+ }
550
+ return null;
551
+ }
552
+ }
553
+ /**
554
+ * Run Pi non-interactively against `cwd` and return its assistant summary. Uses
555
+ * print + JSON mode (`-p --mode json`) with `--approve` so it runs unattended.
556
+ *
557
+ * The (untrusted) prompt is fed over stdin, never as an argv positional, so a
558
+ * prompt beginning with `-`/`--` can't be mis-parsed as a Pi CLI flag (Pi has no
559
+ * `--` end-of-options terminator, so a positional `-foo` errors as "Unknown
560
+ * option"). Pi's print mode reads the prompt from piped stdin; we write it and
561
+ * close the pipe so Pi gets an immediate EOF and proceeds (an open, never-closed
562
+ * stdin pipe would make print mode block forever waiting for EOF).
563
+ */
564
+ export function runPi(opts) {
565
+ return new Promise((resolve, reject) => {
566
+ if (opts.signal?.aborted) {
567
+ reject(new Error('pi aborted before start'));
568
+ return;
569
+ }
570
+ const child = spawn('pi', ['-p', '--mode', 'json', '--model', `proxy/${opts.model}`, '--approve'], {
571
+ cwd: opts.cwd,
572
+ env: { ...process.env, ...opts.extraEnv, PI_PROXY_TOKEN: opts.sessionToken },
573
+ // stdin is piped (not 'ignore') so the prompt is delivered out-of-band
574
+ // rather than on argv — see the function doc for the injection rationale.
575
+ stdio: ['pipe', 'pipe', 'pipe'],
576
+ });
577
+ // Hand Pi the prompt over stdin, then close it so print mode sees EOF and
578
+ // runs. Ignore stdin errors (e.g. EPIPE if Pi exits before reading): the
579
+ // 'close'/'error' handlers below own the actual failure reporting.
580
+ child.stdin.on('error', () => { });
581
+ child.stdin.end(opts.userPrompt);
582
+ let stdout = '';
583
+ let stderr = '';
584
+ let aborted = false;
585
+ // Set when the no-progress guard kills Pi; carries the diagnostic the run
586
+ // fails with (distinct from an external watchdog abort).
587
+ let guardReason;
588
+ // Pi's json mode is strict LF-framed JSONL; buffer partial lines across
589
+ // chunks so we only ever parse complete records for progress + the guard.
590
+ let lineBuffer = '';
591
+ // Counters for silent losses, warned ONCE at close (not per-line, to avoid log
592
+ // spam): `{`-leading lines that failed to JSON.parse, and observer-callback throws.
593
+ let malformedLines = 0;
594
+ let observerErrors = 0;
595
+ const guard = new ProgressGuard(opts.guardLimits ?? progressGuardLimitsFromEnv(), opts.expectsEdits ?? true);
596
+ // Start boundary for the next tool span: each tool's slice runs from the previous
597
+ // tool's end (or the run start) to its own `tool_execution_end`. Approximate but
598
+ // contiguous — enough for the trace tree, and metadata-only.
599
+ let toolBoundary = Date.now();
600
+ // SIGTERM first, then SIGKILL if Pi ignores it. Shared by the watchdog abort
601
+ // and the no-progress guard; the `close` handler turns it into a rejection.
602
+ const killChild = () => killChildProcess(child);
603
+ // Parse each complete JSONL record once, feeding both the todo-progress
604
+ // emitter and the no-progress guard. A tripped guard kills Pi with a
605
+ // diagnostic the run then fails on.
606
+ // `runGuard` is false only for the at-close flush of a final unterminated line: the
607
+ // process has already exited, so feeding that record to the no-progress guard could trip
608
+ // it and turn a clean (code 0) exit into a spurious "no progress" rejection. The flush
609
+ // still recovers the record's progress/span signal; only the kill decision is skipped.
610
+ const processLine = (line, runGuard = true) => {
611
+ if (!line.startsWith('{'))
612
+ return;
613
+ let event;
614
+ try {
615
+ event = JSON.parse(line);
616
+ }
617
+ catch {
618
+ // A `{`-leading line that doesn't parse is a corrupted/truncated record we drop
619
+ // (progress/spans for it are lost). Count it; warned once at close.
620
+ malformedLines++;
621
+ return;
622
+ }
623
+ if (opts.onEvent) {
624
+ try {
625
+ opts.onEvent(event);
626
+ }
627
+ catch {
628
+ // A faulty observer must never break the run.
629
+ observerErrors++;
630
+ }
631
+ }
632
+ if (opts.onProgress) {
633
+ const progress = parseTodoProgress(event);
634
+ if (progress)
635
+ opts.onProgress(progress);
636
+ }
637
+ if (opts.onSpan) {
638
+ const signal = toolCallSignal(event);
639
+ if (signal && signal.name) {
640
+ const endedAt = Date.now();
641
+ try {
642
+ opts.onSpan({
643
+ tool: signal.name,
644
+ startedAt: toolBoundary,
645
+ endedAt,
646
+ ok: !signal.isError,
647
+ });
648
+ }
649
+ catch {
650
+ // A faulty observer must never break the run.
651
+ observerErrors++;
652
+ }
653
+ toolBoundary = endedAt;
654
+ }
655
+ }
656
+ if (runGuard && !guardReason && !aborted) {
657
+ const reason = guard.observe(event);
658
+ if (reason) {
659
+ guardReason = reason;
660
+ killChild();
661
+ }
662
+ }
663
+ };
664
+ const consumeStdout = (text) => {
665
+ lineBuffer += text;
666
+ let nl = lineBuffer.indexOf('\n');
667
+ while (nl !== -1) {
668
+ const line = lineBuffer.slice(0, nl).trim();
669
+ lineBuffer = lineBuffer.slice(nl + 1);
670
+ nl = lineBuffer.indexOf('\n');
671
+ processLine(line);
672
+ }
673
+ };
674
+ // When the watchdog aborts, terminate Pi: the `close` handler then rejects
675
+ // with the abort reason.
676
+ const onAbort = () => {
677
+ aborted = true;
678
+ killChild();
679
+ };
680
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
681
+ const onChunk = (chunk, sink) => {
682
+ const text = chunk.toString();
683
+ if (sink === 'out') {
684
+ stdout += text;
685
+ consumeStdout(text);
686
+ }
687
+ else
688
+ stderr += text;
689
+ // Any output means progress: reset the inactivity watchdog.
690
+ opts.onActivity?.();
691
+ };
692
+ child.stdout.on('data', (chunk) => onChunk(chunk, 'out'));
693
+ child.stderr.on('data', (chunk) => onChunk(chunk, 'err'));
694
+ child.on('error', (error) => {
695
+ opts.signal?.removeEventListener('abort', onAbort);
696
+ reject(error);
697
+ });
698
+ child.on('close', (code) => {
699
+ opts.signal?.removeEventListener('abort', onAbort);
700
+ // Flush a final record that arrived without a trailing newline: Pi usually LF-frames
701
+ // every line, but a clean exit can leave the last event (often `agent_end`) unterminated
702
+ // in the buffer, so without this its progress/span/guard signal would be silently lost.
703
+ if (lineBuffer.trim()) {
704
+ processLine(lineBuffer.trim(), false);
705
+ lineBuffer = '';
706
+ }
707
+ // Surface any silent stream losses ONCE (counts, not per-line), so a corrupted JSONL
708
+ // stream or a throwing observer is diagnosable rather than invisible.
709
+ if (malformedLines > 0 || observerErrors > 0) {
710
+ log.warn('pi: skipped malformed JSONL lines / observer errors', {
711
+ malformedLines,
712
+ observerErrors,
713
+ });
714
+ }
715
+ if (guardReason) {
716
+ const tail = redactSecrets(stderr.trim()).slice(-700);
717
+ reject(new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason));
718
+ }
719
+ else if (aborted) {
720
+ reject(new Error(opts.signal?.reason instanceof Error ? opts.signal.reason.message : 'pi aborted'));
721
+ }
722
+ else if (code === 0) {
723
+ const tail = redactSecrets(stderr.trim()).slice(-1500);
724
+ // Pi can exit 0 even when the agent run ended in a hard error (e.g. every
725
+ // model call failed and its retries were exhausted): the process completed,
726
+ // but the agent did not. Exit code alone then reads as success, and a run
727
+ // that RESUMED a branch with prior commits would even open a PR off work this
728
+ // pass never produced. Inspect the terminal transcript and fail loudly so the
729
+ // step is marked failed instead of masking a total failure as green.
730
+ const runError = terminalRunError(stdout);
731
+ if (runError) {
732
+ const scrubbed = redactSecrets(runError).slice(0, 1000);
733
+ reject(new Error(tail ? `${scrubbed} Agent stderr: ${tail}` : scrubbed));
734
+ }
735
+ else {
736
+ resolve({ ...summarizePiRun(stdout), ...(tail ? { stderrTail: tail } : {}) });
737
+ }
738
+ }
739
+ else {
740
+ reject(new Error(`pi exited with code ${code}: ${(stderr || stdout).slice(-500)}`));
741
+ }
742
+ });
743
+ });
744
+ }
745
+ /** Parse Pi's LF-framed JSONL stdout into its event records, skipping noise. */
746
+ function parsePiEvents(stdout) {
747
+ const events = [];
748
+ for (const raw of stdout.split('\n')) {
749
+ const line = raw.trim();
750
+ if (!line.startsWith('{'))
751
+ continue;
752
+ try {
753
+ events.push(JSON.parse(line));
754
+ }
755
+ catch {
756
+ // Not a JSON event line; skip.
757
+ }
758
+ }
759
+ return events;
760
+ }
761
+ /**
762
+ * The terminal-failure message when Pi's run ended in a hard error (the model was
763
+ * unreachable / refused, and Pi exhausted its auto-retries), else undefined. Only
764
+ * the FINAL outcome counts: a mid-run hiccup the agent recovered from leaves a clean
765
+ * terminal `agent_end`, so it returns undefined. Scans from the end and decides on
766
+ * the first terminal signal it meets — the trailing `auto_retry_end` (its `success`
767
+ * flag) or the last `agent_end` (its `stopReason`). Pure so it is unit-testable over
768
+ * a fixed event sequence.
769
+ */
770
+ export function terminalRunError(stdout) {
771
+ const events = parsePiEvents(stdout);
772
+ for (let i = events.length - 1; i >= 0; i--) {
773
+ const e = events[i];
774
+ if (e.type === 'auto_retry_end') {
775
+ if (e.success === false) {
776
+ return typeof e.finalError === 'string'
777
+ ? e.finalError
778
+ : 'the agent failed after exhausting its retries';
779
+ }
780
+ return undefined;
781
+ }
782
+ if (e.type === 'agent_end') {
783
+ return e.stopReason === 'error' && typeof e.errorMessage === 'string'
784
+ ? e.errorMessage
785
+ : undefined;
786
+ }
787
+ }
788
+ return undefined;
789
+ }
790
+ /**
791
+ * Pi's assistant summary plus {@link PiRunStats}, derived from one pass over its
792
+ * output — the canonical close-of-run signal the harness uses both to report the
793
+ * answer and to detect a no-op run (the agent never acted).
794
+ */
795
+ export function summarizePiRun(stdout) {
796
+ const events = parsePiEvents(stdout);
797
+ return {
798
+ summary: summaryFromEvents(events, stdout),
799
+ stats: statsFromEvents(events),
800
+ diagnostics: diagnosticsFromEvents(events),
801
+ };
802
+ }
803
+ /**
804
+ * Output-quality signals over the canonical `agent_end` transcript: whether any
805
+ * completion hit the output ceiling (its content was cut off), whether the FINAL
806
+ * completion did, and whether that final turn carried no text at all. Pure so it is
807
+ * unit-testable over a fixed event sequence. Defaults to all-false when there is no
808
+ * terminal transcript (a no-op run is already caught by {@link agentNeverActed}).
809
+ *
810
+ * `cap` is the per-completion ceiling Pi requested ({@link PI_MAX_OUTPUT_TOKENS});
811
+ * truncation is detected by an assistant message whose `usage.output` reached it,
812
+ * which is reliable even when the model reports a non-`length` stop reason (Workers
813
+ * AI labelled a cut-off tool call `tool_calls`, not `length`).
814
+ */
815
+ export function diagnosticsFromEvents(events, cap = PI_MAX_OUTPUT_TOKENS) {
816
+ let messages;
817
+ for (let i = events.length - 1; i >= 0; i--) {
818
+ const e = events[i];
819
+ if (e.type === 'agent_end' && Array.isArray(e.messages)) {
820
+ messages = e.messages;
821
+ break;
822
+ }
823
+ }
824
+ if (!messages)
825
+ return { truncated: false, finalTruncated: false, finalAnswerEmpty: false };
826
+ const assistants = messages.filter((m) => isObject(m) && m.role === 'assistant');
827
+ const truncated = assistants.some((m) => assistantOutputTokens(m) >= cap);
828
+ const last = assistants.at(-1);
829
+ return {
830
+ truncated,
831
+ finalTruncated: last ? assistantOutputTokens(last) >= cap : false,
832
+ finalAnswerEmpty: last ? messageText(last) === '' : false,
833
+ };
834
+ }
835
+ /** `usage.output` (completion tokens) reported on a Pi assistant message, or 0. */
836
+ function assistantOutputTokens(message) {
837
+ const usage = message.usage;
838
+ if (!isObject(usage))
839
+ return 0;
840
+ const output = usage.output;
841
+ return typeof output === 'number' ? output : 0;
842
+ }
843
+ /** {@link RunDiagnostics} over Pi's raw `--mode json` stdout (see {@link diagnosticsFromEvents}). */
844
+ export function runDiagnostics(stdout, cap = PI_MAX_OUTPUT_TOKENS) {
845
+ return diagnosticsFromEvents(parsePiEvents(stdout), cap);
846
+ }
847
+ /**
848
+ * Count what the agent actually did. Prefers the canonical `agent_end`
849
+ * transcript (assistant `toolCall` parts + text); falls back to the streamed
850
+ * `tool_execution_end` / `message_end` events when no terminal transcript was
851
+ * emitted, so a no-op is never mistaken for a real run because of a schema tweak.
852
+ */
853
+ function statsFromEvents(events) {
854
+ for (let i = events.length - 1; i >= 0; i--) {
855
+ const e = events[i];
856
+ if (e.type === 'agent_end' && Array.isArray(e.messages)) {
857
+ return statsFromMessages(e.messages);
858
+ }
859
+ }
860
+ let toolCalls = 0;
861
+ let toolResults = 0;
862
+ let assistantChars = 0;
863
+ for (const e of events) {
864
+ if (e.type === 'tool_execution_end') {
865
+ toolCalls++;
866
+ }
867
+ else if (e.type === 'message_end' && isObject(e.message)) {
868
+ const m = e.message;
869
+ if (m.role === 'assistant')
870
+ assistantChars += messageText(m).length;
871
+ else if (m.role === 'toolResult')
872
+ toolResults++;
873
+ }
874
+ }
875
+ // The same call can surface as both a `tool_execution_end` and a toolResult
876
+ // `message_end`; prefer the former and only fall back to toolResult counts.
877
+ return { toolCalls: toolCalls || toolResults, assistantChars };
878
+ }
879
+ /** {@link PiRunStats} from a transcript: assistant `toolCall` parts + text length. */
880
+ function statsFromMessages(messages) {
881
+ let toolCalls = 0;
882
+ let assistantChars = 0;
883
+ for (const m of messages) {
884
+ if (!isObject(m) || m.role !== 'assistant')
885
+ continue;
886
+ const content = m.content;
887
+ if (typeof content === 'string') {
888
+ assistantChars += content.trim().length;
889
+ }
890
+ else if (Array.isArray(content)) {
891
+ for (const part of content) {
892
+ if (!isObject(part))
893
+ continue;
894
+ if (part.type === 'toolCall')
895
+ toolCalls++;
896
+ else if (typeof part.text === 'string')
897
+ assistantChars += part.text.length;
898
+ }
899
+ }
900
+ }
901
+ return { toolCalls, assistantChars };
902
+ }
903
+ /**
904
+ * Extract the assistant's final summary from Pi's JSON-lines output. Pi emits a
905
+ * terminal `agent_end` event whose `messages` is the full transcript, so the
906
+ * last assistant message there is the canonical answer. Falls back to scanning
907
+ * `message_end` events, then to a raw tail, so a schema tweak never loses output.
908
+ */
909
+ export function parsePiOutput(stdout) {
910
+ return summaryFromEvents(parsePiEvents(stdout), stdout);
911
+ }
912
+ /** Shared summary extraction over already-parsed events (see {@link parsePiOutput}). */
913
+ function summaryFromEvents(events, stdout) {
914
+ // Preferred: the final transcript from the last agent_end event.
915
+ for (let i = events.length - 1; i >= 0; i--) {
916
+ const e = events[i];
917
+ if (e.type === 'agent_end' && Array.isArray(e.messages)) {
918
+ const text = lastAssistantText(e.messages);
919
+ if (text)
920
+ return text;
921
+ }
922
+ }
923
+ // Fallback: assistant text accumulated from message_end events.
924
+ const parts = [];
925
+ for (const e of events) {
926
+ if (e.type === 'message_end' &&
927
+ typeof e.message === 'object' &&
928
+ e.message !== null &&
929
+ e.message.role === 'assistant') {
930
+ const text = messageText(e.message);
931
+ if (text)
932
+ parts.push(text);
933
+ }
934
+ }
935
+ const joined = parts.join('\n').trim();
936
+ if (joined)
937
+ return joined;
938
+ // Nothing structured matched — return a trimmed tail of the raw output.
939
+ return stdout.trim().slice(-2000);
940
+ }
941
+ /** The text of the last assistant message in a transcript, or '' if none. */
942
+ function lastAssistantText(messages) {
943
+ for (let i = messages.length - 1; i >= 0; i--) {
944
+ const m = messages[i];
945
+ if (typeof m === 'object' && m !== null && m.role === 'assistant') {
946
+ const text = messageText(m);
947
+ if (text)
948
+ return text;
949
+ }
950
+ }
951
+ return '';
952
+ }
953
+ /** Join the text parts of a Pi message whose content is a string or parts array. */
954
+ function messageText(message) {
955
+ if (typeof message !== 'object' || message === null)
956
+ return '';
957
+ const content = message.content;
958
+ if (typeof content === 'string')
959
+ return content.trim();
960
+ if (Array.isArray(content)) {
961
+ return content
962
+ .map((part) => typeof part === 'object' &&
963
+ part !== null &&
964
+ typeof part.text === 'string'
965
+ ? part.text
966
+ : '')
967
+ .join('')
968
+ .trim();
969
+ }
970
+ return '';
971
+ }