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 _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]
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.