From 91ec374c5fe47e5c3a09197ef44c6d31d3c039f0 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 28 Jun 2026 01:18:04 +0800 Subject: [PATCH 1/8] fix(core): halt repeated shell inspection variants --- packages/cli/src/nonInteractiveCli.ts | 3 + .../src/services/loopDetectionService.test.ts | 42 ++++++++++ .../core/src/services/loopDetectionService.ts | 76 +++++++++++++++++++ packages/core/src/telemetry/types.ts | 2 + 4 files changed, 123 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 3108cc87875..b48289fd331 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -112,6 +112,8 @@ const LOOP_TYPE_LABELS: Record = { 'the model spent too many consecutive calls reading files without making progress', [LoopType.ACTION_STAGNATION]: 'the model kept calling the same tool without making progress', + [LoopType.SHELL_COMMAND_STAGNATION]: + 'the model repeated similar shell inspection commands without making progress', [LoopType.GLOBAL_TOOL_CALL_DUPLICATE]: 'the model repeated the same tool call across the turn, even when not back-to-back', [LoopType.ALTERNATING_TOOL_CALL_PATTERN]: @@ -129,6 +131,7 @@ function formatLoopDetectedMessage(loopType: LoopType | undefined): string { 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; const hint = isAlwaysOn ? ' This is an always-on guard and cannot be disabled via `model.skipLoopDetection`.' diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index a3545bddf29..213531e16b4 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -221,6 +221,48 @@ describe('LoopDetectionService', () => { }); }); + describe('Shell Command Stagnation (Always-On Circuit Breaker)', () => { + it('halts repeated git inspection command variants via the always-on guard', () => { + const commands = [ + 'git status --short', + 'git status --short && git diff --stat', + 'git diff --name-only HEAD', + 'git status --porcelain=v1', + 'git diff --stat HEAD', + 'git -C . status --short', + 'git --no-pager diff --stat', + 'git ls-files --modified', + ]; + + for (const command of commands.slice(0, -1)) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command, + description: 'Inspect repository changes', + }), + ), + ).toBe(false); + } + + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command: commands.at(-1), + description: 'Inspect repository changes', + }), + ), + ).toBe(true); + expect(service.getLastLoopType()).toBe(LoopType.SHELL_COMMAND_STAGNATION); + expect(loggers.logLoopDetected).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'shell_command_stagnation', + }), + ); + }); + }); + describe('Content Loop Detection', () => { const generateRandomString = (length: number) => { let result = ''; diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index b81ceef151b..94bdf5fddfd 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -50,6 +50,12 @@ const FILE_READ_WINDOW = 15; // Action stagnation tracking const STAGNATION_THRESHOLD = 8; +// Similar shell inspection commands are precise enough to guard always-on +// when the model keeps rewriting read-only repository checks instead of +// making progress. Use the same threshold as the heuristic action-stagnation +// guard to leave room for legitimate branch-review inspection. +const SHELL_COMMAND_STAGNATION_THRESHOLD = STAGNATION_THRESHOLD; + // Global tool call duplicate tracking: how many times the same (tool, args) // pair must appear across the entire turn (not necessarily consecutively) // before it is treated as a loop. @@ -99,6 +105,12 @@ export class LoopDetectionService { private sameNameStreak = 0; private lastSeenToolName: string | null = null; + // Always-on shell inspection stagnation tracking. This is narrower than + // action stagnation: it only covers read-only git inspection commands that + // are semantically equivalent even when the shell text varies. + private lastShellInspectionKey: string | null = null; + private shellInspectionStreak = 0; + // Cold-start gate for READ_FILE_LOOP: the opening exploration of a prompt // is almost always read-heavy (list + parallel reads). Until at least one // non-read-like tool fires, a window full of reads is treated as legitimate @@ -285,6 +297,14 @@ export class LoopDetectionService { return true; } + if ( + !this.disabledForSession && + this.checkShellCommandStagnation(event.value) + ) { + this.loopDetected = true; + return true; + } + if (this.checkTurnToolCallCap()) { this.loopDetected = true; return true; @@ -314,6 +334,60 @@ export class LoopDetectionService { return false; } + private checkShellCommandStagnation(toolCall: { + name: string; + args: object; + }): boolean { + const key = this.getShellInspectionKey(toolCall); + if (!key) { + this.lastShellInspectionKey = null; + this.shellInspectionStreak = 0; + return false; + } + + if (this.lastShellInspectionKey === key) { + this.shellInspectionStreak++; + } else { + this.lastShellInspectionKey = key; + this.shellInspectionStreak = 1; + } + + if (this.shellInspectionStreak >= SHELL_COMMAND_STAGNATION_THRESHOLD) { + this.lastLoopType = LoopType.SHELL_COMMAND_STAGNATION; + logLoopDetected( + this.config, + new LoopDetectedEvent(LoopType.SHELL_COMMAND_STAGNATION, this.promptId), + ); + return true; + } + + return false; + } + + private getShellInspectionKey(toolCall: { + name: string; + args: object; + }): string | null { + if (toolCall.name !== 'run_shell_command') { + return null; + } + + const command = (toolCall.args as { command?: unknown }).command; + if (typeof command !== 'string') { + return null; + } + + return this.isGitInspectionCommand(command) + ? 'run_shell_command:git-inspection' + : null; + } + + private isGitInspectionCommand(command: string): boolean { + return /(?:^|[;&|]\s*)git(?:\s+(?:-C\s+\S+|--no-pager))*\s+(?:status|diff|ls-files)\b/i.test( + command, + ); + } + /** * Detects content loops by analyzing streaming text for repetitive patterns. * @@ -752,6 +826,8 @@ export class LoopDetectionService { private resetToolCallCount(): void { this.lastToolCallKey = null; this.toolCallRepetitionCount = 0; + this.lastShellInspectionKey = null; + this.shellInspectionStreak = 0; } private resetContentTracking(resetHistory = true): void { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 54e31ce5134..a2a2b2f49f8 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -435,6 +435,8 @@ export enum LoopType { REPETITIVE_THOUGHTS = 'repetitive_thoughts', READ_FILE_LOOP = 'read_file_loop', ACTION_STAGNATION = 'action_stagnation', + /** Similar read-only shell inspection commands repeat with varied args. */ + SHELL_COMMAND_STAGNATION = 'shell_command_stagnation', /** Same (tool, args) pair appears N times across the entire turn, not necessarily consecutively. */ GLOBAL_TOOL_CALL_DUPLICATE = 'global_tool_call_duplicate', /** Two tools alternating in a fixed pattern (A B A B A B ...). */ From 5aaec99d8b54e1c5c3bd5a06b1f6eac62c73ea34 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 28 Jun 2026 12:03:36 +0800 Subject: [PATCH 2/8] test(core): cover shell-stagnation streak reset, fix guard-count doc Address review on #5944: the checkAlwaysOnSafeties JSDoc still said it enforces two guards after the shell inspection-command stagnation guard was added, so correct it to three. Add a regression test proving a non-inspection tool call resets the shell-stagnation streak to zero, which is the guard's main false-positive defense and was previously untested. --- .../src/services/loopDetectionService.test.ts | 45 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 7 +-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 213531e16b4..5a7142c53a2 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -29,6 +29,7 @@ const CONTENT_CHUNK_SIZE = 50; // self-describing and failures point to the constant that changed. 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; @@ -261,6 +262,50 @@ describe('LoopDetectionService', () => { }), ); }); + + it('resets the streak when a non-inspection tool call interrupts the run', () => { + // Vary the command text so the consecutive-identical guard (threshold 5) + // never fires and only the shell-stagnation bucket accumulates. + const variants = [ + 'git status --short', + 'git diff --stat', + 'git ls-files --modified', + 'git status --porcelain=v1', + 'git diff --name-only HEAD', + 'git -C . status --short', + 'git --no-pager diff --stat', + ]; + const gitInspect = (i: number) => + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command: variants[i % variants.length], + description: 'Inspect repository changes', + }), + ); + + // One short of the threshold, so the next inspection alone would trip. + for (let i = 0; i < SHELL_COMMAND_STAGNATION_THRESHOLD - 1; i++) { + expect(gitInspect(i)).toBe(false); + } + + // A non-inspection tool call must reset the streak to zero. + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('read_file', { + absolute_path: '/repo/README.md', + }), + ), + ).toBe(false); + + // Counting restarts from zero: a full threshold-minus-one run of git + // inspections still does not trip, proving the streak did not carry over. + for (let i = 0; i < SHELL_COMMAND_STAGNATION_THRESHOLD - 1; i++) { + expect(gitInspect(i)).toBe(false); + } + expect(service.getLastLoopType()).not.toBe( + LoopType.SHELL_COMMAND_STAGNATION, + ); + }); }); describe('Content Loop Detection', () => { diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 94bdf5fddfd..bf27b89fb86 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -250,9 +250,10 @@ export class LoopDetectionService { /** * Always-on safety checks that fire regardless of the `skipLoopDetection` - * config default. Enforces two guards: the consecutive-identical tool-call - * loop and the per-turn tool-call cap. Call this before the gated heuristic - * checks so neither guard can be bypassed by configuration. + * 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. */ checkAlwaysOnSafeties(event: ServerGeminiStreamEvent): boolean { if (this.loopDetected) { From 2fd9ad001c83fe040b4791730bcfc9dcb7c5172f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 28 Jun 2026 12:11:21 +0800 Subject: [PATCH 3/8] fix(core): exclude write-bearing git chains from stagnation bucket isGitInspectionCommand matched if any segment of a shell chain was a git status/diff/ls-files, so a productive chain like `git add . && git status && git commit` was classified as read-only inspection. Eight such commands would falsely trip the always-on shell-stagnation guard. Require every segment of the chain to be a read-only git inspection before bucketing. Mixed chains that also write fail open (non-inspection), the safe direction for an always-on halt. The #4695 loop is still caught: its compound case is status && diff, both read-only. --- .../src/services/loopDetectionService.test.ts | 20 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 19 ++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 5a7142c53a2..5339e3619a9 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -306,6 +306,26 @@ describe('LoopDetectionService', () => { LoopType.SHELL_COMMAND_STAGNATION, ); }); + + it('does not bucket compound commands that also write to the repository', () => { + // Each chain stages and commits real work; the embedded `git status` must + // not classify the whole command as stagnant read-only inspection. Vary + // the path so the consecutive-identical guard never fires, isolating the + // shell-stagnation guard under test. + for (let i = 0; i < SHELL_COMMAND_STAGNATION_THRESHOLD; i++) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command: `git add file-${i}.txt && git status --short && git commit -m progress-${i}`, + description: 'Stage, inspect, and commit progress', + }), + ), + ).toBe(false); + } + expect(service.getLastLoopType()).not.toBe( + LoopType.SHELL_COMMAND_STAGNATION, + ); + }); }); describe('Content Loop Detection', () => { diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index bf27b89fb86..15ea7eed092 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -384,8 +384,23 @@ export class LoopDetectionService { } private isGitInspectionCommand(command: string): boolean { - return /(?:^|[;&|]\s*)git(?:\s+(?:-C\s+\S+|--no-pager))*\s+(?:status|diff|ls-files)\b/i.test( - command, + // Only classify a command as read-only inspection when *every* segment of + // the shell chain is a git status/diff/ls-files. A chain that also stages, + // commits, or runs another tool (e.g. `git add . && git status`) is making + // progress, so it must not share the stagnation bucket and trip a false + // halt. Failing open (treating mixed chains as non-inspection) is the safe + // direction for an always-on guard. + const segments = command + .split(/&&|\|\||[;&|]/) + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length === 0) { + return false; + } + return segments.every((segment) => + /^git(?:\s+(?:-C\s+\S+|--no-pager))*\s+(?:status|diff|ls-files)\b/i.test( + segment, + ), ); } From 08bd62d065bc481d9f5f862ff5137d7224f1a2e4 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 28 Jun 2026 01:18:04 +0800 Subject: [PATCH 4/8] fix(core): halt repeated shell inspection variants --- packages/cli/src/nonInteractiveCli.ts | 3 + .../src/services/loopDetectionService.test.ts | 72 +++++++++++++ .../core/src/services/loopDetectionService.ts | 100 ++++++++++++++++++ packages/core/src/telemetry/types.ts | 2 + 4 files changed, 177 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 3108cc87875..b48289fd331 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -112,6 +112,8 @@ const LOOP_TYPE_LABELS: Record = { 'the model spent too many consecutive calls reading files without making progress', [LoopType.ACTION_STAGNATION]: 'the model kept calling the same tool without making progress', + [LoopType.SHELL_COMMAND_STAGNATION]: + 'the model repeated similar shell inspection commands without making progress', [LoopType.GLOBAL_TOOL_CALL_DUPLICATE]: 'the model repeated the same tool call across the turn, even when not back-to-back', [LoopType.ALTERNATING_TOOL_CALL_PATTERN]: @@ -129,6 +131,7 @@ function formatLoopDetectedMessage(loopType: LoopType | undefined): string { 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; const hint = isAlwaysOn ? ' This is an always-on guard and cannot be disabled via `model.skipLoopDetection`.' diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index a3545bddf29..59be27ce4fd 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -221,6 +221,78 @@ describe('LoopDetectionService', () => { }); }); + describe('Shell Command Stagnation (Always-On Circuit Breaker)', () => { + it('halts repeated git inspection command variants via the always-on guard', () => { + const commands = [ + 'git status --short', + 'git status --short && git diff --stat', + 'git diff --name-only HEAD', + 'git status --porcelain=v1', + 'git diff --stat HEAD', + 'git -C . status --short', + 'git --no-pager diff --stat', + 'git ls-files --modified', + ]; + + for (const command of commands.slice(0, -1)) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command, + description: 'Inspect repository changes', + }), + ), + ).toBe(false); + } + + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command: commands.at(-1), + description: 'Inspect repository changes', + }), + ), + ).toBe(true); + expect(service.getLastLoopType()).toBe(LoopType.SHELL_COMMAND_STAGNATION); + expect(loggers.logLoopDetected).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'shell_command_stagnation', + }), + ); + }); + + it('does not halt file-specific git diff review commands', () => { + const commands = [ + 'git status --short', + 'git diff --stat', + 'git diff -- src/a.ts', + 'git diff -- src/b.ts', + 'git diff -- src/c.ts', + 'git diff -- src/d.ts', + 'git diff -- src/e.ts', + 'git diff -- src/f.ts', + ]; + + for (const command of commands) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command, + description: 'Inspect repository changes', + }), + ), + ).toBe(false); + } + expect(loggers.logLoopDetected).not.toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'shell_command_stagnation', + }), + ); + }); + }); + describe('Content Loop Detection', () => { const generateRandomString = (length: number) => { let result = ''; diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index b81ceef151b..7b307dc3802 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -50,6 +50,12 @@ const FILE_READ_WINDOW = 15; // Action stagnation tracking const STAGNATION_THRESHOLD = 8; +// Similar shell inspection commands are precise enough to guard always-on +// when the model keeps rewriting overview-style repository checks instead of +// making progress. Use the same threshold as the heuristic action-stagnation +// guard to leave room for legitimate branch-review inspection. +const SHELL_COMMAND_STAGNATION_THRESHOLD = STAGNATION_THRESHOLD; + // Global tool call duplicate tracking: how many times the same (tool, args) // pair must appear across the entire turn (not necessarily consecutively) // before it is treated as a loop. @@ -99,6 +105,12 @@ export class LoopDetectionService { private sameNameStreak = 0; private lastSeenToolName: string | null = null; + // Always-on shell inspection stagnation tracking. This is narrower than + // action stagnation: it only covers overview-style git inspection commands + // and excludes file-specific diffs that are normal during code review. + private lastShellInspectionKey: string | null = null; + private shellInspectionStreak = 0; + // Cold-start gate for READ_FILE_LOOP: the opening exploration of a prompt // is almost always read-heavy (list + parallel reads). Until at least one // non-read-like tool fires, a window full of reads is treated as legitimate @@ -285,6 +297,14 @@ export class LoopDetectionService { return true; } + if ( + !this.disabledForSession && + this.checkShellCommandStagnation(event.value) + ) { + this.loopDetected = true; + return true; + } + if (this.checkTurnToolCallCap()) { this.loopDetected = true; return true; @@ -314,6 +334,84 @@ export class LoopDetectionService { return false; } + private checkShellCommandStagnation(toolCall: { + name: string; + args: object; + }): boolean { + const key = this.getShellInspectionKey(toolCall); + if (!key) { + this.lastShellInspectionKey = null; + this.shellInspectionStreak = 0; + return false; + } + + if (this.lastShellInspectionKey === key) { + this.shellInspectionStreak++; + } else { + this.lastShellInspectionKey = key; + this.shellInspectionStreak = 1; + } + + if (this.shellInspectionStreak >= SHELL_COMMAND_STAGNATION_THRESHOLD) { + this.lastLoopType = LoopType.SHELL_COMMAND_STAGNATION; + logLoopDetected( + this.config, + new LoopDetectedEvent(LoopType.SHELL_COMMAND_STAGNATION, this.promptId), + ); + return true; + } + + return false; + } + + private getShellInspectionKey(toolCall: { + name: string; + args: object; + }): string | null { + if (toolCall.name !== 'run_shell_command') { + return null; + } + + const command = (toolCall.args as { command?: unknown }).command; + if (typeof command !== 'string') { + return null; + } + + return this.isGitOverviewInspectionCommand(command) + ? 'run_shell_command:git-inspection' + : null; + } + + private isGitOverviewInspectionCommand(command: string): boolean { + const segments = command + .split(/\s*(?:&&|\|\||[;|])\s*/) + .map((segment) => segment.trim()) + .filter(Boolean); + let hasGitInspection = false; + + for (const segment of segments) { + if (/^echo\b/i.test(segment)) { + continue; + } + + const match = + /^git(?:\s+(?:-C\s+\S+|--no-pager))*\s+(status|diff|ls-files)\b/i.exec( + segment, + ); + if (!match) { + return false; + } + + if (match[1]?.toLowerCase() === 'diff' && /\s--\s+\S/.test(segment)) { + return false; + } + + hasGitInspection = true; + } + + return hasGitInspection; + } + /** * Detects content loops by analyzing streaming text for repetitive patterns. * @@ -752,6 +850,8 @@ export class LoopDetectionService { private resetToolCallCount(): void { this.lastToolCallKey = null; this.toolCallRepetitionCount = 0; + this.lastShellInspectionKey = null; + this.shellInspectionStreak = 0; } private resetContentTracking(resetHistory = true): void { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 54e31ce5134..a2a2b2f49f8 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -435,6 +435,8 @@ export enum LoopType { REPETITIVE_THOUGHTS = 'repetitive_thoughts', READ_FILE_LOOP = 'read_file_loop', ACTION_STAGNATION = 'action_stagnation', + /** Similar read-only shell inspection commands repeat with varied args. */ + SHELL_COMMAND_STAGNATION = 'shell_command_stagnation', /** Same (tool, args) pair appears N times across the entire turn, not necessarily consecutively. */ GLOBAL_TOOL_CALL_DUPLICATE = 'global_tool_call_duplicate', /** Two tools alternating in a fixed pattern (A B A B A B ...). */ From c2c73a777cbba906318ce67c3fcc36a7d4802e53 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 28 Jun 2026 18:12:12 +0800 Subject: [PATCH 5/8] fix(core): treat path-specific git diffs as progress --- packages/core/src/core/client.ts | 7 ++-- .../src/services/loopDetectionService.test.ts | 30 +++++++++++++++ .../core/src/services/loopDetectionService.ts | 37 ++++++++++++++++++- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index b57446b734c..d60896a3bb4 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -2143,9 +2143,10 @@ export class GeminiClient { didUpdateIdeContextState = true; } - // Always-on safety checks (consecutive-identical tool-call guard + - // per-turn tool-call cap). These fire before the skipLoopDetection - // gate so they cannot be bypassed by configuration. + // Always-on safety checks (consecutive-identical tool-call guard, + // shell inspection stagnation, and per-turn tool-call cap). These fire + // before the skipLoopDetection gate so they cannot be bypassed by + // configuration. const alwaysOnLoop = this.loopDetector.checkAlwaysOnSafeties(event); if (alwaysOnLoop) { // Drop every tool call collected before the guard fired so the run diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index af49ed6c5f5..416199b6e24 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -356,6 +356,36 @@ describe('LoopDetectionService', () => { }), ); }); + + it('does not halt file-specific git diff review commands without -- separator', () => { + const commands = [ + 'git status --short', + 'git diff --stat', + 'git diff src/a.ts', + 'git diff src/b.ts', + 'git diff src/c.ts', + 'git diff src/d.ts', + 'git diff src/e.ts', + 'git diff src/f.ts', + ]; + + for (const command of commands) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command, + description: 'Inspect repository changes', + }), + ), + ).toBe(false); + } + expect(loggers.logLoopDetected).not.toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'shell_command_stagnation', + }), + ); + }); }); describe('Content Loop Detection', () => { diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 29dad4f82bb..6d7b2bd15e3 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -227,7 +227,7 @@ export class LoopDetectionService { // to avoid firing on a duplicated replay — e.g. 3 identical calls + // Retry + 3 more would otherwise hit the global-duplicate threshold of // 6. The always-on guards reset their own counters in - // checkAlwaysOnSafeties' Retry branch (cap rollback + consecutive + // checkAlwaysOnSafeties' Retry branch (cap rollback + always-on // streak reset). this.globalToolCallCounts.clear(); this.recentToolCallKeys = []; @@ -404,10 +404,43 @@ export class LoopDetectionService { if (!match) { return false; } - return match[1]?.toLowerCase() !== 'diff' || !/\s--\s+\S/.test(segment); + return ( + match[1]?.toLowerCase() !== 'diff' || + this.isOverviewGitDiff(segment.slice(match[0].length)) + ); }); } + private isOverviewGitDiff(args: string): boolean { + const trimmedArgs = args.trim(); + if (!trimmedArgs) { + return true; + } + + const tokens = trimmedArgs.split(/\s+/); + const pathspecSeparatorIndex = tokens.indexOf('--'); + if ( + pathspecSeparatorIndex !== -1 && + pathspecSeparatorIndex < tokens.length - 1 + ) { + return false; + } + + return tokens.every( + (token) => token.startsWith('-') || this.isGitRevisionToken(token), + ); + } + + private isGitRevisionToken(token: string): boolean { + return ( + token === 'HEAD' || + token === '@' || + /^(?:HEAD|@)(?:[~^]\d*)+$/.test(token) || + /^[0-9a-f]{7,40}$/i.test(token) || + /^[^\s]+\.{2,3}[^\s]+$/.test(token) + ); + } + /** * Detects content loops by analyzing streaming text for repetitive patterns. * From d8ab43fe1a10c9035f2bcccfcdadbb4de30c424d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 28 Jun 2026 18:25:52 +0800 Subject: [PATCH 6/8] test(core): cover shell stagnation guard boundaries --- .../src/services/loopDetectionService.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 416199b6e24..dbd2ea0db2e 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -307,6 +307,82 @@ describe('LoopDetectionService', () => { ); }); + it('resets the streak when a retry replays shell inspections', () => { + const variants = [ + 'git status --short', + 'git diff --stat', + 'git ls-files --modified', + 'git status --porcelain=v1', + 'git diff --name-only HEAD', + 'git -C . status --short', + 'git --no-pager diff --stat', + ]; + + for (const command of variants) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command, + description: 'Inspect repository changes', + }), + ), + ).toBe(false); + } + + expect( + service.checkAlwaysOnSafeties({ + type: GeminiEventType.Retry, + value: {}, + }), + ).toBe(false); + + for (const command of variants) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command, + description: 'Inspect repository changes', + }), + ), + ).toBe(false); + } + expect(service.getLastLoopType()).not.toBe( + LoopType.SHELL_COMMAND_STAGNATION, + ); + }); + + it('honors an in-session disable for shell inspection stagnation', () => { + service.disableForSession(); + + const variants = [ + 'git status --short', + 'git diff --stat', + 'git ls-files --modified', + 'git status --porcelain=v1', + 'git diff --name-only HEAD', + 'git -C . status --short', + 'git --no-pager diff --stat', + 'git ls-files --others', + ]; + + for (const command of variants) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command, + description: 'Inspect repository changes', + }), + ), + ).toBe(false); + } + expect(loggers.logLoopDetected).not.toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'shell_command_stagnation', + }), + ); + }); + it('does not bucket compound commands that also write to the repository', () => { // Each chain stages and commits real work; the embedded `git status` must // not classify the whole command as stagnant read-only inspection. Vary @@ -327,6 +403,22 @@ describe('LoopDetectionService', () => { ); }); + it('does not bucket shell chains that include non-git commands', () => { + for (let i = 0; i < SHELL_COMMAND_STAGNATION_THRESHOLD; i++) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command: `git status --short && npm test -- --runInBand=${i}`, + description: 'Inspect repository changes and run tests', + }), + ), + ).toBe(false); + } + expect(service.getLastLoopType()).not.toBe( + LoopType.SHELL_COMMAND_STAGNATION, + ); + }); + it('does not halt file-specific git diff review commands', () => { const commands = [ 'git status --short', From 9f50530212892a6f244c05344e88d840732aad77 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 28 Jun 2026 18:44:06 +0800 Subject: [PATCH 7/8] test(core): address shell stagnation review feedback --- .../src/services/loopDetectionService.test.ts | 53 ++++++++++++++++++- .../core/src/services/loopDetectionService.ts | 2 +- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index dbd2ea0db2e..1350e4bd51a 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -332,8 +332,7 @@ describe('LoopDetectionService', () => { expect( service.checkAlwaysOnSafeties({ type: GeminiEventType.Retry, - value: {}, - }), + } as ServerGeminiStreamEvent), ).toBe(false); for (const command of variants) { @@ -419,6 +418,56 @@ describe('LoopDetectionService', () => { ); }); + it('does not halt repeated non-git shell commands', () => { + for (let i = 0; i < SHELL_COMMAND_STAGNATION_THRESHOLD + 2; i++) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command: `npm test -- --runInBand=${i}`, + description: 'Run tests', + }), + ), + ).toBe(false); + } + expect(service.getLastLoopType()).not.toBe( + LoopType.SHELL_COMMAND_STAGNATION, + ); + }); + + it('halts newline-separated git inspection command variants', () => { + const commands = [ + 'git diff --stat\ngit status --short', + 'git diff --name-only HEAD\ngit ls-files --modified', + 'git --no-pager diff --stat\ngit status --porcelain=v1', + 'git diff --stat HEAD\ngit ls-files --others', + 'git diff --name-only\ngit status --short', + 'git diff --stat\ngit -C . status --short', + 'git --no-pager diff --stat\ngit ls-files --modified', + 'git diff --name-only HEAD\ngit status --short', + ]; + + for (const command of commands.slice(0, -1)) { + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command, + description: 'Inspect repository changes', + }), + ), + ).toBe(false); + } + + expect( + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('run_shell_command', { + command: commands.at(-1), + description: 'Inspect repository changes', + }), + ), + ).toBe(true); + expect(service.getLastLoopType()).toBe(LoopType.SHELL_COMMAND_STAGNATION); + }); + it('does not halt file-specific git diff review commands', () => { const commands = [ 'git status --short', diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 6d7b2bd15e3..fd6df93c361 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -390,7 +390,7 @@ export class LoopDetectionService { // making progress, so it must not share the stagnation bucket and trip a // false halt. Failing open is the safe direction for an always-on guard. const segments = command - .split(/&&|\|\||[;&|]/) + .split(/&&|\|\||[;&|\n]/) .map((segment) => segment.trim()) .filter(Boolean); if (segments.length === 0) { From f86b74ed6c44f1ee1729dcc47a9e93796576e1e4 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 28 Jun 2026 18:54:27 +0800 Subject: [PATCH 8/8] docs(core): sync loop guard comments --- packages/core/src/core/client.ts | 7 ++++--- packages/core/src/services/loopDetectionService.ts | 13 +++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index d60896a3bb4..08e3de63070 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -2176,9 +2176,10 @@ export class GeminiClient { // interruptions. Only the historically false-positive-prone heuristics // (content/thought repetition, read-file and action stagnation, // global-duplicate and alternating tool-call patterns) sit behind this - // flag. The precise consecutive-identical guard and the per-turn cap - // run unconditionally in checkAlwaysOnSafeties above, so the documented - // escape hatch only relaxes the heuristics (see nonInteractiveCli.ts). + // flag. The precise consecutive-identical guard, shell inspection + // stagnation guard, and per-turn cap run unconditionally in + // checkAlwaysOnSafeties above, so the documented escape hatch only + // relaxes the heuristics (see nonInteractiveCli.ts). const skipLoopDetection = this.config.getSkipLoopDetection(); const heuristicLoop = !skipLoopDetection && diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index fd6df93c361..8900af3cf92 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -178,12 +178,13 @@ export class LoopDetectionService { /** * Convenience aggregate that runs every tier in order: the always-on - * safeties (consecutive-identical guard + per-turn cap) followed by the - * opt-in heuristics. Intended as a single "check everything" entry point for - * unit tests. Production code (client.ts) intentionally calls the tiers - * separately so the `skipLoopDetection` gate can sit between them — a new - * guard added here will NOT take effect in production unless it is also - * wired into checkAlwaysOnSafeties or addAndCheckHeuristicLoops. + * safeties (consecutive-identical guard, shell inspection-command + * stagnation guard, and per-turn cap) followed by the opt-in heuristics. + * Intended as a single "check everything" entry point for unit tests. + * Production code (client.ts) intentionally calls the tiers separately so + * the `skipLoopDetection` gate can sit between them — a new guard added here + * will NOT take effect in production unless it is also wired into + * checkAlwaysOnSafeties or addAndCheckHeuristicLoops. * @param event - The stream event to process * @returns true if any tier detects a loop, false otherwise */