@@ -5,18 +5,28 @@ import { t } from "../../i18n"
55/**
66 * Class for detecting consecutive identical tool calls
77 * to prevent the AI from getting stuck in a loop.
8+ *
9+ * Also includes path-based detection for read_file to catch cases where
10+ * the model reads the same file with different parameters (e.g., different line ranges).
811 */
912export class ToolRepetitionDetector {
1013 private previousToolCallJson : string | null = null
1114 private consecutiveIdenticalToolCallCount : number = 0
1215 private readonly consecutiveIdenticalToolCallLimit : number
1316
17+ // Path-based tracking for read_file
18+ private previousReadFilePaths : string | null = null
19+ private consecutiveReadFilePathCount : number = 0
20+ private readonly readFilePathLimit : number
21+
1422 /**
1523 * Creates a new ToolRepetitionDetector
1624 * @param limit The maximum number of identical consecutive tool calls allowed
25+ * @param readFilePathLimit The maximum number of consecutive read_file calls for the same file path (default: same as limit)
1726 */
18- constructor ( limit : number = 3 ) {
27+ constructor ( limit : number = 3 , readFilePathLimit ?: number ) {
1928 this . consecutiveIdenticalToolCallLimit = limit
29+ this . readFilePathLimit = readFilePathLimit ?? limit
2030 }
2131
2232 /**
@@ -40,6 +50,12 @@ export class ToolRepetitionDetector {
4050 return { allowExecution : true }
4151 }
4252
53+ // Check path-based repetition for read_file
54+ const pathRepetitionResult = this . checkReadFilePathRepetition ( currentToolCallBlock )
55+ if ( ! pathRepetitionResult . allowExecution ) {
56+ return pathRepetitionResult
57+ }
58+
4359 // Serialize the block to a canonical JSON string for comparison
4460 const currentToolCallJson = this . serializeToolUse ( currentToolCallBlock )
4561
@@ -74,6 +90,111 @@ export class ToolRepetitionDetector {
7490 return { allowExecution : true }
7591 }
7692
93+ /**
94+ * Checks for path-based repetition specifically for read_file tool.
95+ * This catches cases where the model reads the same file with different parameters
96+ * (e.g., different line ranges), which would not be caught by identical call detection.
97+ *
98+ * @param toolUse The ToolUse object to check
99+ * @returns Object indicating if execution is allowed and a message to show if not
100+ */
101+ private checkReadFilePathRepetition ( toolUse : ToolUse ) : {
102+ allowExecution : boolean
103+ askUser ?: {
104+ messageKey : string
105+ messageDetail : string
106+ }
107+ } {
108+ // Only apply to read_file tool
109+ if ( toolUse . name !== "read_file" ) {
110+ // Reset path tracking when switching to a different tool
111+ this . previousReadFilePaths = null
112+ this . consecutiveReadFilePathCount = 0
113+ return { allowExecution : true }
114+ }
115+
116+ // Extract file paths from the tool use
117+ const currentPaths = this . extractReadFilePaths ( toolUse )
118+
119+ // Compare with previous paths
120+ if ( this . previousReadFilePaths === currentPaths ) {
121+ this . consecutiveReadFilePathCount ++
122+ } else {
123+ this . consecutiveReadFilePathCount = 0
124+ this . previousReadFilePaths = currentPaths
125+ }
126+
127+ // Check if limit is reached (0 means unlimited)
128+ if ( this . readFilePathLimit > 0 && this . consecutiveReadFilePathCount >= this . readFilePathLimit ) {
129+ // Reset counters to allow recovery if user guides the AI past this point
130+ this . consecutiveReadFilePathCount = 0
131+ this . previousReadFilePaths = null
132+
133+ // Return result indicating execution should not be allowed
134+ return {
135+ allowExecution : false ,
136+ askUser : {
137+ messageKey : "mistake_limit_reached" ,
138+ messageDetail : t ( "tools:readFilePathRepetitionLimitReached" , { toolName : toolUse . name } ) ,
139+ } ,
140+ }
141+ }
142+
143+ return { allowExecution : true }
144+ }
145+
146+ /**
147+ * Extracts file paths from a read_file tool use.
148+ * Handles both params-based and nativeArgs-based formats.
149+ *
150+ * @param toolUse The read_file ToolUse object
151+ * @returns A canonical string representation of the file paths
152+ */
153+ private extractReadFilePaths ( toolUse : ToolUse ) : string {
154+ const paths : string [ ] = [ ]
155+
156+ // Check nativeArgs first (native protocol format)
157+ if ( toolUse . nativeArgs && typeof toolUse . nativeArgs === "object" ) {
158+ const nativeArgs = toolUse . nativeArgs as { files ?: Array < { path ?: string } > }
159+ if ( nativeArgs . files && Array . isArray ( nativeArgs . files ) ) {
160+ for ( const file of nativeArgs . files ) {
161+ if ( file . path ) {
162+ paths . push ( file . path )
163+ }
164+ }
165+ }
166+ }
167+
168+ // Check params (legacy format or if nativeArgs didn't have files)
169+ if ( paths . length === 0 && toolUse . params ) {
170+ // Single file path
171+ if ( toolUse . params . path ) {
172+ paths . push ( toolUse . params . path as string )
173+ }
174+ // Multiple files format (params.files is a JSON array string or array)
175+ if ( toolUse . params . files ) {
176+ const files = toolUse . params . files
177+ if ( typeof files === "string" ) {
178+ try {
179+ const parsed = JSON . parse ( files )
180+ if ( Array . isArray ( parsed ) ) {
181+ for ( const file of parsed ) {
182+ if ( file . path ) {
183+ paths . push ( file . path )
184+ }
185+ }
186+ }
187+ } catch {
188+ // Ignore parse errors
189+ }
190+ }
191+ }
192+ }
193+
194+ // Sort paths for consistent comparison
195+ return paths . sort ( ) . join ( "|" )
196+ }
197+
77198 /**
78199 * Checks if a tool use is a browser scroll action
79200 *
0 commit comments