@cat-factory/executor-harness 1.62.0 → 1.64.2
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-runner.js +92 -18
- package/dist/claude-stream.js +18 -0
- package/dist/embed.js +2 -1
- package/dist/pi-workspace.js +35 -2
- package/dist/pi.js +15 -184
- package/dist/progress-guard.js +211 -0
- package/dist/progress.js +122 -13
- package/dist/subagents.js +1 -50
- package/package.json +4 -4
- package/src/agent-runner.ts +99 -17
- package/src/claude-stream.ts +19 -0
- package/src/coding-agent.ts +1 -1
- package/src/embed.ts +5 -3
- package/src/pi-workspace.ts +40 -4
- package/src/pi.ts +26 -252
- package/src/progress-guard.ts +285 -0
- package/src/progress.ts +138 -14
- package/src/subagents.ts +7 -16
package/dist/agent-runner.js
CHANGED
|
@@ -4,10 +4,11 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
|
|
6
6
|
import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
|
|
7
|
+
import { ProgressGuard } from './progress-guard.js';
|
|
7
8
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
8
9
|
import { redact, secretsToRedact } from './redact.js';
|
|
9
10
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js';
|
|
10
|
-
import { createTaskPlanTracker, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
|
|
11
|
+
import { createTaskPlanTracker, mergeProgress, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
|
|
11
12
|
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
|
|
12
13
|
import { retainSessionTranscripts } from './transcript-retention.js';
|
|
13
14
|
/**
|
|
@@ -58,7 +59,12 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
|
|
|
58
59
|
let aborted = false;
|
|
59
60
|
let lineBuffer = '';
|
|
60
61
|
const killChild = () => killChildProcess(child);
|
|
61
|
-
|
|
62
|
+
// `final` marks the at-close flush of a trailing unterminated line: the CLI has already
|
|
63
|
+
// exited, so an observer must not act on that record in a way that KILLS the run (mirrors
|
|
64
|
+
// `runPi`'s `runGuard = false` flush — without it, a guard tripping on the last buffered
|
|
65
|
+
// record could turn a clean exit into a spurious "no progress" failure). The record's
|
|
66
|
+
// progress/telemetry signal is still delivered; only kill decisions are suppressed.
|
|
67
|
+
const processLine = (line, final = false) => {
|
|
62
68
|
if (!line.startsWith('{'))
|
|
63
69
|
return;
|
|
64
70
|
let event;
|
|
@@ -69,7 +75,7 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
|
|
|
69
75
|
return;
|
|
70
76
|
}
|
|
71
77
|
try {
|
|
72
|
-
onEvent(event);
|
|
78
|
+
onEvent(event, { final });
|
|
73
79
|
}
|
|
74
80
|
catch {
|
|
75
81
|
// A faulty observer must never break the run.
|
|
@@ -106,11 +112,14 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
|
|
|
106
112
|
});
|
|
107
113
|
child.on('close', (code) => {
|
|
108
114
|
opts.signal?.removeEventListener('abort', onAbort);
|
|
109
|
-
if (lineBuffer.trim())
|
|
110
|
-
processLine(lineBuffer.trim());
|
|
111
115
|
const stderrTail = redact(stderr, secrets).slice(-700);
|
|
116
|
+
if (lineBuffer.trim())
|
|
117
|
+
processLine(lineBuffer.trim(), true);
|
|
112
118
|
if (aborted) {
|
|
113
|
-
|
|
119
|
+
// Carry the tail on the rejection so a caller that REPLACES this generic message with a
|
|
120
|
+
// more specific cause (the no-progress guard's diagnostic) can still append it — the
|
|
121
|
+
// stderr is often the only evidence of what the CLI was doing when it was killed.
|
|
122
|
+
reject(Object.assign(new Error('agent run aborted by watchdog'), { stderrTail }));
|
|
114
123
|
return;
|
|
115
124
|
}
|
|
116
125
|
if (code !== 0) {
|
|
@@ -224,29 +233,67 @@ export async function runClaudeCode(opts) {
|
|
|
224
233
|
// Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
|
|
225
234
|
// may still rewrite below (a published call must be final — see the publisher).
|
|
226
235
|
const publisher = createCallMetricPublisher(calls, opts.onCallMetric);
|
|
227
|
-
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from
|
|
228
|
-
//
|
|
229
|
-
// stream (only a subagent's intermediate turns don't), so `sliceTracker`
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// update, so neither masks the other — the pr-reviewer prompt writes its plan ONCE and never
|
|
233
|
-
// marks it done, which used to gate the slice signal off and pin progress at 0%.
|
|
236
|
+
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
|
|
237
|
+
// produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
|
|
238
|
+
// DO appear on this stream (only a subagent's intermediate turns don't), so `sliceTracker`
|
|
239
|
+
// knows which slices are in flight and which have returned; the parent's own plan (tracked by
|
|
240
|
+
// `planTracker` + `lastTodo`) is the only place a not-yet-dispatched slice is named at all.
|
|
234
241
|
//
|
|
235
242
|
// The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
|
|
236
243
|
// `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
|
|
237
244
|
// `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
|
|
238
|
-
// because the task id is minted there). Both are read
|
|
245
|
+
// because the task id is minted there). Both are read, and `pickProgress` resolves that
|
|
246
|
+
// either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
|
|
247
|
+
// competing with it — picking the further-along view collapsed the list to the dispatched
|
|
248
|
+
// slices alone the moment the first subagent returned. See ./progress.ts.
|
|
239
249
|
const sliceTracker = createSliceTracker();
|
|
240
250
|
const planTracker = createTaskPlanTracker();
|
|
241
251
|
let lastTodo;
|
|
242
252
|
const emitProgress = () => {
|
|
243
253
|
if (!opts.onProgress)
|
|
244
254
|
return;
|
|
245
|
-
const progress =
|
|
255
|
+
const progress = mergeProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
|
|
246
256
|
if (progress)
|
|
247
257
|
opts.onProgress(progress);
|
|
248
258
|
};
|
|
249
|
-
|
|
259
|
+
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
260
|
+
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
261
|
+
// turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
|
|
262
|
+
// `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
|
|
263
|
+
// `guardAbort` (folded into streamCli's signal below) and the run then fails with its
|
|
264
|
+
// diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
|
|
265
|
+
const guard = opts.guardLimits
|
|
266
|
+
? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
|
|
267
|
+
: undefined;
|
|
268
|
+
const toolNames = new Map();
|
|
269
|
+
const guardAbort = new AbortController();
|
|
270
|
+
let guardReason;
|
|
271
|
+
// Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
|
|
272
|
+
// with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
|
|
273
|
+
// it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
|
|
274
|
+
// below surfaces `guardReason` over the generic abort message). A standalone closure so the
|
|
275
|
+
// per-block loop doesn't nest onEvent past the readable-depth limit.
|
|
276
|
+
const feedGuard = (content) => {
|
|
277
|
+
if (!guard || guardReason)
|
|
278
|
+
return;
|
|
279
|
+
for (const block of content) {
|
|
280
|
+
if (!isObject(block) || block.type !== 'tool_result')
|
|
281
|
+
continue;
|
|
282
|
+
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
|
|
283
|
+
const name = id ? toolNames.get(id) : undefined;
|
|
284
|
+
if (id)
|
|
285
|
+
toolNames.delete(id);
|
|
286
|
+
if (!name)
|
|
287
|
+
continue;
|
|
288
|
+
const reason = guard.observeSignal({ name, isError: block.is_error === true });
|
|
289
|
+
if (reason) {
|
|
290
|
+
guardReason = reason;
|
|
291
|
+
guardAbort.abort();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const onEvent = (event, meta) => {
|
|
250
297
|
const type = event.type;
|
|
251
298
|
if (type === 'assistant' && isObject(event.message)) {
|
|
252
299
|
const message = event.message;
|
|
@@ -255,7 +302,14 @@ export async function runClaudeCode(opts) {
|
|
|
255
302
|
stats.assistantChars += text.length;
|
|
256
303
|
stats.toolCalls += toolUses;
|
|
257
304
|
for (const block of content) {
|
|
258
|
-
if (isObject(block)
|
|
305
|
+
if (!isObject(block) || block.type !== 'tool_use')
|
|
306
|
+
continue;
|
|
307
|
+
// Remember each call's name against its id so the guard can pair it with the
|
|
308
|
+
// `is_error` its `tool_result` carries on the next `user` turn.
|
|
309
|
+
if (typeof block.id === 'string' && typeof block.name === 'string') {
|
|
310
|
+
toolNames.set(block.id, block.name);
|
|
311
|
+
}
|
|
312
|
+
if (block.name === 'TodoWrite') {
|
|
259
313
|
const progress = todosToProgress(block.input?.todos);
|
|
260
314
|
if (progress)
|
|
261
315
|
lastTodo = progress;
|
|
@@ -288,6 +342,10 @@ export async function runClaudeCode(opts) {
|
|
|
288
342
|
sliceTracker.onUser(content);
|
|
289
343
|
planTracker.onUser(content);
|
|
290
344
|
emitProgress();
|
|
345
|
+
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
346
|
+
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
347
|
+
if (!meta?.final)
|
|
348
|
+
feedGuard(content);
|
|
291
349
|
messages.push({ role: 'tool', content });
|
|
292
350
|
}
|
|
293
351
|
}
|
|
@@ -350,6 +408,11 @@ export async function runClaudeCode(opts) {
|
|
|
350
408
|
...(opts.log ? { log: opts.log } : {}),
|
|
351
409
|
})
|
|
352
410
|
: undefined;
|
|
411
|
+
// Fold the guard's abort into the run signal so a tripped guard kills the CLI the same way the
|
|
412
|
+
// external watchdog does; `guardReason` (set above) distinguishes the two at the catch below.
|
|
413
|
+
const runSignal = opts.signal
|
|
414
|
+
? AbortSignal.any([opts.signal, guardAbort.signal])
|
|
415
|
+
: guardAbort.signal;
|
|
353
416
|
try {
|
|
354
417
|
const { stderrTail } = await streamCli({
|
|
355
418
|
command: 'claude',
|
|
@@ -368,7 +431,7 @@ export async function runClaudeCode(opts) {
|
|
|
368
431
|
opts.model,
|
|
369
432
|
...appendArgs,
|
|
370
433
|
],
|
|
371
|
-
}, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
434
|
+
}, prompt, { ...opts, signal: runSignal }, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
372
435
|
return await assembleClaudeOutcome({
|
|
373
436
|
summary,
|
|
374
437
|
stats,
|
|
@@ -379,6 +442,17 @@ export async function runClaudeCode(opts) {
|
|
|
379
442
|
subagents,
|
|
380
443
|
});
|
|
381
444
|
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
// A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
|
|
447
|
+
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
|
|
448
|
+
// it attached, since that is usually the only evidence of what the CLI was doing when it was
|
|
449
|
+
// killed. Byte-for-byte the shape `runPi` fails with.
|
|
450
|
+
if (guardReason) {
|
|
451
|
+
const tail = err?.stderrTail;
|
|
452
|
+
throw new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason);
|
|
453
|
+
}
|
|
454
|
+
throw err;
|
|
455
|
+
}
|
|
382
456
|
finally {
|
|
383
457
|
await subagents?.stop();
|
|
384
458
|
if (configHome) {
|
package/dist/claude-stream.js
CHANGED
|
@@ -7,6 +7,24 @@ import { redact } from './redact.js';
|
|
|
7
7
|
export function isObject(value) {
|
|
8
8
|
return typeof value === 'object' && value !== null;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
|
|
12
|
+
* shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
|
|
13
|
+
* `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
|
|
14
|
+
* harness runs against whatever CLI the image happens to bundle, and matching only the old name
|
|
15
|
+
* is what left a CLI 2.1.x pr-review reporting no slices at all.
|
|
16
|
+
*
|
|
17
|
+
* Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
|
|
18
|
+
* FALSE signal rather than merely no signal — if a future build were to name a plain task-list
|
|
19
|
+
* tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
|
|
20
|
+
* build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
|
|
21
|
+
* `progress.ts`), and dropping legacy coverage is the more likely regression.
|
|
22
|
+
*
|
|
23
|
+
* Lives here rather than in `subagents.ts` because BOTH the slice tracker and the no-progress
|
|
24
|
+
* guard (`pi.ts`, which `subagents.ts` imports — so it cannot import back) must agree on what a
|
|
25
|
+
* subagent dispatch looks like.
|
|
26
|
+
*/
|
|
27
|
+
export const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
|
|
10
28
|
export function numberOf(value) {
|
|
11
29
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
12
30
|
}
|
package/dist/embed.js
CHANGED
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
// repo, write the agent context, point Pi at an OpenAI-compatible endpoint, run
|
|
5
5
|
// it, and inspect what changed. The HTTP server / job lifecycle stays internal;
|
|
6
6
|
// only the reusable primitives are exposed here.
|
|
7
|
-
export { PI_MAX_OUTPUT_TOKENS,
|
|
7
|
+
export { PI_MAX_OUTPUT_TOKENS, writePiModelsConfig, writeAgentsContext, runPi, summarizePiRun, parsePiOutput, parseTodoProgress, terminalRunError, } from './pi.js';
|
|
8
|
+
export { DEFAULT_PROGRESS_GUARD_LIMITS, progressGuardLimitsFromEnv, } from './progress-guard.js';
|
|
8
9
|
export { cloneRepo, createBranch, changedPathsFromPorcelain, hasAgentChanges, redactSecrets, } from './git.js';
|
package/dist/pi-workspace.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readEffortReport } from './effort.js';
|
|
5
5
|
import { log } from './logger.js';
|
|
6
|
-
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources,
|
|
6
|
+
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
7
|
+
import { mergeGuardLimits, progressGuardLimitsFromEnv, } from './progress-guard.js';
|
|
7
8
|
import { runSubscriptionHarness } from './agent-runner.js';
|
|
8
9
|
// The thin base every container agent shares: an ephemeral working directory, and
|
|
9
10
|
// one Pi run inside it driven by the harness-written context. The agents differ in
|
|
@@ -106,6 +107,29 @@ export async function acquireRepoCheckout(opts, fn) {
|
|
|
106
107
|
return withPersistentWorkspace(opts.repo, fn);
|
|
107
108
|
return withWorkspace(opts.prefix, fn);
|
|
108
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Whether the run's checkout actually ships a `blueprints/` folder — what gates the blueprint
|
|
112
|
+
* orientation note in AGENTS.md (an external repo has none, so the note would be ~10 lines of
|
|
113
|
+
* dead guidance pointing at files that don't exist, re-sent on every turn).
|
|
114
|
+
*
|
|
115
|
+
* A MULTI-REPO run's `dir` is the workspace ROOT with each repo checked out as a sibling under
|
|
116
|
+
* it, so the root itself never holds `blueprints/`: the legs are checked too, and the note is
|
|
117
|
+
* included when ANY leg ships one (it orients the agent to the concept, and the agent finds the
|
|
118
|
+
* per-repo folder from there). Best-effort throughout — any stat/readdir failure simply omits
|
|
119
|
+
* the note rather than failing the dispatch.
|
|
120
|
+
*/
|
|
121
|
+
export async function checkoutHasBlueprints(dir, multiRepo) {
|
|
122
|
+
const isBlueprintDir = (path) => stat(join(path, 'blueprints'))
|
|
123
|
+
.then((s) => s.isDirectory())
|
|
124
|
+
.catch(() => false);
|
|
125
|
+
if (await isBlueprintDir(dir))
|
|
126
|
+
return true;
|
|
127
|
+
if (!multiRepo)
|
|
128
|
+
return false;
|
|
129
|
+
const legs = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
130
|
+
const checks = await Promise.all(legs.filter((e) => e.isDirectory()).map((e) => isBlueprintDir(join(dir, e.name))));
|
|
131
|
+
return checks.some(Boolean);
|
|
132
|
+
}
|
|
109
133
|
/**
|
|
110
134
|
* Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
|
|
111
135
|
* then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
|
|
@@ -148,6 +172,13 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
148
172
|
...(spec.skill ? { skill: spec.skill } : {}),
|
|
149
173
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
150
174
|
signal: opts.signal,
|
|
175
|
+
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
176
|
+
// defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
|
|
177
|
+
// no-edit allowance, so a claude-code run that stops making progress is killed early
|
|
178
|
+
// instead of burning the full wall-clock budget. The claude runner consumes it; codex
|
|
179
|
+
// ignores it for now (its stream isn't wired to the guard).
|
|
180
|
+
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
181
|
+
expectsEdits: spec.expectsEdits ?? true,
|
|
151
182
|
onActivity: opts.onActivity,
|
|
152
183
|
onProgress: opts.onProgress,
|
|
153
184
|
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
@@ -180,11 +211,13 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
180
211
|
const webSearch = webSearchConfigFromEnv({ ...process.env, ...extraEnv });
|
|
181
212
|
if (webSearch)
|
|
182
213
|
await writeWebToolsConfig(webSearch);
|
|
214
|
+
const hasBlueprints = await checkoutHasBlueprints(spec.dir, spec.multiRepo === true);
|
|
183
215
|
await writeAgentsContext(spec.systemPrompt, {
|
|
184
216
|
webSearch: Boolean(webSearch),
|
|
185
217
|
guidance: spec.webToolsGuidance,
|
|
186
218
|
serviceDirectory: spec.serviceDirectory,
|
|
187
219
|
contextFiles,
|
|
220
|
+
hasBlueprints,
|
|
188
221
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
189
222
|
});
|
|
190
223
|
await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
|
package/dist/pi.js
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 { ProgressGuard, progressGuardLimitsFromEnv, toolCallSignal, } from './progress-guard.js';
|
|
10
11
|
// Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
|
|
11
12
|
// proxy via a custom provider in ~/.pi/agent/models.json, authenticated with the
|
|
12
13
|
// per-job session token (interpolated from $PI_PROXY_TOKEN) — so no provider key
|
|
@@ -93,21 +94,12 @@ modules). Do NOT read every module file. Only open \`blueprints/modules/<name>.m
|
|
|
93
94
|
for a module that is directly relevant to your task, when you need its summary and
|
|
94
95
|
exact code references. \`blueprints/version.json\` is a tiny manifest for quick
|
|
95
96
|
staleness checks. Treat the blueprint as orientation, not a task list.`;
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
If a \`spec/\` folder exists, it is the specification for this service. It is sharded
|
|
104
|
-
by a module (domain) → feature (group) taxonomy. **Read \`spec/overview.md\` first** —
|
|
105
|
-
it states what MUST be true and indexes the modules and their features (with links).
|
|
106
|
-
Open \`spec/modules/<module>/<feature>.md\` (or its \`.json\` for exact detail) for the
|
|
107
|
-
feature you are working on — it carries that feature's requirements AND the domain
|
|
108
|
-
rules scoped to it. \`spec/features/<module>/<feature>.feature\` are the Gherkin
|
|
109
|
-
acceptance scenarios your work must satisfy — treat them as the source of truth for
|
|
110
|
-
behaviour and tests. Read only the modules/features relevant to your task.`;
|
|
97
|
+
// NOTE: the spec-reading guidance is NOT appended here. It is contributed once, backend-side, by
|
|
98
|
+
// the `spec-aware` trait (`SPEC_AWARE_GUIDANCE` in @cat-factory/agents), which lands in the
|
|
99
|
+
// composed system prompt for every spec-aware kind on BOTH harness paths. This harness used to
|
|
100
|
+
// append a near-duplicate block, so a spec-aware Pi run carried the guidance twice; the claude-code
|
|
101
|
+
// path never appended it. Sourcing it solely from the trait dedupes the Pi prompt and makes the two
|
|
102
|
+
// paths consistent. (A non-spec-aware kind is deliberately not told to read the spec.)
|
|
111
103
|
/**
|
|
112
104
|
* Write the composed system prompt as Pi's GLOBAL agent context
|
|
113
105
|
* (`~/.pi/agent/AGENTS.md`), which Pi reads automatically and concatenates with
|
|
@@ -143,7 +135,12 @@ export async function writeAgentsContext(systemPrompt, opts = {}) {
|
|
|
143
135
|
// Point the agent at any linked context the backend materialised into the checkout
|
|
144
136
|
// (requirements / RFCs / PRDs / tracker issues) so it reads them on demand.
|
|
145
137
|
const context = contextGuidance(opts.contextFiles ?? []);
|
|
146
|
-
|
|
138
|
+
// Only orient the agent to `blueprints/` when the checkout actually has them — otherwise the
|
|
139
|
+
// note is dead weight re-sent on every turn. The spec-reading guidance is NOT appended here
|
|
140
|
+
// (see the note above `writeAgentsContext`): it comes solely from the backend `spec-aware`
|
|
141
|
+
// trait, so a spec-aware run no longer carries it twice.
|
|
142
|
+
const blueprint = opts.hasBlueprints ? BLUEPRINT_GUIDANCE : '';
|
|
143
|
+
await writeFile(join(dir, 'AGENTS.md'), `${systemPrompt}${blueprint}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}`, 'utf8');
|
|
147
144
|
}
|
|
148
145
|
/** The MULTI-REPO mechanics note appended to AGENTS.md when a run spans sibling checkouts. */
|
|
149
146
|
const MULTI_REPO_GUIDANCE = `
|
|
@@ -497,174 +494,8 @@ export function parseTodoProgress(event) {
|
|
|
497
494
|
}
|
|
498
495
|
return undefined;
|
|
499
496
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
// `tool_execution_end` is the canonical per-call stream event (statsFromEvents
|
|
503
|
-
// counts the same one), so the guard reads it and nothing else — no double count.
|
|
504
|
-
if (event.type !== 'tool_execution_end')
|
|
505
|
-
return undefined;
|
|
506
|
-
const name = typeof event.toolName === 'string' ? event.toolName : '';
|
|
507
|
-
return { name, isError: event.isError === true };
|
|
508
|
-
}
|
|
509
|
-
// `satisfies` (not a type annotation) so each property keeps its concrete `number`
|
|
510
|
-
// type — `maxConsecutiveWebCalls` is optional on the interface (callers may omit it),
|
|
511
|
-
// but the defaults always define it, so consumers reading it off here get a `number`.
|
|
512
|
-
export const DEFAULT_PROGRESS_GUARD_LIMITS = {
|
|
513
|
-
// Counts only non-exploration, non-planning calls (see EXPLORATION_TOOLS), so the
|
|
514
|
-
// ceiling can be generous without risking a false kill on a read-heavy large task.
|
|
515
|
-
maxToolCallsWithoutEdit: 40,
|
|
516
|
-
maxConsecutiveErrors: 12,
|
|
517
|
-
// A genuine research burst is a handful of searches; an uninterrupted run of this
|
|
518
|
-
// many web calls (with no read/edit/bash between) is a search loop, not progress.
|
|
519
|
-
maxConsecutiveWebCalls: 25,
|
|
520
|
-
};
|
|
521
|
-
// Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
|
|
522
|
-
// broad on purpose: different models/extensions name the same capability differently
|
|
523
|
-
// (`edit`/`write`, but also `apply_patch`/`patch`/`str_replace`/`multiedit`/`create`),
|
|
524
|
-
// and a false "no edits" reading would kill a run that IS making changes. Matched
|
|
525
|
-
// case-insensitively. NOTE: a file written purely via `bash` (e.g. a heredoc) is not
|
|
526
|
-
// recognised here — broaden or move to a working-tree signal if that becomes common.
|
|
527
|
-
const FILE_EDIT_TOOLS = new Set([
|
|
528
|
-
'edit',
|
|
529
|
-
'write',
|
|
530
|
-
'apply_patch',
|
|
531
|
-
'patch',
|
|
532
|
-
'str_replace',
|
|
533
|
-
'multiedit',
|
|
534
|
-
'create',
|
|
535
|
-
]);
|
|
536
|
-
// Planning/bookkeeping tools that are neither file edits nor the environment-probing
|
|
537
|
-
// the no-edit bound targets — the todo list the agent maintains as it works. These do
|
|
538
|
-
// NOT count toward `maxToolCallsWithoutEdit`: a run that diligently updates a long
|
|
539
|
-
// todo list before its first edit (common on a large task) would otherwise be killed
|
|
540
|
-
// for "no edits" purely from planning calls. They still reset the consecutive-error
|
|
541
|
-
// streak (a successful call means the agent isn't wedged). Matched case-insensitively.
|
|
542
|
-
const PLANNING_TOOLS = new Set(['todo']);
|
|
543
|
-
// Read-only exploration tools: reading/searching the repo is legitimate work-up to an
|
|
544
|
-
// edit, NOT the environment-probing the no-edit bound targets, so they don't count
|
|
545
|
-
// toward `maxToolCallsWithoutEdit` (a large task may read/search dozens of files
|
|
546
|
-
// before its first edit). The bound thus counts only "action" calls — chiefly `bash`
|
|
547
|
-
// (the credential rabbit-hole's vector) — that have yet to produce an edit. Kept broad
|
|
548
|
-
// since models/extensions name the same capability differently. Matched case-insensitively.
|
|
549
|
-
const EXPLORATION_TOOLS = new Set([
|
|
550
|
-
'read',
|
|
551
|
-
'grep',
|
|
552
|
-
'search',
|
|
553
|
-
'glob',
|
|
554
|
-
'ls',
|
|
555
|
-
'list',
|
|
556
|
-
'find',
|
|
557
|
-
'tree',
|
|
558
|
-
'cat',
|
|
559
|
-
'view',
|
|
560
|
-
'head',
|
|
561
|
-
'tail',
|
|
562
|
-
'stat',
|
|
563
|
-
// rpiv-web-tools: querying/reading the web is read-only research up to an edit,
|
|
564
|
-
// not the environment-probing the no-edit bound targets, so it doesn't count.
|
|
565
|
-
'web_search',
|
|
566
|
-
'web_fetch',
|
|
567
|
-
]);
|
|
568
|
-
// The rpiv-web-tools calls, tracked separately so an unbounded run of them (with no
|
|
569
|
-
// other tool call between) can be caught as a search loop — see `maxConsecutiveWebCalls`.
|
|
570
|
-
const WEB_TOOLS = new Set(['web_search', 'web_fetch']);
|
|
571
|
-
/** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
|
|
572
|
-
export function progressGuardLimitsFromEnv(env = process.env) {
|
|
573
|
-
const num = (raw, fallback) => {
|
|
574
|
-
const n = Number(raw);
|
|
575
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
576
|
-
};
|
|
577
|
-
return {
|
|
578
|
-
maxToolCallsWithoutEdit: num(env.JOB_MAX_TOOLCALLS_WITHOUT_EDIT, DEFAULT_PROGRESS_GUARD_LIMITS.maxToolCallsWithoutEdit),
|
|
579
|
-
maxConsecutiveErrors: num(env.JOB_MAX_CONSECUTIVE_TOOL_ERRORS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveErrors),
|
|
580
|
-
maxConsecutiveWebCalls: num(env.JOB_MAX_CONSECUTIVE_WEB_CALLS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls),
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* Apply per-knob overrides onto a base set of guard limits, ENFORCING loosen-only: an
|
|
585
|
-
* override can only RAISE a knob (more headroom), never lower it below the base. A
|
|
586
|
-
* larger value is more lenient for every knob (more no-edit tool calls / errors / web
|
|
587
|
-
* calls tolerated), so each result is `max(base, override)`. This is a hard guarantee,
|
|
588
|
-
* not a convention — a tuning entry (built-in or a custom kind's, which reaches this via
|
|
589
|
-
* an untrusted job body) that supplies a value TIGHTER than the base is clamped back up
|
|
590
|
-
* to the base rather than aborting a legitimately-progressing run. An absent/undefined
|
|
591
|
-
* knob keeps the base value untouched.
|
|
592
|
-
*/
|
|
593
|
-
export function mergeGuardLimits(base, overrides) {
|
|
594
|
-
if (!overrides)
|
|
595
|
-
return base;
|
|
596
|
-
const loosen = (b, o) => typeof o === 'number' ? Math.max(b, o) : b;
|
|
597
|
-
return {
|
|
598
|
-
maxToolCallsWithoutEdit: loosen(base.maxToolCallsWithoutEdit, overrides.maxToolCallsWithoutEdit),
|
|
599
|
-
maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
|
|
600
|
-
// `maxConsecutiveWebCalls` is optional on the interface (callers may omit it), so
|
|
601
|
-
// fall back to the default before loosening — keeps `loosen`'s base a concrete number.
|
|
602
|
-
maxConsecutiveWebCalls: loosen(base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls, overrides.maxConsecutiveWebCalls),
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
/**
|
|
606
|
-
* Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
|
|
607
|
-
* reason the moment a run has plainly stopped making progress, so the harness can
|
|
608
|
-
* kill Pi early instead of letting it burn the whole budget (and then surface a
|
|
609
|
-
* useful failure instead of a generic "no file changes"). Pure and incremental so
|
|
610
|
-
* it can be unit-tested over a fixed event sequence.
|
|
611
|
-
*/
|
|
612
|
-
export class ProgressGuard {
|
|
613
|
-
limits;
|
|
614
|
-
expectsEdits;
|
|
615
|
-
toolCalls = 0;
|
|
616
|
-
edits = 0;
|
|
617
|
-
consecutiveErrors = 0;
|
|
618
|
-
consecutiveWebCalls = 0;
|
|
619
|
-
constructor(limits,
|
|
620
|
-
/** When false (assess-only runs like the merger), the no-edit bound is skipped. */
|
|
621
|
-
expectsEdits = true) {
|
|
622
|
-
this.limits = limits;
|
|
623
|
-
this.expectsEdits = expectsEdits;
|
|
624
|
-
}
|
|
625
|
-
/** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
|
|
626
|
-
observe(event) {
|
|
627
|
-
const tool = toolCallSignal(event);
|
|
628
|
-
if (!tool)
|
|
629
|
-
return null;
|
|
630
|
-
const name = tool.name.toLowerCase();
|
|
631
|
-
// The error streak tracks ANY tool call (a planning call still proves the agent
|
|
632
|
-
// isn't wedged in a failing-op loop), so it's updated before the planning skip.
|
|
633
|
-
this.consecutiveErrors = tool.isError ? this.consecutiveErrors + 1 : 0;
|
|
634
|
-
if (this.consecutiveErrors >= this.limits.maxConsecutiveErrors) {
|
|
635
|
-
return (`no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
|
|
636
|
-
`retrying a failing operation rather than making progress. Aborting.`);
|
|
637
|
-
}
|
|
638
|
-
// Web search/fetch loop: web tools are read-only (they don't count toward the
|
|
639
|
-
// no-edit bound), so guard them separately — an uninterrupted streak of them is a
|
|
640
|
-
// research rabbit-hole. Any non-web tool call resets the streak.
|
|
641
|
-
if (WEB_TOOLS.has(name)) {
|
|
642
|
-
this.consecutiveWebCalls++;
|
|
643
|
-
const webCap = this.limits.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls;
|
|
644
|
-
if (this.consecutiveWebCalls >= webCap) {
|
|
645
|
-
return (`no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
|
|
646
|
-
`any other action — the agent is stuck researching instead of doing the work. Aborting.`);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
else {
|
|
650
|
-
this.consecutiveWebCalls = 0;
|
|
651
|
-
}
|
|
652
|
-
// Planning and read-only exploration calls don't count toward the no-edit bound
|
|
653
|
-
// (see PLANNING_TOOLS / EXPLORATION_TOOLS) — only "action" calls without an edit do.
|
|
654
|
-
if (PLANNING_TOOLS.has(name) || EXPLORATION_TOOLS.has(name))
|
|
655
|
-
return null;
|
|
656
|
-
this.toolCalls++;
|
|
657
|
-
if (FILE_EDIT_TOOLS.has(name))
|
|
658
|
-
this.edits++;
|
|
659
|
-
if (this.expectsEdits &&
|
|
660
|
-
this.edits === 0 &&
|
|
661
|
-
this.toolCalls >= this.limits.maxToolCallsWithoutEdit) {
|
|
662
|
-
return (`no progress: ${this.toolCalls} tool calls and not one file edit — the agent is exploring or ` +
|
|
663
|
-
`probing the environment without implementing anything. Aborting before it burns the whole run.`);
|
|
664
|
-
}
|
|
665
|
-
return null;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
497
|
+
// The no-progress guard (its limits, tool vocabulary and the `ProgressGuard` itself) lives in
|
|
498
|
+
// `progress-guard.ts` — it is shared with the claude-code runner, so it is no longer Pi's.
|
|
668
499
|
/**
|
|
669
500
|
* Run Pi non-interactively against `cwd` and return its assistant summary. Uses
|
|
670
501
|
* print + JSON mode (`-p --mode json`) with `--approve` so it runs unattended.
|