diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 4d6139f69f..f40e897a84 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -175,6 +175,93 @@ describe('LoopDetectionService', () => { } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); + + it('should detect an alternating tool call loop (cycle of length 2)', () => { + const eventA = createToolCallRequestEvent('read_file', { + file_path: 'loop_a.txt', + }); + const eventB = createToolCallRequestEvent('read_file', { + file_path: 'loop_b.txt', + }); + + let loopCount = 0; + for (let i = 0; i < 15; i++) { + const currentEvent = i % 2 === 0 ? eventA : eventB; + const result = service.addAndCheck(currentEvent); + if (result.count > 0) { + loopCount = result.count; + } + } + + expect(loopCount).toBeGreaterThan(0); + }); + + it('should detect a cyclic tool call loop of length 3', () => { + const eventA = createToolCallRequestEvent('read_file', { + file_path: 'loop_a.txt', + }); + const eventB = createToolCallRequestEvent('read_file', { + file_path: 'loop_b.txt', + }); + const eventC = createToolCallRequestEvent('read_file', { + file_path: 'loop_c.txt', + }); + + let loopCount = 0; + const sequence = [eventA, eventB, eventC]; + for (let i = 0; i < 20; i++) { + const result = service.addAndCheck(sequence[i % 3]); + if (result.count > 0) { + loopCount = result.count; + } + } + + expect(loopCount).toBeGreaterThan(0); + }); + + it('should detect a cyclic tool call loop of length 5', () => { + const e1 = createToolCallRequestEvent('t', { id: 1 }); + const e2 = createToolCallRequestEvent('t', { id: 2 }); + const e3 = createToolCallRequestEvent('t', { id: 3 }); + const e4 = createToolCallRequestEvent('t', { id: 4 }); + const e5 = createToolCallRequestEvent('t', { id: 5 }); + + let loopCount = 0; + const sequence = [e1, e2, e3, e4, e5]; + for (let i = 0; i < 30; i++) { + const result = service.addAndCheck(sequence[i % 5]); + if (result.count > 0) { + loopCount = result.count; + } + } + + expect(loopCount).toBeGreaterThan(0); + }); + + it('should not detect loops for non-looping alternating sequences that vary', () => { + const eventA = createToolCallRequestEvent('read_file', { + file_path: 'loop_a.txt', + }); + const eventB = createToolCallRequestEvent('read_file', { + file_path: 'loop_b.txt', + }); + const eventC = createToolCallRequestEvent('read_file', { + file_path: 'loop_c.txt', + }); + + // Run some alternating calls, then break the pattern with eventC + for (let i = 0; i < 4; i++) { + expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe( + 0, + ); + } + expect(service.addAndCheck(eventC).count).toBe(0); + for (let i = 0; i < 4; i++) { + expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe( + 0, + ); + } + }); }); describe('Content Loop Detection', () => { diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 53030911b0..62c693aef1 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -136,8 +136,7 @@ export class LoopDetectionService { private userPrompt = ''; // Tool call tracking - private lastToolCallKey: string | null = null; - private toolCallRepetitionCount: number = 0; + private toolCallHistory: string[] = []; // Content streaming tracking private streamContentHistory = ''; @@ -313,15 +312,39 @@ export class LoopDetectionService { private checkToolCallLoop(toolCall: { name: string; args: object }): boolean { const key = this.getToolCallKey(toolCall); - if (this.lastToolCallKey === key) { - this.toolCallRepetitionCount++; - } else { - this.lastToolCallKey = key; - this.toolCallRepetitionCount = 1; + this.toolCallHistory.push(key); + + const maxRequiredLength = 5 * TOOL_CALL_LOOP_THRESHOLD; + if (this.toolCallHistory.length > maxRequiredLength) { + this.toolCallHistory = this.toolCallHistory.slice(-maxRequiredLength); } - if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) { - return true; + + const n = this.toolCallHistory.length; + const R = TOOL_CALL_LOOP_THRESHOLD; // 5 + + // Check for repeating patterns of cycle length k from 1 to 5 + for (let k = 1; k <= 5; k++) { + const requiredLength = k * R; + if (n >= requiredLength) { + const cycle = this.toolCallHistory.slice(-k); + let isPatternMatch = true; + + for (let i = 0; i < requiredLength; i++) { + const indexFromEnd = requiredLength - i; + const actualKey = this.toolCallHistory[n - indexFromEnd]; + const expectedKey = cycle[i % k]; + if (actualKey !== expectedKey) { + isPatternMatch = false; + break; + } + } + + if (isPatternMatch) { + return true; + } + } } + return false; } @@ -739,8 +762,7 @@ export class LoopDetectionService { } private resetToolCallCount(): void { - this.lastToolCallKey = null; - this.toolCallRepetitionCount = 0; + this.toolCallHistory = []; } private resetContentTracking(resetHistory = true): void {