@cat-factory/executor-harness 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/agent-runner.js +389 -0
- package/dist/agent.js +810 -0
- package/dist/blueprint.js +367 -0
- package/dist/bootstrap.js +99 -0
- package/dist/ci-fixer.js +46 -0
- package/dist/coding-agent.js +285 -0
- package/dist/conflict-resolver.js +138 -0
- package/dist/embed.js +8 -0
- package/dist/explore.js +74 -0
- package/dist/failure.js +47 -0
- package/dist/fixer.js +44 -0
- package/dist/follow-ups.js +103 -0
- package/dist/frontend-infra.js +283 -0
- package/dist/fs-utils.js +11 -0
- package/dist/git.js +778 -0
- package/dist/job.js +409 -0
- package/dist/logger.js +27 -0
- package/dist/merger.js +135 -0
- package/dist/on-call.js +126 -0
- package/dist/pi-workspace.js +237 -0
- package/dist/pi.js +971 -0
- package/dist/process.js +25 -0
- package/dist/redact.js +109 -0
- package/dist/runner.js +228 -0
- package/dist/server.js +135 -0
- package/dist/spec.js +754 -0
- package/dist/structured-output.js +431 -0
- package/dist/tester.js +191 -0
- package/package.json +35 -0
- package/src/agent-runner.ts +484 -0
- package/src/agent.ts +948 -0
- package/src/coding-agent.ts +393 -0
- package/src/embed.ts +32 -0
- package/src/failure.ts +73 -0
- package/src/follow-ups.ts +106 -0
- package/src/frontend-infra.ts +340 -0
- package/src/fs-utils.ts +11 -0
- package/src/git.ts +955 -0
- package/src/job.ts +766 -0
- package/src/logger.ts +45 -0
- package/src/pi-workspace.ts +348 -0
- package/src/pi.ts +1236 -0
- package/src/process.ts +33 -0
- package/src/redact.ts +109 -0
- package/src/runner.ts +384 -0
- package/src/server.ts +153 -0
- package/src/structured-output.ts +524 -0
package/dist/on-call.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { cloneRepo } from './git.js';
|
|
2
|
+
import { extractJsonObject } from './blueprint.js';
|
|
3
|
+
import { agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
|
|
4
|
+
import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
|
|
5
|
+
import { log } from './logger.js';
|
|
6
|
+
// Async job execution for the on-call agent. The engine dispatches this when the
|
|
7
|
+
// post-release-health gate detects a Datadog regression. The released PR has already
|
|
8
|
+
// merged and its work branch was deleted, so we clone the BASE branch (which contains
|
|
9
|
+
// the merged change), have Pi locate the merged commit (via the PR number / the
|
|
10
|
+
// now-historical head branch) and correlate its diff with the regression evidence
|
|
11
|
+
// (handed in via the user prompt), then return ONLY a JSON assessment of whether THIS
|
|
12
|
+
// change is the likely culprit. The on-call agent makes NO commits and reverts nothing —
|
|
13
|
+
// the engine raises a `release_regression` notification carrying this assessment.
|
|
14
|
+
const ASSESSMENT_SHAPE_HINT = 'Expected an on-call assessment: {"culpritConfidence": number 0..1, "recommendation": ' +
|
|
15
|
+
'"revert"|"hold"|"monitor", "rationale": string, "evidence": string[]}.';
|
|
16
|
+
function clamp01(value, fallback) {
|
|
17
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
18
|
+
if (!Number.isFinite(n))
|
|
19
|
+
return fallback;
|
|
20
|
+
return Math.min(1, Math.max(0, n));
|
|
21
|
+
}
|
|
22
|
+
function coerceRecommendation(value) {
|
|
23
|
+
return value === 'revert' || value === 'monitor' ? value : 'hold';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Coerce the agent's JSON into a well-formed assessment. A missing confidence defaults
|
|
27
|
+
* to a CONSERVATIVE 0 (don't imply the PR is at fault without evidence); a missing
|
|
28
|
+
* recommendation defaults to `hold` (human decides). Returns null only when no JSON
|
|
29
|
+
* object could be extracted at all.
|
|
30
|
+
*/
|
|
31
|
+
function coerceAssessment(raw, summary) {
|
|
32
|
+
if (typeof raw !== 'object' || raw === null)
|
|
33
|
+
return null;
|
|
34
|
+
const o = raw;
|
|
35
|
+
const evidence = Array.isArray(o.evidence)
|
|
36
|
+
? o.evidence.filter((e) => typeof e === 'string')
|
|
37
|
+
: [];
|
|
38
|
+
return {
|
|
39
|
+
culpritConfidence: clamp01(o.culpritConfidence, 0),
|
|
40
|
+
recommendation: coerceRecommendation(o.recommendation),
|
|
41
|
+
rationale: typeof o.rationale === 'string' && o.rationale ? o.rationale : summary.slice(0, 2000),
|
|
42
|
+
evidence,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function buildUserPrompt(job) {
|
|
46
|
+
const pr = job.prNumber !== undefined ? `#${job.prNumber}` : '';
|
|
47
|
+
// The PR has already merged into the base branch and its work branch was deleted, so the
|
|
48
|
+
// checkout is the base branch. Point the agent at how to find the merged commit.
|
|
49
|
+
const locate = job.prNumber
|
|
50
|
+
? `It merged as a commit referencing ${pr} — find it with ` +
|
|
51
|
+
`\`git log --oneline -n 50\` (squash/merge commits include \`(${pr})\`; a merge commit ` +
|
|
52
|
+
`mentions \`#${job.prNumber}\`), then inspect it with \`git show <sha>\`.`
|
|
53
|
+
: job.headBranch
|
|
54
|
+
? `Its work branch was \`${job.headBranch}\` (now deleted) — find the merged commit in ` +
|
|
55
|
+
`\`git log --oneline -n 50\` and inspect it with \`git show <sha>\`.`
|
|
56
|
+
: `Find the most recent merge/feature commit with \`git log --oneline -n 50\` and inspect ` +
|
|
57
|
+
`it with \`git show <sha>\`.`;
|
|
58
|
+
return [
|
|
59
|
+
job.userPrompt,
|
|
60
|
+
'',
|
|
61
|
+
`You are on the base branch \`${job.repo.baseBranch}\`, which already contains the released ` +
|
|
62
|
+
`pull request ${pr}. ${locate} Correlate that change with the regression evidence above. ` +
|
|
63
|
+
`Beware correlation vs causation.`,
|
|
64
|
+
'',
|
|
65
|
+
'Respond with ONLY a JSON object {"culpritConfidence":0.0,"recommendation":"revert"|"hold"|"monitor","rationale":"…","evidence":["…"]}.',
|
|
66
|
+
].join('\n');
|
|
67
|
+
}
|
|
68
|
+
/** Run one on-call job: clone branch → Pi investigates → return the assessment (no commit). */
|
|
69
|
+
export async function handleOnCall(job, opts = {}) {
|
|
70
|
+
const trace = { jobId: job.jobId, repo: `${job.repo.owner}/${job.repo.name}`, branch: job.branch };
|
|
71
|
+
return withWorkspace('on-call', async (dir) => {
|
|
72
|
+
log.info('on-call: cloning base branch', trace);
|
|
73
|
+
await cloneRepo({
|
|
74
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
75
|
+
ghToken: job.ghToken,
|
|
76
|
+
dir,
|
|
77
|
+
// Full clone so the agent has the history to locate + diff the merged commit.
|
|
78
|
+
full: true,
|
|
79
|
+
signal: opts.signal,
|
|
80
|
+
});
|
|
81
|
+
log.info('on-call: running agent', trace);
|
|
82
|
+
const { summary, stats, stderrTail, usage } = await runAgentInWorkspace({
|
|
83
|
+
dir,
|
|
84
|
+
systemPrompt: job.systemPrompt,
|
|
85
|
+
userPrompt: buildUserPrompt(job),
|
|
86
|
+
model: job.model,
|
|
87
|
+
harness: job.harness,
|
|
88
|
+
subscriptionToken: job.subscriptionToken,
|
|
89
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
90
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
91
|
+
sessionToken: job.sessionToken,
|
|
92
|
+
// Investigation only — no commits/edits, so the no-edit guard must not fire.
|
|
93
|
+
expectsEdits: false,
|
|
94
|
+
}, opts);
|
|
95
|
+
const { value: assessment, diagnostics } = await resolveStructuredOutput({
|
|
96
|
+
label: 'on-call',
|
|
97
|
+
shapeHint: ASSESSMENT_SHAPE_HINT,
|
|
98
|
+
parse: (text) => coerceAssessment(extractJsonObject(text), text),
|
|
99
|
+
}, summary, {
|
|
100
|
+
harness: job.harness,
|
|
101
|
+
subscriptionToken: job.subscriptionToken,
|
|
102
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
103
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
104
|
+
sessionToken: job.sessionToken,
|
|
105
|
+
model: job.model,
|
|
106
|
+
jobId: job.jobId,
|
|
107
|
+
signal: opts.signal,
|
|
108
|
+
});
|
|
109
|
+
if (!assessment) {
|
|
110
|
+
return {
|
|
111
|
+
summary,
|
|
112
|
+
stats,
|
|
113
|
+
error: noAssessmentReason(stats, stderrTail, diagnostics),
|
|
114
|
+
...(usage ? { usage } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
log.info('on-call: assessed', { ...trace, ...assessment });
|
|
118
|
+
return { onCallAssessment: assessment, summary, stats, ...(usage ? { usage } : {}) };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function noAssessmentReason(stats, stderrTail, diagnostics) {
|
|
122
|
+
const cause = agentNeverActed(stats)
|
|
123
|
+
? NEVER_ACTED_CAUSE
|
|
124
|
+
: ' The agent did not return a parseable JSON assessment.';
|
|
125
|
+
return `On-call produced no assessment.${cause}${diagnostics ? diagnosticsSuffix(diagnostics) : ''}${agentOutputTail(stderrTail)}`;
|
|
126
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CONTEXT_DIR, materializeContextFiles, mergeGuardLimits, progressGuardLimitsFromEnv, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
5
|
+
import { runSubscriptionHarness } from './agent-runner.js';
|
|
6
|
+
// The thin base every container agent shares: an ephemeral working directory, and
|
|
7
|
+
// one Pi run inside it driven by the harness-written context. The agents differ in
|
|
8
|
+
// how the directory is prepared (clone a branch, scaffold from scratch, read files
|
|
9
|
+
// to build the prompt) and what they do with the result (push a branch, open a PR,
|
|
10
|
+
// render files, return JSON) — but the middle (write AGENTS.md + provider config,
|
|
11
|
+
// run Pi, tear the workspace down) is identical, so it lives here once. Carries no
|
|
12
|
+
// secrets beyond the call: the per-job tokens arrive in the spec and are gone when
|
|
13
|
+
// the workspace is removed.
|
|
14
|
+
/**
|
|
15
|
+
* Run `fn` against a fresh temp working directory, always removing it afterwards
|
|
16
|
+
* (even on throw). `prefix` labels the directory (e.g. 'impl', 'merge').
|
|
17
|
+
*/
|
|
18
|
+
export async function withWorkspace(prefix, fn) {
|
|
19
|
+
const dir = await mkdtemp(join(tmpdir(), `${prefix}-`));
|
|
20
|
+
try {
|
|
21
|
+
return await fn(dir);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await rm(dir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The PERSISTENT-checkout root in a reused (pooled) container — a stable per-repo
|
|
29
|
+
* directory that survives across jobs so a new run can `git fetch` + switch branch
|
|
30
|
+
* instead of cloning from scratch. Only the local warm-pool transport activates this
|
|
31
|
+
* (by setting `persistentCheckout` on the job); every other runtime uses the ephemeral
|
|
32
|
+
* {@link withWorkspace} path, so this code is dormant there.
|
|
33
|
+
*/
|
|
34
|
+
function persistentWorkspaceRoot() {
|
|
35
|
+
return process.env.HARNESS_WORKSPACE_ROOT?.trim() || '/workspace';
|
|
36
|
+
}
|
|
37
|
+
/** Sanitise an owner/name path segment so a repo identity can never escape the root. */
|
|
38
|
+
function safeSegment(value) {
|
|
39
|
+
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
|
|
40
|
+
}
|
|
41
|
+
// A per-directory async mutex: two jobs that land in the same container share ONE
|
|
42
|
+
// persistent checkout, so they must not mutate its working tree concurrently. The
|
|
43
|
+
// engine runs a run's steps sequentially, so contention is rare — this is correctness
|
|
44
|
+
// insurance (and keeps a stray concurrent dispatch from corrupting the tree).
|
|
45
|
+
const dirLocks = new Map();
|
|
46
|
+
async function withDirLock(dir, fn) {
|
|
47
|
+
const prev = dirLocks.get(dir) ?? Promise.resolve();
|
|
48
|
+
let release;
|
|
49
|
+
const current = new Promise((resolve) => {
|
|
50
|
+
release = resolve;
|
|
51
|
+
});
|
|
52
|
+
// Store the SAME promise we await on for cleanup-identity. (Storing `prev.then(...)`
|
|
53
|
+
// instead would make the tail check below — `=== tail` — never match, so the entry
|
|
54
|
+
// would never be deleted and the map would grow without bound.)
|
|
55
|
+
const tail = prev.then(() => current);
|
|
56
|
+
dirLocks.set(dir, tail);
|
|
57
|
+
await prev.catch(() => { });
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
release();
|
|
63
|
+
// Drop the entry once we're the tail (no later caller has queued behind us), so the
|
|
64
|
+
// map doesn't grow unbounded across distinct repo dirs.
|
|
65
|
+
if (dirLocks.get(dir) === tail)
|
|
66
|
+
dirLocks.delete(dir);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Run `fn` against a STABLE per-repo working directory (`<root>/<owner>/<repo>`) that is
|
|
71
|
+
* NOT removed afterwards — the persistent-checkout analogue of {@link withWorkspace}. The
|
|
72
|
+
* caller (via `prepareExistingCheckout`) clean-sweeps + fetches the dir into the right
|
|
73
|
+
* state before use; serialised per dir so concurrent jobs can't corrupt the shared tree.
|
|
74
|
+
*/
|
|
75
|
+
export async function withPersistentWorkspace(repo, fn) {
|
|
76
|
+
const dir = join(persistentWorkspaceRoot(), safeSegment(repo.owner), safeSegment(repo.name));
|
|
77
|
+
return withDirLock(dir, async () => {
|
|
78
|
+
await mkdir(dir, { recursive: true });
|
|
79
|
+
return fn(dir);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Acquire a working directory for a run: a STABLE, reused per-repo checkout when the job
|
|
84
|
+
* opted into persistent checkout (the warm-pool path), else a fresh ephemeral temp dir
|
|
85
|
+
* (every other runtime). The two flows differ ONLY in dir lifecycle — the caller populates
|
|
86
|
+
* the dir (clone vs `prepareExistingCheckout`) itself, so it can keep its flow-specific
|
|
87
|
+
* resume / full-clone / branch logic.
|
|
88
|
+
*/
|
|
89
|
+
export async function acquireRepoCheckout(opts, fn) {
|
|
90
|
+
if (opts.persistent)
|
|
91
|
+
return withPersistentWorkspace(opts.repo, fn);
|
|
92
|
+
return withWorkspace(opts.prefix, fn);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
|
|
96
|
+
* then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
|
|
97
|
+
* lives outside the checkout so it never lands in a commit; the shared middle of
|
|
98
|
+
* every container agent.
|
|
99
|
+
*/
|
|
100
|
+
export async function runAgentInWorkspace(spec, opts = {}) {
|
|
101
|
+
// Materialise any backend-prepared linked context into the checkout up front, so the
|
|
102
|
+
// agent (which can't reach Jira/GitHub) reads it on demand from disk. Shared by both
|
|
103
|
+
// harness paths; kept out of the agent's commits via a local git exclude entry.
|
|
104
|
+
const contextFiles = spec.contextFiles ?? [];
|
|
105
|
+
await materializeContextFiles(spec.dir, contextFiles);
|
|
106
|
+
// Subscription harnesses (Claude Code / Codex) authenticate with the leased
|
|
107
|
+
// token and talk direct to the vendor — no proxy config, no AGENTS.md. The
|
|
108
|
+
// system prompt is passed straight to the CLI; everything around this (clone,
|
|
109
|
+
// push, watchdogs) is unchanged.
|
|
110
|
+
if (spec.harness === 'claude-code' || spec.harness === 'codex') {
|
|
111
|
+
// Ambient (native) mode authenticates with the developer's own CLI login, so no
|
|
112
|
+
// leased token is required; otherwise the leased subscription token is mandatory.
|
|
113
|
+
if (!spec.ambientAuth && !spec.subscriptionToken) {
|
|
114
|
+
throw new Error(`The ${spec.harness} harness requires a subscription token`);
|
|
115
|
+
}
|
|
116
|
+
return runSubscriptionHarness(spec.harness, {
|
|
117
|
+
cwd: spec.dir,
|
|
118
|
+
model: spec.model,
|
|
119
|
+
systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles),
|
|
120
|
+
userPrompt: spec.userPrompt,
|
|
121
|
+
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
122
|
+
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
123
|
+
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
124
|
+
signal: opts.signal,
|
|
125
|
+
onActivity: opts.onActivity,
|
|
126
|
+
onProgress: opts.onProgress,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (!spec.proxyBaseUrl || !spec.sessionToken) {
|
|
130
|
+
throw new Error('The Pi harness requires proxyBaseUrl and sessionToken');
|
|
131
|
+
}
|
|
132
|
+
const proxyBaseUrl = spec.proxyBaseUrl;
|
|
133
|
+
const sessionToken = spec.sessionToken;
|
|
134
|
+
// Opt-in web search/fetch (rpiv-web-tools). Two ways it turns on, both no-ops by
|
|
135
|
+
// default:
|
|
136
|
+
// - proxy-backed (the Cloudflare/managed path): the backend set `webSearchProxy`,
|
|
137
|
+
// so point the SearXNG provider at `${proxyBaseUrl}/web-search` with the session
|
|
138
|
+
// token — the search runs server-side, no provider key in the sandbox.
|
|
139
|
+
// - direct (the self-hosted runner-pool path): a provider key is present in the
|
|
140
|
+
// container env, which `webSearchConfigFromEnv` autodetects.
|
|
141
|
+
// The proxy vars are handed to Pi's child via `extraEnv` (not the harness's own
|
|
142
|
+
// process.env), so detection runs against the same merged view the extension sees.
|
|
143
|
+
const extraEnv = spec.webSearchProxy
|
|
144
|
+
? webSearchProxyEnv(proxyBaseUrl, sessionToken)
|
|
145
|
+
: {};
|
|
146
|
+
const webSearch = webSearchConfigFromEnv({ ...process.env, ...extraEnv });
|
|
147
|
+
if (webSearch)
|
|
148
|
+
await writeWebToolsConfig(webSearch);
|
|
149
|
+
await writeAgentsContext(spec.systemPrompt, {
|
|
150
|
+
webSearch: Boolean(webSearch),
|
|
151
|
+
guidance: spec.webToolsGuidance,
|
|
152
|
+
serviceDirectory: spec.serviceDirectory,
|
|
153
|
+
contextFiles,
|
|
154
|
+
});
|
|
155
|
+
await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
|
|
156
|
+
const { signal, onActivity, onProgress, onSpan } = opts;
|
|
157
|
+
return runPi({
|
|
158
|
+
cwd: spec.dir,
|
|
159
|
+
model: spec.model,
|
|
160
|
+
userPrompt: spec.userPrompt,
|
|
161
|
+
sessionToken,
|
|
162
|
+
signal,
|
|
163
|
+
onActivity,
|
|
164
|
+
onProgress,
|
|
165
|
+
onSpan,
|
|
166
|
+
expectsEdits: spec.expectsEdits ?? true,
|
|
167
|
+
// Start from the env/built-in defaults and apply only the per-knob overrides the
|
|
168
|
+
// backend set for this kind (loosen-only), so an unspecified knob keeps its default.
|
|
169
|
+
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
170
|
+
extraEnv,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Append a pointer to the materialised linked context onto a subscription harness's
|
|
175
|
+
* system prompt. The Pi harness surfaces this via AGENTS.md, but Claude Code / Codex
|
|
176
|
+
* take the system prompt straight, so the note has to ride along here. '' files ⇒ the
|
|
177
|
+
* prompt is returned unchanged.
|
|
178
|
+
*/
|
|
179
|
+
function subscriptionSystemPrompt(systemPrompt, files) {
|
|
180
|
+
if (!files.length)
|
|
181
|
+
return systemPrompt;
|
|
182
|
+
const list = files.map((f) => `- ${CONTEXT_DIR}/${f.path} — ${f.title}`).join('\n');
|
|
183
|
+
return `${systemPrompt}
|
|
184
|
+
|
|
185
|
+
Linked context (requirements / RFCs / PRDs / tracker issues) for this task is in the
|
|
186
|
+
${CONTEXT_DIR}/ directory of your checkout — read a file when relevant. Do NOT try to reach
|
|
187
|
+
external systems; everything available is already on disk:
|
|
188
|
+
${list}`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* True when Pi exited cleanly without a single tool call or token of output — the
|
|
192
|
+
* signature of a run where it never reached the model. Used by every agent's
|
|
193
|
+
* no-op reason to point at the most likely cause (an unreachable proxy / rejected
|
|
194
|
+
* model) rather than a genuine "nothing to do".
|
|
195
|
+
*/
|
|
196
|
+
export function agentNeverActed(stats) {
|
|
197
|
+
return stats.toolCalls === 0 && stats.assistantChars === 0;
|
|
198
|
+
}
|
|
199
|
+
/** The full-sentence "never acted" cause shared by the structured no-op reasons. */
|
|
200
|
+
export const NEVER_ACTED_CAUSE = ' The agent never acted (no tool calls, no model output) — it most likely could not reach the model.';
|
|
201
|
+
/**
|
|
202
|
+
* A human-readable cause when the agent's FINAL answer is unusable — its last turn was
|
|
203
|
+
* cut off at the output ceiling, or carried no text at all (an empty completion) — or
|
|
204
|
+
* `undefined` when the final answer looks fine.
|
|
205
|
+
*
|
|
206
|
+
* This is OPT-IN per agent, never a blanket harness rule. Only agents whose work
|
|
207
|
+
* product is a final text/document the pipeline hands ONWARD to be reviewed or parsed
|
|
208
|
+
* (the spec-writer, the blueprinter) should treat a non-undefined result as a hard
|
|
209
|
+
* failure — for them an empty/cut-off final turn means there is nothing trustworthy to
|
|
210
|
+
* review, which is exactly what drove the spec-writer ⇄ companion rework loop. Agents
|
|
211
|
+
* whose product is a side effect (a pushed PR/commit from the coder or ci-fixer, a
|
|
212
|
+
* self-contained validation) legitimately end with no final text and MUST NOT call this.
|
|
213
|
+
*/
|
|
214
|
+
export function unusableFinalAnswerCause(diagnostics) {
|
|
215
|
+
if (!diagnostics)
|
|
216
|
+
return undefined;
|
|
217
|
+
if (diagnostics.finalTruncated) {
|
|
218
|
+
return 'its final answer hit the output-token ceiling and was cut off (raise the limit or narrow the task)';
|
|
219
|
+
}
|
|
220
|
+
if (diagnostics.finalAnswerEmpty) {
|
|
221
|
+
return 'its final turn produced no text (an empty completion), so there is no document to read';
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The credential-scrubbed tail where a no-op's real cause shows up: a slice of Pi's
|
|
227
|
+
* stderr, or — when stderr is empty — a slice of its summary. Empty when neither is
|
|
228
|
+
* present. Shared by every agent's no-op reason so the cause is always diagnosable
|
|
229
|
+
* without shelling into the (ephemeral) container.
|
|
230
|
+
*/
|
|
231
|
+
export function agentOutputTail(stderrTail, summary) {
|
|
232
|
+
if (stderrTail)
|
|
233
|
+
return ` Agent stderr: ${stderrTail.slice(-700)}`;
|
|
234
|
+
if (summary)
|
|
235
|
+
return ` Agent output: ${summary.slice(0, 700)}`;
|
|
236
|
+
return '';
|
|
237
|
+
}
|