@cat-factory/executor-harness 1.64.4 → 1.68.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 +33 -0
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +111 -25
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +9 -139
- package/dist/bootstrap-mode.js +141 -0
- package/dist/claude-call-aggregator.js +6 -3
- package/dist/claude-stream.js +12 -6
- package/dist/coding-agent.js +85 -67
- package/dist/inline.js +29 -1
- package/dist/job.js +6 -75
- package/dist/pi-workspace.js +15 -14
- package/dist/pi.js +24 -16
- package/dist/subagents.js +14 -3
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +155 -44
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +8 -165
- package/src/bootstrap-mode.ts +174 -0
- package/src/claude-call-aggregator.ts +8 -4
- package/src/claude-stream.ts +15 -7
- package/src/coding-agent.ts +102 -71
- package/src/inline.ts +34 -1
- package/src/job.ts +51 -94
- package/src/pi-workspace.ts +30 -20
- package/src/pi.ts +38 -17
- package/src/subagents.ts +17 -3
package/dist/agent.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
|
-
import { mkdir, mkdtemp,
|
|
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 { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
|
|
7
7
|
import { configurePackageRegistries } from './package-registries.js';
|
|
8
8
|
import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
|
|
9
|
-
import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches,
|
|
9
|
+
import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
|
|
10
10
|
import { inferVcsProvider, openPullRequest } from './vcs-api.js';
|
|
11
11
|
import { applyPrDescription } from './pr-description.js';
|
|
12
12
|
import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
|
|
13
13
|
import { validationFailureMessage } from './validation-checks.js';
|
|
14
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js';
|
|
15
|
+
import { runBootstrap } from './bootstrap-mode.js';
|
|
14
16
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
|
|
15
17
|
import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
|
|
16
18
|
import { log } from './logger.js';
|
|
@@ -231,14 +233,6 @@ async function cloneServiceCheckout(dir, job, signal) {
|
|
|
231
233
|
});
|
|
232
234
|
return deriveWorkDir(dir, job.repo.serviceDirectory);
|
|
233
235
|
}
|
|
234
|
-
/**
|
|
235
|
-
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
236
|
-
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
237
|
-
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
238
|
-
*/
|
|
239
|
-
function mergeEffort(result, effortReport) {
|
|
240
|
-
return effortReport ? { ...result, effortReport } : result;
|
|
241
|
-
}
|
|
242
236
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
243
237
|
export async function handleAgent(job, opts = {}) {
|
|
244
238
|
// An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
|
|
@@ -503,6 +497,7 @@ async function runExploreMode(job, opts) {
|
|
|
503
497
|
webSearchProxy: job.webSearch,
|
|
504
498
|
contextFiles: job.contextFiles,
|
|
505
499
|
guardLimits: job.guardLimits,
|
|
500
|
+
...agentCapabilities(job),
|
|
506
501
|
}, agentOpts);
|
|
507
502
|
return mergeEffort(await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal }), effortReport);
|
|
508
503
|
}
|
|
@@ -690,6 +685,7 @@ async function runMultiRepoExplore(job, opts) {
|
|
|
690
685
|
webSearchProxy: job.webSearch,
|
|
691
686
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
692
687
|
guardLimits: job.guardLimits,
|
|
688
|
+
...agentCapabilities(job),
|
|
693
689
|
multiRepo: true,
|
|
694
690
|
}, opts);
|
|
695
691
|
return mergeEffort(await finalizeExploreResult(job, {
|
|
@@ -792,8 +788,8 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
792
788
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
793
789
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
794
790
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
795
|
-
//
|
|
796
|
-
...(job
|
|
791
|
+
// Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
|
|
792
|
+
...agentCapabilities(job),
|
|
797
793
|
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
798
794
|
...(job.validation
|
|
799
795
|
? {
|
|
@@ -1039,6 +1035,7 @@ async function runConflictResolution(job, opts) {
|
|
|
1039
1035
|
sessionToken: job.sessionToken,
|
|
1040
1036
|
contextFiles: job.contextFiles,
|
|
1041
1037
|
guardLimits: job.guardLimits,
|
|
1038
|
+
...agentCapabilities(job),
|
|
1042
1039
|
}, opts);
|
|
1043
1040
|
// Never push a half-resolved tree: if any conflict markers / unmerged paths remain,
|
|
1044
1041
|
// the PR would still be broken. Fail so the engine can retry / notify.
|
|
@@ -1115,133 +1112,6 @@ function unresolvedReason(unresolved, stats, stderrTail) {
|
|
|
1115
1112
|
`(${unresolved.length} file(s) still conflicted: ${sample}).${cause}` +
|
|
1116
1113
|
agentOutputTail(stderrTail));
|
|
1117
1114
|
}
|
|
1118
|
-
/**
|
|
1119
|
-
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
1120
|
-
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
1121
|
-
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
1122
|
-
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
1123
|
-
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
1124
|
-
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
1125
|
-
*/
|
|
1126
|
-
async function runBootstrap(job, opts) {
|
|
1127
|
-
const { signal } = opts;
|
|
1128
|
-
const boot = job.bootstrap;
|
|
1129
|
-
const fromScratch = boot.fromScratch === true;
|
|
1130
|
-
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
|
|
1131
|
-
return withWorkspace('boot', async (dir) => {
|
|
1132
|
-
if (!fromScratch) {
|
|
1133
|
-
opts.onPhase?.('clone');
|
|
1134
|
-
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
1135
|
-
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
1136
|
-
});
|
|
1137
|
-
await cloneRepo({
|
|
1138
|
-
repo: { ...job.repo, baseBranch: job.branch },
|
|
1139
|
-
ghToken: job.ghToken,
|
|
1140
|
-
dir,
|
|
1141
|
-
signal,
|
|
1142
|
-
});
|
|
1143
|
-
}
|
|
1144
|
-
else {
|
|
1145
|
-
logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
|
|
1146
|
-
}
|
|
1147
|
-
opts.onPhase?.('agent');
|
|
1148
|
-
logger.info('agent(bootstrap): running agent');
|
|
1149
|
-
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
1150
|
-
dir,
|
|
1151
|
-
systemPrompt: job.systemPrompt,
|
|
1152
|
-
userPrompt: job.userPrompt,
|
|
1153
|
-
model: job.model,
|
|
1154
|
-
harness: job.harness,
|
|
1155
|
-
subscriptionToken: job.subscriptionToken,
|
|
1156
|
-
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1157
|
-
ambientAuth: job.ambientAuth,
|
|
1158
|
-
proxyBaseUrl: job.proxyBaseUrl,
|
|
1159
|
-
sessionToken: job.sessionToken,
|
|
1160
|
-
guardLimits: job.guardLimits,
|
|
1161
|
-
}, opts);
|
|
1162
|
-
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
1163
|
-
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
1164
|
-
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
1165
|
-
// agent did) instead of pushing nothing.
|
|
1166
|
-
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1167
|
-
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
|
|
1168
|
-
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
|
|
1169
|
-
return mergeEffort({
|
|
1170
|
-
summary,
|
|
1171
|
-
stats,
|
|
1172
|
-
error,
|
|
1173
|
-
failureCause: 'agent',
|
|
1174
|
-
...(usage ? { usage } : {}),
|
|
1175
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1176
|
-
}, effortReport);
|
|
1177
|
-
}
|
|
1178
|
-
opts.onPhase?.('push');
|
|
1179
|
-
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
|
|
1180
|
-
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
1181
|
-
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
1182
|
-
await reinitAndPush({
|
|
1183
|
-
dir,
|
|
1184
|
-
target: boot.target,
|
|
1185
|
-
ghToken: job.ghToken,
|
|
1186
|
-
message: fromScratch
|
|
1187
|
-
? 'Bootstrap new repository'
|
|
1188
|
-
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1189
|
-
});
|
|
1190
|
-
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
|
|
1191
|
-
return mergeEffort({
|
|
1192
|
-
defaultBranch: boot.target.defaultBranch,
|
|
1193
|
-
summary,
|
|
1194
|
-
stats,
|
|
1195
|
-
...(usage ? { usage } : {}),
|
|
1196
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1197
|
-
}, effortReport);
|
|
1198
|
-
});
|
|
1199
|
-
}
|
|
1200
|
-
/**
|
|
1201
|
-
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
1202
|
-
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
1203
|
-
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
1204
|
-
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
1205
|
-
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
1206
|
-
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
1207
|
-
*/
|
|
1208
|
-
export async function producedRepoContent(dir, hasReference, signal) {
|
|
1209
|
-
if (hasReference)
|
|
1210
|
-
return hasAgentChanges(dir, signal);
|
|
1211
|
-
return containsAnyFile(dir);
|
|
1212
|
-
}
|
|
1213
|
-
/**
|
|
1214
|
-
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
1215
|
-
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
1216
|
-
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
1217
|
-
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
1218
|
-
* entry before the check).
|
|
1219
|
-
*/
|
|
1220
|
-
async function containsAnyFile(dir) {
|
|
1221
|
-
const handle = await opendir(dir);
|
|
1222
|
-
try {
|
|
1223
|
-
for await (const entry of handle) {
|
|
1224
|
-
if (entry.isFile())
|
|
1225
|
-
return true;
|
|
1226
|
-
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
|
|
1227
|
-
return true;
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
catch {
|
|
1231
|
-
// A directory that vanished mid-walk has nothing to contribute.
|
|
1232
|
-
}
|
|
1233
|
-
return false;
|
|
1234
|
-
}
|
|
1235
|
-
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
1236
|
-
function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
|
|
1237
|
-
const what = hasReference
|
|
1238
|
-
? 'made no changes to the reference architecture'
|
|
1239
|
-
: 'scaffolded no files';
|
|
1240
|
-
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
|
|
1241
|
-
return (`the bootstrapper agent ${what} ` +
|
|
1242
|
-
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
1243
|
-
agentOutputTail(stderrTail, summary));
|
|
1244
|
-
}
|
|
1245
1115
|
/** Human-readable reason a read-only run produced no usable output. */
|
|
1246
1116
|
function noOutputReason(stats, stderrTail) {
|
|
1247
1117
|
const cause = agentNeverActed(stats)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { opendir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { NEVER_ACTED_CAUSE, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
|
|
4
|
+
import { cloneRepo, hasAgentChanges, reinitAndPush } from './git.js';
|
|
5
|
+
import { log } from './logger.js';
|
|
6
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// The repo-BOOTSTRAP mode: adapt a reference architecture (or scaffold from scratch) into a
|
|
9
|
+
// pre-created empty repo and force-push it as a single commit. Extracted from `agent.ts` as a
|
|
10
|
+
// cohesive collaborator — it is a whole MODE with its own push semantics (a separate target repo
|
|
11
|
+
// and a reinitialised history, not a work branch + PR), and it shares only the small agent-run
|
|
12
|
+
// helpers in `agent-shared.ts` with the coding/explore flows.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/**
|
|
15
|
+
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
16
|
+
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
17
|
+
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
18
|
+
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
19
|
+
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
20
|
+
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
21
|
+
*/
|
|
22
|
+
export async function runBootstrap(job, opts) {
|
|
23
|
+
const { signal } = opts;
|
|
24
|
+
const boot = job.bootstrap;
|
|
25
|
+
const fromScratch = boot.fromScratch === true;
|
|
26
|
+
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
|
|
27
|
+
return withWorkspace('boot', async (dir) => {
|
|
28
|
+
if (!fromScratch) {
|
|
29
|
+
opts.onPhase?.('clone');
|
|
30
|
+
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
31
|
+
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
32
|
+
});
|
|
33
|
+
await cloneRepo({
|
|
34
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
35
|
+
ghToken: job.ghToken,
|
|
36
|
+
dir,
|
|
37
|
+
signal,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
|
|
42
|
+
}
|
|
43
|
+
opts.onPhase?.('agent');
|
|
44
|
+
logger.info('agent(bootstrap): running agent');
|
|
45
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
46
|
+
dir,
|
|
47
|
+
systemPrompt: job.systemPrompt,
|
|
48
|
+
userPrompt: job.userPrompt,
|
|
49
|
+
model: job.model,
|
|
50
|
+
harness: job.harness,
|
|
51
|
+
subscriptionToken: job.subscriptionToken,
|
|
52
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
53
|
+
ambientAuth: job.ambientAuth,
|
|
54
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
55
|
+
sessionToken: job.sessionToken,
|
|
56
|
+
guardLimits: job.guardLimits,
|
|
57
|
+
...agentCapabilities(job),
|
|
58
|
+
}, opts);
|
|
59
|
+
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
60
|
+
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
61
|
+
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
62
|
+
// agent did) instead of pushing nothing.
|
|
63
|
+
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
64
|
+
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
|
|
65
|
+
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
|
|
66
|
+
return mergeEffort({
|
|
67
|
+
summary,
|
|
68
|
+
stats,
|
|
69
|
+
error,
|
|
70
|
+
failureCause: 'agent',
|
|
71
|
+
...(usage ? { usage } : {}),
|
|
72
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
73
|
+
}, effortReport);
|
|
74
|
+
}
|
|
75
|
+
opts.onPhase?.('push');
|
|
76
|
+
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
|
|
77
|
+
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
78
|
+
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
79
|
+
await reinitAndPush({
|
|
80
|
+
dir,
|
|
81
|
+
target: boot.target,
|
|
82
|
+
ghToken: job.ghToken,
|
|
83
|
+
message: fromScratch
|
|
84
|
+
? 'Bootstrap new repository'
|
|
85
|
+
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
86
|
+
});
|
|
87
|
+
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
|
|
88
|
+
return mergeEffort({
|
|
89
|
+
defaultBranch: boot.target.defaultBranch,
|
|
90
|
+
summary,
|
|
91
|
+
stats,
|
|
92
|
+
...(usage ? { usage } : {}),
|
|
93
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
94
|
+
}, effortReport);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
99
|
+
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
100
|
+
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
101
|
+
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
102
|
+
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
103
|
+
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
104
|
+
*/
|
|
105
|
+
export async function producedRepoContent(dir, hasReference, signal) {
|
|
106
|
+
if (hasReference)
|
|
107
|
+
return hasAgentChanges(dir, signal);
|
|
108
|
+
return containsAnyFile(dir);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
112
|
+
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
113
|
+
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
114
|
+
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
115
|
+
* entry before the check).
|
|
116
|
+
*/
|
|
117
|
+
async function containsAnyFile(dir) {
|
|
118
|
+
const handle = await opendir(dir);
|
|
119
|
+
try {
|
|
120
|
+
for await (const entry of handle) {
|
|
121
|
+
if (entry.isFile())
|
|
122
|
+
return true;
|
|
123
|
+
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// A directory that vanished mid-walk has nothing to contribute.
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
133
|
+
function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
|
|
134
|
+
const what = hasReference
|
|
135
|
+
? 'made no changes to the reference architecture'
|
|
136
|
+
: 'scaffolded no files';
|
|
137
|
+
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
|
|
138
|
+
return (`the bootstrapper agent ${what} ` +
|
|
139
|
+
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
140
|
+
agentOutputTail(stderrTail, summary));
|
|
141
|
+
}
|
|
@@ -45,7 +45,8 @@ export function createClaudeCallAggregator(handlers) {
|
|
|
45
45
|
reasoning: '',
|
|
46
46
|
stopReason: null,
|
|
47
47
|
inputTokens: 0,
|
|
48
|
-
|
|
48
|
+
cacheReadTokens: 0,
|
|
49
|
+
cacheWriteTokens: 0,
|
|
49
50
|
outputTokens: 0,
|
|
50
51
|
toolResults: [],
|
|
51
52
|
toolUses: 0,
|
|
@@ -57,7 +58,8 @@ export function createClaudeCallAggregator(handlers) {
|
|
|
57
58
|
pending.reasoning += reasoning;
|
|
58
59
|
pending.toolUses += toolUses;
|
|
59
60
|
pending.inputTokens = Math.max(pending.inputTokens, usage.inputTokens);
|
|
60
|
-
pending.
|
|
61
|
+
pending.cacheReadTokens = Math.max(pending.cacheReadTokens, usage.cacheReadTokens);
|
|
62
|
+
pending.cacheWriteTokens = Math.max(pending.cacheWriteTokens, usage.cacheWriteTokens);
|
|
61
63
|
pending.outputTokens = Math.max(pending.outputTokens, usage.outputTokens);
|
|
62
64
|
// A block-split response reports its stop reason on the envelope that carries the end of the
|
|
63
65
|
// message; earlier ones report none. Keep the first non-null rather than the last seen.
|
|
@@ -114,7 +116,8 @@ export function createClaudeStreamTelemetry(opts) {
|
|
|
114
116
|
responseText: redactBody(call.text, opts.secrets),
|
|
115
117
|
reasoningText: redactBody(call.reasoning, opts.secrets),
|
|
116
118
|
inputTokens: call.inputTokens,
|
|
117
|
-
|
|
119
|
+
cacheReadTokens: call.cacheReadTokens,
|
|
120
|
+
cacheWriteTokens: call.cacheWriteTokens,
|
|
118
121
|
outputTokens: call.outputTokens,
|
|
119
122
|
finishReason: call.stopReason,
|
|
120
123
|
});
|
package/dist/claude-stream.js
CHANGED
|
@@ -51,16 +51,22 @@ export function claudeAssistantContent(content) {
|
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
53
|
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
54
|
-
* the cumulative `result` total).
|
|
55
|
-
*
|
|
54
|
+
* the cumulative `result` total).
|
|
55
|
+
*
|
|
56
|
+
* Anthropic reports all three input classes SEPARATELY and `input_tokens` is already
|
|
57
|
+
* exclusive of both caches, so the three fields here are orthogonal and additive:
|
|
58
|
+
* total input = `inputTokens + cacheReadTokens + cacheWriteTokens`. Do NOT re-lump the
|
|
59
|
+
* reads and the writes — a cache write costs 1.25–2× base input while a read costs ~0.1×,
|
|
60
|
+
* so a turn that keeps invalidating the prefix and one that rides a warm cache are
|
|
61
|
+
* indistinguishable once they are summed.
|
|
56
62
|
*/
|
|
57
63
|
export function claudeCallUsage(raw) {
|
|
58
64
|
if (!isObject(raw))
|
|
59
|
-
return { inputTokens: 0,
|
|
60
|
-
const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens);
|
|
65
|
+
return { inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 };
|
|
61
66
|
return {
|
|
62
|
-
inputTokens: numberOf(raw.input_tokens)
|
|
63
|
-
|
|
67
|
+
inputTokens: numberOf(raw.input_tokens),
|
|
68
|
+
cacheReadTokens: numberOf(raw.cache_read_input_tokens),
|
|
69
|
+
cacheWriteTokens: numberOf(raw.cache_creation_input_tokens),
|
|
64
70
|
outputTokens: numberOf(raw.output_tokens),
|
|
65
71
|
};
|
|
66
72
|
}
|
package/dist/coding-agent.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import { killChildProcess, spawnDetached } from './process.js';
|
|
5
|
-
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
|
|
3
|
+
import { runCapturedCommand } from './captured-command.js';
|
|
6
4
|
import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
7
5
|
import { openPullRequest } from './vcs-api.js';
|
|
8
6
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
|
|
@@ -153,7 +151,8 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
153
151
|
webToolsGuidance: spec.webToolsGuidance,
|
|
154
152
|
webSearchProxy: spec.webSearchProxy,
|
|
155
153
|
guardLimits: spec.guardLimits,
|
|
156
|
-
...(spec.
|
|
154
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
155
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
157
156
|
}, opts);
|
|
158
157
|
let outcome;
|
|
159
158
|
try {
|
|
@@ -457,7 +456,7 @@ async function finalizeCodingRun(args) {
|
|
|
457
456
|
// Runs regardless of whether this pass pushed — a no-op iteration must still be able
|
|
458
457
|
// to report that the criterion is (already) met. The harness runs it, never the model.
|
|
459
458
|
if (spec.validation) {
|
|
460
|
-
outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
|
|
459
|
+
outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts);
|
|
461
460
|
}
|
|
462
461
|
// Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
|
|
463
462
|
// reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
|
|
@@ -525,81 +524,96 @@ function mergeAgentPasses(previous, next) {
|
|
|
525
524
|
* killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
|
|
526
525
|
* Overridable via env for tests; defaults to 15 minutes.
|
|
527
526
|
*/
|
|
528
|
-
function ralphValidationTimeoutMs() {
|
|
527
|
+
export function ralphValidationTimeoutMs() {
|
|
529
528
|
const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS);
|
|
530
529
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
|
|
531
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
|
|
533
|
+
* The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
|
|
534
|
+
* — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
|
|
535
|
+
* events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
|
|
536
|
+
* watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
|
|
537
|
+
* validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
|
|
538
|
+
* a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
|
|
539
|
+
* settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
|
|
540
|
+
* always fed it; this one did not. Overridable via env for tests.
|
|
541
|
+
*/
|
|
542
|
+
export function ralphHeartbeatMs() {
|
|
543
|
+
const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS);
|
|
544
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Bound on the validation output tail that crosses the wire. Deliberately smaller than
|
|
548
|
+
* `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
|
|
549
|
+
* the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
|
|
550
|
+
* log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
|
|
551
|
+
*/
|
|
552
|
+
export const RALPH_VALIDATION_TAIL_CHARS = 4_000;
|
|
532
553
|
/**
|
|
533
554
|
* Ralph loop: run the programmatic completion command in the checkout and return its exit
|
|
534
|
-
* code
|
|
535
|
-
* done signal (0 = the criterion is met) — computed
|
|
536
|
-
* by the model, which is the whole point of a
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
555
|
+
* code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
|
|
556
|
+
* The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
|
|
557
|
+
* here by the harness, never self-reported by the model, which is the whole point of a
|
|
558
|
+
* programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
|
|
559
|
+
* trust boundary as the coding agent) — there is no host/backend execution.
|
|
560
|
+
*
|
|
561
|
+
* The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
|
|
562
|
+
* command, rather than the near-verbatim copy this used to be. That copy had drifted in two
|
|
563
|
+
* ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
|
|
564
|
+
* margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
|
|
565
|
+
* an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
|
|
566
|
+
* it published the full 16k capture where both siblings deliberately bound the wire tail.
|
|
567
|
+
*
|
|
568
|
+
* `headSha` is what lets the engine tell a loop that is iterating from one that is merely
|
|
569
|
+
* repeating: two consecutive failing iterations against an unchanged head means the agent
|
|
570
|
+
* committed nothing, and the loop is ended early instead of spending the rest of its budget.
|
|
571
|
+
* Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
|
|
541
572
|
*/
|
|
542
|
-
async function runRalphValidation(cwd, validation, logger, opts) {
|
|
543
|
-
const timeoutMs = ralphValidationTimeoutMs();
|
|
573
|
+
export async function runRalphValidation(repoDir, cwd, validation, logger, opts) {
|
|
544
574
|
logger.info('coding-agent(ralph): running validation command', {
|
|
545
575
|
iteration: validation.iteration,
|
|
546
576
|
});
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
577
|
+
// Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
|
|
578
|
+
const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs());
|
|
579
|
+
heartbeat.unref?.();
|
|
580
|
+
let captured;
|
|
581
|
+
try {
|
|
582
|
+
captured = await runCapturedCommand({
|
|
551
583
|
cwd,
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
584
|
+
command: validation.command,
|
|
585
|
+
timeoutMs: ralphValidationTimeoutMs(),
|
|
586
|
+
reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
|
|
587
|
+
logLabel: 'coding-agent(ralph): validation',
|
|
588
|
+
logFields: { iteration: validation.iteration },
|
|
589
|
+
logger,
|
|
590
|
+
opts,
|
|
558
591
|
});
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
opts.signal?.removeEventListener('abort', onAbort);
|
|
571
|
-
const trimmed = out.trim();
|
|
572
|
-
const tail = trimmed ? redactSecrets(trimmed) : undefined;
|
|
573
|
-
logger.info('coding-agent(ralph): validation finished', {
|
|
574
|
-
exitCode,
|
|
575
|
-
iteration: validation.iteration,
|
|
576
|
-
});
|
|
577
|
-
resolve({
|
|
578
|
-
validationPassed: exitCode === 0,
|
|
579
|
-
exitCode,
|
|
580
|
-
...(tail ? { validationOutputTail: tail } : {}),
|
|
581
|
-
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
582
|
-
});
|
|
583
|
-
};
|
|
584
|
-
const timer = setTimeout(() => {
|
|
585
|
-
logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs });
|
|
586
|
-
killChildProcess(child, undefined, logger);
|
|
587
|
-
finish(124); // conventional timeout exit code (a non-zero fail)
|
|
588
|
-
}, timeoutMs);
|
|
589
|
-
timer.unref?.();
|
|
590
|
-
const onAbort = () => {
|
|
591
|
-
killChildProcess(child, undefined, logger);
|
|
592
|
-
finish(130); // aborted (a non-zero fail)
|
|
593
|
-
};
|
|
594
|
-
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
595
|
-
child.on('error', (err) => {
|
|
596
|
-
logger.warn('coding-agent(ralph): validation command failed to spawn', {
|
|
597
|
-
error: err instanceof Error ? err.message : String(err),
|
|
598
|
-
});
|
|
599
|
-
finish(127); // spawn error / command not found (a non-zero fail)
|
|
592
|
+
}
|
|
593
|
+
finally {
|
|
594
|
+
clearInterval(heartbeat);
|
|
595
|
+
}
|
|
596
|
+
// The commit the criterion was judged against. Read AFTER the command so a validation that
|
|
597
|
+
// itself commits (a formatter check that rewrites files, say) is attributed to what it left.
|
|
598
|
+
// Best-effort: an unreadable head only costs the engine's no-progress guard, never the
|
|
599
|
+
// verdict — but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
|
|
600
|
+
const headSha = await headCommit(repoDir, opts.signal).catch((err) => {
|
|
601
|
+
logger.warn('coding-agent(ralph): could not read the work-branch head', {
|
|
602
|
+
error: err instanceof Error ? err.message : String(err),
|
|
600
603
|
});
|
|
601
|
-
|
|
604
|
+
return '';
|
|
602
605
|
});
|
|
606
|
+
logger.info('coding-agent(ralph): validation finished', {
|
|
607
|
+
exitCode: captured.exitCode,
|
|
608
|
+
iteration: validation.iteration,
|
|
609
|
+
});
|
|
610
|
+
return {
|
|
611
|
+
validationPassed: captured.passed,
|
|
612
|
+
exitCode: captured.exitCode,
|
|
613
|
+
...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
|
|
614
|
+
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
615
|
+
...(headSha ? { headSha } : {}),
|
|
616
|
+
};
|
|
603
617
|
}
|
|
604
618
|
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
605
619
|
export function safeDirSegment(value) {
|
|
@@ -707,6 +721,10 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
707
721
|
webSearchProxy: job.webSearch,
|
|
708
722
|
guardLimits: job.guardLimits,
|
|
709
723
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
724
|
+
// Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
|
|
725
|
+
// are properties of the AGENT KIND, not of the checkout layout.
|
|
726
|
+
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
727
|
+
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
710
728
|
multiRepo: true,
|
|
711
729
|
}, opts);
|
|
712
730
|
// Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
|
package/dist/inline.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function handleInline(job, opts) {
|
|
|
48
48
|
return {
|
|
49
49
|
text: outcome.summary,
|
|
50
50
|
finishReason: deriveFinishReason(outcome.callMetrics),
|
|
51
|
-
...(outcome.usage ? { usage: outcome.usage } : {}),
|
|
51
|
+
...(outcome.usage ? { usage: inlineUsage(outcome.usage, outcome.callMetrics) } : {}),
|
|
52
52
|
...(outcome.callMetrics ? { callMetrics: outcome.callMetrics } : {}),
|
|
53
53
|
};
|
|
54
54
|
}
|
|
@@ -56,3 +56,31 @@ export async function handleInline(job, opts) {
|
|
|
56
56
|
await rm(cwd, { recursive: true, force: true }).catch(() => { });
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Split the run's coarse usage into the three orthogonal input classes an {@link InlineResult}
|
|
61
|
+
* carries. `outcome.usage` is the ROTATION-window weight — every billed input bucket summed —
|
|
62
|
+
* so the split has to come from the per-call metrics, the only channel that kept the classes
|
|
63
|
+
* apart. Fresh input is likewise taken from the calls rather than derived by subtraction, so a
|
|
64
|
+
* CLI whose per-call and cumulative counts disagree can never produce a negative class.
|
|
65
|
+
*
|
|
66
|
+
* With no per-call telemetry (an older CLI build that streams nothing) the coarse total is
|
|
67
|
+
* reported as fresh with both cache classes 0. That is the honest reading: nothing is KNOWN to
|
|
68
|
+
* have been cached, and inventing a split would be worse than admitting the channel is silent.
|
|
69
|
+
*/
|
|
70
|
+
function inlineUsage(usage, calls) {
|
|
71
|
+
if (!calls?.length) {
|
|
72
|
+
return {
|
|
73
|
+
inputTokens: usage.inputTokens,
|
|
74
|
+
cacheReadTokens: 0,
|
|
75
|
+
cacheWriteTokens: 0,
|
|
76
|
+
outputTokens: usage.outputTokens,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const sum = (pick) => calls.reduce((total, call) => total + pick(call), 0);
|
|
80
|
+
return {
|
|
81
|
+
inputTokens: sum((call) => call.inputTokens),
|
|
82
|
+
cacheReadTokens: sum((call) => call.cacheReadTokens),
|
|
83
|
+
cacheWriteTokens: sum((call) => call.cacheWriteTokens),
|
|
84
|
+
outputTokens: usage.outputTokens,
|
|
85
|
+
};
|
|
86
|
+
}
|