@cat-factory/executor-harness 1.62.0 → 1.64.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pi.ts CHANGED
@@ -8,6 +8,12 @@ import { redactSecrets } from './redact.js'
8
8
  import { HarnessFailure } from './failure.js'
9
9
  import { log } from './logger.js'
10
10
  import type { EffortReport } from './effort.js'
11
+ import {
12
+ ProgressGuard,
13
+ progressGuardLimitsFromEnv,
14
+ toolCallSignal,
15
+ type ProgressGuardLimits,
16
+ } from './progress-guard.js'
11
17
 
12
18
  // Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
13
19
  // proxy via a custom provider in ~/.pi/agent/models.json, authenticated with the
@@ -106,21 +112,12 @@ for a module that is directly relevant to your task, when you need its summary a
106
112
  exact code references. \`blueprints/version.json\` is a tiny manifest for quick
107
113
  staleness checks. Treat the blueprint as orientation, not a task list.`
108
114
 
109
- // Appended to every AGENTS.md so an agent treats the persisted spec as the
110
- // PRESCRIPTIVE source (what must be true) and the acceptance scenarios its work must
111
- // satisfy. Harmless when no spec exists yet the files simply aren't there.
112
- const SPEC_GUIDANCE = `
113
-
114
- ## Service specification (the prescriptive spec)
115
-
116
- If a \`spec/\` folder exists, it is the specification for this service. It is sharded
117
- by a module (domain) → feature (group) taxonomy. **Read \`spec/overview.md\` first** —
118
- it states what MUST be true and indexes the modules and their features (with links).
119
- Open \`spec/modules/<module>/<feature>.md\` (or its \`.json\` for exact detail) for the
120
- feature you are working on — it carries that feature's requirements AND the domain
121
- rules scoped to it. \`spec/features/<module>/<feature>.feature\` are the Gherkin
122
- acceptance scenarios your work must satisfy — treat them as the source of truth for
123
- behaviour and tests. Read only the modules/features relevant to your task.`
115
+ // NOTE: the spec-reading guidance is NOT appended here. It is contributed once, backend-side, by
116
+ // the `spec-aware` trait (`SPEC_AWARE_GUIDANCE` in @cat-factory/agents), which lands in the
117
+ // composed system prompt for every spec-aware kind on BOTH harness paths. This harness used to
118
+ // append a near-duplicate block, so a spec-aware Pi run carried the guidance twice; the claude-code
119
+ // path never appended it. Sourcing it solely from the trait dedupes the Pi prompt and makes the two
120
+ // paths consistent. (A non-spec-aware kind is deliberately not told to read the spec.)
124
121
 
125
122
  /**
126
123
  * Write the composed system prompt as Pi's GLOBAL agent context
@@ -144,6 +141,12 @@ export async function writeAgentsContext(
144
141
  serviceDirectory?: string
145
142
  contextFiles?: ContextFileInfo[]
146
143
  multiRepo?: boolean
144
+ /**
145
+ * Whether the checkout actually ships a `blueprints/` folder. The blueprint orientation
146
+ * note is only appended when it does — otherwise it is ~10 lines of dead guidance (re-sent
147
+ * every turn) pointing at files that don't exist. Absent/false ⇒ the note is omitted.
148
+ */
149
+ hasBlueprints?: boolean
147
150
  } = {},
