mirror of
https://github.com/cline/cline.git
synced 2026-09-15 04:14:34 +08:00
Merge branch 'main' into prompts-library-mvp
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user