Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 129 additions & 14 deletions src/core/tools/ToolRepetitionDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding parameter validation in the constructor. This would prevent issue if someone passes negative values or extremely large values for historyLength or patternLimit.

For example:

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)
}

This would prevent potential issues with invalid parameters.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

this.consecutiveIdenticalToolCallLimit = Math.max(0, consecutiveLimit)
this.historyMaxLength = Math.max(1, Math.min(historyLength, 1000)) // reasonable upper bound
this.patternRepetitionLimit = Math.max(0, patternLimit)
}

/**
Expand All @@ -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 {
Expand All @@ -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
*
Expand Down
154 changes: 154 additions & 0 deletions src/core/tools/__tests__/ToolRepetitionDetector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that after pattern detection triggers and resets, the next tool call still fails (line 468-469). Is this intentional?

If the goal of resetState() is to allow recovery after hitting a limit, shouldn't the next call be allowed? The current behavior could trap users in a situation where they can't recover from pattern detection.

Could you clarify the intended behavior here? If this is intentional, it might be worth adding a comment explaining why.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment explaining why.

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)
})
})
})