148
151
  ): Promise<void> {
149
152
  const dir = join(homedir(), '.pi', 'agent')
@@ -167,9 +170,14 @@ export async function writeAgentsContext(
167
170
  // Point the agent at any linked context the backend materialised into the checkout
168
171
  // (requirements / RFCs / PRDs / tracker issues) so it reads them on demand.
169
172
  const context = contextGuidance(opts.contextFiles ?? [])
173
+ // Only orient the agent to `blueprints/` when the checkout actually has them — otherwise the
174
+ // note is dead weight re-sent on every turn. The spec-reading guidance is NOT appended here
175
+ // (see the note above `writeAgentsContext`): it comes solely from the backend `spec-aware`
176
+ // trait, so a spec-aware run no longer carries it twice.
177
+ const blueprint = opts.hasBlueprints ? BLUEPRINT_GUIDANCE : ''
170
178
  await writeFile(
171
179
  join(dir, 'AGENTS.md'),
172
- `${systemPrompt}${BLUEPRINT_GUIDANCE}${SPEC_GUIDANCE}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}`,
180
+ `${systemPrompt}${blueprint}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}`,
173
181
  'utf8',
174
182
  )
175
183
  }
@@ -727,242 +735,8 @@ export function parseTodoProgress(event: Record<string, unknown>): TodoProgress
727
735
 
728
736
  return undefined
729
737
  }
