Ensure functions have docstrings for documentation
def files_reducer(
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]
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.