From f0c3c81e94f04720daf0661b28369e8699a1266a Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 3 Nov 2025 18:06:22 -0800 Subject: [PATCH] fix(core): Improve loop detection for longer repeating patterns (#12505) --- .../src/services/loopDetectionService.test.ts | 76 +++++++++++++++++++ .../core/src/services/loopDetectionService.ts | 6 +- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 41cb10a1fb..ce8c1bbb8d 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -207,6 +207,82 @@ describe('LoopDetectionService', () => { expect(isLoop).toBe(false); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); + + it('should detect a loop with longer repeating patterns (e.g. ~150 chars)', () => { + service.reset(''); + const longPattern = createRepetitiveContent(1, 150); + expect(longPattern.length).toBe(150); + + let isLoop = false; + for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) { + isLoop = service.addAndCheck(createContentEvent(longPattern)); + if (isLoop) break; + } + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); + }); + + it('should detect the specific user-provided loop example', () => { + service.reset(''); + const userPattern = `I will not output any text. + I will just end the turn. + I am done. + I will not do anything else. + I will wait for the user's next command. +`; + + let isLoop = false; + // Loop enough times to trigger the threshold + for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 5; i++) { + isLoop = service.addAndCheck(createContentEvent(userPattern)); + if (isLoop) break; + } + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); + }); + + it('should detect the second specific user-provided loop example', () => { + service.reset(''); + const userPattern = + 'I have added all the requested logs and verified the test file. I will now mark the task as complete.\n '; + + let isLoop = false; + for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 5; i++) { + isLoop = service.addAndCheck(createContentEvent(userPattern)); + if (isLoop) break; + } + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); + }); + + it('should detect a loop of alternating short phrases', () => { + service.reset(''); + const alternatingPattern = 'Thinking... Done. '; + + let isLoop = false; + // Needs more iterations because the pattern is short relative to chunk size, + // so it takes a few slides of the window to find the exact alignment. + for (let i = 0; i < CONTENT_LOOP_THRESHOLD * 3; i++) { + isLoop = service.addAndCheck(createContentEvent(alternatingPattern)); + if (isLoop) break; + } + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); + }); + + it('should detect a loop of repeated complex thought processes', () => { + service.reset(''); + const thoughtPattern = + 'I need to check the file. The file does not exist. I will create the file. '; + + let isLoop = false; + for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 5; i++) { + isLoop = service.addAndCheck(createContentEvent(thoughtPattern)); + if (isLoop) break; + } + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); + }); }); describe('Content Loop Detection with Code Blocks', () => { diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index a850816648..3448b6c0c6 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -28,7 +28,7 @@ import { debugLogger } from '../utils/debugLogger.js'; const TOOL_CALL_LOOP_THRESHOLD = 5; const CONTENT_LOOP_THRESHOLD = 10; const CONTENT_CHUNK_SIZE = 50; -const MAX_HISTORY_LENGTH = 1000; +const MAX_HISTORY_LENGTH = 5000; /** * The number of recent conversation turns to include in the history when asking the LLM to check for a loop. @@ -328,7 +328,7 @@ export class LoopDetectionService { * 2. Verify actual content matches to prevent hash collisions * 3. Track all positions where this chunk appears * 4. A loop is detected when the same chunk appears CONTENT_LOOP_THRESHOLD times - * within a small average distance (≤ 1.5 * chunk size) + * within a small average distance (≤ 5 * chunk size) */ private isLoopDetectedForChunk(chunk: string, hash: string): boolean { const existingIndices = this.contentStats.get(hash); @@ -353,7 +353,7 @@ export class LoopDetectionService { const totalDistance = recentIndices[recentIndices.length - 1] - recentIndices[0]; const averageDistance = totalDistance / (CONTENT_LOOP_THRESHOLD - 1); - const maxAllowedDistance = CONTENT_CHUNK_SIZE * 1.5; + const maxAllowedDistance = CONTENT_CHUNK_SIZE * 5; return averageDistance <= maxAllowedDistance; }