libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py PYTHON 1,172 lines View on github.com → Search inside
1"""Anthropic text editor and memory tool middleware.23This module provides client-side implementations of Anthropic's text editor and4memory tools using schema-less tool definitions and tool call interception.5"""67from __future__ import annotations89import os10import shutil11from datetime import datetime, timezone12from pathlib import Path13from typing import TYPE_CHECKING, Annotated, Any, cast1415from langchain.agents.middleware.types import (16    AgentMiddleware,17    AgentState,18    ModelRequest,19    ModelResponse,20    _ModelRequestOverrides,21)22from langchain.tools import ToolRuntime, tool23from langchain_core.messages import SystemMessage, ToolMessage24from langgraph.types import Command25from typing_extensions import NotRequired, TypedDict2627if TYPE_CHECKING:28    from collections.abc import Awaitable, Callable, Sequence293031# Tool type constants32TEXT_EDITOR_TOOL_TYPE = "text_editor_20250728"33TEXT_EDITOR_TOOL_NAME = "str_replace_based_edit_tool"34MEMORY_TOOL_TYPE = "memory_20250818"35MEMORY_TOOL_NAME = "memory"3637MEMORY_SYSTEM_PROMPT = """IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE \38DOING ANYTHING ELSE.39MEMORY PROTOCOL:401. Use the `view` command of your `memory` tool to check for earlier progress.412. ... (work on the task) ...42   - As you make progress, record status / progress / thoughts etc in your memory.43ASSUME INTERRUPTION: Your context window might be reset at any moment, so you risk \44losing any progress that is not recorded in your memory directory."""454647class FileData(TypedDict):48    """Data structure for storing file contents."""4950    content: list[str]51    """Lines of the file."""5253    created_at: str54    """ISO 8601 timestamp of file creation."""5556    modified_at: str57    """ISO 8601 timestamp of last modification."""585960def files_reducer(61    left: dict[str, FileData] | None, right: dict[str, FileData | None]62) -> dict[str, FileData]:63    """Custom reducer that merges file updates.6465    Args:66        left: Existing files dict.67        right: New files dict to merge (`None` values delete files).6869    Returns:70        Merged `dict` where right overwrites left for matching keys.71    """72    if left is None:73        # Filter out None values when initializing74        return {k: v for k, v in right.items() if v is not None}7576    # Merge, filtering out None values (deletions)77    result = {**left}78    for k, v in right.items():79        if v is None:80            result.pop(k, None)81        else:82            result[k] = v83    return result848586class AnthropicToolsState(AgentState):87    """State schema for Anthropic text editor and memory tools."""8889    text_editor_files: NotRequired[Annotated[dict[str, FileData], files_reducer]]90    """Virtual file system for text editor tools."""9192    memory_files: NotRequired[Annotated[dict[str, FileData], files_reducer]]93    """Virtual file system for memory tools."""949596def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str:97    """Validate and normalize file path for security.9899    Args:100        path: The path to validate.101        allowed_prefixes: Optional list of allowed path prefixes.102103    Returns:104        Normalized canonical path.105106    Raises:107        ValueError: If path contains traversal sequences or violates prefix rules.108    """109    # Reject paths with traversal attempts110    if ".." in path or path.startswith("~"):111        msg = f"Path traversal not allowed: {path}"112        raise ValueError(msg)113114    # Normalize path (resolve ., //, etc.)115    normalized = os.path.normpath(path)116117    # Convert to forward slashes for consistency118    normalized = normalized.replace("\\", "/")119120    # Ensure path starts with /121    if not normalized.startswith("/"):122        normalized = f"/{normalized}"123124    # Check allowed prefixes if specified125    if allowed_prefixes is not None and not any(126        normalized.startswith(prefix) for prefix in allowed_prefixes127    ):128        msg = f"Path must start with one of {allowed_prefixes}: {path}"129        raise ValueError(msg)130131    return normalized132133134def _list_directory(files: dict[str, FileData], path: str) -> list[str]:135    """List files in a directory.136137    Args:138        files: Files `dict`.139        path: Normalized directory path.140141    Returns:142        Sorted list of file paths in the directory.143    """144    # Ensure path ends with / for directory matching145    dir_path = path if path.endswith("/") else f"{path}/"146147    matching_files = []148    for file_path in files:149        if file_path.startswith(dir_path):150            # Get relative path from directory151            relative = file_path[len(dir_path) :]152            # Only include direct children (no subdirectories)153            if "/" not in relative:154                matching_files.append(file_path)155156    return sorted(matching_files)157158159class _StateClaudeFileToolMiddleware(AgentMiddleware):160    """Base class for state-based file tool middleware (internal)."""161162    state_schema = AnthropicToolsState163164    def __init__(165        self,166        *,167        tool_type: str,168        tool_name: str,169        state_key: str,170        allowed_path_prefixes: Sequence[str] | None = None,171        system_prompt: str | None = None,172    ) -> None:173        """Initialize.174175        Args:176            tool_type: Tool type identifier.177            tool_name: Tool name.178            state_key: State key for file storage.179            allowed_path_prefixes: Optional list of allowed path prefixes.180            system_prompt: Optional system prompt to inject.181        """182        self.tool_type = tool_type183        self.tool_name = tool_name184        self.state_key = state_key185        self.allowed_prefixes = allowed_path_prefixes186        self.system_prompt = system_prompt187188        # Create tool that will be executed by the tool node189        @tool(tool_name)190        def file_tool(191            runtime: ToolRuntime[None, AnthropicToolsState],192            command: str,193            path: str,194            file_text: str | None = None,195            old_str: str | None = None,196            new_str: str | None = None,197            insert_line: int | None = None,198            new_path: str | None = None,199            view_range: list[int] | None = None,200        ) -> Command | str:201            """Execute file operations on virtual file system.202203            Args:204                runtime: Tool runtime providing access to state.205                command: Operation to perform.206                path: File path to operate on.207                file_text: Full file content for create command.208                old_str: String to replace for str_replace command.209                new_str: Replacement string for str_replace command.210                insert_line: Line number for insert command.211                new_path: New path for rename command.212                view_range: Line range `[start, end]` for view command.213214            Returns:215                Command for state update or string result.216            """217            # Build args dict for handler methods218            args: dict[str, Any] = {"path": path}219            if file_text is not None:220                args["file_text"] = file_text221            if old_str is not None:222                args["old_str"] = old_str223            if new_str is not None:224                args["new_str"] = new_str225            if insert_line is not None:226                args["insert_line"] = insert_line227            if new_path is not None:228                args["new_path"] = new_path229            if view_range is not None:230                args["view_range"] = view_range231232            # Route to appropriate handler based on command233            try:234                if command == "view":235                    return self._handle_view(args, runtime.state, runtime.tool_call_id)236                if command == "create":237                    return self._handle_create(238                        args, runtime.state, runtime.tool_call_id239                    )240                if command == "str_replace":241                    return self._handle_str_replace(242                        args, runtime.state, runtime.tool_call_id243                    )244                if command == "insert":245                    return self._handle_insert(246                        args, runtime.state, runtime.tool_call_id247                    )248                if command == "delete":249                    return self._handle_delete(250                        args, runtime.state, runtime.tool_call_id251                    )252                if command == "rename":253                    return self._handle_rename(254                        args, runtime.state, runtime.tool_call_id255                    )256                return f"Unknown command: {command}"257            except (ValueError, FileNotFoundError) as e:258                return str(e)259260        self.tools = [file_tool]261262    def wrap_model_call(263        self,264        request: ModelRequest,265        handler: Callable[[ModelRequest], ModelResponse],266    ) -> ModelResponse:267        """Inject Anthropic tool descriptor and optional system prompt."""268        # Replace our BaseTool with Anthropic's native tool descriptor269        tools = [270            t271            for t in (request.tools or [])272            if getattr(t, "name", None) != self.tool_name273        ] + [{"type": self.tool_type, "name": self.tool_name}]274275        # Inject system prompt if provided276        overrides: _ModelRequestOverrides = {"tools": tools}277        if self.system_prompt:278            if request.system_message is not None:279                new_system_content = [280                    *request.system_message.content_blocks,281                    {"type": "text", "text": f"\n\n{self.system_prompt}"},282                ]283            else:284                new_system_content = [{"type": "text", "text": self.system_prompt}]285            new_system_message = SystemMessage(286                content=cast("list[str | dict[str, str]]", new_system_content)287            )288            overrides["system_message"] = new_system_message289290        return handler(request.override(**overrides))291292    async def awrap_model_call(293        self,294        request: ModelRequest,295        handler: Callable[[ModelRequest], Awaitable[ModelResponse]],296    ) -> ModelResponse:297        """Inject Anthropic tool descriptor and optional system prompt."""298        # Replace our BaseTool with Anthropic's native tool descriptor299        tools = [300            t301            for t in (request.tools or [])302            if getattr(t, "name", None) != self.tool_name303        ] + [{"type": self.tool_type, "name": self.tool_name}]304305        # Inject system prompt if provided306        overrides: _ModelRequestOverrides = {"tools": tools}307        if self.system_prompt:308            if request.system_message is not None:309                new_system_content = [310                    *request.system_message.content_blocks,311                    {"type": "text", "text": f"\n\n{self.system_prompt}"},312                ]313            else:314                new_system_content = [{"type": "text", "text": self.system_prompt}]315            new_system_message = SystemMessage(316                content=cast("list[str | dict[str, str]]", new_system_content)317            )318            overrides["system_message"] = new_system_message319320        return await handler(request.override(**overrides))321322    def _handle_view(323        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None324    ) -> Command:325        """Handle view command."""326        path = args["path"]327        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)328329        files = cast("dict[str, Any]", state.get(self.state_key, {}))330        file_data = files.get(normalized_path)331332        if file_data is None:333            # Try directory listing334            matching = _list_directory(files, normalized_path)335336            if matching:337                content = "\n".join(matching)338                return Command(339                    update={340                        "messages": [341                            ToolMessage(342                                content=content,343                                tool_call_id=tool_call_id,344                                name=self.tool_name,345                            )346                        ]347                    }348                )349350            msg = f"File not found: {path}"351            raise FileNotFoundError(msg)352353        # Format file content with line numbers354        lines_content = file_data["content"]355        formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines_content)]356        content = "\n".join(formatted_lines)357358        return Command(359            update={360                "messages": [361                    ToolMessage(362                        content=content,363                        tool_call_id=tool_call_id,364                        name=self.tool_name,365                    )366                ]367            }368        )369370    def _handle_create(371        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None372    ) -> Command:373        """Handle create command."""374        path = args["path"]375        file_text = args["file_text"]376377        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)378379        # Get existing files380        files = cast("dict[str, Any]", state.get(self.state_key, {}))381        existing = files.get(normalized_path)382383        # Create file data384        now = datetime.now(timezone.utc).isoformat()385        created_at = existing["created_at"] if existing else now386387        content_lines = file_text.split("\n")388389        return Command(390            update={391                self.state_key: {392                    normalized_path: {393                        "content": content_lines,394                        "created_at": created_at,395                        "modified_at": now,396                    }397                },398                "messages": [399                    ToolMessage(400                        content=f"File created: {path}",401                        tool_call_id=tool_call_id,402                        name=self.tool_name,403                    )404                ],405            }406        )407408    def _handle_str_replace(409        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None410    ) -> Command:411        """Handle str_replace command."""412        path = args["path"]413        old_str = args["old_str"]414        new_str = args.get("new_str", "")415416        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)417418        # Read file419        files = cast("dict[str, Any]", state.get(self.state_key, {}))420        file_data = files.get(normalized_path)421        if file_data is None:422            msg = f"File not found: {path}"423            raise FileNotFoundError(msg)424425        lines_content = file_data["content"]426        content = "\n".join(lines_content)427428        # Replace string429        if old_str not in content:430            msg = f"String not found in file: {old_str}"431            raise ValueError(msg)432433        new_content = content.replace(old_str, new_str, 1)434        new_lines = new_content.split("\n")435436        # Update file437        now = datetime.now(timezone.utc).isoformat()438439        return Command(440            update={441                self.state_key: {442                    normalized_path: {443                        "content": new_lines,444                        "created_at": file_data["created_at"],445                        "modified_at": now,446                    }447                },448                "messages": [449                    ToolMessage(450                        content=f"String replaced in {path}",451                        tool_call_id=tool_call_id,452                        name=self.tool_name,453                    )454                ],455            }456        )457458    def _handle_insert(459        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None460    ) -> Command:461        """Handle insert command."""462        path = args["path"]463        insert_line = args["insert_line"]464        text_to_insert = args["new_str"]465466        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)467468        # Read file469        files = cast("dict[str, Any]", state.get(self.state_key, {}))470        file_data = files.get(normalized_path)471        if file_data is None:472            msg = f"File not found: {path}"473            raise FileNotFoundError(msg)474475        lines_content = file_data["content"]476        new_lines = text_to_insert.split("\n")477478        # Insert after insert_line (0-indexed)479        updated_lines = (480            lines_content[:insert_line] + new_lines + lines_content[insert_line:]481        )482483        # Update file484        now = datetime.now(timezone.utc).isoformat()485486        return Command(487            update={488                self.state_key: {489                    normalized_path: {490                        "content": updated_lines,491                        "created_at": file_data["created_at"],492                        "modified_at": now,493                    }494                },495                "messages": [496                    ToolMessage(497                        content=f"Text inserted in {path}",498                        tool_call_id=tool_call_id,499                        name=self.tool_name,500                    )501                ],502            }503        )504505    def _handle_delete(506        self,507        args: dict,508        state: AnthropicToolsState,509        tool_call_id: str | None,510    ) -> Command:511        """Handle delete command."""512        path = args["path"]513514        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)515516        return Command(517            update={518                self.state_key: {normalized_path: None},519                "messages": [520                    ToolMessage(521                        content=f"File deleted: {path}",522                        tool_call_id=tool_call_id,523                        name=self.tool_name,524                    )525                ],526            }527        )528529    def _handle_rename(530        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None531    ) -> Command:532        """Handle rename command."""533        old_path = args["old_path"]534        new_path = args["new_path"]535536        normalized_old = _validate_path(537            old_path, allowed_prefixes=self.allowed_prefixes538        )539        normalized_new = _validate_path(540            new_path, allowed_prefixes=self.allowed_prefixes541        )542543        # Read file544        files = cast("dict[str, Any]", state.get(self.state_key, {}))545        file_data = files.get(normalized_old)546        if file_data is None:547            msg = f"File not found: {old_path}"548            raise ValueError(msg)549550        # Update timestamp551        now = datetime.now(timezone.utc).isoformat()552        file_data_copy = file_data.copy()553        file_data_copy["modified_at"] = now554555        return Command(556            update={557                self.state_key: {558                    normalized_old: None,559                    normalized_new: file_data_copy,560                },561                "messages": [562                    ToolMessage(563                        content=f"File renamed: {old_path} -> {new_path}",564                        tool_call_id=tool_call_id,565                        name=self.tool_name,566                    )567                ],568            }569        )570571572class StateClaudeTextEditorMiddleware(_StateClaudeFileToolMiddleware):573    """State-based text editor tool middleware.574575    Provides Anthropic's `text_editor` tool using LangGraph state for storage.576    Files persist for the conversation thread.577578    Example:579        ```python580        from langchain.agents import create_agent581        from langchain.agents.middleware import StateTextEditorToolMiddleware582583        agent = create_agent(584            model=model,585            tools=[],586            middleware=[StateTextEditorToolMiddleware()],587        )588        ```589    """590591    def __init__(592        self,593        *,594        allowed_path_prefixes: Sequence[str] | None = None,595    ) -> None:596        """Initialize the text editor middleware.597598        Args:599            allowed_path_prefixes: Optional list of allowed path prefixes.600601                If specified, only paths starting with these prefixes are allowed.602        """603        super().__init__(604            tool_type=TEXT_EDITOR_TOOL_TYPE,605            tool_name=TEXT_EDITOR_TOOL_NAME,606            state_key="text_editor_files",607            allowed_path_prefixes=allowed_path_prefixes,608        )609610611class StateClaudeMemoryMiddleware(_StateClaudeFileToolMiddleware):612    """State-based memory tool middleware.613614    Provides Anthropic's memory tool using LangGraph state for storage.615    Files persist for the conversation thread.616617    Enforces `/memories` prefix and injects Anthropic's recommended system prompt.618619    Example:620        ```python621        from langchain.agents import create_agent622        from langchain.agents.middleware import StateMemoryToolMiddleware623624        agent = create_agent(625            model=model,626            tools=[],627            middleware=[StateMemoryToolMiddleware()],628        )629        ```630    """631632    def __init__(633        self,634        *,635        allowed_path_prefixes: Sequence[str] | None = None,636        system_prompt: str = MEMORY_SYSTEM_PROMPT,637    ) -> None:638        """Initialize the memory middleware.639640        Args:641            allowed_path_prefixes: Optional list of allowed path prefixes.642643                Defaults to `['/memories']`.644            system_prompt: System prompt to inject.645646                Defaults to Anthropic's recommended memory prompt.647        """648        super().__init__(649            tool_type=MEMORY_TOOL_TYPE,650            tool_name=MEMORY_TOOL_NAME,651            state_key="memory_files",652            allowed_path_prefixes=allowed_path_prefixes or ["/memories"],653            system_prompt=system_prompt,654        )655656657class _FilesystemClaudeFileToolMiddleware(AgentMiddleware):658    """Base class for filesystem-based file tool middleware (internal)."""659660    def __init__(661        self,662        *,663        tool_type: str,664        tool_name: str,665        root_path: str,666        allowed_prefixes: list[str] | None = None,667        max_file_size_mb: int = 10,668        system_prompt: str | None = None,669    ) -> None:670        """Initialize.671672        Args:673            tool_type: Tool type identifier.674            tool_name: Tool name.675            root_path: Root directory for file operations.676            allowed_prefixes: Optional list of allowed virtual path prefixes.677            max_file_size_mb: Maximum file size in MB.678            system_prompt: Optional system prompt to inject.679        """680        self.tool_type = tool_type681        self.tool_name = tool_name682        self.root_path = Path(root_path).resolve()683        self.allowed_prefixes = allowed_prefixes or ["/"]684        self.max_file_size_bytes = max_file_size_mb * 1024 * 1024685        self.system_prompt = system_prompt686687        # Create root directory if it doesn't exist688        self.root_path.mkdir(parents=True, exist_ok=True)689690        # Create tool that will be executed by the tool node691        @tool(tool_name)692        def file_tool(693            runtime: ToolRuntime,694            command: str,695            path: str,696            file_text: str | None = None,697            old_str: str | None = None,698            new_str: str | None = None,699            insert_line: int | None = None,700            new_path: str | None = None,701            view_range: list[int] | None = None,702        ) -> Command | str:703            """Execute file operations on filesystem.704705            Args:706                runtime: Tool runtime providing `tool_call_id`.707                command: Operation to perform.708                path: File path to operate on.709                file_text: Full file content for create command.710                old_str: String to replace for `str_replace` command.711                new_str: Replacement string for `str_replace` command.712                insert_line: Line number for insert command.713                new_path: New path for rename command.714                view_range: Line range `[start, end]` for view command.715716            Returns:717                Command for message update or string result.718            """719            # Build args dict for handler methods720            args: dict[str, Any] = {"path": path}721            if file_text is not None:722                args["file_text"] = file_text723            if old_str is not None:724                args["old_str"] = old_str725            if new_str is not None:726                args["new_str"] = new_str727            if insert_line is not None:728                args["insert_line"] = insert_line729            if new_path is not None:730                args["new_path"] = new_path731            if view_range is not None:732                args["view_range"] = view_range733734            # Route to appropriate handler based on command735            try:736                if command == "view":737                    return self._handle_view(args, runtime.tool_call_id)738                if command == "create":739                    return self._handle_create(args, runtime.tool_call_id)740                if command == "str_replace":741                    return self._handle_str_replace(args, runtime.tool_call_id)742                if command == "insert":743                    return self._handle_insert(args, runtime.tool_call_id)744                if command == "delete":745                    return self._handle_delete(args, runtime.tool_call_id)746                if command == "rename":747                    return self._handle_rename(args, runtime.tool_call_id)748                return f"Unknown command: {command}"749            except (ValueError, FileNotFoundError, PermissionError) as e:750                return str(e)751752        self.tools = [file_tool]753754    def wrap_model_call(755        self,756        request: ModelRequest,757        handler: Callable[[ModelRequest], ModelResponse],758    ) -> ModelResponse:759        """Inject Anthropic tool descriptor and optional system prompt."""760        # Replace our BaseTool with Anthropic's native tool descriptor761        tools = [762            t763            for t in (request.tools or [])764            if getattr(t, "name", None) != self.tool_name765        ] + [{"type": self.tool_type, "name": self.tool_name}]766767        # Inject system prompt if provided768        overrides: _ModelRequestOverrides = {"tools": tools}769        if self.system_prompt:770            if request.system_message is not None:771                new_system_content = [772                    *request.system_message.content_blocks,773                    {"type": "text", "text": f"\n\n{self.system_prompt}"},774                ]775            else:776                new_system_content = [{"type": "text", "text": self.system_prompt}]777            new_system_message = SystemMessage(778                content=cast("list[str | dict[str, str]]", new_system_content)779            )780            overrides["system_message"] = new_system_message781782        return handler(request.override(**overrides))783784    async def awrap_model_call(785        self,786        request: ModelRequest,787        handler: Callable[[ModelRequest], Awaitable[ModelResponse]],788    ) -> ModelResponse:789        """Inject Anthropic tool descriptor and optional system prompt."""790        # Replace our BaseTool with Anthropic's native tool descriptor791        tools = [792            t793            for t in (request.tools or [])794            if getattr(t, "name", None) != self.tool_name795        ] + [{"type": self.tool_type, "name": self.tool_name}]796797        # Inject system prompt if provided798        overrides: _ModelRequestOverrides = {"tools": tools}799        if self.system_prompt:800            if request.system_message is not None:801                new_system_content = [802                    *request.system_message.content_blocks,803                    {"type": "text", "text": f"\n\n{self.system_prompt}"},804                ]805            else:806                new_system_content = [{"type": "text", "text": self.system_prompt}]807            new_system_message = SystemMessage(808                content=cast("list[str | dict[str, str]]", new_system_content)809            )810            overrides["system_message"] = new_system_message811812        return await handler(request.override(**overrides))813814    def _validate_and_resolve_path(self, path: str) -> Path:815        """Validate and resolve a virtual path to filesystem path.816817        Args:818            path: Virtual path (e.g., `/file.txt` or `/src/main.py`).819820        Returns:821            Resolved absolute filesystem path within `root_path`.822823        Raises:824            ValueError: If path contains traversal attempts, escapes root directory,825                or violates `allowed_prefixes` restrictions.826        """827        # Normalize path828        if not path.startswith("/"):829            path = "/" + path830831        # Check for path traversal832        if ".." in path or "~" in path:833            msg = "Path traversal not allowed"834            raise ValueError(msg)835836        # Convert virtual path to filesystem path837        # Remove leading / and resolve relative to root838        relative = path.lstrip("/")839        full_path = (self.root_path / relative).resolve()840841        # Ensure path is within root842        try:843            full_path.relative_to(self.root_path)844        except ValueError:845            msg = f"Path outside root directory: {path}"846            raise ValueError(msg) from None847848        # Check allowed prefixes849        virtual_path = "/" + str(full_path.relative_to(self.root_path))850        if self.allowed_prefixes:851            allowed = any(852                virtual_path.startswith(prefix) or virtual_path == prefix.rstrip("/")853                for prefix in self.allowed_prefixes854            )855            if not allowed:856                msg = f"Path must start with one of: {self.allowed_prefixes}"857                raise ValueError(msg)858859        return full_path860861    def _handle_view(self, args: dict, tool_call_id: str | None) -> Command:862        """Handle view command."""863        path = args["path"]864        full_path = self._validate_and_resolve_path(path)865866        if not full_path.exists() or not full_path.is_file():867            msg = f"File not found: {path}"868            raise FileNotFoundError(msg)869870        # Check file size871        if full_path.stat().st_size > self.max_file_size_bytes:872            max_mb = self.max_file_size_bytes / 1024 / 1024873            msg = f"File too large: {path} exceeds {max_mb}MB"874            raise ValueError(msg)875876        # Read file877        try:878            content = full_path.read_text()879        except UnicodeDecodeError as e:880            msg = f"Cannot decode file {path}: {e}"881            raise ValueError(msg) from e882883        # Format with line numbers884        lines = content.split("\n")885        # Remove trailing newline's empty string if present886        if lines and lines[-1] == "":887            lines = lines[:-1]888        formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines)]889        formatted_content = "\n".join(formatted_lines)890891        return Command(892            update={893                "messages": [894                    ToolMessage(895                        content=formatted_content,896                        tool_call_id=tool_call_id,897                        name=self.tool_name,898                    )899                ]900            }901        )902903    def _handle_create(self, args: dict, tool_call_id: str | None) -> Command:904        """Handle create command."""905        path = args["path"]906        file_text = args["file_text"]907908        full_path = self._validate_and_resolve_path(path)909910        # Create parent directories911        full_path.parent.mkdir(parents=True, exist_ok=True)912913        # Write file914        full_path.write_text(file_text + "\n")915916        return Command(917            update={918                "messages": [919                    ToolMessage(920                        content=f"File created: {path}",921                        tool_call_id=tool_call_id,922                        name=self.tool_name,923                    )924                ]925            }926        )927928    def _handle_str_replace(self, args: dict, tool_call_id: str | None) -> Command:929        """Handle `str_replace` command."""930        path = args["path"]931        old_str = args["old_str"]932        new_str = args.get("new_str", "")933934        full_path = self._validate_and_resolve_path(path)935936        if not full_path.exists():937            msg = f"File not found: {path}"938            raise FileNotFoundError(msg)939940        # Read file941        content = full_path.read_text()942943        # Replace string944        if old_str not in content:945            msg = f"String not found in file: {old_str}"946            raise ValueError(msg)947948        new_content = content.replace(old_str, new_str, 1)949950        # Write back951        full_path.write_text(new_content)952953        return Command(954            update={955                "messages": [956                    ToolMessage(957                        content=f"String replaced in {path}",958                        tool_call_id=tool_call_id,959                        name=self.tool_name,960                    )961                ]962            }963        )964965    def _handle_insert(self, args: dict, tool_call_id: str | None) -> Command:966        """Handle insert command."""967        path = args["path"]968        insert_line = args["insert_line"]969        text_to_insert = args["new_str"]970971        full_path = self._validate_and_resolve_path(path)972973        if not full_path.exists():974            msg = f"File not found: {path}"975            raise FileNotFoundError(msg)976977        # Read file978        content = full_path.read_text()979        lines = content.split("\n")980        # Handle trailing newline981        if lines and lines[-1] == "":982            lines = lines[:-1]983            had_trailing_newline = True984        else:985            had_trailing_newline = False986987        new_lines = text_to_insert.split("\n")988989        # Insert after insert_line (0-indexed)990        updated_lines = lines[:insert_line] + new_lines + lines[insert_line:]991992        # Write back993        new_content = "\n".join(updated_lines)994        if had_trailing_newline:995            new_content += "\n"996        full_path.write_text(new_content)997998        return Command(999            update={1000                "messages": [1001                    ToolMessage(1002                        content=f"Text inserted in {path}",1003                        tool_call_id=tool_call_id,1004                        name=self.tool_name,1005                    )1006                ]1007            }1008        )10091010    def _handle_delete(self, args: dict, tool_call_id: str | None) -> Command:1011        """Handle delete command."""1012        path = args["path"]1013        full_path = self._validate_and_resolve_path(path)10141015        if full_path.is_file():1016            full_path.unlink()1017        elif full_path.is_dir():1018            shutil.rmtree(full_path)1019        # If doesn't exist, silently succeed10201021        return Command(1022            update={1023                "messages": [1024                    ToolMessage(1025                        content=f"File deleted: {path}",1026                        tool_call_id=tool_call_id,1027                        name=self.tool_name,1028                    )1029                ]1030            }1031        )10321033    def _handle_rename(self, args: dict, tool_call_id: str | None) -> Command:1034        """Handle rename command."""1035        old_path = args["old_path"]1036        new_path = args["new_path"]10371038        old_full = self._validate_and_resolve_path(old_path)1039        new_full = self._validate_and_resolve_path(new_path)10401041        if not old_full.exists():1042            msg = f"File not found: {old_path}"1043            raise ValueError(msg)10441045        # Create parent directory for new path1046        new_full.parent.mkdir(parents=True, exist_ok=True)10471048        # Rename1049        old_full.rename(new_full)10501051        return Command(1052            update={1053                "messages": [1054                    ToolMessage(1055                        content=f"File renamed: {old_path} -> {new_path}",1056                        tool_call_id=tool_call_id,1057                        name=self.tool_name,1058                    )1059                ]1060            }1061        )106210631064class FilesystemClaudeTextEditorMiddleware(_FilesystemClaudeFileToolMiddleware):1065    """Filesystem-based text editor tool middleware.10661067    Provides Anthropic's `text_editor` tool using local filesystem for storage.1068    User handles persistence via volumes, git, or other mechanisms.10691070    Example:1071        ```python1072        from langchain.agents import create_agent1073        from langchain.agents.middleware import FilesystemTextEditorToolMiddleware10741075        agent = create_agent(1076            model=model,1077            tools=[],1078            middleware=[FilesystemTextEditorToolMiddleware(root_path="/workspace")],1079        )1080        ```1081    """10821083    def __init__(1084        self,1085        *,1086        root_path: str,1087        allowed_prefixes: list[str] | None = None,1088        max_file_size_mb: int = 10,1089    ) -> None:1090        """Initialize the text editor middleware.10911092        Args:1093            root_path: Root directory for file operations.1094            allowed_prefixes: Optional list of allowed virtual path prefixes.10951096                Defaults to `['/']`.1097            max_file_size_mb: Maximum file size in MB10981099                Defaults to `10`.1100        """1101        super().__init__(1102            tool_type=TEXT_EDITOR_TOOL_TYPE,1103            tool_name=TEXT_EDITOR_TOOL_NAME,1104            root_path=root_path,1105            allowed_prefixes=allowed_prefixes,1106            max_file_size_mb=max_file_size_mb,1107        )110811091110class FilesystemClaudeMemoryMiddleware(_FilesystemClaudeFileToolMiddleware):1111    """Filesystem-based memory tool middleware.11121113    Provides Anthropic's memory tool using local filesystem for storage.1114    User handles persistence via volumes, git, or other mechanisms.11151116    Enforces `/memories` prefix and injects Anthropic's recommended system1117    prompt.11181119    Example:1120        ```python1121        from langchain.agents import create_agent1122        from langchain.agents.middleware import FilesystemMemoryToolMiddleware11231124        agent = create_agent(1125            model=model,1126            tools=[],1127            middleware=[FilesystemMemoryToolMiddleware(root_path="/workspace")],1128        )1129        ```1130    """11311132    def __init__(1133        self,1134        *,1135        root_path: str,1136        allowed_prefixes: list[str] | None = None,1137        max_file_size_mb: int = 10,1138        system_prompt: str = MEMORY_SYSTEM_PROMPT,1139    ) -> None:1140        """Initialize the memory middleware.11411142        Args:1143            root_path: Root directory for file operations.1144            allowed_prefixes: Optional list of allowed virtual path prefixes.11451146                Defaults to `['/memories']`.1147            max_file_size_mb: Maximum file size in MB11481149                Defaults to `10`.1150            system_prompt: System prompt to inject.11511152                Defaults to Anthropic's recommended memory prompt.1153        """1154        super().__init__(1155            tool_type=MEMORY_TOOL_TYPE,1156            tool_name=MEMORY_TOOL_NAME,1157            root_path=root_path,1158            allowed_prefixes=allowed_prefixes or ["/memories"],1159            max_file_size_mb=max_file_size_mb,1160            system_prompt=system_prompt,1161        )116211631164__all__ = [1165    "AnthropicToolsState",1166    "FileData",1167    "FilesystemClaudeMemoryMiddleware",1168    "FilesystemClaudeTextEditorMiddleware",1169    "StateClaudeMemoryMiddleware",1170    "StateClaudeTextEditorMiddleware",1171]

Code quality findings 9

Ensure functions have docstrings for documentation
missing-docstring
def files_reducer(
Ensure functions have docstrings for documentation
missing-docstring
def file_tool(
Ensure try blocks have corresponding except or finally blocks
try-without-except
try:
Ensure functions have docstrings for documentation
missing-docstring
def wrap_model_call(
Ensure functions have docstrings for documentation
missing-docstring
async def awrap_model_call(
Ensure functions have docstrings for documentation
missing-docstring
def file_tool(
Ensure try blocks have corresponding except or finally blocks
try-without-except
try:
Ensure functions have docstrings for documentation
missing-docstring
def wrap_model_call(
Ensure functions have docstrings for documentation
missing-docstring
async def awrap_model_call(

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.