From bf71632bbdc6aee7d8ada3caf37a7df647be7ba2 Mon Sep 17 00:00:00 2001 From: aheizi Date: Wed, 9 Jul 2025 11:40:45 +0800 Subject: [PATCH] feat: extend the tool to call the duplicate detection function to support pattern duplicate detection --- src/core/tools/ToolRepetitionDetector.ts | 143 ++++++++++++++-- .../__tests__/ToolRepetitionDetector.spec.ts | 154 ++++++++++++++++++ 2 files changed, 283 insertions(+), 14 deletions(-) diff --git a/src/core/tools/ToolRepetitionDetector.ts b/src/core/tools/ToolRepetitionDetector.ts index 927b031e3b..edc16af4b3 100644 --- a/src/core/tools/ToolRepetitionDetector.ts +++ b/src/core/tools/ToolRepetitionDetector.ts @@ -2,20 +2,33 @@ import { ToolUse } from "../../shared/tools" import { t } from "../../i18n" /** - * Class for detecting consecutive identical tool calls - * to prevent the AI from getting stuck in a loop. + * Class for detecting tool call repetition patterns + * to prevent the AI from getting stuck in loops. + * Can detect both consecutive identical calls and pattern repetitions like "abcabc". */ export class ToolRepetitionDetector { + private static readonly HISTORY_RETENTION_ON_RESET = 5 + private previousToolCallJson: string | null = null private consecutiveIdenticalToolCallCount: number = 0 private readonly consecutiveIdenticalToolCallLimit: number + private toolCallHistory: string[] = [] + private readonly historyMaxLength: number + private readonly patternRepetitionLimit: number + /** * Creates a new ToolRepetitionDetector - * @param limit The maximum number of identical consecutive tool calls allowed + * @param consecutiveLimit The maximum number of identical consecutive tool calls allowed. + * Setting this to 0 disables ALL repetition detection (both consecutive and pattern). + * Negative values will be treated as 0. + * @param historyLength The maximum length of tool call history to maintain + * @param patternLimit The maximum number of pattern repetitions allowed */ - constructor(limit: number = 3) { - this.consecutiveIdenticalToolCallLimit = limit + constructor(consecutiveLimit: number = 3, historyLength: number = 20, patternLimit: number = 2) { + this.consecutiveIdenticalToolCallLimit = Math.max(0, consecutiveLimit) + this.historyMaxLength = Math.max(1, Math.min(historyLength, 1000)) // reasonable upper bound + this.patternRepetitionLimit = Math.max(0, patternLimit) } /** @@ -32,25 +45,33 @@ export class ToolRepetitionDetector { messageDetail: string } } { + // Skip ALL repetition checks when consecutive limit is 0 (unlimited) + // This disables both consecutive identical tool call detection AND pattern repetition detection + if (this.consecutiveIdenticalToolCallLimit <= 0) { + return { allowExecution: true } + } + // Serialize the block to a canonical JSON string for comparison const currentToolCallJson = this.serializeToolUse(currentToolCallBlock) - // Compare with previous tool call + // Update history record only when detection is enabled + this.updateHistory(currentToolCallJson) + + // Check for consecutive identical tool calls if (this.previousToolCallJson === currentToolCallJson) { this.consecutiveIdenticalToolCallCount++ } else { - this.consecutiveIdenticalToolCallCount = 1 // Start with 1 for the first occurrence + this.consecutiveIdenticalToolCallCount = 1 // First occurrence of new tool this.previousToolCallJson = currentToolCallJson } - // Check if limit is reached (0 means unlimited) - if ( - this.consecutiveIdenticalToolCallLimit > 0 && - this.consecutiveIdenticalToolCallCount >= this.consecutiveIdenticalToolCallLimit - ) { + // Check for pattern repetition + const patternRepetition = this.detectPatternRepetition() + + // If any type of repetition is detected, prevent execution + if (this.consecutiveIdenticalToolCallCount >= this.consecutiveIdenticalToolCallLimit || patternRepetition) { // Reset counters to allow recovery if user guides the AI past this point - this.consecutiveIdenticalToolCallCount = 0 - this.previousToolCallJson = null + this.resetState() // Return result indicating execution should not be allowed return { @@ -66,6 +87,100 @@ export class ToolRepetitionDetector { return { allowExecution: true } } + /** + * Updates the tool call history with the latest call + * @param toolCallJson The serialized tool call to add to history + */ + private updateHistory(toolCallJson: string): void { + this.toolCallHistory.push(toolCallJson) + + // Keep history within maximum length + if (this.toolCallHistory.length > this.historyMaxLength) { + this.toolCallHistory.shift() // Remove oldest entry + } + } + + /** + * Detects repeating patterns in the tool call history + * @returns true if a pattern repetition is detected beyond the allowed limit + */ + private detectPatternRepetition(): boolean { + const history = this.toolCallHistory + if (history.length < 4) { + // Need at least 4 elements to detect a pattern (minimum pattern length is 2, repeated at least twice) + return false + } + + // Check patterns of various lengths + // Start with longer patterns to avoid false positives with short patterns + const maxPatternLength = Math.floor(history.length / 2) + + for (let patternLength = 2; patternLength <= maxPatternLength; patternLength++) { + // Check for repeating patterns of current length + if (this.hasRepeatingPattern(history, patternLength)) { + return true + } + } + + return false + } + + /** + * Checks if the history contains a repeating pattern of specified length + * @param history Array of serialized tool calls + * @param patternLength Length of the pattern to check + * @returns true if a repeating pattern is found beyond the allowed limit + */ + private hasRepeatingPattern(history: string[], patternLength: number): boolean { + if (patternLength <= 0 || history.length < patternLength * 2) { + return false + } + + // Get the most recent pattern + const recentPattern = history.slice(history.length - patternLength) + + // Count how many times this pattern repeats consecutively + let repetitionCount = 1 // Start with 1 for the pattern itself + let position = history.length - patternLength - 1 + + while (position >= 0) { + let isMatch = true + + // Check if the current segment matches the pattern + for (let i = 0; i < patternLength; i++) { + if (position - i < 0 || history[position - i] !== recentPattern[patternLength - 1 - i]) { + isMatch = false + break + } + } + + if (isMatch) { + repetitionCount++ + position -= patternLength + + // If we've found enough repetitions, return true + if (repetitionCount > this.patternRepetitionLimit) { + return true + } + } else { + // If we find a non-matching segment, stop searching + break + } + } + + return false + } + + /** + * Resets the detector state to recover from repetition detection + */ + private resetState(): void { + this.consecutiveIdenticalToolCallCount = 0 + this.previousToolCallJson = null + // Keep last N entries for context + this.toolCallHistory = this.toolCallHistory.slice(-ToolRepetitionDetector.HISTORY_RETENTION_ON_RESET) + } + /** * Serializes a ToolUse object into a canonical JSON string for comparison * diff --git a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts index 42041c1a46..ce794a7f37 100644 --- a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts +++ b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts @@ -357,5 +357,159 @@ describe("ToolRepetitionDetector", () => { expect(result.askUser).toBeUndefined() } }) + + it("should support custom consecutive limit of 4", () => { + const detector = new ToolRepetitionDetector(4) + + // First call (counter = 1) + let result = detector.check(createToolUse("tool", "tool-name")) + expect(result.allowExecution).toBe(true) + + // Second call (counter = 2) + result = detector.check(createToolUse("tool", "tool-name")) + expect(result.allowExecution).toBe(true) + + // Third call (counter = 3) + result = detector.check(createToolUse("tool", "tool-name")) + expect(result.allowExecution).toBe(true) + + // Fourth call (counter = 4) should be blocked + result = detector.check(createToolUse("tool", "tool-name")) + expect(result.allowExecution).toBe(false) + expect(result.askUser).toBeDefined() + }) + }) + + // ===== Pattern Repetition Detection tests ===== + describe("pattern repetition detection", () => { + it("should detect simple alternating pattern (ABABAB)", () => { + // Use custom limits: 3 for consecutive, 20 for history, 2 for pattern + const detector = new ToolRepetitionDetector(3, 20, 2) + + // Create alternating pattern: A-B-A-B-A-B + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + detector.check(createToolUse("toolA", "tool-A")) + + // This should trigger pattern detection (AB repeated 3 times) + const result = detector.check(createToolUse("toolB", "tool-B")) + + expect(result.allowExecution).toBe(false) + expect(result.askUser).toBeDefined() + expect(result.askUser?.messageKey).toBe("mistake_limit_reached") + }) + + it("should detect complex pattern repetition (ABCABCABC)", () => { + // Use custom limits: 3 for consecutive, 20 for history, 2 for pattern + const detector = new ToolRepetitionDetector(3, 20, 2) + + // Create pattern: A-B-C-A-B-C-A-B-C + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + detector.check(createToolUse("toolC", "tool-C")) + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + detector.check(createToolUse("toolC", "tool-C")) + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + + // This should trigger pattern detection (ABC repeated 3 times) + const result = detector.check(createToolUse("toolC", "tool-C")) + + expect(result.allowExecution).toBe(false) + expect(result.askUser).toBeDefined() + expect(result.askUser?.messageKey).toBe("mistake_limit_reached") + }) + + it("should not detect pattern when repetition is below limit", () => { + // Use custom limits: 3 for consecutive, 20 for history, 2 for pattern (need 3 repetitions to trigger) + const detector = new ToolRepetitionDetector(3, 20, 2) + + // Create pattern: A-B-C-A-B-C (only 2 repetitions) + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + detector.check(createToolUse("toolC", "tool-C")) + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + + // This should NOT trigger pattern detection (ABC repeated only 2 times) + const result = detector.check(createToolUse("toolC", "tool-C")) + + expect(result.allowExecution).toBe(true) + expect(result.askUser).toBeUndefined() + }) + + it("should respect custom pattern repetition limit", () => { + // Use custom limits: 3 for consecutive, 20 for history, 1 for pattern (need only 2 repetitions) + const detector = new ToolRepetitionDetector(3, 20, 1) + + // Create pattern: A-B-A-B (only 2 repetitions) + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + detector.check(createToolUse("toolA", "tool-A")) + + // This should trigger pattern detection with lower limit (AB repeated 2 times) + const result = detector.check(createToolUse("toolB", "tool-B")) + + expect(result.allowExecution).toBe(false) + expect(result.askUser).toBeDefined() + expect(result.askUser?.messageKey).toBe("mistake_limit_reached") + }) + + it("should retain history after reset and continue pattern detection", () => { + // Use custom limits: 3 for consecutive, 20 for history, 2 for pattern + const detector = new ToolRepetitionDetector(3, 20, 2) + + // Create pattern: A-B-A-B-A-B + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + detector.check(createToolUse("toolA", "tool-A")) + detector.check(createToolUse("toolB", "tool-B")) + detector.check(createToolUse("toolA", "tool-A")) + + // This should trigger pattern detection + const result1 = detector.check(createToolUse("toolB", "tool-B")) + expect(result1.allowExecution).toBe(false) + + // After reset, history is retained (last 5 entries: [B,A,B,A,B]) + // Adding toolA creates [B,A,B,A,B,A] which still contains B-A pattern repeated 3 times + // This should still trigger pattern detection due to history retention + const result2 = detector.check(createToolUse("toolA", "tool-A")) + expect(result2.allowExecution).toBe(false) + }) + + it("should detect patterns with different parameter values", () => { + // Use custom limits: 3 for consecutive, 20 for history, 2 for pattern + const detector = new ToolRepetitionDetector(3, 20, 2) + + // Create pattern with different parameters but same tool names + // Create pattern with same parameters to ensure detection + detector.check(createToolUse("toolA", "tool-A", { param: "value1" })) + detector.check(createToolUse("toolB", "tool-B", { param: "value2" })) + detector.check(createToolUse("toolA", "tool-A", { param: "value1" })) + detector.check(createToolUse("toolB", "tool-B", { param: "value2" })) + detector.check(createToolUse("toolA", "tool-A", { param: "value1" })) + + // This should trigger pattern detection with same parameters + const result = detector.check(createToolUse("toolB", "tool-B", { param: "value2" })) + + expect(result.allowExecution).toBe(false) + expect(result.askUser).toBeDefined() + }) + + it("should not detect pattern when same tool is used with different parameters in non-repeating way", () => { + const detector = new ToolRepetitionDetector(3, 20, 2) + + // A(1)-A(2)-A(3)-A(4) - same tool, different params each time + detector.check(createToolUse("toolA", "tool-A", { param: "value1" })) + detector.check(createToolUse("toolA", "tool-A", { param: "value2" })) + detector.check(createToolUse("toolA", "tool-A", { param: "value3" })) + + const result = detector.check(createToolUse("toolA", "tool-A", { param: "value4" })) + + expect(result.allowExecution).toBe(true) + }) }) })