@cat-factory/executor-harness 1.86.2 → 1.90.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/README.md +64 -49
- package/dist/agent-capabilities.d.ts +20 -3
- package/dist/agent-capabilities.js +32 -4
- package/dist/agent.d.ts +7 -0
- package/dist/agent.js +19 -1
- package/dist/job-env.d.ts +44 -0
- package/dist/job-env.js +108 -0
- package/dist/job.d.ts +14 -17
- package/dist/job.js +11 -97
- package/dist/progress-guard.d.ts +29 -0
- package/dist/progress-guard.js +82 -8
- package/package.json +4 -4
- package/src/agent-capabilities.ts +34 -4
- package/src/agent.ts +20 -1
- package/src/job-env.ts +121 -0
- package/src/job.ts +27 -112
- package/src/progress-guard.ts +129 -10
package/src/job.ts
CHANGED
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
type SkillResourceSpec,
|
|
21
21
|
type SkillSpec,
|
|
22
22
|
} from './agent-capabilities.js'
|
|
23
|
+
import { type TestSecretSpec, parseInfraEnv, parseSecretEnvPairs, str } from './job-env.js'
|
|
24
|
+
|
|
25
|
+
// Re-exported so a handler describing a job keeps ONE import site (the env-pair shape is a job
|
|
26
|
+
// body field like any other; only its VALIDATION moved out).
|
|
27
|
+
export type { TestSecretSpec }
|
|
23
28
|
|
|
24
29
|
// Re-exported so the job body stays the one import site for a harness handler describing a job.
|
|
25
30
|
export type { McpServerSpec, SkillResourceSpec, SkillSpec }
|
|
@@ -142,13 +147,6 @@ export interface ReferenceRepoSpec {
|
|
|
142
147
|
ghToken?: string
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
function str(value: unknown, path: string): string {
|
|
146
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
147
|
-
throw new Error(`Invalid job: '${path}' must be a non-empty string`)
|
|
148
|
-
}
|
|
149
|
-
return value
|
|
150
|
-
}
|
|
151
|
-
|
|
152
150
|
/** A positive finite integer, or undefined for any other input (silently ignored). */
|
|
153
151
|
function posInt(value: unknown): number | undefined {
|
|
154
152
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
@@ -183,9 +181,13 @@ function parseGuardLimits(value: unknown): GuardLimitsSpec | undefined {
|
|
|
183
181
|
const noEdit = posInt(o.maxToolCallsWithoutEdit)
|
|
184
182
|
const errors = posInt(o.maxConsecutiveErrors)
|
|
185
183
|
const web = posInt(o.maxConsecutiveWebCalls)
|
|
184
|
+
const mcp = posInt(o.maxConsecutiveMcpCalls)
|
|
185
|
+
const nonAction = posInt(o.maxConsecutiveNonActionCalls)
|
|
186
186
|
if (noEdit !== undefined) spec.maxToolCallsWithoutEdit = noEdit
|
|
187
187
|
if (errors !== undefined) spec.maxConsecutiveErrors = errors
|
|
188
188
|
if (web !== undefined) spec.maxConsecutiveWebCalls = web
|
|
189
|
+
if (mcp !== undefined) spec.maxConsecutiveMcpCalls = mcp
|
|
190
|
+
if (nonAction !== undefined) spec.maxConsecutiveNonActionCalls = nonAction
|
|
189
191
|
return Object.keys(spec).length > 0 ? spec : undefined
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -494,49 +496,6 @@ export function parsePackageRegistries(
|
|
|
494
496
|
return entries
|
|
495
497
|
}
|
|
496
498
|
|
|
497
|
-
/**
|
|
498
|
-
* One sensitive test credential the tester receives: an env-var name + its (secret) value.
|
|
499
|
-
* The backend seals these at rest and decrypts them at dispatch; the harness injects each as an
|
|
500
|
-
* environment variable the tester's shell can read (out of band — the value is NEVER in the
|
|
501
|
-
* prompt/telemetry). See {@link parseTestSecrets}.
|
|
502
|
-
*/
|
|
503
|
-
export interface TestSecretSpec {
|
|
504
|
-
key: string
|
|
505
|
-
value: string
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/** A valid POSIX shell variable name (letters, digits, underscore; not starting with a digit). */
|
|
509
|
-
const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Validate the optional tester `testSecrets` list — `{ key, value }` env pairs the harness
|
|
513
|
-
* injects into the run environment. Keys must be valid env-var names; toolchain-critical /
|
|
514
|
-
* reserved names ({@link isReservedEnvName}) and duplicates are dropped so a drifted body can't
|
|
515
|
-
* clobber PATH/NODE_OPTIONS/etc. Absent ⇒ no secrets injected.
|
|
516
|
-
*/
|
|
517
|
-
export function parseTestSecrets(value: unknown): TestSecretSpec[] {
|
|
518
|
-
if (value === undefined || value === null) return []
|
|
519
|
-
if (!Array.isArray(value)) throw new Error("Invalid job: 'testSecrets' must be an array")
|
|
520
|
-
const entries: TestSecretSpec[] = []
|
|
521
|
-
const seen = new Set<string>()
|
|
522
|
-
for (const [i, raw] of value.entries()) {
|
|
523
|
-
if (typeof raw !== 'object' || raw === null) {
|
|
524
|
-
throw new Error(`Invalid job: 'testSecrets[${i}]' must be an object`)
|
|
525
|
-
}
|
|
526
|
-
const entry = raw as Record<string, unknown>
|
|
527
|
-
const key = str(entry.key, `testSecrets[${i}].key`).trim()
|
|
528
|
-
if (!ENV_VAR_NAME_PATTERN.test(key)) {
|
|
529
|
-
throw new Error(
|
|
530
|
-
`Invalid job: 'testSecrets[${i}].key' must be a valid environment variable name`,
|
|
531
|
-
)
|
|
532
|
-
}
|
|
533
|
-
if (isReservedEnvName(key) || seen.has(key)) continue
|
|
534
|
-
seen.add(key)
|
|
535
|
-
entries.push({ key, value: str(entry.value, `testSecrets[${i}].value`) })
|
|
536
|
-
}
|
|
537
|
-
return entries
|
|
538
|
-
}
|
|
539
|
-
|
|
540
499
|
// ---- Shared repo-bootstrap target ---------------------------------------
|
|
541
500
|
|
|
542
501
|
/** The new repository a repo-bootstrap run force-pushes its fresh history to. */
|
|
@@ -782,6 +741,16 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
782
741
|
* Absent ⇒ no secrets injected.
|
|
783
742
|
*/
|
|
784
743
|
testSecrets?: TestSecretSpec[]
|
|
744
|
+
/**
|
|
745
|
+
* The resolved credentials of the step's GENERATIVE BINARY INTEGRATIONS (the image / music /
|
|
746
|
+
* video generation APIs its `binaryOutput` selection named), as env pairs the harness injects
|
|
747
|
+
* into the agent's own process — where the agent's brief has already told it to read them from.
|
|
748
|
+
* Distinct from {@link testSecrets} because the two have different producers and different
|
|
749
|
+
* lifetimes: tester secrets are workspace state a human stored, these are a deployment's
|
|
750
|
+
* registration resolved per dispatch. Absent ⇒ no integration declared a credential, or none
|
|
751
|
+
* resolved (which the agent is told to report rather than work around).
|
|
752
|
+
*/
|
|
753
|
+
generatorSecrets?: TestSecretSpec[]
|
|
785
754
|
/**
|
|
786
755
|
* Explore mode: stand the service's dependencies up before the agent runs (the
|
|
787
756
|
* tester). Brings the docker-compose infra up on localhost for the duration of the
|
|
@@ -909,6 +878,8 @@ export interface GuardLimitsSpec {
|
|
|
909
878
|
maxToolCallsWithoutEdit?: number
|
|
910
879
|
maxConsecutiveErrors?: number
|
|
911
880
|
maxConsecutiveWebCalls?: number
|
|
881
|
+
maxConsecutiveMcpCalls?: number
|
|
882
|
+
maxConsecutiveNonActionCalls?: number
|
|
912
883
|
}
|
|
913
884
|
|
|
914
885
|
/**
|
|
@@ -1110,66 +1081,6 @@ function parseStringMap(value: unknown): Record<string, string> | undefined {
|
|
|
1110
1081
|
return Object.keys(out).length ? out : undefined
|
|
1111
1082
|
}
|
|
1112
1083
|
|
|
1113
|
-
/**
|
|
1114
|
-
* Env-var names never injected from a frontend binding: spread over `process.env` at build
|
|
1115
|
-
* time, so any of these would break the toolchain (or enable code execution / cert overrides)
|
|
1116
|
-
* rather than name an upstream URL. Matched exactly (Linux env is case-sensitive); the
|
|
1117
|
-
* {@link RESERVED_ENV_PREFIXES} below cover whole families (`npm_config_*`, `GIT_*`, …).
|
|
1118
|
-
*/
|
|
1119
|
-
const RESERVED_ENV_NAMES = new Set([
|
|
1120
|
-
'PATH',
|
|
1121
|
-
'HOME',
|
|
1122
|
-
'NODE_OPTIONS',
|
|
1123
|
-
'NODE_PATH',
|
|
1124
|
-
'NODE_EXTRA_CA_CERTS',
|
|
1125
|
-
'LD_PRELOAD',
|
|
1126
|
-
'LD_LIBRARY_PATH',
|
|
1127
|
-
'BASH_ENV',
|
|
1128
|
-
'ENV',
|
|
1129
|
-
'SHELL',
|
|
1130
|
-
'IFS',
|
|
1131
|
-
])
|
|
1132
|
-
|
|
1133
|
-
/**
|
|
1134
|
-
* Env-var name PREFIXES never injected from a frontend binding. `npm_config_*` reconfigures the
|
|
1135
|
-
* package manager (registry, scripts, prefix), and `GIT_*` reconfigures git — both run during a
|
|
1136
|
-
* frontend install/build, so a binding in either family is toolchain control, not an upstream URL.
|
|
1137
|
-
* Compared case-INSENSITIVELY (lower-cased here, matched lower-cased below): npm reads its config
|
|
1138
|
-
* env with a case-insensitive `/^npm_config_/i`, so `NPM_CONFIG_REGISTRY` is honoured just like
|
|
1139
|
-
* `npm_config_registry` — a case-sensitive prefix match would let the upper-cased form slip through.
|
|
1140
|
-
*/
|
|
1141
|
-
const RESERVED_ENV_PREFIXES = ['npm_config_', 'git_']
|
|
1142
|
-
|
|
1143
|
-
/**
|
|
1144
|
-
* Whether an env-var name is reserved (an exact name, or a reserved family prefix). The exact
|
|
1145
|
-
* names are canonical upper-case env vars matched verbatim (Linux env is case-sensitive, so a
|
|
1146
|
-
* distinct lower-cased `home` is a different, harmless var); the family PREFIXES are matched
|
|
1147
|
-
* case-insensitively because npm interprets `npm_config_*` regardless of case (see above).
|
|
1148
|
-
*/
|
|
1149
|
-
function isReservedEnvName(key: string): boolean {
|
|
1150
|
-
if (RESERVED_ENV_NAMES.has(key)) return true
|
|
1151
|
-
const lower = key.toLowerCase()
|
|
1152
|
-
return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p))
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
/**
|
|
1156
|
-
* Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
|
|
1157
|
-
* malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
|
|
1158
|
-
* names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
|
|
1159
|
-
* dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
|
|
1160
|
-
* replace it with a URL and the build would no longer find its tools. Extracted from the infra
|
|
1161
|
-
* parsers to keep their cyclomatic complexity down.
|
|
1162
|
-
*/
|
|
1163
|
-
function parseInfraEnv(raw: unknown): Record<string, string> {
|
|
1164
|
-
const env: Record<string, string> = {}
|
|
1165
|
-
if (typeof raw === 'object' && raw !== null) {
|
|
1166
|
-
for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
|
|
1167
|
-
if (key && !isReservedEnvName(key) && typeof val === 'string') env[key] = val
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
return env
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
1084
|
/** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
|
|
1174
1085
|
function parseFrontendInfraSpec(o: Record<string, unknown>): FrontendInfraSpec {
|
|
1175
1086
|
const packageManager =
|
|
@@ -1321,7 +1232,8 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1321
1232
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1322
1233
|
skills: parseSkillSpecs(o.skills),
|
|
1323
1234
|
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
1324
|
-
testSecrets:
|
|
1235
|
+
testSecrets: parseSecretEnvPairs(o.testSecrets, 'testSecrets'),
|
|
1236
|
+
generatorSecrets: parseSecretEnvPairs(o.generatorSecrets, 'generatorSecrets'),
|
|
1325
1237
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1326
1238
|
validation: parseValidationSpec(o.validation),
|
|
1327
1239
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
@@ -1362,7 +1274,8 @@ interface ParsedAgentJobParts {
|
|
|
1362
1274
|
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1363
1275
|
skills: ReturnType<typeof parseSkillSpecs>
|
|
1364
1276
|
mcpServers: ReturnType<typeof parseMcpServerSpecs>
|
|
1365
|
-
testSecrets: ReturnType<typeof
|
|
1277
|
+
testSecrets: ReturnType<typeof parseSecretEnvPairs>
|
|
1278
|
+
generatorSecrets: ReturnType<typeof parseSecretEnvPairs>
|
|
1366
1279
|
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1367
1280
|
validation: ReturnType<typeof parseValidationSpec>
|
|
1368
1281
|
validationChecks: ReturnType<typeof parseValidationChecksSpec>
|
|
@@ -1425,6 +1338,7 @@ function assembleAgentJob(
|
|
|
1425
1338
|
reproduction,
|
|
1426
1339
|
dependencyInstall,
|
|
1427
1340
|
reviewPrNumber,
|
|
1341
|
+
generatorSecrets,
|
|
1428
1342
|
} = parts
|
|
1429
1343
|
const repo = (o.repo ?? {}) as Record<string, unknown>
|
|
1430
1344
|
return {
|
|
@@ -1445,6 +1359,7 @@ function assembleAgentJob(
|
|
|
1445
1359
|
...(skills ? { skills } : {}),
|
|
1446
1360
|
...(mcpServers ? { mcpServers } : {}),
|
|
1447
1361
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
1362
|
+
...(generatorSecrets.length ? { generatorSecrets } : {}),
|
|
1448
1363
|
...(infra ? { infra } : {}),
|
|
1449
1364
|
...(pr ? { pr } : {}),
|
|
1450
1365
|
...(peerRepos.length ? { peerRepos } : {}),
|
package/src/progress-guard.ts
CHANGED
|
@@ -50,6 +50,31 @@ export interface ProgressGuardLimits {
|
|
|
50
50
|
* without it.
|
|
51
51
|
*/
|
|
52
52
|
maxConsecutiveWebCalls?: number
|
|
53
|
+
/**
|
|
54
|
+
* Abort after this many consecutive MCP tool-server calls (`mcp__*`) with no other
|
|
55
|
+
* tool call in between: the tool-server analogue of `maxConsecutiveWebCalls`, and
|
|
56
|
+
* present for the same reason. An `mcp__*` call is exempt from the no-edit bound (see
|
|
57
|
+
* `isMcpToolCall`), so without a streak of its own a run could query a tool server
|
|
58
|
+
* indefinitely without tripping any guard. Any non-MCP tool call resets the streak.
|
|
59
|
+
* Optional: defaults to {@link DEFAULT_PROGRESS_GUARD_LIMITS}.
|
|
60
|
+
*/
|
|
61
|
+
maxConsecutiveMcpCalls?: number
|
|
62
|
+
/**
|
|
63
|
+
* Abort after this many consecutive calls that are EXEMPT from the no-edit bound
|
|
64
|
+
* (planning, read-only exploration, subagent dispatch, `mcp__*`) with no action call
|
|
65
|
+
* in between. The backstop that makes each individual exemption mean "not counted"
|
|
66
|
+
* rather than "unbounded": every per-family streak above resets on any call outside
|
|
67
|
+
* its own family, so a run alternating `web_search` with `mcp__issues__search` (or
|
|
68
|
+
* with `read`) trips none of them and, having never made an action call, never
|
|
69
|
+
* reaches `maxToolCallsWithoutEdit` either. Only the job's wall-clock ceiling
|
|
70
|
+
* bounded that.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately far above every family cap, because it is not a research bound and
|
|
73
|
+
* must not become one: reading a hundred files before the first edit is legitimate
|
|
74
|
+
* work-up, and any `bash`/edit/action call resets the streak. Optional: defaults to
|
|
75
|
+
* {@link DEFAULT_PROGRESS_GUARD_LIMITS}.
|
|
76
|
+
*/
|
|
77
|
+
maxConsecutiveNonActionCalls?: number
|
|
53
78
|
}
|
|
54
79
|
|
|
55
80
|
// `satisfies` (not a type annotation) so each property keeps its concrete `number`
|
|
@@ -63,6 +88,16 @@ export const DEFAULT_PROGRESS_GUARD_LIMITS = {
|
|
|
63
88
|
// A genuine research burst is a handful of searches; an uninterrupted run of this
|
|
64
89
|
// many web calls (with no read/edit/bash between) is a search loop, not progress.
|
|
65
90
|
maxConsecutiveWebCalls: 25,
|
|
91
|
+
// Looser than the web cap: a tool server is usually the agent's route to the SYSTEM OF
|
|
92
|
+
// RECORD (the issue tracker, the advisory database, the design source), and reading a
|
|
93
|
+
// list and then each of its items is a normal opening move, not a rabbit-hole. A run
|
|
94
|
+
// that makes this many in a row with no read, edit or bash between is looping.
|
|
95
|
+
maxConsecutiveMcpCalls: 40,
|
|
96
|
+
// Well clear of every family cap above, and of any plausible read-up: a run that makes
|
|
97
|
+
// this many exempt calls with not one action call between them has stopped converging,
|
|
98
|
+
// whatever mix of reads, searches and lookups it is cycling through. Sized as a
|
|
99
|
+
// backstop rather than a judgement, because the families are where judgement belongs.
|
|
100
|
+
maxConsecutiveNonActionCalls: 200,
|
|
66
101
|
} satisfies ProgressGuardLimits
|
|
67
102
|
|
|
68
103
|
// Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
|
|
@@ -139,6 +174,32 @@ const EXPLORATION_TOOLS = new Set([
|
|
|
139
174
|
// Pi's `web_search`/`web_fetch` and Claude Code's `WebSearch`/`WebFetch`.
|
|
140
175
|
const WEB_TOOLS = new Set(['web_search', 'web_fetch', 'websearch', 'webfetch'])
|
|
141
176
|
|
|
177
|
+
// A call to a tool server (MCP). Every MCP client names these `mcp__<server>__<tool>`, and
|
|
178
|
+
// the prefix is the ONLY thing the harness can know about them: what a given server's tools
|
|
179
|
+
// do is a backend registration this image has never seen, so the guard classifies by shape.
|
|
180
|
+
//
|
|
181
|
+
// Matched, rather than enumerated in EXPLORATION_TOOLS, because the set is open: it is
|
|
182
|
+
// whatever tool servers the running kind was wired with. A prefix test is also why this is a
|
|
183
|
+
// function, `name.startsWith` on the already-lower-cased name, so `MCP__Issues__search`
|
|
184
|
+
// classifies the same as `mcp__issues__search`.
|
|
185
|
+
function isMcpToolCall(loweredName: string): boolean {
|
|
186
|
+
return loweredName.startsWith('mcp__')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Whether a call is EXEMPT from the no-edit bound: planning and bookkeeping, read-only
|
|
190
|
+
// exploration, a subagent dispatch, or a tool-server call. One predicate rather than the
|
|
191
|
+
// four tests inlined at the branch, because the combined non-action streak and the no-edit
|
|
192
|
+
// exemption must be the SAME set: a family exempted in one place and missed in the other is
|
|
193
|
+
// either an unbounded loop or a run killed for a call the bound says it may make.
|
|
194
|
+
function isNonActionToolCall(loweredName: string): boolean {
|
|
195
|
+
return (
|
|
196
|
+
PLANNING_TOOLS.has(loweredName) ||
|
|
197
|
+
EXPLORATION_TOOLS.has(loweredName) ||
|
|
198
|
+
SUBAGENT_DISPATCH_TOOLS.has(loweredName) ||
|
|
199
|
+
isMcpToolCall(loweredName)
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
142
203
|
/** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
|
|
143
204
|
export function progressGuardLimitsFromEnv(
|
|
144
205
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -160,6 +221,14 @@ export function progressGuardLimitsFromEnv(
|
|
|
160
221
|
env.JOB_MAX_CONSECUTIVE_WEB_CALLS,
|
|
161
222
|
DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls,
|
|
162
223
|
),
|
|
224
|
+
maxConsecutiveMcpCalls: num(
|
|
225
|
+
env.JOB_MAX_CONSECUTIVE_MCP_CALLS,
|
|
226
|
+
DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls,
|
|
227
|
+
),
|
|
228
|
+
maxConsecutiveNonActionCalls: num(
|
|
229
|
+
env.JOB_MAX_CONSECUTIVE_NON_ACTION_CALLS,
|
|
230
|
+
DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls,
|
|
231
|
+
),
|
|
163
232
|
}
|
|
164
233
|
}
|
|
165
234
|
|
|
@@ -186,12 +255,21 @@ export function mergeGuardLimits(
|
|
|
186
255
|
overrides.maxToolCallsWithoutEdit,
|
|
187
256
|
),
|
|
188
257
|
maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
|
|
189
|
-
//
|
|
190
|
-
//
|
|
258
|
+
// The streak knobs are optional on the interface (callers may omit them), so fall back
|
|
259
|
+
// to the default before loosening: it keeps `loosen`'s base a concrete number.
|
|
191
260
|
maxConsecutiveWebCalls: loosen(
|
|
192
261
|
base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls,
|
|
193
262
|
overrides.maxConsecutiveWebCalls,
|
|
194
263
|
),
|
|
264
|
+
maxConsecutiveMcpCalls: loosen(
|
|
265
|
+
base.maxConsecutiveMcpCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls,
|
|
266
|
+
overrides.maxConsecutiveMcpCalls,
|
|
267
|
+
),
|
|
268
|
+
maxConsecutiveNonActionCalls: loosen(
|
|
269
|
+
base.maxConsecutiveNonActionCalls ??
|
|
270
|
+
DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls,
|
|
271
|
+
overrides.maxConsecutiveNonActionCalls,
|
|
272
|
+
),
|
|
195
273
|
}
|
|
196
274
|
}
|
|
197
275
|
|
|
@@ -207,6 +285,8 @@ export class ProgressGuard {
|
|
|
207
285
|
private edits = 0
|
|
208
286
|
private consecutiveErrors = 0
|
|
209
287
|
private consecutiveWebCalls = 0
|
|
288
|
+
private consecutiveMcpCalls = 0
|
|
289
|
+
private consecutiveNonActionCalls = 0
|
|
210
290
|
|
|
211
291
|
constructor(
|
|
212
292
|
private readonly limits: ProgressGuardLimits,
|
|
@@ -257,16 +337,55 @@ export class ProgressGuard {
|
|
|
257
337
|
this.consecutiveWebCalls = 0
|
|
258
338
|
}
|
|
259
339
|
|
|
260
|
-
//
|
|
261
|
-
// no-edit bound
|
|
262
|
-
//
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
340
|
+
// Tool-server (MCP) calls: bounded as their own streak for exactly the reason the web
|
|
341
|
+
// streak exists. They are exempt from the no-edit bound below, and an exemption with no
|
|
342
|
+
// counter-bound is a loop the guard cannot see. Any non-MCP call resets it.
|
|
343
|
+
if (isMcpToolCall(name)) {
|
|
344
|
+
this.consecutiveMcpCalls++
|
|
345
|
+
const mcpCap =
|
|
346
|
+
this.limits.maxConsecutiveMcpCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls
|
|
347
|
+
if (this.consecutiveMcpCalls >= mcpCap) {
|
|
348
|
+
return (
|
|
349
|
+
`no progress: ${this.consecutiveMcpCalls} consecutive tool-server (MCP) calls without ` +
|
|
350
|
+
`any other action. The agent is stuck querying its tools instead of doing the work. ` +
|
|
351
|
+
`Aborting.`
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
this.consecutiveMcpCalls = 0
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Planning, read-only exploration, subagent-dispatch and tool-server calls don't count
|
|
359
|
+
// toward the no-edit bound (see `isNonActionToolCall`): only "action" calls without an
|
|
360
|
+
// edit do.
|
|
361
|
+
//
|
|
362
|
+
// An `mcp__*` call is exempt for the same reason a `read` is: the bound targets the
|
|
363
|
+
// credential rabbit-hole (endless `bash` probing with nothing implemented), and reaching
|
|
364
|
+
// a registered tool server is the platform TELLING the agent to look something up
|
|
365
|
+
// ("prefer them over guessing"). Counting them would abort an edits-expected kind for
|
|
366
|
+
// consulting the issue tracker the deployment wired for it, punishing the run for
|
|
367
|
+
// following its own prompt. They are neutral rather than edit-satisfying, exactly like a
|
|
368
|
+
// subagent dispatch: a read-only lookup must not clear the suspicion the bound holds.
|
|
369
|
+
//
|
|
370
|
+
// The exempt calls carry ONE streak of their own, and it is what keeps every exemption
|
|
371
|
+
// above from adding up to an unbounded run: each per-family cap resets on any call
|
|
372
|
+
// outside its family, so alternating two exempt families trips neither, and a run that
|
|
373
|
+
// never makes an action call never reaches the no-edit bound either.
|
|
374
|
+
if (isNonActionToolCall(name)) {
|
|
375
|
+
this.consecutiveNonActionCalls++
|
|
376
|
+
const nonActionCap =
|
|
377
|
+
this.limits.maxConsecutiveNonActionCalls ??
|
|
378
|
+
DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls
|
|
379
|
+
if (this.consecutiveNonActionCalls >= nonActionCap) {
|
|
380
|
+
return (
|
|
381
|
+
`no progress: ${this.consecutiveNonActionCalls} consecutive read-only calls (searching, ` +
|
|
382
|
+
`reading, tool-server lookups, subagent dispatches) with no action call between them. ` +
|
|
383
|
+
`The agent is cycling through research instead of doing the work. Aborting.`
|
|
384
|
+
)
|
|
385
|
+
}
|
|
268
386
|
return null
|
|
269
387
|
}
|
|
388
|
+
this.consecutiveNonActionCalls = 0
|
|
270
389
|
this.toolCalls++
|
|
271
390
|
if (FILE_EDIT_TOOLS.has(name)) this.edits++
|
|
272
391
|
|