@cat-factory/executor-harness 1.88.0 → 1.92.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-runner.d.ts +8 -1
- package/dist/agent-runner.js +27 -0
- package/dist/job.d.ts +2 -0
- package/dist/job.js +6 -0
- package/dist/pi-workspace.js +3 -0
- package/dist/pi.d.ts +26 -5
- package/dist/pi.js +18 -13
- package/dist/progress-guard.d.ts +29 -0
- package/dist/progress-guard.js +82 -8
- package/dist/tool-trajectory.d.ts +78 -0
- package/dist/tool-trajectory.js +194 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +34 -4
- package/src/agent-runner.ts +48 -0
- package/src/job.ts +6 -0
- package/src/pi-workspace.ts +3 -0
- package/src/pi.ts +53 -18
- package/src/progress-guard.ts +129 -10
- package/src/tool-trajectory.ts +239 -0
package/src/agent-runner.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { tmpdir } from 'node:os'
|
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude-stream.js'
|
|
6
6
|
import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js'
|
|
7
|
+
import {
|
|
8
|
+
ToolCallTracker,
|
|
9
|
+
type TrackedToolCall,
|
|
10
|
+
recordClaudeToolResults,
|
|
11
|
+
} from './tool-trajectory.js'
|
|
7
12
|
import type { Logger } from './logger.js'
|
|
8
13
|
import {
|
|
9
14
|
createCallMetricPublisher,
|
|
@@ -13,6 +18,7 @@ import {
|
|
|
13
18
|
type PiRunOutcome,
|
|
14
19
|
type PiRunStats,
|
|
15
20
|
type TodoProgress,
|
|
21
|
+
type ToolSpan,
|
|
16
22
|
} from './pi.js'
|
|
17
23
|
import {
|
|
18
24
|
claudeAllowedToolPatterns,
|
|
@@ -123,6 +129,13 @@ export interface SubscriptionRunOptions {
|
|
|
123
129
|
onActivity?: () => void
|
|
124
130
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
125
131
|
onProgress?: (progress: TodoProgress) => void
|
|
132
|
+
/**
|
|
133
|
+
* Called once per completed tool call with a {@link ToolSpan}: the run's TRAJECTORY. The CLI's
|
|
134
|
+
* tool loop is internal to the CLI and never touches our proxy, so its own event stream is the
|
|
135
|
+
* only place these exist — without this hook a subscription-harness run's account of what it
|
|
136
|
+
* DID dies with the container.
|
|
137
|
+
*/
|
|
138
|
+
onSpan?: (span: ToolSpan) => void
|
|
126
139
|
/**
|
|
127
140
|
* Called with the FULL set of per-slice reviews each time one lands, so the backend can persist
|
|
128
141
|
* a parallel review's completed work as it happens instead of only from the terminal result.
|
|
@@ -538,6 +551,36 @@ function createClaudeProgressGuard(opts: SubscriptionRunOptions): {
|
|
|
538
551
|
}
|
|
539
552
|
}
|
|
540
553
|
|
|
554
|
+
/**
|
|
555
|
+
* The run's TRAJECTORY, on the claude-code stream: each `tool_use` block paired with the
|
|
556
|
+
* `tool_result` that answers it on the following user turn, numbered and captured (scrubbed +
|
|
557
|
+
* capped). The CLI's stream is the only place this loop is visible at all — its tool calls never
|
|
558
|
+
* touch our proxy — so without this a subscription-harness run's account of what it DID dies with
|
|
559
|
+
* the container.
|
|
560
|
+
*
|
|
561
|
+
* Both halves are no-ops when the caller wants no spans, so a driver that only needs the run's
|
|
562
|
+
* output never pays to serialise a body nothing will read. Split out of {@link runClaudeCode} for
|
|
563
|
+
* the per-function line budget, like {@link createClaudeProgressGuard}.
|
|
564
|
+
*/
|
|
565
|
+
function createClaudeToolTrajectory(
|
|
566
|
+
opts: SubscriptionRunOptions,
|
|
567
|
+
secrets: readonly string[],
|
|
568
|
+
): {
|
|
569
|
+
onToolUse: (id: string, name: string, input: unknown) => void
|
|
570
|
+
onToolResults: (content: unknown[]) => void
|
|
571
|
+
} {
|
|
572
|
+
if (!opts.onSpan) return { onToolUse: () => {}, onToolResults: () => {} }
|
|
573
|
+
const onSpan = opts.onSpan
|
|
574
|
+
const tracker = new ToolCallTracker(secrets)
|
|
575
|
+
return {
|
|
576
|
+
onToolUse: (id, name, input) => tracker.started(id, name, input),
|
|
577
|
+
onToolResults: (content) =>
|
|
578
|
+
recordClaudeToolResults(tracker, content, (call: TrackedToolCall) =>
|
|
579
|
+
onSpan({ ...call, bodies: 'stored' }),
|
|
580
|
+
),
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
541
584
|
export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
542
585
|
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
543
586
|
let summary = ''
|
|
@@ -628,6 +671,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
628
671
|
// diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
|
|
629
672
|
const progressGuard = createClaudeProgressGuard(opts)
|
|
630
673
|
const { rememberTool, feedGuard, guardAbort } = progressGuard
|
|
674
|
+
const trajectory = createClaudeToolTrajectory(opts, secrets)
|
|
631
675
|
|
|
632
676
|
const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
|
|
633
677
|
const type = event.type
|
|
@@ -650,6 +694,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
650
694
|
// `is_error` its `tool_result` carries on the next `user` turn.
|
|
651
695
|
if (typeof block.id === 'string' && typeof block.name === 'string') {
|
|
652
696
|
rememberTool(block.id, block.name)
|
|
697
|
+
trajectory.onToolUse(block.id, block.name, block.input)
|
|
653
698
|
}
|
|
654
699
|
if (block.name === 'TodoWrite') {
|
|
655
700
|
const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
|
|
@@ -672,6 +717,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
672
717
|
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
673
718
|
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
674
719
|
if (!meta?.final) feedGuard(content)
|
|
720
|
+
// The trajectory's other half — fed on the FINAL flush too, unlike the guard, since a
|
|
721
|
+
// CLI that has exited is exactly when the last calls' results matter.
|
|
722
|
+
trajectory.onToolResults(content)
|
|
675
723
|
telemetry.onToolResult(dispatchId, content)
|
|
676
724
|
}
|
|
677
725
|
} else if (type === 'result') {
|
package/src/job.ts
CHANGED
|
@@ -181,9 +181,13 @@ function parseGuardLimits(value: unknown): GuardLimitsSpec | undefined {
|
|
|
181
181
|
const noEdit = posInt(o.maxToolCallsWithoutEdit)
|
|
182
182
|
const errors = posInt(o.maxConsecutiveErrors)
|
|
183
183
|
const web = posInt(o.maxConsecutiveWebCalls)
|
|
184
|
+
const mcp = posInt(o.maxConsecutiveMcpCalls)
|
|
185
|
+
const nonAction = posInt(o.maxConsecutiveNonActionCalls)
|
|
184
186
|
if (noEdit !== undefined) spec.maxToolCallsWithoutEdit = noEdit
|
|
185
187
|
if (errors !== undefined) spec.maxConsecutiveErrors = errors
|
|
186
188
|
if (web !== undefined) spec.maxConsecutiveWebCalls = web
|
|
189
|
+
if (mcp !== undefined) spec.maxConsecutiveMcpCalls = mcp
|
|
190
|
+
if (nonAction !== undefined) spec.maxConsecutiveNonActionCalls = nonAction
|
|
187
191
|
return Object.keys(spec).length > 0 ? spec : undefined
|
|
188
192
|
}
|
|
189
193
|
|
|
@@ -874,6 +878,8 @@ export interface GuardLimitsSpec {
|
|
|
874
878
|
maxToolCallsWithoutEdit?: number
|
|
875
879
|
maxConsecutiveErrors?: number
|
|
876
880
|
maxConsecutiveWebCalls?: number
|
|
881
|
+
maxConsecutiveMcpCalls?: number
|
|
882
|
+
maxConsecutiveNonActionCalls?: number
|
|
877
883
|
}
|
|
878
884
|
|
|
879
885
|
/**
|
package/src/pi-workspace.ts
CHANGED
|
@@ -325,6 +325,9 @@ export async function runAgentInWorkspace(
|
|
|
325
325
|
expectsEdits: spec.expectsEdits ?? true,
|
|
326
326
|
onActivity: opts.onActivity,
|
|
327
327
|
onProgress: opts.onProgress,
|
|
328
|
+
// The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
|
|
329
|
+
// and a proxied one produce the same evidence rather than one of them producing none.
|
|
330
|
+
onSpan: opts.onSpan,
|
|
328
331
|
// Per-slice review capture, so a parallel review's finished slices are persisted as they
|
|
329
332
|
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
330
333
|
// across subagents, so this is the only path that can produce it.
|
package/src/pi.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from 'node:os'
|
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
6
6
|
import { pathExists } from './fs-utils.js'
|
|
7
|
-
import { redactSecrets } from './redact.js'
|
|
7
|
+
import { redactSecrets, secretsToRedact } 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'
|
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
toolCallSignal,
|
|
15
15
|
type ProgressGuardLimits,
|
|
16
16
|
} from './progress-guard.js'
|
|
17
|
+
import {
|
|
18
|
+
ToolCallTracker,
|
|
19
|
+
readToolCallId,
|
|
20
|
+
toolCallResult,
|
|
21
|
+
toolCallStart,
|
|
22
|
+
} from './tool-trajectory.js'
|
|
17
23
|
|
|
18
24
|
// Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
|
|
19
25
|
// proxy via a custom provider in ~/.pi/agent/models.json, authenticated with the
|
|
@@ -501,18 +507,39 @@ export interface TodoProgress {
|
|
|
501
507
|
}
|
|
502
508
|
|
|
503
509
|
/**
|
|
504
|
-
* One tool invocation in
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
510
|
+
* One tool invocation in an agent's loop, captured for the run's TRAJECTORY: the ordered
|
|
511
|
+
* account of what the agent did, drained by the backend on its existing job poll and
|
|
512
|
+
* both persisted and emitted as a child span under the run trace.
|
|
513
|
+
*
|
|
514
|
+
* It carries the call's arguments and result (scrubbed and capped at capture — see
|
|
515
|
+
* `tool-trajectory.ts`), because the question asked of a finished run is which command
|
|
516
|
+
* ran against what, not how long a tool named `bash` took. Whether those bodies are
|
|
517
|
+
* RETAINED is the backend's decision, taken against the deployment switch and the
|
|
518
|
+
* workspace's opt-out; the harness's job is to capture them bounded and scrubbed.
|
|
508
519
|
*/
|
|
509
520
|
export interface ToolSpan {
|
|
510
521
|
tool: string
|
|
511
|
-
/**
|
|
522
|
+
/**
|
|
523
|
+
* The call's 0-based ordinal within this job. Two calls routinely land in the same
|
|
524
|
+
* millisecond, so this is the only thing that orders the trajectory — and it is what
|
|
525
|
+
* makes the backend's stored row id deterministic, so a replayed poll re-records
|
|
526
|
+
* instead of duplicating.
|
|
527
|
+
*/
|
|
528
|
+
seq: number
|
|
529
|
+
/** Epoch ms the tool call started (the previous call's end when no start was seen). */
|
|
512
530
|
startedAt: number
|
|
513
531
|
/** Epoch ms the tool call ended (when its `tool_execution_end` event arrived). */
|
|
514
532
|
endedAt: number
|
|
515
533
|
ok: boolean
|
|
534
|
+
/** Whether the bodies below were captured at all — always `'stored'` from this harness. */
|
|
535
|
+
bodies: 'stored' | 'withheld'
|
|
536
|
+
/** The call's arguments, serialised, scrubbed and capped. `''` when it took none. */
|
|
537
|
+
args: string
|
|
538
|
+
/** What the tool returned, scrubbed and capped. `''` when it returned nothing. */
|
|
539
|
+
result: string
|
|
540
|
+
/** Characters the cap dropped from {@link args} / {@link result}; 0 when nothing was cut. */
|
|
541
|
+
argsDropped: number
|
|
542
|
+
resultDropped: number
|
|
516
543
|
}
|
|
517
544
|
|
|
518
545
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -918,10 +945,17 @@ export function runPi(opts: {
|
|
|
918
945
|
opts.guardLimits ?? progressGuardLimitsFromEnv(),
|
|
919
946
|
opts.expectsEdits ?? true,
|
|
920
947
|
)
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
|
|
948
|
+
// Pairs each tool call's start with its result, numbers the pairs and captures the two
|
|
949
|
+
// bodies (scrubbed + capped). A call whose start Pi never emitted still gets an entry,
|
|
950
|
+
// timed from the previous call's end — see `ToolCallTracker`.
|
|
951
|
+
//
|
|
952
|
+
// The known-secret list is DERIVED from the token this function itself hands the child
|
|
953
|
+
// (`PI_PROXY_TOKEN` / `SEARXNG_API_KEY`) rather than taken as a parameter: the bodies
|
|
954
|
+
// travel to a store and to external trace sinks, and a caller that forgets to pass the
|
|
955
|
+
// list produces bodies scrubbed of credential SHAPES only, with no signal that the
|
|
956
|
+
// narrower rule ever ran. Deriving it here means the one place that knows the child's
|
|
957
|
+
// credentials is the place that scrubs them.
|
|
958
|
+
const tools = new ToolCallTracker(secretsToRedact(opts.sessionToken))
|
|
925
959
|
|
|
926
960
|
// SIGTERM first, then SIGKILL if Pi ignores it. Shared by the watchdog abort
|
|
927
961
|
// and the no-progress guard; the `close` handler turns it into a rejection.
|
|
@@ -958,21 +992,22 @@ export function runPi(opts: {
|
|
|
958
992
|
if (progress) opts.onProgress(progress)
|
|
959
993
|
}
|
|
960
994
|
if (opts.onSpan) {
|
|
995
|
+
const start = toolCallStart(event)
|
|
996
|
+
if (start) tools.started(start.id, start.name, start.args)
|
|
961
997
|
const signal = toolCallSignal(event)
|
|
962
998
|
if (signal && signal.name) {
|
|
963
|
-
const
|
|
999
|
+
const call = tools.finished(
|
|
1000
|
+
readToolCallId(event),
|
|
1001
|
+
signal.name,
|
|
1002
|
+
toolCallResult(event),
|
|
1003
|
+
signal.isError,
|
|
1004
|
+
)
|
|
964
1005
|
try {
|
|
965
|
-
opts.onSpan({
|
|
966
|
-
tool: signal.name,
|
|
967
|
-
startedAt: toolBoundary,
|
|
968
|
-
endedAt,
|
|
969
|
-
ok: !signal.isError,
|
|
970
|
-
})
|
|
1006
|
+
opts.onSpan({ ...call, bodies: 'stored' })
|
|
971
1007
|
} catch {
|
|
972
1008
|
// A faulty observer must never break the run.
|
|
973
1009
|
observerErrors++
|
|
974
1010
|
}
|
|
975
|
-
toolBoundary = endedAt
|
|
976
1011
|
}
|
|
977
1012
|
}
|
|
978
1013
|
if (runGuard && !guardReason && !aborted) {
|
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
|
|