diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 59862e0a4a..4bfd0df099 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -210,6 +210,25 @@ describe('LoopDetectionService', () => { expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); }); + it('should not detect a loop for a list with a long shared prefix', () => { + service.reset(''); + let isLoop = false; + const longPrefix = + 'projects/my-google-cloud-project-12345/locations/us-central1/services/'; + + let listContent = ''; + for (let i = 0; i < 15; i++) { + listContent += `- ${longPrefix}${i}\n`; + } + + // Simulate receiving the list in a single large chunk or a few chunks + // This is the specific case where the issue occurs, as list boundaries might not reset tracking properly + isLoop = service.addAndCheck(createContentEvent(listContent)); + + expect(isLoop).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + it('should not detect a loop if repetitions are very far apart', () => { service.reset(''); const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 23541a3903..2e4a73cf03 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -379,7 +379,30 @@ export class LoopDetectionService { const averageDistance = totalDistance / (CONTENT_LOOP_THRESHOLD - 1); const maxAllowedDistance = CONTENT_CHUNK_SIZE * 5; - return averageDistance <= maxAllowedDistance; + if (averageDistance > maxAllowedDistance) { + return false; + } + + // Verify that the sequence is actually repeating, not just sharing a common prefix. + // For a true loop, the text between occurrences of the chunk (the period) should be highly repetitive. + const periods = new Set(); + for (let i = 0; i < recentIndices.length - 1; i++) { + periods.add( + this.streamContentHistory.substring( + recentIndices[i], + recentIndices[i + 1], + ), + ); + } + + // If the periods are mostly unique, it's a list of distinct items with a shared prefix. + // A true loop will have a small number of unique periods (usually 1, sometimes 2 or 3). + // We use Math.floor(CONTENT_LOOP_THRESHOLD / 2) as a safe threshold. + if (periods.size > Math.floor(CONTENT_LOOP_THRESHOLD / 2)) { + return false; + } + + return true; } /**