feat: add tool-level file cache with LRU eviction and mtime invalidation
Introduces FileCache (OrderedDict LRU, st_mtime_ns validation) to avoid redundant disk reads and duplicate content in conversation context. Read tools return a short "[Cached]" message on cache hit instead of resending unchanged file content, saving tokens. Write/edit/delete tools invalidate affected paths; str_replace pre-warms the cache after edits. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
"""Filesystem tools: read_file, list_dir, write_file, make_dir, delete_file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.config import AppConfig
|
||||
from app.models.tool_call import ToolResult, ToolResultStatus
|
||||
from app.tools.base import BaseTool
|
||||
from app.utils.file_cache import FileCache, cached_read_file
|
||||
from app.utils.file_helpers import (
|
||||
BinaryFileError,
|
||||
FileSizeError,
|
||||
@@ -36,14 +40,22 @@ class ReadFileTool(BaseTool):
|
||||
description = "Read the full contents of a text file. Returns the file content as a string."
|
||||
params_model = ReadFileParams
|
||||
|
||||
def __init__(
|
||||
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
|
||||
) -> None:
|
||||
super().__init__(workspace_root, config)
|
||||
self._file_cache = file_cache
|
||||
|
||||
def execute(self, *, tool_call_id: str, file_path: str, **kwargs: Any) -> ToolResult:
|
||||
fs_config = self.config.tools.filesystem
|
||||
hits_before = self._file_cache.stats.hits if self._file_cache else 0
|
||||
try:
|
||||
content = safe_read_file(
|
||||
content = cached_read_file(
|
||||
file_path,
|
||||
self.workspace_root,
|
||||
max_size_bytes=fs_config.max_file_size_bytes,
|
||||
check_binary=fs_config.binary_detection,
|
||||
cache=self._file_cache,
|
||||
)
|
||||
except PathSecurityError as exc:
|
||||
return ToolResult(
|
||||
@@ -74,6 +86,23 @@ class ReadFileTool(BaseTool):
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
# On cache hit the file is unchanged — its content is already in
|
||||
# conversation context from the earlier read, so avoid resending it.
|
||||
was_cache_hit = (
|
||||
self._file_cache is not None
|
||||
and self._file_cache.stats.hits > hits_before
|
||||
)
|
||||
if was_cache_hit:
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
status=ToolResultStatus.SUCCESS,
|
||||
output=(
|
||||
f"[Cached] {file_path} is unchanged since last read "
|
||||
f"({len(content):,} chars). Content is already in conversation context."
|
||||
),
|
||||
)
|
||||
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
@@ -92,6 +121,12 @@ class ReadManyFilesTool(BaseTool):
|
||||
)
|
||||
params_model = ReadManyFilesParams
|
||||
|
||||
def __init__(
|
||||
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
|
||||
) -> None:
|
||||
super().__init__(workspace_root, config)
|
||||
self._file_cache = file_cache
|
||||
|
||||
def execute(self, *, tool_call_id: str, file_paths: list[str], **kwargs: Any) -> ToolResult:
|
||||
if not file_paths:
|
||||
return ToolResult(
|
||||
@@ -106,14 +141,26 @@ class ReadManyFilesTool(BaseTool):
|
||||
success_count = 0
|
||||
|
||||
for fp in file_paths:
|
||||
hits_before = self._file_cache.stats.hits if self._file_cache else 0
|
||||
try:
|
||||
content = safe_read_file(
|
||||
content = cached_read_file(
|
||||
fp,
|
||||
self.workspace_root,
|
||||
max_size_bytes=fs_config.max_file_size_bytes,
|
||||
check_binary=fs_config.binary_detection,
|
||||
cache=self._file_cache,
|
||||
)
|
||||
sections.append(f"=== {fp} ===\n{content}")
|
||||
was_hit = (
|
||||
self._file_cache is not None
|
||||
and self._file_cache.stats.hits > hits_before
|
||||
)
|
||||
if was_hit:
|
||||
sections.append(
|
||||
f"=== {fp} ===\n[Cached] Unchanged since last read "
|
||||
f"({len(content):,} chars). Already in conversation context."
|
||||
)
|
||||
else:
|
||||
sections.append(f"=== {fp} ===\n{content}")
|
||||
success_count += 1
|
||||
except (PathSecurityError, FileNotFoundError, FileSizeError, BinaryFileError) as exc:
|
||||
sections.append(f"=== {fp} ===\n[ERROR] {exc}")
|
||||
@@ -225,6 +272,12 @@ class WriteFileTool(BaseTool):
|
||||
)
|
||||
params_model = WriteFileParams
|
||||
|
||||
def __init__(
|
||||
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
|
||||
) -> None:
|
||||
super().__init__(workspace_root, config)
|
||||
self._file_cache = file_cache
|
||||
|
||||
def execute(self, *, tool_call_id: str, file_path: str, content: str, **kwargs: Any) -> ToolResult:
|
||||
fs_config = self.config.tools.filesystem
|
||||
try:
|
||||
@@ -249,6 +302,9 @@ class WriteFileTool(BaseTool):
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
if self._file_cache is not None:
|
||||
self._file_cache.invalidate(safe_path)
|
||||
|
||||
try:
|
||||
rel_path = safe_path.relative_to(self.workspace_root)
|
||||
except ValueError:
|
||||
@@ -330,6 +386,12 @@ class DeleteFileTool(BaseTool):
|
||||
description = "Delete a single file. Does not delete directories."
|
||||
params_model = DeleteFileParams
|
||||
|
||||
def __init__(
|
||||
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
|
||||
) -> None:
|
||||
super().__init__(workspace_root, config)
|
||||
self._file_cache = file_cache
|
||||
|
||||
def execute(self, *, tool_call_id: str, file_path: str, **kwargs: Any) -> ToolResult:
|
||||
try:
|
||||
safe_path = resolve_safe_path(file_path, self.workspace_root)
|
||||
@@ -367,6 +429,9 @@ class DeleteFileTool(BaseTool):
|
||||
error=f"Failed to delete file: {exc}",
|
||||
)
|
||||
|
||||
if self._file_cache is not None:
|
||||
self._file_cache.invalidate(safe_path)
|
||||
|
||||
try:
|
||||
rel_path = safe_path.relative_to(self.workspace_root)
|
||||
except ValueError:
|
||||
|
||||
Reference in New Issue
Block a user