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:
2026-03-11 22:26:51 -05:00
parent 2c532adbbc
commit d829e6553c
8 changed files with 623 additions and 10 deletions

View File

@@ -1,13 +1,17 @@
"""Edit tools: str_replace and patch_apply."""
from __future__ import annotations
import subprocess
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 (
FileSizeError,
PathSecurityError,
@@ -37,6 +41,12 @@ class StrReplaceTool(BaseTool):
)
params_model = StrReplaceParams
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, old_str: str, new_str: str, **kwargs: Any
) -> ToolResult:
@@ -44,11 +54,12 @@ class StrReplaceTool(BaseTool):
# Read the file
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(
@@ -117,8 +128,14 @@ class StrReplaceTool(BaseTool):
safe_path = resolve_safe_path(file_path, self.workspace_root)
rel_path = safe_path.relative_to(self.workspace_root)
except (PathSecurityError, ValueError):
safe_path = None
rel_path = Path(file_path)
# Pre-warm cache with the new content (we already have it in memory).
if self._file_cache is not None and safe_path is not None:
self._file_cache.invalidate(safe_path)
self._file_cache.put(safe_path, new_content)
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
@@ -144,6 +161,12 @@ class PatchApplyTool(BaseTool):
)
params_model = PatchApplyParams
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, patch: str, **kwargs: Any) -> ToolResult:
try:
safe_path = resolve_safe_path(file_path, self.workspace_root)
@@ -195,6 +218,9 @@ class PatchApplyTool(BaseTool):
error=f"Patch failed (exit {result.returncode}): {result.stderr or result.stdout}",
)
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:

View File

@@ -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:

View File

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
from app.models.config import AppConfig
from app.tools.base import BaseTool
from app.utils.file_cache import FileCache
if TYPE_CHECKING:
from app.services.skills import SkillsManager
@@ -89,6 +90,7 @@ def create_default_registry(
config: AppConfig,
skills_manager: SkillsManager | None = None,
skill_runner: object | None = None,
file_cache: FileCache | None = None,
) -> ToolRegistry:
"""Create a ToolRegistry populated with all built-in tools.
@@ -97,6 +99,7 @@ def create_default_registry(
config: Application configuration.
skills_manager: Optional skills manager for skill tools.
skill_runner: Optional SkillRunner for package skill activation.
file_cache: Optional file cache shared across file-reading tools.
"""
# Read tools
from app.tools.filesystem import ListDirTool, ReadFileTool, ReadManyFilesTool
@@ -119,8 +122,8 @@ def create_default_registry(
registry = ToolRegistry()
# Read
registry.register(ReadFileTool(workspace_root, config))
registry.register(ReadManyFilesTool(workspace_root, config))
registry.register(ReadFileTool(workspace_root, config, file_cache=file_cache))
registry.register(ReadManyFilesTool(workspace_root, config, file_cache=file_cache))
registry.register(ListDirTool(workspace_root, config))
# Search
@@ -128,13 +131,13 @@ def create_default_registry(
registry.register(FindFilesTool(workspace_root, config))
# Write
registry.register(WriteFileTool(workspace_root, config))
registry.register(WriteFileTool(workspace_root, config, file_cache=file_cache))
registry.register(MakeDirTool(workspace_root, config))
registry.register(DeleteFileTool(workspace_root, config))
registry.register(DeleteFileTool(workspace_root, config, file_cache=file_cache))
# Edit
registry.register(StrReplaceTool(workspace_root, config))
registry.register(PatchApplyTool(workspace_root, config))
registry.register(StrReplaceTool(workspace_root, config, file_cache=file_cache))
registry.register(PatchApplyTool(workspace_root, config, file_cache=file_cache))
# Shell
registry.register(RunCommandTool(workspace_root, config))