@cat-factory/executor-harness 1.62.0 → 1.64.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/dist/agent-runner.js +81 -8
- 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/subagents.js +1 -50
- package/package.json +4 -4
- package/src/agent-runner.ts +88 -8
- 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/subagents.ts +7 -16
package/dist/agent-runner.js
CHANGED
|
@@ -4,6 +4,7 @@ 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';
|
|
@@ -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) {
|
|
@@ -246,7 +255,44 @@ export async function runClaudeCode(opts) {
|
|
|
246
255
|
if (progress)
|
|
247
256
|
opts.onProgress(progress);
|
|
248
257
|
};
|
|
249
|
-
|
|
258
|
+
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
259
|
+
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
260
|
+
// turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
|
|
261
|
+
// `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
|
|
262
|
+
// `guardAbort` (folded into streamCli's signal below) and the run then fails with its
|
|
263
|
+
// diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
|
|
264
|
+
const guard = opts.guardLimits
|
|
265
|
+
? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
|
|
266
|
+
: undefined;
|
|
267
|
+
const toolNames = new Map();
|
|
268
|
+
const guardAbort = new AbortController();
|
|
269
|
+
let guardReason;
|
|
270
|
+
// Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
|
|
271
|
+
// with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
|
|
272
|
+
// it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
|
|
273
|
+
// below surfaces `guardReason` over the generic abort message). A standalone closure so the
|
|
274
|
+
// per-block loop doesn't nest onEvent past the readable-depth limit.
|
|
275
|
+
const feedGuard = (content) => {
|
|
276
|
+
if (!guard || guardReason)
|
|
277
|
+
return;
|
|
278
|
+
for (const block of content) {
|
|
279
|
+
if (!isObject(block) || block.type !== 'tool_result')
|
|
280
|
+
continue;
|
|
281
|
+
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
|
|
282
|
+
const name = id ? toolNames.get(id) : undefined;
|
|
283
|
+
if (id)
|
|
284
|
+
toolNames.delete(id);
|
|
285
|
+
if (!name)
|
|
286
|
+
continue;
|
|
287
|
+
const reason = guard.observeSignal({ name, isError: block.is_error === true });
|
|
288
|
+
if (reason) {
|
|
289
|
+
guardReason = reason;
|
|
290
|
+
guardAbort.abort();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const onEvent = (event, meta) => {
|
|
250
296
|
const type = event.type;
|
|
251
297
|
if (type === 'assistant' && isObject(event.message)) {
|
|
252
298
|
const message = event.message;
|
|
@@ -255,7 +301,14 @@ export async function runClaudeCode(opts) {
|
|
|
255
301
|
stats.assistantChars += text.length;
|
|
256
302
|
stats.toolCalls += toolUses;
|
|
257
303
|
for (const block of content) {
|
|
258
|
-
if (isObject(block)
|
|
304
|
+
if (!isObject(block) || block.type !== 'tool_use')
|
|
305
|
+
continue;
|
|
306
|
+
// Remember each call's name against its id so the guard can pair it with the
|
|
307
|
+
// `is_error` its `tool_result` carries on the next `user` turn.
|
|
308
|
+
if (typeof block.id === 'string' && typeof block.name === 'string') {
|
|
309
|
+
toolNames.set(block.id, block.name);
|
|
310
|
+
}
|
|
311
|
+
if (block.name === 'TodoWrite') {
|
|
259
312
|
const progress = todosToProgress(block.input?.todos);
|
|
260
313
|
if (progress)
|
|
261
314
|
lastTodo = progress;
|
|
@@ -288,6 +341,10 @@ export async function runClaudeCode(opts) {
|
|
|
288
341
|
sliceTracker.onUser(content);
|
|
289
342
|
planTracker.onUser(content);
|
|
290
343
|
emitProgress();
|
|
344
|
+
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
345
|
+
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
346
|
+
if (!meta?.final)
|
|
347
|
+
feedGuard(content);
|
|
291
348
|
messages.push({ role: 'tool', content });
|
|
292
349
|
}
|
|
293
350
|
}
|
|
@@ -350,6 +407,11 @@ export async function runClaudeCode(opts) {
|
|
|
350
407
|
...(opts.log ? { log: opts.log } : {}),
|
|
351
408
|
})
|
|
352
409
|
: undefined;
|
|
410
|
+
// Fold the guard's abort into the run signal so a tripped guard kills the CLI the same way the
|
|
411
|
+
// external watchdog does; `guardReason` (set above) distinguishes the two at the catch below.
|
|
412
|
+
const runSignal = opts.signal
|
|
413
|
+
? AbortSignal.any([opts.signal, guardAbort.signal])
|
|
414
|
+
: guardAbort.signal;
|
|
353
415
|
try {
|
|
354
416
|
const { stderrTail } = await streamCli({
|
|
355
417
|
command: 'claude',
|
|
@@ -368,7 +430,7 @@ export async function runClaudeCode(opts) {
|
|
|
368
430
|
opts.model,
|
|
369
431
|
...appendArgs,
|
|
370
432
|
],
|
|
371
|
-
}, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
433
|
+
}, prompt, { ...opts, signal: runSignal }, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
372
434
|
return await assembleClaudeOutcome({
|
|
373
435
|
summary,
|
|
374
436
|
stats,
|
|
@@ -379,6 +441,17 @@ export async function runClaudeCode(opts) {
|
|
|
379
441
|
subagents,
|
|
380
442
|
});
|
|
381
443
|
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
// A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
|
|
446
|
+
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
|
|
447
|
+
// it attached, since that is usually the only evidence of what the CLI was doing when it was
|
|
448
|
+
// killed. Byte-for-byte the shape `runPi` fails with.
|
|
449
|
+
if (guardReason) {
|
|
450
|
+
const tail = err?.stderrTail;
|
|
451
|
+
throw new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason);
|
|
452
|
+
}
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
382
455
|
finally {
|
|
383
456
|
await subagents?.stop();
|
|
384
457
|
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.
|