@@ -2,14 +2,21 @@ import { ToolUse } from "../../shared/tools"
22import { t } from "../../i18n"
33
44/**
5- * Class for detecting consecutive identical tool calls
6- * to prevent the AI from getting stuck in a loop .
5+ * Class for detecting both consecutive and non-consecutive repetitive tool call patterns
6+ * to prevent the AI from getting stuck in loops .
77 */
88export class ToolRepetitionDetector {
99 private previousToolCallJson : string | null = null
1010 private consecutiveIdenticalToolCallCount : number = 0
1111 private readonly consecutiveIdenticalToolCallLimit : number
1212
13+ // Enhanced pattern detection for non-continuous repetitions
14+ private toolCallHistory : string [ ] = [ ]
15+ private readonly maxHistorySize : number = 20 // Keep last 20 tool calls for pattern detection
16+ private readonly minPatternLength : number = 2 // Minimum pattern length (e.g., AB)
17+ private readonly maxPatternLength : number = 6 // Maximum pattern length (e.g., ABCDEF)
18+ private readonly minPatternRepetitions : number = 2 // Minimum repetitions to consider it a loop
19+
1320 /**
1421 * Creates a new ToolRepetitionDetector
1522 * @param limit The maximum number of identical consecutive tool calls allowed
@@ -19,7 +26,7 @@ export class ToolRepetitionDetector {
1926 }
2027
2128 /**
22- * Checks if the current tool call is identical to the previous one
29+ * Checks if the current tool call is identical to the previous one or forms a repetitive pattern
2330 * and determines if execution should be allowed
2431 *
2532 * @param currentToolCallBlock ToolUse object representing the current tool call
@@ -35,19 +42,21 @@ export class ToolRepetitionDetector {
3542 // Serialize the block to a canonical JSON string for comparison
3643 const currentToolCallJson = this . serializeToolUse ( currentToolCallBlock )
3744
38- // Compare with previous tool call
45+ // Add to history for pattern detection
46+ this . addToHistory ( currentToolCallJson )
47+
48+ // Check for consecutive repetitions (existing behavior)
3949 if ( this . previousToolCallJson === currentToolCallJson ) {
4050 this . consecutiveIdenticalToolCallCount ++
4151 } else {
4252 this . consecutiveIdenticalToolCallCount = 1 // Start with 1 for the first occurrence
4353 this . previousToolCallJson = currentToolCallJson
4454 }
4555
46- // Check if limit is reached
56+ // Check if consecutive limit is reached
4757 if ( this . consecutiveIdenticalToolCallCount >= this . consecutiveIdenticalToolCallLimit ) {
4858 // Reset counters to allow recovery if user guides the AI past this point
49- this . consecutiveIdenticalToolCallCount = 0
50- this . previousToolCallJson = null
59+ this . resetState ( )
5160
5261 // Return result indicating execution should not be allowed
5362 return {
@@ -59,6 +68,21 @@ export class ToolRepetitionDetector {
5968 }
6069 }
6170
71+ // Check for non-consecutive repetitive patterns
72+ const patternDetectionResult = this . detectRepetitivePattern ( )
73+ if ( patternDetectionResult . isRepetitive ) {
74+ // Reset state to allow recovery
75+ this . resetState ( )
76+
77+ return {
78+ allowExecution : false ,
79+ askUser : {
80+ messageKey : "mistake_limit_reached" ,
81+ messageDetail : t ( "tools:toolRepetitionLimitReached" , { toolName : currentToolCallBlock . name } ) ,
82+ } ,
83+ }
84+ }
85+
6286 // Execution is allowed
6387 return { allowExecution : true }
6488 }
@@ -92,4 +116,106 @@ export class ToolRepetitionDetector {
92116 // Convert to a canonical JSON string
93117 return JSON . stringify ( toolObject )
94118 }
119+
120+ /**
121+ * Adds a tool call to the history for pattern detection
122+ * @param toolCallJson Serialized tool call JSON
123+ */
124+ private addToHistory ( toolCallJson : string ) : void {
125+ this . toolCallHistory . push ( toolCallJson )
126+
127+ // Keep history size manageable
128+ if ( this . toolCallHistory . length > this . maxHistorySize ) {
129+ this . toolCallHistory . shift ( ) // Remove oldest entry
130+ }
131+ }
132+
133+ /**
134+ * Resets the internal state of the detector
135+ */
136+ private resetState ( ) : void {
137+ this . consecutiveIdenticalToolCallCount = 0
138+ this . previousToolCallJson = null
139+ // Clear history to prevent false positives after reset
140+ this . toolCallHistory = [ ]
141+ }
142+
143+ /**
144+ * Detects repetitive patterns in the tool call history
145+ * @returns Object indicating if a repetitive pattern was detected
146+ */
147+ private detectRepetitivePattern ( ) : { isRepetitive : boolean ; pattern ?: string [ ] } {
148+ // Need at least enough history to detect a pattern
149+ if ( this . toolCallHistory . length < this . minPatternLength * this . minPatternRepetitions ) {
150+ return { isRepetitive : false }
151+ }
152+
153+ // Try different pattern lengths, starting from the smallest
154+ for ( let patternLength = this . minPatternLength ; patternLength <= this . maxPatternLength ; patternLength ++ ) {
155+ // Don't check patterns longer than what we can fit with minimum repetitions
156+ if ( patternLength * this . minPatternRepetitions > this . toolCallHistory . length ) {
157+ break
158+ }
159+
160+ // Check if the last N elements form a repeating pattern
161+ if ( this . isRepeatingPattern ( patternLength ) ) {
162+ const pattern = this . toolCallHistory . slice ( - patternLength )
163+ return { isRepetitive : true , pattern }
164+ }
165+ }
166+
167+ return { isRepetitive : false }
168+ }
169+
170+ /**
171+ * Checks if the end of the history forms a repeating pattern of the given length
172+ * @param patternLength Length of the pattern to check
173+ * @returns True if a repeating pattern is found
174+ */
175+ private isRepeatingPattern ( patternLength : number ) : boolean {
176+ const totalLength = patternLength * this . minPatternRepetitions
177+ if ( this . toolCallHistory . length < totalLength ) {
178+ return false
179+ }
180+
181+ // Get the pattern from the end
182+ const pattern = this . toolCallHistory . slice ( - patternLength )
183+
184+ // Check if this pattern repeats the required number of times
185+ for ( let i = 1 ; i < this . minPatternRepetitions ; i ++ ) {
186+ const startIndex = this . toolCallHistory . length - ( i + 1 ) * patternLength
187+ const endIndex = this . toolCallHistory . length - i * patternLength
188+
189+ if ( startIndex < 0 ) {
190+ return false
191+ }
192+
193+ const segment = this . toolCallHistory . slice ( startIndex , endIndex )
194+ if ( ! this . arraysEqual ( segment , pattern ) ) {
195+ return false
196+ }
197+ }
198+
199+ return true
200+ }
201+
202+ /**
203+ * Utility method to check if two arrays are equal
204+ * @param arr1 First array
205+ * @param arr2 Second array
206+ * @returns True if arrays are equal
207+ */
208+ private arraysEqual ( arr1 : string [ ] , arr2 : string [ ] ) : boolean {
209+ if ( arr1 . length !== arr2 . length ) {
210+ return false
211+ }
212+
213+ for ( let i = 0 ; i < arr1 . length ; i ++ ) {
214+ if ( arr1 [ i ] !== arr2 [ i ] ) {
215+ return false
216+ }
217+ }
218+
219+ return true
220+ }
95221}
0 commit comments