@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.
@@ -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
- const processLine = (line) => {
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
- reject(new Error('agent run aborted by watchdog'));
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 TWO reconciled
228
- // sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
229
- // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
230
- // progress for the parallel shape; the parent's own plan (the sequential shape) is tracked
231
- // by `planTracker` + `lastTodo`. `pickProgress` picks whichever is further along on each
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 see ./progress.ts.
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 = pickProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
255
+ const progress = mergeProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
246
256
  if (progress)
247
257
  opts.onProgress(progress);
248
258
  };
249
- const onEvent = (event) => {
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) && block.type === 'tool_use' && block.name === 'TodoWrite') {
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) {
@@ -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, DEFAULT_PROGRESS_GUARD_LIMITS, writePiModelsConfig, writeAgentsContext, runPi, summarizePiRun, parsePiOutput, parseTodoProgress, progressGuardLimitsFromEnv, terminalRunError, } from './pi.js';
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';
@@ -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, mergeGuardLimits, progressGuardLimitsFromEnv, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
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
- // Appended to every AGENTS.md so an agent treats the persisted spec as the
97
- // PRESCRIPTIVE source (what must be true) and the acceptance scenarios its work must
98
- // satisfy. Harmless when no spec exists yet the files simply aren't there.
99
- const SPEC_GUIDANCE = `
100
-
101
- ## Service specification (the prescriptive spec)
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
- await writeFile(join(dir, 'AGENTS.md'), `${systemPrompt}${BLUEPRINT_GUIDANCE}${SPEC_GUIDANCE}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}`, 'utf8');
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
- /** Tool-call signal read off a streamed Pi event, or undefined if not a tool call. */
501
- function toolCallSignal(event) {
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.