Skip to content
Draft
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
123 changes: 122 additions & 1 deletion src/core/tools/ToolRepetitionDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,28 @@ import { t } from "../../i18n"
/**
* Class for detecting consecutive identical tool calls
* to prevent the AI from getting stuck in a loop.
*
* Also includes path-based detection for read_file to catch cases where
* the model reads the same file with different parameters (e.g., different line ranges).
*/
export class ToolRepetitionDetector {
private previousToolCallJson: string | null = null
private consecutiveIdenticalToolCallCount: number = 0
private readonly consecutiveIdenticalToolCallLimit: number

// Path-based tracking for read_file
private previousReadFilePaths: string | null = null
private consecutiveReadFilePathCount: number = 0
private readonly readFilePathLimit: number

/**
* Creates a new ToolRepetitionDetector
* @param limit The maximum number of identical consecutive tool calls allowed
* @param readFilePathLimit The maximum number of consecutive read_file calls for the same file path (default: same as limit)
*/
constructor(limit: number = 3) {
constructor(limit: number = 3, readFilePathLimit?: number) {
this.consecutiveIdenticalToolCallLimit = limit
this.readFilePathLimit = readFilePathLimit ?? limit
}

/**
Expand All @@ -40,6 +50,12 @@ export class ToolRepetitionDetector {
return { allowExecution: true }
}

// Check path-based repetition for read_file
const pathRepetitionResult = this.checkReadFilePathRepetition(currentToolCallBlock)
if (!pathRepetitionResult.allowExecution) {
return pathRepetitionResult
}

// Serialize the block to a canonical JSON string for comparison
const currentToolCallJson = this.serializeToolUse(currentToolCallBlock)

Expand Down Expand Up @@ -74,6 +90,111 @@ export class ToolRepetitionDetector {
return { allowExecution: true }
}

/**
* Checks for path-based repetition specifically for read_file tool.
* This catches cases where the model reads the same file with different parameters
* (e.g., different line ranges), which would not be caught by identical call detection.
*
* @param toolUse The ToolUse object to check
* @returns Object indicating if execution is allowed and a message to show if not
*/
private checkReadFilePathRepetition(toolUse: ToolUse): {
allowExecution: boolean
askUser?: {
messageKey: string
messageDetail: string
}
} {
// Only apply to read_file tool
if (toolUse.name !== "read_file") {
// Reset path tracking when switching to a different tool
this.previousReadFilePaths = null
this.consecutiveReadFilePathCount = 0
return { allowExecution: true }
}

// Extract file paths from the tool use
const currentPaths = this.extractReadFilePaths(toolUse)

// Compare with previous paths
if (this.previousReadFilePaths === currentPaths) {
this.consecutiveReadFilePathCount++
} else {
this.consecutiveReadFilePathCount = 0
this.previousReadFilePaths = currentPaths
}

// Check if limit is reached (0 means unlimited)
if (this.readFilePathLimit > 0 && this.consecutiveReadFilePathCount >= this.readFilePathLimit) {
// Reset counters to allow recovery if user guides the AI past this point
this.consecutiveReadFilePathCount = 0
this.previousReadFilePaths = null

// Return result indicating execution should not be allowed
return {
allowExecution: false,
askUser: {
messageKey: "mistake_limit_reached",
messageDetail: t("tools:readFilePathRepetitionLimitReached", { toolName: toolUse.name }),
},
}
}

return { allowExecution: true }
}

/**
* Extracts file paths from a read_file tool use.
* Handles both params-based and nativeArgs-based formats.
*
* @param toolUse The read_file ToolUse object
* @returns A canonical string representation of the file paths
*/
private extractReadFilePaths(toolUse: ToolUse): string {
const paths: string[] = []

// Check nativeArgs first (native protocol format)
if (toolUse.nativeArgs && typeof toolUse.nativeArgs === "object") {
const nativeArgs = toolUse.nativeArgs as { files?: Array<{ path?: string }> }
if (nativeArgs.files && Array.isArray(nativeArgs.files)) {
for (const file of nativeArgs.files) {
if (file.path) {
paths.push(file.path)
}
}
}
}

// Check params (legacy format or if nativeArgs didn't have files)
if (paths.length === 0 && toolUse.params) {
// Single file path
if (toolUse.params.path) {
paths.push(toolUse.params.path as string)
}
// Multiple files format (params.files is a JSON array string or array)
if (toolUse.params.files) {
const files = toolUse.params.files
if (typeof files === "string") {
try {
const parsed = JSON.parse(files)
if (Array.isArray(parsed)) {
for (const file of parsed) {
if (file.path) {
paths.push(file.path)
}
}
}
} catch {
// Ignore parse errors
}
}
}
}

// Sort paths for consistent comparison
return paths.sort().join("|")
}

/**
* Checks if a tool use is a browser scroll action
*
Expand Down
Loading
Loading