@cat-factory/executor-harness 1.66.0 → 1.70.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/src/agent.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path'
2
2
  import { tmpdir } from 'node:os'
3
- import { mkdir, mkdtemp, opendir, rm } from 'node:fs/promises'
3
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
4
4
  import { execFile } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import type {
@@ -20,17 +20,14 @@ import {
20
20
  conflictDiff,
21
21
  fetchPullRequestHead,
22
22
  fetchReferenceBranches,
23
- hasAgentChanges,
24
23
  headCommit,
25
24
  mergeBranch,
26
25
  prepareExistingCheckout,
27
26
  pushBranch,
28
- reinitAndPush,
29
27
  unmergedPaths,
30
28
  } from './git.js'
31
29
  import { inferVcsProvider, openPullRequest } from './vcs-api.js'
32
30
  import type { PiRunStats, RunDiagnostics } from './pi.js'
33
- import type { EffortReport } from './effort.js'
34
31
  import { applyPrDescription } from './pr-description.js'
35
32
  import {
36
33
  makeDirClaimer,
@@ -39,6 +36,8 @@ import {
39
36
  runMultiRepoCoding,
40
37
  } from './coding-agent.js'
41
38
  import { validationFailureMessage } from './validation-checks.js'
39
+ import { agentCapabilities, mergeEffort } from './agent-shared.js'
40
+ import { runBootstrap } from './bootstrap-mode.js'
42
41
  import {
43
42
  acquireRepoCheckout,
44
43
  agentNeverActed,
@@ -256,6 +255,7 @@ async function resolveReplyCustom(
256
255
  subscriptionToken: job.subscriptionToken,
257
256
  subscriptionBaseUrl: job.subscriptionBaseUrl,
258
257
  proxyBaseUrl: job.proxyBaseUrl,
258
+ proxyPhasePath: job.proxyPhasePath,
259
259
  sessionToken: job.sessionToken,
260
260
  model: job.model,
261
261
  jobId: job.jobId,
@@ -313,15 +313,6 @@ async function cloneServiceCheckout(
313
313
  return deriveWorkDir(dir, job.repo.serviceDirectory)
314
314
  }
315
315
 
316
- /**
317
- * Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
318
- * onto its final result. Every container mode routes its result through this so the report reaches
319
- * the backend uniformly. A run that wrote no report passes through unchanged.
320
- */
321
- function mergeEffort(result: AgentResult, effortReport: EffortReport | undefined): AgentResult {
322
- return effortReport ? { ...result, effortReport } : result
323
- }
324
-
325
316
  /** Run one generic agent job end to end, dispatching on `mode`. */
326
317
  export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
327
318
  // An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
@@ -599,6 +590,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
599
590
  subscriptionBaseUrl: job.subscriptionBaseUrl,
600
591
  ambientAuth: job.ambientAuth,
601
592
  proxyBaseUrl: job.proxyBaseUrl,
593
+ proxyPhasePath: job.proxyPhasePath,
602
594
  sessionToken: job.sessionToken,
603
595
  serviceDirectory,
604
596
  // Read-only: it inspects and reports, making no edits — so the no-progress
@@ -608,6 +600,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
608
600
  webSearchProxy: job.webSearch,
609
601
  contextFiles: job.contextFiles,
610
602
  guardLimits: job.guardLimits,
603
+ ...agentCapabilities(job),
611
604
  },
612
605
  agentOpts,
613
606
  )
@@ -829,6 +822,7 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
829
822
  subscriptionBaseUrl: job.subscriptionBaseUrl,
830
823
  ambientAuth: job.ambientAuth,
831
824
  proxyBaseUrl: job.proxyBaseUrl,
825
+ proxyPhasePath: job.proxyPhasePath,
832
826
  sessionToken: job.sessionToken,
833
827
  // Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
834
828
  expectsEdits: false,
@@ -836,6 +830,7 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
836
830
  webSearchProxy: job.webSearch,
837
831
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
838
832
  guardLimits: job.guardLimits,
833
+ ...agentCapabilities(job),
839
834
  multiRepo: true,
840
835
  },
841
836
  opts,
@@ -946,6 +941,7 @@ function buildSingleRepoCodingSpec(
946
941
  subscriptionBaseUrl: job.subscriptionBaseUrl,
947
942
  ambientAuth: job.ambientAuth,
948
943
  proxyBaseUrl: job.proxyBaseUrl,
944
+ proxyPhasePath: job.proxyPhasePath,
949
945
  sessionToken: job.sessionToken,
950
946
  commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
951
947
  webToolsGuidance: job.webToolsGuidance,
@@ -954,8 +950,8 @@ function buildSingleRepoCodingSpec(
954
950
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
955
951
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
956
952
  ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
957
- // Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
958
- ...(job.skill ? { skill: job.skill } : {}),
953
+ // Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
954
+ ...agentCapabilities(job),
959
955
  // Ralph loop: run the completion command after the agent commits and report its verdict.
960
956
  ...(job.validation
961
957
  ? {
@@ -1225,9 +1221,11 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
1225
1221
  subscriptionBaseUrl: job.subscriptionBaseUrl,
1226
1222
  ambientAuth: job.ambientAuth,
1227
1223
  proxyBaseUrl: job.proxyBaseUrl,
1224
+ proxyPhasePath: job.proxyPhasePath,
1228
1225
  sessionToken: job.sessionToken,
1229
1226
  contextFiles: job.contextFiles,
1230
1227
  guardLimits: job.guardLimits,
1228
+ ...agentCapabilities(job),
1231
1229
  },
1232
1230
  opts,
1233
1231
  )
@@ -1328,156 +1326,6 @@ function unresolvedReason(
1328
1326
  )
1329
1327
  }
1330
1328
 
1331
- /**
1332
- * Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
1333
- * the agent adapts it in place per the instructions; without one (`fromScratch`), start from
1334
- * an empty directory → the agent scaffolds the new service. Either way the result's history
1335
- * is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
1336
- * default branch. Diverges from the ordinary coding flow in pushing to a different repo with
1337
- * a reinitialised history rather than a work branch + PR on the cloned repo.
1338
- */
1339
- async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
1340
- const { signal } = opts
1341
- const boot = job.bootstrap!
1342
- const fromScratch = boot.fromScratch === true
1343
- const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` })
1344
- return withWorkspace('boot', async (dir) => {
1345
- if (!fromScratch) {
1346
- opts.onPhase?.('clone')
1347
- logger.info('agent(bootstrap): cloning reference architecture', {
1348
- reference: `${job.repo.owner}/${job.repo.name}`,
1349
- })
1350
- await cloneRepo({
1351
- repo: { ...job.repo, baseBranch: job.branch },
1352
- ghToken: job.ghToken,
1353
- dir,
1354
- signal,
1355
- })
1356
- } else {
1357
- logger.info('agent(bootstrap): scaffolding from scratch (no reference)')
1358
- }
1359
-
1360
- opts.onPhase?.('agent')
1361
- logger.info('agent(bootstrap): running agent')
1362
- const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
1363
- await runAgentInWorkspace(
1364
- {
1365
- dir,
1366
- systemPrompt: job.systemPrompt,
1367
- userPrompt: job.userPrompt,
1368
- model: job.model,
1369
- harness: job.harness,
1370
- subscriptionToken: job.subscriptionToken,
1371
- subscriptionBaseUrl: job.subscriptionBaseUrl,
1372
- ambientAuth: job.ambientAuth,
1373
- proxyBaseUrl: job.proxyBaseUrl,
1374
- sessionToken: job.sessionToken,
1375
- guardLimits: job.guardLimits,
1376
- },
1377
- opts,
1378
- )
1379
-
1380
- // Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
1381
- // reached the model), and a force-push would then publish an empty tree — leaving the
1382
- // run "succeeded" but the repo bare. Fail with a structured error (carrying what the
1383
- // agent did) instead of pushing nothing.
1384
- if (!(await producedRepoContent(dir, !fromScratch, signal))) {
1385
- const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
1386
- logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
1387
- return mergeEffort(
1388
- {
1389
- summary,
1390
- stats,
1391
- error,
1392
- failureCause: 'agent',
1393
- ...(usage ? { usage } : {}),
1394
- ...(callMetrics ? { callMetrics } : {}),
1395
- },
1396
- effortReport,
1397
- )
1398
- }
1399
-
1400
- opts.onPhase?.('push')
1401
- logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats })
1402
- // Bootstrap always resets history to one commit + force-pushes (the fresh history
1403
- // shares no ancestor with whatever boilerplate the new repo was created with).
1404
- await reinitAndPush({
1405
- dir,
1406
- target: boot.target,
1407
- ghToken: job.ghToken,
1408
- message: fromScratch
1409
- ? 'Bootstrap new repository'
1410
- : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
1411
- })
1412
- logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
1413
- return mergeEffort(
1414
- {
1415
- defaultBranch: boot.target.defaultBranch,
1416
- summary,
1417
- stats,
1418
- ...(usage ? { usage } : {}),
1419
- ...(callMetrics ? { callMetrics } : {}),
1420
- },
1421
- effortReport,
1422
- )
1423
- })
1424
- }
1425
-
1426
- /**
1427
- * Whether the bootstrapper actually produced repository content, so a no-op run (the agent
1428
- * never reached the model / never wrote anything) is failed rather than force-pushed as an
1429
- * empty repo. With a reference architecture, "produced content" means the agent changed the
1430
- * clone; scaffolding from scratch, it means at least one file now exists in the working
1431
- * directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
1432
- * never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
1433
- */
1434
- export async function producedRepoContent(
1435
- dir: string,
1436
- hasReference: boolean,
1437
- signal?: AbortSignal,
1438
- ): Promise<boolean> {
1439
- if (hasReference) return hasAgentChanges(dir, signal)
1440
- return containsAnyFile(dir)
1441
- }
1442
-
1443
- /**
1444
- * Whether `dir` contains at least one regular file anywhere in its tree, walking
1445
- * depth-first and stopping at the FIRST file found — so the cost is bounded by how
1446
- * quickly a file turns up (a scaffold almost always writes a root-level file), not by
1447
- * the size of the produced tree (a full recursive `readdir` would materialise every
1448
- * entry before the check).
1449
- */
1450
- async function containsAnyFile(dir: string): Promise<boolean> {
1451
- const handle = await opendir(dir)
1452
- try {
1453
- for await (const entry of handle) {
1454
- if (entry.isFile()) return true
1455
- if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name)))) return true
1456
- }
1457
- } catch {
1458
- // A directory that vanished mid-walk has nothing to contribute.
1459
- }
1460
- return false
1461
- }
1462
-
1463
- /** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
1464
- function bootstrapNoOpReason(
1465
- hasReference: boolean,
1466
- stats: PiRunStats,
1467
- summary: string,
1468
- stderrTail: string | undefined,
1469
- ): string {
1470
- const what = hasReference
1471
- ? 'made no changes to the reference architecture'
1472
- : 'scaffolded no files'
1473
- const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : ''
1474
- return (
1475
- `the bootstrapper agent ${what} ` +
1476
- `(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
1477
- agentOutputTail(stderrTail, summary)
1478
- )
1479
- }
1480
-
1481
1329
  /** Human-readable reason a read-only run produced no usable output. */
1482
1330
  function noOutputReason(stats: PiRunStats, stderrTail: string | undefined): string {
1483
1331
  const cause = agentNeverActed(stats)
@@ -0,0 +1,175 @@
1
+ import { opendir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { AgentJob, AgentResult } from './job.js'
4
+ import type { PiRunStats } from './pi.js'
5
+ import type { RunOptions } from './runner.js'
6
+ import {
7
+ NEVER_ACTED_CAUSE,
8
+ agentNeverActed,
9
+ agentOutputTail,
10
+ runAgentInWorkspace,
11
+ withWorkspace,
12
+ } from './pi-workspace.js'
13
+ import { cloneRepo, hasAgentChanges, reinitAndPush } from './git.js'
14
+ import { log } from './logger.js'
15
+ import { agentCapabilities, mergeEffort } from './agent-shared.js'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // The repo-BOOTSTRAP mode: adapt a reference architecture (or scaffold from scratch) into a
19
+ // pre-created empty repo and force-push it as a single commit. Extracted from `agent.ts` as a
20
+ // cohesive collaborator — it is a whole MODE with its own push semantics (a separate target repo
21
+ // and a reinitialised history, not a work branch + PR), and it shares only the small agent-run
22
+ // helpers in `agent-shared.ts` with the coding/explore flows.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
27
+ * the agent adapts it in place per the instructions; without one (`fromScratch`), start from
28
+ * an empty directory → the agent scaffolds the new service. Either way the result's history
29
+ * is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
30
+ * default branch. Diverges from the ordinary coding flow in pushing to a different repo with
31
+ * a reinitialised history rather than a work branch + PR on the cloned repo.
32
+ */
33
+ export async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
34
+ const { signal } = opts
35
+ const boot = job.bootstrap!
36
+ const fromScratch = boot.fromScratch === true
37
+ const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` })
38
+ return withWorkspace('boot', async (dir) => {
39
+ if (!fromScratch) {
40
+ opts.onPhase?.('clone')
41
+ logger.info('agent(bootstrap): cloning reference architecture', {
42
+ reference: `${job.repo.owner}/${job.repo.name}`,
43
+ })
44
+ await cloneRepo({
45
+ repo: { ...job.repo, baseBranch: job.branch },
46
+ ghToken: job.ghToken,
47
+ dir,
48
+ signal,
49
+ })
50
+ } else {
51
+ logger.info('agent(bootstrap): scaffolding from scratch (no reference)')
52
+ }
53
+
54
+ opts.onPhase?.('agent')
55
+ logger.info('agent(bootstrap): running agent')
56
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
57
+ await runAgentInWorkspace(
58
+ {
59
+ dir,
60
+ systemPrompt: job.systemPrompt,
61
+ userPrompt: job.userPrompt,
62
+ model: job.model,
63
+ harness: job.harness,
64
+ subscriptionToken: job.subscriptionToken,
65
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
66
+ ambientAuth: job.ambientAuth,
67
+ proxyBaseUrl: job.proxyBaseUrl,
68
+ proxyPhasePath: job.proxyPhasePath,
69
+ sessionToken: job.sessionToken,
70
+ guardLimits: job.guardLimits,
71
+ ...agentCapabilities(job),
72
+ },
73
+ opts,
74
+ )
75
+
76
+ // Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
77
+ // reached the model), and a force-push would then publish an empty tree — leaving the
78
+ // run "succeeded" but the repo bare. Fail with a structured error (carrying what the
79
+ // agent did) instead of pushing nothing.
80
+ if (!(await producedRepoContent(dir, !fromScratch, signal))) {
81
+ const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
82
+ logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
83
+ return mergeEffort(
84
+ {
85
+ summary,
86
+ stats,
87
+ error,
88
+ failureCause: 'agent',
89
+ ...(usage ? { usage } : {}),
90
+ ...(callMetrics ? { callMetrics } : {}),
91
+ },
92
+ effortReport,
93
+ )
94
+ }
95
+
96
+ opts.onPhase?.('push')
97
+ logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats })
98
+ // Bootstrap always resets history to one commit + force-pushes (the fresh history
99
+ // shares no ancestor with whatever boilerplate the new repo was created with).
100
+ await reinitAndPush({
101
+ dir,
102
+ target: boot.target,
103
+ ghToken: job.ghToken,
104
+ message: fromScratch
105
+ ? 'Bootstrap new repository'
106
+ : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
107
+ })
108
+ logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
109
+ return mergeEffort(
110
+ {
111
+ defaultBranch: boot.target.defaultBranch,
112
+ summary,
113
+ stats,
114
+ ...(usage ? { usage } : {}),
115
+ ...(callMetrics ? { callMetrics } : {}),
116
+ },
117
+ effortReport,
118
+ )
119
+ })
120
+ }
121
+
122
+ /**
123
+ * Whether the bootstrapper actually produced repository content, so a no-op run (the agent
124
+ * never reached the model / never wrote anything) is failed rather than force-pushed as an
125
+ * empty repo. With a reference architecture, "produced content" means the agent changed the
126
+ * clone; scaffolding from scratch, it means at least one file now exists in the working
127
+ * directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
128
+ * never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
129
+ */
130
+ export async function producedRepoContent(
131
+ dir: string,
132
+ hasReference: boolean,
133
+ signal?: AbortSignal,
134
+ ): Promise<boolean> {
135
+ if (hasReference) return hasAgentChanges(dir, signal)
136
+ return containsAnyFile(dir)
137
+ }
138
+
139
+ /**
140
+ * Whether `dir` contains at least one regular file anywhere in its tree, walking
141
+ * depth-first and stopping at the FIRST file found — so the cost is bounded by how
142
+ * quickly a file turns up (a scaffold almost always writes a root-level file), not by
143
+ * the size of the produced tree (a full recursive `readdir` would materialise every
144
+ * entry before the check).
145
+ */
146
+ async function containsAnyFile(dir: string): Promise<boolean> {
147
+ const handle = await opendir(dir)
148
+ try {
149
+ for await (const entry of handle) {
150
+ if (entry.isFile()) return true
151
+ if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name)))) return true
152
+ }
153
+ } catch {
154
+ // A directory that vanished mid-walk has nothing to contribute.
155
+ }
156
+ return false
157
+ }
158
+
159
+ /** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
160
+ function bootstrapNoOpReason(
161
+ hasReference: boolean,
162
+ stats: PiRunStats,
163
+ summary: string,
164
+ stderrTail: string | undefined,
165
+ ): string {
166
+ const what = hasReference
167
+ ? 'made no changes to the reference architecture'
168
+ : 'scaffolded no files'
169
+ const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : ''
170
+ return (
171
+ `the bootstrapper agent ${what} ` +
172
+ `(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
173
+ agentOutputTail(stderrTail, summary)
174
+ )
175
+ }