730
-
731
- /** Tool-call signal read off a streamed Pi event, or undefined if not a tool call. */
732
- function toolCallSignal(
733
- event: Record<string, unknown>,
734
- ): { name: string; isError: boolean } | undefined {
735
- // `tool_execution_end` is the canonical per-call stream event (statsFromEvents
736
- // counts the same one), so the guard reads it and nothing else — no double count.
737
- if (event.type !== 'tool_execution_end') return undefined
738
- const name = typeof event.toolName === 'string' ? event.toolName : ''
739
- return { name, isError: event.isError === true }
740
- }
741
-
742
- /** Tunable bounds for the {@link ProgressGuard}. */
743
- export interface ProgressGuardLimits {
744
- /**
745
- * Abort once the agent has made this many NON-exploration tool calls without ever
746
- * using a file-editing tool (see `FILE_EDIT_TOOLS`). The signature of the credential
747
- * rabbit-hole that motivated this: probing the environment (`bash`/exec) endlessly
748
- * without implementing anything. Read-only exploration (`read`/`grep`/… — see
749
- * `EXPLORATION_TOOLS`) and planning (`todo`) do NOT count, so a large task that
750
- * legitimately reads/searches many files before its first edit is not killed for it.
751
- * Disabled when `expectsEdits` is false (e.g. the assess-only merger / Blueprinter,
752
- * which legitimately edit nothing). Note this bound only guards the run UNTIL its
753
- * first edit: once the agent has edited a file at all, it has demonstrably started
754
- * the work, so only `maxConsecutiveErrors` guards a later stall.
755
- */
756
- maxToolCallsWithoutEdit: number
757
- /**
758
- * Abort after this many consecutive failing tool calls — the agent is stuck
759
- * retrying an operation that keeps failing rather than making progress.
760
- */
761
- maxConsecutiveErrors: number
762
- /**
763
- * Abort after this many consecutive web-search/web-fetch calls with no other tool
764
- * call in between. Web tools are read-only exploration (they don't count toward the
765
- * no-edit bound), so without this a model could rabbit-hole on searches indefinitely
766
- * without ever tripping a guard. Any non-web tool call resets the streak. Optional:
767
- * defaults to {@link DEFAULT_PROGRESS_GUARD_LIMITS} when a caller builds limits
768
- * without it.
769
- */
770
- maxConsecutiveWebCalls?: number
771
- }
772
-
773
- // `satisfies` (not a type annotation) so each property keeps its concrete `number`
774
- // type — `maxConsecutiveWebCalls` is optional on the interface (callers may omit it),
775
- // but the defaults always define it, so consumers reading it off here get a `number`.
776
- export const DEFAULT_PROGRESS_GUARD_LIMITS = {
777
- // Counts only non-exploration, non-planning calls (see EXPLORATION_TOOLS), so the
778
- // ceiling can be generous without risking a false kill on a read-heavy large task.
779
- maxToolCallsWithoutEdit: 40,
780
- maxConsecutiveErrors: 12,
781
- // A genuine research burst is a handful of searches; an uninterrupted run of this
782
- // many web calls (with no read/edit/bash between) is a search loop, not progress.
783
- maxConsecutiveWebCalls: 25,
784
- } satisfies ProgressGuardLimits
785
-
786
- // Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
787
- // broad on purpose: different models/extensions name the same capability differently
788
- // (`edit`/`write`, but also `apply_patch`/`patch`/`str_replace`/`multiedit`/`create`),
789
- // and a false "no edits" reading would kill a run that IS making changes. Matched
790
- // case-insensitively. NOTE: a file written purely via `bash` (e.g. a heredoc) is not
791
- // recognised here — broaden or move to a working-tree signal if that becomes common.
792
- const FILE_EDIT_TOOLS = new Set([
793
- 'edit',
794
- 'write',
795
- 'apply_patch',
796
- 'patch',
797
- 'str_replace',
798
- 'multiedit',
799
- 'create',
800
- ])
801
-
802
- // Planning/bookkeeping tools that are neither file edits nor the environment-probing
803
- // the no-edit bound targets — the todo list the agent maintains as it works. These do
804
- // NOT count toward `maxToolCallsWithoutEdit`: a run that diligently updates a long
805
- // todo list before its first edit (common on a large task) would otherwise be killed
806
- // for "no edits" purely from planning calls. They still reset the consecutive-error
807
- // streak (a successful call means the agent isn't wedged). Matched case-insensitively.
808
- const PLANNING_TOOLS = new Set(['todo'])
809
-
810
- // Read-only exploration tools: reading/searching the repo is legitimate work-up to an
811
- // edit, NOT the environment-probing the no-edit bound targets, so they don't count
812
- // toward `maxToolCallsWithoutEdit` (a large task may read/search dozens of files
813
- // before its first edit). The bound thus counts only "action" calls — chiefly `bash`
814
- // (the credential rabbit-hole's vector) — that have yet to produce an edit. Kept broad
815
- // since models/extensions name the same capability differently. Matched case-insensitively.
816
- const EXPLORATION_TOOLS = new Set([
817
- 'read',
818
- 'grep',
819
- 'search',
820
- 'glob',
821
- 'ls',
822
- 'list',
823
- 'find',
824
- 'tree',
825
- 'cat',
826
- 'view',
827
- 'head',
828
- 'tail',
829
- 'stat',
830
- // rpiv-web-tools: querying/reading the web is read-only research up to an edit,
831
- // not the environment-probing the no-edit bound targets, so it doesn't count.
832
- 'web_search',
833
- 'web_fetch',
834
- ])
835
-
836
- // The rpiv-web-tools calls, tracked separately so an unbounded run of them (with no
837
- // other tool call between) can be caught as a search loop — see `maxConsecutiveWebCalls`.
838
- const WEB_TOOLS = new Set(['web_search', 'web_fetch'])
839
-
840
- /** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
841
- export function progressGuardLimitsFromEnv(
842
- env: NodeJS.ProcessEnv = process.env,
843
- ): ProgressGuardLimits {
844
- const num = (raw: string | undefined, fallback: number): number => {
845
- const n = Number(raw)
846
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
847
- }
848
- return {
849
- maxToolCallsWithoutEdit: num(
850
- env.JOB_MAX_TOOLCALLS_WITHOUT_EDIT,
851
- DEFAULT_PROGRESS_GUARD_LIMITS.maxToolCallsWithoutEdit,
852
- ),
853
- maxConsecutiveErrors: num(
854
- env.JOB_MAX_CONSECUTIVE_TOOL_ERRORS,
855
- DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveErrors,
856
- ),
857
- maxConsecutiveWebCalls: num(
858
- env.JOB_MAX_CONSECUTIVE_WEB_CALLS,
859
- DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls,
860
- ),
861
- }
862
- }
863
-
864
- /**
865
- * Apply per-knob overrides onto a base set of guard limits, ENFORCING loosen-only: an
866
- * override can only RAISE a knob (more headroom), never lower it below the base. A
867
- * larger value is more lenient for every knob (more no-edit tool calls / errors / web
868
- * calls tolerated), so each result is `max(base, override)`. This is a hard guarantee,
869
- * not a convention — a tuning entry (built-in or a custom kind's, which reaches this via
870
- * an untrusted job body) that supplies a value TIGHTER than the base is clamped back up
871
- * to the base rather than aborting a legitimately-progressing run. An absent/undefined
872
- * knob keeps the base value untouched.
873
- */
874
- export function mergeGuardLimits(
875
- base: ProgressGuardLimits,
876
- overrides: Partial<ProgressGuardLimits> | undefined,
877
- ): ProgressGuardLimits {
878
- if (!overrides) return base
879
- const loosen = (b: number, o: number | undefined): number =>
880
- typeof o === 'number' ? Math.max(b, o) : b
881
- return {
882
- maxToolCallsWithoutEdit: loosen(
883
- base.maxToolCallsWithoutEdit,
884
- overrides.maxToolCallsWithoutEdit,
885
- ),
886
- maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
887
- // `maxConsecutiveWebCalls` is optional on the interface (callers may omit it), so
888
- // fall back to the default before loosening — keeps `loosen`'s base a concrete number.
889
- maxConsecutiveWebCalls: loosen(
890
- base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls,
891
- overrides.maxConsecutiveWebCalls,
892
- ),
893
- }
894
- }
895
-
896
- /**
897
- * Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
898
- * reason the moment a run has plainly stopped making progress, so the harness can
899
- * kill Pi early instead of letting it burn the whole budget (and then surface a
900
- * useful failure instead of a generic "no file changes"). Pure and incremental so
901
- * it can be unit-tested over a fixed event sequence.
902
- */
903
- export class ProgressGuard {
904
- private toolCalls = 0
905
- private edits = 0
906
- private consecutiveErrors = 0
907
- private consecutiveWebCalls = 0
908
-
909
- constructor(
910
- private readonly limits: ProgressGuardLimits,
911
- /** When false (assess-only runs like the merger), the no-edit bound is skipped. */
912
- private readonly expectsEdits: boolean = true,
913
- ) {}
914
-
915
- /** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
916
- observe(event: Record<string, unknown>): string | null {
917
- const tool = toolCallSignal(event)
918
- if (!tool) return null
919
- const name = tool.name.toLowerCase()
920
- // The error streak tracks ANY tool call (a planning call still proves the agent
921
- // isn't wedged in a failing-op loop), so it's updated before the planning skip.
922
- this.consecutiveErrors = tool.isError ? this.consecutiveErrors + 1 : 0
923
- if (this.consecutiveErrors >= this.limits.maxConsecutiveErrors) {
924
- return (
925
- `no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
926
- `retrying a failing operation rather than making progress. Aborting.`
927
- )
928
- }
929
-
930
- // Web search/fetch loop: web tools are read-only (they don't count toward the
931
- // no-edit bound), so guard them separately — an uninterrupted streak of them is a
932
- // research rabbit-hole. Any non-web tool call resets the streak.
933
- if (WEB_TOOLS.has(name)) {
934
- this.consecutiveWebCalls++
935
- const webCap =
936
- this.limits.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls
937
- if (this.consecutiveWebCalls >= webCap) {
938
- return (
939
- `no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
940
- `any other action — the agent is stuck researching instead of doing the work. Aborting.`
941
- )
942
- }
943
- } else {
944
- this.consecutiveWebCalls = 0
945
- }
946
-
947
- // Planning and read-only exploration calls don't count toward the no-edit bound
948
- // (see PLANNING_TOOLS / EXPLORATION_TOOLS) — only "action" calls without an edit do.
949
- if (PLANNING_TOOLS.has(name) || EXPLORATION_TOOLS.has(name)) return null
950
- this.toolCalls++
951
- if (FILE_EDIT_TOOLS.has(name)) this.edits++
952
-
953
- if (
954
- this.expectsEdits &&
955
- this.edits === 0 &&
956
- this.toolCalls >= this.limits.maxToolCallsWithoutEdit
957
- ) {
958
- return (
959
- `no progress: ${this.toolCalls} tool calls and not one file edit — the agent is exploring or ` +
960
- `probing the environment without implementing anything. Aborting before it burns the whole run.`
961
- )
962
- }
963
- return null
964
- }
965
- }
738
+ // The no-progress guard (its limits, tool vocabulary and the `ProgressGuard` itself) lives in
739
+ // `progress-guard.ts` it is shared with the claude-code runner, so it is no longer Pi's.
966
740
 
967
741
  /**
968
742
  * Run Pi non-interactively against `cwd` and return its assistant summary. Uses
@@ -0,0 +1,285 @@
1
+ import { SUBAGENT_TOOL_NAMES } from './claude-stream.js'
2
+
3
+ // The harness's no-progress guard: the live anti-rabbithole bound every agent run is held to,
4
+ // plus the tool-name vocabulary it classifies calls with and the limits it reads from the
5
+ // environment. Extracted from `pi.ts` when the guard stopped being Pi's: it now also drives the
6
+ // claude-code subscription runner (`agent-runner.ts` feeds it via `observeSignal`), so the two
7
+ // harnesses share ONE definition of "this run has stopped making progress" — and the tool-name
8
+ // sets below deliberately cover both CLIs' vocabularies.
9
+
10
+ /**
11
+ * Tool-call signal read off a streamed Pi event, or undefined if not a tool call. Exported for
12
+ * `runPi`'s span emitter, which reads the same event for its per-tool trace spans.
13
+ */
14
+ export function toolCallSignal(
15
+ event: Record<string, unknown>,
16
+ ): { name: string; isError: boolean } | undefined {
17
+ // `tool_execution_end` is the canonical per-call stream event (statsFromEvents
18
+ // counts the same one), so the guard reads it and nothing else — no double count.
19
+ if (event.type !== 'tool_execution_end') return undefined
20
+ const name = typeof event.toolName === 'string' ? event.toolName : ''
21
+ return { name, isError: event.isError === true }
22
+ }
23
+
24
+ /** Tunable bounds for the {@link ProgressGuard}. */
25
+ export interface ProgressGuardLimits {
26
+ /**
27
+ * Abort once the agent has made this many NON-exploration tool calls without ever
28
+ * using a file-editing tool (see `FILE_EDIT_TOOLS`). The signature of the credential
29
+ * rabbit-hole that motivated this: probing the environment (`bash`/exec) endlessly
30
+ * without implementing anything. Read-only exploration (`read`/`grep`/… — see
31
+ * `EXPLORATION_TOOLS`) and planning (`todo`) do NOT count, so a large task that
32
+ * legitimately reads/searches many files before its first edit is not killed for it.
33
+ * Disabled when `expectsEdits` is false (e.g. the assess-only merger / Blueprinter,
34
+ * which legitimately edit nothing). Note this bound only guards the run UNTIL its
35
+ * first edit: once the agent has edited a file at all, it has demonstrably started
36
+ * the work, so only `maxConsecutiveErrors` guards a later stall.
37
+ */
38
+ maxToolCallsWithoutEdit: number
39
+ /**
40
+ * Abort after this many consecutive failing tool calls — the agent is stuck
41
+ * retrying an operation that keeps failing rather than making progress.
42
+ */
43
+ maxConsecutiveErrors: number
44
+ /**
45
+ * Abort after this many consecutive web-search/web-fetch calls with no other tool
46
+ * call in between. Web tools are read-only exploration (they don't count toward the
47
+ * no-edit bound), so without this a model could rabbit-hole on searches indefinitely
48
+ * without ever tripping a guard. Any non-web tool call resets the streak. Optional:
49
+ * defaults to {@link DEFAULT_PROGRESS_GUARD_LIMITS} when a caller builds limits
50
+ * without it.
51
+ */
52
+ maxConsecutiveWebCalls?: number
53
+ }
54
+
55
+ // `satisfies` (not a type annotation) so each property keeps its concrete `number`
56
+ // type — `maxConsecutiveWebCalls` is optional on the interface (callers may omit it),
57
+ // but the defaults always define it, so consumers reading it off here get a `number`.
58
+ export const DEFAULT_PROGRESS_GUARD_LIMITS = {
59
+ // Counts only non-exploration, non-planning calls (see EXPLORATION_TOOLS), so the
60
+ // ceiling can be generous without risking a false kill on a read-heavy large task.
61
+ maxToolCallsWithoutEdit: 40,
62
+ maxConsecutiveErrors: 12,
63
+ // A genuine research burst is a handful of searches; an uninterrupted run of this
64
+ // many web calls (with no read/edit/bash between) is a search loop, not progress.
65
+ maxConsecutiveWebCalls: 25,
66
+ } satisfies ProgressGuardLimits
67
+
68
+ // Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
69
+ // broad on purpose: different models/extensions name the same capability differently
70
+ // (`edit`/`write`, but also `apply_patch`/`patch`/`str_replace`/`multiedit`/`create`),
71
+ // and a false "no edits" reading would kill a run that IS making changes. Matched
72
+ // case-insensitively. NOTE: a file written purely via `bash` (e.g. a heredoc) is not
73
+ // recognised here — broaden or move to a working-tree signal if that becomes common.
74
+ const FILE_EDIT_TOOLS = new Set([
75
+ 'edit',
76
+ 'write',
77
+ 'apply_patch',
78
+ 'patch',
79
+ 'str_replace',
80
+ 'multiedit',
81
+ 'create',
82
+ // Claude Code tool names (the guard now runs on the claude-code stream too): Edit/Write/
83
+ // MultiEdit already match above; NotebookEdit is its own tool.
84
+ 'notebookedit',
85
+ ])
86
+
87
+ // Planning/bookkeeping tools that are neither file edits nor the environment-probing
88
+ // the no-edit bound targets — the todo list the agent maintains as it works. These do
89
+ // NOT count toward `maxToolCallsWithoutEdit`: a run that diligently updates a long
90
+ // todo list before its first edit (common on a large task) would otherwise be killed
91
+ // for "no edits" purely from planning calls. They still reset the consecutive-error
92
+ // streak (a successful call means the agent isn't wedged). Matched case-insensitively.
93
+ // `todo` is Pi's tool; `TodoWrite` and the incremental `TaskCreate`/`TaskUpdate` pair are
94
+ // Claude Code's plan vocabularies — all pure bookkeeping, exempt from the no-edit bound.
95
+ const PLANNING_TOOLS = new Set(['todo', 'todowrite', 'taskcreate', 'taskupdate'])
96
+
97
+ // A subagent dispatch (Claude Code's `Agent`/`Task`) is exempt from the no-edit bound because
98
+ // the parent stream CANNOT see the edits it makes: only the dispatch and its terminal
99
+ // tool_result appear there, while every Edit/Write the subagent performs happens on a transcript
100
+ // the guard never reads (`subagents.ts` watches those separately, for usage/progress only). So a
101
+ // coder that fans its implementation out across subagents looks, to this guard, like a run making
102
+ // dozens of action calls and zero edits — and would be killed for making excellent progress.
103
+ // Counting them as edits instead would be worse (a read-only research subagent would then clear
104
+ // the suspicion the bound exists to hold), so they are neutral: they neither count toward the
105
+ // bound nor satisfy it. Sourced from the same set the slice tracker matches on, lower-cased for
106
+ // this module's case-insensitive comparison.
107
+ const SUBAGENT_DISPATCH_TOOLS = new Set([...SUBAGENT_TOOL_NAMES].map((name) => name.toLowerCase()))
108
+
109
+ // Read-only exploration tools: reading/searching the repo is legitimate work-up to an
110
+ // edit, NOT the environment-probing the no-edit bound targets, so they don't count
111
+ // toward `maxToolCallsWithoutEdit` (a large task may read/search dozens of files
112
+ // before its first edit). The bound thus counts only "action" calls — chiefly `bash`
113
+ // (the credential rabbit-hole's vector) — that have yet to produce an edit. Kept broad
114
+ // since models/extensions name the same capability differently. Matched case-insensitively.
115
+ const EXPLORATION_TOOLS = new Set([
116
+ 'read',
117
+ 'grep',
118
+ 'search',
119
+ 'glob',
120
+ 'ls',
121
+ 'list',
122
+ 'find',
123
+ 'tree',
124
+ 'cat',
125
+ 'view',
126
+ 'head',
127
+ 'tail',
128
+ 'stat',
129
+ // rpiv-web-tools (Pi) + Claude Code's WebSearch/WebFetch: querying/reading the web is
130
+ // read-only research up to an edit, not the environment-probing the no-edit bound targets.
131
+ 'web_search',
132
+ 'web_fetch',
133
+ 'websearch',
134
+ 'webfetch',
135
+ ])
136
+
137
+ // The web-tool calls, tracked separately so an unbounded run of them (with no other tool
138
+ // call between) can be caught as a search loop — see `maxConsecutiveWebCalls`. Covers both
139
+ // Pi's `web_search`/`web_fetch` and Claude Code's `WebSearch`/`WebFetch`.
140
+ const WEB_TOOLS = new Set(['web_search', 'web_fetch', 'websearch', 'webfetch'])
141
+
142
+ /** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
143
+ export function progressGuardLimitsFromEnv(
144
+ env: NodeJS.ProcessEnv = process.env,
145
+ ): ProgressGuardLimits {
146
+ const num = (raw: string | undefined, fallback: number): number => {
147
+ const n = Number(raw)
148
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
149
+ }
150
+ return {
151
+ maxToolCallsWithoutEdit: num(
152
+ env.JOB_MAX_TOOLCALLS_WITHOUT_EDIT,
153
+ DEFAULT_PROGRESS_GUARD_LIMITS.maxToolCallsWithoutEdit,
154
+ ),
155
+ maxConsecutiveErrors: num(
156
+ env.JOB_MAX_CONSECUTIVE_TOOL_ERRORS,
157
+ DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveErrors,
158
+ ),
159
+ maxConsecutiveWebCalls: num(
160
+ env.JOB_MAX_CONSECUTIVE_WEB_CALLS,
161
+ DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls,
162
+ ),
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Apply per-knob overrides onto a base set of guard limits, ENFORCING loosen-only: an
168
+ * override can only RAISE a knob (more headroom), never lower it below the base. A
169
+ * larger value is more lenient for every knob (more no-edit tool calls / errors / web
170
+ * calls tolerated), so each result is `max(base, override)`. This is a hard guarantee,
171
+ * not a convention — a tuning entry (built-in or a custom kind's, which reaches this via
172
+ * an untrusted job body) that supplies a value TIGHTER than the base is clamped back up
173
+ * to the base rather than aborting a legitimately-progressing run. An absent/undefined
174
+ * knob keeps the base value untouched.
175
+ */
176
+ export function mergeGuardLimits(
177
+ base: ProgressGuardLimits,
178
+ overrides: Partial<ProgressGuardLimits> | undefined,
179
+ ): ProgressGuardLimits {
180
+ if (!overrides) return base
181
+ const loosen = (b: number, o: number | undefined): number =>
182
+ typeof o === 'number' ? Math.max(b, o) : b
183
+ return {
184
+ maxToolCallsWithoutEdit: loosen(
185
+ base.maxToolCallsWithoutEdit,
186
+ overrides.maxToolCallsWithoutEdit,
187
+ ),
188
+ maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
189
+ // `maxConsecutiveWebCalls` is optional on the interface (callers may omit it), so
190
+ // fall back to the default before loosening — keeps `loosen`'s base a concrete number.
191
+ maxConsecutiveWebCalls: loosen(
192
+ base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls,
193
+ overrides.maxConsecutiveWebCalls,
194
+ ),
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
200
+ * reason the moment a run has plainly stopped making progress, so the harness can
201
+ * kill Pi early instead of letting it burn the whole budget (and then surface a
202
+ * useful failure instead of a generic "no file changes"). Pure and incremental so
203
+ * it can be unit-tested over a fixed event sequence.
204
+ */
205
+ export class ProgressGuard {
206
+ private toolCalls = 0
207
+ private edits = 0
208
+ private consecutiveErrors = 0
209
+ private consecutiveWebCalls = 0
210
+
211
+ constructor(
212
+ private readonly limits: ProgressGuardLimits,
213
+ /** When false (assess-only runs like the merger), the no-edit bound is skipped. */
214
+ private readonly expectsEdits: boolean = true,
215
+ ) {}
216
+
217
+ /** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
218
+ observe(event: Record<string, unknown>): string | null {
219
+ const tool = toolCallSignal(event)
220
+ if (!tool) return null
221
+ return this.observeSignal(tool)
222
+ }
223
+
224
+ /**
225
+ * Feed one already-parsed tool-call signal (name + error flag), returning a diagnostic reason
226
+ * when the run should abort, else null. Split out of {@link observe} so a caller whose stream
227
+ * is NOT Pi's `tool_execution_end` envelope — the claude-code runner, which correlates a
228
+ * `tool_use` block's name with its `tool_result`'s `is_error` — can drive the SAME guard logic
229
+ * without synthesising a fake Pi event.
230
+ */
231
+ observeSignal(tool: { name: string; isError: boolean }): string | null {
232
+ const name = tool.name.toLowerCase()
233
+ // The error streak tracks ANY tool call (a planning call still proves the agent
234
+ // isn't wedged in a failing-op loop), so it's updated before the planning skip.
235
+ this.consecutiveErrors = tool.isError ? this.consecutiveErrors + 1 : 0
236
+ if (this.consecutiveErrors >= this.limits.maxConsecutiveErrors) {
237
+ return (
238
+ `no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
239
+ `retrying a failing operation rather than making progress. Aborting.`
240
+ )
241
+ }
242
+
243
+ // Web search/fetch loop: web tools are read-only (they don't count toward the
244
+ // no-edit bound), so guard them separately — an uninterrupted streak of them is a
245
+ // research rabbit-hole. Any non-web tool call resets the streak.
246
+ if (WEB_TOOLS.has(name)) {
247
+ this.consecutiveWebCalls++
248
+ const webCap =
249
+ this.limits.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls
250
+ if (this.consecutiveWebCalls >= webCap) {
251
+ return (
252
+ `no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
253
+ `any other action — the agent is stuck researching instead of doing the work. Aborting.`
254
+ )
255
+ }
256
+ } else {
257
+ this.consecutiveWebCalls = 0
258
+ }
259
+
260
+ // Planning, read-only exploration and subagent-dispatch calls don't count toward the
261
+ // no-edit bound (see PLANNING_TOOLS / EXPLORATION_TOOLS / SUBAGENT_DISPATCH_TOOLS) —
262
+ // only "action" calls without an edit do.
263
+ if (
264
+ PLANNING_TOOLS.has(name) ||
265
+ EXPLORATION_TOOLS.has(name) ||
266
+ SUBAGENT_DISPATCH_TOOLS.has(name)
267
+ ) {
268
+ return null
269
+ }
270
+ this.toolCalls++
271
+ if (FILE_EDIT_TOOLS.has(name)) this.edits++
272
+
273
+ if (
274
+ this.expectsEdits &&
275
+ this.edits === 0 &&
276
+ this.toolCalls >= this.limits.maxToolCallsWithoutEdit
277
+ ) {
278
+ return (
279
+ `no progress: ${this.toolCalls} tool calls and not one file edit — the agent is exploring or ` +
280
+ `probing the environment without implementing anything. Aborting before it burns the whole run.`
281
+ )
282
+ }
283
+ return null
284
+ }
285
+ }