feat(core): add setting to disable loop detection (#18008)

This commit is contained in:
Sandy Tao
2026-02-02 10:13:20 -08:00
committed by GitHub
parent b0be1f1689
commit e860f517c0
9 changed files with 46 additions and 2 deletions
+1
View File
@@ -77,6 +77,7 @@ they appear in the UI.
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- | | ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | | Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | | Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | | Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Context ### Context
+6
View File
@@ -326,6 +326,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `0.5` - **Default:** `0.5`
- **Requires restart:** Yes - **Requires restart:** Yes
- **`model.disableLoopDetection`** (boolean):
- **Description:** Disable automatic detection and prevention of infinite
loops.
- **Default:** `false`
- **Requires restart:** Yes
- **`model.skipNextSpeakerCheck`** (boolean): - **`model.skipNextSpeakerCheck`** (boolean):
- **Description:** Skip the next speaker check. - **Description:** Skip the next speaker check.
- **Default:** `true` - **Default:** `true`
+1
View File
@@ -762,6 +762,7 @@ export async function loadCliConfig(
noBrowser: !!process.env['NO_BROWSER'], noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput, summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode, ideMode,
disableLoopDetection: settings.model?.disableLoopDetection,
compressionThreshold: settings.model?.compressionThreshold, compressionThreshold: settings.model?.compressionThreshold,
folderTrust, folderTrust,
interactive, interactive,
+10
View File
@@ -739,6 +739,16 @@ const SETTINGS_SCHEMA = {
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).', 'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true, showInDialog: true,
}, },
disableLoopDetection: {
type: 'boolean',
label: 'Disable Loop Detection',
category: 'Model',
requiresRestart: true,
default: false,
description:
'Disable automatic detection and prevention of infinite loops.',
showInDialog: true,
},
skipNextSpeakerCheck: { skipNextSpeakerCheck: {
type: 'boolean', type: 'boolean',
label: 'Skip Next Speaker Check', label: 'Skip Next Speaker Check',
+7
View File
@@ -393,6 +393,7 @@ export interface ConfigParameters {
includeDirectories?: string[]; includeDirectories?: string[];
bugCommand?: BugCommandSettings; bugCommand?: BugCommandSettings;
model: string; model: string;
disableLoopDetection?: boolean;
maxSessionTurns?: number; maxSessionTurns?: number;
experimentalZedIntegration?: boolean; experimentalZedIntegration?: boolean;
listSessions?: boolean; listSessions?: boolean;
@@ -531,6 +532,7 @@ export class Config {
private readonly cwd: string; private readonly cwd: string;
private readonly bugCommand: BugCommandSettings | undefined; private readonly bugCommand: BugCommandSettings | undefined;
private model: string; private model: string;
private readonly disableLoopDetection: boolean;
private previewFeatures: boolean | undefined; private previewFeatures: boolean | undefined;
private hasAccessToPreviewModel: boolean = false; private hasAccessToPreviewModel: boolean = false;
private readonly noBrowser: boolean; private readonly noBrowser: boolean;
@@ -697,6 +699,7 @@ export class Config {
this.fileDiscoveryService = params.fileDiscoveryService ?? null; this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand; this.bugCommand = params.bugCommand;
this.model = params.model; this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model; this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false; this.enableAgents = params.enableAgents ?? false;
this.agents = params.agents ?? {}; this.agents = params.agents ?? {};
@@ -1118,6 +1121,10 @@ export class Config {
return this.model; return this.model;
} }
getDisableLoopDetection(): boolean {
return this.disableLoopDetection ?? false;
}
setModel(newModel: string, isTemporary: boolean = true): void { setModel(newModel: string, isTemporary: boolean = true): void {
if (this.model !== newModel || this._activeModel !== newModel) { if (this.model !== newModel || this._activeModel !== newModel) {
this.model = newModel; this.model = newModel;
+1
View File
@@ -213,6 +213,7 @@ describe('Gemini Client (client.ts)', () => {
getGlobalMemory: vi.fn().mockReturnValue(''), getGlobalMemory: vi.fn().mockReturnValue(''),
getEnvironmentMemory: vi.fn().mockReturnValue(''), getEnvironmentMemory: vi.fn().mockReturnValue(''),
isJitContextEnabled: vi.fn().mockReturnValue(false), isJitContextEnabled: vi.fn().mockReturnValue(false),
getDisableLoopDetection: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'), getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProxy: vi.fn().mockReturnValue(undefined), getProxy: vi.fn().mockReturnValue(undefined),
@@ -38,6 +38,7 @@ describe('LoopDetectionService', () => {
mockConfig = { mockConfig = {
getTelemetryEnabled: () => true, getTelemetryEnabled: () => true,
isInteractive: () => false, isInteractive: () => false,
getDisableLoopDetection: () => false,
getModelAvailabilityService: vi getModelAvailabilityService: vi
.fn() .fn()
.mockReturnValue(createAvailabilityServiceMock()), .mockReturnValue(createAvailabilityServiceMock()),
@@ -162,6 +163,15 @@ describe('LoopDetectionService', () => {
// Should now return false even though a loop was previously detected // Should now return false even though a loop was previously detected
expect(service.addAndCheck(event)).toBe(false); expect(service.addAndCheck(event)).toBe(false);
}); });
it('should skip loop detection if disabled in config', () => {
vi.spyOn(mockConfig, 'getDisableLoopDetection').mockReturnValue(true);
const event = createToolCallRequestEvent('testTool', { param: 'value' });
for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD + 2; i++) {
expect(service.addAndCheck(event)).toBe(false);
}
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
});
}); });
describe('Content Loop Detection', () => { describe('Content Loop Detection', () => {
@@ -742,6 +752,7 @@ describe('LoopDetectionService LLM Checks', () => {
mockConfig = { mockConfig = {
getGeminiClient: () => mockGeminiClient, getGeminiClient: () => mockGeminiClient,
getBaseLlmClient: () => mockBaseLlmClient, getBaseLlmClient: () => mockBaseLlmClient,
getDisableLoopDetection: () => false,
getDebugMode: () => false, getDebugMode: () => false,
getTelemetryEnabled: () => true, getTelemetryEnabled: () => true,
getModel: vi.fn().mockReturnValue('cognitive-loop-v1'), getModel: vi.fn().mockReturnValue('cognitive-loop-v1'),
@@ -147,7 +147,7 @@ export class LoopDetectionService {
* @returns true if a loop is detected, false otherwise * @returns true if a loop is detected, false otherwise
*/ */
addAndCheck(event: ServerGeminiStreamEvent): boolean { addAndCheck(event: ServerGeminiStreamEvent): boolean {
if (this.disabledForSession) { if (this.disabledForSession || this.config.getDisableLoopDetection()) {
return false; return false;
} }
@@ -182,7 +182,7 @@ export class LoopDetectionService {
* @returns A promise that resolves to `true` if a loop is detected, and `false` otherwise. * @returns A promise that resolves to `true` if a loop is detected, and `false` otherwise.
*/ */
async turnStarted(signal: AbortSignal) { async turnStarted(signal: AbortSignal) {
if (this.disabledForSession) { if (this.disabledForSession || this.config.getDisableLoopDetection()) {
return false; return false;
} }
this.turnsInCurrentPrompt++; this.turnsInCurrentPrompt++;
+7
View File
@@ -457,6 +457,13 @@
"default": 0.5, "default": 0.5,
"type": "number" "type": "number"
}, },
"disableLoopDetection": {
"title": "Disable Loop Detection",
"description": "Disable automatic detection and prevention of infinite loops.",
"markdownDescription": "Disable automatic detection and prevention of infinite loops.\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"skipNextSpeakerCheck": { "skipNextSpeakerCheck": {
"title": "Skip Next Speaker Check", "title": "Skip Next Speaker Check",
"description": "Skip the next speaker check.", "description": "Skip the next speaker check.",