Merge branch 'main' into prompts-library-mvp

This commit is contained in:
Tony Loehr
2026-03-18 12:19:01 -07:00
committed by GitHub
5 changed files with 95 additions and 0 deletions
+4
View File
@@ -45,6 +45,10 @@ export class TaskState {
didEditFile = false
lastToolName = "" // Track last tool used for consecutive call detection
// File read deduplication cache - prevents the model from endlessly reading the same files
// Maps absolute file path → { readCount: times read in this task, mtime: last modified timestamp, imageBlock: optional image data for multimodal models }
fileReadCache: Map<string, { readCount: number; mtime: number; imageBlock?: Anthropic.ImageBlockParam }> = new Map()
// Error tracking
consecutiveMistakeCount = 0
doubleCheckCompletionPending = false
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises"
import { resolve as resolvePath } from "node:path"
import type { ToolUse } from "@core/assistant-message"
import { resolveWorkspacePath } from "@core/workspace"
import { processFilesIntoText } from "@integrations/misc/extract-text"
@@ -335,6 +336,13 @@ export class ApplyPatchHandler implements IFullyManagedTool {
const pathToTrack = change.type === PatchActionType.UPDATE && change.movePath ? change.movePath : changedFilePath
config.services.fileContextTracker.markFileAsEditedByCline(pathToTrack)
await config.services.fileContextTracker.trackFileContext(pathToTrack, "cline_edited")
// Invalidate file read cache for all changed files so re-reads get fresh content
config.taskState.fileReadCache.delete(resolvePath(config.cwd, pathToTrack).toLowerCase())
// Also invalidate old path for move operations
if (change.type === PatchActionType.UPDATE && change.movePath) {
config.taskState.fileReadCache.delete(resolvePath(config.cwd, changedFilePath).toLowerCase())
}
}
this.config = undefined
@@ -348,6 +356,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
for (const [path, result] of Object.entries(applyResults)) {
if (result.deleted) {
config.taskState.didEditFile = true
// Note: cache invalidation for deleted files is already handled in the changedFiles loop above
responseLines.push(`\n${path}: [deleted]`)
} else {
// Format response similar to WriteToFileToolHandler
@@ -312,6 +312,16 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
clearTimeout(timeoutId)
}
// Invalidate the entire file read cache after any command execution.
// Bash commands can modify files in ways we can't predict (sed, npm install, git checkout, mv, etc.),
// so we must clear the cache to prevent stale reads.
// Invalidate the entire file read cache after any command execution.
// Bash commands can modify files in ways we can't predict (sed, npm install, git checkout, mv, etc.),
// so we must clear the cache to prevent stale reads.
if (!userRejected) {
config.taskState.fileReadCache.clear()
}
if (userRejected) {
config.taskState.didRejectTool = true
}
@@ -167,6 +167,61 @@ export class ReadFileToolHandler implements IFullyManagedTool {
throw error
}
// === File Read Deduplication ===
// Check if we've already read this exact file in this task.
// This prevents the model from endlessly reading the same file, which wastes API tokens.
// The cache stores only metadata (readCount, mtime, imageBlock) — not file content —
// to keep memory usage minimal. On cache hits we re-read from disk to return fresh content.
const cacheKey = absolutePath.toLowerCase()
const cached = config.taskState.fileReadCache.get(cacheKey)
if (cached) {
// Check if the file has been modified externally (e.g. user edited in their editor)
// by comparing the mtime. If it changed, treat this as a fresh read.
try {
const stat = await import("node:fs/promises").then((fs) => fs.stat(absolutePath))
if (stat.mtimeMs !== cached.mtime) {
// File was modified externally — evict cache entry and fall through to fresh read
config.taskState.fileReadCache.delete(cacheKey)
}
} catch {
// If we can't stat the file, evict the cache and let extractFileContent handle the error
config.taskState.fileReadCache.delete(cacheKey)
}
}
// Re-check after possible mtime eviction
const validCached = config.taskState.fileReadCache.get(cacheKey)
if (validCached) {
validCached.readCount++
// Re-push image block for multimodal models so image context is not lost on cached reads
if (validCached.imageBlock) {
config.taskState.userMessageContent.push(validCached.imageBlock)
}
// Re-read from disk (cache doesn't store content to save memory)
const supportsImages = config.api.getModel().info.supportsImages ?? false
let fileContent: FileContentResult
try {
fileContent = await extractFileContent(absolutePath, supportsImages)
} catch (error) {
config.taskState.consecutiveMistakeCount++
const errorMessage = error instanceof Error ? error.message : String(error)
const normalizedMessage = errorMessage.startsWith("Error reading file:")
? errorMessage
: `Error reading file: ${errorMessage}`
return formatResponse.toolError(normalizedMessage)
}
if (validCached.readCount >= 3) {
return `[DUPLICATE READ] You have already read '${displayPath}' ${validCached.readCount} times in this conversation. The content has not changed since your last read. Please use the information you already have and proceed with your task.\n\n${fileContent.text}`
}
return `[File already read] The file '${displayPath}' was already read earlier in this conversation. Returning content:\n${fileContent.text}`
}
// Execute the actual file read operation
const supportsImages = config.api.getModel().info.supportsImages ?? false
let fileContent: FileContentResult
@@ -191,6 +246,20 @@ export class ReadFileToolHandler implements IFullyManagedTool {
// Track file read operation
await config.services.fileContextTracker.trackFileContext(relPath!, "read_tool")
// Cache metadata for deduplication (no content stored — saves memory)
let mtime = 0
try {
const stat = await import("node:fs/promises").then((fs) => fs.stat(absolutePath))
mtime = stat.mtimeMs
} catch {
// If stat fails, use 0 — the next cache hit will evict due to mtime mismatch
}
config.taskState.fileReadCache.set(cacheKey, {
readCount: 1,
mtime,
imageBlock: fileContent.imageBlock,
})
// Handle image blocks separately - they need to be pushed to userMessageContent
if (fileContent.imageBlock) {
config.taskState.userMessageContent.push(fileContent.imageBlock)
@@ -361,6 +361,9 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
config.taskState.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
// Invalidate file read cache for this file so re-reads get fresh content
config.taskState.fileReadCache.delete(absolutePath.toLowerCase())
// Track file edit operation
await config.services.fileContextTracker.trackFileContext(relPath, "cline_edited")