@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.
@@ -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
  /**
@@ -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 Pi's loop, captured for the run's observability trace.
505
- * Metadata only (name + timing + ok) never the tool's args or result so the
506
- * harness buffer stays tiny. The backend drains these on its existing job poll and
507
- * emits them as child spans under the run trace.
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
- /** Epoch ms the tool call started (approximated as the previous tool's end). */
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
- // Start boundary for the next tool span: each tool's slice runs from the previous
922
- // tool's end (or the run start) to its own `tool_execution_end`. Approximate but
923
- // contiguous enough for the trace tree, and metadata-only.
924
- let toolBoundary = Date.now()
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 endedAt = Date.now()
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) {
@@ -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
- // `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.
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
- // 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
- ) {
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