libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py PYTHON 1,194 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 _is_within_allowed_prefix(normalized: str, prefixes: Sequence[str]) -> bool:97    """Check whether a normalized path lies within an allowed prefix directory.9899    Uses a segment-boundary comparison rather than a raw string prefix test so100    that sibling directories sharing a textual prefix cannot escape the allowed101    directory. For example, with prefix `/memories` the path `/memories2/evil.txt`102    is rejected because it is not the prefix itself nor a descendant of it.103104    Args:105        normalized: A normalized, forward-slash, absolute-style path.106        prefixes: Allowed path prefixes to compare against.107108    Returns:109        `True` if `normalized` exactly equals one of the prefix directories or is110        contained within one of them, `False` otherwise.111    """112    for prefix in prefixes:113        # Normalize the prefix the same way the path was normalized so the114        # comparison is consistent (drop any trailing slash for the boundary).115        prefix_dir = prefix.rstrip("/")116        if normalized == prefix_dir or normalized.startswith(f"{prefix_dir}/"):117            return True118    return False119120121def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str:122    """Validate and normalize file path for security.123124    Args:125        path: The path to validate.126        allowed_prefixes: Optional list of allowed path prefixes.127128    Returns:129        Normalized canonical path.130131    Raises:132        ValueError: If path contains traversal sequences or violates prefix rules.133    """134    # Reject paths with traversal attempts135    if ".." in path or path.startswith("~"):136        msg = f"Path traversal not allowed: {path}"137        raise ValueError(msg)138139    # Normalize path (resolve ., //, etc.)140    normalized = os.path.normpath(path)141142    # Convert to forward slashes for consistency143    normalized = normalized.replace("\\", "/")144145    # Ensure path starts with /146    if not normalized.startswith("/"):147        normalized = f"/{normalized}"148149    # Check allowed prefixes if specified150    if allowed_prefixes is not None and not _is_within_allowed_prefix(151        normalized, allowed_prefixes152    ):153        msg = f"Path must start with one of {allowed_prefixes}: {path}"154        raise ValueError(msg)155156    return normalized157158159def _list_directory(files: dict[str, FileData], path: str) -> list[str]:160    """List files in a directory.161162    Args:163        files: Files `dict`.164        path: Normalized directory path.165166    Returns:167        Sorted list of file paths in the directory.168    """169    # Ensure path ends with / for directory matching170    dir_path = path if path.endswith("/") else f"{path}/"171172    matching_files = []173    for file_path in files:174        if file_path.startswith(dir_path):175            # Get relative path from directory176            relative = file_path[len(dir_path) :]177            # Only include direct children (no subdirectories)178            if "/" not in relative:179                matching_files.append(file_path)180181    return sorted(matching_files)182183184class _StateClaudeFileToolMiddleware(AgentMiddleware):185    """Base class for state-based file tool middleware (internal)."""186187    state_schema = AnthropicToolsState188189    def __init__(190        self,191        *,192        tool_type: str,193        tool_name: str,194        state_key: str,195        allowed_path_prefixes: Sequence[str] | None = None,196        system_prompt: str | None = None,197    ) -> None:198        """Initialize.199200        Args:201            tool_type: Tool type identifier.202            tool_name: Tool name.203            state_key: State key for file storage.204            allowed_path_prefixes: Optional list of allowed path prefixes.205            system_prompt: Optional system prompt to inject.206        """207        self.tool_type = tool_type208        self.tool_name = tool_name209        self.state_key = state_key210        self.allowed_prefixes = allowed_path_prefixes211        self.system_prompt = system_prompt212213        # Create tool that will be executed by the tool node214        @tool(tool_name)215        def file_tool(216            runtime: ToolRuntime[None, AnthropicToolsState],217            command: str,218            path: str,219            file_text: str | None = None,220            old_str: str | None = None,221            new_str: str | None = None,222            insert_line: int | None = None,223            new_path: str | None = None,224            view_range: list[int] | None = None,225        ) -> Command | str:226            """Execute file operations on virtual file system.227228            Args:229                runtime: Tool runtime providing access to state.230                command: Operation to perform.231                path: File path to operate on.232                file_text: Full file content for create command.233                old_str: String to replace for str_replace command.234                new_str: Replacement string for str_replace command.235                insert_line: Line number for insert command.236                new_path: New path for rename command.237                view_range: Line range `[start, end]` for view command.238239            Returns:240                Command for state update or string result.241            """242            # Build args dict for handler methods243            args: dict[str, Any] = {"path": path}244            if file_text is not None:245                args["file_text"] = file_text246            if old_str is not None:247                args["old_str"] = old_str248            if new_str is not None:249                args["new_str"] = new_str250            if insert_line is not None:251                args["insert_line"] = insert_line252            if new_path is not None:253                args["new_path"] = new_path254            if view_range is not None:255                args["view_range"] = view_range256257            # Route to appropriate handler based on command258            try:259                if command == "view":260                    return self._handle_view(args, runtime.state, runtime.tool_call_id)261                if command == "create":262                    return self._handle_create(263                        args, runtime.state, runtime.tool_call_id264                    )265                if command == "str_replace":266                    return self._handle_str_replace(267                        args, runtime.state, runtime.tool_call_id268                    )269                if command == "insert":270                    return self._handle_insert(271                        args, runtime.state, runtime.tool_call_id272                    )273                if command == "delete":274                    return self._handle_delete(275                        args, runtime.state, runtime.tool_call_id276                    )277                if command == "rename":278                    return self._handle_rename(279                        args, runtime.state, runtime.tool_call_id280                    )281                return f"Unknown command: {command}"282            except (ValueError, FileNotFoundError) as e:283                return str(e)284285        self.tools = [file_tool]286287    def wrap_model_call(288        self,289        request: ModelRequest,290        handler: Callable[[ModelRequest], ModelResponse],291    ) -> ModelResponse:292        """Inject Anthropic tool descriptor and optional system prompt."""293        # Replace our BaseTool with Anthropic's native tool descriptor294        tools = [295            t296            for t in (request.tools or [])297            if getattr(t, "name", None) != self.tool_name298        ] + [{"type": self.tool_type, "name": self.tool_name}]299300        # Inject system prompt if provided301        overrides: _ModelRequestOverrides = {"tools": tools}302        if self.system_prompt:303            if request.system_message is not None:304                new_system_content = [305                    *request.system_message.content_blocks,306                    {"type": "text", "text": f"\n\n{self.system_prompt}"},307                ]308            else:309                new_system_content = [{"type": "text", "text": self.system_prompt}]310            new_system_message = SystemMessage(311                content=cast("list[str | dict[str, str]]", new_system_content)312            )313            overrides["system_message"] = new_system_message314315        return handler(request.override(**overrides))316317    async def awrap_model_call(318        self,319        request: ModelRequest,320        handler: Callable[[ModelRequest], Awaitable[ModelResponse]],321    ) -> ModelResponse:322        """Inject Anthropic tool descriptor and optional system prompt."""323        # Replace our BaseTool with Anthropic's native tool descriptor324        tools = [325            t326            for t in (request.tools or [])327            if getattr(t, "name", None) != self.tool_name328        ] + [{"type": self.tool_type, "name": self.tool_name}]329330        # Inject system prompt if provided331        overrides: _ModelRequestOverrides = {"tools": tools}332        if self.system_prompt:333            if request.system_message is not None:334                new_system_content = [335                    *request.system_message.content_blocks,336                    {"type": "text", "text": f"\n\n{self.system_prompt}"},337                ]338            else:339                new_system_content = [{"type": "text", "text": self.system_prompt}]340            new_system_message = SystemMessage(341                content=cast("list[str | dict[str, str]]", new_system_content)342            )343            overrides["system_message"] = new_system_message344345        return await handler(request.override(**overrides))346347    def _handle_view(348        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None349    ) -> Command:350        """Handle view command."""351        path = args["path"]352        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)353354        files = cast("dict[str, Any]", state.get(self.state_key, {}))355        file_data = files.get(normalized_path)356357        if file_data is None:358            # Try directory listing359            matching = _list_directory(files, normalized_path)360361            if matching:362                content = "\n".join(matching)363                return Command(364                    update={365                        "messages": [366                            ToolMessage(367                                content=content,368                                tool_call_id=tool_call_id,369                                name=self.tool_name,370                            )371                        ]372                    }373                )374375            msg = f"File not found: {path}"376            raise FileNotFoundError(msg)377378        # Format file content with line numbers379        lines_content = file_data["content"]380        formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines_content)]381        content = "\n".join(formatted_lines)382383        return Command(384            update={385                "messages": [386                    ToolMessage(387                        content=content,388                        tool_call_id=tool_call_id,389                        name=self.tool_name,390                    )391                ]392            }393        )394395    def _handle_create(396        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None397    ) -> Command:398        """Handle create command."""399        path = args["path"]400        file_text = args["file_text"]401402        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)403404        # Get existing files405        files = cast("dict[str, Any]", state.get(self.state_key, {}))406        existing = files.get(normalized_path)407408        # Create file data409        now = datetime.now(timezone.utc).isoformat()410        created_at = existing["created_at"] if existing else now411412        content_lines = file_text.split("\n")413414        return Command(415            update={416                self.state_key: {417                    normalized_path: {418                        "content": content_lines,419                        "created_at": created_at,420                        "modified_at": now,421                    }422                },423                "messages": [424                    ToolMessage(425                        content=f"File created: {path}",426                        tool_call_id=tool_call_id,427                        name=self.tool_name,428                    )429                ],430            }431        )432433    def _handle_str_replace(434        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None435    ) -> Command:436        """Handle str_replace command."""437        path = args["path"]438        old_str = args["old_str"]439        new_str = args.get("new_str", "")440441        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)442443        # Read file444        files = cast("dict[str, Any]", state.get(self.state_key, {}))445        file_data = files.get(normalized_path)446        if file_data is None:447            msg = f"File not found: {path}"448            raise FileNotFoundError(msg)449450        lines_content = file_data["content"]451        content = "\n".join(lines_content)452453        # Replace string454        if old_str not in content:455            msg = f"String not found in file: {old_str}"456            raise ValueError(msg)457458        new_content = content.replace(old_str, new_str, 1)459        new_lines = new_content.split("\n")460461        # Update file462        now = datetime.now(timezone.utc).isoformat()463464        return Command(465            update={466                self.state_key: {467                    normalized_path: {468                        "content": new_lines,469                        "created_at": file_data["created_at"],470                        "modified_at": now,471                    }472                },473                "messages": [474                    ToolMessage(475                        content=f"String replaced in {path}",476                        tool_call_id=tool_call_id,477                        name=self.tool_name,478                    )479                ],480            }481        )482483    def _handle_insert(484        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None485    ) -> Command:486        """Handle insert command."""487        path = args["path"]488        insert_line = args["insert_line"]489        text_to_insert = args["new_str"]490491        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)492493        # Read file494        files = cast("dict[str, Any]", state.get(self.state_key, {}))495        file_data = files.get(normalized_path)496        if file_data is None:497            msg = f"File not found: {path}"498            raise FileNotFoundError(msg)499500        lines_content = file_data["content"]501        new_lines = text_to_insert.split("\n")502503        # Insert after insert_line (0-indexed)504        updated_lines = (505            lines_content[:insert_line] + new_lines + lines_content[insert_line:]506        )507508        # Update file509        now = datetime.now(timezone.utc).isoformat()510511        return Command(512            update={513                self.state_key: {514                    normalized_path: {515                        "content": updated_lines,516                        "created_at": file_data["created_at"],517                        "modified_at": now,518                    }519                },520                "messages": [521                    ToolMessage(522                        content=f"Text inserted in {path}",523                        tool_call_id=tool_call_id,524                        name=self.tool_name,525                    )526                ],527            }528        )529530    def _handle_delete(531        self,532        args: dict,533        state: AnthropicToolsState,534        tool_call_id: str | None,535    ) -> Command:536        """Handle delete command."""537        path = args["path"]538539        normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)540541        return Command(542            update={543                self.state_key: {normalized_path: None},544                "messages": [545                    ToolMessage(546                        content=f"File deleted: {path}",547                        tool_call_id=tool_call_id,548                        name=self.tool_name,549                    )550                ],551            }552        )553554    def _handle_rename(555        self, args: dict, state: AnthropicToolsState, tool_call_id: str | None556    ) -> Command:557        """Handle rename command."""558        old_path = args["old_path"]559        new_path = args["new_path"]560561        normalized_old = _validate_path(562            old_path, allowed_prefixes=self.allowed_prefixes563        )564        normalized_new = _validate_path(565            new_path, allowed_prefixes=self.allowed_prefixes566        )567568        # Read file569        files = cast("dict[str, Any]", state.get(self.state_key, {}))570        file_data = files.get(normalized_old)571        if file_data is None:572            msg = f"File not found: {old_path}"573            raise ValueError(msg)574575        # Update timestamp576        now = datetime.now(timezone.utc).isoformat()577        file_data_copy = file_data.copy()578        file_data_copy["modified_at"] = now579580        return Command(581            update={582                self.state_key: {583                    normalized_old: None,584                    normalized_new: file_data_copy,585                },586                "messages": [587                    ToolMessage(588                        content=f"File renamed: {old_path} -> {new_path}",589                        tool_call_id=tool_call_id,590                        name=self.tool_name,591                    )592                ],593            }594        )595596597class StateClaudeTextEditorMiddleware(_StateClaudeFileToolMiddleware):598    """State-based text editor tool middleware.599600    Provides Anthropic's `text_editor` tool using LangGraph state for storage.601    Files persist for the conversation thread.602603    Example:604        ```python605        from langchain.agents import create_agent606        from langchain.agents.middleware import StateTextEditorToolMiddleware607608        agent = create_agent(609            model=model,610            tools=[],611            middleware=[StateTextEditorToolMiddleware()],612        )613        ```614    """615616    def __init__(617        self,618        *,619        allowed_path_prefixes: Sequence[str] | None = None,620    ) -> None:621        """Initialize the text editor middleware.622623        Args:624            allowed_path_prefixes: Optional list of allowed path prefixes.625626                If specified, only paths starting with these prefixes are allowed.627        """628        super().__init__(629            tool_type=TEXT_EDITOR_TOOL_TYPE,630            tool_name=TEXT_EDITOR_TOOL_NAME,631            state_key="text_editor_files",632            allowed_path_prefixes=allowed_path_prefixes,633        )634635636class StateClaudeMemoryMiddleware(_StateClaudeFileToolMiddleware):637    """State-based memory tool middleware.638639    Provides Anthropic's memory tool using LangGraph state for storage.640    Files persist for the conversation thread.641642    Enforces `/memories` prefix and injects Anthropic's recommended system prompt.643644    Example:645        ```python646        from langchain.agents import create_agent647        from langchain.agents.middleware import StateMemoryToolMiddleware648649        agent = create_agent(650            model=model,651            tools=[],652            middleware=[StateMemoryToolMiddleware()],653        )654        ```655    """656657    def __init__(658        self,659        *,660        allowed_path_prefixes: Sequence[str] | None = None,661        system_prompt: str = MEMORY_SYSTEM_PROMPT,662    ) -> None:663        """Initialize the memory middleware.664665        Args:666            allowed_path_prefixes: Optional list of allowed path prefixes.667668                Defaults to `['/memories']`.669            system_prompt: System prompt to inject.670671                Defaults to Anthropic's recommended memory prompt.672        """673        super().__init__(674            tool_type=MEMORY_TOOL_TYPE,675            tool_name=MEMORY_TOOL_NAME,676            state_key="memory_files",677            allowed_path_prefixes=allowed_path_prefixes or ["/memories"],678            system_prompt=system_prompt,679        )680681682class _FilesystemClaudeFileToolMiddleware(AgentMiddleware):683    """Base class for filesystem-based file tool middleware (internal)."""684685    def __init__(686        self,687        *,688        tool_type: str,689        tool_name: str,690        root_path: str,691        allowed_prefixes: list[str] | None = None,692        max_file_size_mb: int = 10,693        system_prompt: str | None = None,694    ) -> None:695        """Initialize.696697        Args:698            tool_type: Tool type identifier.699            tool_name: Tool name.700            root_path: Root directory for file operations.701            allowed_prefixes: Optional list of allowed virtual path prefixes.702            max_file_size_mb: Maximum file size in MB.703            system_prompt: Optional system prompt to inject.704        """705        self.tool_type = tool_type706        self.tool_name = tool_name707        self.root_path = Path(root_path).resolve()708        self.allowed_prefixes = allowed_prefixes or ["/"]709        self.max_file_size_bytes = max_file_size_mb * 1024 * 1024710        self.system_prompt = system_prompt711712        # Create root directory if it doesn't exist713        self.root_path.mkdir(parents=True, exist_ok=True)714715        # Create tool that will be executed by the tool node716        @tool(tool_name)717        def file_tool(718            runtime: ToolRuntime,719            command: str,720            path: str,721            file_text: str | None = None,722            old_str: str | None = None,723            new_str: str | None = None,724            insert_line: int | None = None,725            new_path: str | None = None,726            view_range: list[int] | None = None,727        ) -> Command | str:728            """Execute file operations on filesystem.729730            Args:731                runtime: Tool runtime providing `tool_call_id`.732                command: Operation to perform.733                path: File path to operate on.734                file_text: Full file content for create command.735                old_str: String to replace for `str_replace` command.736                new_str: Replacement string for `str_replace` command.737                insert_line: Line number for insert command.738                new_path: New path for rename command.739                view_range: Line range `[start, end]` for view command.740741            Returns:742                Command for message update or string result.743            """744            # Build args dict for handler methods745            args: dict[str, Any] = {"path": path}746            if file_text is not None:747                args["file_text"] = file_text748            if old_str is not None:749                args["old_str"] = old_str750            if new_str is not None:751                args["new_str"] = new_str752            if insert_line is not None:753                args["insert_line"] = insert_line754            if new_path is not None:755                args["new_path"] = new_path756            if view_range is not None:757                args["view_range"] = view_range758759            # Route to appropriate handler based on command760            try:761                if command == "view":762                    return self._handle_view(args, runtime.tool_call_id)763                if command == "create":764                    return self._handle_create(args, runtime.tool_call_id)765                if command == "str_replace":766                    return self._handle_str_replace(args, runtime.tool_call_id)767                if command == "insert":768                    return self._handle_insert(args, runtime.tool_call_id)769                if command == "delete":770                    return self._handle_delete(args, runtime.tool_call_id)771                if command == "rename":772                    return self._handle_rename(args, runtime.tool_call_id)773                return f"Unknown command: {command}"774            except (ValueError, FileNotFoundError, PermissionError) as e:775                return str(e)776777        self.tools = [file_tool]778779    def wrap_model_call(780        self,781        request: ModelRequest,782        handler: Callable[[ModelRequest], ModelResponse],783    ) -> ModelResponse:784        """Inject Anthropic tool descriptor and optional system prompt."""785        # Replace our BaseTool with Anthropic's native tool descriptor786        tools = [787            t788            for t in (request.tools or [])789            if getattr(t, "name", None) != self.tool_name790        ] + [{"type": self.tool_type, "name": self.tool_name}]791792        # Inject system prompt if provided793        overrides: _ModelRequestOverrides = {"tools": tools}794        if self.system_prompt:795            if request.system_message is not None:796                new_system_content = [797                    *request.system_message.content_blocks,798                    {"type": "text", "text": f"\n\n{self.system_prompt}"},799                ]800            else:801                new_system_content = [{"type": "text", "text": self.system_prompt}]802            new_system_message = SystemMessage(803                content=cast("list[str | dict[str, str]]", new_system_content)804            )805            overrides["system_message"] = new_system_message806807        return handler(request.override(**overrides))808809    async def awrap_model_call(810        self,811        request: ModelRequest,812        handler: Callable[[ModelRequest], Awaitable[ModelResponse]],813    ) -> ModelResponse:814        """Inject Anthropic tool descriptor and optional system prompt."""815        # Replace our BaseTool with Anthropic's native tool descriptor816        tools = [817            t818            for t in (request.tools or [])819            if getattr(t, "name", None) != self.tool_name820        ] + [{"type": self.tool_type, "name": self.tool_name}]821822        # Inject system prompt if provided823        overrides: _ModelRequestOverrides = {"tools": tools}824        if self.system_prompt:825            if request.system_message is not None:826                new_system_content = [827                    *request.system_message.content_blocks,828                    {"type": "text", "text": f"\n\n{self.system_prompt}"},829                ]830            else:831                new_system_content = [{"type": "text", "text": self.system_prompt}]832            new_system_message = SystemMessage(833                content=cast("list[str | dict[str, str]]", new_system_content)834            )835            overrides["system_message"] = new_system_message836837        return await handler(request.override(**overrides))838839    def _validate_and_resolve_path(self, path: str) -> Path:840        """Validate and resolve a virtual path to filesystem path.841842        Args:843            path: Virtual path (e.g., `/file.txt` or `/src/main.py`).844845        Returns:846            Resolved absolute filesystem path within `root_path`.847848        Raises:849            ValueError: If path contains traversal attempts, escapes root directory,850                or violates `allowed_prefixes` restrictions.851        """852        # Normalize path853        if not path.startswith("/"):854            path = "/" + path855856        # Check for path traversal857        if ".." in path or "~" in path:858            msg = "Path traversal not allowed"859            raise ValueError(msg)860861        # Convert virtual path to filesystem path862        # Remove leading / and resolve relative to root863        relative = path.lstrip("/")864        full_path = (self.root_path / relative).resolve()865866        # Ensure path is within root867        try:868            full_path.relative_to(self.root_path)869        except ValueError:870            msg = f"Path outside root directory: {path}"871            raise ValueError(msg) from None872873        # Check allowed prefixes874        virtual_path = "/" + str(full_path.relative_to(self.root_path))875        if self.allowed_prefixes and not _is_within_allowed_prefix(876            virtual_path, self.allowed_prefixes877        ):878            msg = f"Path must start with one of: {self.allowed_prefixes}"879            raise ValueError(msg)880881        return full_path882883    def _handle_view(self, args: dict, tool_call_id: str | None) -> Command:884        """Handle view command."""885        path = args["path"]886        full_path = self._validate_and_resolve_path(path)887888        if not full_path.exists() or not full_path.is_file():889            msg = f"File not found: {path}"890            raise FileNotFoundError(msg)891892        # Check file size893        if full_path.stat().st_size > self.max_file_size_bytes:894            max_mb = self.max_file_size_bytes / 1024 / 1024895            msg = f"File too large: {path} exceeds {max_mb}MB"896            raise ValueError(msg)897898        # Read file899        try:900            content = full_path.read_text()901        except UnicodeDecodeError as e:902            msg = f"Cannot decode file {path}: {e}"903            raise ValueError(msg) from e904905        # Format with line numbers906        lines = content.split("\n")907        # Remove trailing newline's empty string if present908        if lines and lines[-1] == "":909            lines = lines[:-1]910        formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines)]911        formatted_content = "\n".join(formatted_lines)912913        return Command(914            update={915                "messages": [916                    ToolMessage(917                        content=formatted_content,918                        tool_call_id=tool_call_id,919                        name=self.tool_name,920                    )921                ]922            }923        )924925    def _handle_create(self, args: dict, tool_call_id: str | None) -> Command:926        """Handle create command."""927        path = args["path"]928        file_text = args["file_text"]929930        full_path = self._validate_and_resolve_path(path)931932        # Create parent directories933        full_path.parent.mkdir(parents=True, exist_ok=True)934935        # Write file936        full_path.write_text(file_text + "\n")937938        return Command(939            update={940                "messages": [941                    ToolMessage(942                        content=f"File created: {path}",943                        tool_call_id=tool_call_id,944                        name=self.tool_name,945                    )946                ]947            }948        )949950    def _handle_str_replace(self, args: dict, tool_call_id: str | None) -> Command:951        """Handle `str_replace` command."""952        path = args["path"]953        old_str = args["old_str"]954        new_str = args.get("new_str", "")955956        full_path = self._validate_and_resolve_path(path)957958        if not full_path.exists():959            msg = f"File not found: {path}"960            raise FileNotFoundError(msg)961962        # Read file963        content = full_path.read_text()964965        # Replace string966        if old_str not in content:967            msg = f"String not found in file: {old_str}"968            raise ValueError(msg)969970        new_content = content.replace(old_str, new_str, 1)971972        # Write back973        full_path.write_text(new_content)974975        return Command(976            update={977                "messages": [978                    ToolMessage(979                        content=f"String replaced in {path}",980                        tool_call_id=tool_call_id,981                        name=self.tool_name,982                    )983                ]984            }985        )986987    def _handle_insert(self, args: dict, tool_call_id: str | None) -> Command:988        """Handle insert command."""989        path = args["path"]990        insert_line = args["insert_line"]991        text_to_insert = args["new_str"]992993        full_path = self._validate_and_resolve_path(path)994995        if not full_path.exists():996            msg = f"File not found: {path}"997            raise FileNotFoundError(msg)998999        # Read file1000        content = full_path.read_text()1001        lines = content.split("\n")1002        # Handle trailing newline1003        if lines and lines[-1] == "":1004            lines = lines[:-1]1005            had_trailing_newline = True1006        else:1007            had_trailing_newline = False10081009        new_lines = text_to_insert.split("\n")10101011        # Insert after insert_line (0-indexed)1012        updated_lines = lines[:insert_line] + new_lines + lines[insert_line:]10131014        # Write back1015        new_content = "\n".join(updated_lines)1016        if had_trailing_newline:1017            new_content += "\n"1018        full_path.write_text(new_content)10191020        return Command(1021            update={1022                "messages": [1023                    ToolMessage(1024                        content=f"Text inserted in {path}",1025                        tool_call_id=tool_call_id,1026                        name=self.tool_name,1027                    )1028                ]1029            }1030        )10311032    def _handle_delete(self, args: dict, tool_call_id: str | None) -> Command:1033        """Handle delete command."""1034        path = args["path"]1035        full_path = self._validate_and_resolve_path(path)10361037        if full_path.is_file():1038            full_path.unlink()1039        elif full_path.is_dir():1040            shutil.rmtree(full_path)1041        # If doesn't exist, silently succeed10421043        return Command(1044            update={1045                "messages": [1046                    ToolMessage(1047                        content=f"File deleted: {path}",1048                        tool_call_id=tool_call_id,1049                        name=self.tool_name,1050                    )1051                ]1052            }1053        )10541055    def _handle_rename(self, args: dict, tool_call_id: str | None) -> Command:1056        """Handle rename command."""1057        old_path = args["old_path"]1058        new_path = args["new_path"]10591060        old_full = self._validate_and_resolve_path(old_path)1061        new_full = self._validate_and_resolve_path(new_path)10621063        if not old_full.exists():1064            msg = f"File not found: {old_path}"1065            raise ValueError(msg)10661067        # Create parent directory for new path1068        new_full.parent.mkdir(parents=True, exist_ok=True)10691070        # Rename1071        old_full.rename(new_full)10721073        return Command(1074            update={1075                "messages": [1076                    ToolMessage(1077                        content=f"File renamed: {old_path} -> {new_path}",1078                        tool_call_id=tool_call_id,1079                        name=self.tool_name,1080                    )1081                ]1082            }1083        )108410851086class FilesystemClaudeTextEditorMiddleware(_FilesystemClaudeFileToolMiddleware):1087    """Filesystem-based text editor tool middleware.10881089    Provides Anthropic's `text_editor` tool using local filesystem for storage.1090    User handles persistence via volumes, git, or other mechanisms.10911092    Example:1093        ```python1094        from langchain.agents import create_agent1095        from langchain.agents.middleware import FilesystemTextEditorToolMiddleware10961097        agent = create_agent(1098            model=model,1099            tools=[],1100            middleware=[FilesystemTextEditorToolMiddleware(root_path="/workspace")],1101        )1102        ```1103    """11041105    def __init__(1106        self,1107        *,1108        root_path: str,1109        allowed_prefixes: list[str] | None = None,1110        max_file_size_mb: int = 10,1111    ) -> None:1112        """Initialize the text editor middleware.11131114        Args:1115            root_path: Root directory for file operations.1116            allowed_prefixes: Optional list of allowed virtual path prefixes.11171118                Defaults to `['/']`.1119            max_file_size_mb: Maximum file size in MB11201121                Defaults to `10`.1122        """1123        super().__init__(1124            tool_type=TEXT_EDITOR_TOOL_TYPE,1125            tool_name=TEXT_EDITOR_TOOL_NAME,1126            root_path=root_path,1127            allowed_prefixes=allowed_prefixes,1128            max_file_size_mb=max_file_size_mb,1129        )113011311132class FilesystemClaudeMemoryMiddleware(_FilesystemClaudeFileToolMiddleware):1133    """Filesystem-based memory tool middleware.11341135    Provides Anthropic's memory tool using local filesystem for storage.1136    User handles persistence via volumes, git, or other mechanisms.11371138    Enforces `/memories` prefix and injects Anthropic's recommended system1139    prompt.11401141    Example:1142        ```python1143        from langchain.agents import create_agent1144        from langchain.agents.middleware import FilesystemMemoryToolMiddleware11451146        agent = create_agent(1147            model=model,1148            tools=[],1149            middleware=[FilesystemMemoryToolMiddleware(root_path="/workspace")],1150        )1151        ```1152    """11531154    def __init__(1155        self,1156        *,1157        root_path: str,1158        allowed_prefixes: list[str] | None = None,1159        max_file_size_mb: int = 10,1160        system_prompt: str = MEMORY_SYSTEM_PROMPT,1161    ) -> None:1162        """Initialize the memory middleware.11631164        Args:1165            root_path: Root directory for file operations.1166            allowed_prefixes: Optional list of allowed virtual path prefixes.11671168                Defaults to `['/memories']`.1169            max_file_size_mb: Maximum file size in MB11701171                Defaults to `10`.1172            system_prompt: System prompt to inject.11731174                Defaults to Anthropic's recommended memory prompt.1175        """1176        super().__init__(1177            tool_type=MEMORY_TOOL_TYPE,1178            tool_name=MEMORY_TOOL_NAME,1179            root_path=root_path,1180            allowed_prefixes=allowed_prefixes or ["/memories"],1181            max_file_size_mb=max_file_size_mb,1182            system_prompt=system_prompt,1183        )118411851186__all__ = [1187    "AnthropicToolsState",1188    "FileData",1189    "FilesystemClaudeMemoryMiddleware",1190    "FilesystemClaudeTextEditorMiddleware",1191    "StateClaudeMemoryMiddleware",1192    "StateClaudeTextEditorMiddleware",1193]

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.