@cat-factory/executor-harness 1.50.14 → 1.50.16
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/dist/agent.js +31 -15
- package/dist/coding-agent.js +15 -2
- package/dist/effort.js +84 -0
- package/dist/pi-workspace.js +14 -2
- package/package.json +3 -3
- package/src/agent.ts +121 -82
- package/src/coding-agent.ts +38 -21
- package/src/effort.ts +99 -0
- package/src/job.ts +7 -0
- package/src/pi-workspace.ts +15 -2
- package/src/pi.ts +7 -0
package/dist/agent.js
CHANGED
|
@@ -228,6 +228,14 @@ async function cloneServiceCheckout(dir, job, signal) {
|
|
|
228
228
|
});
|
|
229
229
|
return deriveWorkDir(dir, job.repo.serviceDirectory);
|
|
230
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
233
|
+
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
234
|
+
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
235
|
+
*/
|
|
236
|
+
function mergeEffort(result, effortReport) {
|
|
237
|
+
return effortReport ? { ...result, effortReport } : result;
|
|
238
|
+
}
|
|
231
239
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
232
240
|
export async function handleAgent(job, opts = {}) {
|
|
233
241
|
// Private-registry auth first, before any mode runs: every mode with a checkout may
|
|
@@ -457,7 +465,7 @@ async function runExploreMode(job, opts) {
|
|
|
457
465
|
try {
|
|
458
466
|
opts.onPhase?.('agent');
|
|
459
467
|
logger.info('agent(explore): running agent', { serviceDirectory });
|
|
460
|
-
const { summary, stats, stderrTail, usage, callMetrics, diagnostics: runDiag, } = await runAgentInWorkspace({
|
|
468
|
+
const { summary, stats, stderrTail, usage, callMetrics, diagnostics: runDiag, effortReport, } = await runAgentInWorkspace({
|
|
461
469
|
dir: workDir,
|
|
462
470
|
systemPrompt: job.systemPrompt,
|
|
463
471
|
userPrompt,
|
|
@@ -477,7 +485,7 @@ async function runExploreMode(job, opts) {
|
|
|
477
485
|
contextFiles: job.contextFiles,
|
|
478
486
|
guardLimits: job.guardLimits,
|
|
479
487
|
}, opts);
|
|
480
|
-
return await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal });
|
|
488
|
+
return mergeEffort(await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal }), effortReport);
|
|
481
489
|
}
|
|
482
490
|
finally {
|
|
483
491
|
restoreSecrets();
|
|
@@ -666,14 +674,14 @@ async function runMultiRepoExplore(job, opts) {
|
|
|
666
674
|
guardLimits: job.guardLimits,
|
|
667
675
|
multiRepo: true,
|
|
668
676
|
}, opts);
|
|
669
|
-
return finalizeExploreResult(job, {
|
|
677
|
+
return mergeEffort(await finalizeExploreResult(job, {
|
|
670
678
|
summary: run.summary,
|
|
671
679
|
stats: run.stats,
|
|
672
680
|
stderrTail: run.stderrTail,
|
|
673
681
|
usage: run.usage,
|
|
674
682
|
callMetrics: run.callMetrics,
|
|
675
683
|
runDiag: run.diagnostics,
|
|
676
|
-
}, { infraSetupFields: {}, logger, signal: opts.signal });
|
|
684
|
+
}, { infraSetupFields: {}, logger, signal: opts.signal }), run.effortReport);
|
|
677
685
|
});
|
|
678
686
|
}
|
|
679
687
|
/**
|
|
@@ -744,7 +752,7 @@ async function runCodingMode(job, opts) {
|
|
|
744
752
|
*/
|
|
745
753
|
async function runSingleRepoCoding(job, opts) {
|
|
746
754
|
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
747
|
-
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } = await runCodingAgent({
|
|
755
|
+
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } = await runCodingAgent({
|
|
748
756
|
kind: 'agent',
|
|
749
757
|
jobId: job.jobId,
|
|
750
758
|
repo: job.repo,
|
|
@@ -785,6 +793,8 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
785
793
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
786
794
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
787
795
|
const ralphVerdict = validation ? { ralphVerdict: validation } : {};
|
|
796
|
+
// The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
|
|
797
|
+
const effort = effortReport ? { effortReport } : {};
|
|
788
798
|
if (!pushed) {
|
|
789
799
|
// A no-op: a failure for the implementer, a clean non-event for the fixers.
|
|
790
800
|
if (job.noChangesIsError === false) {
|
|
@@ -796,6 +806,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
796
806
|
...(usage ? { usage } : {}),
|
|
797
807
|
...(callMetrics ? { callMetrics } : {}),
|
|
798
808
|
...ralphVerdict,
|
|
809
|
+
...effort,
|
|
799
810
|
};
|
|
800
811
|
}
|
|
801
812
|
return {
|
|
@@ -807,6 +818,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
807
818
|
failureCause: 'no-changes',
|
|
808
819
|
...(usage ? { usage } : {}),
|
|
809
820
|
...(callMetrics ? { callMetrics } : {}),
|
|
821
|
+
...effort,
|
|
810
822
|
};
|
|
811
823
|
}
|
|
812
824
|
// Changes are on the branch. Open a PR only when the job asked for one.
|
|
@@ -839,6 +851,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
839
851
|
stats,
|
|
840
852
|
...(usage ? { usage } : {}),
|
|
841
853
|
...(callMetrics ? { callMetrics } : {}),
|
|
854
|
+
...effort,
|
|
842
855
|
};
|
|
843
856
|
}
|
|
844
857
|
return {
|
|
@@ -850,6 +863,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
850
863
|
failureCause: 'no-changes',
|
|
851
864
|
...(usage ? { usage } : {}),
|
|
852
865
|
...(callMetrics ? { callMetrics } : {}),
|
|
866
|
+
...effort,
|
|
853
867
|
};
|
|
854
868
|
}
|
|
855
869
|
return {
|
|
@@ -861,6 +875,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
861
875
|
...(usage ? { usage } : {}),
|
|
862
876
|
...(callMetrics ? { callMetrics } : {}),
|
|
863
877
|
...ralphVerdict,
|
|
878
|
+
...effort,
|
|
864
879
|
};
|
|
865
880
|
}
|
|
866
881
|
return {
|
|
@@ -871,6 +886,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
871
886
|
...(usage ? { usage } : {}),
|
|
872
887
|
...(callMetrics ? { callMetrics } : {}),
|
|
873
888
|
...ralphVerdict,
|
|
889
|
+
...effort,
|
|
874
890
|
};
|
|
875
891
|
}
|
|
876
892
|
/**
|
|
@@ -935,7 +951,7 @@ async function runConflictResolution(job, opts) {
|
|
|
935
951
|
logger.info('agent(conflict): resolving conflicts with agent', { conflicted });
|
|
936
952
|
const diff = await conflictDiff(dir, conflicted, signal);
|
|
937
953
|
const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt);
|
|
938
|
-
const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
|
|
954
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
939
955
|
dir,
|
|
940
956
|
systemPrompt: job.systemPrompt,
|
|
941
957
|
userPrompt,
|
|
@@ -956,7 +972,7 @@ async function runConflictResolution(job, opts) {
|
|
|
956
972
|
logger.error('agent(conflict): unresolved conflicts remain, refusing to push', {
|
|
957
973
|
unresolved: unresolved.length,
|
|
958
974
|
});
|
|
959
|
-
return {
|
|
975
|
+
return mergeEffort({
|
|
960
976
|
pushed: false,
|
|
961
977
|
branch: job.branch,
|
|
962
978
|
summary,
|
|
@@ -965,21 +981,21 @@ async function runConflictResolution(job, opts) {
|
|
|
965
981
|
failureCause: 'agent',
|
|
966
982
|
...(usage ? { usage } : {}),
|
|
967
983
|
...(callMetrics ? { callMetrics } : {}),
|
|
968
|
-
};
|
|
984
|
+
}, effortReport);
|
|
969
985
|
}
|
|
970
986
|
// Complete the merge commit with the agent's resolution staged, then push.
|
|
971
987
|
await commitAll(dir, `Merge ${mergeBase} into ${job.branch}`, signal);
|
|
972
988
|
opts.onPhase?.('push');
|
|
973
989
|
logger.info('agent(conflict): pushing resolved branch', { ...stats });
|
|
974
990
|
await pushBranch(dir, job.branch, job.ghToken, signal);
|
|
975
|
-
return {
|
|
991
|
+
return mergeEffort({
|
|
976
992
|
pushed: true,
|
|
977
993
|
branch: job.branch,
|
|
978
994
|
summary,
|
|
979
995
|
stats,
|
|
980
996
|
...(usage ? { usage } : {}),
|
|
981
997
|
...(callMetrics ? { callMetrics } : {}),
|
|
982
|
-
};
|
|
998
|
+
}, effortReport);
|
|
983
999
|
});
|
|
984
1000
|
}
|
|
985
1001
|
/**
|
|
@@ -1055,7 +1071,7 @@ async function runBootstrap(job, opts) {
|
|
|
1055
1071
|
}
|
|
1056
1072
|
opts.onPhase?.('agent');
|
|
1057
1073
|
logger.info('agent(bootstrap): running agent');
|
|
1058
|
-
const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
|
|
1074
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
1059
1075
|
dir,
|
|
1060
1076
|
systemPrompt: job.systemPrompt,
|
|
1061
1077
|
userPrompt: job.userPrompt,
|
|
@@ -1075,14 +1091,14 @@ async function runBootstrap(job, opts) {
|
|
|
1075
1091
|
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1076
1092
|
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
|
|
1077
1093
|
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
|
|
1078
|
-
return {
|
|
1094
|
+
return mergeEffort({
|
|
1079
1095
|
summary,
|
|
1080
1096
|
stats,
|
|
1081
1097
|
error,
|
|
1082
1098
|
failureCause: 'agent',
|
|
1083
1099
|
...(usage ? { usage } : {}),
|
|
1084
1100
|
...(callMetrics ? { callMetrics } : {}),
|
|
1085
|
-
};
|
|
1101
|
+
}, effortReport);
|
|
1086
1102
|
}
|
|
1087
1103
|
opts.onPhase?.('push');
|
|
1088
1104
|
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
|
|
@@ -1097,13 +1113,13 @@ async function runBootstrap(job, opts) {
|
|
|
1097
1113
|
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1098
1114
|
});
|
|
1099
1115
|
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
|
|
1100
|
-
return {
|
|
1116
|
+
return mergeEffort({
|
|
1101
1117
|
defaultBranch: boot.target.defaultBranch,
|
|
1102
1118
|
summary,
|
|
1103
1119
|
stats,
|
|
1104
1120
|
...(usage ? { usage } : {}),
|
|
1105
1121
|
...(callMetrics ? { callMetrics } : {}),
|
|
1106
|
-
};
|
|
1122
|
+
}, effortReport);
|
|
1107
1123
|
});
|
|
1108
1124
|
}
|
|
1109
1125
|
/**
|
package/dist/coding-agent.js
CHANGED
|
@@ -5,6 +5,7 @@ import { killChildProcess, spawnDetached } from './process.js';
|
|
|
5
5
|
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
|
|
6
6
|
import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
7
7
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
|
|
8
|
+
import { EFFORT_REPORT_FILE } from './effort.js';
|
|
8
9
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
|
|
9
10
|
import { log } from './logger.js';
|
|
10
11
|
/**
|
|
@@ -105,6 +106,13 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
105
106
|
const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir;
|
|
106
107
|
if (serviceDirectory)
|
|
107
108
|
await mkdir(workDir, { recursive: true });
|
|
109
|
+
// Every container agent is asked to write its effort self-assessment to `.cat-effort.json`
|
|
110
|
+
// in its cwd (the backend appends EFFORT_REPORT_GUIDANCE to every container prompt). Locally
|
|
111
|
+
// exclude it from git — exactly like the follow-ups sentinel below — so the agent's own
|
|
112
|
+
// `git add` can never stage it into the PR. `readEffortReport` also removes it after the run,
|
|
113
|
+
// but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
|
|
114
|
+
// filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
|
|
115
|
+
await excludeFromGit(dir, EFFORT_REPORT_FILE, signal);
|
|
108
116
|
// Follow-up companion: tail the Coder's sentinel file and stream new items out on the
|
|
109
117
|
// job view. Locally exclude it from git first so the agent's own `git add` can never
|
|
110
118
|
// stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
|
|
@@ -278,7 +286,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
|
|
|
278
286
|
async function finalizeCodingRun(args) {
|
|
279
287
|
const { dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
|
|
280
288
|
const { signal } = opts;
|
|
281
|
-
const { summary, stats, stderrTail, usage, callMetrics } = agentRun;
|
|
289
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
|
|
282
290
|
let outcome;
|
|
283
291
|
// Stop tailing the follow-up sentinel and flush any items written after the last
|
|
284
292
|
// tick, so a fast final burst still reaches the job view before the run is recorded.
|
|
@@ -335,6 +343,7 @@ async function finalizeCodingRun(args) {
|
|
|
335
343
|
...(stderrTail ? { stderrTail } : {}),
|
|
336
344
|
...(usage ? { usage } : {}),
|
|
337
345
|
...(callMetrics ? { callMetrics } : {}),
|
|
346
|
+
...(effortReport ? { effortReport } : {}),
|
|
338
347
|
};
|
|
339
348
|
}
|
|
340
349
|
else {
|
|
@@ -349,6 +358,7 @@ async function finalizeCodingRun(args) {
|
|
|
349
358
|
...(stderrTail ? { stderrTail } : {}),
|
|
350
359
|
...(usage ? { usage } : {}),
|
|
351
360
|
...(callMetrics ? { callMetrics } : {}),
|
|
361
|
+
...(effortReport ? { effortReport } : {}),
|
|
352
362
|
};
|
|
353
363
|
}
|
|
354
364
|
// Ralph loop: run the programmatic completion command against the pushed/committed
|
|
@@ -528,7 +538,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
528
538
|
// note + the backend system-prompt section explain the layout.
|
|
529
539
|
opts.onPhase?.('agent');
|
|
530
540
|
logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) });
|
|
531
|
-
const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
|
|
541
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
532
542
|
dir: root,
|
|
533
543
|
systemPrompt: job.systemPrompt,
|
|
534
544
|
userPrompt: job.userPrompt,
|
|
@@ -560,6 +570,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
560
570
|
stats,
|
|
561
571
|
...(usage ? { usage } : {}),
|
|
562
572
|
...(callMetrics ? { callMetrics } : {}),
|
|
573
|
+
...(effortReport ? { effortReport } : {}),
|
|
563
574
|
};
|
|
564
575
|
}
|
|
565
576
|
return {
|
|
@@ -571,6 +582,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
571
582
|
failureCause: 'no-changes',
|
|
572
583
|
...(usage ? { usage } : {}),
|
|
573
584
|
...(callMetrics ? { callMetrics } : {}),
|
|
585
|
+
...(effortReport ? { effortReport } : {}),
|
|
574
586
|
};
|
|
575
587
|
}
|
|
576
588
|
logger.info('multi-repo: complete', {
|
|
@@ -587,6 +599,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
587
599
|
stats,
|
|
588
600
|
...(usage ? { usage } : {}),
|
|
589
601
|
...(callMetrics ? { callMetrics } : {}),
|
|
602
|
+
...(effortReport ? { effortReport } : {}),
|
|
590
603
|
};
|
|
591
604
|
});
|
|
592
605
|
}
|
package/dist/effort.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The agent effort self-assessment side channel. Every container agent is asked
|
|
5
|
+
// (via the backend-composed system prompt) to end its run by writing a short JSON
|
|
6
|
+
// self-assessment — how hard the work was, what reduced its effectiveness, the key
|
|
7
|
+
// obstacles — to a sentinel file in its working directory. The harness reads it after
|
|
8
|
+
// the agent finishes, removes it (so it never lands in a commit), and forwards it on
|
|
9
|
+
// the job result; the backend records it on the step and surfaces it in run details.
|
|
10
|
+
//
|
|
11
|
+
// The filename is kept in sync with `EFFORT_REPORT_FILE` in `@cat-factory/agents`
|
|
12
|
+
// (the executor-harness has no dependency on that package), exactly like CONTEXT_DIR
|
|
13
|
+
// and the follow-ups sentinel. The shape mirrors the contracts `AgentEffortReport`.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** The sentinel file the agent writes its effort self-assessment to (relative to its cwd). */
|
|
16
|
+
export const EFFORT_REPORT_FILE = '.cat-effort.json';
|
|
17
|
+
/**
|
|
18
|
+
* Read + parse + REMOVE the agent's effort sentinel file from `cwd`. Lenient: returns undefined
|
|
19
|
+
* when the file is absent (the agent wrote none), unreadable, not JSON, or carries nothing
|
|
20
|
+
* meaningful. Never throws — a malformed self-report must never fail an otherwise-good run.
|
|
21
|
+
*/
|
|
22
|
+
export async function readEffortReport(cwd) {
|
|
23
|
+
const path = join(cwd, EFFORT_REPORT_FILE);
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = await readFile(path, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined; // no report written — the common case
|
|
30
|
+
}
|
|
31
|
+
// Remove it so it never lands in a commit (defence in depth; the backend also excludes it).
|
|
32
|
+
await rm(path, { force: true }).catch(() => { });
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return coerceEffort(parsed);
|
|
41
|
+
}
|
|
42
|
+
/** Coerce arbitrary parsed JSON into a clean {@link EffortReport}, or undefined when it carries nothing. */
|
|
43
|
+
function coerceEffort(value) {
|
|
44
|
+
if (typeof value !== 'object' || value === null)
|
|
45
|
+
return undefined;
|
|
46
|
+
const o = value;
|
|
47
|
+
const report = { difficulty: clampDifficulty(o.difficulty) };
|
|
48
|
+
if (typeof o.summary === 'string' && o.summary.trim()) {
|
|
49
|
+
report.summary = o.summary.trim().slice(0, 2000);
|
|
50
|
+
}
|
|
51
|
+
if (typeof o.reducedEffectiveness === 'string' && o.reducedEffectiveness.trim()) {
|
|
52
|
+
report.reducedEffectiveness = o.reducedEffectiveness.trim().slice(0, 2000);
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(o.obstacles)) {
|
|
55
|
+
const obstacles = o.obstacles
|
|
56
|
+
.filter((x) => typeof x === 'string' && x.trim().length > 0)
|
|
57
|
+
.map((x) => x.trim().slice(0, 500))
|
|
58
|
+
.slice(0, 20);
|
|
59
|
+
if (obstacles.length)
|
|
60
|
+
report.obstacles = obstacles;
|
|
61
|
+
}
|
|
62
|
+
// Nothing beyond a defaulted difficulty ⇒ the agent didn't really report anything; drop it so
|
|
63
|
+
// run details don't show an empty "5/10, no detail" card for a stray/blank file.
|
|
64
|
+
if (report.summary === undefined &&
|
|
65
|
+
report.reducedEffectiveness === undefined &&
|
|
66
|
+
report.obstacles === undefined &&
|
|
67
|
+
!isFiniteNumber(o.difficulty)) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return report;
|
|
71
|
+
}
|
|
72
|
+
function clampDifficulty(v) {
|
|
73
|
+
const n = isFiniteNumber(v)
|
|
74
|
+
? v
|
|
75
|
+
: typeof v === 'string' && v.trim() !== ''
|
|
76
|
+
? Number(v)
|
|
77
|
+
: Number.NaN;
|
|
78
|
+
if (!Number.isFinite(n))
|
|
79
|
+
return 5;
|
|
80
|
+
return Math.min(10, Math.max(1, Math.round(n)));
|
|
81
|
+
}
|
|
82
|
+
function isFiniteNumber(v) {
|
|
83
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
84
|
+
}
|
package/dist/pi-workspace.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { readEffortReport } from './effort.js';
|
|
4
5
|
import { log } from './logger.js';
|
|
5
6
|
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, mergeGuardLimits, progressGuardLimitsFromEnv, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
6
7
|
import { runSubscriptionHarness } from './agent-runner.js';
|
|
@@ -134,7 +135,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
134
135
|
if (!spec.ambientAuth && !spec.subscriptionToken) {
|
|
135
136
|
throw new Error(`The ${spec.harness} harness requires a subscription token`);
|
|
136
137
|
}
|
|
137
|
-
|
|
138
|
+
const subOutcome = await runSubscriptionHarness(spec.harness, {
|
|
138
139
|
cwd: spec.dir,
|
|
139
140
|
model: spec.model,
|
|
140
141
|
systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles),
|
|
@@ -148,6 +149,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
148
149
|
onProgress: opts.onProgress,
|
|
149
150
|
...(opts.log ? { log: opts.log } : {}),
|
|
150
151
|
});
|
|
152
|
+
return withEffortReport(spec.dir, subOutcome);
|
|
151
153
|
}
|
|
152
154
|
if (!spec.proxyBaseUrl || !spec.sessionToken) {
|
|
153
155
|
throw new Error('The Pi harness requires proxyBaseUrl and sessionToken');
|
|
@@ -178,7 +180,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
178
180
|
});
|
|
179
181
|
await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
|
|
180
182
|
const { signal, onActivity, onProgress, onSpan } = opts;
|
|
181
|
-
|
|
183
|
+
const piOutcome = await runPi({
|
|
182
184
|
cwd: spec.dir,
|
|
183
185
|
model: spec.model,
|
|
184
186
|
userPrompt: spec.userPrompt,
|
|
@@ -193,6 +195,16 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
193
195
|
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
194
196
|
extraEnv,
|
|
195
197
|
});
|
|
198
|
+
return withEffortReport(spec.dir, piOutcome);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Lift the agent's effort self-assessment off its sentinel file in `dir` and fold it onto the
|
|
202
|
+
* run outcome. Shared by both harness paths so EVERY container agent's effort report is captured
|
|
203
|
+
* in one place. Never throws (a bad/absent report just yields no `effortReport`).
|
|
204
|
+
*/
|
|
205
|
+
async function withEffortReport(dir, outcome) {
|
|
206
|
+
const effortReport = await readEffortReport(dir);
|
|
207
|
+
return effortReport ? { ...outcome, effortReport } : outcome;
|
|
196
208
|
}
|
|
197
209
|
/**
|
|
198
210
|
* Append a pointer to the materialised linked context onto a subscription harness's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.16",
|
|
4
4
|
"description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"hono": "^4.12.30",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/server": "0.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.142.0",
|
|
30
|
+
"@cat-factory/spend": "0.12.74"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
unmergedPaths,
|
|
32
32
|
} from './git.js'
|
|
33
33
|
import type { PiRunStats, RunDiagnostics } from './pi.js'
|
|
34
|
+
import type { EffortReport } from './effort.js'
|
|
34
35
|
import {
|
|
35
36
|
makeDirClaimer,
|
|
36
37
|
noChangesReason,
|
|
@@ -312,6 +313,15 @@ async function cloneServiceCheckout(
|
|
|
312
313
|
return deriveWorkDir(dir, job.repo.serviceDirectory)
|
|
313
314
|
}
|
|
314
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
|
+
|
|
315
325
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
316
326
|
export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
|
|
317
327
|
// Private-registry auth first, before any mode runs: every mode with a checkout may
|
|
@@ -558,6 +568,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
558
568
|
usage,
|
|
559
569
|
callMetrics,
|
|
560
570
|
diagnostics: runDiag,
|
|
571
|
+
effortReport,
|
|
561
572
|
} = await runAgentInWorkspace(
|
|
562
573
|
{
|
|
563
574
|
dir: workDir,
|
|
@@ -582,10 +593,13 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
582
593
|
opts,
|
|
583
594
|
)
|
|
584
595
|
|
|
585
|
-
return
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
596
|
+
return mergeEffort(
|
|
597
|
+
await finalizeExploreResult(
|
|
598
|
+
job,
|
|
599
|
+
{ summary, stats, stderrTail, usage, callMetrics, runDiag },
|
|
600
|
+
{ infra, infraSetupFields, logger, signal: opts.signal },
|
|
601
|
+
),
|
|
602
|
+
effortReport,
|
|
589
603
|
)
|
|
590
604
|
} finally {
|
|
591
605
|
restoreSecrets()
|
|
@@ -808,17 +822,20 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
808
822
|
},
|
|
809
823
|
opts,
|
|
810
824
|
)
|
|
811
|
-
return
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
825
|
+
return mergeEffort(
|
|
826
|
+
await finalizeExploreResult(
|
|
827
|
+
job,
|
|
828
|
+
{
|
|
829
|
+
summary: run.summary,
|
|
830
|
+
stats: run.stats,
|
|
831
|
+
stderrTail: run.stderrTail,
|
|
832
|
+
usage: run.usage,
|
|
833
|
+
callMetrics: run.callMetrics,
|
|
834
|
+
runDiag: run.diagnostics,
|
|
835
|
+
},
|
|
836
|
+
{ infraSetupFields: {}, logger, signal: opts.signal },
|
|
837
|
+
),
|
|
838
|
+
run.effortReport,
|
|
822
839
|
)
|
|
823
840
|
})
|
|
824
841
|
}
|
|
@@ -894,7 +911,7 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
|
|
|
894
911
|
*/
|
|
895
912
|
async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
896
913
|
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch
|
|
897
|
-
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } =
|
|
914
|
+
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } =
|
|
898
915
|
await runCodingAgent(
|
|
899
916
|
{
|
|
900
917
|
kind: 'agent',
|
|
@@ -939,6 +956,8 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
939
956
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
940
957
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
941
958
|
const ralphVerdict = validation ? { ralphVerdict: validation } : {}
|
|
959
|
+
// The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
|
|
960
|
+
const effort = effortReport ? { effortReport } : {}
|
|
942
961
|
|
|
943
962
|
if (!pushed) {
|
|
944
963
|
// A no-op: a failure for the implementer, a clean non-event for the fixers.
|
|
@@ -951,6 +970,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
951
970
|
...(usage ? { usage } : {}),
|
|
952
971
|
...(callMetrics ? { callMetrics } : {}),
|
|
953
972
|
...ralphVerdict,
|
|
973
|
+
...effort,
|
|
954
974
|
}
|
|
955
975
|
}
|
|
956
976
|
return {
|
|
@@ -962,6 +982,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
962
982
|
failureCause: 'no-changes',
|
|
963
983
|
...(usage ? { usage } : {}),
|
|
964
984
|
...(callMetrics ? { callMetrics } : {}),
|
|
985
|
+
...effort,
|
|
965
986
|
}
|
|
966
987
|
}
|
|
967
988
|
|
|
@@ -995,6 +1016,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
995
1016
|
stats,
|
|
996
1017
|
...(usage ? { usage } : {}),
|
|
997
1018
|
...(callMetrics ? { callMetrics } : {}),
|
|
1019
|
+
...effort,
|
|
998
1020
|
}
|
|
999
1021
|
}
|
|
1000
1022
|
return {
|
|
@@ -1010,6 +1032,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1010
1032
|
failureCause: 'no-changes',
|
|
1011
1033
|
...(usage ? { usage } : {}),
|
|
1012
1034
|
...(callMetrics ? { callMetrics } : {}),
|
|
1035
|
+
...effort,
|
|
1013
1036
|
}
|
|
1014
1037
|
}
|
|
1015
1038
|
return {
|
|
@@ -1021,6 +1044,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1021
1044
|
...(usage ? { usage } : {}),
|
|
1022
1045
|
...(callMetrics ? { callMetrics } : {}),
|
|
1023
1046
|
...ralphVerdict,
|
|
1047
|
+
...effort,
|
|
1024
1048
|
}
|
|
1025
1049
|
}
|
|
1026
1050
|
return {
|
|
@@ -1031,6 +1055,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1031
1055
|
...(usage ? { usage } : {}),
|
|
1032
1056
|
...(callMetrics ? { callMetrics } : {}),
|
|
1033
1057
|
...ralphVerdict,
|
|
1058
|
+
...effort,
|
|
1034
1059
|
}
|
|
1035
1060
|
}
|
|
1036
1061
|
|
|
@@ -1100,23 +1125,24 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1100
1125
|
const diff = await conflictDiff(dir, conflicted, signal)
|
|
1101
1126
|
const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt)
|
|
1102
1127
|
|
|
1103
|
-
const { summary, stats, stderrTail, usage, callMetrics } =
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1128
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1129
|
+
await runAgentInWorkspace(
|
|
1130
|
+
{
|
|
1131
|
+
dir,
|
|
1132
|
+
systemPrompt: job.systemPrompt,
|
|
1133
|
+
userPrompt,
|
|
1134
|
+
model: job.model,
|
|
1135
|
+
harness: job.harness,
|
|
1136
|
+
subscriptionToken: job.subscriptionToken,
|
|
1137
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1138
|
+
ambientAuth: job.ambientAuth,
|
|
1139
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
1140
|
+
sessionToken: job.sessionToken,
|
|
1141
|
+
contextFiles: job.contextFiles,
|
|
1142
|
+
guardLimits: job.guardLimits,
|
|
1143
|
+
},
|
|
1144
|
+
opts,
|
|
1145
|
+
)
|
|
1120
1146
|
|
|
1121
1147
|
// Never push a half-resolved tree: if any conflict markers / unmerged paths remain,
|
|
1122
1148
|
// the PR would still be broken. Fail so the engine can retry / notify.
|
|
@@ -1125,30 +1151,36 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1125
1151
|
logger.error('agent(conflict): unresolved conflicts remain, refusing to push', {
|
|
1126
1152
|
unresolved: unresolved.length,
|
|
1127
1153
|
})
|
|
1128
|
-
return
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1154
|
+
return mergeEffort(
|
|
1155
|
+
{
|
|
1156
|
+
pushed: false,
|
|
1157
|
+
branch: job.branch,
|
|
1158
|
+
summary,
|
|
1159
|
+
stats,
|
|
1160
|
+
error: unresolvedReason(unresolved, stats, stderrTail),
|
|
1161
|
+
failureCause: 'agent',
|
|
1162
|
+
...(usage ? { usage } : {}),
|
|
1163
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
1164
|
+
},
|
|
1165
|
+
effortReport,
|
|
1166
|
+
)
|
|
1138
1167
|
}
|
|
1139
1168
|
// Complete the merge commit with the agent's resolution staged, then push.
|
|
1140
1169
|
await commitAll(dir, `Merge ${mergeBase} into ${job.branch}`, signal)
|
|
1141
1170
|
opts.onPhase?.('push')
|
|
1142
1171
|
logger.info('agent(conflict): pushing resolved branch', { ...stats })
|
|
1143
1172
|
await pushBranch(dir, job.branch, job.ghToken, signal)
|
|
1144
|
-
return
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1173
|
+
return mergeEffort(
|
|
1174
|
+
{
|
|
1175
|
+
pushed: true,
|
|
1176
|
+
branch: job.branch,
|
|
1177
|
+
summary,
|
|
1178
|
+
stats,
|
|
1179
|
+
...(usage ? { usage } : {}),
|
|
1180
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
1181
|
+
},
|
|
1182
|
+
effortReport,
|
|
1183
|
+
)
|
|
1152
1184
|
})
|
|
1153
1185
|
}
|
|
1154
1186
|
|
|
@@ -1239,22 +1271,23 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
|
|
|
1239
1271
|
|
|
1240
1272
|
opts.onPhase?.('agent')
|
|
1241
1273
|
logger.info('agent(bootstrap): running agent')
|
|
1242
|
-
const { summary, stats, stderrTail, usage, callMetrics } =
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1274
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1275
|
+
await runAgentInWorkspace(
|
|
1276
|
+
{
|
|
1277
|
+
dir,
|
|
1278
|
+
systemPrompt: job.systemPrompt,
|
|
1279
|
+
userPrompt: job.userPrompt,
|
|
1280
|
+
model: job.model,
|
|
1281
|
+
harness: job.harness,
|
|
1282
|
+
subscriptionToken: job.subscriptionToken,
|
|
1283
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1284
|
+
ambientAuth: job.ambientAuth,
|
|
1285
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
1286
|
+
sessionToken: job.sessionToken,
|
|
1287
|
+
guardLimits: job.guardLimits,
|
|
1288
|
+
},
|
|
1289
|
+
opts,
|
|
1290
|
+
)
|
|
1258
1291
|
|
|
1259
1292
|
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
1260
1293
|
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
@@ -1263,14 +1296,17 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
|
|
|
1263
1296
|
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1264
1297
|
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
|
|
1265
1298
|
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
|
|
1266
|
-
return
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1299
|
+
return mergeEffort(
|
|
1300
|
+
{
|
|
1301
|
+
summary,
|
|
1302
|
+
stats,
|
|
1303
|
+
error,
|
|
1304
|
+
failureCause: 'agent',
|
|
1305
|
+
...(usage ? { usage } : {}),
|
|
1306
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
1307
|
+
},
|
|
1308
|
+
effortReport,
|
|
1309
|
+
)
|
|
1274
1310
|
}
|
|
1275
1311
|
|
|
1276
1312
|
opts.onPhase?.('push')
|
|
@@ -1286,13 +1322,16 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
|
|
|
1286
1322
|
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1287
1323
|
})
|
|
1288
1324
|
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
|
|
1289
|
-
return
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1325
|
+
return mergeEffort(
|
|
1326
|
+
{
|
|
1327
|
+
defaultBranch: boot.target.defaultBranch,
|
|
1328
|
+
summary,
|
|
1329
|
+
stats,
|
|
1330
|
+
...(usage ? { usage } : {}),
|
|
1331
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
1332
|
+
},
|
|
1333
|
+
effortReport,
|
|
1334
|
+
)
|
|
1296
1335
|
})
|
|
1297
1336
|
}
|
|
1298
1337
|
|
package/src/coding-agent.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from './git.js'
|
|
32
32
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js'
|
|
33
33
|
import type { HarnessCallMetric, PiRunStats } from './pi.js'
|
|
34
|
+
import { EFFORT_REPORT_FILE, type EffortReport } from './effort.js'
|
|
34
35
|
import {
|
|
35
36
|
acquireRepoCheckout,
|
|
36
37
|
agentNeverActed,
|
|
@@ -123,6 +124,8 @@ export interface CodingAgentOutcome {
|
|
|
123
124
|
usage?: { inputTokens: number; outputTokens: number }
|
|
124
125
|
/** Per-model-call telemetry from a subscription harness's CLI stream (absent for Pi). */
|
|
125
126
|
callMetrics?: HarnessCallMetric[]
|
|
127
|
+
/** The agent's effort self-assessment, lifted from its sentinel file (absent when it wrote none). */
|
|
128
|
+
effortReport?: EffortReport
|
|
126
129
|
/**
|
|
127
130
|
* Ralph loop: the verdict of the post-commit validation command (whether it exited 0, the
|
|
128
131
|
* exit code, and a bounded/redacted output tail). Present only when {@link CodingAgentSpec.validation}
|
|
@@ -242,6 +245,14 @@ export async function runCodingAgent(
|
|
|
242
245
|
const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir
|
|
243
246
|
if (serviceDirectory) await mkdir(workDir, { recursive: true })
|
|
244
247
|
|
|
248
|
+
// Every container agent is asked to write its effort self-assessment to `.cat-effort.json`
|
|
249
|
+
// in its cwd (the backend appends EFFORT_REPORT_GUIDANCE to every container prompt). Locally
|
|
250
|
+
// exclude it from git — exactly like the follow-ups sentinel below — so the agent's own
|
|
251
|
+
// `git add` can never stage it into the PR. `readEffortReport` also removes it after the run,
|
|
252
|
+
// but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
|
|
253
|
+
// filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
|
|
254
|
+
await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
|
|
255
|
+
|
|
245
256
|
// Follow-up companion: tail the Coder's sentinel file and stream new items out on the
|
|
246
257
|
// job view. Locally exclude it from git first so the agent's own `git add` can never
|
|
247
258
|
// stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
|
|
@@ -462,7 +473,7 @@ async function finalizeCodingRun(args: {
|
|
|
462
473
|
agentRun,
|
|
463
474
|
} = args
|
|
464
475
|
const { signal } = opts
|
|
465
|
-
const { summary, stats, stderrTail, usage, callMetrics } = agentRun
|
|
476
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun
|
|
466
477
|
let outcome: CodingAgentOutcome
|
|
467
478
|
|
|
468
479
|
// Stop tailing the follow-up sentinel and flush any items written after the last
|
|
@@ -521,6 +532,7 @@ async function finalizeCodingRun(args: {
|
|
|
521
532
|
...(stderrTail ? { stderrTail } : {}),
|
|
522
533
|
...(usage ? { usage } : {}),
|
|
523
534
|
...(callMetrics ? { callMetrics } : {}),
|
|
535
|
+
...(effortReport ? { effortReport } : {}),
|
|
524
536
|
}
|
|
525
537
|
} else {
|
|
526
538
|
opts.onPhase?.('push')
|
|
@@ -534,6 +546,7 @@ async function finalizeCodingRun(args: {
|
|
|
534
546
|
...(stderrTail ? { stderrTail } : {}),
|
|
535
547
|
...(usage ? { usage } : {}),
|
|
536
548
|
...(callMetrics ? { callMetrics } : {}),
|
|
549
|
+
...(effortReport ? { effortReport } : {}),
|
|
537
550
|
}
|
|
538
551
|
}
|
|
539
552
|
|
|
@@ -765,26 +778,27 @@ export async function runMultiRepoCoding(
|
|
|
765
778
|
// note + the backend system-prompt section explain the layout.
|
|
766
779
|
opts.onPhase?.('agent')
|
|
767
780
|
logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) })
|
|
768
|
-
const { summary, stats, stderrTail, usage, callMetrics } =
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
781
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
782
|
+
await runAgentInWorkspace(
|
|
783
|
+
{
|
|
784
|
+
dir: root,
|
|
785
|
+
systemPrompt: job.systemPrompt,
|
|
786
|
+
userPrompt: job.userPrompt,
|
|
787
|
+
model: job.model,
|
|
788
|
+
harness: job.harness,
|
|
789
|
+
subscriptionToken: job.subscriptionToken,
|
|
790
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
791
|
+
ambientAuth: job.ambientAuth,
|
|
792
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
793
|
+
sessionToken: job.sessionToken,
|
|
794
|
+
webToolsGuidance: job.webToolsGuidance,
|
|
795
|
+
webSearchProxy: job.webSearch,
|
|
796
|
+
guardLimits: job.guardLimits,
|
|
797
|
+
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
798
|
+
multiRepo: true,
|
|
799
|
+
},
|
|
800
|
+
opts,
|
|
801
|
+
)
|
|
788
802
|
|
|
789
803
|
// Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
|
|
790
804
|
const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(
|
|
@@ -807,6 +821,7 @@ export async function runMultiRepoCoding(
|
|
|
807
821
|
stats,
|
|
808
822
|
...(usage ? { usage } : {}),
|
|
809
823
|
...(callMetrics ? { callMetrics } : {}),
|
|
824
|
+
...(effortReport ? { effortReport } : {}),
|
|
810
825
|
}
|
|
811
826
|
}
|
|
812
827
|
return {
|
|
@@ -822,6 +837,7 @@ export async function runMultiRepoCoding(
|
|
|
822
837
|
failureCause: 'no-changes',
|
|
823
838
|
...(usage ? { usage } : {}),
|
|
824
839
|
...(callMetrics ? { callMetrics } : {}),
|
|
840
|
+
...(effortReport ? { effortReport } : {}),
|
|
825
841
|
}
|
|
826
842
|
}
|
|
827
843
|
logger.info('multi-repo: complete', {
|
|
@@ -838,6 +854,7 @@ export async function runMultiRepoCoding(
|
|
|
838
854
|
stats,
|
|
839
855
|
...(usage ? { usage } : {}),
|
|
840
856
|
...(callMetrics ? { callMetrics } : {}),
|
|
857
|
+
...(effortReport ? { effortReport } : {}),
|
|
841
858
|
}
|
|
842
859
|
})
|
|
843
860
|
}
|
package/src/effort.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// The agent effort self-assessment side channel. Every container agent is asked
|
|
6
|
+
// (via the backend-composed system prompt) to end its run by writing a short JSON
|
|
7
|
+
// self-assessment — how hard the work was, what reduced its effectiveness, the key
|
|
8
|
+
// obstacles — to a sentinel file in its working directory. The harness reads it after
|
|
9
|
+
// the agent finishes, removes it (so it never lands in a commit), and forwards it on
|
|
10
|
+
// the job result; the backend records it on the step and surfaces it in run details.
|
|
11
|
+
//
|
|
12
|
+
// The filename is kept in sync with `EFFORT_REPORT_FILE` in `@cat-factory/agents`
|
|
13
|
+
// (the executor-harness has no dependency on that package), exactly like CONTEXT_DIR
|
|
14
|
+
// and the follow-ups sentinel. The shape mirrors the contracts `AgentEffortReport`.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** The sentinel file the agent writes its effort self-assessment to (relative to its cwd). */
|
|
18
|
+
export const EFFORT_REPORT_FILE = '.cat-effort.json'
|
|
19
|
+
|
|
20
|
+
/** A container agent's self-assessment of the work it just did. */
|
|
21
|
+
export interface EffortReport {
|
|
22
|
+
/** How hard the work was: 1 (trivial) .. 10 (extremely hard). */
|
|
23
|
+
difficulty: number
|
|
24
|
+
/** One or two sentences on how hard/easy the work was and why. */
|
|
25
|
+
summary?: string
|
|
26
|
+
/** What reduced the agent's effectiveness. */
|
|
27
|
+
reducedEffectiveness?: string
|
|
28
|
+
/** The key obstacles the agent hit. */
|
|
29
|
+
obstacles?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read + parse + REMOVE the agent's effort sentinel file from `cwd`. Lenient: returns undefined
|
|
34
|
+
* when the file is absent (the agent wrote none), unreadable, not JSON, or carries nothing
|
|
35
|
+
* meaningful. Never throws — a malformed self-report must never fail an otherwise-good run.
|
|
36
|
+
*/
|
|
37
|
+
export async function readEffortReport(cwd: string): Promise<EffortReport | undefined> {
|
|
38
|
+
const path = join(cwd, EFFORT_REPORT_FILE)
|
|
39
|
+
let raw: string
|
|
40
|
+
try {
|
|
41
|
+
raw = await readFile(path, 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined // no report written — the common case
|
|
44
|
+
}
|
|
45
|
+
// Remove it so it never lands in a commit (defence in depth; the backend also excludes it).
|
|
46
|
+
await rm(path, { force: true }).catch(() => {})
|
|
47
|
+
let parsed: unknown
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(raw)
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
return coerceEffort(parsed)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Coerce arbitrary parsed JSON into a clean {@link EffortReport}, or undefined when it carries nothing. */
|
|
57
|
+
function coerceEffort(value: unknown): EffortReport | undefined {
|
|
58
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
59
|
+
const o = value as Record<string, unknown>
|
|
60
|
+
const report: EffortReport = { difficulty: clampDifficulty(o.difficulty) }
|
|
61
|
+
if (typeof o.summary === 'string' && o.summary.trim()) {
|
|
62
|
+
report.summary = o.summary.trim().slice(0, 2000)
|
|
63
|
+
}
|
|
64
|
+
if (typeof o.reducedEffectiveness === 'string' && o.reducedEffectiveness.trim()) {
|
|
65
|
+
report.reducedEffectiveness = o.reducedEffectiveness.trim().slice(0, 2000)
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(o.obstacles)) {
|
|
68
|
+
const obstacles = o.obstacles
|
|
69
|
+
.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
|
70
|
+
.map((x) => x.trim().slice(0, 500))
|
|
71
|
+
.slice(0, 20)
|
|
72
|
+
if (obstacles.length) report.obstacles = obstacles
|
|
73
|
+
}
|
|
74
|
+
// Nothing beyond a defaulted difficulty ⇒ the agent didn't really report anything; drop it so
|
|
75
|
+
// run details don't show an empty "5/10, no detail" card for a stray/blank file.
|
|
76
|
+
if (
|
|
77
|
+
report.summary === undefined &&
|
|
78
|
+
report.reducedEffectiveness === undefined &&
|
|
79
|
+
report.obstacles === undefined &&
|
|
80
|
+
!isFiniteNumber(o.difficulty)
|
|
81
|
+
) {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
return report
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clampDifficulty(v: unknown): number {
|
|
88
|
+
const n = isFiniteNumber(v)
|
|
89
|
+
? v
|
|
90
|
+
: typeof v === 'string' && v.trim() !== ''
|
|
91
|
+
? Number(v)
|
|
92
|
+
: Number.NaN
|
|
93
|
+
if (!Number.isFinite(n)) return 5
|
|
94
|
+
return Math.min(10, Math.max(1, Math.round(n)))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isFiniteNumber(v: unknown): v is number {
|
|
98
|
+
return typeof v === 'number' && Number.isFinite(v)
|
|
99
|
+
}
|
package/src/job.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HarnessCallMetric, PiRunStats } from './pi.js'
|
|
2
2
|
import type { HarnessKind } from './pi-workspace.js'
|
|
3
3
|
import type { FailureCause } from './failure.js'
|
|
4
|
+
import type { EffortReport } from './effort.js'
|
|
4
5
|
|
|
5
6
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
6
7
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
@@ -921,6 +922,12 @@ export interface AgentResult {
|
|
|
921
922
|
* {@link HarnessCallMetric}.
|
|
922
923
|
*/
|
|
923
924
|
callMetrics?: HarnessCallMetric[]
|
|
925
|
+
/**
|
|
926
|
+
* The agent's effort self-assessment (how hard the work was, what reduced its effectiveness,
|
|
927
|
+
* the key obstacles), lifted from its sentinel file after the run. The backend forwards it onto
|
|
928
|
+
* the job result and records it on the step for run details. Absent when the agent wrote none.
|
|
929
|
+
*/
|
|
930
|
+
effortReport?: EffortReport
|
|
924
931
|
}
|
|
925
932
|
|
|
926
933
|
/** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
|
package/src/pi-workspace.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import type { RepoSpec, SkillSpec } from './job.js'
|
|
5
|
+
import { readEffortReport } from './effort.js'
|
|
5
6
|
import { log } from './logger.js'
|
|
6
7
|
import {
|
|
7
8
|
type ContextFileInfo,
|
|
@@ -258,7 +259,7 @@ export async function runAgentInWorkspace(
|
|
|
258
259
|
if (!spec.ambientAuth && !spec.subscriptionToken) {
|
|
259
260
|
throw new Error(`The ${spec.harness} harness requires a subscription token`)
|
|
260
261
|
}
|
|
261
|
-
|
|
262
|
+
const subOutcome = await runSubscriptionHarness(spec.harness, {
|
|
262
263
|
cwd: spec.dir,
|
|
263
264
|
model: spec.model,
|
|
264
265
|
systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles),
|
|
@@ -272,6 +273,7 @@ export async function runAgentInWorkspace(
|
|
|
272
273
|
onProgress: opts.onProgress,
|
|
273
274
|
...(opts.log ? { log: opts.log } : {}),
|
|
274
275
|
})
|
|
276
|
+
return withEffortReport(spec.dir, subOutcome)
|
|
275
277
|
}
|
|
276
278
|
if (!spec.proxyBaseUrl || !spec.sessionToken) {
|
|
277
279
|
throw new Error('The Pi harness requires proxyBaseUrl and sessionToken')
|
|
@@ -301,7 +303,7 @@ export async function runAgentInWorkspace(
|
|
|
301
303
|
})
|
|
302
304
|
await writePiModelsConfig({ model: spec.model, proxyBaseUrl })
|
|
303
305
|
const { signal, onActivity, onProgress, onSpan } = opts
|
|
304
|
-
|
|
306
|
+
const piOutcome = await runPi({
|
|
305
307
|
cwd: spec.dir,
|
|
306
308
|
model: spec.model,
|
|
307
309
|
userPrompt: spec.userPrompt,
|
|
@@ -316,6 +318,17 @@ export async function runAgentInWorkspace(
|
|
|
316
318
|
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
317
319
|
extraEnv,
|
|
318
320
|
})
|
|
321
|
+
return withEffortReport(spec.dir, piOutcome)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Lift the agent's effort self-assessment off its sentinel file in `dir` and fold it onto the
|
|
326
|
+
* run outcome. Shared by both harness paths so EVERY container agent's effort report is captured
|
|
327
|
+
* in one place. Never throws (a bad/absent report just yields no `effortReport`).
|
|
328
|
+
*/
|
|
329
|
+
async function withEffortReport(dir: string, outcome: PiRunOutcome): Promise<PiRunOutcome> {
|
|
330
|
+
const effortReport = await readEffortReport(dir)
|
|
331
|
+
return effortReport ? { ...outcome, effortReport } : outcome
|
|
319
332
|
}
|
|
320
333
|
|
|
321
334
|
/**
|
package/src/pi.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { pathExists } from './fs-utils.js'
|
|
|
7
7
|
import { redactSecrets } from './redact.js'
|
|
8
8
|
import { HarnessFailure } from './failure.js'
|
|
9
9
|
import { log } from './logger.js'
|
|
10
|
+
import type { EffortReport } from './effort.js'
|
|
10
11
|
|
|
11
12
|
// Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
|
|
12
13
|
// proxy via a custom provider in ~/.pi/agent/models.json, authenticated with the
|
|
@@ -534,6 +535,12 @@ export interface PiRunOutcome {
|
|
|
534
535
|
callMetrics?: HarnessCallMetric[]
|
|
535
536
|
/** Output-quality signals (truncation / empty final answer); see {@link RunDiagnostics}. */
|
|
536
537
|
diagnostics?: RunDiagnostics
|
|
538
|
+
/**
|
|
539
|
+
* The agent's effort self-assessment, lifted from its sentinel file after the run (how hard the
|
|
540
|
+
* work was, what reduced its effectiveness, the key obstacles). Absent when the agent wrote none.
|
|
541
|
+
* See {@link EffortReport}.
|
|
542
|
+
*/
|
|
543
|
+
effortReport?: EffortReport
|
|
537
544
|
}
|
|
538
545
|
|
|
539
546
|
/**
|