fix(core): give Stop-hook continuations a fresh per-turn tool-call budget; make the cap configurable (#6238)

* fix(core): give Stop-hook continuations a fresh per-turn tool-call budget; make the cap configurable

A blocking Stop-hook continuation (e.g. a /goal iteration) feeds a fresh
user-role prompt to the model — a new logical turn — but the loop detector
never reset, so an entire goal chain billed one per-turn tool-call budget
and healthy long-running goals halted with turn_tool_call_cap. The ACP
daemon path already used per-continuation budgets; core now matches.

- Reset loop detection at each blocking Stop-hook continuation
- Add model.maxToolCallsPerTurn setting (default 100; <= 0 disables),
  resolved once in Config (<= 0 maps to Infinity)
- Honor the in-session 'Disable loop detection for this session' choice
  in the per-turn cap, as the dialog always claimed
- Point the headless halt message at the setting; update dialog/docs

* test(cli): add DEFAULT_MAX_TOOL_CALLS_PER_TURN to core module mocks

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
tanzhenxin 2026-07-04 10:36:59 +08:00 committed by GitHub
parent ad7e23f99f
commit cdf83d8bd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 320 additions and 55 deletions

View file

@ -170,6 +170,7 @@ Settings are organized into categories. Most settings should be placed within th
| `model.chatCompression.screenshotTriggerThreshold` | number | Tool-returned image count at or above which the screenshot trigger fires (only when `enableScreenshotTrigger`). Compaction resets the count — surviving images are re-embedded as top-level parts, which the trigger doesn't count — so it won't immediately re-fire. Env override: `QWEN_COMPACT_SCREENSHOT_THRESHOLD`. | `50` |
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `true` |
| `model.skipLoopDetection` | boolean | Disables streaming loop detection checks. Defaults to `true` (loop detection is skipped) to avoid false positives interrupting legitimate workflows. Set to `false` to re-enable streaming loop detection — useful as a guardrail in headless / non-interactive runs where stuck repetition can otherwise waste budget. | `true` |
| `model.maxToolCallsPerTurn` | number | Hard cap on tool calls within a single turn (one model turn plus its tool-result continuations; blocking Stop-hook continuations such as `/goal` iterations start a fresh budget). Always-on circuit breaker against runaway turns, independent of `model.skipLoopDetection`; the pattern-based loop detectors fire long before this cap, which only bounds total volume. Set to `0` or a negative value to disable the cap. Choosing "Disable loop detection for this session" in the loop-detected dialog also suppresses it for the rest of the session. | `100` |
| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` |
| `model.enableOpenAILogging` | boolean | Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. | `false` |
| `model.openAILoggingDir` | string | Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). | `undefined` |

View file

@ -115,6 +115,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
APPROVAL_MODES: [],
DEFAULT_STOP_HOOK_BLOCK_CAP: 8,
DEFAULT_MAX_SUBAGENT_DEPTH: 5,
DEFAULT_MAX_TOOL_CALLS_PER_TURN: 100,
DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH: 1024 * 1024,
SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH_LIMIT: 100 * 1024 * 1024,
DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD: 500_000,

View file

@ -454,6 +454,11 @@ describe('Session', () => {
isCronEnabled: vi.fn().mockReturnValue(false),
getSessionTokenLimit: vi.fn().mockReturnValue(0),
getStopHookBlockingCap: vi.fn().mockReturnValue(8),
// Mimics the resolved Config getter: always a number. The daemon-cap
// test overrides this with a small value.
getMaxToolCallsPerTurn: vi
.fn()
.mockReturnValue(core.DEFAULT_MAX_TOOL_CALLS_PER_TURN),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getBackgroundTaskRegistry: vi
.fn()
@ -2954,6 +2959,9 @@ describe('Session', () => {
it('stops an ACP prompt after exceeding the daemon tool-call cap', async () => {
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
// Pin the cap via the config mock — the daemon halts at whatever the
// resolved getter returns.
mockConfig.getMaxToolCallsPerTurn = vi.fn().mockReturnValue(100);
const functionCalls = Array.from({ length: 102 }, (_, index) => ({
id: `read_${index}`,
name: 'read_file',

View file

@ -235,7 +235,6 @@ type DaemonToolLoopState = {
loopDetected: boolean;
};
const DAEMON_TURN_TOOL_CALL_CAP = 100;
const DAEMON_INVALID_TOOL_PARAMS_THRESHOLD = 3;
const PERMISSION_CANCEL_SKIP_MESSAGE =
@ -277,7 +276,12 @@ function recordDaemonToolCalls(
if (!loopState || loopState.loopDetected)
return loopState?.loopDetected ?? false;
loopState.totalToolCalls += count;
if (loopState.totalToolCalls <= DAEMON_TURN_TOOL_CALL_CAP) return false;
// Same per-turn cap as the core LoopDetectionService (getMaxToolCallsPerTurn
// resolves model.maxToolCallsPerTurn to an effective value, Infinity when
// disabled). Unlike core there is no in-session disable check — that flag is
// only set by the interactive loop-detection dialog, which has no ACP
// equivalent.
if (loopState.totalToolCalls <= config.getMaxToolCallsPerTurn()) return false;
return recordDaemonLoopDetected(
config,
promptId,

View file

@ -2111,6 +2111,7 @@ export async function loadCliConfig(
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
skipWorkflowUsageWarning: settings.model?.skipWorkflowUsageWarning ?? false,
skipLoopDetection: settings.model?.skipLoopDetection ?? true,
maxToolCallsPerTurn: settings.model?.maxToolCallsPerTurn,
skipStartupContext: settings.model?.skipStartupContext ?? false,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,

View file

@ -17,6 +17,7 @@ import type {
import {
ApprovalMode,
DEFAULT_MAX_SUBAGENT_DEPTH,
DEFAULT_MAX_TOOL_CALLS_PER_TURN,
DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH,
DEFAULT_QWEN_CUSTOM_IGNORE_FILE_NAMES,
DEFAULT_STOP_HOOK_BLOCK_CAP,
@ -1415,7 +1416,17 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: true,
description:
'Skip the opt-in streaming loop-detection heuristics (content/thought repetition, read-file and action stagnation, global-duplicate and alternating tool-call patterns). Defaults to true to avoid false-positive interruptions; set to false to re-enable them as an unattended-run guardrail. A minimal always-on guard (consecutive identical tool calls plus a per-turn tool-call cap) still runs regardless of this setting.',
'Skip the opt-in streaming loop-detection heuristics (content/thought repetition, read-file and action stagnation, global-duplicate and alternating tool-call patterns). Defaults to true to avoid false-positive interruptions; set to false to re-enable them as an unattended-run guardrail. A minimal always-on guard (consecutive identical tool calls plus a per-turn tool-call cap, see model.maxToolCallsPerTurn) still runs regardless of this setting.',
showInDialog: false,
},
maxToolCallsPerTurn: {
type: 'number',
label: 'Max Tool Calls Per Turn',
category: 'Model',
requiresRestart: false,
default: DEFAULT_MAX_TOOL_CALLS_PER_TURN,
description:
'Hard cap on tool calls within a single turn (one model turn plus its tool-result continuations; blocking Stop-hook continuations such as /goal iterations start a fresh budget). An always-on circuit breaker against runaway turns, independent of model.skipLoopDetection. Set to 0 or a negative value to disable the cap.',
showInDialog: false,
},
skipStartupContext: {

View file

@ -783,6 +783,42 @@ describe('runNonInteractive', () => {
);
});
it('shows the maxToolCallsPerTurn hint when the per-turn cap halts the run', async () => {
setupMetricsMock();
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Partial work' },
{
type: GeminiEventType.LoopDetected,
value: { loopType: LoopType.TURN_TOOL_CALL_CAP },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
const exitCode = await runNonInteractive(
mockConfig,
mockSettings,
'Long turn',
'prompt-id-turn-cap',
);
expect(exitCode).toBe(1);
// The cap has its own knob, so the message must point at it rather than
// claiming the halt cannot be configured away.
expect(processStderrSpy).toHaveBeenCalledWith(
expect.stringContaining('`model.maxToolCallsPerTurn`'),
);
expect(processStderrSpy).not.toHaveBeenCalledWith(
expect.stringContaining('cannot be disabled'),
);
expect(processStderrSpy).not.toHaveBeenCalledWith(
expect.stringContaining(
'Set the `model.skipLoopDetection` setting to true',
),
);
});
it('marks JSON output as an error when loop detection halts the run', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
setupMetricsMock();

View file

@ -146,18 +146,21 @@ const LOOP_TYPE_LABELS: Record<LoopType, string> = {
function formatLoopDetectedMessage(loopType: LoopType | undefined): string {
const reason = loopType ? LOOP_TYPE_LABELS[loopType] : undefined;
const detail = reason ? ` (${loopType}: ${reason})` : '';
// The consecutive-identical guard and the per-turn cap both run before the
// skipLoopDetection gate, so that setting can't disable them — don't suggest
// it for those always-on loop types.
// The always-on guards run before the skipLoopDetection gate, so that
// setting can't disable them — don't suggest it for those loop types. The
// per-turn cap is also always-on but has its own knob, so it gets a
// dedicated hint instead of membership in this list.
const isAlwaysOn =
loopType === LoopType.TURN_TOOL_CALL_CAP ||
loopType === LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS ||
loopType === LoopType.SHELL_COMMAND_STAGNATION ||
loopType === LoopType.GLOBAL_TOOL_CALL_DUPLICATE ||
loopType === LoopType.INVALID_TOOL_PARAMS_STAGNATION;
const hint = isAlwaysOn
? ' This is an always-on guard and cannot be disabled via `model.skipLoopDetection`.'
: ' Set the `model.skipLoopDetection` setting to true to disable.';
const hint =
loopType === LoopType.TURN_TOOL_CALL_CAP
? ' Raise the `model.maxToolCallsPerTurn` setting to allow longer turns, or set it to 0 to disable the cap.'
: isAlwaysOn
? ' This is an always-on guard and cannot be disabled via `model.skipLoopDetection`.'
: ' Set the `model.skipLoopDetection` setting to true to disable.';
return `Loop detection halted the run${detail}.${hint}`;
}

View file

@ -60,6 +60,7 @@ vi.mock('@qwen-code/qwen-code-core', () => {
},
DEFAULT_STOP_HOOK_BLOCK_CAP: 5,
DEFAULT_MAX_SUBAGENT_DEPTH: 5,
DEFAULT_MAX_TOOL_CALLS_PER_TURN: 100,
DEFAULT_TOOL_OUTPUT_BATCH_BUDGET: 100_000,
DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD: 100_000,
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES: 2000,

View file

@ -30,11 +30,14 @@ describe('LoopDetectionConfirmation', () => {
expect(output).toContain(
'This can happen due to repetitive tool calls or other model behavior',
);
// The note must scope skipLoopDetection to the heuristics and flag the
// consecutive-identical guard as always-on (it cannot be turned off there).
// The note must scope skipLoopDetection to the heuristics, flag the
// always-on guards as unaffected by it, and point at the cap's own knob.
// (Assertions stay within single rendered lines — the frame wraps text.)
expect(output).toContain('heuristic loop checks for future sessions');
expect(output).toContain('always-on guard against consecutive identical');
expect(output).toContain('always-on guards');
expect(output).toContain('model.skipLoopDetection');
expect(output).toContain('model.maxToolCallsPerTurn');
expect(output).toContain('suppresses everything');
expect(output).toContain('settings.json');
});
});

View file

@ -86,9 +86,11 @@ export function LoopDetectionConfirmation({
<Text color={theme.text.secondary}>
Note: Setting &quot;model.skipLoopDetection&quot; to true in
your settings.json disables only the heuristic loop checks for
future sessions; the always-on guard against consecutive
identical tool calls cannot be turned off there. Disabling for
this session above suppresses both.
future sessions; the always-on guards (consecutive identical
tool calls, repeated shell inspection commands, and the per-turn
tool-call cap) are not affected by it. The cap is tunable via
&quot;model.maxToolCallsPerTurn&quot; (0 disables it). Disabling
for this session above suppresses everything.
</Text>
</Box>
</Box>

View file

@ -11,7 +11,9 @@ exports[`LoopDetectionConfirmation > renders correctly 1`] = `
│ 2. Disable loop detection for this session │
│ │
│ Note: Setting "model.skipLoopDetection" to true in your settings.json disables only the │
│ heuristic loop checks for future sessions; the always-on guard against consecutive identical │
│ tool calls cannot be turned off there. Disabling for this session above suppresses both. │
│ heuristic loop checks for future sessions; the always-on guards (consecutive identical tool │
│ calls, repeated shell inspection commands, and the per-turn tool-call cap) are not affected by │
│ it. The cap is tunable via "model.maxToolCallsPerTurn" (0 disables it). Disabling for this │
│ session above suppresses everything. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -19,6 +19,7 @@ import {
matchesAnyServerPattern,
} from './config.js';
import { Storage } from './storage.js';
import { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from '../services/loopDetectionService.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@ -4675,6 +4676,31 @@ describe('Server Config (config.ts)', () => {
});
});
describe('getMaxToolCallsPerTurn', () => {
it('should return the default cap when unset', () => {
const config = new Config(baseParams);
expect(config.getMaxToolCallsPerTurn()).toBe(
DEFAULT_MAX_TOOL_CALLS_PER_TURN,
);
});
it('should use a custom maxToolCallsPerTurn if provided', () => {
const config = new Config({ ...baseParams, maxToolCallsPerTurn: 42 });
expect(config.getMaxToolCallsPerTurn()).toBe(42);
});
it.each([0, -1])(
'should return infinity (cap disabled) when set to %d',
(capValue) => {
const config = new Config({
...baseParams,
maxToolCallsPerTurn: capValue,
});
expect(config.getMaxToolCallsPerTurn()).toBe(Number.POSITIVE_INFINITY);
},
);
});
describe('getClearContextOnIdle', () => {
it('should default the cumulative tool result threshold to 500000 chars', () => {
const config = new Config(baseParams);

View file

@ -107,6 +107,7 @@ import { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js'
import { WorkflowRunRegistry } from '../agents/workflow-run-registry.js';
import { FileReadCache } from '../services/fileReadCache.js';
import { resolveStopHookBlockingCap } from '../hooks/stopHookCap.js';
import { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from '../services/loopDetectionService.js';
import { buildContextUsage } from '../hooks/context-usage.js';
import {
DEFAULT_OTLP_ENDPOINT,
@ -1037,6 +1038,8 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
skipLoopDetection?: boolean;
/** Per-turn tool-call cap; <= 0 disables. See getMaxToolCallsPerTurn. */
maxToolCallsPerTurn?: number;
truncateToolOutputThreshold?: number;
truncateToolOutputLines?: number;
toolOutputBatchBudget?: number;
@ -1608,6 +1611,7 @@ export class Config {
private readonly agentsSettings: AgentsCollabSettings;
private readonly worktreeSettings: WorktreeSettings;
private readonly skipLoopDetection: boolean;
private readonly maxToolCallsPerTurn: number;
private readonly skipStartupContext: boolean;
private readonly bareMode: boolean;
private readonly safeMode: boolean;
@ -1840,6 +1844,8 @@ export class Config {
this.interactive = params.interactive ?? false;
this.trustedFolder = params.trustedFolder;
this.skipLoopDetection = params.skipLoopDetection ?? false;
this.maxToolCallsPerTurn =
params.maxToolCallsPerTurn ?? DEFAULT_MAX_TOOL_CALLS_PER_TURN;
this.skipStartupContext = params.skipStartupContext ?? false;
this.bareMode = params.bareMode ?? false;
this.safeMode = params.safeMode ?? isSafeModeEnv();
@ -5607,6 +5613,18 @@ export class Config {
return this.skipLoopDetection;
}
/**
* Effective per-turn tool-call cap. A configured value <= 0 disables the
* cap and is returned as Infinity so callers can compare unconditionally
* (mirrors getTruncateToolOutputThreshold).
*/
getMaxToolCallsPerTurn(): number {
if (this.maxToolCallsPerTurn <= 0) {
return Number.POSITIVE_INFINITY;
}
return this.maxToolCallsPerTurn;
}
getSkipStartupContext(): boolean {
return this.skipStartupContext;
}

View file

@ -512,6 +512,9 @@ describe('Gemini Client (client.ts)', () => {
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getBaseLlmClient: vi.fn(),
getSkipLoopDetection: vi.fn().mockReturnValue(false),
// Mimics the resolved Config getter: always a number (Infinity keeps
// the cap out of the way of unrelated streaming tests).
getMaxToolCallsPerTurn: vi.fn().mockReturnValue(Number.POSITIVE_INFINITY),
getChatRecordingService: vi.fn().mockReturnValue(undefined),
getFileHistoryService: vi.fn().mockReturnValue(mockFileHistoryService),
getResumedSessionData: vi.fn().mockReturnValue(undefined),
@ -7187,6 +7190,76 @@ Other open files:
});
});
it('gives a blocking Stop hook continuation a fresh per-turn tool-call budget', async () => {
// First Stop check blocks (like a /goal "not met" verdict); the
// second allows the loop to end.
const mockMessageBus = {
request: vi
.fn()
.mockResolvedValueOnce({
output: { decision: 'block', reason: 'Keep working' },
stopHookCount: 1,
})
.mockResolvedValue({ output: undefined }),
response: vi.fn(),
};
vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
vi.mocked(mockConfig.getMessageBus).mockReturnValue(
mockMessageBus as unknown as ReturnType<Config['getMessageBus']>,
);
vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
(event: string) => event === 'Stop',
);
// Cap of 4: each turn's 3 tool calls fit, but 6 accumulated across
// the continuation boundary would not.
vi.mocked(mockConfig.getMaxToolCallsPerTurn).mockReturnValue(4);
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi
.fn()
.mockReturnValue([
{ role: 'model', parts: [{ text: 'not done' }] },
]),
} as unknown as GeminiChat;
let turnIndex = 0;
mockTurnRunFn.mockImplementation(() => {
const turnNo = turnIndex++;
return (async function* () {
for (let i = 0; i < 3; i++) {
yield {
type: GeminiEventType.ToolCallRequest,
value: {
callId: `call-${turnNo}-${i}`,
name: 'test_tool',
args: { turnNo, i },
isClientInitiated: false,
prompt_id: 'prompt-stop-hook-budget',
},
};
}
yield { type: GeminiEventType.Content, value: 'not done' };
})();
});
const events = await fromAsync(
client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-stop-hook-budget',
),
);
// The hook continuation turn actually ran...
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
// ...and 3+3 tool calls under a cap of 4 never tripped the cap: the
// continuation started a fresh budget instead of inheriting the
// first turn's accumulated count.
expect(events).not.toContainEqual(
expect.objectContaining({ type: GeminiEventType.LoopDetected }),
);
});
it('emits one active_goal null when the blocking cap aborts an active goal', async () => {
setActiveGoal('test-session-id', {
condition: 'finish the refactor',

View file

@ -2471,6 +2471,18 @@ export class GeminiClient {
},
};
// A blocking Stop hook (e.g. /goal) feeds a fresh user-role prompt
// back to the model, starting a new logical turn — reset per-turn
// loop accounting so each continuation gets its own tool-call
// budget. Without this, a goal chain accumulates every iteration's
// tool calls into one "turn" and trips TURN_TOOL_CALL_CAP after a
// handful of healthy iterations. The ACP daemon path already has
// these semantics (fresh DaemonToolLoopState per continuation).
// Runaway protection is preserved: the cap still bounds each
// iteration, and the chain itself is bounded by
// stopHookBlockingCap / MAX_GOAL_ITERATIONS.
this.loopDetector.reset(prompt_id);
const continueRequest = [{ text: continueReason }];
const activeGoal = getActiveGoal(this.config.getSessionId());
const hookTurnBudget = activeGoal ? boundedTurns : boundedTurns - 1;

View file

@ -213,6 +213,7 @@ export * from './services/fileReadCache.js';
export * from './services/fileSystemService.js';
export { decodeBufferWithEncodingInfo } from './utils/fileUtils.js';
export * from './services/gitWorktreeService.js';
export { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from './services/loopDetectionService.js';
export * from './services/visionBridge/vision-bridge-service.js';
export * from './services/visionBridge/image-part-utils.js';
export * from './services/sessionRecap.js';

View file

@ -15,7 +15,10 @@ import type {
import { GeminiEventType } from '../core/turn.js';
import * as loggers from '../telemetry/loggers.js';
import { LoopType } from '../telemetry/types.js';
import { LoopDetectionService } from './loopDetectionService.js';
import {
DEFAULT_MAX_TOOL_CALLS_PER_TURN,
LoopDetectionService,
} from './loopDetectionService.js';
vi.mock('../telemetry/loggers.js', () => ({
logLoopDetected: vi.fn(),
@ -31,16 +34,21 @@ const FILE_READ_WINDOW = 15;
const GLOBAL_DUPLICATE_THRESHOLD = 6;
const SHELL_COMMAND_STAGNATION_THRESHOLD = 8;
const ALTERNATING_PATTERN_CYCLES = 3;
const TURN_TOOL_CALL_CAP = 100;
describe('LoopDetectionService', () => {
let service: LoopDetectionService;
let mockConfig: Config;
beforeEach(() => {
mockConfig = {
// getMaxToolCallsPerTurn mimics the real Config getter, which always
// returns an effective cap (default applied, <= 0 resolved to Infinity).
const makeConfig = (cap: number = DEFAULT_MAX_TOOL_CALLS_PER_TURN): Config =>
({
getTelemetryEnabled: () => true,
} as unknown as Config;
getMaxToolCallsPerTurn: () => cap,
}) as unknown as Config;
beforeEach(() => {
mockConfig = makeConfig();
service = new LoopDetectionService(mockConfig);
vi.clearAllMocks();
});
@ -1366,6 +1374,17 @@ describe('LoopDetectionService', () => {
});
describe('Turn Tool Call Cap (Always-On Circuit Breaker)', () => {
// The cap is configurable via model.maxToolCallsPerTurn; the service
// reads the resolved Config getter with no fallback of its own, so the
// pinned mock below is the single source of the cap in these tests.
const TURN_TOOL_CALL_CAP = 100;
let capConfig: Config;
beforeEach(() => {
capConfig = makeConfig(TURN_TOOL_CALL_CAP);
service = new LoopDetectionService(capConfig);
});
it('should not fire when total calls are below the cap', () => {
service.reset('');
for (let i = 0; i < TURN_TOOL_CALL_CAP; i++) {
@ -1390,7 +1409,7 @@ describe('LoopDetectionService', () => {
expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1);
// The turn cap reports its own loop type, not consecutive-identical.
expect(loggers.logLoopDetected).toHaveBeenCalledWith(
mockConfig,
capConfig,
expect.objectContaining({
loop_type: 'turn_tool_call_cap',
}),
@ -1398,22 +1417,49 @@ describe('LoopDetectionService', () => {
expect(service.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP);
});
it('should fire regardless of disabledForSession', () => {
it('fires at the built-in default cap (the resolved getter value)', () => {
const svc = new LoopDetectionService(mockConfig);
svc.reset('');
for (let i = 0; i < DEFAULT_MAX_TOOL_CALLS_PER_TURN; i++) {
expect(
svc.checkAlwaysOnSafeties(createToolCallRequestEvent('t', { i })),
).toBe(false);
}
expect(
svc.checkAlwaysOnSafeties(
createToolCallRequestEvent('t', { last: true }),
),
).toBe(true);
expect(svc.getLastLoopType()).toBe(LoopType.TURN_TOOL_CALL_CAP);
});
it('never fires when the cap is disabled (Config resolves <= 0 to Infinity)', () => {
const svc = new LoopDetectionService(
makeConfig(Number.POSITIVE_INFINITY),
);
svc.reset('');
for (let i = 0; i < DEFAULT_MAX_TOOL_CALLS_PER_TURN + 50; i++) {
expect(
svc.checkAlwaysOnSafeties(createToolCallRequestEvent('t', { i })),
).toBe(false);
}
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
});
it('does not fire after loop detection is disabled for the session', () => {
// The dialog's "Disable loop detection for this session" must suppress
// the cap too — the user's explicit choice outranks the circuit breaker
// (it used to fire regardless, contradicting the dialog text).
service.reset('');
service.disableForSession();
// disableForSession prevents heuristic checks, but not the turn cap
for (let i = 0; i < TURN_TOOL_CALL_CAP; i++) {
service.checkAlwaysOnSafeties(
createToolCallRequestEvent('any_tool', { i }),
);
for (let i = 0; i < TURN_TOOL_CALL_CAP + 10; i++) {
expect(
service.checkAlwaysOnSafeties(
createToolCallRequestEvent('any_tool', { i }),
),
).toBe(false);
}
const isLoop = service.checkAlwaysOnSafeties(
createToolCallRequestEvent('any_tool', { extra: true }),
);
// disabledForSession blocks non-ToolCallRequest events in
// checkAlwaysOnSafeties, but this IS a ToolCallRequest so the cap
// still fires.
expect(isLoop).toBe(true);
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
});
const retryEvent = {

View file

@ -65,10 +65,17 @@ const GLOBAL_DUPLICATE_THRESHOLD = 6;
// trip the detector (3 cycles = 6 calls: A B A B A B).
const ALTERNATING_PATTERN_CYCLES = 3;
// Hard per-turn tool call cap. Always-on circuit breaker — not gated by
// skipLoopDetection. If a single turn exceeds this many tool calls the
// turn is halted regardless of loop-detection configuration.
const TURN_TOOL_CALL_CAP = 100;
// Default hard per-turn tool call cap. Circuit breaker against runaway
// turns that no pattern detector catches (e.g. the model varies arguments
// on every call). Not gated by skipLoopDetection, but configurable via the
// `model.maxToolCallsPerTurn` setting (values <= 0 disable the cap) and
// suppressed by an explicit in-session disable. A "turn" for cap purposes
// is one model turn plus its ToolResult continuations; a blocking Stop-hook
// continuation (e.g. a /goal iteration) starts a fresh budget via
// loopDetector.reset() in client.ts, so the cap bounds each iteration
// rather than an entire goal chain — which is what keeps 100 sufficient
// for legitimate work.
export const DEFAULT_MAX_TOOL_CALLS_PER_TURN = 100;
/**
* Service for detecting and preventing infinite loops in AI responses.
@ -127,8 +134,9 @@ export class LoopDetectionService {
private recentToolCallKeys: string[] = [];
// Total tool calls emitted in the current turn. Always-on circuit breaker;
// exceeds TURN_TOOL_CALL_CAP → hard-stop. Accumulates across ToolResult
// continuations within a turn (reset() only runs for top-level interactions).
// exceeds the configured per-turn cap → hard-stop. Accumulates across
// ToolResult continuations within a turn (reset() only runs for top-level
// interactions).
private turnToolCallTotal = 0;
// Rollback floor for turnToolCallTotal: the committed total as of the last
@ -254,7 +262,9 @@ export class LoopDetectionService {
* config default. Enforces three guards: the consecutive-identical tool-call
* loop, the shell inspection-command stagnation loop, and the per-turn
* tool-call cap. Call this before the gated heuristic checks so none of the
* guards can be bypassed by configuration.
* guards can be bypassed by `skipLoopDetection`. All three honor an
* explicit in-session disable; the cap is additionally tunable via the
* `model.maxToolCallsPerTurn` setting.
*/
checkAlwaysOnSafeties(event: ServerGeminiStreamEvent): boolean {
if (this.loopDetected) {
@ -290,10 +300,10 @@ export class LoopDetectionService {
// identical call returns an identical result, so it is never productive.
// Promoted here from the opt-in tier so it protects every user regardless
// of the `skipLoopDetection` config default: the DashScope server rejects
// this pattern with a 400 (issue #5019) far below the per-turn cap (100),
// so the gated default left users unprotected. It still honors an explicit
// in-session disable — the user's active "stop detecting" choice — whereas
// the per-turn cap below honors nothing.
// this pattern with a 400 (issue #5019) far below the per-turn cap, so
// the gated default left users unprotected. Like the per-turn cap below,
// it honors an explicit in-session disable — the user's active "stop
// detecting" choice.
if (!this.disabledForSession && this.checkToolCallLoop(event.value)) {
this.loopDetected = true;
return true;
@ -307,7 +317,7 @@ export class LoopDetectionService {
return true;
}
if (this.checkTurnToolCallCap()) {
if (!this.disabledForSession && this.checkTurnToolCallCap()) {
this.loopDetected = true;
return true;
}
@ -773,14 +783,15 @@ export class LoopDetectionService {
}
/**
* Always-on hard cap: if the turn exceeds TURN_TOOL_CALL_CAP tool calls
* the turn is halted. This is a safety net independent of
* skipLoopDetection and fires on the very next tool call that pushes the
* total past the cap, not retroactively.
* Per-turn hard cap: if the turn exceeds the configured maximum number of
* tool calls (getMaxToolCallsPerTurn already resolved to an effective
* value, Infinity when disabled) the turn is halted. This is a safety net
* independent of skipLoopDetection and fires on the very next tool call
* that pushes the total past the cap, not retroactively.
*/
private checkTurnToolCallCap(): boolean {
this.turnToolCallTotal++;
if (this.turnToolCallTotal > TURN_TOOL_CALL_CAP) {
if (this.turnToolCallTotal > this.config.getMaxToolCallsPerTurn()) {
this.lastLoopType = LoopType.TURN_TOOL_CALL_CAP;
logLoopDetected(
this.config,

View file

@ -617,10 +617,15 @@
"default": false
},
"skipLoopDetection": {
"description": "Skip the opt-in streaming loop-detection heuristics (content/thought repetition, read-file and action stagnation, global-duplicate and alternating tool-call patterns). Defaults to true to avoid false-positive interruptions; set to false to re-enable them as an unattended-run guardrail. A minimal always-on guard (consecutive identical tool calls plus a per-turn tool-call cap) still runs regardless of this setting.",
"description": "Skip the opt-in streaming loop-detection heuristics (content/thought repetition, read-file and action stagnation, global-duplicate and alternating tool-call patterns). Defaults to true to avoid false-positive interruptions; set to false to re-enable them as an unattended-run guardrail. A minimal always-on guard (consecutive identical tool calls plus a per-turn tool-call cap, see model.maxToolCallsPerTurn) still runs regardless of this setting.",
"type": "boolean",
"default": true
},
"maxToolCallsPerTurn": {
"description": "Hard cap on tool calls within a single turn (one model turn plus its tool-result continuations; blocking Stop-hook continuations such as /goal iterations start a fresh budget). An always-on circuit breaker against runaway turns, independent of model.skipLoopDetection. Set to 0 or a negative value to disable the cap.",
"type": "number",
"default": 100
},
"skipStartupContext": {
"description": "Avoid sending the workspace startup context at the beginning of each session.",
"type": "boolean",