docs/design/2026-07-17-adaptive-tool-call-cap.md
Date: 2026-07-17
Status: Implemented
Area: packages/core loop detection
The always-on per-turn tool-call cap (model.maxToolCallsPerTurn, default 100)
is a blunt circuit breaker: it halts the turn on the 101st tool call regardless
of whether the model is actually stuck or doing productive work. Large
multi-package implementation turns legitimately exceed 100 tool calls, so the
cap kills productive work — a false positive.
Concrete case: session 80db472f-… (qwen-code-x1, "Web Shell git status/diff
chip"). The 继续Phase 2 turn made exactly 100 tool calls and was hard-halted
mid-npm run build with no completion summary. Analysis of that turn and its
siblings:
| turn | tool calls | distinct (tool,args) keys | max repeat of one key | max same-name streak |
|---|---|---|---|---|
| 7 | 96 | 96 | 1 | 7 |
| 8 | 100 | 99 | 2 | 3 |
| 9 | 95 | 95 | 1 | 7 |
Productive turns are highly diverse: no single (tool, args) call repeats more
than twice. A genuinely stuck turn repeats the same call many times.
The behavior depends on whether maxToolCallsPerTurn was explicitly
configured (tracked by Config.isMaxToolCallsPerTurnExplicit()):
N → a hard cap (the released contract): the turn
halts on the call that exceeds N, with no adaptive extension. This preserves
backward compatibility — a user who set the value to bound unattended cost
still gets exactly that bound. (v0.19.10 shipped the cap as a hard cap; an
earlier iteration of this PR multiplied explicit values by 3, which was a
breaking change — reverted.)S = 100) → adaptive: distinguish a productive long
turn from a stuck one using a repetition signal, and only hard-halt the latter
(plus an absolute backstop). Modern models legitimately make hundreds of calls
per task, so the default must not hard-halt productive long turns.Two thresholds for the adaptive (default) cap:
S (100): when the turn exceeds S tool calls, halt only if a
stuck-repetition signal is present; otherwise treat the turn as productive and
let it continue.S * ADAPTIVE_CAP_HARD_MULTIPLIER (multiplier 10 → 1000):
absolute backstop. Halt regardless of repetition once exceeded, so a runaway
that varies arguments on every call (which no repetition signal catches) is
still bounded. The multiplier is high enough that hundreds-of-calls productive
turns are not false-positived.Stuck-repetition signal: the maximum number of times any single (tool, args)
key has appeared in the turn reaches GLOBAL_DUPLICATE_THRESHOLD (6). This
reuses the existing global-duplicate semantics and has a wide safety margin
(productive turns observed ≤ 2).
The same-name streak is intentionally NOT used as a gate signal: parallel tool
batches (e.g. several read_file of different files in one assistant message)
legitimately produce same-name streaks of 6–7, too close to the action
stagnation threshold of 8.
The cap is always-on (not gated by skipLoopDetection), but the existing
globalToolCallCounts map is only maintained inside the gated heuristic path.
To keep the always-on cap independent of the gated path, the cap maintains its
own small always-on tracker:
capKeyCounts: Map<string, number> — per-(tool,args) counts this turn.capMaxKeyRepeat: number — running max of any single key's count.Maintained in checkAlwaysOnSafeties for every ToolCallRequest, cleared in
reset() and on Retry (consistent with how the heuristic path clears
globalToolCallCounts on retry).
Explicit value N (hard cap):
| total calls | result |
|---|---|
≤ N | allow |
> N | halt (hard) |
Default (unset), soft cap S = 100, hard cap H = 1000:
| total calls | repetition signal | result |
|---|---|---|
≤ S | any | allow |
S < total ≤ H | max key repeat < 6 | allow (productive) |
S < total ≤ H | max key repeat ≥ 6 | halt (stuck) |
> H | any | halt (backstop) |
When S ≤ 0 the cap is disabled (getMaxToolCallsPerTurn() returns
Infinity); behavior is unchanged (never fires).
packages/core/src/config/config.ts — track maxToolCallsPerTurnExplicit +
isMaxToolCallsPerTurnExplicit() getter.packages/core/src/services/loopDetectionService.ts — explicit-vs-default
cap logic + always-on tracker + canonicalized tool-call key.packages/core/src/services/loopDetectionService.test.ts — explicit hard-cap
regression + adaptive (default) cases.packages/core/src/core/client.test.ts — Stop-hook budget test (explicit
hard cap).packages/core/src/config/config.test.ts — explicit-flag tracking.packages/cli/src/config/settingsSchema.ts — maxToolCallsPerTurn
description.docs/users/configuration/settings.md — same.maxToolCallsPerTurn scales both).(tool, args) repeated 6 times anywhere in
the turn marks it stuck, even if those repeats are legitimate (e.g. re-running
the same build/test after successive fixes). This is never a regression — the
signal only acts past the soft cap, where the old cap always halted — but that
productive class does not benefit. The "productive turns repeat ≤ 2" evidence
comes from one session's three turns; revisit with a windowed signal if
telemetry shows this false-stuck pattern.TURN_TOOL_CALL_CAP; a boolean/attribute on
LoopDetectedEvent would tell which fired in the wild (useful for validating
the 10× multiplier). The headless message already hedges to cover both.recordDaemonToolCalls in
packages/cli/src/acp-integration/session/Session.ts) had its own blunt
per-turn cap that did not use LoopDetectionService and always treated the
value as a hard cap regardless of repetition. Landed in #8631: the daemon
now mirrors the adaptive default (explicit value = hard cap; default =
adaptive with the same stuck-repetition signal and hard backstop, reusing
the thresholds and the (tool,args) repeat key exported from this module),
and mirrors checkGlobalDuplicate on the same side of the
skipLoopDetection gate as core (off by default, since this detector
class is the false-positive-prone one; the always-on stuck signal still
only acts past the soft cap, as argued above). The interactive TUI path
that produced the reported false positive is fixed here.