@cat-factory/executor-harness 1.60.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/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.
@@ -0,0 +1,157 @@
1
+ import { readFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { inertInline, inertMarkdown, walkFences } from './host-markdown.js';
4
+ import { redactSecrets } from './redact.js';
5
+ // ---------------------------------------------------------------------------
6
+ // The agent-authored pull-request description side channel. A coding agent whose
7
+ // dispatch opens a PR is asked (via the backend-composed system prompt) to end its
8
+ // run by writing a reviewer briefing — the problem, the decisions made, what to
9
+ // look out for — to a sentinel file at the root of the checkout the PR belongs to.
10
+ // The harness reads it after the agent settles, removes it (so it never lands in a
11
+ // commit), and uses it as the PR body in place of the generic dispatch-time text
12
+ // the job body carries. Absent or unusable ⇒ the dispatch-time fallback, unchanged.
13
+ //
14
+ // The briefing is MODEL-AUTHORED text landing verbatim on a host-parsed surface, so
15
+ // it crosses `host-markdown.ts` (auto-link triggers defused, open code fences closed)
16
+ // on the way out — see that module for why a PR body is not an inert string sink.
17
+ //
18
+ // The filename is kept in sync with `PR_DESCRIPTION_FILE` in `@cat-factory/agents`
19
+ // (the executor-harness has no dependency on that package), exactly like the
20
+ // effort-report and follow-ups sentinels.
21
+ // ---------------------------------------------------------------------------
22
+ /** The sentinel file the agent writes its PR description to (relative to the checkout root). */
23
+ export const PR_DESCRIPTION_FILE = '.cat-pr-description.md';
24
+ /**
25
+ * Ceiling on the agent-authored body.
26
+ *
27
+ * The engine appends its verification report to the SAME body later, and that section carries
28
+ * its own 50,000-character ceiling (`MAX_SECTION_CHARS` in kernel's `hostMarkdown`). GitHub
29
+ * rejects a body over 65,536 with a 422, and the report publisher swallows its own failures —
30
+ * so a briefing budget that does not leave the report room would surface as a report that
31
+ * silently never publishes. 15,000 + 50,000 stays under the limit with room to join them.
32
+ */
33
+ const MAX_PR_BODY_CHARS = 15_000;
34
+ /** Ceiling on an agent-supplied title (GitHub truncates around 256; a title should be short). */
35
+ const MAX_PR_TITLE_CHARS = 160;
36
+ /** Opens the engine-managed region of a PR body (kept in sync with `kernel/domain/pr-report.ts`). */
37
+ export const PR_REPORT_MARKER_START = '<!-- cat-factory:verification-report:start -->';
38
+ /** Closes the engine-managed region of a PR body. */
39
+ export const PR_REPORT_MARKER_END = '<!-- cat-factory:verification-report:end -->';
40
+ /**
41
+ * A marker inside the agent-authored briefing would make the engine's splice treat part of the
42
+ * briefing as its own managed region and rewrite it, so any occurrence is stripped up front.
43
+ * Deliberately laxer than the exact constants above (whitespace-tolerant), so a near-miss the
44
+ * splice itself would not match cannot survive here either.
45
+ */
46
+ const MANAGED_SECTION_MARKER = /<!--\s*cat-factory:verification-report:(?:start|end)\s*-->/g;
47
+ /**
48
+ * Read + parse + REMOVE the agent's PR-description sentinel from `dir`. Lenient: returns
49
+ * undefined when the file is absent (the agent wrote none) or carries nothing usable. Never
50
+ * throws — a bad description must never fail an otherwise-good run; the caller falls back to
51
+ * the dispatch-time text.
52
+ *
53
+ * A SINGLE `# <title>` heading on the first line sets the PR title; everything after it is the
54
+ * body (see {@link splitTitle} for why a LONE heading is required). The whole text is
55
+ * secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent cut would
56
+ * read as the complete briefing), and both halves are made inert for the host.
57
+ *
58
+ * On scrubbing: `redactSecrets`'s credential-assignment rule is deliberately eager, so a
59
+ * briefing sentence like "the token: handling changed" loses its next word. That is the right
60
+ * trade for a surface this public — the rule is shared with every other redaction path, and
61
+ * narrowing it so prose reads better would weaken all of them.
62
+ */
63
+ export async function readPrDescription(dir) {
64
+ const path = join(dir, PR_DESCRIPTION_FILE);
65
+ let raw;
66
+ try {
67
+ raw = await readFile(path, 'utf8');
68
+ }
69
+ catch {
70
+ return undefined; // no description written — the fallback body applies
71
+ }
72
+ // Remove it so it never lands in a commit (defence in depth; the checkout also excludes it).
73
+ await rm(path, { force: true }).catch(() => { });
74
+ const text = redactSecrets(raw).replace(MANAGED_SECTION_MARKER, '').trim();
75
+ if (!text)
76
+ return undefined;
77
+ const split = splitTitle(text);
78
+ // Cap BEFORE the escapes on both halves, so a numeric entity can never be sliced in half.
79
+ const title = split.title ? inertInline(capTitle(split.title)) : undefined;
80
+ const body = split.body ? inertMarkdown(capBody(split.body)) : undefined;
81
+ if (!title && !body)
82
+ return undefined;
83
+ return { ...(title ? { title } : {}), ...(body ? { body } : {}) };
84
+ }
85
+ /**
86
+ * Split a leading `# <title>` heading off the briefing.
87
+ *
88
+ * The heading becomes the title ONLY when it is the single level-1 heading in the whole file,
89
+ * which is exactly what the prompt asks for ("a single `# <title>` heading line"). An agent
90
+ * that instead uses `#` for its section headings — `# Problem`, `# Decisions`, entirely
91
+ * idiomatic for the briefing the prompt describes — would otherwise have its first section
92
+ * silently become the pull request's title, replacing `<block> (<pipeline>)` with the word
93
+ * "Problem". Headings inside fenced code are not headings and are skipped, or a briefing
94
+ * quoting a shell snippet (`# rebuild the image`) would lose its title to the snippet.
95
+ */
96
+ function splitTitle(text) {
97
+ const lines = text.split('\n');
98
+ const headings = [];
99
+ let index = 0;
100
+ walkFences(lines, (line, insideFence) => {
101
+ if (!insideFence && /^#\s+\S/.test(line))
102
+ headings.push(index);
103
+ index += 1;
104
+ });
105
+ if (headings.length !== 1 || headings[0] !== 0)
106
+ return { body: text };
107
+ const title = lines[0].replace(/^#\s+/, '').trim();
108
+ if (!title)
109
+ return { body: text };
110
+ return { title, body: lines.slice(1).join('\n').trim() };
111
+ }
112
+ /** Cut an over-long title at a word boundary when one is near, marking the cut. */
113
+ function capTitle(value) {
114
+ const collapsed = value.trim();
115
+ if (collapsed.length <= MAX_PR_TITLE_CHARS)
116
+ return collapsed;
117
+ const head = collapsed.slice(0, MAX_PR_TITLE_CHARS - 1);
118
+ const space = head.lastIndexOf(' ');
119
+ const kept = space > MAX_PR_TITLE_CHARS * 0.6 ? head.slice(0, space) : head;
120
+ return `${kept.trimEnd()}…`;
121
+ }
122
+ /** Cut an over-budget body, marking the cut so it is never read as the whole briefing. */
123
+ function capBody(value) {
124
+ if (value.length <= MAX_PR_BODY_CHARS)
125
+ return value;
126
+ return (value.slice(0, MAX_PR_BODY_CHARS).trimEnd() +
127
+ '\n\n_Truncated by the platform: the description exceeded the size budget._');
128
+ }
129
+ /**
130
+ * Fold an agent-authored description over the dispatch-time fallback the job body carries.
131
+ * Field-wise: the agent's title/body each win when present, so a body-only briefing keeps the
132
+ * backend-composed title and vice versa.
133
+ */
134
+ export function applyPrDescription(fallback, agent) {
135
+ if (!agent)
136
+ return fallback;
137
+ return { title: agent.title ?? fallback.title, body: agent.body ?? fallback.body };
138
+ }
139
+ /**
140
+ * The body to PATCH onto an ALREADY-OPEN pull request when a resumed run produced a fresh
141
+ * briefing: the new description followed by whatever the engine's managed verification-report
142
+ * region currently holds.
143
+ *
144
+ * Carrying the region across is what makes the refresh safe. The engine re-publishes the report
145
+ * on every step settlement, so dropping it here would usually self-heal — but "usually" is not
146
+ * a property to rest the one artefact a reviewer reads on, and a run that settles no further
147
+ * step (the work is already merged, the run failed after its push) would never restore it.
148
+ */
149
+ export function preserveManagedSection(currentBody, nextBody) {
150
+ const existing = currentBody ?? '';
151
+ const start = existing.indexOf(PR_REPORT_MARKER_START);
152
+ const end = existing.indexOf(PR_REPORT_MARKER_END);
153
+ if (start === -1 || end <= start)
154
+ return nextBody;
155
+ const region = existing.slice(start, end + PR_REPORT_MARKER_END.length);
156
+ return `${nextBody.trim()}\n\n${region}\n`;
157
+ }
@@ -0,0 +1,211 @@
1
+ import { SUBAGENT_TOOL_NAMES } from './claude-stream.js';
2
+ // The harness's no-progress guard: the live anti-rabbithole bound every agent run is held to,
3
+ // plus the tool-name vocabulary it classifies calls with and the limits it reads from the
4
+ // environment. Extracted from `pi.ts` when the guard stopped being Pi's: it now also drives the
5
+ // claude-code subscription runner (`agent-runner.ts` feeds it via `observeSignal`), so the two
6
+ // harnesses share ONE definition of "this run has stopped making progress" — and the tool-name
7
+ // sets below deliberately cover both CLIs' vocabularies.
8
+ /**
9
+ * Tool-call signal read off a streamed Pi event, or undefined if not a tool call. Exported for
10
+ * `runPi`'s span emitter, which reads the same event for its per-tool trace spans.
11
+ */
12
+ export function toolCallSignal(event) {
13
+ // `tool_execution_end` is the canonical per-call stream event (statsFromEvents
14
+ // counts the same one), so the guard reads it and nothing else — no double count.
15
+ if (event.type !== 'tool_execution_end')
16
+ return undefined;
17
+ const name = typeof event.toolName === 'string' ? event.toolName : '';
18
+ return { name, isError: event.isError === true };
19
+ }
20
+ // `satisfies` (not a type annotation) so each property keeps its concrete `number`
21
+ // type — `maxConsecutiveWebCalls` is optional on the interface (callers may omit it),
22
+ // but the defaults always define it, so consumers reading it off here get a `number`.
23
+ export const DEFAULT_PROGRESS_GUARD_LIMITS = {
24
+ // Counts only non-exploration, non-planning calls (see EXPLORATION_TOOLS), so the
25
+ // ceiling can be generous without risking a false kill on a read-heavy large task.
26
+ maxToolCallsWithoutEdit: 40,
27
+ maxConsecutiveErrors: 12,
28
+ // A genuine research burst is a handful of searches; an uninterrupted run of this
29
+ // many web calls (with no read/edit/bash between) is a search loop, not progress.
30
+ maxConsecutiveWebCalls: 25,
31
+ };
32
+ // Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
33
+ // broad on purpose: different models/extensions name the same capability differently
34
+ // (`edit`/`write`, but also `apply_patch`/`patch`/`str_replace`/`multiedit`/`create`),
35
+ // and a false "no edits" reading would kill a run that IS making changes. Matched
36
+ // case-insensitively. NOTE: a file written purely via `bash` (e.g. a heredoc) is not
37
+ // recognised here — broaden or move to a working-tree signal if that becomes common.
38
+ const FILE_EDIT_TOOLS = new Set([
39
+ 'edit',
40
+ 'write',
41
+ 'apply_patch',
42
+ 'patch',
43
+ 'str_replace',
44
+ 'multiedit',
45
+ 'create',
46
+ // Claude Code tool names (the guard now runs on the claude-code stream too): Edit/Write/
47
+ // MultiEdit already match above; NotebookEdit is its own tool.
48
+ 'notebookedit',
49
+ ]);
50
+ // Planning/bookkeeping tools that are neither file edits nor the environment-probing
51
+ // the no-edit bound targets — the todo list the agent maintains as it works. These do
52
+ // NOT count toward `maxToolCallsWithoutEdit`: a run that diligently updates a long
53
+ // todo list before its first edit (common on a large task) would otherwise be killed
54
+ // for "no edits" purely from planning calls. They still reset the consecutive-error
55
+ // streak (a successful call means the agent isn't wedged). Matched case-insensitively.
56
+ // `todo` is Pi's tool; `TodoWrite` and the incremental `TaskCreate`/`TaskUpdate` pair are
57
+ // Claude Code's plan vocabularies — all pure bookkeeping, exempt from the no-edit bound.
58
+ const PLANNING_TOOLS = new Set(['todo', 'todowrite', 'taskcreate', 'taskupdate']);
59
+ // A subagent dispatch (Claude Code's `Agent`/`Task`) is exempt from the no-edit bound because
60
+ // the parent stream CANNOT see the edits it makes: only the dispatch and its terminal
61
+ // tool_result appear there, while every Edit/Write the subagent performs happens on a transcript
62
+ // the guard never reads (`subagents.ts` watches those separately, for usage/progress only). So a
63
+ // coder that fans its implementation out across subagents looks, to this guard, like a run making
64
+ // dozens of action calls and zero edits — and would be killed for making excellent progress.
65
+ // Counting them as edits instead would be worse (a read-only research subagent would then clear
66
+ // the suspicion the bound exists to hold), so they are neutral: they neither count toward the
67
+ // bound nor satisfy it. Sourced from the same set the slice tracker matches on, lower-cased for
68
+ // this module's case-insensitive comparison.
69
+ const SUBAGENT_DISPATCH_TOOLS = new Set([...SUBAGENT_TOOL_NAMES].map((name) => name.toLowerCase()));
70
+ // Read-only exploration tools: reading/searching the repo is legitimate work-up to an
71
+ // edit, NOT the environment-probing the no-edit bound targets, so they don't count
72
+ // toward `maxToolCallsWithoutEdit` (a large task may read/search dozens of files
73
+ // before its first edit). The bound thus counts only "action" calls — chiefly `bash`
74
+ // (the credential rabbit-hole's vector) — that have yet to produce an edit. Kept broad
75
+ // since models/extensions name the same capability differently. Matched case-insensitively.
76
+ const EXPLORATION_TOOLS = new Set([
77
+ 'read',
78
+ 'grep',
79
+ 'search',
80
+ 'glob',
81
+ 'ls',
82
+ 'list',
83
+ 'find',
84
+ 'tree',
85
+ 'cat',
86
+ 'view',
87
+ 'head',
88
+ 'tail',
89
+ 'stat',
90
+ // rpiv-web-tools (Pi) + Claude Code's WebSearch/WebFetch: querying/reading the web is
91
+ // read-only research up to an edit, not the environment-probing the no-edit bound targets.
92
+ 'web_search',
93
+ 'web_fetch',
94
+ 'websearch',
95
+ 'webfetch',
96
+ ]);
97
+ // The web-tool calls, tracked separately so an unbounded run of them (with no other tool
98
+ // call between) can be caught as a search loop — see `maxConsecutiveWebCalls`. Covers both
99
+ // Pi's `web_search`/`web_fetch` and Claude Code's `WebSearch`/`WebFetch`.
100
+ const WEB_TOOLS = new Set(['web_search', 'web_fetch', 'websearch', 'webfetch']);
101
+ /** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
102
+ export function progressGuardLimitsFromEnv(env = process.env) {
103
+ const num = (raw, fallback) => {
104
+ const n = Number(raw);
105
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
106
+ };
107
+ return {
108
+ maxToolCallsWithoutEdit: num(env.JOB_MAX_TOOLCALLS_WITHOUT_EDIT, DEFAULT_PROGRESS_GUARD_LIMITS.maxToolCallsWithoutEdit),
109
+ maxConsecutiveErrors: num(env.JOB_MAX_CONSECUTIVE_TOOL_ERRORS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveErrors),
110
+ maxConsecutiveWebCalls: num(env.JOB_MAX_CONSECUTIVE_WEB_CALLS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls),
111
+ };
112
+ }
113
+ /**
114
+ * Apply per-knob overrides onto a base set of guard limits, ENFORCING loosen-only: an
115
+ * override can only RAISE a knob (more headroom), never lower it below the base. A
116
+ * larger value is more lenient for every knob (more no-edit tool calls / errors / web
117
+ * calls tolerated), so each result is `max(base, override)`. This is a hard guarantee,
118
+ * not a convention — a tuning entry (built-in or a custom kind's, which reaches this via
119
+ * an untrusted job body) that supplies a value TIGHTER than the base is clamped back up
120
+ * to the base rather than aborting a legitimately-progressing run. An absent/undefined
121
+ * knob keeps the base value untouched.
122
+ */
123
+ export function mergeGuardLimits(base, overrides) {
124
+ if (!overrides)
125
+ return base;
126
+ const loosen = (b, o) => typeof o === 'number' ? Math.max(b, o) : b;
127
+ return {
128
+ maxToolCallsWithoutEdit: loosen(base.maxToolCallsWithoutEdit, overrides.maxToolCallsWithoutEdit),
129
+ maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
130
+ // `maxConsecutiveWebCalls` is optional on the interface (callers may omit it), so
131
+ // fall back to the default before loosening — keeps `loosen`'s base a concrete number.
132
+ maxConsecutiveWebCalls: loosen(base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls, overrides.maxConsecutiveWebCalls),
133
+ };
134
+ }
135
+ /**
136
+ * Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
137
+ * reason the moment a run has plainly stopped making progress, so the harness can
138
+ * kill Pi early instead of letting it burn the whole budget (and then surface a
139
+ * useful failure instead of a generic "no file changes"). Pure and incremental so
140
+ * it can be unit-tested over a fixed event sequence.
141
+ */
142
+ export class ProgressGuard {
143
+ limits;
144
+ expectsEdits;
145
+ toolCalls = 0;
146
+ edits = 0;
147
+ consecutiveErrors = 0;
148
+ consecutiveWebCalls = 0;
149
+ constructor(limits,
150
+ /** When false (assess-only runs like the merger), the no-edit bound is skipped. */
151
+ expectsEdits = true) {
152
+ this.limits = limits;
153
+ this.expectsEdits = expectsEdits;
154
+ }
155
+ /** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
156
+ observe(event) {
157
+ const tool = toolCallSignal(event);
158
+ if (!tool)
159
+ return null;
160
+ return this.observeSignal(tool);
161
+ }
162
+ /**
163
+ * Feed one already-parsed tool-call signal (name + error flag), returning a diagnostic reason
164
+ * when the run should abort, else null. Split out of {@link observe} so a caller whose stream
165
+ * is NOT Pi's `tool_execution_end` envelope — the claude-code runner, which correlates a
166
+ * `tool_use` block's name with its `tool_result`'s `is_error` — can drive the SAME guard logic
167
+ * without synthesising a fake Pi event.
168
+ */
169
+ observeSignal(tool) {
170
+ const name = tool.name.toLowerCase();
171
+ // The error streak tracks ANY tool call (a planning call still proves the agent
172
+ // isn't wedged in a failing-op loop), so it's updated before the planning skip.
173
+ this.consecutiveErrors = tool.isError ? this.consecutiveErrors + 1 : 0;
174
+ if (this.consecutiveErrors >= this.limits.maxConsecutiveErrors) {
175
+ return (`no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
176
+ `retrying a failing operation rather than making progress. Aborting.`);
177
+ }
178
+ // Web search/fetch loop: web tools are read-only (they don't count toward the
179
+ // no-edit bound), so guard them separately — an uninterrupted streak of them is a
180
+ // research rabbit-hole. Any non-web tool call resets the streak.
181
+ if (WEB_TOOLS.has(name)) {
182
+ this.consecutiveWebCalls++;
183
+ const webCap = this.limits.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls;
184
+ if (this.consecutiveWebCalls >= webCap) {
185
+ return (`no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
186
+ `any other action — the agent is stuck researching instead of doing the work. Aborting.`);
187
+ }
188
+ }
189
+ else {
190
+ this.consecutiveWebCalls = 0;
191
+ }
192
+ // Planning, read-only exploration and subagent-dispatch calls don't count toward the
193
+ // no-edit bound (see PLANNING_TOOLS / EXPLORATION_TOOLS / SUBAGENT_DISPATCH_TOOLS) —
194
+ // only "action" calls without an edit do.
195
+ if (PLANNING_TOOLS.has(name) ||
196
+ EXPLORATION_TOOLS.has(name) ||
197
+ SUBAGENT_DISPATCH_TOOLS.has(name)) {
198
+ return null;
199
+ }
200
+ this.toolCalls++;
201
+ if (FILE_EDIT_TOOLS.has(name))
202
+ this.edits++;
203
+ if (this.expectsEdits &&
204
+ this.edits === 0 &&
205
+ this.toolCalls >= this.limits.maxToolCallsWithoutEdit) {
206
+ return (`no progress: ${this.toolCalls} tool calls and not one file edit — the agent is exploring or ` +
207
+ `probing the environment without implementing anything. Aborting before it burns the whole run.`);
208
+ }
209
+ return null;
210
+ }
211
+ }
package/dist/subagents.js CHANGED
@@ -1,57 +1,8 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
- import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
4
+ import { claudeAssistantContent, claudeCallUsage, isObject, redactBody, SUBAGENT_TOOL_NAMES, } from './claude-stream.js';
5
5
  import { publishCallMetric } from './pi.js';
6
- // ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
7
- // it fans the work out across parallel `Task` subagents. Two things then go dark to the
8
- // harness, which only reads the PARENT process's stream-json stdout:
9
- //
10
- // - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
11
- // review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
12
- // - every subagent's token spend is written to a SEPARATE `subagents/*.jsonl`
13
- // transcript under the CLI's config home and never reaches the parent stream, so
14
- // the run's telemetry reports ~0 tokens while hundreds of thousands are spent (P3).
15
- //
16
- // This module closes both without disabling the (context-bounding, ADR-0023-wanted)
17
- // subagent parallelism:
18
- //
19
- // - {@link createSliceTracker} derives the slice plan + per-slice progress from the
20
- // PARENT stream alone — the subagent-dispatch tool_use and its terminal tool_result
21
- // DO appear there (only the subagent's intermediate turns don't), so slices/progress
22
- // need no file watching (D2.1). `pickProgress` (./progress.ts) reconciles it with the
23
- // parent's own plan (ADR 0027 Defect B);
24
- // - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
25
- // heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
26
- // the run's telemetry (D3).
27
- //
28
- // The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
29
- // 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
30
- // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
31
- // session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
32
- // `projects` root and DISCOVERS the `subagents/` dir by walking (see
33
- // {@link findSubagentTranscripts}).
34
- //
35
- // Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
36
- // so a missing directory, an unreadable file, or an unparseable line is swallowed and the
37
- // harness falls back to today's parent-stream-only behaviour.
38
- // ---------------------------------------------------------------------------
39
- // Slice / progress tracking off the PARENT stream (D2.1)
40
- // ---------------------------------------------------------------------------
41
- /**
42
- * The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
43
- * shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
44
- * `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
45
- * harness runs against whatever CLI the image happens to bundle, and matching only the old name
46
- * is what left a CLI 2.1.x pr-review reporting no slices at all.
47
- *
48
- * Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
49
- * FALSE signal rather than merely no signal — if a future build were to name a plain task-list
50
- * tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
51
- * build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
52
- * `progress.ts`), and dropping legacy coverage is the more likely regression.
53
- */
54
- const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
55
6
  export function createSliceTracker() {
56
7
  // Insertion-ordered so the progress `items` render in dispatch order.
57
8
  const slices = new Map();