From bd5ea78bfddf033a3450c9257cb41ee61ea28b75 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Thu, 9 Oct 2025 12:58:08 +0100 Subject: [PATCH 01/22] feat(langchain_v1): Add Anthropic tools middleware with text editor, memory, and file search Middleware Classes Text Editor Tools - StateClaudeTextEditorToolMiddleware: In-memory text editor using agent state - FilesystemClaudeTextEditorToolMiddleware: Text editor operating on real filesystem Implementing Claude's text editor tools https://docs.claude.com/en/docs/agents-and-tools/tool-use/text-editor-tool Operations: view, create, str_replace, insert Memory Tools - StateClaudeMemoryToolMiddleware: Memory persistence in agent state - FilesystemClaudeMemoryToolMiddleware: Memory persistence on filesystem Implementing Claude's memory tools https://docs.claude.com/en/docs/agents-and-tools/tool-use/memory-tool Operations: Same as text editor plus delete and rename File Search Tools - StateFileSearchMiddleware: Search state-based files - FilesystemFileSearchMiddleware: Search real filesystem Provides Glob and Grep tools with same schema as used by Claude Code (but compatible with any model) - Glob: Pattern matching (e.g., **/*.py, src/**/*.ts), sorted by modification time - Grep: Regex content search with output modes (files_with_matches, content, count) Usage ``` from langchain.agents import create_agent from langchain.agents.middleware import ( StateTextEditorToolMiddleware, StateFileSearchMiddleware, ) agent = create_agent( model=model, tools=[], middleware=[ StateTextEditorToolMiddleware(), StateFileSearchMiddleware(), ], ) ``` --- .claude/settings.local.json | 6 +- .../langchain/agents/middleware/__init__.py | 17 + .../agents/middleware/anthropic_tools.py | 1027 +++++++++++++++++ .../agents/middleware/file_search.py | 550 +++++++++ .../agents/middleware/test_anthropic_tools.py | 276 +++++ .../agents/middleware/test_file_search.py | 461 ++++++++ 6 files changed, 2336 insertions(+), 1 deletion(-) create mode 100644 libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py create mode 100644 libs/langchain_v1/langchain/agents/middleware/file_search.py create mode 100644 libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py create mode 100644 libs/langchain_v1/tests/unit_tests/agents/middleware/test_file_search.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c39d2e22d2f11..a6fc3466df473 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,11 @@ "Bash(python3:*)", "WebFetch(domain:github.com)", "Bash(gh pr view:*)", - "Bash(gh pr diff:*)" + "Bash(gh pr diff:*)", + "WebFetch(domain:langchain-ai.github.io)", + "WebFetch(domain:gist.github.com)", + "WebFetch(domain:kirshatrov.com)", + "WebFetch(domain:aiengineerguide.com)" ], "deny": [], "ask": [] diff --git a/libs/langchain_v1/langchain/agents/middleware/__init__.py b/libs/langchain_v1/langchain/agents/middleware/__init__.py index d7066c6792072..8ab86583890c6 100644 --- a/libs/langchain_v1/langchain/agents/middleware/__init__.py +++ b/libs/langchain_v1/langchain/agents/middleware/__init__.py @@ -1,9 +1,18 @@ """Middleware plugins for agents.""" +from .anthropic_tools import ( + AnthropicToolsState, + FileData, + FilesystemClaudeMemoryMiddleware, + FilesystemClaudeTextEditorMiddleware, + StateClaudeMemoryMiddleware, + StateClaudeTextEditorMiddleware, +) from .context_editing import ( ClearToolUsesEdit, ContextEditingMiddleware, ) +from .file_search import FilesystemFileSearchMiddleware, StateFileSearchMiddleware from .human_in_the_loop import HumanInTheLoopMiddleware from .model_call_limit import ModelCallLimitMiddleware from .model_fallback import ModelFallbackMiddleware @@ -31,8 +40,13 @@ "AgentState", # should move to langchain-anthropic if we decide to keep it "AnthropicPromptCachingMiddleware", + "AnthropicToolsState", "ClearToolUsesEdit", "ContextEditingMiddleware", + "FileData", + "FilesystemClaudeMemoryMiddleware", + "FilesystemClaudeTextEditorMiddleware", + "FilesystemFileSearchMiddleware", "HumanInTheLoopMiddleware", "LLMToolSelectorMiddleware", "ModelCallLimitMiddleware", @@ -41,6 +55,9 @@ "PIIDetectionError", "PIIMiddleware", "PlanningMiddleware", + "StateClaudeMemoryMiddleware", + "StateClaudeTextEditorMiddleware", + "StateFileSearchMiddleware", "SummarizationMiddleware", "ToolCallLimitMiddleware", "after_agent", diff --git a/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py b/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py new file mode 100644 index 0000000000000..1ae5e5a8657e1 --- /dev/null +++ b/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py @@ -0,0 +1,1027 @@ +"""Anthropic text editor and memory tool middleware. + +This module provides client-side implementations of Anthropic's text editor and +memory tools using schema-less tool definitions and tool call interception. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, cast + +from langchain_core.messages import AIMessage, ToolMessage +from langgraph.types import Command +from typing_extensions import NotRequired, TypedDict + +from langchain.agents.middleware.types import AgentMiddleware, AgentState, ModelRequest + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from langchain.tools.tool_node import ToolCallRequest + +# Tool type constants +TEXT_EDITOR_TOOL_TYPE = "text_editor_20250728" +TEXT_EDITOR_TOOL_NAME = "str_replace_based_edit_tool" +MEMORY_TOOL_TYPE = "memory_20250818" +MEMORY_TOOL_NAME = "memory" + +MEMORY_SYSTEM_PROMPT = """IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE \ +DOING ANYTHING ELSE. +MEMORY PROTOCOL: +1. Use the `view` command of your `memory` tool to check for earlier progress. +2. ... (work on the task) ... + - As you make progress, record status / progress / thoughts etc in your memory. +ASSUME INTERRUPTION: Your context window might be reset at any moment, so you risk \ +losing any progress that is not recorded in your memory directory.""" + + +class FileData(TypedDict): + """Data structure for storing file contents.""" + + content: list[str] + """Lines of the file.""" + + created_at: str + """ISO 8601 timestamp of file creation.""" + + modified_at: str + """ISO 8601 timestamp of last modification.""" + + +def files_reducer( + left: dict[str, FileData] | None, right: dict[str, FileData | None] +) -> dict[str, FileData]: + """Custom reducer that merges file updates. + + Args: + left: Existing files dict. + right: New files dict to merge (None values delete files). + + Returns: + Merged dict where right overwrites left for matching keys. + """ + if left is None: + # Filter out None values when initializing + return {k: v for k, v in right.items() if v is not None} + + # Merge, filtering out None values (deletions) + result = {**left} + for k, v in right.items(): + if v is None: + result.pop(k, None) + else: + result[k] = v + return result + + +class AnthropicToolsState(AgentState): + """State schema for Anthropic text editor and memory tools.""" + + text_editor_files: NotRequired[Annotated[dict[str, FileData], files_reducer]] + """Virtual file system for text editor tools.""" + + memory_files: NotRequired[Annotated[dict[str, FileData], files_reducer]] + """Virtual file system for memory tools.""" + + +def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str: + """Validate and normalize file path for security. + + Args: + path: The path to validate. + allowed_prefixes: Optional list of allowed path prefixes. + + Returns: + Normalized canonical path. + + Raises: + ValueError: If path contains traversal sequences or violates prefix rules. + """ + # Reject paths with traversal attempts + if ".." in path or path.startswith("~"): + msg = f"Path traversal not allowed: {path}" + raise ValueError(msg) + + # Normalize path (resolve ., //, etc.) + normalized = os.path.normpath(path) + + # Convert to forward slashes for consistency + normalized = normalized.replace("\\", "/") + + # Ensure path starts with / + if not normalized.startswith("/"): + normalized = f"/{normalized}" + + # Check allowed prefixes if specified + if allowed_prefixes is not None and not any( + normalized.startswith(prefix) for prefix in allowed_prefixes + ): + msg = f"Path must start with one of {allowed_prefixes}: {path}" + raise ValueError(msg) + + return normalized + + +def _list_directory(files: dict[str, FileData], path: str) -> list[str]: + """List files in a directory. + + Args: + files: Files dict. + path: Normalized directory path. + + Returns: + Sorted list of file paths in the directory. + """ + # Ensure path ends with / for directory matching + dir_path = path if path.endswith("/") else f"{path}/" + + matching_files = [] + for file_path in files: + if file_path.startswith(dir_path): + # Get relative path from directory + relative = file_path[len(dir_path) :] + # Only include direct children (no subdirectories) + if "/" not in relative: + matching_files.append(file_path) + + return sorted(matching_files) + + +class _StateClaudeFileToolMiddleware(AgentMiddleware): + """Base class for state-based file tool middleware (internal).""" + + state_schema = AnthropicToolsState + + def __init__( + self, + *, + tool_type: str, + tool_name: str, + state_key: str, + allowed_path_prefixes: Sequence[str] | None = None, + system_prompt: str | None = None, + ) -> None: + """Initialize the middleware. + + Args: + tool_type: Tool type identifier. + tool_name: Tool name. + state_key: State key for file storage. + allowed_path_prefixes: Optional list of allowed path prefixes. + system_prompt: Optional system prompt to inject. + """ + self.tool_type = tool_type + self.tool_name = tool_name + self.state_key = state_key + self.allowed_prefixes = allowed_path_prefixes + self.system_prompt = system_prompt + + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], AIMessage], + ) -> AIMessage: + """Inject tool and optional system prompt.""" + # Add tool + tools = list(request.tools or []) + tools.append( + { + "type": self.tool_type, + "name": self.tool_name, + } + ) + request.tools = tools + + # Inject system prompt if provided + if self.system_prompt: + request.system_prompt = ( + request.system_prompt + "\n\n" + self.system_prompt + if request.system_prompt + else self.system_prompt + ) + + return handler(request) + + def wrap_tool_call( + self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command] + ) -> ToolMessage | Command: + """Intercept tool calls.""" + tool_call = request.tool_call + tool_name = tool_call.get("name") + + if tool_name != self.tool_name: + return handler(request) + + # Handle tool call + try: + args = tool_call.get("args", {}) + command = args.get("command") + state = request.state + + if command == "view": + return self._handle_view(args, state, tool_call["id"]) + if command == "create": + return self._handle_create(args, state, tool_call["id"]) + if command == "str_replace": + return self._handle_str_replace(args, state, tool_call["id"]) + if command == "insert": + return self._handle_insert(args, state, tool_call["id"]) + if command == "delete": + return self._handle_delete(args, state, tool_call["id"]) + if command == "rename": + return self._handle_rename(args, state, tool_call["id"]) + + msg = f"Unknown command: {command}" + return ToolMessage( + content=msg, + tool_call_id=tool_call["id"], + name=tool_name, + status="error", + ) + except (ValueError, FileNotFoundError) as e: + return ToolMessage( + content=str(e), + tool_call_id=tool_call["id"], + name=tool_name, + status="error", + ) + + def _handle_view( + self, args: dict, state: AnthropicToolsState, tool_call_id: str | None + ) -> Command: + """Handle view command.""" + path = args["path"] + normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + + files = cast("dict[str, Any]", state.get(self.state_key, {})) + file_data = files.get(normalized_path) + + if file_data is None: + # Try directory listing + matching = _list_directory(files, normalized_path) + + if matching: + content = "\n".join(matching) + return Command( + update={ + "messages": [ + ToolMessage( + content=content, + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ] + } + ) + + msg = f"File not found: {path}" + raise FileNotFoundError(msg) + + # Format file content with line numbers + lines_content = file_data["content"] + formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines_content)] + content = "\n".join(formatted_lines) + + return Command( + update={ + "messages": [ + ToolMessage( + content=content, + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ] + } + ) + + def _handle_create( + self, args: dict, state: AnthropicToolsState, tool_call_id: str | None + ) -> Command: + """Handle create command.""" + path = args["path"] + file_text = args["file_text"] + + normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + + # Get existing files + files = cast("dict[str, Any]", state.get(self.state_key, {})) + existing = files.get(normalized_path) + + # Create file data + now = datetime.now(timezone.utc).isoformat() + created_at = existing["created_at"] if existing else now + + content_lines = file_text.split("\n") + + return Command( + update={ + self.state_key: { + normalized_path: { + "content": content_lines, + "created_at": created_at, + "modified_at": now, + } + }, + "messages": [ + ToolMessage( + content=f"File created: {path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ], + } + ) + + def _handle_str_replace( + self, args: dict, state: AnthropicToolsState, tool_call_id: str | None + ) -> Command: + """Handle str_replace command.""" + path = args["path"] + old_str = args["old_str"] + new_str = args.get("new_str", "") + + normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + + # Read file + files = cast("dict[str, Any]", state.get(self.state_key, {})) + file_data = files.get(normalized_path) + if file_data is None: + msg = f"File not found: {path}" + raise FileNotFoundError(msg) + + lines_content = file_data["content"] + content = "\n".join(lines_content) + + # Replace string + if old_str not in content: + msg = f"String not found in file: {old_str}" + raise ValueError(msg) + + new_content = content.replace(old_str, new_str, 1) + new_lines = new_content.split("\n") + + # Update file + now = datetime.now(timezone.utc).isoformat() + + return Command( + update={ + self.state_key: { + normalized_path: { + "content": new_lines, + "created_at": file_data["created_at"], + "modified_at": now, + } + }, + "messages": [ + ToolMessage( + content=f"String replaced in {path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ], + } + ) + + def _handle_insert( + self, args: dict, state: AnthropicToolsState, tool_call_id: str | None + ) -> Command: + """Handle insert command.""" + path = args["path"] + insert_line = args["insert_line"] + text_to_insert = args["new_str"] + + normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + + # Read file + files = cast("dict[str, Any]", state.get(self.state_key, {})) + file_data = files.get(normalized_path) + if file_data is None: + msg = f"File not found: {path}" + raise FileNotFoundError(msg) + + lines_content = file_data["content"] + new_lines = text_to_insert.split("\n") + + # Insert after insert_line (0-indexed) + updated_lines = lines_content[:insert_line] + new_lines + lines_content[insert_line:] + + # Update file + now = datetime.now(timezone.utc).isoformat() + + return Command( + update={ + self.state_key: { + normalized_path: { + "content": updated_lines, + "created_at": file_data["created_at"], + "modified_at": now, + } + }, + "messages": [ + ToolMessage( + content=f"Text inserted in {path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ], + } + ) + + def _handle_delete( + self, + args: dict, + state: AnthropicToolsState, # noqa: ARG002 + tool_call_id: str | None, + ) -> Command: + """Handle delete command.""" + path = args["path"] + + normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + + return Command( + update={ + self.state_key: {normalized_path: None}, + "messages": [ + ToolMessage( + content=f"File deleted: {path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ], + } + ) + + def _handle_rename( + self, args: dict, state: AnthropicToolsState, tool_call_id: str | None + ) -> Command: + """Handle rename command.""" + old_path = args["old_path"] + new_path = args["new_path"] + + normalized_old = _validate_path(old_path, allowed_prefixes=self.allowed_prefixes) + normalized_new = _validate_path(new_path, allowed_prefixes=self.allowed_prefixes) + + # Read file + files = cast("dict[str, Any]", state.get(self.state_key, {})) + file_data = files.get(normalized_old) + if file_data is None: + msg = f"File not found: {old_path}" + raise ValueError(msg) + + # Update timestamp + now = datetime.now(timezone.utc).isoformat() + file_data_copy = file_data.copy() + file_data_copy["modified_at"] = now + + return Command( + update={ + self.state_key: { + normalized_old: None, + normalized_new: file_data_copy, + }, + "messages": [ + ToolMessage( + content=f"File renamed: {old_path} -> {new_path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ], + } + ) + + +class StateClaudeTextEditorMiddleware(_StateClaudeFileToolMiddleware): + """State-based text editor tool middleware. + + Provides Anthropic's text_editor tool using LangGraph state for storage. + Files persist for the conversation thread. + + Example: + ```python + from langchain.agents import create_agent + from langchain.agents.middleware import StateTextEditorToolMiddleware + + agent = create_agent( + model=model, + tools=[], + middleware=[StateTextEditorToolMiddleware()], + ) + ``` + """ + + def __init__( + self, + *, + allowed_path_prefixes: Sequence[str] | None = None, + ) -> None: + """Initialize the text editor middleware. + + Args: + allowed_path_prefixes: Optional list of allowed path prefixes. + If specified, only paths starting with these prefixes are allowed. + """ + super().__init__( + tool_type=TEXT_EDITOR_TOOL_TYPE, + tool_name=TEXT_EDITOR_TOOL_NAME, + state_key="text_editor_files", + allowed_path_prefixes=allowed_path_prefixes, + ) + + +class StateClaudeMemoryMiddleware(_StateClaudeFileToolMiddleware): + """State-based memory tool middleware. + + Provides Anthropic's memory tool using LangGraph state for storage. + Files persist for the conversation thread. Enforces /memories prefix + and injects Anthropic's recommended system prompt. + + Example: + ```python + from langchain.agents import create_agent + from langchain.agents.middleware import StateMemoryToolMiddleware + + agent = create_agent( + model=model, + tools=[], + middleware=[StateMemoryToolMiddleware()], + ) + ``` + """ + + def __init__( + self, + *, + allowed_path_prefixes: Sequence[str] | None = None, + system_prompt: str = MEMORY_SYSTEM_PROMPT, + ) -> None: + """Initialize the memory middleware. + + Args: + allowed_path_prefixes: Optional list of allowed path prefixes. + Defaults to ["/memories"]. + system_prompt: System prompt to inject. Defaults to Anthropic's + recommended memory prompt. + """ + super().__init__( + tool_type=MEMORY_TOOL_TYPE, + tool_name=MEMORY_TOOL_NAME, + state_key="memory_files", + allowed_path_prefixes=allowed_path_prefixes or ["/memories"], + system_prompt=system_prompt, + ) + + +class _FilesystemClaudeFileToolMiddleware(AgentMiddleware): + """Base class for filesystem-based file tool middleware (internal).""" + + def __init__( + self, + *, + tool_type: str, + tool_name: str, + root_path: str, + allowed_prefixes: list[str] | None = None, + max_file_size_mb: int = 10, + system_prompt: str | None = None, + ) -> None: + """Initialize the middleware. + + Args: + tool_type: Tool type identifier. + tool_name: Tool name. + root_path: Root directory for file operations. + allowed_prefixes: Optional list of allowed virtual path prefixes. + max_file_size_mb: Maximum file size in MB. + system_prompt: Optional system prompt to inject. + """ + self.tool_type = tool_type + self.tool_name = tool_name + self.root_path = Path(root_path).resolve() + self.allowed_prefixes = allowed_prefixes or ["/"] + self.max_file_size_bytes = max_file_size_mb * 1024 * 1024 + self.system_prompt = system_prompt + + # Create root directory if it doesn't exist + self.root_path.mkdir(parents=True, exist_ok=True) + + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], AIMessage], + ) -> AIMessage: + """Inject tool and optional system prompt.""" + # Add tool + tools = list(request.tools or []) + tools.append( + { + "type": self.tool_type, + "name": self.tool_name, + } + ) + request.tools = tools + + # Inject system prompt if provided + if self.system_prompt: + request.system_prompt = ( + request.system_prompt + "\n\n" + self.system_prompt + if request.system_prompt + else self.system_prompt + ) + + return handler(request) + + def wrap_tool_call( + self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command] + ) -> ToolMessage | Command: + """Intercept tool calls.""" + tool_call = request.tool_call + tool_name = tool_call.get("name") + + if tool_name != self.tool_name: + return handler(request) + + # Handle tool call + try: + args = tool_call.get("args", {}) + command = args.get("command") + + if command == "view": + return self._handle_view(args, tool_call["id"]) + if command == "create": + return self._handle_create(args, tool_call["id"]) + if command == "str_replace": + return self._handle_str_replace(args, tool_call["id"]) + if command == "insert": + return self._handle_insert(args, tool_call["id"]) + if command == "delete": + return self._handle_delete(args, tool_call["id"]) + if command == "rename": + return self._handle_rename(args, tool_call["id"]) + + msg = f"Unknown command: {command}" + return ToolMessage( + content=msg, + tool_call_id=tool_call["id"], + name=tool_name, + status="error", + ) + except (ValueError, FileNotFoundError) as e: + return ToolMessage( + content=str(e), + tool_call_id=tool_call["id"], + name=tool_name, + status="error", + ) + + def _validate_and_resolve_path(self, path: str) -> Path: + """Validate and resolve a virtual path to filesystem path. + + Args: + path: Virtual path (e.g., /file.txt or /src/main.py). + + Returns: + Resolved absolute filesystem path within root_path. + + Raises: + ValueError: If path contains traversal attempts, escapes root directory, + or violates allowed_prefixes restrictions. + """ + # Normalize path + if not path.startswith("/"): + path = "/" + path + + # Check for path traversal + if ".." in path or "~" in path: + msg = "Path traversal not allowed" + raise ValueError(msg) + + # Convert virtual path to filesystem path + # Remove leading / and resolve relative to root + relative = path.lstrip("/") + full_path = (self.root_path / relative).resolve() + + # Ensure path is within root + try: + full_path.relative_to(self.root_path) + except ValueError: + msg = f"Path outside root directory: {path}" + raise ValueError(msg) from None + + # Check allowed prefixes + virtual_path = "/" + str(full_path.relative_to(self.root_path)) + if self.allowed_prefixes: + allowed = any( + virtual_path.startswith(prefix) or virtual_path == prefix.rstrip("/") + for prefix in self.allowed_prefixes + ) + if not allowed: + msg = f"Path must start with one of: {self.allowed_prefixes}" + raise ValueError(msg) + + return full_path + + def _handle_view(self, args: dict, tool_call_id: str | None) -> Command: + """Handle view command.""" + path = args["path"] + full_path = self._validate_and_resolve_path(path) + + if not full_path.exists() or not full_path.is_file(): + msg = f"File not found: {path}" + raise FileNotFoundError(msg) + + # Check file size + if full_path.stat().st_size > self.max_file_size_bytes: + msg = f"File too large: {path} exceeds {self.max_file_size_bytes / 1024 / 1024}MB" + raise ValueError(msg) + + # Read file + try: + content = full_path.read_text() + except UnicodeDecodeError as e: + msg = f"Cannot decode file {path}: {e}" + raise ValueError(msg) from e + + # Format with line numbers + lines = content.split("\n") + # Remove trailing newline's empty string if present + if lines and lines[-1] == "": + lines = lines[:-1] + formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines)] + formatted_content = "\n".join(formatted_lines) + + return Command( + update={ + "messages": [ + ToolMessage( + content=formatted_content, + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ] + } + ) + + def _handle_create(self, args: dict, tool_call_id: str | None) -> Command: + """Handle create command.""" + path = args["path"] + file_text = args["file_text"] + + full_path = self._validate_and_resolve_path(path) + + # Create parent directories + full_path.parent.mkdir(parents=True, exist_ok=True) + + # Write file + full_path.write_text(file_text + "\n") + + return Command( + update={ + "messages": [ + ToolMessage( + content=f"File created: {path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ] + } + ) + + def _handle_str_replace(self, args: dict, tool_call_id: str | None) -> Command: + """Handle str_replace command.""" + path = args["path"] + old_str = args["old_str"] + new_str = args.get("new_str", "") + + full_path = self._validate_and_resolve_path(path) + + if not full_path.exists(): + msg = f"File not found: {path}" + raise FileNotFoundError(msg) + + # Read file + content = full_path.read_text() + + # Replace string + if old_str not in content: + msg = f"String not found in file: {old_str}" + raise ValueError(msg) + + new_content = content.replace(old_str, new_str, 1) + + # Write back + full_path.write_text(new_content) + + return Command( + update={ + "messages": [ + ToolMessage( + content=f"String replaced in {path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ] + } + ) + + def _handle_insert(self, args: dict, tool_call_id: str | None) -> Command: + """Handle insert command.""" + path = args["path"] + insert_line = args["insert_line"] + text_to_insert = args["new_str"] + + full_path = self._validate_and_resolve_path(path) + + if not full_path.exists(): + msg = f"File not found: {path}" + raise FileNotFoundError(msg) + + # Read file + content = full_path.read_text() + lines = content.split("\n") + # Handle trailing newline + if lines and lines[-1] == "": + lines = lines[:-1] + had_trailing_newline = True + else: + had_trailing_newline = False + + new_lines = text_to_insert.split("\n") + + # Insert after insert_line (0-indexed) + updated_lines = lines[:insert_line] + new_lines + lines[insert_line:] + + # Write back + new_content = "\n".join(updated_lines) + if had_trailing_newline: + new_content += "\n" + full_path.write_text(new_content) + + return Command( + update={ + "messages": [ + ToolMessage( + content=f"Text inserted in {path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ] + } + ) + + def _handle_delete(self, args: dict, tool_call_id: str | None) -> Command: + """Handle delete command.""" + import shutil + + path = args["path"] + full_path = self._validate_and_resolve_path(path) + + if full_path.is_file(): + full_path.unlink() + elif full_path.is_dir(): + shutil.rmtree(full_path) + # If doesn't exist, silently succeed + + return Command( + update={ + "messages": [ + ToolMessage( + content=f"File deleted: {path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ] + } + ) + + def _handle_rename(self, args: dict, tool_call_id: str | None) -> Command: + """Handle rename command.""" + old_path = args["old_path"] + new_path = args["new_path"] + + old_full = self._validate_and_resolve_path(old_path) + new_full = self._validate_and_resolve_path(new_path) + + if not old_full.exists(): + msg = f"File not found: {old_path}" + raise ValueError(msg) + + # Create parent directory for new path + new_full.parent.mkdir(parents=True, exist_ok=True) + + # Rename + old_full.rename(new_full) + + return Command( + update={ + "messages": [ + ToolMessage( + content=f"File renamed: {old_path} -> {new_path}", + tool_call_id=tool_call_id, + name=self.tool_name, + ) + ] + } + ) + + +class FilesystemClaudeTextEditorMiddleware(_FilesystemClaudeFileToolMiddleware): + """Filesystem-based text editor tool middleware. + + Provides Anthropic's text_editor tool using local filesystem for storage. + User handles persistence via volumes, git, or other mechanisms. + + Example: + ```python + from langchain.agents import create_agent + from langchain.agents.middleware import FilesystemTextEditorToolMiddleware + + agent = create_agent( + model=model, + tools=[], + middleware=[FilesystemTextEditorToolMiddleware(root_path="/workspace")], + ) + ``` + """ + + def __init__( + self, + *, + root_path: str, + allowed_prefixes: list[str] | None = None, + max_file_size_mb: int = 10, + ) -> None: + """Initialize the text editor middleware. + + Args: + root_path: Root directory for file operations. + allowed_prefixes: Optional list of allowed virtual path prefixes (default: ["/"]). + max_file_size_mb: Maximum file size in MB (default: 10). + """ + super().__init__( + tool_type=TEXT_EDITOR_TOOL_TYPE, + tool_name=TEXT_EDITOR_TOOL_NAME, + root_path=root_path, + allowed_prefixes=allowed_prefixes, + max_file_size_mb=max_file_size_mb, + ) + + +class FilesystemClaudeMemoryMiddleware(_FilesystemClaudeFileToolMiddleware): + """Filesystem-based memory tool middleware. + + Provides Anthropic's memory tool using local filesystem for storage. + User handles persistence via volumes, git, or other mechanisms. + Enforces /memories prefix and injects Anthropic's recommended system prompt. + + Example: + ```python + from langchain.agents import create_agent + from langchain.agents.middleware import FilesystemMemoryToolMiddleware + + agent = create_agent( + model=model, + tools=[], + middleware=[FilesystemMemoryToolMiddleware(root_path="/workspace")], + ) + ``` + """ + + def __init__( + self, + *, + root_path: str, + allowed_prefixes: list[str] | None = None, + max_file_size_mb: int = 10, + system_prompt: str = MEMORY_SYSTEM_PROMPT, + ) -> None: + """Initialize the memory middleware. + + Args: + root_path: Root directory for file operations. + allowed_prefixes: Optional list of allowed virtual path prefixes. + Defaults to ["/memories"]. + max_file_size_mb: Maximum file size in MB (default: 10). + system_prompt: System prompt to inject. Defaults to Anthropic's + recommended memory prompt. + """ + super().__init__( + tool_type=MEMORY_TOOL_TYPE, + tool_name=MEMORY_TOOL_NAME, + root_path=root_path, + allowed_prefixes=allowed_prefixes or ["/memories"], + max_file_size_mb=max_file_size_mb, + system_prompt=system_prompt, + ) + + +__all__ = [ + "AnthropicToolsState", + "FileData", + "FilesystemClaudeMemoryMiddleware", + "FilesystemClaudeTextEditorMiddleware", + "StateClaudeMemoryMiddleware", + "StateClaudeTextEditorMiddleware", +] diff --git a/libs/langchain_v1/langchain/agents/middleware/file_search.py b/libs/langchain_v1/langchain/agents/middleware/file_search.py new file mode 100644 index 0000000000000..34d31ddebe54b --- /dev/null +++ b/libs/langchain_v1/langchain/agents/middleware/file_search.py @@ -0,0 +1,550 @@ +"""File search middleware for Anthropic text editor and memory tools. + +This module provides Glob and Grep search tools that operate on files stored +in state or filesystem. +""" + +from __future__ import annotations + +import fnmatch +import json +import re +import subprocess +from contextlib import suppress +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Annotated, Any, Literal, cast + +from langchain_core.tools import InjectedToolArg, tool + +from langchain.agents.middleware.anthropic_tools import AnthropicToolsState +from langchain.agents.middleware.types import AgentMiddleware + + +class StateFileSearchMiddleware(AgentMiddleware): + """Provides Glob and Grep search over state-based files. + + This middleware adds two tools that search through virtual files in state: + - Glob: Fast file pattern matching by file path + - Grep: Fast content search using regular expressions + + Example: + ```python + from langchain.agents import create_agent + from langchain.agents.middleware import ( + StateTextEditorToolMiddleware, + StateFileSearchMiddleware, + ) + + agent = create_agent( + model=model, + tools=[], + middleware=[ + StateTextEditorToolMiddleware(), + StateFileSearchMiddleware(), + ], + ) + ``` + """ + + state_schema = AnthropicToolsState + + def __init__( + self, + *, + state_key: str = "text_editor_files", + ) -> None: + """Initialize the search middleware. + + Args: + state_key: State key to search (default: "text_editor_files"). + Use "memory_files" to search memory tool files. + """ + self.state_key = state_key + + # Create tool instances + @tool + def glob_search( # noqa: D417 + pattern: str, + path: str = "/", + state: Annotated[AnthropicToolsState, InjectedToolArg] = None, # type: ignore[assignment] + ) -> str: + """Fast file pattern matching tool that works with any codebase size. + + Supports glob patterns like **/*.js or src/**/*.ts. + Returns matching file paths sorted by modification time. + Use this tool when you need to find files by name patterns. + + Args: + pattern: The glob pattern to match files against. + path: The directory to search in. If not specified, searches from root. + + Returns: + Newline-separated list of matching file paths, sorted by modification + time (most recently modified first). Returns "No files found" if no + matches. + """ + # Normalize base path + base_path = path if path.startswith("/") else "/" + path + + # Get files from state + files = cast("dict[str, Any]", state.get(self.state_key, {})) + + # Match files + matches = [] + for file_path, file_data in files.items(): + if file_path.startswith(base_path): + # Get relative path from base + if base_path == "/": + relative = file_path[1:] # Remove leading / + elif file_path == base_path: + relative = Path(file_path).name + elif file_path.startswith(base_path + "/"): + relative = file_path[len(base_path) + 1 :] + else: + continue + + # Match against pattern + # Handle ** pattern which requires special care + # PurePosixPath.match doesn't match single-level paths against **/pattern + is_match = PurePosixPath(relative).match(pattern) + if not is_match and pattern.startswith("**/"): + # Also try matching without the **/ prefix for files in base dir + is_match = PurePosixPath(relative).match(pattern[3:]) + + if is_match: + matches.append((file_path, file_data["modified_at"])) + + if not matches: + return "No files found" + + # Sort by modification time + matches.sort(key=lambda x: x[1], reverse=True) + file_paths = [path for path, _ in matches] + + return "\n".join(file_paths) + + @tool + def grep_search( # noqa: D417 + pattern: str, + path: str = "/", + include: str | None = None, + output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches", + state: Annotated[AnthropicToolsState, InjectedToolArg] = None, # type: ignore[assignment] + ) -> str: + """Fast content search tool that works with any codebase size. + + Searches file contents using regular expressions. Supports full regex + syntax and filters files by pattern with the include parameter. + + Args: + pattern: The regular expression pattern to search for in file contents. + path: The directory to search in. If not specified, searches from root. + include: File pattern to filter (e.g., "*.js", "*.{ts,tsx}"). + output_mode: Output format: + - "files_with_matches": Only file paths containing matches (default) + - "content": Matching lines with file:line:content format + - "count": Count of matches per file + + Returns: + Search results formatted according to output_mode. Returns "No matches + found" if no results. + """ + # Normalize base path + base_path = path if path.startswith("/") else "/" + path + + # Compile regex pattern (for validation) + try: + regex = re.compile(pattern) + except re.error as e: + return f"Invalid regex pattern: {e}" + + # Search files + files = cast("dict[str, Any]", state.get(self.state_key, {})) + results: dict[str, list[tuple[int, str]]] = {} + + for file_path, file_data in files.items(): + if not file_path.startswith(base_path): + continue + + # Check include filter + if include: + basename = Path(file_path).name + if not self._match_include(basename, include): + continue + + # Search file content + for line_num, line in enumerate(file_data["content"], 1): + if regex.search(line): + if file_path not in results: + results[file_path] = [] + results[file_path].append((line_num, line)) + + if not results: + return "No matches found" + + # Format output based on mode + return self._format_grep_results(results, output_mode) + + self.glob_search = glob_search + self.grep_search = grep_search + self.tools = [glob_search, grep_search] + + def _match_include(self, basename: str, pattern: str) -> bool: + """Match filename against include pattern.""" + # Handle brace expansion {a,b,c} + if "{" in pattern and "}" in pattern: + start = pattern.index("{") + end = pattern.index("}") + prefix = pattern[:start] + suffix = pattern[end + 1 :] + alternatives = pattern[start + 1 : end].split(",") + + for alt in alternatives: + expanded = prefix + alt + suffix + if fnmatch.fnmatch(basename, expanded): + return True + return False + return fnmatch.fnmatch(basename, pattern) + + def _format_grep_results( + self, + results: dict[str, list[tuple[int, str]]], + output_mode: str, + ) -> str: + """Format grep results based on output mode.""" + if output_mode == "files_with_matches": + # Just return file paths + return "\n".join(sorted(results.keys())) + + if output_mode == "content": + # Return file:line:content format + lines = [] + for file_path in sorted(results.keys()): + for line_num, line in results[file_path]: + lines.append(f"{file_path}:{line_num}:{line}") + return "\n".join(lines) + + if output_mode == "count": + # Return file:count format + lines = [] + for file_path in sorted(results.keys()): + count = len(results[file_path]) + lines.append(f"{file_path}:{count}") + return "\n".join(lines) + + # Default to files_with_matches + return "\n".join(sorted(results.keys())) + + +class FilesystemFileSearchMiddleware(AgentMiddleware): + """Provides Glob and Grep search over filesystem files. + + This middleware adds two tools that search through local filesystem: + - Glob: Fast file pattern matching by file path + - Grep: Fast content search using ripgrep or Python fallback + + Example: + ```python + from langchain.agents import create_agent + from langchain.agents.middleware import ( + FilesystemTextEditorToolMiddleware, + FilesystemFileSearchMiddleware, + ) + + agent = create_agent( + model=model, + tools=[], + middleware=[ + FilesystemTextEditorToolMiddleware(root_path="/workspace"), + FilesystemFileSearchMiddleware(root_path="/workspace"), + ], + ) + ``` + """ + + def __init__( + self, + *, + root_path: str, + use_ripgrep: bool = True, + max_file_size_mb: int = 10, + ) -> None: + """Initialize the search middleware. + + Args: + root_path: Root directory to search. + use_ripgrep: Whether to use ripgrep for search (default: True). + Falls back to Python if ripgrep unavailable. + max_file_size_mb: Maximum file size to search in MB (default: 10). + """ + self.root_path = Path(root_path).resolve() + self.use_ripgrep = use_ripgrep + self.max_file_size_bytes = max_file_size_mb * 1024 * 1024 + + # Create tool instances as closures that capture self + @tool + def glob_search(pattern: str, path: str = "/") -> str: + """Fast file pattern matching tool that works with any codebase size. + + Supports glob patterns like **/*.js or src/**/*.ts. + Returns matching file paths sorted by modification time. + Use this tool when you need to find files by name patterns. + + Args: + pattern: The glob pattern to match files against. + path: The directory to search in. If not specified, searches from root. + + Returns: + Newline-separated list of matching file paths, sorted by modification + time (most recently modified first). Returns "No files found" if no + matches. + """ + try: + base_full = self._validate_and_resolve_path(path) + except ValueError: + return "No files found" + + if not base_full.exists() or not base_full.is_dir(): + return "No files found" + + # Use pathlib glob + matching: list[tuple[str, str]] = [] + for match in base_full.glob(pattern): + if match.is_file(): + # Convert to virtual path + virtual_path = "/" + str(match.relative_to(self.root_path)) + stat = match.stat() + modified_at = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() + matching.append((virtual_path, modified_at)) + + if not matching: + return "No files found" + + file_paths = [p for p, _ in matching] + return "\n".join(file_paths) + + @tool + def grep_search( + pattern: str, + path: str = "/", + include: str | None = None, + output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches", + ) -> str: + """Fast content search tool that works with any codebase size. + + Searches file contents using regular expressions. Supports full regex + syntax and filters files by pattern with the include parameter. + + Args: + pattern: The regular expression pattern to search for in file contents. + path: The directory to search in. If not specified, searches from root. + include: File pattern to filter (e.g., "*.js", "*.{ts,tsx}"). + output_mode: Output format: + - "files_with_matches": Only file paths containing matches (default) + - "content": Matching lines with file:line:content format + - "count": Count of matches per file + + Returns: + Search results formatted according to output_mode. Returns "No matches + found" if no results. + """ + # Compile regex pattern (for validation) + try: + re.compile(pattern) + except re.error as e: + return f"Invalid regex pattern: {e}" + + # Try ripgrep first if enabled + results = None + if self.use_ripgrep: + with suppress( + FileNotFoundError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): + results = self._ripgrep_search(pattern, path, include) + + # Python fallback if ripgrep failed or is disabled + if results is None: + results = self._python_search(pattern, path, include) + + if not results: + return "No matches found" + + # Format output based on mode + return self._format_grep_results(results, output_mode) + + self.glob_search = glob_search + self.grep_search = grep_search + self.tools = [glob_search, grep_search] + + def _validate_and_resolve_path(self, path: str) -> Path: + """Validate and resolve a virtual path to filesystem path.""" + # Normalize path + if not path.startswith("/"): + path = "/" + path + + # Check for path traversal + if ".." in path or "~" in path: + msg = "Path traversal not allowed" + raise ValueError(msg) + + # Convert virtual path to filesystem path + relative = path.lstrip("/") + full_path = (self.root_path / relative).resolve() + + # Ensure path is within root + try: + full_path.relative_to(self.root_path) + except ValueError: + msg = f"Path outside root directory: {path}" + raise ValueError(msg) from None + + return full_path + + def _ripgrep_search( + self, pattern: str, base_path: str, include: str | None + ) -> dict[str, list[tuple[int, str]]]: + """Search using ripgrep subprocess.""" + try: + base_full = self._validate_and_resolve_path(base_path) + except ValueError: + return {} + + if not base_full.exists(): + return {} + + # Build ripgrep command + cmd = ["rg", "--json", pattern, str(base_full)] + + if include: + # Convert glob pattern to ripgrep glob + cmd.extend(["--glob", include]) + + try: + result = subprocess.run( # noqa: S603 + cmd, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + # Fallback to Python search if ripgrep unavailable or times out + return self._python_search(pattern, base_path, include) + + # Parse ripgrep JSON output + results: dict[str, list[tuple[int, str]]] = {} + for line in result.stdout.splitlines(): + try: + data = json.loads(line) + if data["type"] == "match": + path = data["data"]["path"]["text"] + # Convert to virtual path + virtual_path = "/" + str(Path(path).relative_to(self.root_path)) + line_num = data["data"]["line_number"] + line_text = data["data"]["lines"]["text"].rstrip("\n") + + if virtual_path not in results: + results[virtual_path] = [] + results[virtual_path].append((line_num, line_text)) + except (json.JSONDecodeError, KeyError): + continue + + return results + + def _python_search( + self, pattern: str, base_path: str, include: str | None + ) -> dict[str, list[tuple[int, str]]]: + """Search using Python regex (fallback).""" + try: + base_full = self._validate_and_resolve_path(base_path) + except ValueError: + return {} + + if not base_full.exists(): + return {} + + regex = re.compile(pattern) + results: dict[str, list[tuple[int, str]]] = {} + + # Walk directory tree + for file_path in base_full.rglob("*"): + if not file_path.is_file(): + continue + + # Check include filter + if include and not self._match_include(file_path.name, include): + continue + + # Skip files that are too large + if file_path.stat().st_size > self.max_file_size_bytes: + continue + + try: + content = file_path.read_text() + except (UnicodeDecodeError, PermissionError): + continue + + # Search content + for line_num, line in enumerate(content.splitlines(), 1): + if regex.search(line): + virtual_path = "/" + str(file_path.relative_to(self.root_path)) + if virtual_path not in results: + results[virtual_path] = [] + results[virtual_path].append((line_num, line)) + + return results + + def _match_include(self, basename: str, pattern: str) -> bool: + """Match filename against include pattern.""" + # Handle brace expansion {a,b,c} + if "{" in pattern and "}" in pattern: + start = pattern.index("{") + end = pattern.index("}") + prefix = pattern[:start] + suffix = pattern[end + 1 :] + alternatives = pattern[start + 1 : end].split(",") + + for alt in alternatives: + expanded = prefix + alt + suffix + if fnmatch.fnmatch(basename, expanded): + return True + return False + return fnmatch.fnmatch(basename, pattern) + + def _format_grep_results( + self, + results: dict[str, list[tuple[int, str]]], + output_mode: str, + ) -> str: + """Format grep results based on output mode.""" + if output_mode == "files_with_matches": + # Just return file paths + return "\n".join(sorted(results.keys())) + + if output_mode == "content": + # Return file:line:content format + lines = [] + for file_path in sorted(results.keys()): + for line_num, line in results[file_path]: + lines.append(f"{file_path}:{line_num}:{line}") + return "\n".join(lines) + + if output_mode == "count": + # Return file:count format + lines = [] + for file_path in sorted(results.keys()): + count = len(results[file_path]) + lines.append(f"{file_path}:{count}") + return "\n".join(lines) + + # Default to files_with_matches + return "\n".join(sorted(results.keys())) + + +__all__ = [ + "FilesystemFileSearchMiddleware", + "StateFileSearchMiddleware", +] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py new file mode 100644 index 0000000000000..4eaa055cbce5f --- /dev/null +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py @@ -0,0 +1,276 @@ +"""Unit tests for Anthropic text editor and memory tool middleware.""" + +import pytest +from langchain.agents.middleware.anthropic_tools import ( + AnthropicToolsState, + StateClaudeMemoryMiddleware, + StateClaudeTextEditorMiddleware, + _validate_path, +) +from langchain_core.messages import ToolMessage +from langgraph.types import Command + + +class TestPathValidation: + """Test path validation and security.""" + + def test_basic_path_normalization(self) -> None: + """Test basic path normalization.""" + assert _validate_path("/foo/bar") == "/foo/bar" + assert _validate_path("foo/bar") == "/foo/bar" + assert _validate_path("/foo//bar") == "/foo/bar" + assert _validate_path("/foo/./bar") == "/foo/bar" + + def test_path_traversal_blocked(self) -> None: + """Test that path traversal attempts are blocked.""" + with pytest.raises(ValueError, match="Path traversal not allowed"): + _validate_path("/foo/../etc/passwd") + + with pytest.raises(ValueError, match="Path traversal not allowed"): + _validate_path("../etc/passwd") + + with pytest.raises(ValueError, match="Path traversal not allowed"): + _validate_path("~/.ssh/id_rsa") + + def test_allowed_prefixes(self) -> None: + """Test path prefix validation.""" + # Should pass + assert ( + _validate_path("/workspace/file.txt", allowed_prefixes=["/workspace"]) + == "/workspace/file.txt" + ) + + # Should fail + with pytest.raises(ValueError, match="Path must start with"): + _validate_path("/etc/passwd", allowed_prefixes=["/workspace"]) + + with pytest.raises(ValueError, match="Path must start with"): + _validate_path("/workspacemalicious/file.txt", allowed_prefixes=["/workspace/"]) + + def test_memories_prefix(self) -> None: + """Test /memories prefix validation for memory tools.""" + assert ( + _validate_path("/memories/notes.txt", allowed_prefixes=["/memories"]) + == "/memories/notes.txt" + ) + + with pytest.raises(ValueError, match="Path must start with"): + _validate_path("/other/notes.txt", allowed_prefixes=["/memories"]) + + +class TestTextEditorMiddleware: + """Test text editor middleware functionality.""" + + def test_middleware_initialization(self) -> None: + """Test middleware initializes correctly.""" + middleware = StateClaudeTextEditorMiddleware() + assert middleware.state_schema == AnthropicToolsState + assert middleware.tool_type == "text_editor_20250728" + assert middleware.tool_name == "str_replace_based_edit_tool" + assert middleware.state_key == "text_editor_files" + + # With path restrictions + middleware = StateClaudeTextEditorMiddleware(allowed_path_prefixes=["/workspace"]) + assert middleware.allowed_prefixes == ["/workspace"] + + +class TestMemoryMiddleware: + """Test memory middleware functionality.""" + + def test_middleware_initialization(self) -> None: + """Test middleware initializes correctly.""" + middleware = StateClaudeMemoryMiddleware() + assert middleware.state_schema == AnthropicToolsState + assert middleware.tool_type == "memory_20250818" + assert middleware.tool_name == "memory" + assert middleware.state_key == "memory_files" + assert middleware.system_prompt # Should have default prompt + + def test_custom_system_prompt(self) -> None: + """Test custom system prompt can be set.""" + custom_prompt = "Custom memory instructions" + middleware = StateClaudeMemoryMiddleware(system_prompt=custom_prompt) + assert middleware.system_prompt == custom_prompt + + +class TestFileOperations: + """Test file operation implementations via wrap_tool_call.""" + + def test_view_operation(self) -> None: + """Test view command execution.""" + middleware = StateClaudeTextEditorMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/test.txt": { + "content": ["line1", "line2", "line3"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + } + }, + } + + args = {"command": "view", "path": "/test.txt"} + result = middleware._handle_view(args, state, "test_id") + + assert isinstance(result, Command) + assert result.update is not None + messages = result.update.get("messages", []) + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].content == "1|line1\n2|line2\n3|line3" + assert messages[0].tool_call_id == "test_id" + + def test_create_operation(self) -> None: + """Test create command execution.""" + middleware = StateClaudeTextEditorMiddleware() + + state: AnthropicToolsState = {"messages": []} + + args = {"command": "create", "path": "/test.txt", "file_text": "line1\nline2"} + result = middleware._handle_create(args, state, "test_id") + + assert isinstance(result, Command) + assert result.update is not None + files = result.update.get("text_editor_files", {}) + assert "/test.txt" in files + assert files["/test.txt"]["content"] == ["line1", "line2"] + + def test_path_prefix_enforcement(self) -> None: + """Test that path prefixes are enforced.""" + middleware = StateClaudeTextEditorMiddleware(allowed_path_prefixes=["/workspace"]) + + state: AnthropicToolsState = {"messages": []} + + # Should fail with /etc/passwd + args = {"command": "create", "path": "/etc/passwd", "file_text": "test"} + + try: + middleware._handle_create(args, state, "test_id") + assert False, "Should have raised ValueError" + except ValueError as e: + assert "Path must start with" in str(e) + + def test_memories_prefix_enforcement(self) -> None: + """Test that /memories prefix is enforced for memory middleware.""" + middleware = StateClaudeMemoryMiddleware() + + state: AnthropicToolsState = {"messages": []} + + # Should fail with /other/path + args = {"command": "create", "path": "/other/path.txt", "file_text": "test"} + + try: + middleware._handle_create(args, state, "test_id") + assert False, "Should have raised ValueError" + except ValueError as e: + assert "/memories" in str(e) + + def test_str_replace_operation(self) -> None: + """Test str_replace command execution.""" + middleware = StateClaudeTextEditorMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/test.txt": { + "content": ["Hello world", "Goodbye world"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + } + }, + } + + args = { + "command": "str_replace", + "path": "/test.txt", + "old_str": "world", + "new_str": "universe", + } + result = middleware._handle_str_replace(args, state, "test_id") + + assert isinstance(result, Command) + files = result.update.get("text_editor_files", {}) + # Should only replace first occurrence + assert files["/test.txt"]["content"] == ["Hello universe", "Goodbye world"] + + def test_insert_operation(self) -> None: + """Test insert command execution.""" + middleware = StateClaudeTextEditorMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/test.txt": { + "content": ["line1", "line2"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + } + }, + } + + args = { + "command": "insert", + "path": "/test.txt", + "insert_line": 0, + "new_str": "inserted", + } + result = middleware._handle_insert(args, state, "test_id") + + assert isinstance(result, Command) + files = result.update.get("text_editor_files", {}) + assert files["/test.txt"]["content"] == ["inserted", "line1", "line2"] + + def test_delete_operation(self) -> None: + """Test delete command execution (memory only).""" + middleware = StateClaudeMemoryMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "memory_files": { + "/memories/test.txt": { + "content": ["line1"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + } + }, + } + + args = {"command": "delete", "path": "/memories/test.txt"} + result = middleware._handle_delete(args, state, "test_id") + + assert isinstance(result, Command) + files = result.update.get("memory_files", {}) + # Deleted files are marked as None in state + assert files.get("/memories/test.txt") is None + + def test_rename_operation(self) -> None: + """Test rename command execution (memory only).""" + middleware = StateClaudeMemoryMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "memory_files": { + "/memories/old.txt": { + "content": ["line1"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + } + }, + } + + args = { + "command": "rename", + "old_path": "/memories/old.txt", + "new_path": "/memories/new.txt", + } + result = middleware._handle_rename(args, state, "test_id") + + assert isinstance(result, Command) + files = result.update.get("memory_files", {}) + # Old path is marked as None (deleted) + assert files.get("/memories/old.txt") is None + # New path has the file data + assert files.get("/memories/new.txt") is not None + assert files["/memories/new.txt"]["content"] == ["line1"] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_file_search.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_file_search.py new file mode 100644 index 0000000000000..c659ca7840be5 --- /dev/null +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_file_search.py @@ -0,0 +1,461 @@ +"""Unit tests for file search middleware.""" + +import pytest +from langchain.agents.middleware.anthropic_tools import AnthropicToolsState +from langchain.agents.middleware.file_search import StateFileSearchMiddleware +from langchain_core.messages import ToolMessage + + +class TestSearchMiddlewareInitialization: + """Test search middleware initialization.""" + + def test_middleware_initialization(self) -> None: + """Test middleware initializes correctly.""" + middleware = StateFileSearchMiddleware() + assert middleware.state_schema == AnthropicToolsState + assert middleware.state_key == "text_editor_files" + + def test_custom_state_key(self) -> None: + """Test middleware with custom state key.""" + middleware = StateFileSearchMiddleware(state_key="memory_files") + assert middleware.state_key == "memory_files" + + +class TestGlobSearch: + """Test Glob file pattern matching.""" + + def test_glob_basic_pattern(self) -> None: + """Test basic glob pattern matching.""" + middleware = StateFileSearchMiddleware() + + test_state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["print('hello')"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/src/utils.py": { + "content": ["def helper(): pass"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/README.md": { + "content": ["# Project"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + # Call tool function directly (state is injected in real usage) + result = middleware.glob_search.func(pattern="*.py", state=test_state) + + assert isinstance(result, str) + assert "/src/main.py" in result + assert "/src/utils.py" in result + assert "/README.md" not in result + + def test_glob_recursive_pattern(self) -> None: + """Test recursive glob pattern matching.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/src/utils/helper.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/tests/test_main.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.glob_search.func(pattern="**/*.py", state=state) + + assert isinstance(result, str) + lines = result.split("\n") + assert len(lines) == 3 + assert all(".py" in line for line in lines) + + def test_glob_with_base_path(self) -> None: + """Test glob with base path restriction.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/tests/test.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.glob_search.func(pattern="**/*.py", path="/src", state=state) + + assert isinstance(result, str) + assert "/src/main.py" in result + assert "/tests/test.py" not in result + + def test_glob_no_matches(self) -> None: + """Test glob with no matching files.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.glob_search.func(pattern="*.ts", state=state) + + assert isinstance(result, str) + assert result == "No files found" + + def test_glob_sorts_by_modified_time(self) -> None: + """Test that glob results are sorted by modification time.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/old.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/new.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-02T00:00:00", + }, + }, + } + + result = middleware.glob_search.func(pattern="*.py", state=state) + + lines = result.split("\n") + # Most recent first + assert lines[0] == "/new.py" + assert lines[1] == "/old.py" + + +class TestGrepSearch: + """Test Grep content search.""" + + def test_grep_files_with_matches_mode(self) -> None: + """Test grep with files_with_matches output mode.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["def foo():", " pass"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/src/utils.py": { + "content": ["def bar():", " return None"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/README.md": { + "content": ["# Documentation", "No code here"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func(pattern=r"def \w+\(\):", state=state) + + assert isinstance(result, str) + assert "/src/main.py" in result + assert "/src/utils.py" in result + assert "/README.md" not in result + # Should only have file paths, not line content + assert "def foo():" not in result + + def test_grep_content_mode(self) -> None: + """Test grep with content output mode.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["def foo():", " pass", "def bar():"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func( + pattern=r"def \w+\(\):", output_mode="content", state=state + ) + + assert isinstance(result, str) + lines = result.split("\n") + assert len(lines) == 2 + assert lines[0] == "/src/main.py:1:def foo():" + assert lines[1] == "/src/main.py:3:def bar():" + + def test_grep_count_mode(self) -> None: + """Test grep with count output mode.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["TODO: fix this", "print('hello')", "TODO: add tests"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/src/utils.py": { + "content": ["TODO: implement"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func(pattern=r"TODO", output_mode="count", state=state) + + assert isinstance(result, str) + lines = result.split("\n") + assert "/src/main.py:2" in lines + assert "/src/utils.py:1" in lines + + def test_grep_with_include_filter(self) -> None: + """Test grep with include file pattern filter.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["import os"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/src/main.ts": { + "content": ["import os from 'os'"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func(pattern="import", include="*.py", state=state) + + assert isinstance(result, str) + assert "/src/main.py" in result + assert "/src/main.ts" not in result + + def test_grep_with_brace_expansion_filter(self) -> None: + """Test grep with brace expansion in include filter.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.ts": { + "content": ["const x = 1"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/src/App.tsx": { + "content": ["const y = 2"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/src/main.py": { + "content": ["z = 3"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func(pattern="const", include="*.{ts,tsx}", state=state) + + assert isinstance(result, str) + assert "/src/main.ts" in result + assert "/src/App.tsx" in result + assert "/src/main.py" not in result + + def test_grep_with_base_path(self) -> None: + """Test grep with base path restriction.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["import foo"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + "/tests/test.py": { + "content": ["import foo"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func(pattern="import", path="/src", state=state) + + assert isinstance(result, str) + assert "/src/main.py" in result + assert "/tests/test.py" not in result + + def test_grep_no_matches(self) -> None: + """Test grep with no matching content.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["print('hello')"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func(pattern=r"TODO", state=state) + + assert isinstance(result, str) + assert result == "No matches found" + + def test_grep_invalid_regex(self) -> None: + """Test grep with invalid regex pattern.""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": {}, + } + + result = middleware.grep_search.func(pattern=r"[unclosed", state=state) + + assert isinstance(result, str) + assert "Invalid regex pattern" in result + + +class TestSearchWithDifferentBackends: + """Test searching with different backend configurations.""" + + def test_glob_default_backend(self) -> None: + """Test that glob searches the default backend (text_editor_files).""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + "memory_files": { + "/memories/notes.txt": { + "content": [], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.glob_search.func(pattern="**/*", state=state) + + assert isinstance(result, str) + assert "/src/main.py" in result + # Should NOT find memory_files since default backend is text_editor_files + assert "/memories/notes.txt" not in result + + def test_grep_default_backend(self) -> None: + """Test that grep searches the default backend (text_editor_files).""" + middleware = StateFileSearchMiddleware() + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["TODO: implement"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + "memory_files": { + "/memories/tasks.txt": { + "content": ["TODO: review"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func(pattern=r"TODO", state=state) + + assert isinstance(result, str) + assert "/src/main.py" in result + # Should NOT find memory_files since default backend is text_editor_files + assert "/memories/tasks.txt" not in result + + def test_search_with_single_store(self) -> None: + """Test searching with a specific state key.""" + middleware = StateFileSearchMiddleware(state_key="text_editor_files") + + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/src/main.py": { + "content": ["code"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + "memory_files": { + "/memories/notes.txt": { + "content": ["notes"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + }, + }, + } + + result = middleware.grep_search.func(pattern=r".*", state=state) + + assert isinstance(result, str) + assert "/src/main.py" in result + assert "/memories/notes.txt" not in result From 89d0fff26f1eca9c0232db00ad6a6f497051ab3a Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Thu, 9 Oct 2025 16:42:51 -0400 Subject: [PATCH 02/22] Add subagents middleware --- .../langchain/agents/middleware/subagents.py | 363 ++++++++++++++++++ libs/langchain_v1/pyproject.toml | 3 +- .../middleware/test_subagent_middleware.py | 201 ++++++++++ .../middleware/test_subagent_middleware.py | 31 ++ libs/langchain_v1/uv.lock | 2 + 5 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 libs/langchain_v1/langchain/agents/middleware/subagents.py create mode 100644 libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py create mode 100644 libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py new file mode 100644 index 0000000000000..bbe3b7df96743 --- /dev/null +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -0,0 +1,363 @@ +"""Middleware for providing subagents to an agent via a `task` tool.""" + +from typing import TYPE_CHECKING, Annotated, Any, NotRequired, TypedDict + +from collections.abc import Callable + +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langchain.agents import create_agent +from langchain.agents.middleware import PlanningMiddleware, SummarizationMiddleware, AnthropicPromptCachingMiddleware +from langchain.agents.middleware.types import AgentMiddleware, ModelRequest +from langchain.chat_models import init_chat_model +from langchain.tools import InjectedToolCallId, InjectedState +from langchain_core.language_models import BaseChatModel, LanguageModelLike +from langchain_core.runnables import Runnable +from langchain_core.tools import BaseTool, tool +from langgraph.types import Command + + +class DefinedSubAgent(TypedDict): + """A subagent constructed with user-defined parameters""" + + name: str + """The name of the subagent.""" + + description: str + """The description of the subagent.""" + + prompt: str + """The system prompt to use for the subagent.""" + + tools: NotRequired[list[BaseTool]] + """The tools to use for the subagent.""" + + model: NotRequired[str | BaseChatModel] + """The model for the subagent.""" + + middleware: NotRequired[list[AgentMiddleware]] + """The middleware to use for the subagent.""" + + +class CustomSubAgent(TypedDict): + """A Runnable passed in as a subagent""" + + name: str + """The name of the subagent.""" + + description: str + """The description of the subagent.""" + + runnable: Runnable + """The Runnable to use for the subagent.""" + +DEFAULT_SUBAGENT_PROMPT = """In order to complete the objective that the user asks of you, you have access to a number of standard tools. +""" + +TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. + +Available agent types and the tools they have access to: +- general-purpose: General-purpose agent for researching complex questions, searching for files and content, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. This agent has access to all tools as the main agent. +{other_agents} + +When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. + +## Usage notes: +1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses +2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. +3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +4. The agent's outputs should generally be trusted +5. Clearly tell the agent whether you expect it to create content, perform analysis, or just do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent +6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +7. When only the general-purpose agent is provided, you should use it for all tasks. It is great for isolating context and token usage, and completing specific, complex tasks, as it has all the same capabilities as the main agent. + +### Example usage of the general-purpose agent: + + +"general-purpose": use this agent for general purpose tasks, it has access to all tools as the main agent. + + + +User: "I want to conduct research on the accomplishments of Lebron James, Michael Jordan, and Kobe Bryant, and then compare them." +Assistant: *Uses the task tool in parallel to conduct isolated research on each of the three players* +Assistant: *Synthesizes the results of the three isolated research tasks and responds to the User* + +Research is a complex, multi-step task in it of itself. +The research of each individual player is not dependent on the research of the other players. +The assistant uses the task tool to break down the complex objective into three isolated tasks. +Each research task only needs to worry about context and tokens about one player, then returns synthesized information about each player as the Tool Result. +This means each research task can dive deep and spend tokens and context deeply researching each player, but the final result is synthesized information, and saves us tokens in the long run when comparing the players to each other. + + + + +User: "Analyze a single large code repository for security vulnerabilities and generate a report." +Assistant: *Launches a single `task` subagent for the repository analysis* +Assistant: *Receives report and integrates results into final summary* + +Subagent is used to isolate a large, context-heavy task, even though there is only one. This prevents the main thread from being overloaded with details. +If the user then asks followup questions, we have a concise report to reference instead of the entire history of analysis and tool calls, which is good and saves us time and money. + + + + +User: "Schedule two meetings for me and prepare agendas for each." +Assistant: *Calls the task tool in parallel to launch two `task` subagents (one per meeting) to prepare agendas* +Assistant: *Returns final schedules and agendas* + +Tasks are simple individually, but subagents help silo agenda preparation. +Each subagent only needs to worry about the agenda for one meeting. + + + + +User: "I want to order a pizza from Dominos, order a burger from McDonald's, and order a salad from Subway." +Assistant: *Calls tools directly in parallel to order a pizza from Dominos, a burger from McDonald's, and a salad from Subway* + +The assistant did not use the task tool because the objective is super simple and clear and only requires a few trivial tool calls. +It is better to just complete the task directly and NOT use the `task`tool. + + + +### Example usage with custom agents: + + +"content-reviewer": use this agent after you are done creating significant content or documents +"greeting-responder": use this agent when to respond to user greetings with a friendly joke +"research-analyst": use this agent to conduct thorough research on complex topics + + + +user: "Please write a function that checks if a number is prime" +assistant: Sure let me write a function that checks if a number is prime +assistant: First let me use the Write tool to write a function that checks if a number is prime +assistant: I'm going to use the Write tool to write the following code: + +function isPrime(n) {{ + if (n <= 1) return false + for (let i = 2; i * i <= n; i++) {{ + if (n % i === 0) return false + }} + return true +}} + + +Since significant content was created and the task was completed, now use the content-reviewer agent to review the work + +assistant: Now let me use the content-reviewer agent to review the code +assistant: Uses the Task tool to launch with the content-reviewer agent + + + +user: "Can you help me research the environmental impact of different renewable energy sources and create a comprehensive report?" + +This is a complex research task that would benefit from using the research-analyst agent to conduct thorough analysis + +assistant: I'll help you research the environmental impact of renewable energy sources. Let me use the research-analyst agent to conduct comprehensive research on this topic. +assistant: Uses the Task tool to launch with the research-analyst agent, providing detailed instructions about what research to conduct and what format the report should take + + + +user: "Hello" + +Since the user is greeting, use the greeting-responder agent to respond with a friendly joke + +assistant: "I'm going to use the Task tool to launch with the greeting-responder agent" +""" + +def _get_subagents( + default_subagent_model: str | BaseChatModel, + default_subagent_tools: list[BaseTool], + subagents: list[DefinedSubAgent | CustomSubAgent], +): + default_subagent_middleware = [ + PlanningMiddleware(), + # TODO: Add FilesystemMiddleware when ready + SummarizationMiddleware( + model=default_subagent_model, + max_tokens_before_summary=120000, + messages_to_keep=20, + ), + AnthropicPromptCachingMiddleware(ttl="5m", unsupported_model_behavior="ignore"), + ] + + # Create the general-purpose subagent + general_purpose_subagent = create_agent( + model=default_subagent_model, + system_prompt=DEFAULT_SUBAGENT_PROMPT, + tools=default_subagent_tools, + middleware=default_subagent_middleware + ) + agents = { + "general-purpose": general_purpose_subagent + } + subagent_descriptions = [] + for _agent in subagents: + subagent_descriptions.append(f"- {_agent['name']}: {_agent['description']}") + if "runnable" in _agent: + agents[_agent["name"]] = _agent["runnable"] + continue + if "tools" in _agent: + _tools = _agent["tools"] + else: + _tools = default_subagent_tools.copy() + + subagent_model = default_subagent_model + if "model" in _agent: + agent_model = _agent["model"] + if isinstance(agent_model, dict): + subagent_model = init_chat_model(**agent_model) + elif isinstance(agent_model, str): + subagent_model = init_chat_model(agent_model) + elif isinstance(agent_model, BaseChatModel): + subagent_model = agent_model + else: + raise ValueError(f"Invalid model type: {type(agent_model)}") + + if "middleware" in _agent: + _middleware = [*default_subagent_middleware, *_agent["middleware"]] + else: + _middleware = default_subagent_middleware + + agents[_agent["name"]] = create_agent( + subagent_model, + system_prompt=_agent["prompt"], + tools=_tools, + middleware=_middleware, + checkpointer=False, + ) + return agents, subagent_descriptions + + +def _create_task_tool( + default_subagent_model: LanguageModelLike | dict[str, Any], + default_subagent_tools: list[BaseTool], + subagents: list[DefinedSubAgent | CustomSubAgent], + is_async: bool = False, +): + subagent_graphs, subagent_descriptions = _get_subagents( + default_subagent_model, + default_subagent_tools, + subagents + ) + subagent_description_str = "\n".join(subagent_descriptions) + + def _return_command_with_state_update(result: dict, tool_call_id: str): + state_update = {} + for k, v in result.items(): + if k not in ["todos", "messages"]: + state_update[k] = v + return Command( + update={ + **state_update, + "messages": [ + ToolMessage( + result["messages"][-1].content, tool_call_id=tool_call_id + ) + ], + } + ) + + task_tool_description = TASK_TOOL_DESCRIPTION.format(other_agents=subagent_description_str) + if is_async: + @tool(description=task_tool_description) + async def task( + description: str, + subagent_type: str, + state: Annotated[dict, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ): + if subagent_type not in subagent_graphs: + return f"Error: invoked agent of type {subagent_type}, the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" + subagent = subagent_graphs[subagent_type] + state["messages"] = [HumanMessage(content=description)] + result = await subagent.ainvoke(state) + return _return_command_with_state_update(result, tool_call_id) + else: + @tool(description=task_tool_description) + def task( + description: str, + subagent_type: str, + state: Annotated[dict, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ): + if subagent_type not in subagent_graphs: + return f"Error: invoked agent of type {subagent_type}, the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" + subagent = subagent_graphs[subagent_type] + state["messages"] = [HumanMessage(content=description)] + result = subagent.invoke(state) + return _return_command_with_state_update(result, tool_call_id) + return task + + + +class SubAgentMiddleware(AgentMiddleware): + """Middleware for providing subagents to an agent via a `task` tool. + + This middleware adds a `task` tool to the agent that can be used to invoke subagents. + Subagents are useful for handling complex tasks that require multiple steps, or tasks + that require a lot of context to resolve. + + A chief benefit of subagents is that they can handle multi-step tasks, and then return + a clean, concise response to the main agent. + + Subagents are also great for different domains of expertise that require a narrower + subset of tools and focus. + + This middleware comes with a default general-purpose subagent that can be used to + handle the same tasks as the main agent, but with isolated context. + + Args: + default_subagent_model: The model to use for the general-purpose subagent. + default_subagent_tools: The tools to use for the general-purpose subagent. + subagents: A list of additional subagents to provide to the agent.. + system_prompt_extension: Additional instructions on how the main agent should use subagents. + is_async: Whether the `task` tool should be asynchronous. + + Example: + ```python + from langchain.agents.middleware.subagents import SubAgentMiddleware + from langchain.agents import create_agent + + agent = create_agent("openai:gpt-4o", middleware=[SubAgentMiddleware( + subagents=[ + + ] + )]) + + # Agent now has access to the `task` tool + result = await agent.invoke({"messages": [HumanMessage("Help me refactor my codebase")]}) + """ + + def __init__( + self, + *, + default_subagent_model: str | BaseChatModel, + default_subagent_tools: list[BaseTool] = [], + subagents: list[DefinedSubAgent | CustomSubAgent] = [], + system_prompt_extension: str = None, + is_async: bool = False, + ) -> None: + """Initialize the SubAgentMiddleware.""" + super().__init__() + self.system_prompt_extension = system_prompt_extension + task_tool = _create_task_tool( + default_subagent_model, + default_subagent_tools, + subagents, + is_async, + ) + self.tools = [task_tool] + + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], AIMessage], + ) -> AIMessage: + """Update the system prompt to include instructions on using subagents.""" + if self.system_prompt_extension is not None: + request.system_prompt = ( + request.system_prompt + "\n\n" + self.system_prompt_extension + if request.system_prompt + else self.system_prompt_extension + ) + return handler(request) diff --git a/libs/langchain_v1/pyproject.toml b/libs/langchain_v1/pyproject.toml index a4a2ee01a9167..e3c9d04a534ca 100644 --- a/libs/langchain_v1/pyproject.toml +++ b/libs/langchain_v1/pyproject.toml @@ -57,7 +57,8 @@ test = [ "toml>=0.10.2,<1.0.0", "langchain-tests", "langchain-text-splitters", - "langchain-openai" + "langchain-openai", + "langchain-anthropic", ] lint = [ "ruff>=0.12.2,<0.13.0", diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py new file mode 100644 index 0000000000000..cf6c3eb9a3ed7 --- /dev/null +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py @@ -0,0 +1,201 @@ +from langchain.agents.middleware.subagents import SubAgentMiddleware +from langchain.agents.middleware import AgentMiddleware +from langchain_core.tools import tool +from langchain.agents import create_agent +from langchain_core.messages import HumanMessage + +@tool +def get_weather(city: str) -> str: + """Get the weather in a city.""" + return f"The weather in {city} is sunny." + +class WeatherMiddleware(AgentMiddleware): + tools = [get_weather] + +custom_subagent = create_agent( + model="gpt-4.1-2025-04-14", + system_prompt="Use the get_weather tool to get the weather in a city.", + tools=[get_weather], +) + +def assert_expected_subgraph_actions(expected_tool_calls, agent, inputs): + current_idx = 0 + for update in agent.stream( + inputs, + subgraphs=True, + stream_mode="updates", + ): + if "model" in update[1]: + ai_message = update[1]["model"]["messages"][-1] + tool_calls = ai_message.tool_calls + for tool_call in tool_calls: + if tool_call["name"] == expected_tool_calls[current_idx]["name"]: + if "model" in expected_tool_calls[current_idx]: + assert ai_message.response_metadata["model_name"] == expected_tool_calls[current_idx]["model"] + for arg in expected_tool_calls[current_idx]["args"]: + assert arg in tool_call["args"] + assert tool_call["args"][arg] == expected_tool_calls[current_idx]["args"][arg] + current_idx += 1 + assert current_idx == len(expected_tool_calls) + + +class TestSubagentMiddleware: + """Integration tests for the SubagentMiddleware class.""" + + def test_general_purpose_subagent(self): + agent = create_agent( + model="claude-sonnet-4-20250514", + system_prompt="Use the general-purpose subagent to get the weather in a city.", + middleware=[ + SubAgentMiddleware( + default_subagent_model="claude-sonnet-4-20250514", + default_subagent_tools=[get_weather], + ) + ], + ) + assert "task" in agent.nodes["tools"].bound._tools_by_name.keys() + response = agent.invoke({"messages": [HumanMessage(content="What is the weather in Tokyo?")]}) + assert response["messages"][1].tool_calls[0]["name"] == "task" + assert response["messages"][1].tool_calls[0]["args"]["subagent_type"] == "general-purpose" + + def test_defined_subagent(self): + agent = create_agent( + model="claude-sonnet-4-20250514", + system_prompt="Use the task tool to call a subagent.", + middleware=[ + SubAgentMiddleware( + default_subagent_model="claude-sonnet-4-20250514", + default_subagent_tools=[], + subagents=[ + { + "name": "weather", + "description": "This subagent can get weather in cities.", + "prompt": "Use the get_weather tool to get the weather in a city.", + "tools": [get_weather], + } + ] + ) + ], + ) + assert "task" in agent.nodes["tools"].bound._tools_by_name.keys() + response = agent.invoke({"messages": [HumanMessage(content="What is the weather in Tokyo?")]}) + assert response["messages"][1].tool_calls[0]["name"] == "task" + assert response["messages"][1].tool_calls[0]["args"]["subagent_type"] == "weather" + + def test_defined_subagent_tool_calls(self): + agent = create_agent( + model="claude-sonnet-4-20250514", + system_prompt="Use the task tool to call a subagent.", + middleware=[ + SubAgentMiddleware( + default_subagent_model="claude-sonnet-4-20250514", + default_subagent_tools=[], + subagents=[ + { + "name": "weather", + "description": "This subagent can get weather in cities.", + "prompt": "Use the get_weather tool to get the weather in a city.", + "tools": [get_weather], + } + ] + ) + ], + ) + expected_tool_calls = [ + {"name": "task", "args": {"subagent_type": "weather"}}, + {"name": "get_weather", "args": {}}, + ] + assert_expected_subgraph_actions( + expected_tool_calls, + agent, + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + ) + + def test_defined_subagent_custom_model(self): + agent = create_agent( + model="claude-sonnet-4-20250514", + system_prompt="Use the task tool to call a subagent.", + middleware=[ + SubAgentMiddleware( + default_subagent_model="claude-sonnet-4-20250514", + default_subagent_tools=[], + subagents=[ + { + "name": "weather", + "description": "This subagent can get weather in cities.", + "prompt": "Use the get_weather tool to get the weather in a city.", + "tools": [get_weather], + "model": "gpt-4.1" + } + ] + ) + ], + ) + expected_tool_calls = [ + {"name": "task", "args": {"subagent_type": "weather"}, "model": "claude-sonnet-4-20250514"}, + {"name": "get_weather", "args": {}, "model": "gpt-4.1-2025-04-14"}, + ] + assert_expected_subgraph_actions( + expected_tool_calls, + agent, + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + ) + + def test_defined_subagent_custom_middleware(self): + agent = create_agent( + model="claude-sonnet-4-20250514", + system_prompt="Use the task tool to call a subagent.", + middleware=[ + SubAgentMiddleware( + default_subagent_model="claude-sonnet-4-20250514", + default_subagent_tools=[], + subagents=[ + { + "name": "weather", + "description": "This subagent can get weather in cities.", + "prompt": "Use the get_weather tool to get the weather in a city.", + "tools": [], # No tools, only in middleware + "model": "gpt-4.1", + "middleware": [WeatherMiddleware()] + } + ] + ) + ], + ) + expected_tool_calls = [ + {"name": "task", "args": {"subagent_type": "weather"}, "model": "claude-sonnet-4-20250514"}, + {"name": "get_weather", "args": {}, "model": "gpt-4.1-2025-04-14"}, + ] + assert_expected_subgraph_actions( + expected_tool_calls, + agent, + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + ) + + def test_defined_subagent_custom_runnable(self): + agent = create_agent( + model="claude-sonnet-4-20250514", + system_prompt="Use the task tool to call a subagent.", + middleware=[ + SubAgentMiddleware( + default_subagent_model="claude-sonnet-4-20250514", + default_subagent_tools=[], + subagents=[ + { + "name": "weather", + "description": "This subagent can get weather in cities.", + "runnable": custom_subagent, + } + ] + ) + ], + ) + expected_tool_calls = [ + {"name": "task", "args": {"subagent_type": "weather"}, "model": "claude-sonnet-4-20250514"}, + {"name": "get_weather", "args": {}, "model": "gpt-4.1-2025-04-14"}, + ] + assert_expected_subgraph_actions( + expected_tool_calls, + agent, + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + ) diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py new file mode 100644 index 0000000000000..92039e9bd3f17 --- /dev/null +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py @@ -0,0 +1,31 @@ +from langchain.agents.middleware.subagents import TASK_TOOL_DESCRIPTION, SubAgentMiddleware + +class TestSubagentMiddleware: + """Test the SubagentMiddleware class.""" + + def test_subagent_middleware_init(self): + middleware = SubAgentMiddleware( + default_subagent_model="gpt-4o-mini", + ) + assert middleware is not None + assert middleware.system_prompt_extension == None + assert len(middleware.tools) == 1 + assert middleware.tools[0].name == "task" + assert middleware.tools[0].description == TASK_TOOL_DESCRIPTION.format(other_agents="") + + def test_default_subagent_with_tools(self): + middleware = SubAgentMiddleware( + default_subagent_model="gpt-4o-mini", + default_subagent_tools=[], + ) + assert middleware is not None + assert middleware.system_prompt_extension == None + + def test_default_subagent_custom_system_prompt_extension(self): + middleware = SubAgentMiddleware( + default_subagent_model="gpt-4o-mini", + default_subagent_tools=[], + system_prompt_extension="Use the task tool to call a subagent.", + ) + assert middleware is not None + assert middleware.system_prompt_extension == "Use the task tool to call a subagent." diff --git a/libs/langchain_v1/uv.lock b/libs/langchain_v1/uv.lock index 0242907e69f87..0243d6152e287 100644 --- a/libs/langchain_v1/uv.lock +++ b/libs/langchain_v1/uv.lock @@ -1607,6 +1607,7 @@ lint = [ { name = "ruff" }, ] test = [ + { name = "langchain-anthropic" }, { name = "langchain-openai" }, { name = "langchain-tests" }, { name = "langchain-text-splitters" }, @@ -1659,6 +1660,7 @@ provides-extras = ["community", "anthropic", "openai", "google-vertexai", "googl [package.metadata.requires-dev] lint = [{ name = "ruff", specifier = ">=0.12.2,<0.13.0" }] test = [ + { name = "langchain-anthropic" }, { name = "langchain-openai", editable = "../partners/openai" }, { name = "langchain-tests", editable = "../standard-tests" }, { name = "langchain-text-splitters", editable = "../text-splitters" }, From 544a755887fb6cdc027b430b480fd63b1ddc8409 Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Thu, 9 Oct 2025 17:14:40 -0400 Subject: [PATCH 03/22] Fix linting --- .../agents/middleware/prompt_caching.py | 2 +- .../langchain/agents/middleware/subagents.py | 153 ++++++++++-------- .../middleware/test_subagent_middleware.py | 63 +++++--- .../middleware/test_subagent_middleware.py | 1 + 4 files changed, 128 insertions(+), 91 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/prompt_caching.py b/libs/langchain_v1/langchain/agents/middleware/prompt_caching.py index ae640a7459d80..80569d61f440d 100644 --- a/libs/langchain_v1/langchain/agents/middleware/prompt_caching.py +++ b/libs/langchain_v1/langchain/agents/middleware/prompt_caching.py @@ -51,7 +51,7 @@ def wrap_model_call( try: from langchain_anthropic import ChatAnthropic except ImportError: - ChatAnthropic = None # noqa: N806 + ChatAnthropic = None # type: ignore[assignment,misc] # noqa: N806 msg: str | None = None diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index bbe3b7df96743..ccf50c214c406 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -1,23 +1,28 @@ """Middleware for providing subagents to an agent via a `task` tool.""" - -from typing import TYPE_CHECKING, Annotated, Any, NotRequired, TypedDict +# ruff: noqa: E501 from collections.abc import Callable +from typing import Annotated, Any, NotRequired, TypedDict, cast -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage -from langchain.agents import create_agent -from langchain.agents.middleware import PlanningMiddleware, SummarizationMiddleware, AnthropicPromptCachingMiddleware -from langchain.agents.middleware.types import AgentMiddleware, ModelRequest -from langchain.chat_models import init_chat_model -from langchain.tools import InjectedToolCallId, InjectedState from langchain_core.language_models import BaseChatModel, LanguageModelLike +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool, tool from langgraph.types import Command +from langchain.agents import create_agent +from langchain.agents.middleware import ( + AnthropicPromptCachingMiddleware, + PlanningMiddleware, + SummarizationMiddleware, +) +from langchain.agents.middleware.types import AgentMiddleware, ModelRequest +from langchain.chat_models import init_chat_model +from langchain.tools import InjectedState, InjectedToolCallId + class DefinedSubAgent(TypedDict): - """A subagent constructed with user-defined parameters""" + """A subagent constructed with user-defined parameters.""" name: str """The name of the subagent.""" @@ -31,7 +36,7 @@ class DefinedSubAgent(TypedDict): tools: NotRequired[list[BaseTool]] """The tools to use for the subagent.""" - model: NotRequired[str | BaseChatModel] + model: NotRequired[LanguageModelLike | dict[str, Any]] """The model for the subagent.""" middleware: NotRequired[list[AgentMiddleware]] @@ -39,7 +44,7 @@ class DefinedSubAgent(TypedDict): class CustomSubAgent(TypedDict): - """A Runnable passed in as a subagent""" + """A Runnable passed in as a subagent.""" name: str """The name of the subagent.""" @@ -50,9 +55,30 @@ class CustomSubAgent(TypedDict): runnable: Runnable """The Runnable to use for the subagent.""" + DEFAULT_SUBAGENT_PROMPT = """In order to complete the objective that the user asks of you, you have access to a number of standard tools. """ + +def _normalize_model(model: LanguageModelLike | dict[str, Any]) -> BaseChatModel: + """Normalize a model specification to a BaseChatModel instance. + + Args: + model: The model specification (can be LanguageModelLike or dict). + + Returns: + A BaseChatModel instance. + """ + if isinstance(model, BaseChatModel): + return model + if isinstance(model, dict): + return init_chat_model(**model) + if isinstance(model, str): + return init_chat_model(model) + # For any other LanguageModelLike, try to convert to string and init + return init_chat_model(str(model)) + + TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. Available agent types and the tools they have access to: @@ -164,16 +190,18 @@ class CustomSubAgent(TypedDict): assistant: "I'm going to use the Task tool to launch with the greeting-responder agent" """ + def _get_subagents( - default_subagent_model: str | BaseChatModel, + default_subagent_model: LanguageModelLike | dict[str, Any], default_subagent_tools: list[BaseTool], subagents: list[DefinedSubAgent | CustomSubAgent], -): +) -> tuple[dict[str, Any], list[str]]: + normalized_model = _normalize_model(default_subagent_model) + default_subagent_middleware = [ PlanningMiddleware(), - # TODO: Add FilesystemMiddleware when ready SummarizationMiddleware( - model=default_subagent_model, + model=normalized_model, max_tokens_before_summary=120000, messages_to_keep=20, ), @@ -182,36 +210,25 @@ def _get_subagents( # Create the general-purpose subagent general_purpose_subagent = create_agent( - model=default_subagent_model, + model=normalized_model, system_prompt=DEFAULT_SUBAGENT_PROMPT, tools=default_subagent_tools, - middleware=default_subagent_middleware + middleware=default_subagent_middleware, ) - agents = { - "general-purpose": general_purpose_subagent - } + agents: dict[str, Any] = {"general-purpose": general_purpose_subagent} subagent_descriptions = [] for _agent in subagents: subagent_descriptions.append(f"- {_agent['name']}: {_agent['description']}") if "runnable" in _agent: - agents[_agent["name"]] = _agent["runnable"] + # Type narrowing: _agent is CustomSubAgent here + custom_agent = cast("CustomSubAgent", _agent) + agents[custom_agent["name"]] = custom_agent["runnable"] continue - if "tools" in _agent: - _tools = _agent["tools"] - else: - _tools = default_subagent_tools.copy() - - subagent_model = default_subagent_model - if "model" in _agent: - agent_model = _agent["model"] - if isinstance(agent_model, dict): - subagent_model = init_chat_model(**agent_model) - elif isinstance(agent_model, str): - subagent_model = init_chat_model(agent_model) - elif isinstance(agent_model, BaseChatModel): - subagent_model = agent_model - else: - raise ValueError(f"Invalid model type: {type(agent_model)}") + _tools = _agent["tools"] if "tools" in _agent else default_subagent_tools.copy() + + subagent_model = ( + _normalize_model(_agent["model"]) if "model" in _agent else normalized_model + ) if "middleware" in _agent: _middleware = [*default_subagent_middleware, *_agent["middleware"]] @@ -232,40 +249,35 @@ def _create_task_tool( default_subagent_model: LanguageModelLike | dict[str, Any], default_subagent_tools: list[BaseTool], subagents: list[DefinedSubAgent | CustomSubAgent], + *, is_async: bool = False, -): +) -> BaseTool: subagent_graphs, subagent_descriptions = _get_subagents( - default_subagent_model, - default_subagent_tools, - subagents + default_subagent_model, default_subagent_tools, subagents ) subagent_description_str = "\n".join(subagent_descriptions) - def _return_command_with_state_update(result: dict, tool_call_id: str): - state_update = {} - for k, v in result.items(): - if k not in ["todos", "messages"]: - state_update[k] = v + def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command: + state_update = {k: v for k, v in result.items() if k not in ["todos", "messages"]} return Command( - update={ - **state_update, - "messages": [ - ToolMessage( - result["messages"][-1].content, tool_call_id=tool_call_id - ) - ], - } - ) + update={ + **state_update, + "messages": [ + ToolMessage(result["messages"][-1].content, tool_call_id=tool_call_id) + ], + } + ) task_tool_description = TASK_TOOL_DESCRIPTION.format(other_agents=subagent_description_str) if is_async: + @tool(description=task_tool_description) async def task( description: str, subagent_type: str, state: Annotated[dict, InjectedState], tool_call_id: Annotated[str, InjectedToolCallId], - ): + ) -> str | Command: if subagent_type not in subagent_graphs: return f"Error: invoked agent of type {subagent_type}, the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" subagent = subagent_graphs[subagent_type] @@ -273,21 +285,22 @@ async def task( result = await subagent.ainvoke(state) return _return_command_with_state_update(result, tool_call_id) else: + @tool(description=task_tool_description) def task( description: str, subagent_type: str, state: Annotated[dict, InjectedState], tool_call_id: Annotated[str, InjectedToolCallId], - ): + ) -> str | Command: if subagent_type not in subagent_graphs: return f"Error: invoked agent of type {subagent_type}, the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" subagent = subagent_graphs[subagent_type] state["messages"] = [HumanMessage(content=description)] result = subagent.invoke(state) return _return_command_with_state_update(result, tool_call_id) - return task + return task class SubAgentMiddleware(AgentMiddleware): @@ -308,8 +321,9 @@ class SubAgentMiddleware(AgentMiddleware): Args: default_subagent_model: The model to use for the general-purpose subagent. + Can be a LanguageModelLike or a dict for init_chat_model. default_subagent_tools: The tools to use for the general-purpose subagent. - subagents: A list of additional subagents to provide to the agent.. + subagents: A list of additional subagents to provide to the agent. system_prompt_extension: Additional instructions on how the main agent should use subagents. is_async: Whether the `task` tool should be asynchronous. @@ -318,23 +332,20 @@ class SubAgentMiddleware(AgentMiddleware): from langchain.agents.middleware.subagents import SubAgentMiddleware from langchain.agents import create_agent - agent = create_agent("openai:gpt-4o", middleware=[SubAgentMiddleware( - subagents=[ - - ] - )]) + agent = create_agent("openai:gpt-4o", middleware=[SubAgentMiddleware(subagents=[])]) # Agent now has access to the `task` tool result = await agent.invoke({"messages": [HumanMessage("Help me refactor my codebase")]}) + ``` """ def __init__( self, *, - default_subagent_model: str | BaseChatModel, - default_subagent_tools: list[BaseTool] = [], - subagents: list[DefinedSubAgent | CustomSubAgent] = [], - system_prompt_extension: str = None, + default_subagent_model: LanguageModelLike | dict[str, Any], + default_subagent_tools: list[BaseTool] | None = None, + subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, + system_prompt_extension: str | None = None, is_async: bool = False, ) -> None: """Initialize the SubAgentMiddleware.""" @@ -342,9 +353,9 @@ def __init__( self.system_prompt_extension = system_prompt_extension task_tool = _create_task_tool( default_subagent_model, - default_subagent_tools, - subagents, - is_async, + default_subagent_tools or [], + subagents or [], + is_async=is_async, ) self.tools = [task_tool] diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py index cf6c3eb9a3ed7..cbf79e6d42607 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py @@ -4,20 +4,24 @@ from langchain.agents import create_agent from langchain_core.messages import HumanMessage + @tool def get_weather(city: str) -> str: """Get the weather in a city.""" return f"The weather in {city} is sunny." + class WeatherMiddleware(AgentMiddleware): tools = [get_weather] + custom_subagent = create_agent( model="gpt-4.1-2025-04-14", system_prompt="Use the get_weather tool to get the weather in a city.", tools=[get_weather], ) + def assert_expected_subgraph_actions(expected_tool_calls, agent, inputs): current_idx = 0 for update in agent.stream( @@ -31,10 +35,15 @@ def assert_expected_subgraph_actions(expected_tool_calls, agent, inputs): for tool_call in tool_calls: if tool_call["name"] == expected_tool_calls[current_idx]["name"]: if "model" in expected_tool_calls[current_idx]: - assert ai_message.response_metadata["model_name"] == expected_tool_calls[current_idx]["model"] + assert ( + ai_message.response_metadata["model_name"] + == expected_tool_calls[current_idx]["model"] + ) for arg in expected_tool_calls[current_idx]["args"]: assert arg in tool_call["args"] - assert tool_call["args"][arg] == expected_tool_calls[current_idx]["args"][arg] + assert ( + tool_call["args"][arg] == expected_tool_calls[current_idx]["args"][arg] + ) current_idx += 1 assert current_idx == len(expected_tool_calls) @@ -54,7 +63,9 @@ def test_general_purpose_subagent(self): ], ) assert "task" in agent.nodes["tools"].bound._tools_by_name.keys() - response = agent.invoke({"messages": [HumanMessage(content="What is the weather in Tokyo?")]}) + response = agent.invoke( + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + ) assert response["messages"][1].tool_calls[0]["name"] == "task" assert response["messages"][1].tool_calls[0]["args"]["subagent_type"] == "general-purpose" @@ -73,12 +84,14 @@ def test_defined_subagent(self): "prompt": "Use the get_weather tool to get the weather in a city.", "tools": [get_weather], } - ] + ], ) ], ) assert "task" in agent.nodes["tools"].bound._tools_by_name.keys() - response = agent.invoke({"messages": [HumanMessage(content="What is the weather in Tokyo?")]}) + response = agent.invoke( + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + ) assert response["messages"][1].tool_calls[0]["name"] == "task" assert response["messages"][1].tool_calls[0]["args"]["subagent_type"] == "weather" @@ -97,7 +110,7 @@ def test_defined_subagent_tool_calls(self): "prompt": "Use the get_weather tool to get the weather in a city.", "tools": [get_weather], } - ] + ], ) ], ) @@ -108,7 +121,7 @@ def test_defined_subagent_tool_calls(self): assert_expected_subgraph_actions( expected_tool_calls, agent, - {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]}, ) def test_defined_subagent_custom_model(self): @@ -125,20 +138,24 @@ def test_defined_subagent_custom_model(self): "description": "This subagent can get weather in cities.", "prompt": "Use the get_weather tool to get the weather in a city.", "tools": [get_weather], - "model": "gpt-4.1" + "model": "gpt-4.1", } - ] + ], ) ], ) expected_tool_calls = [ - {"name": "task", "args": {"subagent_type": "weather"}, "model": "claude-sonnet-4-20250514"}, + { + "name": "task", + "args": {"subagent_type": "weather"}, + "model": "claude-sonnet-4-20250514", + }, {"name": "get_weather", "args": {}, "model": "gpt-4.1-2025-04-14"}, ] assert_expected_subgraph_actions( expected_tool_calls, agent, - {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]}, ) def test_defined_subagent_custom_middleware(self): @@ -154,22 +171,26 @@ def test_defined_subagent_custom_middleware(self): "name": "weather", "description": "This subagent can get weather in cities.", "prompt": "Use the get_weather tool to get the weather in a city.", - "tools": [], # No tools, only in middleware + "tools": [], # No tools, only in middleware "model": "gpt-4.1", - "middleware": [WeatherMiddleware()] + "middleware": [WeatherMiddleware()], } - ] + ], ) ], ) expected_tool_calls = [ - {"name": "task", "args": {"subagent_type": "weather"}, "model": "claude-sonnet-4-20250514"}, + { + "name": "task", + "args": {"subagent_type": "weather"}, + "model": "claude-sonnet-4-20250514", + }, {"name": "get_weather", "args": {}, "model": "gpt-4.1-2025-04-14"}, ] assert_expected_subgraph_actions( expected_tool_calls, agent, - {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]}, ) def test_defined_subagent_custom_runnable(self): @@ -186,16 +207,20 @@ def test_defined_subagent_custom_runnable(self): "description": "This subagent can get weather in cities.", "runnable": custom_subagent, } - ] + ], ) ], ) expected_tool_calls = [ - {"name": "task", "args": {"subagent_type": "weather"}, "model": "claude-sonnet-4-20250514"}, + { + "name": "task", + "args": {"subagent_type": "weather"}, + "model": "claude-sonnet-4-20250514", + }, {"name": "get_weather", "args": {}, "model": "gpt-4.1-2025-04-14"}, ] assert_expected_subgraph_actions( expected_tool_calls, agent, - {"messages": [HumanMessage(content="What is the weather in Tokyo?")]} + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]}, ) diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py index 92039e9bd3f17..fbc6bee92d73f 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py @@ -1,5 +1,6 @@ from langchain.agents.middleware.subagents import TASK_TOOL_DESCRIPTION, SubAgentMiddleware + class TestSubagentMiddleware: """Test the SubagentMiddleware class.""" From 203323d24911cb1103d914a199eda818ccdee26f Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Thu, 9 Oct 2025 17:22:39 -0400 Subject: [PATCH 04/22] Remove claude file --- .claude/settings.local.json | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index a6fc3466df473..0000000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(uv run:*)", - "Bash(make:*)", - "WebSearch", - "WebFetch(domain:ai.pydantic.dev)", - "WebFetch(domain:openai.github.io)", - "Bash(uv run:*)", - "Bash(python3:*)", - "WebFetch(domain:github.com)", - "Bash(gh pr view:*)", - "Bash(gh pr diff:*)", - "WebFetch(domain:langchain-ai.github.io)", - "WebFetch(domain:gist.github.com)", - "WebFetch(domain:kirshatrov.com)", - "WebFetch(domain:aiengineerguide.com)" - ], - "deny": [], - "ask": [] - } -} From 7340cb5353a91cab23d70b47b43bd7f0ba4a2bd5 Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Thu, 9 Oct 2025 22:09:19 -0400 Subject: [PATCH 05/22] Share code between anthropic tools and filesystem, add deepagents --- .../langchain/agents/deepagents.py | 237 ++++++++ .../agents/middleware/anthropic_tools.py | 199 ++----- .../langchain/agents/middleware/file_utils.py | 247 ++++++++ .../langchain/agents/middleware/filesystem.py | 557 ++++++++++++++++++ .../langchain/agents/middleware/subagents.py | 55 +- .../middleware/test_filesystem_middleware.py | 274 +++++++++ .../agents/test_deepagents.py | 304 ++++++++++ .../agents/middleware/test_anthropic_tools.py | 26 +- 8 files changed, 1690 insertions(+), 209 deletions(-) create mode 100644 libs/langchain_v1/langchain/agents/deepagents.py create mode 100644 libs/langchain_v1/langchain/agents/middleware/file_utils.py create mode 100644 libs/langchain_v1/langchain/agents/middleware/filesystem.py create mode 100644 libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py create mode 100644 libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py diff --git a/libs/langchain_v1/langchain/agents/deepagents.py b/libs/langchain_v1/langchain/agents/deepagents.py new file mode 100644 index 0000000000000..fb54f1e9eecb4 --- /dev/null +++ b/libs/langchain_v1/langchain/agents/deepagents.py @@ -0,0 +1,237 @@ +"""Deepagents come with planning, filesystem, and subagents, along with other supportive middlewares..""" +# ruff: noqa: E501 + +from collections.abc import Callable, Sequence +from typing import Any + +from langchain_anthropic import ChatAnthropic +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.store.base import BaseStore +from langgraph.types import Checkpointer + +from langchain.agents import create_agent +from langchain.agents.middleware import AgentMiddleware +from langchain.agents.middleware.filesystem import FilesystemMiddleware +from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware, ToolConfig +from langchain.agents.middleware.planning import PlanningMiddleware +from langchain.agents.middleware.prompt_caching import AnthropicPromptCachingMiddleware +from langchain.agents.middleware.subagents import ( + CustomSubAgent, + DefinedSubAgent, + SubAgentMiddleware, +) +from langchain.agents.middleware.summarization import SummarizationMiddleware + +BASE_AGENT_PROMPT = """ +In order to complete the objective that the user asks of you, you have access to a number of standard tools. # noqa: E501 +""" + + +def get_default_model() -> ChatAnthropic: + """Get the default model for deep agents. + + Returns: + ChatAnthropic instance configured with Claude Sonnet 4. + """ + return ChatAnthropic( + model_name="claude-sonnet-4-20250514", + timeout=None, + stop=None, + model_kwargs={"max_tokens": 64000}, + ) + + +def agent_builder( + tools: Sequence[BaseTool | Callable | dict[str, Any]], + instructions: str, + middleware: list[AgentMiddleware] | None = None, + tool_configs: dict[str, bool | ToolConfig] | None = None, + model: str | BaseChatModel | None = None, + subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, + context_schema: type[Any] | None = None, + checkpointer: Checkpointer | None = None, + store: BaseStore | None = None, + *, + use_longterm_memory: bool = False, + is_async: bool = False, +) -> Any: + """Build a deep agent with standard middleware stack. + + Args: + tools: The tools the agent should have access to. + instructions: The instructions for the agent system prompt. + middleware: Additional middleware to apply after standard middleware. + tool_configs: Optional tool interrupt configurations. + model: The model to use. Defaults to Claude Sonnet 4. + subagents: Optional list of subagent configurations. + context_schema: Optional schema for the agent context. + checkpointer: Optional checkpointer for state persistence. + store: Optional store for longterm memory. + use_longterm_memory: Whether to enable longterm memory features. + is_async: Whether to create async subagent tools. + + Returns: + A configured agent with deep agent middleware stack. + """ + if model is None: + model = get_default_model() + + deepagent_middleware = [ + PlanningMiddleware(), + FilesystemMiddleware( + use_longterm_memory=use_longterm_memory, + ), + SubAgentMiddleware( + default_subagent_tools=tools, + default_subagent_model=model, + subagents=subagents if subagents is not None else [], + is_async=is_async, + ), + SummarizationMiddleware( + model=model, + max_tokens_before_summary=120000, + messages_to_keep=20, + ), + AnthropicPromptCachingMiddleware(ttl="5m", unsupported_model_behavior="ignore"), + ] + if tool_configs is not None: + deepagent_middleware.append(HumanInTheLoopMiddleware(interrupt_on=tool_configs)) + if middleware is not None: + deepagent_middleware.extend(middleware) + + return create_agent( + model, + system_prompt=instructions + "\n\n" + BASE_AGENT_PROMPT, + tools=tools, + middleware=deepagent_middleware, + context_schema=context_schema, + checkpointer=checkpointer, + store=store, + ) + + +def create_deep_agent( + tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, + instructions: str = "", + middleware: list[AgentMiddleware] | None = None, + model: str | BaseChatModel | None = None, + subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, + context_schema: type[Any] | None = None, + checkpointer: Checkpointer | None = None, + store: BaseStore | None = None, + *, + use_longterm_memory: bool = False, + tool_configs: dict[str, bool | ToolConfig] | None = None, +) -> Any: + """Create a deep agent. + + This agent will by default have access to a tool to write todos (write_todos), + four file editing tools: write_file, ls, read_file, edit_file, and a tool to call + subagents. + + Args: + tools: The tools the agent should have access to. + instructions: The additional instructions the agent should have. Will go in + the system prompt. + middleware: Additional middleware to apply after standard middleware. + model: The model to use. + subagents: The subagents to use. Each subagent should be a dictionary with the + following keys: + - `name` + - `description` (used by the main agent to decide whether to call the + sub agent) + - `prompt` (used as the system prompt in the subagent) + - (optional) `tools` + - (optional) `model` (either a LanguageModelLike instance or dict + settings) + - (optional) `middleware` (list of AgentMiddleware) + context_schema: The schema of the deep agent. + checkpointer: Optional checkpointer for persisting agent state between runs. + store: Optional store for persisting longterm memories. + use_longterm_memory: Whether to use longterm memory - you must provide a store + in order to use longterm memory. + tool_configs: Optional Dict[str, HumanInTheLoopConfig] mapping tool names to + interrupt configs. + + Returns: + A configured deep agent. + """ + if tools is None: + tools = [] + return agent_builder( + tools=tools, + instructions=instructions, + middleware=middleware, + model=model, + subagents=subagents, + context_schema=context_schema, + checkpointer=checkpointer, + store=store, + use_longterm_memory=use_longterm_memory, + tool_configs=tool_configs, + is_async=False, + ) + + +def async_create_deep_agent( + tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, + instructions: str = "", + middleware: list[AgentMiddleware] | None = None, + model: str | BaseChatModel | None = None, + subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, + context_schema: type[Any] | None = None, + checkpointer: Checkpointer | None = None, + store: BaseStore | None = None, + *, + use_longterm_memory: bool = False, + tool_configs: dict[str, bool | ToolConfig] | None = None, +) -> Any: + """Create an async deep agent. + + This agent will by default have access to a tool to write todos (write_todos), + four file editing tools: write_file, ls, read_file, edit_file, and a tool to call + subagents. + + Args: + tools: The tools the agent should have access to. + instructions: The additional instructions the agent should have. Will go in + the system prompt. + middleware: Additional middleware to apply after standard middleware. + model: The model to use. + subagents: The subagents to use. Each subagent should be a dictionary with the + following keys: + - `name` + - `description` (used by the main agent to decide whether to call the + sub agent) + - `prompt` (used as the system prompt in the subagent) + - (optional) `tools` + - (optional) `model` (either a LanguageModelLike instance or dict + settings) + - (optional) `middleware` (list of AgentMiddleware) + context_schema: The schema of the deep agent. + checkpointer: Optional checkpointer for persisting agent state between runs. + store: Optional store for persisting longterm memories. + use_longterm_memory: Whether to use longterm memory - you must provide a store + in order to use longterm memory. + tool_configs: Optional Dict[str, HumanInTheLoopConfig] mapping tool names to + interrupt configs. + + Returns: + A configured deep agent with async subagent tools. + """ + if tools is None: + tools = [] + return agent_builder( + tools=tools, + instructions=instructions, + middleware=middleware, + model=model, + subagents=subagents, + context_schema=context_schema, + checkpointer=checkpointer, + store=store, + use_longterm_memory=use_longterm_memory, + tool_configs=tool_configs, + is_async=True, + ) diff --git a/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py b/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py index 1ae5e5a8657e1..88b9aa52c5ddc 100644 --- a/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py +++ b/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py @@ -6,15 +6,24 @@ from __future__ import annotations -import os -from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, cast from langchain_core.messages import AIMessage, ToolMessage from langgraph.types import Command -from typing_extensions import NotRequired, TypedDict - +from typing_extensions import NotRequired + +from langchain.agents.middleware.file_utils import ( + FileData, + apply_string_replacement, + create_file_data, + file_data_reducer, + file_data_to_string, + format_content_with_line_numbers, + list_directory, + update_file_data, + validate_path, +) from langchain.agents.middleware.types import AgentMiddleware, AgentState, ModelRequest if TYPE_CHECKING: @@ -38,118 +47,16 @@ losing any progress that is not recorded in your memory directory.""" -class FileData(TypedDict): - """Data structure for storing file contents.""" - - content: list[str] - """Lines of the file.""" - - created_at: str - """ISO 8601 timestamp of file creation.""" - - modified_at: str - """ISO 8601 timestamp of last modification.""" - - -def files_reducer( - left: dict[str, FileData] | None, right: dict[str, FileData | None] -) -> dict[str, FileData]: - """Custom reducer that merges file updates. - - Args: - left: Existing files dict. - right: New files dict to merge (None values delete files). - - Returns: - Merged dict where right overwrites left for matching keys. - """ - if left is None: - # Filter out None values when initializing - return {k: v for k, v in right.items() if v is not None} - - # Merge, filtering out None values (deletions) - result = {**left} - for k, v in right.items(): - if v is None: - result.pop(k, None) - else: - result[k] = v - return result - - class AnthropicToolsState(AgentState): """State schema for Anthropic text editor and memory tools.""" - text_editor_files: NotRequired[Annotated[dict[str, FileData], files_reducer]] + text_editor_files: NotRequired[Annotated[dict[str, FileData], file_data_reducer]] """Virtual file system for text editor tools.""" - memory_files: NotRequired[Annotated[dict[str, FileData], files_reducer]] + memory_files: NotRequired[Annotated[dict[str, FileData], file_data_reducer]] """Virtual file system for memory tools.""" -def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str: - """Validate and normalize file path for security. - - Args: - path: The path to validate. - allowed_prefixes: Optional list of allowed path prefixes. - - Returns: - Normalized canonical path. - - Raises: - ValueError: If path contains traversal sequences or violates prefix rules. - """ - # Reject paths with traversal attempts - if ".." in path or path.startswith("~"): - msg = f"Path traversal not allowed: {path}" - raise ValueError(msg) - - # Normalize path (resolve ., //, etc.) - normalized = os.path.normpath(path) - - # Convert to forward slashes for consistency - normalized = normalized.replace("\\", "/") - - # Ensure path starts with / - if not normalized.startswith("/"): - normalized = f"/{normalized}" - - # Check allowed prefixes if specified - if allowed_prefixes is not None and not any( - normalized.startswith(prefix) for prefix in allowed_prefixes - ): - msg = f"Path must start with one of {allowed_prefixes}: {path}" - raise ValueError(msg) - - return normalized - - -def _list_directory(files: dict[str, FileData], path: str) -> list[str]: - """List files in a directory. - - Args: - files: Files dict. - path: Normalized directory path. - - Returns: - Sorted list of file paths in the directory. - """ - # Ensure path ends with / for directory matching - dir_path = path if path.endswith("/") else f"{path}/" - - matching_files = [] - for file_path in files: - if file_path.startswith(dir_path): - # Get relative path from directory - relative = file_path[len(dir_path) :] - # Only include direct children (no subdirectories) - if "/" not in relative: - matching_files.append(file_path) - - return sorted(matching_files) - - class _StateClaudeFileToolMiddleware(AgentMiddleware): """Base class for state-based file tool middleware (internal).""" @@ -254,14 +161,14 @@ def _handle_view( ) -> Command: """Handle view command.""" path = args["path"] - normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) files = cast("dict[str, Any]", state.get(self.state_key, {})) file_data = files.get(normalized_path) if file_data is None: # Try directory listing - matching = _list_directory(files, normalized_path) + matching = list_directory(files, normalized_path) if matching: content = "\n".join(matching) @@ -281,9 +188,7 @@ def _handle_view( raise FileNotFoundError(msg) # Format file content with line numbers - lines_content = file_data["content"] - formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines_content)] - content = "\n".join(formatted_lines) + content = format_content_with_line_numbers(file_data["content"], format_style="pipe") return Command( update={ @@ -304,26 +209,22 @@ def _handle_create( path = args["path"] file_text = args["file_text"] - normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) # Get existing files files = cast("dict[str, Any]", state.get(self.state_key, {})) existing = files.get(normalized_path) - # Create file data - now = datetime.now(timezone.utc).isoformat() - created_at = existing["created_at"] if existing else now - - content_lines = file_text.split("\n") + # Create or update file data + if existing: + new_file_data = update_file_data(existing, file_text) + else: + new_file_data = create_file_data(file_text) return Command( update={ self.state_key: { - normalized_path: { - "content": content_lines, - "created_at": created_at, - "modified_at": now, - } + normalized_path: new_file_data, }, "messages": [ ToolMessage( @@ -343,7 +244,7 @@ def _handle_str_replace( old_str = args["old_str"] new_str = args.get("new_str", "") - normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) # Read file files = cast("dict[str, Any]", state.get(self.state_key, {})) @@ -352,28 +253,21 @@ def _handle_str_replace( msg = f"File not found: {path}" raise FileNotFoundError(msg) - lines_content = file_data["content"] - content = "\n".join(lines_content) + content = file_data_to_string(file_data) # Replace string if old_str not in content: msg = f"String not found in file: {old_str}" raise ValueError(msg) - - new_content = content.replace(old_str, new_str, 1) - new_lines = new_content.split("\n") + new_content, _ = apply_string_replacement(content, old_str, new_str, replace_all=False) # Update file - now = datetime.now(timezone.utc).isoformat() + new_file_data = update_file_data(file_data, new_content) return Command( update={ self.state_key: { - normalized_path: { - "content": new_lines, - "created_at": file_data["created_at"], - "modified_at": now, - } + normalized_path: new_file_data, }, "messages": [ ToolMessage( @@ -393,7 +287,7 @@ def _handle_insert( insert_line = args["insert_line"] text_to_insert = args["new_str"] - normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) # Read file files = cast("dict[str, Any]", state.get(self.state_key, {})) @@ -409,16 +303,12 @@ def _handle_insert( updated_lines = lines_content[:insert_line] + new_lines + lines_content[insert_line:] # Update file - now = datetime.now(timezone.utc).isoformat() + new_file_data = update_file_data(file_data, updated_lines) return Command( update={ self.state_key: { - normalized_path: { - "content": updated_lines, - "created_at": file_data["created_at"], - "modified_at": now, - } + normalized_path: new_file_data, }, "messages": [ ToolMessage( @@ -439,7 +329,7 @@ def _handle_delete( """Handle delete command.""" path = args["path"] - normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes) + normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) return Command( update={ @@ -461,8 +351,8 @@ def _handle_rename( old_path = args["old_path"] new_path = args["new_path"] - normalized_old = _validate_path(old_path, allowed_prefixes=self.allowed_prefixes) - normalized_new = _validate_path(new_path, allowed_prefixes=self.allowed_prefixes) + normalized_old = validate_path(old_path, allowed_prefixes=self.allowed_prefixes) + normalized_new = validate_path(new_path, allowed_prefixes=self.allowed_prefixes) # Read file files = cast("dict[str, Any]", state.get(self.state_key, {})) @@ -472,15 +362,14 @@ def _handle_rename( raise ValueError(msg) # Update timestamp - now = datetime.now(timezone.utc).isoformat() - file_data_copy = file_data.copy() - file_data_copy["modified_at"] = now + content = file_data["content"] + new_file_data = update_file_data(file_data, content) return Command( update={ self.state_key: { normalized_old: None, - normalized_new: file_data_copy, + normalized_new: new_file_data, }, "messages": [ ToolMessage( @@ -745,12 +634,7 @@ def _handle_view(self, args: dict, tool_call_id: str | None) -> Command: raise ValueError(msg) from e # Format with line numbers - lines = content.split("\n") - # Remove trailing newline's empty string if present - if lines and lines[-1] == "": - lines = lines[:-1] - formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines)] - formatted_content = "\n".join(formatted_lines) + formatted_content = format_content_with_line_numbers(content, format_style="pipe") return Command( update={ @@ -808,8 +692,7 @@ def _handle_str_replace(self, args: dict, tool_call_id: str | None) -> Command: if old_str not in content: msg = f"String not found in file: {old_str}" raise ValueError(msg) - - new_content = content.replace(old_str, new_str, 1) + new_content, _ = apply_string_replacement(content, old_str, new_str, replace_all=False) # Write back full_path.write_text(new_content) diff --git a/libs/langchain_v1/langchain/agents/middleware/file_utils.py b/libs/langchain_v1/langchain/agents/middleware/file_utils.py new file mode 100644 index 0000000000000..42112eb15b206 --- /dev/null +++ b/libs/langchain_v1/langchain/agents/middleware/file_utils.py @@ -0,0 +1,247 @@ +"""Shared utility functions for file operations in middleware.""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Literal + +from typing_extensions import TypedDict + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class FileData(TypedDict): + """Data structure for storing file contents with metadata.""" + + content: list[str] + """Lines of the file.""" + + created_at: str + """ISO 8601 timestamp of file creation.""" + + modified_at: str + """ISO 8601 timestamp of last modification.""" + + +def file_data_reducer( + left: dict[str, FileData] | None, right: dict[str, FileData | None] +) -> dict[str, FileData]: + """Custom reducer that merges file updates. + + Args: + left: Existing files dict. + right: New files dict to merge (None values delete files). + + Returns: + Merged dict where right overwrites left for matching keys. + """ + if left is None: + # Filter out None values when initializing + return {k: v for k, v in right.items() if v is not None} + + # Merge, filtering out None values (deletions) + result = {**left} + for k, v in right.items(): + if v is None: + result.pop(k, None) + else: + result[k] = v + return result + + +def validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str: + """Validate and normalize file path for security. + + Args: + path: The path to validate. + allowed_prefixes: Optional list of allowed path prefixes. + + Returns: + Normalized canonical path. + + Raises: + ValueError: If path contains traversal sequences or violates prefix rules. + """ + # Reject paths with traversal attempts + if ".." in path or path.startswith("~"): + msg = f"Path traversal not allowed: {path}" + raise ValueError(msg) + + # Normalize path (resolve ., //, etc.) + normalized = os.path.normpath(path) + + # Convert to forward slashes for consistency + normalized = normalized.replace("\\", "/") + + # Ensure path starts with / + if not normalized.startswith("/"): + normalized = f"/{normalized}" + + # Check allowed prefixes if specified + if allowed_prefixes is not None and not any( + normalized.startswith(prefix) for prefix in allowed_prefixes + ): + msg = f"Path must start with one of {allowed_prefixes}: {path}" + raise ValueError(msg) + + return normalized + + +def format_content_with_line_numbers( + content: str | list[str], + *, + format_style: Literal["pipe", "tab"] = "pipe", + start_line: int = 1, +) -> str: + r"""Format file content with line numbers. + + Args: + content: File content as string or list of lines. + format_style: "pipe" for "1|content" or "tab" for " 1\tcontent". + start_line: Starting line number. + + Returns: + Formatted content with line numbers. + """ + if isinstance(content, str): + lines = content.split("\n") + # Remove trailing empty line from split + if lines and lines[-1] == "": + lines = lines[:-1] + else: + lines = content + + if format_style == "pipe": + return "\n".join(f"{i + start_line}|{line}" for i, line in enumerate(lines)) + + return "\n".join(f"{i + start_line:6d}\t{line[:2000]}" for i, line in enumerate(lines)) + + +def apply_string_replacement( + content: str, + old_string: str, + new_string: str, + *, + replace_all: bool = False, +) -> tuple[str, int]: + """Apply string replacement to content. + + Args: + content: Original content. + old_string: String to replace. + new_string: Replacement string. + replace_all: If True, replace all occurrences. Otherwise, replace first. + + Returns: + Tuple of (new_content, replacement_count). + """ + if replace_all: + count = content.count(old_string) + new_content = content.replace(old_string, new_string) + else: + count = 1 + new_content = content.replace(old_string, new_string, 1) + + return new_content, count + + +def create_file_data( + content: str | list[str], + *, + created_at: str | None = None, +) -> FileData: + """Create a FileData object from content. + + Args: + content: File content as string or list of lines. + created_at: Optional creation timestamp. If None, uses current time. + + Returns: + FileData object. + """ + lines = content.split("\n") if isinstance(content, str) else content + + now = datetime.now(timezone.utc).isoformat() + + return { + "content": lines, + "created_at": created_at or now, + "modified_at": now, + } + + +def update_file_data( + file_data: FileData, + content: str | list[str], +) -> FileData: + """Update a FileData object with new content. + + Args: + file_data: Existing FileData object. + content: New file content as string or list of lines. + + Returns: + Updated FileData object with new modified_at timestamp. + """ + lines = content.split("\n") if isinstance(content, str) else content + + now = datetime.now(timezone.utc).isoformat() + + return { + "content": lines, + "created_at": file_data["created_at"], + "modified_at": now, + } + + +def file_data_to_string(file_data: FileData) -> str: + """Convert FileData to plain string content. + + Args: + file_data: FileData object. + + Returns: + File content as string. + """ + return "\n".join(file_data["content"]) + + +def list_directory(files: dict[str, FileData], path: str) -> list[str]: + """List files in a directory. + + Args: + files: Files dict mapping paths to FileData. + path: Normalized directory path. + + Returns: + Sorted list of file paths in the directory. + """ + # Ensure path ends with / for directory matching + dir_path = path if path.endswith("/") else f"{path}/" + + matching_files = [] + for file_path in files: + if file_path.startswith(dir_path): + # Get relative path from directory + relative = file_path[len(dir_path) :] + # Only include direct children (no subdirectories) + if "/" not in relative: + matching_files.append(file_path) + + return sorted(matching_files) + + +def check_empty_content(content: str) -> str | None: + """Check if file content is empty and return warning message. + + Args: + content: File content. + + Returns: + Warning message if empty, None otherwise. + """ + if not content or content.strip() == "": + return "System reminder: File exists but has empty contents" + return None diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py new file mode 100644 index 0000000000000..85e530d06114c --- /dev/null +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -0,0 +1,557 @@ +"""Middleware for providing filesystem tools to an agent.""" +# ruff: noqa: E501 + +from collections.abc import Callable +from typing import TYPE_CHECKING, Annotated, Any, NotRequired + +if TYPE_CHECKING: + from langgraph.runtime import Runtime + from langgraph.store.base import Item + +from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.tools import BaseTool, InjectedToolCallId, tool +from langgraph.runtime import Runtime, get_runtime +from langgraph.types import Command + +from langchain.agents.middleware import AgentMiddleware, AgentState, ModelRequest +from langchain.agents.middleware.file_utils import ( + FileData, + apply_string_replacement, + check_empty_content, + create_file_data, + file_data_reducer, + file_data_to_string, + format_content_with_line_numbers, + list_directory, + update_file_data, +) +from langchain.tools.tool_node import InjectedState + + +class FilesystemState(AgentState): + """State for the filesystem middleware.""" + + files: Annotated[NotRequired[dict[str, FileData]], file_data_reducer] + """Files in the filesystem.""" + + +LIST_FILES_TOOL_DESCRIPTION = """Lists all files in the filesystem, optionally filtering by directory. + +Usage: +- The list_files tool will return a list of all files in the filesystem. +- You can optionally provide a path parameter to list files in a specific directory. +- This is very useful for exploring the file system and finding the right file to read or edit. +- You should almost ALWAYS use this tool before using the Read or Edit tools.""" +LIST_FILES_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( + "\n- Files from the longterm filesystem will be prefixed with the memories/ path." +) + +READ_FILE_TOOL_DESCRIPTION = """Reads a file from the filesystem. You can access any file directly by using this tool. +Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. + +Usage: +- The file_path parameter must be an absolute path, not a relative path +- By default, it reads up to 2000 lines starting from the beginning of the file +- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters +- Any lines longer than 2000 characters will be truncated +- Results are returned using cat -n format, with line numbers starting at 1 +- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. +- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. +- You should ALWAYS make sure a file has been read before editing it.""" +READ_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( + "\n- file_paths prefixed with the memories/ path will be read from the longterm filesystem." +) + +EDIT_FILE_TOOL_DESCRIPTION = """Performs exact string replacements in files. + +Usage: +- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. +- ALWAYS prefer editing existing files. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. +- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.""" +EDIT_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = "\n- You can edit files in the longterm filesystem by prefixing the filename with the memories/ path." + +WRITE_FILE_TOOL_DESCRIPTION = """Writes to a new file in the filesystem. + +Usage: +- The file_path parameter must be an absolute path, not a relative path +- The content parameter must be a string +- The write_file tool will create the a new file. +- Prefer to edit existing files over creating new ones when possible. +- file_paths prefixed with the memories/ path will be written to the longterm filesystem.""" +WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( + "\n- file_paths prefixed with the memories/ path will be written to the longterm filesystem." +) + +FILESYSTEM_SYSTEM_PROMPT = """## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file` + +You have access to a filesystem which you can interact with using these tools. +Do not prepend a / to file_paths. + +- ls: list all files in the filesystem +- read_file: read a file from the filesystem +- write_file: write to a file in the filesystem +- edit_file: edit a file in the filesystem""" +FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT = """ + +You also have access to a longterm filesystem in which you can store files that you want to keep around for longer than the current conversation. +In order to interact with the longterm filesystem, you can use those same tools, but filenames must be prefixed with the memories/ path. +Remember, to interact with the longterm filesystem, you must prefix the filename with the memories/ path.""" + + +def _has_memories_prefix(file_path: str) -> bool: + return file_path.startswith("memories/") + + +def _append_memories_prefix(file_path: str) -> str: + return f"memories/{file_path}" + + +def _strip_memories_prefix(file_path: str) -> str: + return file_path.replace("memories/", "") + + +def _get_namespace(runtime: Runtime[Any]) -> tuple[str] | tuple[str, str]: + namespace = "filesystem" + if runtime.context is None: + return (namespace,) + assistant_id = runtime.context.get("assistant_id") + if assistant_id is None: + return (namespace,) + return (assistant_id, "filesystem") + + +def _ls_tool_generator( + custom_description: str | None = None, *, has_longterm_memory: bool +) -> BaseTool: + tool_description = LIST_FILES_TOOL_DESCRIPTION + if custom_description: + tool_description = custom_description + elif has_longterm_memory: + tool_description += LIST_FILES_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + + if has_longterm_memory: + # Tool with Long-term memory + @tool(description=tool_description) + def ls( + state: Annotated[FilesystemState, InjectedState], path: str | None = None + ) -> list[str]: + files_dict = state.get("files", {}) + + # If path is provided, filter by directory + if path is not None: + from langchain.agents.middleware.file_utils import validate_path + + normalized_path = validate_path(path) + files = list_directory(files_dict, normalized_path) + else: + files = list(files_dict.keys()) + + runtime = get_runtime() + store = runtime.store + if store is None: + msg = "Longterm memory is enabled, but no store is available" + raise ValueError(msg) + namespace = _get_namespace(runtime) + file_data_list = store.search(namespace) + memories_files = [_append_memories_prefix(f.key) for f in file_data_list] + files.extend(memories_files) + return files + else: + # Tool without long-term memory + @tool(description=tool_description) + def ls( + state: Annotated[FilesystemState, InjectedState], path: str | None = None + ) -> list[str]: + files_dict = state.get("files", {}) + + # If path is provided, filter by directory + if path is not None: + from langchain.agents.middleware.file_utils import validate_path + + normalized_path = validate_path(path) + return list_directory(files_dict, normalized_path) + + return list(files_dict.keys()) + + return ls + + +def _read_file_tool_generator( + custom_description: str | None = None, *, has_longterm_memory: bool +) -> BaseTool: + tool_description = READ_FILE_TOOL_DESCRIPTION + if custom_description: + tool_description = custom_description + elif has_longterm_memory: + tool_description += READ_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + + if has_longterm_memory: + # Tool with Long-term memory + @tool(description=tool_description) + def read_file( + file_path: str, + state: Annotated[FilesystemState, InjectedState], + offset: int = 0, + limit: int = 2000, + ) -> str: + if _has_memories_prefix(file_path): + stripped_file_path = _strip_memories_prefix(file_path) + runtime = get_runtime() + store = runtime.store + if store is None: + msg = "Longterm memory is enabled, but no store is available" + raise ValueError(msg) + namespace = _get_namespace(runtime) + item: Item | None = store.get(namespace, stripped_file_path) + if item is None: + return f"Error: File '{file_path}' not found" + content: str = str(item.value["content"]) + else: + mock_filesystem = state.get("files", {}) + if file_path not in mock_filesystem: + return f"Error: File '{file_path}' not found" + file_data = mock_filesystem[file_path] + content = file_data_to_string(file_data) + + # Check for empty content + empty_msg = check_empty_content(content) + if empty_msg: + return empty_msg + + lines = content.splitlines() + start_idx = offset + end_idx = min(start_idx + limit, len(lines)) + if start_idx >= len(lines): + return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)" + + # Use shared formatting for the selected range + selected_lines = lines[start_idx:end_idx] + return format_content_with_line_numbers( + selected_lines, format_style="tab", start_line=start_idx + 1 + ) + else: + # Tool without long-term memory + @tool(description=tool_description) + def read_file( + file_path: str, + state: Annotated[FilesystemState, InjectedState], + offset: int = 0, + limit: int = 2000, + ) -> str: + mock_filesystem = state.get("files", {}) + if file_path not in mock_filesystem: + return f"Error: File '{file_path}' not found" + file_data = mock_filesystem[file_path] + content = file_data_to_string(file_data) + + # Check for empty content + empty_msg = check_empty_content(content) + if empty_msg: + return empty_msg + + lines = content.splitlines() + start_idx = offset + end_idx = min(start_idx + limit, len(lines)) + if start_idx >= len(lines): + return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)" + + # Use shared formatting for the selected range + selected_lines = lines[start_idx:end_idx] + return format_content_with_line_numbers( + selected_lines, format_style="tab", start_line=start_idx + 1 + ) + + return read_file + + +def _write_file_tool_generator( + custom_description: str | None = None, *, has_longterm_memory: bool +) -> BaseTool: + tool_description = WRITE_FILE_TOOL_DESCRIPTION + if custom_description: + tool_description = custom_description + elif has_longterm_memory: + tool_description += WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + + if has_longterm_memory: + # Tool with Long-term memory + @tool(description=tool_description) + def write_file( + file_path: str, + content: str, + state: Annotated[FilesystemState, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> Command: + if _has_memories_prefix(file_path): + stripped_file_path = _strip_memories_prefix(file_path) + runtime = get_runtime() + store = runtime.store + if store is None: + msg = "Longterm memory is enabled, but no store is available" + raise ValueError(msg) + namespace = _get_namespace(runtime) + store.put(namespace, stripped_file_path, {"content": content}) + return Command( + update={ + "messages": [ + ToolMessage( + f"Updated longterm memories file {file_path}", + tool_call_id=tool_call_id, + ) + ] + } + ) + mock_filesystem = state.get("files", {}) + existing = mock_filesystem.get(file_path) + + # Create or update FileData + if existing: + new_file_data = update_file_data(existing, content) + else: + new_file_data = create_file_data(content) + + return Command( + update={ + "files": {file_path: new_file_data}, + "messages": [ + ToolMessage(f"Updated file {file_path}", tool_call_id=tool_call_id) + ], + } + ) + else: + # Tool without long-term memory + @tool(description=tool_description) + def write_file( + file_path: str, + content: str, + state: Annotated[FilesystemState, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> Command: + mock_filesystem = state.get("files", {}) + existing = mock_filesystem.get(file_path) + + # Create or update FileData + if existing: + new_file_data = update_file_data(existing, content) + else: + new_file_data = create_file_data(content) + + return Command( + update={ + "files": {file_path: new_file_data}, + "messages": [ + ToolMessage(f"Updated file {file_path}", tool_call_id=tool_call_id) + ], + } + ) + + return write_file + + +def _edit_file_tool_generator( + custom_description: str | None = None, *, has_longterm_memory: bool +) -> BaseTool: + tool_description = EDIT_FILE_TOOL_DESCRIPTION + if custom_description: + tool_description = custom_description + elif has_longterm_memory: + tool_description += EDIT_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + + if has_longterm_memory: + # Tool with Long-term memory + @tool(description=tool_description) + def edit_file( + file_path: str, + old_string: str, + new_string: str, + state: Annotated[FilesystemState, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + *, + replace_all: bool = False, + ) -> Command | str: + if _has_memories_prefix(file_path): + stripped_file_path = _strip_memories_prefix(file_path) + runtime = get_runtime() + store = runtime.store + if store is None: + msg = "Longterm memory is enabled, but no store is available" + raise ValueError(msg) + namespace = _get_namespace(runtime) + item: Item | None = store.get(namespace, stripped_file_path) + if item is None: + return f"Error: File '{file_path}' not found" + content: str = str(item.value["content"]) + if old_string not in content: + return f"Error: String not found in file: '{old_string}'" + if not replace_all: + occurrences = content.count(old_string) + if occurrences > 1: + return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context." + if occurrences == 0: + return f"Error: String not found in file: '{old_string}'" + new_content = content.replace(old_string, new_string, 1) + replacement_count = 1 + store.put(namespace, stripped_file_path, {"content": new_content}) + return f"Successfully replaced {replacement_count} instance(s) of the string in '{file_path}'" + mock_filesystem = state.get("files", {}) + if file_path not in mock_filesystem: + return f"Error: File '{file_path}' not found" + file_data = mock_filesystem[file_path] + content = file_data_to_string(file_data) + + # Check if string exists + if old_string not in content: + return f"Error: String not found in file: '{old_string}'" + + # Apply replacement + new_content, replacement_count = apply_string_replacement( + content, old_string, new_string, replace_all=replace_all + ) + + # Update file data + new_file_data = update_file_data(file_data, new_content) + + result_msg = f"Successfully replaced {replacement_count} instance(s) of the string in '{file_path}'" + return Command( + update={ + "files": {file_path: new_file_data}, + "messages": [ToolMessage(result_msg, tool_call_id=tool_call_id)], + } + ) + else: + # Tool without long-term memory + @tool(description=tool_description) + def edit_file( + file_path: str, + old_string: str, + new_string: str, + state: Annotated[FilesystemState, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + *, + replace_all: bool = False, + ) -> Command | str: + mock_filesystem = state.get("files", {}) + if file_path not in mock_filesystem: + return f"Error: File '{file_path}' not found" + file_data = mock_filesystem[file_path] + content = file_data_to_string(file_data) + + # Check if string exists + if old_string not in content: + return f"Error: String not found in file: '{old_string}'" + + # Apply replacement + new_content, replacement_count = apply_string_replacement( + content, old_string, new_string, replace_all=replace_all + ) + + # Update file data + new_file_data = update_file_data(file_data, new_content) + + result_msg = f"Successfully replaced {replacement_count} instance(s) of the string in '{file_path}'" + return Command( + update={ + "files": {file_path: new_file_data}, + "messages": [ToolMessage(result_msg, tool_call_id=tool_call_id)], + } + ) + + return edit_file + + +TOOL_GENERATORS = { + "ls": _ls_tool_generator, + "read_file": _read_file_tool_generator, + "write_file": _write_file_tool_generator, + "edit_file": _edit_file_tool_generator, +} + + +def _get_filesystem_tools( + custom_tool_descriptions: dict[str, str] | None = None, *, has_longterm_memory: bool +) -> list[BaseTool]: + """Get filesystem tools. + + Args: + has_longterm_memory: Whether to enable longterm memory support. + custom_tool_descriptions: Optional custom descriptions for tools. + + Returns: + List of configured filesystem tools. + """ + if custom_tool_descriptions is None: + custom_tool_descriptions = {} + tools = [] + for tool_name, tool_generator in TOOL_GENERATORS.items(): + tool = tool_generator( + custom_tool_descriptions.get(tool_name), has_longterm_memory=has_longterm_memory + ) + tools.append(tool) + return tools + + +class FilesystemMiddleware(AgentMiddleware): + """Middleware for providing filesystem tools to an agent. + + Args: + use_longterm_memory: Whether to enable longterm memory support. + system_prompt_extension: Optional custom system prompt. + custom_tool_descriptions: Optional custom tool descriptions. + + Returns: + List of configured filesystem tools. + + Raises: + ValueError: If longterm memory is enabled but no store is available. + + Example: + ```python + from langchain.agents.middleware.filesystem import FilesystemMiddleware + from langchain.agents import create_agent + + agent = create_agent(middleware=[FilesystemMiddleware(use_longterm_memory=False)]) + ``` + """ + + state_schema = FilesystemState + + def __init__( + self, + *, + use_longterm_memory: bool = False, + system_prompt_extension: str | None = None, + custom_tool_descriptions: dict[str, str] | None = None, + ) -> None: + """Initialize the filesystem middleware. + + Args: + use_longterm_memory: Whether to enable longterm memory support. + system_prompt_extension: Optional custom system prompt. + custom_tool_descriptions: Optional custom tool descriptions. + """ + self.system_prompt_extension = FILESYSTEM_SYSTEM_PROMPT + if system_prompt_extension is not None: + self.system_prompt_extension = system_prompt_extension + elif use_longterm_memory: + self.system_prompt_extension += FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT + + self.tools = _get_filesystem_tools( + custom_tool_descriptions, has_longterm_memory=use_longterm_memory + ) + + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], AIMessage], + ) -> AIMessage: + """Update the system prompt to include instructions on using the filesystem.""" + if self.system_prompt_extension is not None: + request.system_prompt = ( + request.system_prompt + "\n\n" + self.system_prompt_extension + if request.system_prompt + else self.system_prompt_extension + ) + return handler(request) diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index ccf50c214c406..c5736f55e09f3 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -1,10 +1,10 @@ """Middleware for providing subagents to an agent via a `task` tool.""" # ruff: noqa: E501 -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Annotated, Any, NotRequired, TypedDict, cast -from langchain_core.language_models import BaseChatModel, LanguageModelLike +from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool, tool @@ -17,7 +17,6 @@ SummarizationMiddleware, ) from langchain.agents.middleware.types import AgentMiddleware, ModelRequest -from langchain.chat_models import init_chat_model from langchain.tools import InjectedState, InjectedToolCallId @@ -36,7 +35,7 @@ class DefinedSubAgent(TypedDict): tools: NotRequired[list[BaseTool]] """The tools to use for the subagent.""" - model: NotRequired[LanguageModelLike | dict[str, Any]] + model: NotRequired[str | BaseChatModel] """The model for the subagent.""" middleware: NotRequired[list[AgentMiddleware]] @@ -59,26 +58,6 @@ class CustomSubAgent(TypedDict): DEFAULT_SUBAGENT_PROMPT = """In order to complete the objective that the user asks of you, you have access to a number of standard tools. """ - -def _normalize_model(model: LanguageModelLike | dict[str, Any]) -> BaseChatModel: - """Normalize a model specification to a BaseChatModel instance. - - Args: - model: The model specification (can be LanguageModelLike or dict). - - Returns: - A BaseChatModel instance. - """ - if isinstance(model, BaseChatModel): - return model - if isinstance(model, dict): - return init_chat_model(**model) - if isinstance(model, str): - return init_chat_model(model) - # For any other LanguageModelLike, try to convert to string and init - return init_chat_model(str(model)) - - TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. Available agent types and the tools they have access to: @@ -192,16 +171,14 @@ def _normalize_model(model: LanguageModelLike | dict[str, Any]) -> BaseChatModel def _get_subagents( - default_subagent_model: LanguageModelLike | dict[str, Any], - default_subagent_tools: list[BaseTool], + default_subagent_model: str | BaseChatModel, + default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]], subagents: list[DefinedSubAgent | CustomSubAgent], ) -> tuple[dict[str, Any], list[str]]: - normalized_model = _normalize_model(default_subagent_model) - default_subagent_middleware = [ PlanningMiddleware(), SummarizationMiddleware( - model=normalized_model, + model=default_subagent_model, max_tokens_before_summary=120000, messages_to_keep=20, ), @@ -210,7 +187,7 @@ def _get_subagents( # Create the general-purpose subagent general_purpose_subagent = create_agent( - model=normalized_model, + model=default_subagent_model, system_prompt=DEFAULT_SUBAGENT_PROMPT, tools=default_subagent_tools, middleware=default_subagent_middleware, @@ -224,11 +201,9 @@ def _get_subagents( custom_agent = cast("CustomSubAgent", _agent) agents[custom_agent["name"]] = custom_agent["runnable"] continue - _tools = _agent["tools"] if "tools" in _agent else default_subagent_tools.copy() + _tools = _agent.get("tools", list(default_subagent_tools)) - subagent_model = ( - _normalize_model(_agent["model"]) if "model" in _agent else normalized_model - ) + subagent_model = _agent.get("model", default_subagent_model) if "middleware" in _agent: _middleware = [*default_subagent_middleware, *_agent["middleware"]] @@ -246,8 +221,8 @@ def _get_subagents( def _create_task_tool( - default_subagent_model: LanguageModelLike | dict[str, Any], - default_subagent_tools: list[BaseTool], + default_subagent_model: str | BaseChatModel, + default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]], subagents: list[DefinedSubAgent | CustomSubAgent], *, is_async: bool = False, @@ -282,6 +257,8 @@ async def task( return f"Error: invoked agent of type {subagent_type}, the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" subagent = subagent_graphs[subagent_type] state["messages"] = [HumanMessage(content=description)] + if "todos" in state: + del state["todos"] result = await subagent.ainvoke(state) return _return_command_with_state_update(result, tool_call_id) else: @@ -297,6 +274,8 @@ def task( return f"Error: invoked agent of type {subagent_type}, the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" subagent = subagent_graphs[subagent_type] state["messages"] = [HumanMessage(content=description)] + if "todos" in state: + del state["todos"] result = subagent.invoke(state) return _return_command_with_state_update(result, tool_call_id) @@ -342,8 +321,8 @@ class SubAgentMiddleware(AgentMiddleware): def __init__( self, *, - default_subagent_model: LanguageModelLike | dict[str, Any], - default_subagent_tools: list[BaseTool] | None = None, + default_subagent_model: str | BaseChatModel, + default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, system_prompt_extension: str | None = None, is_async: bool = False, diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py new file mode 100644 index 0000000000000..92db21c761b53 --- /dev/null +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py @@ -0,0 +1,274 @@ +from langchain.agents.middleware.filesystem import ( + FilesystemMiddleware, + WRITE_FILE_TOOL_DESCRIPTION, + WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT, +) +from langchain.agents import create_agent +from langchain.agents.deepagents import create_deep_agent +from langchain_core.messages import HumanMessage +from langchain_anthropic import ChatAnthropic +from langgraph.store.memory import InMemoryStore +from langgraph.checkpoint.memory import MemorySaver +import pytest +import uuid + + +class TestFilesystem: + def test_create_deepagent_without_store_and_with_longterm_memory_should_fail(self): + with pytest.raises(ValueError): + deepagent = create_deep_agent(tools=[], use_longterm_memory=True) + deepagent.invoke( + {"messages": [HumanMessage(content="List all of the files in your filesystem?")]} + ) + + def test_filesystem_system_prompt_override(self): + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=False, + system_prompt_extension="In every single response, you must say the word 'pokemon'! You love it!", + ) + ], + ) + response = agent.invoke({"messages": [HumanMessage(content="What do you like?")]}) + assert "pokemon" in response["messages"][1].text.lower() + + def test_filesystem_system_prompt_override_with_longterm_memory(self): + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + system_prompt_extension="In every single response, you must say the word 'pokemon'! You love it!", + ) + ], + store=InMemoryStore(), + ) + response = agent.invoke({"messages": [HumanMessage(content="What do you like?")]}) + assert "pokemon" in response["messages"][1].text.lower() + + def test_filesystem_tool_prompt_override(self): + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=False, + custom_tool_descriptions={ + "ls": "Charmander", + "read_file": "Bulbasaur", + "edit_file": "Squirtle", + }, + ) + ], + ) + tools = agent.nodes["tools"].bound._tools_by_name + assert "ls" in tools + assert tools["ls"].description == "Charmander" + assert "read_file" in tools + assert tools["read_file"].description == "Bulbasaur" + assert "write_file" in tools + assert tools["write_file"].description == WRITE_FILE_TOOL_DESCRIPTION + assert "edit_file" in tools + assert tools["edit_file"].description == "Squirtle" + + def test_filesystem_tool_prompt_override_with_longterm_memory(self): + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + custom_tool_descriptions={ + "ls": "Charmander", + "read_file": "Bulbasaur", + "edit_file": "Squirtle", + }, + ) + ], + store=InMemoryStore(), + ) + tools = agent.nodes["tools"].bound._tools_by_name + assert "ls" in tools + assert tools["ls"].description == "Charmander" + assert "read_file" in tools + assert tools["read_file"].description == "Bulbasaur" + assert "write_file" in tools + assert ( + tools["write_file"].description + == WRITE_FILE_TOOL_DESCRIPTION + WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + ) + assert "edit_file" in tools + assert tools["edit_file"].description == "Squirtle" + + def test_longterm_memory_tools(self): + checkpointer = MemorySaver() + store = InMemoryStore() + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + assert_longterm_mem_tools(agent, store) + + def test_longterm_memory_tools_deepagent(self): + checkpointer = MemorySaver() + store = InMemoryStore() + agent = create_deep_agent(use_longterm_memory=True, checkpointer=checkpointer, store=store) + assert_longterm_mem_tools(agent, store) + + def test_shortterm_memory_tools_deepagent(self): + checkpointer = MemorySaver() + store = InMemoryStore() + agent = create_deep_agent(use_longterm_memory=False, checkpointer=checkpointer, store=store) + assert_shortterm_mem_tools(agent) + + +def assert_longterm_mem_tools(agent, store): + config = {"configurable": {"thread_id": uuid.uuid4()}} + agent.invoke( + { + "messages": [ + HumanMessage( + content="Write a haiku about Charmander to longterm memory in charmander.txt, use the word 'fiery'" + ) + ] + }, + config=config, + ) + + namespaces = store.list_namespaces() + assert len(namespaces) == 1 + assert namespaces[0] == ("filesystem",) + file_item = store.get(("filesystem",), "charmander.txt") + assert file_item is not None + assert file_item.key == "charmander.txt" + + config2 = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Read the haiku about Charmander from longterm memory at charmander.txt" + ) + ] + }, + config=config2, + ) + + messages = response["messages"] + read_file_message = next( + message for message in messages if message.type == "tool" and message.name == "read_file" + ) + assert "fiery" in read_file_message.content or "Fiery" in read_file_message.content + + config3 = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + {"messages": [HumanMessage(content="List all of the files in longterm memory")]}, + config=config3, + ) + messages = response["messages"] + ls_message = next( + message for message in messages if message.type == "tool" and message.name == "ls" + ) + assert "memories/charmander.txt" in ls_message.content + + config4 = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Edit the haiku about Charmander in longterm memory to use the word 'ember'" + ) + ] + }, + config=config4, + ) + file_item = store.get(("filesystem",), "charmander.txt") + assert file_item is not None + assert file_item.key == "charmander.txt" + assert "ember" in file_item.value["content"] or "Ember" in file_item.value["content"] + + config5 = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Read the haiku about Charmander from longterm memory at charmander.txt" + ) + ] + }, + config=config5, + ) + messages = response["messages"] + read_file_message = next( + message for message in messages if message.type == "tool" and message.name == "read_file" + ) + assert "ember" in read_file_message.content or "Ember" in read_file_message.content + + +def assert_shortterm_mem_tools(agent): + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Write a haiku about Charmander to charmander.txt, use the word 'fiery'" + ) + ] + }, + config=config, + ) + files = response["files"] + assert "charmander.txt" in files + + response = agent.invoke( + {"messages": [HumanMessage(content="Read the haiku about Charmander from charmander.txt")]}, + config=config, + ) + messages = response["messages"] + read_file_message = next( + message + for message in reversed(messages) + if message.type == "tool" and message.name == "read_file" + ) + assert "fiery" in read_file_message.content or "Fiery" in read_file_message.content + + response = agent.invoke( + {"messages": [HumanMessage(content="List all of the files in memory")]}, config=config + ) + messages = response["messages"] + ls_message = next( + message for message in messages if message.type == "tool" and message.name == "ls" + ) + assert "charmander.txt" in ls_message.content + + response = agent.invoke( + { + "messages": [ + HumanMessage(content="Edit the haiku about Charmander to use the word 'ember'") + ] + }, + config=config, + ) + files = response["files"] + assert "charmander.txt" in files + assert "ember" in "\n".join(files["charmander.txt"]["content"]) or "Ember" in "\n".join( + files["charmander.txt"]["content"] + ) + + response = agent.invoke( + {"messages": [HumanMessage(content="Read the haiku about Charmander at charmander.txt")]}, + config=config, + ) + messages = response["messages"] + read_file_message = next( + message + for message in reversed(messages) + if message.type == "tool" and message.name == "read_file" + ) + assert "ember" in read_file_message.content or "Ember" in read_file_message.content diff --git a/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py b/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py new file mode 100644 index 0000000000000..02d494deb56bf --- /dev/null +++ b/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py @@ -0,0 +1,304 @@ +from langchain.agents.deepagents import create_deep_agent +from langchain.agents import create_agent +from langchain_core.tools import tool, InjectedToolCallId +from typing import Annotated +from langchain.tools.tool_node import InjectedState +from langchain.agents.middleware import AgentMiddleware, AgentState +from langgraph.types import Command +from langchain_core.messages import ToolMessage + + +def assert_all_deepagent_qualities(agent): + assert "todos" in agent.stream_channels + assert "files" in agent.stream_channels + assert "write_todos" in agent.nodes["tools"].bound._tools_by_name.keys() + assert "ls" in agent.nodes["tools"].bound._tools_by_name.keys() + assert "read_file" in agent.nodes["tools"].bound._tools_by_name.keys() + assert "write_file" in agent.nodes["tools"].bound._tools_by_name.keys() + assert "edit_file" in agent.nodes["tools"].bound._tools_by_name.keys() + assert "task" in agent.nodes["tools"].bound._tools_by_name.keys() + + +SAMPLE_MODEL = "claude-3-5-sonnet-20240620" + + +@tool(description="Use this tool to get the weather") +def get_weather(location: str): + return f"The weather in {location} is sunny." + + +@tool(description="Use this tool to get the latest soccer scores") +def get_soccer_scores(team: str): + return f"The latest soccer scores for {team} are 2-1." + + +@tool(description="Sample tool") +def sample_tool(sample_input: str): + return sample_input + + +@tool(description="Sample tool with injected state") +def sample_tool_with_injected_state(sample_input: str, state: Annotated[dict, InjectedState]): + return sample_input + state["sample_input"] + + +TOY_BASKETBALL_RESEARCH = "Lebron James is the best basketball player of all time with over 40k points and 21 seasons in the NBA." + + +@tool(description="Use this tool to conduct research into basketball and save it to state") +def research_basketball( + topic: str, + state: Annotated[dict, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], +): + current_research = state.get("research", "") + research = f"{current_research}\n\nResearching on {topic}... Done! {TOY_BASKETBALL_RESEARCH}" + return Command( + update={ + "research": research, + "messages": [ToolMessage(research, tool_call_id=tool_call_id)], + } + ) + + +class ResearchState(AgentState): + research: str + + +class ResearchMiddlewareWithTools(AgentMiddleware): + state_schema = ResearchState + tools = [research_basketball] + + +class ResearchMiddleware(AgentMiddleware): + state_schema = ResearchState + + +class SampleMiddlewareWithTools(AgentMiddleware): + tools = [sample_tool] + + +class SampleState(AgentState): + sample_input: str + + +class SampleMiddlewareWithToolsAndState(AgentMiddleware): + state_schema = SampleState + tools = [sample_tool] + + +class WeatherToolMiddleware(AgentMiddleware): + tools = [get_weather] + + +class TestDeepAgentsFilesystem: + def test_base_deep_agent(self): + agent = create_deep_agent() + assert_all_deepagent_qualities(agent) + + def test_deep_agent_with_tool(self): + agent = create_deep_agent(tools=[sample_tool]) + assert_all_deepagent_qualities(agent) + assert "sample_tool" in agent.nodes["tools"].bound._tools_by_name.keys() + + def test_deep_agent_with_middleware_with_tool(self): + agent = create_deep_agent(middleware=[SampleMiddlewareWithTools()]) + assert_all_deepagent_qualities(agent) + assert "sample_tool" in agent.nodes["tools"].bound._tools_by_name.keys() + + def test_deep_agent_with_middleware_with_tool_and_state(self): + agent = create_deep_agent(middleware=[SampleMiddlewareWithToolsAndState()]) + assert_all_deepagent_qualities(agent) + assert "sample_tool" in agent.nodes["tools"].bound._tools_by_name.keys() + assert "sample_input" in agent.stream_channels + + def test_deep_agent_with_subagents(self): + subagents = [ + { + "name": "weather_agent", + "description": "Use this agent to get the weather", + "prompt": "You are a weather agent.", + "tools": [get_weather], + "model": SAMPLE_MODEL, + } + ] + agent = create_deep_agent(tools=[sample_tool], subagents=subagents) + assert_all_deepagent_qualities(agent) + result = agent.invoke( + {"messages": [{"role": "user", "content": "What is the weather in Tokyo?"}]} + ) + agent_messages = [msg for msg in result.get("messages", []) if msg.type == "ai"] + tool_calls = [tool_call for msg in agent_messages for tool_call in msg.tool_calls] + assert any( + [ + tool_call["name"] == "task" + and tool_call["args"].get("subagent_type") == "weather_agent" + for tool_call in tool_calls + ] + ) + + def test_deep_agent_with_subagents_gen_purpose(self): + subagents = [ + { + "name": "weather_agent", + "description": "Use this agent to get the weather", + "prompt": "You are a weather agent.", + "tools": [get_weather], + "model": SAMPLE_MODEL, + } + ] + agent = create_deep_agent(tools=[sample_tool], subagents=subagents) + assert_all_deepagent_qualities(agent) + result = agent.invoke( + { + "messages": [ + { + "role": "user", + "content": "Use the general purpose subagent to call the sample tool", + } + ] + } + ) + agent_messages = [msg for msg in result.get("messages", []) if msg.type == "ai"] + tool_calls = [tool_call for msg in agent_messages for tool_call in msg.tool_calls] + assert any( + [ + tool_call["name"] == "task" + and tool_call["args"].get("subagent_type") == "general-purpose" + for tool_call in tool_calls + ] + ) + + def test_deep_agent_with_subagents_with_middleware(self): + subagents = [ + { + "name": "weather_agent", + "description": "Use this agent to get the weather", + "prompt": "You are a weather agent.", + "tools": [], + "model": SAMPLE_MODEL, + "middleware": [WeatherToolMiddleware()], + } + ] + agent = create_deep_agent(tools=[sample_tool], subagents=subagents) + assert_all_deepagent_qualities(agent) + result = agent.invoke( + {"messages": [{"role": "user", "content": "What is the weather in Tokyo?"}]} + ) + agent_messages = [msg for msg in result.get("messages", []) if msg.type == "ai"] + tool_calls = [tool_call for msg in agent_messages for tool_call in msg.tool_calls] + assert any( + [ + tool_call["name"] == "task" + and tool_call["args"].get("subagent_type") == "weather_agent" + for tool_call in tool_calls + ] + ) + + def test_deep_agent_with_custom_subagents(self): + subagents = [ + { + "name": "weather_agent", + "description": "Use this agent to get the weather", + "prompt": "You are a weather agent.", + "tools": [get_weather], + "model": SAMPLE_MODEL, + }, + { + "name": "soccer_agent", + "description": "Use this agent to get the latest soccer scores", + "runnable": create_agent( + model=SAMPLE_MODEL, + tools=[get_soccer_scores], + system_prompt="You are a soccer agent.", + ), + }, + ] + agent = create_deep_agent(tools=[sample_tool], subagents=subagents) + assert_all_deepagent_qualities(agent) + result = agent.invoke( + { + "messages": [ + { + "role": "user", + "content": "Look up the weather in Tokyo, and the latest scores for Manchester City!", + } + ] + } + ) + agent_messages = [msg for msg in result.get("messages", []) if msg.type == "ai"] + tool_calls = [tool_call for msg in agent_messages for tool_call in msg.tool_calls] + assert any( + [ + tool_call["name"] == "task" + and tool_call["args"].get("subagent_type") == "weather_agent" + for tool_call in tool_calls + ] + ) + assert any( + [ + tool_call["name"] == "task" + and tool_call["args"].get("subagent_type") == "soccer_agent" + for tool_call in tool_calls + ] + ) + + def test_deep_agent_with_extended_state_and_subagents(self): + subagents = [ + { + "name": "basketball_info_agent", + "description": "Use this agent to get surface level info on any basketball topic", + "prompt": "You are a basketball info agent.", + "middleware": [ResearchMiddlewareWithTools()], + } + ] + agent = create_deep_agent( + tools=[sample_tool], subagents=subagents, middleware=[ResearchMiddleware()] + ) + assert_all_deepagent_qualities(agent) + assert "research" in agent.stream_channels + result = agent.invoke( + {"messages": [{"role": "user", "content": "Get surface level info on lebron james"}]}, + config={"recursion_limit": 100}, + ) + agent_messages = [msg for msg in result.get("messages", []) if msg.type == "ai"] + tool_calls = [tool_call for msg in agent_messages for tool_call in msg.tool_calls] + assert any( + [ + tool_call["name"] == "task" + and tool_call["args"].get("subagent_type") == "basketball_info_agent" + for tool_call in tool_calls + ] + ) + assert TOY_BASKETBALL_RESEARCH in result["research"] + + def test_deep_agent_with_subagents_no_tools(self): + subagents = [ + { + "name": "basketball_info_agent", + "description": "Use this agent to get surface level info on any basketball topic", + "prompt": "You are a basketball info agent.", + } + ] + agent = create_deep_agent(tools=[sample_tool], subagents=subagents) + assert_all_deepagent_qualities(agent) + result = agent.invoke( + { + "messages": [ + { + "role": "user", + "content": "Use the basketball info subagent to call the sample tool", + } + ] + }, + config={"recursion_limit": 100}, + ) + agent_messages = [msg for msg in result.get("messages", []) if msg.type == "ai"] + tool_calls = [tool_call for msg in agent_messages for tool_call in msg.tool_calls] + assert any( + [ + tool_call["name"] == "task" + and tool_call["args"].get("subagent_type") == "basketball_info_agent" + for tool_call in tool_calls + ] + ) diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py index 4eaa055cbce5f..a7cf985c47009 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py @@ -5,8 +5,8 @@ AnthropicToolsState, StateClaudeMemoryMiddleware, StateClaudeTextEditorMiddleware, - _validate_path, ) +from langchain.agents.middleware.file_utils import validate_path from langchain_core.messages import ToolMessage from langgraph.types import Command @@ -16,46 +16,46 @@ class TestPathValidation: def test_basic_path_normalization(self) -> None: """Test basic path normalization.""" - assert _validate_path("/foo/bar") == "/foo/bar" - assert _validate_path("foo/bar") == "/foo/bar" - assert _validate_path("/foo//bar") == "/foo/bar" - assert _validate_path("/foo/./bar") == "/foo/bar" + assert validate_path("/foo/bar") == "/foo/bar" + assert validate_path("foo/bar") == "/foo/bar" + assert validate_path("/foo//bar") == "/foo/bar" + assert validate_path("/foo/./bar") == "/foo/bar" def test_path_traversal_blocked(self) -> None: """Test that path traversal attempts are blocked.""" with pytest.raises(ValueError, match="Path traversal not allowed"): - _validate_path("/foo/../etc/passwd") + validate_path("/foo/../etc/passwd") with pytest.raises(ValueError, match="Path traversal not allowed"): - _validate_path("../etc/passwd") + validate_path("../etc/passwd") with pytest.raises(ValueError, match="Path traversal not allowed"): - _validate_path("~/.ssh/id_rsa") + validate_path("~/.ssh/id_rsa") def test_allowed_prefixes(self) -> None: """Test path prefix validation.""" # Should pass assert ( - _validate_path("/workspace/file.txt", allowed_prefixes=["/workspace"]) + validate_path("/workspace/file.txt", allowed_prefixes=["/workspace"]) == "/workspace/file.txt" ) # Should fail with pytest.raises(ValueError, match="Path must start with"): - _validate_path("/etc/passwd", allowed_prefixes=["/workspace"]) + validate_path("/etc/passwd", allowed_prefixes=["/workspace"]) with pytest.raises(ValueError, match="Path must start with"): - _validate_path("/workspacemalicious/file.txt", allowed_prefixes=["/workspace/"]) + validate_path("/workspacemalicious/file.txt", allowed_prefixes=["/workspace/"]) def test_memories_prefix(self) -> None: """Test /memories prefix validation for memory tools.""" assert ( - _validate_path("/memories/notes.txt", allowed_prefixes=["/memories"]) + validate_path("/memories/notes.txt", allowed_prefixes=["/memories"]) == "/memories/notes.txt" ) with pytest.raises(ValueError, match="Path must start with"): - _validate_path("/other/notes.txt", allowed_prefixes=["/memories"]) + validate_path("/other/notes.txt", allowed_prefixes=["/memories"]) class TestTextEditorMiddleware: From 9cab0be04762be8e3e4d33396cea86d8146fe346 Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Thu, 9 Oct 2025 23:16:06 -0400 Subject: [PATCH 06/22] Update imports --- libs/langchain_v1/langchain/agents/deepagents.py | 2 +- .../langchain/agents/middleware/__init__.py | 4 ++++ .../langchain/agents/middleware/filesystem.py | 2 +- .../langchain/agents/middleware/subagents.py | 13 +++++++------ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/deepagents.py b/libs/langchain_v1/langchain/agents/deepagents.py index fb54f1e9eecb4..58dc3e6bd6ac5 100644 --- a/libs/langchain_v1/langchain/agents/deepagents.py +++ b/libs/langchain_v1/langchain/agents/deepagents.py @@ -10,7 +10,7 @@ from langgraph.store.base import BaseStore from langgraph.types import Checkpointer -from langchain.agents import create_agent +from langchain.agents.factory import create_agent from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware.filesystem import FilesystemMiddleware from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware, ToolConfig diff --git a/libs/langchain_v1/langchain/agents/middleware/__init__.py b/libs/langchain_v1/langchain/agents/middleware/__init__.py index 8ab86583890c6..e87d0f22edb16 100644 --- a/libs/langchain_v1/langchain/agents/middleware/__init__.py +++ b/libs/langchain_v1/langchain/agents/middleware/__init__.py @@ -13,12 +13,14 @@ ContextEditingMiddleware, ) from .file_search import FilesystemFileSearchMiddleware, StateFileSearchMiddleware +from .filesystem import FilesystemMiddleware from .human_in_the_loop import HumanInTheLoopMiddleware from .model_call_limit import ModelCallLimitMiddleware from .model_fallback import ModelFallbackMiddleware from .pii import PIIDetectionError, PIIMiddleware from .planning import PlanningMiddleware from .prompt_caching import AnthropicPromptCachingMiddleware +from .subagents import SubAgentMiddleware from .summarization import SummarizationMiddleware from .tool_call_limit import ToolCallLimitMiddleware from .tool_selection import LLMToolSelectorMiddleware @@ -47,6 +49,7 @@ "FilesystemClaudeMemoryMiddleware", "FilesystemClaudeTextEditorMiddleware", "FilesystemFileSearchMiddleware", + "FilesystemMiddleware", "HumanInTheLoopMiddleware", "LLMToolSelectorMiddleware", "ModelCallLimitMiddleware", @@ -58,6 +61,7 @@ "StateClaudeMemoryMiddleware", "StateClaudeTextEditorMiddleware", "StateFileSearchMiddleware", + "SubAgentMiddleware", "SummarizationMiddleware", "ToolCallLimitMiddleware", "after_agent", diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py index 85e530d06114c..6c917c00d218b 100644 --- a/libs/langchain_v1/langchain/agents/middleware/filesystem.py +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -13,7 +13,7 @@ from langgraph.runtime import Runtime, get_runtime from langgraph.types import Command -from langchain.agents.middleware import AgentMiddleware, AgentState, ModelRequest +from langchain.agents.middleware.types import AgentMiddleware, AgentState, ModelRequest from langchain.agents.middleware.file_utils import ( FileData, apply_string_replacement, diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index c5736f55e09f3..107b3bcb00a5f 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -10,12 +10,10 @@ from langchain_core.tools import BaseTool, tool from langgraph.types import Command -from langchain.agents import create_agent -from langchain.agents.middleware import ( - AnthropicPromptCachingMiddleware, - PlanningMiddleware, - SummarizationMiddleware, -) +from langchain.agents.middleware.filesystem import FilesystemMiddleware +from langchain.agents.middleware.planning import PlanningMiddleware +from langchain.agents.middleware.prompt_caching import AnthropicPromptCachingMiddleware +from langchain.agents.middleware.summarization import SummarizationMiddleware from langchain.agents.middleware.types import AgentMiddleware, ModelRequest from langchain.tools import InjectedState, InjectedToolCallId @@ -175,8 +173,11 @@ def _get_subagents( default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]], subagents: list[DefinedSubAgent | CustomSubAgent], ) -> tuple[dict[str, Any], list[str]]: + from langchain.agents.factory import create_agent + default_subagent_middleware = [ PlanningMiddleware(), + FilesystemMiddleware(), SummarizationMiddleware( model=default_subagent_model, max_tokens_before_summary=120000, From c7a52e8c789947489dc2c407329bcd8319289ecd Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Thu, 9 Oct 2025 23:31:54 -0400 Subject: [PATCH 07/22] Fix formatting --- libs/langchain_v1/langchain/agents/middleware/filesystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py index 6c917c00d218b..b7fba737b2a36 100644 --- a/libs/langchain_v1/langchain/agents/middleware/filesystem.py +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -13,7 +13,6 @@ from langgraph.runtime import Runtime, get_runtime from langgraph.types import Command -from langchain.agents.middleware.types import AgentMiddleware, AgentState, ModelRequest from langchain.agents.middleware.file_utils import ( FileData, apply_string_replacement, @@ -25,6 +24,7 @@ list_directory, update_file_data, ) +from langchain.agents.middleware.types import AgentMiddleware, AgentState, ModelRequest from langchain.tools.tool_node import InjectedState From f682a9e0a423f3741405255763fe66e63d7d85ad Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Fri, 10 Oct 2025 14:48:38 -0400 Subject: [PATCH 08/22] Fix and simplify filesystem substantially - add integration tests for filesystem --- .../langchain/agents/middleware/file_utils.py | 36 ++ .../langchain/agents/middleware/filesystem.py | 403 +++++++------- .../langchain/agents/middleware/subagents.py | 17 +- .../middleware/test_filesystem_middleware.py | 493 +++++++++++++++++- .../middleware/test_subagent_middleware.py | 12 +- .../middleware/test_filesystem_middleware.py | 114 ++++ 6 files changed, 827 insertions(+), 248 deletions(-) create mode 100644 libs/langchain_v1/tests/unit_tests/agents/middleware/test_filesystem_middleware.py diff --git a/libs/langchain_v1/langchain/agents/middleware/file_utils.py b/libs/langchain_v1/langchain/agents/middleware/file_utils.py index 42112eb15b206..9449a803a24e7 100644 --- a/libs/langchain_v1/langchain/agents/middleware/file_utils.py +++ b/libs/langchain_v1/langchain/agents/middleware/file_utils.py @@ -245,3 +245,39 @@ def check_empty_content(content: str) -> str | None: if not content or content.strip() == "": return "System reminder: File exists but has empty contents" return None + + +def has_memories_prefix(file_path: str) -> bool: + """Check if file path has the memories prefix. + + Args: + file_path: File path. + + Returns: + True if file path has the memories prefix, False otherwise. + """ + return file_path.startswith("/memories/") + + +def append_memories_prefix(file_path: str) -> str: + """Append the memories prefix to a file path. + + Args: + file_path: File path. + + Returns: + File path with the memories prefix. + """ + return f"/memories{file_path}" + + +def strip_memories_prefix(file_path: str) -> str: + """Strip the memories prefix from a file path. + + Args: + file_path: File path. + + Returns: + File path without the memories prefix. + """ + return file_path.replace("/memories", "") diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py index b7fba737b2a36..f0f326c15da44 100644 --- a/libs/langchain_v1/langchain/agents/middleware/filesystem.py +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -6,23 +6,25 @@ if TYPE_CHECKING: from langgraph.runtime import Runtime - from langgraph.store.base import Item from langchain_core.messages import AIMessage, ToolMessage from langchain_core.tools import BaseTool, InjectedToolCallId, tool from langgraph.runtime import Runtime, get_runtime +from langgraph.store.base import BaseStore, Item from langgraph.types import Command from langchain.agents.middleware.file_utils import ( FileData, - apply_string_replacement, + append_memories_prefix, check_empty_content, create_file_data, file_data_reducer, file_data_to_string, format_content_with_line_numbers, - list_directory, + has_memories_prefix, + strip_memories_prefix, update_file_data, + validate_path, ) from langchain.agents.middleware.types import AgentMiddleware, AgentState, ModelRequest from langchain.tools.tool_node import InjectedState @@ -43,7 +45,7 @@ class FilesystemState(AgentState): - This is very useful for exploring the file system and finding the right file to read or edit. - You should almost ALWAYS use this tool before using the Read or Edit tools.""" LIST_FILES_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( - "\n- Files from the longterm filesystem will be prefixed with the memories/ path." + "\n- Files from the longterm filesystem will be prefixed with the /memories/ path." ) READ_FILE_TOOL_DESCRIPTION = """Reads a file from the filesystem. You can access any file directly by using this tool. @@ -59,7 +61,7 @@ class FilesystemState(AgentState): - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. - You should ALWAYS make sure a file has been read before editing it.""" READ_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( - "\n- file_paths prefixed with the memories/ path will be read from the longterm filesystem." + "\n- file_paths prefixed with the /memories/ path will be read from the longterm filesystem." ) EDIT_FILE_TOOL_DESCRIPTION = """Performs exact string replacements in files. @@ -71,7 +73,7 @@ class FilesystemState(AgentState): - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. - The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. - Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.""" -EDIT_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = "\n- You can edit files in the longterm filesystem by prefixing the filename with the memories/ path." +EDIT_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = "\n- You can edit files in the longterm filesystem by prefixing the filename with the /memories/ path." WRITE_FILE_TOOL_DESCRIPTION = """Writes to a new file in the filesystem. @@ -80,15 +82,15 @@ class FilesystemState(AgentState): - The content parameter must be a string - The write_file tool will create the a new file. - Prefer to edit existing files over creating new ones when possible. -- file_paths prefixed with the memories/ path will be written to the longterm filesystem.""" +- file_paths prefixed with the /memories/ path will be written to the longterm filesystem.""" WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( - "\n- file_paths prefixed with the memories/ path will be written to the longterm filesystem." + "\n- file_paths prefixed with the /memories/ path will be written to the longterm filesystem." ) FILESYSTEM_SYSTEM_PROMPT = """## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file` You have access to a filesystem which you can interact with using these tools. -Do not prepend a / to file_paths. +All file paths must start with a /. - ls: list all files in the filesystem - read_file: read a file from the filesystem @@ -97,20 +99,8 @@ class FilesystemState(AgentState): FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT = """ You also have access to a longterm filesystem in which you can store files that you want to keep around for longer than the current conversation. -In order to interact with the longterm filesystem, you can use those same tools, but filenames must be prefixed with the memories/ path. -Remember, to interact with the longterm filesystem, you must prefix the filename with the memories/ path.""" - - -def _has_memories_prefix(file_path: str) -> bool: - return file_path.startswith("memories/") - - -def _append_memories_prefix(file_path: str) -> str: - return f"memories/{file_path}" - - -def _strip_memories_prefix(file_path: str) -> str: - return file_path.replace("memories/", "") +In order to interact with the longterm filesystem, you can use those same tools, but filenames must be prefixed with the /memories/ path. +Remember, to interact with the longterm filesystem, you must prefix the filename with the /memories/ path.""" def _get_namespace(runtime: Runtime[Any]) -> tuple[str] | tuple[str, str]: @@ -123,6 +113,48 @@ def _get_namespace(runtime: Runtime[Any]) -> tuple[str] | tuple[str, str]: return (assistant_id, "filesystem") +def _get_store(runtime: Runtime[Any]) -> BaseStore: + if runtime.store is None: + msg = "Longterm memory is enabled, but no store is available" + raise ValueError(msg) + return runtime.store + + +def _convert_store_item_to_file_data(store_item: Item) -> FileData: + if "content" not in store_item.value or not isinstance(store_item.value["content"], list): + msg = "Store item does not contain content" + raise ValueError(msg) + if "created_at" not in store_item.value or not isinstance(store_item.value["created_at"], str): + msg = "Store item does not contain created_at" + raise ValueError(msg) + if "modified_at" not in store_item.value or not isinstance( + store_item.value["modified_at"], str + ): + msg = "Store item does not contain modified_at" + raise ValueError(msg) + return FileData( + content=store_item.value["content"], + created_at=store_item.value["created_at"], + modified_at=store_item.value["modified_at"], + ) + + +def _convert_file_data_to_store_item(file_data: FileData) -> dict[str, Any]: + return { + "content": file_data["content"], + "created_at": file_data["created_at"], + "modified_at": file_data["modified_at"], + } + + +def _get_file_data_from_state(state: FilesystemState, file_path: str) -> FileData: + mock_filesystem = state.get("files", {}) + if file_path not in mock_filesystem: + msg = f"File '{file_path}' not found" + raise ValueError(msg) + return mock_filesystem[file_path] + + def _ls_tool_generator( custom_description: str | None = None, *, has_longterm_memory: bool ) -> BaseTool: @@ -132,49 +164,39 @@ def _ls_tool_generator( elif has_longterm_memory: tool_description += LIST_FILES_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + def _get_filenames_from_state(state: FilesystemState) -> list[str]: + files_dict = state.get("files", {}) + return list(files_dict.keys()) + + def _filter_files_by_path(filenames: list[str], path: str | None) -> list[str]: + if path is None: + return filenames + normalized_path = validate_path(path) + return [f for f in filenames if f.startswith(normalized_path)] + if has_longterm_memory: - # Tool with Long-term memory + @tool(description=tool_description) def ls( state: Annotated[FilesystemState, InjectedState], path: str | None = None ) -> list[str]: - files_dict = state.get("files", {}) - - # If path is provided, filter by directory - if path is not None: - from langchain.agents.middleware.file_utils import validate_path - - normalized_path = validate_path(path) - files = list_directory(files_dict, normalized_path) - else: - files = list(files_dict.keys()) - + files = _get_filenames_from_state(state) + # Add filenames from longterm memory runtime = get_runtime() - store = runtime.store - if store is None: - msg = "Longterm memory is enabled, but no store is available" - raise ValueError(msg) + store = _get_store(runtime) namespace = _get_namespace(runtime) - file_data_list = store.search(namespace) - memories_files = [_append_memories_prefix(f.key) for f in file_data_list] - files.extend(memories_files) - return files + longterm_files = store.search(namespace) + longterm_files_prefixed = [append_memories_prefix(f.key) for f in longterm_files] + files.extend(longterm_files_prefixed) + return _filter_files_by_path(files, path) else: - # Tool without long-term memory + @tool(description=tool_description) def ls( state: Annotated[FilesystemState, InjectedState], path: str | None = None ) -> list[str]: - files_dict = state.get("files", {}) - - # If path is provided, filter by directory - if path is not None: - from langchain.agents.middleware.file_utils import validate_path - - normalized_path = validate_path(path) - return list_directory(files_dict, normalized_path) - - return list(files_dict.keys()) + files = _get_filenames_from_state(state) + return _filter_files_by_path(files, path) return ls @@ -188,8 +210,23 @@ def _read_file_tool_generator( elif has_longterm_memory: tool_description += READ_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + def _read_file_data_content(file_data: FileData, offset: int, limit: int) -> str: + content = file_data_to_string(file_data) + empty_msg = check_empty_content(content) + if empty_msg: + return empty_msg + lines = content.splitlines() + start_idx = offset + end_idx = min(start_idx + limit, len(lines)) + if start_idx >= len(lines): + return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)" + selected_lines = lines[start_idx:end_idx] + return format_content_with_line_numbers( + selected_lines, format_style="tab", start_line=start_idx + 1 + ) + if has_longterm_memory: - # Tool with Long-term memory + @tool(description=tool_description) def read_file( file_path: str, @@ -197,43 +234,25 @@ def read_file( offset: int = 0, limit: int = 2000, ) -> str: - if _has_memories_prefix(file_path): - stripped_file_path = _strip_memories_prefix(file_path) + file_path = validate_path(file_path) + if has_memories_prefix(file_path): + stripped_file_path = strip_memories_prefix(file_path) runtime = get_runtime() - store = runtime.store - if store is None: - msg = "Longterm memory is enabled, but no store is available" - raise ValueError(msg) + store = _get_store(runtime) namespace = _get_namespace(runtime) item: Item | None = store.get(namespace, stripped_file_path) if item is None: return f"Error: File '{file_path}' not found" - content: str = str(item.value["content"]) + file_data = _convert_store_item_to_file_data(item) else: - mock_filesystem = state.get("files", {}) - if file_path not in mock_filesystem: - return f"Error: File '{file_path}' not found" - file_data = mock_filesystem[file_path] - content = file_data_to_string(file_data) - - # Check for empty content - empty_msg = check_empty_content(content) - if empty_msg: - return empty_msg - - lines = content.splitlines() - start_idx = offset - end_idx = min(start_idx + limit, len(lines)) - if start_idx >= len(lines): - return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)" - - # Use shared formatting for the selected range - selected_lines = lines[start_idx:end_idx] - return format_content_with_line_numbers( - selected_lines, format_style="tab", start_line=start_idx + 1 - ) + try: + file_data = _get_file_data_from_state(state, file_path) + except ValueError as e: + return str(e) + return _read_file_data_content(file_data, offset, limit) + else: - # Tool without long-term memory + @tool(description=tool_description) def read_file( file_path: str, @@ -241,28 +260,12 @@ def read_file( offset: int = 0, limit: int = 2000, ) -> str: - mock_filesystem = state.get("files", {}) - if file_path not in mock_filesystem: - return f"Error: File '{file_path}' not found" - file_data = mock_filesystem[file_path] - content = file_data_to_string(file_data) - - # Check for empty content - empty_msg = check_empty_content(content) - if empty_msg: - return empty_msg - - lines = content.splitlines() - start_idx = offset - end_idx = min(start_idx + limit, len(lines)) - if start_idx >= len(lines): - return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)" - - # Use shared formatting for the selected range - selected_lines = lines[start_idx:end_idx] - return format_content_with_line_numbers( - selected_lines, format_style="tab", start_line=start_idx + 1 - ) + file_path = validate_path(file_path) + try: + file_data = _get_file_data_from_state(state, file_path) + except ValueError as e: + return str(e) + return _read_file_data_content(file_data, offset, limit) return read_file @@ -276,77 +279,56 @@ def _write_file_tool_generator( elif has_longterm_memory: tool_description += WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT + def _write_file_to_state( + state: FilesystemState, tool_call_id: str, file_path: str, content: str + ) -> Command | str: + mock_filesystem = state.get("files", {}) + existing = mock_filesystem.get(file_path) + if existing: + return f"Cannot write to {file_path} because it already exists. Read and then make an edit, or write to a new path." + new_file_data = create_file_data(content) + return Command( + update={ + "files": {file_path: new_file_data}, + "messages": [ToolMessage(f"Updated file {file_path}", tool_call_id=tool_call_id)], + } + ) + if has_longterm_memory: - # Tool with Long-term memory + @tool(description=tool_description) def write_file( file_path: str, content: str, state: Annotated[FilesystemState, InjectedState], tool_call_id: Annotated[str, InjectedToolCallId], - ) -> Command: - if _has_memories_prefix(file_path): - stripped_file_path = _strip_memories_prefix(file_path) + ) -> Command | str: + file_path = validate_path(file_path) + if has_memories_prefix(file_path): + stripped_file_path = strip_memories_prefix(file_path) runtime = get_runtime() - store = runtime.store - if store is None: - msg = "Longterm memory is enabled, but no store is available" - raise ValueError(msg) + store = _get_store(runtime) namespace = _get_namespace(runtime) - store.put(namespace, stripped_file_path, {"content": content}) - return Command( - update={ - "messages": [ - ToolMessage( - f"Updated longterm memories file {file_path}", - tool_call_id=tool_call_id, - ) - ] - } - ) - mock_filesystem = state.get("files", {}) - existing = mock_filesystem.get(file_path) - - # Create or update FileData - if existing: - new_file_data = update_file_data(existing, content) - else: + if store.get(namespace, stripped_file_path) is not None: + return f"Cannot write to {file_path} because it already exists. Read and then make an edit, or write to a new path." new_file_data = create_file_data(content) + store.put( + namespace, stripped_file_path, _convert_file_data_to_store_item(new_file_data) + ) + return f"Updated longterm memories file {file_path}" + return _write_file_to_state(state, tool_call_id, file_path, content) - return Command( - update={ - "files": {file_path: new_file_data}, - "messages": [ - ToolMessage(f"Updated file {file_path}", tool_call_id=tool_call_id) - ], - } - ) else: - # Tool without long-term memory + @tool(description=tool_description) def write_file( file_path: str, content: str, state: Annotated[FilesystemState, InjectedState], tool_call_id: Annotated[str, InjectedToolCallId], - ) -> Command: - mock_filesystem = state.get("files", {}) - existing = mock_filesystem.get(file_path) - - # Create or update FileData - if existing: - new_file_data = update_file_data(existing, content) - else: - new_file_data = create_file_data(content) - - return Command( - update={ - "files": {file_path: new_file_data}, - "messages": [ - ToolMessage(f"Updated file {file_path}", tool_call_id=tool_call_id) - ], - } - ) + ) -> Command | str: + file_path = validate_path(file_path) + return _write_file_to_state(state, tool_call_id, file_path, content) return write_file @@ -361,7 +343,7 @@ def _edit_file_tool_generator( tool_description += EDIT_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT if has_longterm_memory: - # Tool with Long-term memory + @tool(description=tool_description) def edit_file( file_path: str, @@ -372,49 +354,39 @@ def edit_file( *, replace_all: bool = False, ) -> Command | str: - if _has_memories_prefix(file_path): - stripped_file_path = _strip_memories_prefix(file_path) + file_path = validate_path(file_path) + is_longterm_memory = has_memories_prefix(file_path) + if is_longterm_memory: + stripped_file_path = strip_memories_prefix(file_path) runtime = get_runtime() - store = runtime.store - if store is None: - msg = "Longterm memory is enabled, but no store is available" - raise ValueError(msg) + store = _get_store(runtime) namespace = _get_namespace(runtime) item: Item | None = store.get(namespace, stripped_file_path) if item is None: return f"Error: File '{file_path}' not found" - content: str = str(item.value["content"]) - if old_string not in content: - return f"Error: String not found in file: '{old_string}'" - if not replace_all: - occurrences = content.count(old_string) - if occurrences > 1: - return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context." - if occurrences == 0: - return f"Error: String not found in file: '{old_string}'" - new_content = content.replace(old_string, new_string, 1) - replacement_count = 1 - store.put(namespace, stripped_file_path, {"content": new_content}) - return f"Successfully replaced {replacement_count} instance(s) of the string in '{file_path}'" - mock_filesystem = state.get("files", {}) - if file_path not in mock_filesystem: - return f"Error: File '{file_path}' not found" - file_data = mock_filesystem[file_path] - content = file_data_to_string(file_data) + file_data = _convert_store_item_to_file_data(item) + else: + try: + file_data = _get_file_data_from_state(state, file_path) + except ValueError as e: + return str(e) - # Check if string exists - if old_string not in content: + content = file_data_to_string(file_data) + occurrences = content.count(old_string) + if occurrences == 0: return f"Error: String not found in file: '{old_string}'" - - # Apply replacement - new_content, replacement_count = apply_string_replacement( - content, old_string, new_string, replace_all=replace_all - ) - - # Update file data + if occurrences > 1 and not replace_all: + return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context." + new_content = content.replace(old_string, new_string) new_file_data = update_file_data(file_data, new_content) - - result_msg = f"Successfully replaced {replacement_count} instance(s) of the string in '{file_path}'" + result_msg = ( + f"Successfully replaced {occurrences} instance(s) of the string in '{file_path}'" + ) + if is_longterm_memory: + store.put( + namespace, stripped_file_path, _convert_file_data_to_store_item(new_file_data) + ) + return result_msg return Command( update={ "files": {file_path: new_file_data}, @@ -422,7 +394,7 @@ def edit_file( } ) else: - # Tool without long-term memory + @tool(description=tool_description) def edit_file( file_path: str, @@ -433,25 +405,22 @@ def edit_file( *, replace_all: bool = False, ) -> Command | str: - mock_filesystem = state.get("files", {}) - if file_path not in mock_filesystem: - return f"Error: File '{file_path}' not found" - file_data = mock_filesystem[file_path] + file_path = validate_path(file_path) + try: + file_data = _get_file_data_from_state(state, file_path) + except ValueError as e: + return str(e) content = file_data_to_string(file_data) - - # Check if string exists - if old_string not in content: + occurrences = content.count(old_string) + if occurrences == 0: return f"Error: String not found in file: '{old_string}'" - - # Apply replacement - new_content, replacement_count = apply_string_replacement( - content, old_string, new_string, replace_all=replace_all - ) - - # Update file data + if occurrences > 1 and not replace_all: + return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context." + new_content = content.replace(old_string, new_string) new_file_data = update_file_data(file_data, new_content) - - result_msg = f"Successfully replaced {replacement_count} instance(s) of the string in '{file_path}'" + result_msg = ( + f"Successfully replaced {occurrences} instance(s) of the string in '{file_path}'" + ) return Command( update={ "files": {file_path: new_file_data}, @@ -532,6 +501,7 @@ def __init__( system_prompt_extension: Optional custom system prompt. custom_tool_descriptions: Optional custom tool descriptions. """ + self.use_longterm_memory = use_longterm_memory self.system_prompt_extension = FILESYSTEM_SYSTEM_PROMPT if system_prompt_extension is not None: self.system_prompt_extension = system_prompt_extension @@ -542,6 +512,13 @@ def __init__( custom_tool_descriptions, has_longterm_memory=use_longterm_memory ) + def before_model_call(self, request: ModelRequest, runtime: Runtime[Any]) -> ModelRequest: + """If use_longterm_memory is True, we must have a store available.""" + if self.use_longterm_memory and runtime.store is None: + msg = "Longterm memory is enabled, but no store is available" + raise ValueError(msg) + return request + def wrap_model_call( self, request: ModelRequest, diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index 107b3bcb00a5f..a5146ffe9a193 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -1,5 +1,4 @@ """Middleware for providing subagents to an agent via a `task` tool.""" -# ruff: noqa: E501 from collections.abc import Callable, Sequence from typing import Annotated, Any, NotRequired, TypedDict, cast @@ -54,7 +53,7 @@ class CustomSubAgent(TypedDict): DEFAULT_SUBAGENT_PROMPT = """In order to complete the objective that the user asks of you, you have access to a number of standard tools. -""" +""" # noqa: E501 TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. @@ -165,7 +164,7 @@ class CustomSubAgent(TypedDict): Since the user is greeting, use the greeting-responder agent to respond with a friendly joke assistant: "I'm going to use the Task tool to launch with the greeting-responder agent" -""" +""" # noqa: E501 def _get_subagents( @@ -255,7 +254,11 @@ async def task( tool_call_id: Annotated[str, InjectedToolCallId], ) -> str | Command: if subagent_type not in subagent_graphs: - return f"Error: invoked agent of type {subagent_type}, the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" + msg = ( + f"Error: invoked agent of type {subagent_type}, " + f"the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" + ) + raise ValueError(msg) subagent = subagent_graphs[subagent_type] state["messages"] = [HumanMessage(content=description)] if "todos" in state: @@ -272,7 +275,11 @@ def task( tool_call_id: Annotated[str, InjectedToolCallId], ) -> str | Command: if subagent_type not in subagent_graphs: - return f"Error: invoked agent of type {subagent_type}, the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" + msg = ( + f"Error: invoked agent of type {subagent_type}, " + f"the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" + ) + raise ValueError(msg) subagent = subagent_graphs[subagent_type] state["messages"] = [HumanMessage(content=description)] if "todos" in state: diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py index 92db21c761b53..f05eaa33537b1 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py @@ -9,6 +9,7 @@ from langchain_anthropic import ChatAnthropic from langgraph.store.memory import InMemoryStore from langgraph.checkpoint.memory import MemorySaver +from langchain.agents.middleware.file_utils import FileData import pytest import uuid @@ -100,7 +101,437 @@ def test_filesystem_tool_prompt_override_with_longterm_memory(self): assert "edit_file" in tools assert tools["edit_file"].description == "Squirtle" - def test_longterm_memory_tools(self): + def test_ls_longterm_without_path(self): + checkpointer = MemorySaver() + store = InMemoryStore() + store.put( + ("filesystem",), + "/test.txt", + { + "content": ["Hello world"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + store.put( + ("filesystem",), + "/pokemon/charmander.txt", + { + "content": ["Ember"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [HumanMessage(content="List all of your files")], + "files": { + "/pizza.txt": FileData( + content=["Hello world"], + created_at="2021-01-01", + modified_at="2021-01-01", + ), + "/pokemon/squirtle.txt": FileData( + content=["Splash"], + created_at="2021-01-01", + modified_at="2021-01-01", + ), + }, + }, + config=config, + ) + messages = response["messages"] + ls_message = next( + message for message in messages if message.type == "tool" and message.name == "ls" + ) + assert "/pizza.txt" in ls_message.text + assert "/pokemon/squirtle.txt" in ls_message.text + assert "/memories/test.txt" in ls_message.text + assert "/memories/pokemon/charmander.txt" in ls_message.text + + def test_ls_longterm_with_path(self): + checkpointer = MemorySaver() + store = InMemoryStore() + store.put( + ("filesystem",), + "/test.txt", + { + "content": ["Hello world"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + store.put( + ("filesystem",), + "/pokemon/charmander.txt", + { + "content": ["Ember"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage(content="List all of your files in the /pokemon directory") + ], + "files": { + "/pizza.txt": FileData( + content=["Hello world"], + created_at="2021-01-01", + modified_at="2021-01-01", + ), + "/pokemon/squirtle.txt": FileData( + content=["Splash"], + created_at="2021-01-01", + modified_at="2021-01-01", + ), + }, + }, + config=config, + ) + messages = response["messages"] + ls_message = next( + message for message in messages if message.type == "tool" and message.name == "ls" + ) + assert "/pokemon/squirtle.txt" in ls_message.text + assert "/memories/pokemon/charmander.txt" not in ls_message.text + + def test_read_file_longterm_local_file(self): + checkpointer = MemorySaver() + store = InMemoryStore() + store.put( + ("filesystem",), + "/test.txt", + { + "content": ["Hello world"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [HumanMessage(content="Read test.txt from local memory")], + "files": { + "/test.txt": FileData( + content=["Goodbye world"], + created_at="2021-01-01", + modified_at="2021-01-01", + ) + }, + }, + config=config, + ) + messages = response["messages"] + read_file_message = next( + message + for message in messages + if message.type == "tool" and message.name == "read_file" + ) + assert read_file_message is not None + assert "Goodbye world" in read_file_message.content + + def test_read_file_longterm_store_file(self): + checkpointer = MemorySaver() + store = InMemoryStore() + store.put( + ("filesystem",), + "/test.txt", + { + "content": ["Hello world"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [HumanMessage(content="Read test.txt from longterm memory")], + "files": { + "/test.txt": FileData( + content=["Goodbye world"], + created_at="2021-01-01", + modified_at="2021-01-01", + ) + }, + }, + config=config, + ) + messages = response["messages"] + read_file_message = next( + message + for message in messages + if message.type == "tool" and message.name == "read_file" + ) + assert read_file_message is not None + assert "Hello world" in read_file_message.content + + def test_read_file_longterm(self): + checkpointer = MemorySaver() + store = InMemoryStore() + store.put( + ("filesystem",), + "/test.txt", + { + "content": ["Hello world"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + store.put( + ("filesystem",), + "/pokemon/charmander.txt", + { + "content": ["Ember"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Read the contents of the file about charmander from longterm memory." + ) + ], + "files": {}, + }, + config=config, + ) + messages = response["messages"] + ai_msg_w_toolcall = next( + message + for message in messages + if message.type == "ai" + and any( + tc["name"] == "read_file" + and tc["args"]["file_path"] == "/memories/pokemon/charmander.txt" + for tc in message.tool_calls + ) + ) + assert ai_msg_w_toolcall is not None + + def test_write_file_longterm(self): + checkpointer = MemorySaver() + store = InMemoryStore() + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Write a haiku about Charmander to longterm memory in /charmander.txt, use the word 'fiery'" + ) + ], + "files": {}, + }, + config=config, + ) + messages = response["messages"] + write_file_message = next( + message + for message in messages + if message.type == "tool" and message.name == "write_file" + ) + assert write_file_message is not None + file_item = store.get(("filesystem",), "/charmander.txt") + assert file_item is not None + assert any("fiery" in c for c in file_item.value["content"]) or any( + "Fiery" in c for c in file_item.value["content"] + ) + + def test_write_file_fail_already_exists_in_store(self): + checkpointer = MemorySaver() + store = InMemoryStore() + store.put( + ("filesystem",), + "/charmander.txt", + { + "content": ["Hello world"], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Write a haiku about Charmander to longterm memory in /charmander.txt, use the word 'fiery'" + ) + ], + "files": {}, + }, + config=config, + ) + messages = response["messages"] + write_file_message = next( + message + for message in messages + if message.type == "tool" and message.name == "write_file" + ) + assert write_file_message is not None + assert "Cannot write" in write_file_message.content + + def test_write_file_fail_already_exists_in_local(self): + checkpointer = MemorySaver() + store = InMemoryStore() + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Write a haiku about Charmander to /charmander.txt, use the word 'fiery'" + ) + ], + "files": { + "/charmander.txt": FileData( + content=["Hello world"], + created_at="2021-01-01", + modified_at="2021-01-01", + ) + }, + }, + config=config, + ) + messages = response["messages"] + write_file_message = next( + message + for message in messages + if message.type == "tool" and message.name == "write_file" + ) + assert write_file_message is not None + assert "Cannot write" in write_file_message.content + + def test_edit_file_longterm(self): + checkpointer = MemorySaver() + store = InMemoryStore() + store.put( + ("filesystem",), + "/charmander.txt", + { + "content": ["The fire burns brightly. The fire burns hot."], + "created_at": "2021-01-01", + "modified_at": "2021-01-01", + }, + ) + agent = create_agent( + model=ChatAnthropic(model="claude-3-5-sonnet-20240620"), + middleware=[ + FilesystemMiddleware( + use_longterm_memory=True, + ) + ], + checkpointer=checkpointer, + store=store, + ) + config = {"configurable": {"thread_id": uuid.uuid4()}} + response = agent.invoke( + { + "messages": [ + HumanMessage( + content="Edit the longterm memory file about charmander, to replace all instances of the word 'fire' with 'embers'" + ) + ], + "files": {}, + }, + config=config, + ) + messages = response["messages"] + edit_file_message = next( + message + for message in messages + if message.type == "tool" and message.name == "edit_file" + ) + assert edit_file_message is not None + assert store.get(("filesystem",), "/charmander.txt").value["content"] == [ + "The embers burns brightly. The embers burns hot." + ] + + def test_longterm_memory_multiple_tools(self): checkpointer = MemorySaver() store = InMemoryStore() agent = create_agent( @@ -115,57 +546,59 @@ def test_longterm_memory_tools(self): ) assert_longterm_mem_tools(agent, store) - def test_longterm_memory_tools_deepagent(self): + def test_longterm_memory_multiple_tools_deepagent(self): checkpointer = MemorySaver() store = InMemoryStore() agent = create_deep_agent(use_longterm_memory=True, checkpointer=checkpointer, store=store) assert_longterm_mem_tools(agent, store) - def test_shortterm_memory_tools_deepagent(self): + def test_shortterm_memory_multiple_tools_deepagent(self): checkpointer = MemorySaver() store = InMemoryStore() agent = create_deep_agent(use_longterm_memory=False, checkpointer=checkpointer, store=store) assert_shortterm_mem_tools(agent) +# Take actions on multiple threads to test longterm memory def assert_longterm_mem_tools(agent, store): + # Write a longterm memory file config = {"configurable": {"thread_id": uuid.uuid4()}} agent.invoke( { "messages": [ HumanMessage( - content="Write a haiku about Charmander to longterm memory in charmander.txt, use the word 'fiery'" + content="Write a haiku about Charmander to longterm memory in /charmander.txt, use the word 'fiery'" ) ] }, config=config, ) - namespaces = store.list_namespaces() assert len(namespaces) == 1 assert namespaces[0] == ("filesystem",) - file_item = store.get(("filesystem",), "charmander.txt") + file_item = store.get(("filesystem",), "/charmander.txt") assert file_item is not None - assert file_item.key == "charmander.txt" + assert file_item.key == "/charmander.txt" + # Read the longterm memory file config2 = {"configurable": {"thread_id": uuid.uuid4()}} response = agent.invoke( { "messages": [ HumanMessage( - content="Read the haiku about Charmander from longterm memory at charmander.txt" + content="Read the haiku about Charmander from longterm memory at /charmander.txt" ) ] }, config=config2, ) - messages = response["messages"] read_file_message = next( message for message in messages if message.type == "tool" and message.name == "read_file" ) assert "fiery" in read_file_message.content or "Fiery" in read_file_message.content + # List all of the files in longterm memory config3 = {"configurable": {"thread_id": uuid.uuid4()}} response = agent.invoke( {"messages": [HumanMessage(content="List all of the files in longterm memory")]}, @@ -175,8 +608,9 @@ def assert_longterm_mem_tools(agent, store): ls_message = next( message for message in messages if message.type == "tool" and message.name == "ls" ) - assert "memories/charmander.txt" in ls_message.content + assert "/memories/charmander.txt" in ls_message.content + # Edit the longterm memory file config4 = {"configurable": {"thread_id": uuid.uuid4()}} response = agent.invoke( { @@ -188,17 +622,20 @@ def assert_longterm_mem_tools(agent, store): }, config=config4, ) - file_item = store.get(("filesystem",), "charmander.txt") + file_item = store.get(("filesystem",), "/charmander.txt") assert file_item is not None - assert file_item.key == "charmander.txt" - assert "ember" in file_item.value["content"] or "Ember" in file_item.value["content"] + assert file_item.key == "/charmander.txt" + assert any("ember" in c for c in file_item.value["content"]) or any( + "Ember" in c for c in file_item.value["content"] + ) + # Read the longterm memory file config5 = {"configurable": {"thread_id": uuid.uuid4()}} response = agent.invoke( { "messages": [ HumanMessage( - content="Read the haiku about Charmander from longterm memory at charmander.txt" + content="Read the haiku about Charmander from longterm memory at /charmander.txt" ) ] }, @@ -212,22 +649,28 @@ def assert_longterm_mem_tools(agent, store): def assert_shortterm_mem_tools(agent): + # Write a shortterm memory file config = {"configurable": {"thread_id": uuid.uuid4()}} response = agent.invoke( { "messages": [ HumanMessage( - content="Write a haiku about Charmander to charmander.txt, use the word 'fiery'" + content="Write a haiku about Charmander to /charmander.txt, use the word 'fiery'" ) ] }, config=config, ) files = response["files"] - assert "charmander.txt" in files + assert "/charmander.txt" in files + # Read the shortterm memory file response = agent.invoke( - {"messages": [HumanMessage(content="Read the haiku about Charmander from charmander.txt")]}, + { + "messages": [ + HumanMessage(content="Read the haiku about Charmander from /charmander.txt") + ] + }, config=config, ) messages = response["messages"] @@ -238,15 +681,18 @@ def assert_shortterm_mem_tools(agent): ) assert "fiery" in read_file_message.content or "Fiery" in read_file_message.content + # List all of the files in shortterm memory response = agent.invoke( - {"messages": [HumanMessage(content="List all of the files in memory")]}, config=config + {"messages": [HumanMessage(content="List all of the files in your filesystem")]}, + config=config, ) messages = response["messages"] ls_message = next( message for message in messages if message.type == "tool" and message.name == "ls" ) - assert "charmander.txt" in ls_message.content + assert "/charmander.txt" in ls_message.content + # Edit the shortterm memory file response = agent.invoke( { "messages": [ @@ -256,13 +702,14 @@ def assert_shortterm_mem_tools(agent): config=config, ) files = response["files"] - assert "charmander.txt" in files - assert "ember" in "\n".join(files["charmander.txt"]["content"]) or "Ember" in "\n".join( - files["charmander.txt"]["content"] + assert "/charmander.txt" in files + assert any("ember" in c for c in files["/charmander.txt"]["content"]) or any( + "Ember" in c for c in files["/charmander.txt"]["content"] ) + # Read the shortterm memory file response = agent.invoke( - {"messages": [HumanMessage(content="Read the haiku about Charmander at charmander.txt")]}, + {"messages": [HumanMessage(content="Read the haiku about Charmander at /charmander.txt")]}, config=config, ) messages = response["messages"] diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py index cbf79e6d42607..8389ca3fb8b48 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py @@ -15,13 +15,6 @@ class WeatherMiddleware(AgentMiddleware): tools = [get_weather] -custom_subagent = create_agent( - model="gpt-4.1-2025-04-14", - system_prompt="Use the get_weather tool to get the weather in a city.", - tools=[get_weather], -) - - def assert_expected_subgraph_actions(expected_tool_calls, agent, inputs): current_idx = 0 for update in agent.stream( @@ -194,6 +187,11 @@ def test_defined_subagent_custom_middleware(self): ) def test_defined_subagent_custom_runnable(self): + custom_subagent = create_agent( + model="gpt-4.1-2025-04-14", + system_prompt="Use the get_weather tool to get the weather in a city.", + tools=[get_weather], + ) agent = create_agent( model="claude-sonnet-4-20250514", system_prompt="Use the task tool to call a subagent.", diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_filesystem_middleware.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_filesystem_middleware.py new file mode 100644 index 0000000000000..b958c39928cd3 --- /dev/null +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_filesystem_middleware.py @@ -0,0 +1,114 @@ +from langchain.agents.middleware.filesystem import ( + FileData, + FilesystemState, + FilesystemMiddleware, + FILESYSTEM_SYSTEM_PROMPT, + FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT, +) +from langgraph.store.memory import InMemoryStore +from langgraph.runtime import Runtime + + +class TestFilesystem: + def test_init_local(self): + middleware = FilesystemMiddleware(use_longterm_memory=False) + assert middleware.use_longterm_memory is False + assert middleware.system_prompt_extension == FILESYSTEM_SYSTEM_PROMPT + assert len(middleware.tools) == 4 + + def test_init_longterm(self): + middleware = FilesystemMiddleware(use_longterm_memory=True) + assert middleware.use_longterm_memory is True + assert middleware.system_prompt_extension == ( + FILESYSTEM_SYSTEM_PROMPT + FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT + ) + assert len(middleware.tools) == 4 + + def test_init_custom_system_prompt_shortterm(self): + middleware = FilesystemMiddleware( + use_longterm_memory=False, system_prompt_extension="Custom system prompt" + ) + assert middleware.use_longterm_memory is False + assert middleware.system_prompt_extension == "Custom system prompt" + assert len(middleware.tools) == 4 + + def test_init_custom_system_prompt_longterm(self): + middleware = FilesystemMiddleware( + use_longterm_memory=True, system_prompt_extension="Custom system prompt" + ) + assert middleware.use_longterm_memory is True + assert middleware.system_prompt_extension == "Custom system prompt" + assert len(middleware.tools) == 4 + + def test_init_custom_tool_descriptions_shortterm(self): + middleware = FilesystemMiddleware( + use_longterm_memory=False, custom_tool_descriptions={"ls": "Custom ls tool description"} + ) + assert middleware.use_longterm_memory is False + assert middleware.system_prompt_extension == FILESYSTEM_SYSTEM_PROMPT + ls_tool = next(tool for tool in middleware.tools if tool.name == "ls") + assert ls_tool.description == "Custom ls tool description" + + def test_init_custom_tool_descriptions_longterm(self): + middleware = FilesystemMiddleware( + use_longterm_memory=True, custom_tool_descriptions={"ls": "Custom ls tool description"} + ) + assert middleware.use_longterm_memory is True + assert middleware.system_prompt_extension == ( + FILESYSTEM_SYSTEM_PROMPT + FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT + ) + ls_tool = next(tool for tool in middleware.tools if tool.name == "ls") + assert ls_tool.description == "Custom ls tool description" + + def test_ls_shortterm(self): + state = FilesystemState( + messages=[], + files={ + "test.txt": FileData( + content=["Hello world"], + modified_at="2021-01-01", + created_at="2021-01-01", + ), + "test2.txt": FileData( + content=["Goodbye world"], + modified_at="2021-01-01", + created_at="2021-01-01", + ), + }, + ) + middleware = FilesystemMiddleware(use_longterm_memory=False) + ls_tool = next(tool for tool in middleware.tools if tool.name == "ls") + result = ls_tool.invoke({"state": state}) + assert result == ["test.txt", "test2.txt"] + + def test_ls_shortterm_with_path(self): + state = FilesystemState( + messages=[], + files={ + "/test.txt": FileData( + content=["Hello world"], + modified_at="2021-01-01", + created_at="2021-01-01", + ), + "/pokemon/test2.txt": FileData( + content=["Goodbye world"], + modified_at="2021-01-01", + created_at="2021-01-01", + ), + "/pokemon/charmander.txt": FileData( + content=["Ember"], + modified_at="2021-01-01", + created_at="2021-01-01", + ), + "/pokemon/water/squirtle.txt": FileData( + content=["Water"], + modified_at="2021-01-01", + created_at="2021-01-01", + ), + }, + ) + middleware = FilesystemMiddleware(use_longterm_memory=False) + ls_tool = next(tool for tool in middleware.tools if tool.name == "ls") + result = ls_tool.invoke({"state": state, "path": "pokemon/"}) + assert "/pokemon/test2.txt" in result + assert "/pokemon/charmander.txt" in result From 1713a149e4a7dbeb3421a685900edd6f56e4bb7e Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Fri, 10 Oct 2025 15:45:50 -0400 Subject: [PATCH 09/22] Address comments --- .../langchain/agents/_internal/__init__.py | 0 .../{middleware => _internal}/file_utils.py | 0 .../langchain/agents/deepagents.py | 208 +++++------------- .../agents/middleware/anthropic_tools.py | 2 +- .../langchain/agents/middleware/filesystem.py | 2 +- .../langchain/agents/middleware/subagents.py | 22 +- .../middleware/test_filesystem_middleware.py | 2 +- .../middleware/test_subagent_middleware.py | 8 +- .../agents/test_deepagents.py | 12 +- 9 files changed, 77 insertions(+), 179 deletions(-) create mode 100644 libs/langchain_v1/langchain/agents/_internal/__init__.py rename libs/langchain_v1/langchain/agents/{middleware => _internal}/file_utils.py (100%) diff --git a/libs/langchain_v1/langchain/agents/_internal/__init__.py b/libs/langchain_v1/langchain/agents/_internal/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/libs/langchain_v1/langchain/agents/middleware/file_utils.py b/libs/langchain_v1/langchain/agents/_internal/file_utils.py similarity index 100% rename from libs/langchain_v1/langchain/agents/middleware/file_utils.py rename to libs/langchain_v1/langchain/agents/_internal/file_utils.py diff --git a/libs/langchain_v1/langchain/agents/deepagents.py b/libs/langchain_v1/langchain/agents/deepagents.py index 58dc3e6bd6ac5..1df885104ddc2 100644 --- a/libs/langchain_v1/langchain/agents/deepagents.py +++ b/libs/langchain_v1/langchain/agents/deepagents.py @@ -1,5 +1,4 @@ -"""Deepagents come with planning, filesystem, and subagents, along with other supportive middlewares..""" -# ruff: noqa: E501 +"""Deepagents come with planning, filesystem, and subagents.""" from collections.abc import Callable, Sequence from typing import Any @@ -7,25 +6,25 @@ from langchain_anthropic import ChatAnthropic from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool +from langgraph.cache.base import BaseCache +from langgraph.graph.state import CompiledStateGraph from langgraph.store.base import BaseStore from langgraph.types import Checkpointer from langchain.agents.factory import create_agent -from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware.filesystem import FilesystemMiddleware from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware, ToolConfig from langchain.agents.middleware.planning import PlanningMiddleware from langchain.agents.middleware.prompt_caching import AnthropicPromptCachingMiddleware from langchain.agents.middleware.subagents import ( - CustomSubAgent, - DefinedSubAgent, + CompiledSubAgent, + SubAgent, SubAgentMiddleware, ) from langchain.agents.middleware.summarization import SummarizationMiddleware +from langchain.agents.middleware.types import AgentMiddleware -BASE_AGENT_PROMPT = """ -In order to complete the objective that the user asks of you, you have access to a number of standard tools. # noqa: E501 -""" +BASE_AGENT_PROMPT = "In order to complete the objective that the user asks of you, you have access to a number of standard tools." # noqa: E501 def get_default_model() -> ChatAnthropic: @@ -42,37 +41,59 @@ def get_default_model() -> ChatAnthropic: ) -def agent_builder( - tools: Sequence[BaseTool | Callable | dict[str, Any]], - instructions: str, - middleware: list[AgentMiddleware] | None = None, - tool_configs: dict[str, bool | ToolConfig] | None = None, +def create_deep_agent( model: str | BaseChatModel | None = None, - subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, + tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, + *, + system_prompt: str | None = None, + middleware: Sequence[AgentMiddleware] = (), + subagents: list[SubAgent | CompiledSubAgent] | None = None, context_schema: type[Any] | None = None, checkpointer: Checkpointer | None = None, store: BaseStore | None = None, - *, use_longterm_memory: bool = False, + tool_configs: dict[str, bool | ToolConfig] | None = None, is_async: bool = False, -) -> Any: - """Build a deep agent with standard middleware stack. + debug: bool = False, + name: str | None = None, + cache: BaseCache | None = None, +) -> CompiledStateGraph: + """Create a deep agent. + + This agent will by default have access to a tool to write todos (write_todos), + four file editing tools: write_file, ls, read_file, edit_file, and a tool to call + subagents. Args: tools: The tools the agent should have access to. - instructions: The instructions for the agent system prompt. + system_prompt: The additional instructions the agent should have. Will go in + the system prompt. middleware: Additional middleware to apply after standard middleware. - tool_configs: Optional tool interrupt configurations. - model: The model to use. Defaults to Claude Sonnet 4. - subagents: Optional list of subagent configurations. - context_schema: Optional schema for the agent context. - checkpointer: Optional checkpointer for state persistence. - store: Optional store for longterm memory. - use_longterm_memory: Whether to enable longterm memory features. - is_async: Whether to create async subagent tools. + model: The model to use. + subagents: The subagents to use. Each subagent should be a dictionary with the + following keys: + - `name` + - `description` (used by the main agent to decide whether to call the + sub agent) + - `prompt` (used as the system prompt in the subagent) + - (optional) `tools` + - (optional) `model` (either a LanguageModelLike instance or dict + settings) + - (optional) `middleware` (list of AgentMiddleware) + context_schema: The schema of the deep agent. + checkpointer: Optional checkpointer for persisting agent state between runs. + store: Optional store for persisting longterm memories. + use_longterm_memory: Whether to use longterm memory - you must provide a store + in order to use longterm memory. + tool_configs: Optional Dict[str, HumanInTheLoopConfig] mapping tool names to + interrupt configs. + is_async: Whether to use async mode. If True, the agent will use async tools. + debug: Whether to enable debug mode. Passed through to create_agent. + name: The name of the agent. Passed through to create_agent. + cache: The cache to use for the agent. Passed through to create_agent. Returns: - A configured agent with deep agent middleware stack. + A configured deep agent. """ if model is None: model = get_default_model() @@ -102,136 +123,15 @@ def agent_builder( return create_agent( model, - system_prompt=instructions + "\n\n" + BASE_AGENT_PROMPT, + system_prompt=system_prompt + "\n\n" + BASE_AGENT_PROMPT + if system_prompt + else BASE_AGENT_PROMPT, tools=tools, middleware=deepagent_middleware, context_schema=context_schema, checkpointer=checkpointer, store=store, - ) - - -def create_deep_agent( - tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, - instructions: str = "", - middleware: list[AgentMiddleware] | None = None, - model: str | BaseChatModel | None = None, - subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, - context_schema: type[Any] | None = None, - checkpointer: Checkpointer | None = None, - store: BaseStore | None = None, - *, - use_longterm_memory: bool = False, - tool_configs: dict[str, bool | ToolConfig] | None = None, -) -> Any: - """Create a deep agent. - - This agent will by default have access to a tool to write todos (write_todos), - four file editing tools: write_file, ls, read_file, edit_file, and a tool to call - subagents. - - Args: - tools: The tools the agent should have access to. - instructions: The additional instructions the agent should have. Will go in - the system prompt. - middleware: Additional middleware to apply after standard middleware. - model: The model to use. - subagents: The subagents to use. Each subagent should be a dictionary with the - following keys: - - `name` - - `description` (used by the main agent to decide whether to call the - sub agent) - - `prompt` (used as the system prompt in the subagent) - - (optional) `tools` - - (optional) `model` (either a LanguageModelLike instance or dict - settings) - - (optional) `middleware` (list of AgentMiddleware) - context_schema: The schema of the deep agent. - checkpointer: Optional checkpointer for persisting agent state between runs. - store: Optional store for persisting longterm memories. - use_longterm_memory: Whether to use longterm memory - you must provide a store - in order to use longterm memory. - tool_configs: Optional Dict[str, HumanInTheLoopConfig] mapping tool names to - interrupt configs. - - Returns: - A configured deep agent. - """ - if tools is None: - tools = [] - return agent_builder( - tools=tools, - instructions=instructions, - middleware=middleware, - model=model, - subagents=subagents, - context_schema=context_schema, - checkpointer=checkpointer, - store=store, - use_longterm_memory=use_longterm_memory, - tool_configs=tool_configs, - is_async=False, - ) - - -def async_create_deep_agent( - tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, - instructions: str = "", - middleware: list[AgentMiddleware] | None = None, - model: str | BaseChatModel | None = None, - subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, - context_schema: type[Any] | None = None, - checkpointer: Checkpointer | None = None, - store: BaseStore | None = None, - *, - use_longterm_memory: bool = False, - tool_configs: dict[str, bool | ToolConfig] | None = None, -) -> Any: - """Create an async deep agent. - - This agent will by default have access to a tool to write todos (write_todos), - four file editing tools: write_file, ls, read_file, edit_file, and a tool to call - subagents. - - Args: - tools: The tools the agent should have access to. - instructions: The additional instructions the agent should have. Will go in - the system prompt. - middleware: Additional middleware to apply after standard middleware. - model: The model to use. - subagents: The subagents to use. Each subagent should be a dictionary with the - following keys: - - `name` - - `description` (used by the main agent to decide whether to call the - sub agent) - - `prompt` (used as the system prompt in the subagent) - - (optional) `tools` - - (optional) `model` (either a LanguageModelLike instance or dict - settings) - - (optional) `middleware` (list of AgentMiddleware) - context_schema: The schema of the deep agent. - checkpointer: Optional checkpointer for persisting agent state between runs. - store: Optional store for persisting longterm memories. - use_longterm_memory: Whether to use longterm memory - you must provide a store - in order to use longterm memory. - tool_configs: Optional Dict[str, HumanInTheLoopConfig] mapping tool names to - interrupt configs. - - Returns: - A configured deep agent with async subagent tools. - """ - if tools is None: - tools = [] - return agent_builder( - tools=tools, - instructions=instructions, - middleware=middleware, - model=model, - subagents=subagents, - context_schema=context_schema, - checkpointer=checkpointer, - store=store, - use_longterm_memory=use_longterm_memory, - tool_configs=tool_configs, - is_async=True, + debug=debug, + name=name, + cache=cache, ) diff --git a/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py b/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py index 88b9aa52c5ddc..056e681d5a0b0 100644 --- a/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py +++ b/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py @@ -13,7 +13,7 @@ from langgraph.types import Command from typing_extensions import NotRequired -from langchain.agents.middleware.file_utils import ( +from langchain.agents._internal.file_utils import ( FileData, apply_string_replacement, create_file_data, diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py index f0f326c15da44..8bff4add32a61 100644 --- a/libs/langchain_v1/langchain/agents/middleware/filesystem.py +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -13,7 +13,7 @@ from langgraph.store.base import BaseStore, Item from langgraph.types import Command -from langchain.agents.middleware.file_utils import ( +from langchain.agents._internal.file_utils import ( FileData, append_memories_prefix, check_empty_content, diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index a5146ffe9a193..7615cc9691585 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -17,7 +17,7 @@ from langchain.tools import InjectedState, InjectedToolCallId -class DefinedSubAgent(TypedDict): +class SubAgent(TypedDict): """A subagent constructed with user-defined parameters.""" name: str @@ -26,10 +26,10 @@ class DefinedSubAgent(TypedDict): description: str """The description of the subagent.""" - prompt: str + system_prompt: str """The system prompt to use for the subagent.""" - tools: NotRequired[list[BaseTool]] + tools: Sequence[BaseTool | Callable | dict[str, Any]] """The tools to use for the subagent.""" model: NotRequired[str | BaseChatModel] @@ -39,7 +39,7 @@ class DefinedSubAgent(TypedDict): """The middleware to use for the subagent.""" -class CustomSubAgent(TypedDict): +class CompiledSubAgent(TypedDict): """A Runnable passed in as a subagent.""" name: str @@ -52,8 +52,7 @@ class CustomSubAgent(TypedDict): """The Runnable to use for the subagent.""" -DEFAULT_SUBAGENT_PROMPT = """In order to complete the objective that the user asks of you, you have access to a number of standard tools. -""" # noqa: E501 +DEFAULT_SUBAGENT_PROMPT = "In order to complete the objective that the user asks of you, you have access to a number of standard tools." # noqa: E501 TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. @@ -170,7 +169,7 @@ class CustomSubAgent(TypedDict): def _get_subagents( default_subagent_model: str | BaseChatModel, default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]], - subagents: list[DefinedSubAgent | CustomSubAgent], + subagents: list[SubAgent | CompiledSubAgent], ) -> tuple[dict[str, Any], list[str]]: from langchain.agents.factory import create_agent @@ -197,8 +196,7 @@ def _get_subagents( for _agent in subagents: subagent_descriptions.append(f"- {_agent['name']}: {_agent['description']}") if "runnable" in _agent: - # Type narrowing: _agent is CustomSubAgent here - custom_agent = cast("CustomSubAgent", _agent) + custom_agent = cast("CompiledSubAgent", _agent) agents[custom_agent["name"]] = custom_agent["runnable"] continue _tools = _agent.get("tools", list(default_subagent_tools)) @@ -212,7 +210,7 @@ def _get_subagents( agents[_agent["name"]] = create_agent( subagent_model, - system_prompt=_agent["prompt"], + system_prompt=_agent["system_prompt"], tools=_tools, middleware=_middleware, checkpointer=False, @@ -223,7 +221,7 @@ def _get_subagents( def _create_task_tool( default_subagent_model: str | BaseChatModel, default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]], - subagents: list[DefinedSubAgent | CustomSubAgent], + subagents: list[SubAgent | CompiledSubAgent], *, is_async: bool = False, ) -> BaseTool: @@ -331,7 +329,7 @@ def __init__( *, default_subagent_model: str | BaseChatModel, default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, - subagents: list[DefinedSubAgent | CustomSubAgent] | None = None, + subagents: list[SubAgent | CompiledSubAgent] | None = None, system_prompt_extension: str | None = None, is_async: bool = False, ) -> None: diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py index f05eaa33537b1..4adfddff5e79b 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py @@ -9,7 +9,7 @@ from langchain_anthropic import ChatAnthropic from langgraph.store.memory import InMemoryStore from langgraph.checkpoint.memory import MemorySaver -from langchain.agents.middleware.file_utils import FileData +from langchain.agents._internal.file_utils import FileData import pytest import uuid diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py index 8389ca3fb8b48..5799fcd7cf347 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py @@ -74,7 +74,7 @@ def test_defined_subagent(self): { "name": "weather", "description": "This subagent can get weather in cities.", - "prompt": "Use the get_weather tool to get the weather in a city.", + "system_prompt": "Use the get_weather tool to get the weather in a city.", "tools": [get_weather], } ], @@ -100,7 +100,7 @@ def test_defined_subagent_tool_calls(self): { "name": "weather", "description": "This subagent can get weather in cities.", - "prompt": "Use the get_weather tool to get the weather in a city.", + "system_prompt": "Use the get_weather tool to get the weather in a city.", "tools": [get_weather], } ], @@ -129,7 +129,7 @@ def test_defined_subagent_custom_model(self): { "name": "weather", "description": "This subagent can get weather in cities.", - "prompt": "Use the get_weather tool to get the weather in a city.", + "system_prompt": "Use the get_weather tool to get the weather in a city.", "tools": [get_weather], "model": "gpt-4.1", } @@ -163,7 +163,7 @@ def test_defined_subagent_custom_middleware(self): { "name": "weather", "description": "This subagent can get weather in cities.", - "prompt": "Use the get_weather tool to get the weather in a city.", + "system_prompt": "Use the get_weather tool to get the weather in a city.", "tools": [], # No tools, only in middleware "model": "gpt-4.1", "middleware": [WeatherMiddleware()], diff --git a/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py b/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py index 02d494deb56bf..29f9ca58341eb 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py +++ b/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py @@ -117,7 +117,7 @@ def test_deep_agent_with_subagents(self): { "name": "weather_agent", "description": "Use this agent to get the weather", - "prompt": "You are a weather agent.", + "system_prompt": "You are a weather agent.", "tools": [get_weather], "model": SAMPLE_MODEL, } @@ -142,7 +142,7 @@ def test_deep_agent_with_subagents_gen_purpose(self): { "name": "weather_agent", "description": "Use this agent to get the weather", - "prompt": "You are a weather agent.", + "system_prompt": "You are a weather agent.", "tools": [get_weather], "model": SAMPLE_MODEL, } @@ -174,7 +174,7 @@ def test_deep_agent_with_subagents_with_middleware(self): { "name": "weather_agent", "description": "Use this agent to get the weather", - "prompt": "You are a weather agent.", + "system_prompt": "You are a weather agent.", "tools": [], "model": SAMPLE_MODEL, "middleware": [WeatherToolMiddleware()], @@ -200,7 +200,7 @@ def test_deep_agent_with_custom_subagents(self): { "name": "weather_agent", "description": "Use this agent to get the weather", - "prompt": "You are a weather agent.", + "system_prompt": "You are a weather agent.", "tools": [get_weather], "model": SAMPLE_MODEL, }, @@ -248,7 +248,7 @@ def test_deep_agent_with_extended_state_and_subagents(self): { "name": "basketball_info_agent", "description": "Use this agent to get surface level info on any basketball topic", - "prompt": "You are a basketball info agent.", + "system_prompt": "You are a basketball info agent.", "middleware": [ResearchMiddlewareWithTools()], } ] @@ -277,7 +277,7 @@ def test_deep_agent_with_subagents_no_tools(self): { "name": "basketball_info_agent", "description": "Use this agent to get surface level info on any basketball topic", - "prompt": "You are a basketball info agent.", + "system_prompt": "You are a basketball info agent.", } ] agent = create_deep_agent(tools=[sample_tool], subagents=subagents) From 206fa156ac7d76833dd0c7de1214151c5f19d042 Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Fri, 10 Oct 2025 16:01:25 -0400 Subject: [PATCH 10/22] Add dependencies to tests --- libs/langchain_v1/langchain/agents/middleware/filesystem.py | 4 +++- libs/langchain_v1/langchain/agents/middleware/subagents.py | 3 ++- .../agents/middleware/test_filesystem_middleware.py | 1 + .../agents/middleware/test_subagent_middleware.py | 2 ++ .../tests/integration_tests/agents/test_deepagents.py | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py index 8bff4add32a61..40e1952ed079c 100644 --- a/libs/langchain_v1/langchain/agents/middleware/filesystem.py +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -2,7 +2,9 @@ # ruff: noqa: E501 from collections.abc import Callable -from typing import TYPE_CHECKING, Annotated, Any, NotRequired +from typing import TYPE_CHECKING, Annotated, Any + +from typing_extensions import NotRequired if TYPE_CHECKING: from langgraph.runtime import Runtime diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index 7615cc9691585..52d3596418eeb 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -1,13 +1,14 @@ """Middleware for providing subagents to an agent via a `task` tool.""" from collections.abc import Callable, Sequence -from typing import Annotated, Any, NotRequired, TypedDict, cast +from typing import Annotated, Any, TypedDict, cast from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool, tool from langgraph.types import Command +from typing_extensions import NotRequired from langchain.agents.middleware.filesystem import FilesystemMiddleware from langchain.agents.middleware.planning import PlanningMiddleware diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py index 4adfddff5e79b..39bcff25ef9d5 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_filesystem_middleware.py @@ -14,6 +14,7 @@ import uuid +@pytest.mark.requires("langchain_anthropic") class TestFilesystem: def test_create_deepagent_without_store_and_with_longterm_memory_should_fail(self): with pytest.raises(ValueError): diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py index 5799fcd7cf347..b3dfd2b41e8ab 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py @@ -3,6 +3,7 @@ from langchain_core.tools import tool from langchain.agents import create_agent from langchain_core.messages import HumanMessage +import pytest @tool @@ -41,6 +42,7 @@ def assert_expected_subgraph_actions(expected_tool_calls, agent, inputs): assert current_idx == len(expected_tool_calls) +@pytest.mark.requires("langchain_anthropic", "langchain_openai") class TestSubagentMiddleware: """Integration tests for the SubagentMiddleware class.""" diff --git a/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py b/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py index 29f9ca58341eb..e25b80bd344cc 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py +++ b/libs/langchain_v1/tests/integration_tests/agents/test_deepagents.py @@ -6,6 +6,7 @@ from langchain.agents.middleware import AgentMiddleware, AgentState from langgraph.types import Command from langchain_core.messages import ToolMessage +import pytest def assert_all_deepagent_qualities(agent): @@ -91,6 +92,7 @@ class WeatherToolMiddleware(AgentMiddleware): tools = [get_weather] +@pytest.mark.requires("langchain_anthropic") class TestDeepAgentsFilesystem: def test_base_deep_agent(self): agent = create_deep_agent() From bcbf2fff5fa8dff02ca43f387b98b2634b3aac12 Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Fri, 10 Oct 2025 16:05:00 -0400 Subject: [PATCH 11/22] Fix import in test --- .../tests/unit_tests/agents/middleware/test_anthropic_tools.py | 2 +- .../unit_tests/agents/middleware/test_subagent_middleware.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py index a7cf985c47009..7e871c69aad36 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py @@ -6,7 +6,7 @@ StateClaudeMemoryMiddleware, StateClaudeTextEditorMiddleware, ) -from langchain.agents.middleware.file_utils import validate_path +from langchain.agents._internal.file_utils import validate_path from langchain_core.messages import ToolMessage from langgraph.types import Command diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py index fbc6bee92d73f..d7c721a2c9a48 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py @@ -1,6 +1,9 @@ from langchain.agents.middleware.subagents import TASK_TOOL_DESCRIPTION, SubAgentMiddleware +import pytest + +@pytest.mark.requires("langchain_openai") class TestSubagentMiddleware: """Test the SubagentMiddleware class.""" From 918e2ee567af98eaf4907bc2d9854505b2bfae35 Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Fri, 10 Oct 2025 17:22:22 -0400 Subject: [PATCH 12/22] Update default model --- libs/langchain_v1/langchain/agents/deepagents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langchain_v1/langchain/agents/deepagents.py b/libs/langchain_v1/langchain/agents/deepagents.py index 1df885104ddc2..02e7073ab929f 100644 --- a/libs/langchain_v1/langchain/agents/deepagents.py +++ b/libs/langchain_v1/langchain/agents/deepagents.py @@ -37,7 +37,7 @@ def get_default_model() -> ChatAnthropic: model_name="claude-sonnet-4-20250514", timeout=None, stop=None, - model_kwargs={"max_tokens": 64000}, + max_tokens=64000, ) From ecbd43b6603b044f9b544b8f00a4e2ec591cd5dc Mon Sep 17 00:00:00 2001 From: Nick Huang Date: Mon, 13 Oct 2025 14:45:05 -0400 Subject: [PATCH 13/22] Change how namespace is built --- .../langchain/agents/middleware/filesystem.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py index 40e1952ed079c..ec4ddaa7e5666 100644 --- a/libs/langchain_v1/langchain/agents/middleware/filesystem.py +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -11,6 +11,7 @@ from langchain_core.messages import AIMessage, ToolMessage from langchain_core.tools import BaseTool, InjectedToolCallId, tool +from langgraph.config import get_config from langgraph.runtime import Runtime, get_runtime from langgraph.store.base import BaseStore, Item from langgraph.types import Command @@ -105,11 +106,12 @@ class FilesystemState(AgentState): Remember, to interact with the longterm filesystem, you must prefix the filename with the /memories/ path.""" -def _get_namespace(runtime: Runtime[Any]) -> tuple[str] | tuple[str, str]: +def _get_namespace() -> tuple[str] | tuple[str, str]: namespace = "filesystem" - if runtime.context is None: + config = get_config() + if config is None: return (namespace,) - assistant_id = runtime.context.get("assistant_id") + assistant_id = config.get("metadata", {}).get("assistant_id") if assistant_id is None: return (namespace,) return (assistant_id, "filesystem") @@ -186,7 +188,7 @@ def ls( # Add filenames from longterm memory runtime = get_runtime() store = _get_store(runtime) - namespace = _get_namespace(runtime) + namespace = _get_namespace() longterm_files = store.search(namespace) longterm_files_prefixed = [append_memories_prefix(f.key) for f in longterm_files] files.extend(longterm_files_prefixed) @@ -241,7 +243,7 @@ def read_file( stripped_file_path = strip_memories_prefix(file_path) runtime = get_runtime() store = _get_store(runtime) - namespace = _get_namespace(runtime) + namespace = _get_namespace() item: Item | None = store.get(namespace, stripped_file_path) if item is None: return f"Error: File '{file_path}' not found" @@ -310,7 +312,7 @@ def write_file( stripped_file_path = strip_memories_prefix(file_path) runtime = get_runtime() store = _get_store(runtime) - namespace = _get_namespace(runtime) + namespace = _get_namespace() if store.get(namespace, stripped_file_path) is not None: return f"Cannot write to {file_path} because it already exists. Read and then make an edit, or write to a new path." new_file_data = create_file_data(content) @@ -362,7 +364,7 @@ def edit_file( stripped_file_path = strip_memories_prefix(file_path) runtime = get_runtime() store = _get_store(runtime) - namespace = _get_namespace(runtime) + namespace = _get_namespace() item: Item | None = store.get(namespace, stripped_file_path) if item is None: return f"Error: File '{file_path}' not found" From 6eb8377e127d181bd8dd0f7894d82e6cf5048f8a Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 14:30:03 -0400 Subject: [PATCH 14/22] excluded keys --- .../langchain/agents/deepagents.py | 8 ++++--- .../langchain/agents/middleware/subagents.py | 21 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/deepagents.py b/libs/langchain_v1/langchain/agents/deepagents.py index 02e7073ab929f..d6801c3f75e89 100644 --- a/libs/langchain_v1/langchain/agents/deepagents.py +++ b/libs/langchain_v1/langchain/agents/deepagents.py @@ -121,11 +121,13 @@ def create_deep_agent( if middleware is not None: deepagent_middleware.extend(middleware) + final_system_prompt = BASE_AGENT_PROMPT + if system_prompt: + final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT + return create_agent( model, - system_prompt=system_prompt + "\n\n" + BASE_AGENT_PROMPT - if system_prompt - else BASE_AGENT_PROMPT, + system_prompt=final_system_prompt, tools=tools, middleware=deepagent_middleware, context_schema=context_schema, diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index 52d3596418eeb..8e925dd4b7c6a 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -55,6 +55,9 @@ class CompiledSubAgent(TypedDict): DEFAULT_SUBAGENT_PROMPT = "In order to complete the objective that the user asks of you, you have access to a number of standard tools." # noqa: E501 +# State keys that should be excluded when passing state to subagents +_EXCLUDED_STATE_KEYS = ("messages", "todos") + TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. Available agent types and the tools they have access to: @@ -232,7 +235,7 @@ def _create_task_tool( subagent_description_str = "\n".join(subagent_descriptions) def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command: - state_update = {k: v for k, v in result.items() if k not in ["todos", "messages"]} + state_update = {k: v for k, v in result.items() if k not in _EXCLUDED_STATE_KEYS} return Command( update={ **state_update, @@ -259,10 +262,10 @@ async def task( ) raise ValueError(msg) subagent = subagent_graphs[subagent_type] - state["messages"] = [HumanMessage(content=description)] - if "todos" in state: - del state["todos"] - result = await subagent.ainvoke(state) + # Create a new state dict to avoid mutating the original + subagent_state = {k: v for k, v in state.items() if k not in _EXCLUDED_STATE_KEYS} + subagent_state["messages"] = [HumanMessage(content=description)] + result = await subagent.ainvoke(subagent_state) return _return_command_with_state_update(result, tool_call_id) else: @@ -280,10 +283,10 @@ def task( ) raise ValueError(msg) subagent = subagent_graphs[subagent_type] - state["messages"] = [HumanMessage(content=description)] - if "todos" in state: - del state["todos"] - result = subagent.invoke(state) + # Create a new state dict to avoid mutating the original + subagent_state = {k: v for k, v in state.items() if k not in _EXCLUDED_STATE_KEYS} + subagent_state["messages"] = [HumanMessage(content=description)] + result = subagent.invoke(subagent_state) return _return_command_with_state_update(result, tool_call_id) return task From 93108324df468489fe194c684c4beb70ec264dcf Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 15:06:54 -0400 Subject: [PATCH 15/22] remove anthropic stuff --- .../langchain/agents/deepagents.py | 139 --- .../langchain/agents/middleware/__init__.py | 17 - .../agents/middleware/anthropic_tools.py | 910 ------------------ .../agents/middleware/file_search.py | 550 ----------- .../agents/middleware/test_anthropic_tools.py | 276 ------ .../agents/middleware/test_file_search.py | 461 --------- 6 files changed, 2353 deletions(-) delete mode 100644 libs/langchain_v1/langchain/agents/deepagents.py delete mode 100644 libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py delete mode 100644 libs/langchain_v1/langchain/agents/middleware/file_search.py delete mode 100644 libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py delete mode 100644 libs/langchain_v1/tests/unit_tests/agents/middleware/test_file_search.py diff --git a/libs/langchain_v1/langchain/agents/deepagents.py b/libs/langchain_v1/langchain/agents/deepagents.py deleted file mode 100644 index d6801c3f75e89..0000000000000 --- a/libs/langchain_v1/langchain/agents/deepagents.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Deepagents come with planning, filesystem, and subagents.""" - -from collections.abc import Callable, Sequence -from typing import Any - -from langchain_anthropic import ChatAnthropic -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool -from langgraph.cache.base import BaseCache -from langgraph.graph.state import CompiledStateGraph -from langgraph.store.base import BaseStore -from langgraph.types import Checkpointer - -from langchain.agents.factory import create_agent -from langchain.agents.middleware.filesystem import FilesystemMiddleware -from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware, ToolConfig -from langchain.agents.middleware.planning import PlanningMiddleware -from langchain.agents.middleware.prompt_caching import AnthropicPromptCachingMiddleware -from langchain.agents.middleware.subagents import ( - CompiledSubAgent, - SubAgent, - SubAgentMiddleware, -) -from langchain.agents.middleware.summarization import SummarizationMiddleware -from langchain.agents.middleware.types import AgentMiddleware - -BASE_AGENT_PROMPT = "In order to complete the objective that the user asks of you, you have access to a number of standard tools." # noqa: E501 - - -def get_default_model() -> ChatAnthropic: - """Get the default model for deep agents. - - Returns: - ChatAnthropic instance configured with Claude Sonnet 4. - """ - return ChatAnthropic( - model_name="claude-sonnet-4-20250514", - timeout=None, - stop=None, - max_tokens=64000, - ) - - -def create_deep_agent( - model: str | BaseChatModel | None = None, - tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, - *, - system_prompt: str | None = None, - middleware: Sequence[AgentMiddleware] = (), - subagents: list[SubAgent | CompiledSubAgent] | None = None, - context_schema: type[Any] | None = None, - checkpointer: Checkpointer | None = None, - store: BaseStore | None = None, - use_longterm_memory: bool = False, - tool_configs: dict[str, bool | ToolConfig] | None = None, - is_async: bool = False, - debug: bool = False, - name: str | None = None, - cache: BaseCache | None = None, -) -> CompiledStateGraph: - """Create a deep agent. - - This agent will by default have access to a tool to write todos (write_todos), - four file editing tools: write_file, ls, read_file, edit_file, and a tool to call - subagents. - - Args: - tools: The tools the agent should have access to. - system_prompt: The additional instructions the agent should have. Will go in - the system prompt. - middleware: Additional middleware to apply after standard middleware. - model: The model to use. - subagents: The subagents to use. Each subagent should be a dictionary with the - following keys: - - `name` - - `description` (used by the main agent to decide whether to call the - sub agent) - - `prompt` (used as the system prompt in the subagent) - - (optional) `tools` - - (optional) `model` (either a LanguageModelLike instance or dict - settings) - - (optional) `middleware` (list of AgentMiddleware) - context_schema: The schema of the deep agent. - checkpointer: Optional checkpointer for persisting agent state between runs. - store: Optional store for persisting longterm memories. - use_longterm_memory: Whether to use longterm memory - you must provide a store - in order to use longterm memory. - tool_configs: Optional Dict[str, HumanInTheLoopConfig] mapping tool names to - interrupt configs. - is_async: Whether to use async mode. If True, the agent will use async tools. - debug: Whether to enable debug mode. Passed through to create_agent. - name: The name of the agent. Passed through to create_agent. - cache: The cache to use for the agent. Passed through to create_agent. - - Returns: - A configured deep agent. - """ - if model is None: - model = get_default_model() - - deepagent_middleware = [ - PlanningMiddleware(), - FilesystemMiddleware( - use_longterm_memory=use_longterm_memory, - ), - SubAgentMiddleware( - default_subagent_tools=tools, - default_subagent_model=model, - subagents=subagents if subagents is not None else [], - is_async=is_async, - ), - SummarizationMiddleware( - model=model, - max_tokens_before_summary=120000, - messages_to_keep=20, - ), - AnthropicPromptCachingMiddleware(ttl="5m", unsupported_model_behavior="ignore"), - ] - if tool_configs is not None: - deepagent_middleware.append(HumanInTheLoopMiddleware(interrupt_on=tool_configs)) - if middleware is not None: - deepagent_middleware.extend(middleware) - - final_system_prompt = BASE_AGENT_PROMPT - if system_prompt: - final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT - - return create_agent( - model, - system_prompt=final_system_prompt, - tools=tools, - middleware=deepagent_middleware, - context_schema=context_schema, - checkpointer=checkpointer, - store=store, - debug=debug, - name=name, - cache=cache, - ) diff --git a/libs/langchain_v1/langchain/agents/middleware/__init__.py b/libs/langchain_v1/langchain/agents/middleware/__init__.py index e87d0f22edb16..c665670300b11 100644 --- a/libs/langchain_v1/langchain/agents/middleware/__init__.py +++ b/libs/langchain_v1/langchain/agents/middleware/__init__.py @@ -1,18 +1,9 @@ """Middleware plugins for agents.""" -from .anthropic_tools import ( - AnthropicToolsState, - FileData, - FilesystemClaudeMemoryMiddleware, - FilesystemClaudeTextEditorMiddleware, - StateClaudeMemoryMiddleware, - StateClaudeTextEditorMiddleware, -) from .context_editing import ( ClearToolUsesEdit, ContextEditingMiddleware, ) -from .file_search import FilesystemFileSearchMiddleware, StateFileSearchMiddleware from .filesystem import FilesystemMiddleware from .human_in_the_loop import HumanInTheLoopMiddleware from .model_call_limit import ModelCallLimitMiddleware @@ -42,13 +33,8 @@ "AgentState", # should move to langchain-anthropic if we decide to keep it "AnthropicPromptCachingMiddleware", - "AnthropicToolsState", "ClearToolUsesEdit", "ContextEditingMiddleware", - "FileData", - "FilesystemClaudeMemoryMiddleware", - "FilesystemClaudeTextEditorMiddleware", - "FilesystemFileSearchMiddleware", "FilesystemMiddleware", "HumanInTheLoopMiddleware", "LLMToolSelectorMiddleware", @@ -58,9 +44,6 @@ "PIIDetectionError", "PIIMiddleware", "PlanningMiddleware", - "StateClaudeMemoryMiddleware", - "StateClaudeTextEditorMiddleware", - "StateFileSearchMiddleware", "SubAgentMiddleware", "SummarizationMiddleware", "ToolCallLimitMiddleware", diff --git a/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py b/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py deleted file mode 100644 index 056e681d5a0b0..0000000000000 --- a/libs/langchain_v1/langchain/agents/middleware/anthropic_tools.py +++ /dev/null @@ -1,910 +0,0 @@ -"""Anthropic text editor and memory tool middleware. - -This module provides client-side implementations of Anthropic's text editor and -memory tools using schema-less tool definitions and tool call interception. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Any, cast - -from langchain_core.messages import AIMessage, ToolMessage -from langgraph.types import Command -from typing_extensions import NotRequired - -from langchain.agents._internal.file_utils import ( - FileData, - apply_string_replacement, - create_file_data, - file_data_reducer, - file_data_to_string, - format_content_with_line_numbers, - list_directory, - update_file_data, - validate_path, -) -from langchain.agents.middleware.types import AgentMiddleware, AgentState, ModelRequest - -if TYPE_CHECKING: - from collections.abc import Callable, Sequence - - from langchain.tools.tool_node import ToolCallRequest - -# Tool type constants -TEXT_EDITOR_TOOL_TYPE = "text_editor_20250728" -TEXT_EDITOR_TOOL_NAME = "str_replace_based_edit_tool" -MEMORY_TOOL_TYPE = "memory_20250818" -MEMORY_TOOL_NAME = "memory" - -MEMORY_SYSTEM_PROMPT = """IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE \ -DOING ANYTHING ELSE. -MEMORY PROTOCOL: -1. Use the `view` command of your `memory` tool to check for earlier progress. -2. ... (work on the task) ... - - As you make progress, record status / progress / thoughts etc in your memory. -ASSUME INTERRUPTION: Your context window might be reset at any moment, so you risk \ -losing any progress that is not recorded in your memory directory.""" - - -class AnthropicToolsState(AgentState): - """State schema for Anthropic text editor and memory tools.""" - - text_editor_files: NotRequired[Annotated[dict[str, FileData], file_data_reducer]] - """Virtual file system for text editor tools.""" - - memory_files: NotRequired[Annotated[dict[str, FileData], file_data_reducer]] - """Virtual file system for memory tools.""" - - -class _StateClaudeFileToolMiddleware(AgentMiddleware): - """Base class for state-based file tool middleware (internal).""" - - state_schema = AnthropicToolsState - - def __init__( - self, - *, - tool_type: str, - tool_name: str, - state_key: str, - allowed_path_prefixes: Sequence[str] | None = None, - system_prompt: str | None = None, - ) -> None: - """Initialize the middleware. - - Args: - tool_type: Tool type identifier. - tool_name: Tool name. - state_key: State key for file storage. - allowed_path_prefixes: Optional list of allowed path prefixes. - system_prompt: Optional system prompt to inject. - """ - self.tool_type = tool_type - self.tool_name = tool_name - self.state_key = state_key - self.allowed_prefixes = allowed_path_prefixes - self.system_prompt = system_prompt - - def wrap_model_call( - self, - request: ModelRequest, - handler: Callable[[ModelRequest], AIMessage], - ) -> AIMessage: - """Inject tool and optional system prompt.""" - # Add tool - tools = list(request.tools or []) - tools.append( - { - "type": self.tool_type, - "name": self.tool_name, - } - ) - request.tools = tools - - # Inject system prompt if provided - if self.system_prompt: - request.system_prompt = ( - request.system_prompt + "\n\n" + self.system_prompt - if request.system_prompt - else self.system_prompt - ) - - return handler(request) - - def wrap_tool_call( - self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command] - ) -> ToolMessage | Command: - """Intercept tool calls.""" - tool_call = request.tool_call - tool_name = tool_call.get("name") - - if tool_name != self.tool_name: - return handler(request) - - # Handle tool call - try: - args = tool_call.get("args", {}) - command = args.get("command") - state = request.state - - if command == "view": - return self._handle_view(args, state, tool_call["id"]) - if command == "create": - return self._handle_create(args, state, tool_call["id"]) - if command == "str_replace": - return self._handle_str_replace(args, state, tool_call["id"]) - if command == "insert": - return self._handle_insert(args, state, tool_call["id"]) - if command == "delete": - return self._handle_delete(args, state, tool_call["id"]) - if command == "rename": - return self._handle_rename(args, state, tool_call["id"]) - - msg = f"Unknown command: {command}" - return ToolMessage( - content=msg, - tool_call_id=tool_call["id"], - name=tool_name, - status="error", - ) - except (ValueError, FileNotFoundError) as e: - return ToolMessage( - content=str(e), - tool_call_id=tool_call["id"], - name=tool_name, - status="error", - ) - - def _handle_view( - self, args: dict, state: AnthropicToolsState, tool_call_id: str | None - ) -> Command: - """Handle view command.""" - path = args["path"] - normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) - - files = cast("dict[str, Any]", state.get(self.state_key, {})) - file_data = files.get(normalized_path) - - if file_data is None: - # Try directory listing - matching = list_directory(files, normalized_path) - - if matching: - content = "\n".join(matching) - return Command( - update={ - "messages": [ - ToolMessage( - content=content, - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ] - } - ) - - msg = f"File not found: {path}" - raise FileNotFoundError(msg) - - # Format file content with line numbers - content = format_content_with_line_numbers(file_data["content"], format_style="pipe") - - return Command( - update={ - "messages": [ - ToolMessage( - content=content, - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ] - } - ) - - def _handle_create( - self, args: dict, state: AnthropicToolsState, tool_call_id: str | None - ) -> Command: - """Handle create command.""" - path = args["path"] - file_text = args["file_text"] - - normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) - - # Get existing files - files = cast("dict[str, Any]", state.get(self.state_key, {})) - existing = files.get(normalized_path) - - # Create or update file data - if existing: - new_file_data = update_file_data(existing, file_text) - else: - new_file_data = create_file_data(file_text) - - return Command( - update={ - self.state_key: { - normalized_path: new_file_data, - }, - "messages": [ - ToolMessage( - content=f"File created: {path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ], - } - ) - - def _handle_str_replace( - self, args: dict, state: AnthropicToolsState, tool_call_id: str | None - ) -> Command: - """Handle str_replace command.""" - path = args["path"] - old_str = args["old_str"] - new_str = args.get("new_str", "") - - normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) - - # Read file - files = cast("dict[str, Any]", state.get(self.state_key, {})) - file_data = files.get(normalized_path) - if file_data is None: - msg = f"File not found: {path}" - raise FileNotFoundError(msg) - - content = file_data_to_string(file_data) - - # Replace string - if old_str not in content: - msg = f"String not found in file: {old_str}" - raise ValueError(msg) - new_content, _ = apply_string_replacement(content, old_str, new_str, replace_all=False) - - # Update file - new_file_data = update_file_data(file_data, new_content) - - return Command( - update={ - self.state_key: { - normalized_path: new_file_data, - }, - "messages": [ - ToolMessage( - content=f"String replaced in {path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ], - } - ) - - def _handle_insert( - self, args: dict, state: AnthropicToolsState, tool_call_id: str | None - ) -> Command: - """Handle insert command.""" - path = args["path"] - insert_line = args["insert_line"] - text_to_insert = args["new_str"] - - normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) - - # Read file - files = cast("dict[str, Any]", state.get(self.state_key, {})) - file_data = files.get(normalized_path) - if file_data is None: - msg = f"File not found: {path}" - raise FileNotFoundError(msg) - - lines_content = file_data["content"] - new_lines = text_to_insert.split("\n") - - # Insert after insert_line (0-indexed) - updated_lines = lines_content[:insert_line] + new_lines + lines_content[insert_line:] - - # Update file - new_file_data = update_file_data(file_data, updated_lines) - - return Command( - update={ - self.state_key: { - normalized_path: new_file_data, - }, - "messages": [ - ToolMessage( - content=f"Text inserted in {path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ], - } - ) - - def _handle_delete( - self, - args: dict, - state: AnthropicToolsState, # noqa: ARG002 - tool_call_id: str | None, - ) -> Command: - """Handle delete command.""" - path = args["path"] - - normalized_path = validate_path(path, allowed_prefixes=self.allowed_prefixes) - - return Command( - update={ - self.state_key: {normalized_path: None}, - "messages": [ - ToolMessage( - content=f"File deleted: {path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ], - } - ) - - def _handle_rename( - self, args: dict, state: AnthropicToolsState, tool_call_id: str | None - ) -> Command: - """Handle rename command.""" - old_path = args["old_path"] - new_path = args["new_path"] - - normalized_old = validate_path(old_path, allowed_prefixes=self.allowed_prefixes) - normalized_new = validate_path(new_path, allowed_prefixes=self.allowed_prefixes) - - # Read file - files = cast("dict[str, Any]", state.get(self.state_key, {})) - file_data = files.get(normalized_old) - if file_data is None: - msg = f"File not found: {old_path}" - raise ValueError(msg) - - # Update timestamp - content = file_data["content"] - new_file_data = update_file_data(file_data, content) - - return Command( - update={ - self.state_key: { - normalized_old: None, - normalized_new: new_file_data, - }, - "messages": [ - ToolMessage( - content=f"File renamed: {old_path} -> {new_path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ], - } - ) - - -class StateClaudeTextEditorMiddleware(_StateClaudeFileToolMiddleware): - """State-based text editor tool middleware. - - Provides Anthropic's text_editor tool using LangGraph state for storage. - Files persist for the conversation thread. - - Example: - ```python - from langchain.agents import create_agent - from langchain.agents.middleware import StateTextEditorToolMiddleware - - agent = create_agent( - model=model, - tools=[], - middleware=[StateTextEditorToolMiddleware()], - ) - ``` - """ - - def __init__( - self, - *, - allowed_path_prefixes: Sequence[str] | None = None, - ) -> None: - """Initialize the text editor middleware. - - Args: - allowed_path_prefixes: Optional list of allowed path prefixes. - If specified, only paths starting with these prefixes are allowed. - """ - super().__init__( - tool_type=TEXT_EDITOR_TOOL_TYPE, - tool_name=TEXT_EDITOR_TOOL_NAME, - state_key="text_editor_files", - allowed_path_prefixes=allowed_path_prefixes, - ) - - -class StateClaudeMemoryMiddleware(_StateClaudeFileToolMiddleware): - """State-based memory tool middleware. - - Provides Anthropic's memory tool using LangGraph state for storage. - Files persist for the conversation thread. Enforces /memories prefix - and injects Anthropic's recommended system prompt. - - Example: - ```python - from langchain.agents import create_agent - from langchain.agents.middleware import StateMemoryToolMiddleware - - agent = create_agent( - model=model, - tools=[], - middleware=[StateMemoryToolMiddleware()], - ) - ``` - """ - - def __init__( - self, - *, - allowed_path_prefixes: Sequence[str] | None = None, - system_prompt: str = MEMORY_SYSTEM_PROMPT, - ) -> None: - """Initialize the memory middleware. - - Args: - allowed_path_prefixes: Optional list of allowed path prefixes. - Defaults to ["/memories"]. - system_prompt: System prompt to inject. Defaults to Anthropic's - recommended memory prompt. - """ - super().__init__( - tool_type=MEMORY_TOOL_TYPE, - tool_name=MEMORY_TOOL_NAME, - state_key="memory_files", - allowed_path_prefixes=allowed_path_prefixes or ["/memories"], - system_prompt=system_prompt, - ) - - -class _FilesystemClaudeFileToolMiddleware(AgentMiddleware): - """Base class for filesystem-based file tool middleware (internal).""" - - def __init__( - self, - *, - tool_type: str, - tool_name: str, - root_path: str, - allowed_prefixes: list[str] | None = None, - max_file_size_mb: int = 10, - system_prompt: str | None = None, - ) -> None: - """Initialize the middleware. - - Args: - tool_type: Tool type identifier. - tool_name: Tool name. - root_path: Root directory for file operations. - allowed_prefixes: Optional list of allowed virtual path prefixes. - max_file_size_mb: Maximum file size in MB. - system_prompt: Optional system prompt to inject. - """ - self.tool_type = tool_type - self.tool_name = tool_name - self.root_path = Path(root_path).resolve() - self.allowed_prefixes = allowed_prefixes or ["/"] - self.max_file_size_bytes = max_file_size_mb * 1024 * 1024 - self.system_prompt = system_prompt - - # Create root directory if it doesn't exist - self.root_path.mkdir(parents=True, exist_ok=True) - - def wrap_model_call( - self, - request: ModelRequest, - handler: Callable[[ModelRequest], AIMessage], - ) -> AIMessage: - """Inject tool and optional system prompt.""" - # Add tool - tools = list(request.tools or []) - tools.append( - { - "type": self.tool_type, - "name": self.tool_name, - } - ) - request.tools = tools - - # Inject system prompt if provided - if self.system_prompt: - request.system_prompt = ( - request.system_prompt + "\n\n" + self.system_prompt - if request.system_prompt - else self.system_prompt - ) - - return handler(request) - - def wrap_tool_call( - self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command] - ) -> ToolMessage | Command: - """Intercept tool calls.""" - tool_call = request.tool_call - tool_name = tool_call.get("name") - - if tool_name != self.tool_name: - return handler(request) - - # Handle tool call - try: - args = tool_call.get("args", {}) - command = args.get("command") - - if command == "view": - return self._handle_view(args, tool_call["id"]) - if command == "create": - return self._handle_create(args, tool_call["id"]) - if command == "str_replace": - return self._handle_str_replace(args, tool_call["id"]) - if command == "insert": - return self._handle_insert(args, tool_call["id"]) - if command == "delete": - return self._handle_delete(args, tool_call["id"]) - if command == "rename": - return self._handle_rename(args, tool_call["id"]) - - msg = f"Unknown command: {command}" - return ToolMessage( - content=msg, - tool_call_id=tool_call["id"], - name=tool_name, - status="error", - ) - except (ValueError, FileNotFoundError) as e: - return ToolMessage( - content=str(e), - tool_call_id=tool_call["id"], - name=tool_name, - status="error", - ) - - def _validate_and_resolve_path(self, path: str) -> Path: - """Validate and resolve a virtual path to filesystem path. - - Args: - path: Virtual path (e.g., /file.txt or /src/main.py). - - Returns: - Resolved absolute filesystem path within root_path. - - Raises: - ValueError: If path contains traversal attempts, escapes root directory, - or violates allowed_prefixes restrictions. - """ - # Normalize path - if not path.startswith("/"): - path = "/" + path - - # Check for path traversal - if ".." in path or "~" in path: - msg = "Path traversal not allowed" - raise ValueError(msg) - - # Convert virtual path to filesystem path - # Remove leading / and resolve relative to root - relative = path.lstrip("/") - full_path = (self.root_path / relative).resolve() - - # Ensure path is within root - try: - full_path.relative_to(self.root_path) - except ValueError: - msg = f"Path outside root directory: {path}" - raise ValueError(msg) from None - - # Check allowed prefixes - virtual_path = "/" + str(full_path.relative_to(self.root_path)) - if self.allowed_prefixes: - allowed = any( - virtual_path.startswith(prefix) or virtual_path == prefix.rstrip("/") - for prefix in self.allowed_prefixes - ) - if not allowed: - msg = f"Path must start with one of: {self.allowed_prefixes}" - raise ValueError(msg) - - return full_path - - def _handle_view(self, args: dict, tool_call_id: str | None) -> Command: - """Handle view command.""" - path = args["path"] - full_path = self._validate_and_resolve_path(path) - - if not full_path.exists() or not full_path.is_file(): - msg = f"File not found: {path}" - raise FileNotFoundError(msg) - - # Check file size - if full_path.stat().st_size > self.max_file_size_bytes: - msg = f"File too large: {path} exceeds {self.max_file_size_bytes / 1024 / 1024}MB" - raise ValueError(msg) - - # Read file - try: - content = full_path.read_text() - except UnicodeDecodeError as e: - msg = f"Cannot decode file {path}: {e}" - raise ValueError(msg) from e - - # Format with line numbers - formatted_content = format_content_with_line_numbers(content, format_style="pipe") - - return Command( - update={ - "messages": [ - ToolMessage( - content=formatted_content, - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ] - } - ) - - def _handle_create(self, args: dict, tool_call_id: str | None) -> Command: - """Handle create command.""" - path = args["path"] - file_text = args["file_text"] - - full_path = self._validate_and_resolve_path(path) - - # Create parent directories - full_path.parent.mkdir(parents=True, exist_ok=True) - - # Write file - full_path.write_text(file_text + "\n") - - return Command( - update={ - "messages": [ - ToolMessage( - content=f"File created: {path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ] - } - ) - - def _handle_str_replace(self, args: dict, tool_call_id: str | None) -> Command: - """Handle str_replace command.""" - path = args["path"] - old_str = args["old_str"] - new_str = args.get("new_str", "") - - full_path = self._validate_and_resolve_path(path) - - if not full_path.exists(): - msg = f"File not found: {path}" - raise FileNotFoundError(msg) - - # Read file - content = full_path.read_text() - - # Replace string - if old_str not in content: - msg = f"String not found in file: {old_str}" - raise ValueError(msg) - new_content, _ = apply_string_replacement(content, old_str, new_str, replace_all=False) - - # Write back - full_path.write_text(new_content) - - return Command( - update={ - "messages": [ - ToolMessage( - content=f"String replaced in {path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ] - } - ) - - def _handle_insert(self, args: dict, tool_call_id: str | None) -> Command: - """Handle insert command.""" - path = args["path"] - insert_line = args["insert_line"] - text_to_insert = args["new_str"] - - full_path = self._validate_and_resolve_path(path) - - if not full_path.exists(): - msg = f"File not found: {path}" - raise FileNotFoundError(msg) - - # Read file - content = full_path.read_text() - lines = content.split("\n") - # Handle trailing newline - if lines and lines[-1] == "": - lines = lines[:-1] - had_trailing_newline = True - else: - had_trailing_newline = False - - new_lines = text_to_insert.split("\n") - - # Insert after insert_line (0-indexed) - updated_lines = lines[:insert_line] + new_lines + lines[insert_line:] - - # Write back - new_content = "\n".join(updated_lines) - if had_trailing_newline: - new_content += "\n" - full_path.write_text(new_content) - - return Command( - update={ - "messages": [ - ToolMessage( - content=f"Text inserted in {path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ] - } - ) - - def _handle_delete(self, args: dict, tool_call_id: str | None) -> Command: - """Handle delete command.""" - import shutil - - path = args["path"] - full_path = self._validate_and_resolve_path(path) - - if full_path.is_file(): - full_path.unlink() - elif full_path.is_dir(): - shutil.rmtree(full_path) - # If doesn't exist, silently succeed - - return Command( - update={ - "messages": [ - ToolMessage( - content=f"File deleted: {path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ] - } - ) - - def _handle_rename(self, args: dict, tool_call_id: str | None) -> Command: - """Handle rename command.""" - old_path = args["old_path"] - new_path = args["new_path"] - - old_full = self._validate_and_resolve_path(old_path) - new_full = self._validate_and_resolve_path(new_path) - - if not old_full.exists(): - msg = f"File not found: {old_path}" - raise ValueError(msg) - - # Create parent directory for new path - new_full.parent.mkdir(parents=True, exist_ok=True) - - # Rename - old_full.rename(new_full) - - return Command( - update={ - "messages": [ - ToolMessage( - content=f"File renamed: {old_path} -> {new_path}", - tool_call_id=tool_call_id, - name=self.tool_name, - ) - ] - } - ) - - -class FilesystemClaudeTextEditorMiddleware(_FilesystemClaudeFileToolMiddleware): - """Filesystem-based text editor tool middleware. - - Provides Anthropic's text_editor tool using local filesystem for storage. - User handles persistence via volumes, git, or other mechanisms. - - Example: - ```python - from langchain.agents import create_agent - from langchain.agents.middleware import FilesystemTextEditorToolMiddleware - - agent = create_agent( - model=model, - tools=[], - middleware=[FilesystemTextEditorToolMiddleware(root_path="/workspace")], - ) - ``` - """ - - def __init__( - self, - *, - root_path: str, - allowed_prefixes: list[str] | None = None, - max_file_size_mb: int = 10, - ) -> None: - """Initialize the text editor middleware. - - Args: - root_path: Root directory for file operations. - allowed_prefixes: Optional list of allowed virtual path prefixes (default: ["/"]). - max_file_size_mb: Maximum file size in MB (default: 10). - """ - super().__init__( - tool_type=TEXT_EDITOR_TOOL_TYPE, - tool_name=TEXT_EDITOR_TOOL_NAME, - root_path=root_path, - allowed_prefixes=allowed_prefixes, - max_file_size_mb=max_file_size_mb, - ) - - -class FilesystemClaudeMemoryMiddleware(_FilesystemClaudeFileToolMiddleware): - """Filesystem-based memory tool middleware. - - Provides Anthropic's memory tool using local filesystem for storage. - User handles persistence via volumes, git, or other mechanisms. - Enforces /memories prefix and injects Anthropic's recommended system prompt. - - Example: - ```python - from langchain.agents import create_agent - from langchain.agents.middleware import FilesystemMemoryToolMiddleware - - agent = create_agent( - model=model, - tools=[], - middleware=[FilesystemMemoryToolMiddleware(root_path="/workspace")], - ) - ``` - """ - - def __init__( - self, - *, - root_path: str, - allowed_prefixes: list[str] | None = None, - max_file_size_mb: int = 10, - system_prompt: str = MEMORY_SYSTEM_PROMPT, - ) -> None: - """Initialize the memory middleware. - - Args: - root_path: Root directory for file operations. - allowed_prefixes: Optional list of allowed virtual path prefixes. - Defaults to ["/memories"]. - max_file_size_mb: Maximum file size in MB (default: 10). - system_prompt: System prompt to inject. Defaults to Anthropic's - recommended memory prompt. - """ - super().__init__( - tool_type=MEMORY_TOOL_TYPE, - tool_name=MEMORY_TOOL_NAME, - root_path=root_path, - allowed_prefixes=allowed_prefixes or ["/memories"], - max_file_size_mb=max_file_size_mb, - system_prompt=system_prompt, - ) - - -__all__ = [ - "AnthropicToolsState", - "FileData", - "FilesystemClaudeMemoryMiddleware", - "FilesystemClaudeTextEditorMiddleware", - "StateClaudeMemoryMiddleware", - "StateClaudeTextEditorMiddleware", -] diff --git a/libs/langchain_v1/langchain/agents/middleware/file_search.py b/libs/langchain_v1/langchain/agents/middleware/file_search.py deleted file mode 100644 index 34d31ddebe54b..0000000000000 --- a/libs/langchain_v1/langchain/agents/middleware/file_search.py +++ /dev/null @@ -1,550 +0,0 @@ -"""File search middleware for Anthropic text editor and memory tools. - -This module provides Glob and Grep search tools that operate on files stored -in state or filesystem. -""" - -from __future__ import annotations - -import fnmatch -import json -import re -import subprocess -from contextlib import suppress -from datetime import datetime, timezone -from pathlib import Path, PurePosixPath -from typing import Annotated, Any, Literal, cast - -from langchain_core.tools import InjectedToolArg, tool - -from langchain.agents.middleware.anthropic_tools import AnthropicToolsState -from langchain.agents.middleware.types import AgentMiddleware - - -class StateFileSearchMiddleware(AgentMiddleware): - """Provides Glob and Grep search over state-based files. - - This middleware adds two tools that search through virtual files in state: - - Glob: Fast file pattern matching by file path - - Grep: Fast content search using regular expressions - - Example: - ```python - from langchain.agents import create_agent - from langchain.agents.middleware import ( - StateTextEditorToolMiddleware, - StateFileSearchMiddleware, - ) - - agent = create_agent( - model=model, - tools=[], - middleware=[ - StateTextEditorToolMiddleware(), - StateFileSearchMiddleware(), - ], - ) - ``` - """ - - state_schema = AnthropicToolsState - - def __init__( - self, - *, - state_key: str = "text_editor_files", - ) -> None: - """Initialize the search middleware. - - Args: - state_key: State key to search (default: "text_editor_files"). - Use "memory_files" to search memory tool files. - """ - self.state_key = state_key - - # Create tool instances - @tool - def glob_search( # noqa: D417 - pattern: str, - path: str = "/", - state: Annotated[AnthropicToolsState, InjectedToolArg] = None, # type: ignore[assignment] - ) -> str: - """Fast file pattern matching tool that works with any codebase size. - - Supports glob patterns like **/*.js or src/**/*.ts. - Returns matching file paths sorted by modification time. - Use this tool when you need to find files by name patterns. - - Args: - pattern: The glob pattern to match files against. - path: The directory to search in. If not specified, searches from root. - - Returns: - Newline-separated list of matching file paths, sorted by modification - time (most recently modified first). Returns "No files found" if no - matches. - """ - # Normalize base path - base_path = path if path.startswith("/") else "/" + path - - # Get files from state - files = cast("dict[str, Any]", state.get(self.state_key, {})) - - # Match files - matches = [] - for file_path, file_data in files.items(): - if file_path.startswith(base_path): - # Get relative path from base - if base_path == "/": - relative = file_path[1:] # Remove leading / - elif file_path == base_path: - relative = Path(file_path).name - elif file_path.startswith(base_path + "/"): - relative = file_path[len(base_path) + 1 :] - else: - continue - - # Match against pattern - # Handle ** pattern which requires special care - # PurePosixPath.match doesn't match single-level paths against **/pattern - is_match = PurePosixPath(relative).match(pattern) - if not is_match and pattern.startswith("**/"): - # Also try matching without the **/ prefix for files in base dir - is_match = PurePosixPath(relative).match(pattern[3:]) - - if is_match: - matches.append((file_path, file_data["modified_at"])) - - if not matches: - return "No files found" - - # Sort by modification time - matches.sort(key=lambda x: x[1], reverse=True) - file_paths = [path for path, _ in matches] - - return "\n".join(file_paths) - - @tool - def grep_search( # noqa: D417 - pattern: str, - path: str = "/", - include: str | None = None, - output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches", - state: Annotated[AnthropicToolsState, InjectedToolArg] = None, # type: ignore[assignment] - ) -> str: - """Fast content search tool that works with any codebase size. - - Searches file contents using regular expressions. Supports full regex - syntax and filters files by pattern with the include parameter. - - Args: - pattern: The regular expression pattern to search for in file contents. - path: The directory to search in. If not specified, searches from root. - include: File pattern to filter (e.g., "*.js", "*.{ts,tsx}"). - output_mode: Output format: - - "files_with_matches": Only file paths containing matches (default) - - "content": Matching lines with file:line:content format - - "count": Count of matches per file - - Returns: - Search results formatted according to output_mode. Returns "No matches - found" if no results. - """ - # Normalize base path - base_path = path if path.startswith("/") else "/" + path - - # Compile regex pattern (for validation) - try: - regex = re.compile(pattern) - except re.error as e: - return f"Invalid regex pattern: {e}" - - # Search files - files = cast("dict[str, Any]", state.get(self.state_key, {})) - results: dict[str, list[tuple[int, str]]] = {} - - for file_path, file_data in files.items(): - if not file_path.startswith(base_path): - continue - - # Check include filter - if include: - basename = Path(file_path).name - if not self._match_include(basename, include): - continue - - # Search file content - for line_num, line in enumerate(file_data["content"], 1): - if regex.search(line): - if file_path not in results: - results[file_path] = [] - results[file_path].append((line_num, line)) - - if not results: - return "No matches found" - - # Format output based on mode - return self._format_grep_results(results, output_mode) - - self.glob_search = glob_search - self.grep_search = grep_search - self.tools = [glob_search, grep_search] - - def _match_include(self, basename: str, pattern: str) -> bool: - """Match filename against include pattern.""" - # Handle brace expansion {a,b,c} - if "{" in pattern and "}" in pattern: - start = pattern.index("{") - end = pattern.index("}") - prefix = pattern[:start] - suffix = pattern[end + 1 :] - alternatives = pattern[start + 1 : end].split(",") - - for alt in alternatives: - expanded = prefix + alt + suffix - if fnmatch.fnmatch(basename, expanded): - return True - return False - return fnmatch.fnmatch(basename, pattern) - - def _format_grep_results( - self, - results: dict[str, list[tuple[int, str]]], - output_mode: str, - ) -> str: - """Format grep results based on output mode.""" - if output_mode == "files_with_matches": - # Just return file paths - return "\n".join(sorted(results.keys())) - - if output_mode == "content": - # Return file:line:content format - lines = [] - for file_path in sorted(results.keys()): - for line_num, line in results[file_path]: - lines.append(f"{file_path}:{line_num}:{line}") - return "\n".join(lines) - - if output_mode == "count": - # Return file:count format - lines = [] - for file_path in sorted(results.keys()): - count = len(results[file_path]) - lines.append(f"{file_path}:{count}") - return "\n".join(lines) - - # Default to files_with_matches - return "\n".join(sorted(results.keys())) - - -class FilesystemFileSearchMiddleware(AgentMiddleware): - """Provides Glob and Grep search over filesystem files. - - This middleware adds two tools that search through local filesystem: - - Glob: Fast file pattern matching by file path - - Grep: Fast content search using ripgrep or Python fallback - - Example: - ```python - from langchain.agents import create_agent - from langchain.agents.middleware import ( - FilesystemTextEditorToolMiddleware, - FilesystemFileSearchMiddleware, - ) - - agent = create_agent( - model=model, - tools=[], - middleware=[ - FilesystemTextEditorToolMiddleware(root_path="/workspace"), - FilesystemFileSearchMiddleware(root_path="/workspace"), - ], - ) - ``` - """ - - def __init__( - self, - *, - root_path: str, - use_ripgrep: bool = True, - max_file_size_mb: int = 10, - ) -> None: - """Initialize the search middleware. - - Args: - root_path: Root directory to search. - use_ripgrep: Whether to use ripgrep for search (default: True). - Falls back to Python if ripgrep unavailable. - max_file_size_mb: Maximum file size to search in MB (default: 10). - """ - self.root_path = Path(root_path).resolve() - self.use_ripgrep = use_ripgrep - self.max_file_size_bytes = max_file_size_mb * 1024 * 1024 - - # Create tool instances as closures that capture self - @tool - def glob_search(pattern: str, path: str = "/") -> str: - """Fast file pattern matching tool that works with any codebase size. - - Supports glob patterns like **/*.js or src/**/*.ts. - Returns matching file paths sorted by modification time. - Use this tool when you need to find files by name patterns. - - Args: - pattern: The glob pattern to match files against. - path: The directory to search in. If not specified, searches from root. - - Returns: - Newline-separated list of matching file paths, sorted by modification - time (most recently modified first). Returns "No files found" if no - matches. - """ - try: - base_full = self._validate_and_resolve_path(path) - except ValueError: - return "No files found" - - if not base_full.exists() or not base_full.is_dir(): - return "No files found" - - # Use pathlib glob - matching: list[tuple[str, str]] = [] - for match in base_full.glob(pattern): - if match.is_file(): - # Convert to virtual path - virtual_path = "/" + str(match.relative_to(self.root_path)) - stat = match.stat() - modified_at = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() - matching.append((virtual_path, modified_at)) - - if not matching: - return "No files found" - - file_paths = [p for p, _ in matching] - return "\n".join(file_paths) - - @tool - def grep_search( - pattern: str, - path: str = "/", - include: str | None = None, - output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches", - ) -> str: - """Fast content search tool that works with any codebase size. - - Searches file contents using regular expressions. Supports full regex - syntax and filters files by pattern with the include parameter. - - Args: - pattern: The regular expression pattern to search for in file contents. - path: The directory to search in. If not specified, searches from root. - include: File pattern to filter (e.g., "*.js", "*.{ts,tsx}"). - output_mode: Output format: - - "files_with_matches": Only file paths containing matches (default) - - "content": Matching lines with file:line:content format - - "count": Count of matches per file - - Returns: - Search results formatted according to output_mode. Returns "No matches - found" if no results. - """ - # Compile regex pattern (for validation) - try: - re.compile(pattern) - except re.error as e: - return f"Invalid regex pattern: {e}" - - # Try ripgrep first if enabled - results = None - if self.use_ripgrep: - with suppress( - FileNotFoundError, - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - ): - results = self._ripgrep_search(pattern, path, include) - - # Python fallback if ripgrep failed or is disabled - if results is None: - results = self._python_search(pattern, path, include) - - if not results: - return "No matches found" - - # Format output based on mode - return self._format_grep_results(results, output_mode) - - self.glob_search = glob_search - self.grep_search = grep_search - self.tools = [glob_search, grep_search] - - def _validate_and_resolve_path(self, path: str) -> Path: - """Validate and resolve a virtual path to filesystem path.""" - # Normalize path - if not path.startswith("/"): - path = "/" + path - - # Check for path traversal - if ".." in path or "~" in path: - msg = "Path traversal not allowed" - raise ValueError(msg) - - # Convert virtual path to filesystem path - relative = path.lstrip("/") - full_path = (self.root_path / relative).resolve() - - # Ensure path is within root - try: - full_path.relative_to(self.root_path) - except ValueError: - msg = f"Path outside root directory: {path}" - raise ValueError(msg) from None - - return full_path - - def _ripgrep_search( - self, pattern: str, base_path: str, include: str | None - ) -> dict[str, list[tuple[int, str]]]: - """Search using ripgrep subprocess.""" - try: - base_full = self._validate_and_resolve_path(base_path) - except ValueError: - return {} - - if not base_full.exists(): - return {} - - # Build ripgrep command - cmd = ["rg", "--json", pattern, str(base_full)] - - if include: - # Convert glob pattern to ripgrep glob - cmd.extend(["--glob", include]) - - try: - result = subprocess.run( # noqa: S603 - cmd, - capture_output=True, - text=True, - timeout=30, - check=False, - ) - except (subprocess.TimeoutExpired, FileNotFoundError): - # Fallback to Python search if ripgrep unavailable or times out - return self._python_search(pattern, base_path, include) - - # Parse ripgrep JSON output - results: dict[str, list[tuple[int, str]]] = {} - for line in result.stdout.splitlines(): - try: - data = json.loads(line) - if data["type"] == "match": - path = data["data"]["path"]["text"] - # Convert to virtual path - virtual_path = "/" + str(Path(path).relative_to(self.root_path)) - line_num = data["data"]["line_number"] - line_text = data["data"]["lines"]["text"].rstrip("\n") - - if virtual_path not in results: - results[virtual_path] = [] - results[virtual_path].append((line_num, line_text)) - except (json.JSONDecodeError, KeyError): - continue - - return results - - def _python_search( - self, pattern: str, base_path: str, include: str | None - ) -> dict[str, list[tuple[int, str]]]: - """Search using Python regex (fallback).""" - try: - base_full = self._validate_and_resolve_path(base_path) - except ValueError: - return {} - - if not base_full.exists(): - return {} - - regex = re.compile(pattern) - results: dict[str, list[tuple[int, str]]] = {} - - # Walk directory tree - for file_path in base_full.rglob("*"): - if not file_path.is_file(): - continue - - # Check include filter - if include and not self._match_include(file_path.name, include): - continue - - # Skip files that are too large - if file_path.stat().st_size > self.max_file_size_bytes: - continue - - try: - content = file_path.read_text() - except (UnicodeDecodeError, PermissionError): - continue - - # Search content - for line_num, line in enumerate(content.splitlines(), 1): - if regex.search(line): - virtual_path = "/" + str(file_path.relative_to(self.root_path)) - if virtual_path not in results: - results[virtual_path] = [] - results[virtual_path].append((line_num, line)) - - return results - - def _match_include(self, basename: str, pattern: str) -> bool: - """Match filename against include pattern.""" - # Handle brace expansion {a,b,c} - if "{" in pattern and "}" in pattern: - start = pattern.index("{") - end = pattern.index("}") - prefix = pattern[:start] - suffix = pattern[end + 1 :] - alternatives = pattern[start + 1 : end].split(",") - - for alt in alternatives: - expanded = prefix + alt + suffix - if fnmatch.fnmatch(basename, expanded): - return True - return False - return fnmatch.fnmatch(basename, pattern) - - def _format_grep_results( - self, - results: dict[str, list[tuple[int, str]]], - output_mode: str, - ) -> str: - """Format grep results based on output mode.""" - if output_mode == "files_with_matches": - # Just return file paths - return "\n".join(sorted(results.keys())) - - if output_mode == "content": - # Return file:line:content format - lines = [] - for file_path in sorted(results.keys()): - for line_num, line in results[file_path]: - lines.append(f"{file_path}:{line_num}:{line}") - return "\n".join(lines) - - if output_mode == "count": - # Return file:count format - lines = [] - for file_path in sorted(results.keys()): - count = len(results[file_path]) - lines.append(f"{file_path}:{count}") - return "\n".join(lines) - - # Default to files_with_matches - return "\n".join(sorted(results.keys())) - - -__all__ = [ - "FilesystemFileSearchMiddleware", - "StateFileSearchMiddleware", -] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py deleted file mode 100644 index 7e871c69aad36..0000000000000 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_anthropic_tools.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Unit tests for Anthropic text editor and memory tool middleware.""" - -import pytest -from langchain.agents.middleware.anthropic_tools import ( - AnthropicToolsState, - StateClaudeMemoryMiddleware, - StateClaudeTextEditorMiddleware, -) -from langchain.agents._internal.file_utils import validate_path -from langchain_core.messages import ToolMessage -from langgraph.types import Command - - -class TestPathValidation: - """Test path validation and security.""" - - def test_basic_path_normalization(self) -> None: - """Test basic path normalization.""" - assert validate_path("/foo/bar") == "/foo/bar" - assert validate_path("foo/bar") == "/foo/bar" - assert validate_path("/foo//bar") == "/foo/bar" - assert validate_path("/foo/./bar") == "/foo/bar" - - def test_path_traversal_blocked(self) -> None: - """Test that path traversal attempts are blocked.""" - with pytest.raises(ValueError, match="Path traversal not allowed"): - validate_path("/foo/../etc/passwd") - - with pytest.raises(ValueError, match="Path traversal not allowed"): - validate_path("../etc/passwd") - - with pytest.raises(ValueError, match="Path traversal not allowed"): - validate_path("~/.ssh/id_rsa") - - def test_allowed_prefixes(self) -> None: - """Test path prefix validation.""" - # Should pass - assert ( - validate_path("/workspace/file.txt", allowed_prefixes=["/workspace"]) - == "/workspace/file.txt" - ) - - # Should fail - with pytest.raises(ValueError, match="Path must start with"): - validate_path("/etc/passwd", allowed_prefixes=["/workspace"]) - - with pytest.raises(ValueError, match="Path must start with"): - validate_path("/workspacemalicious/file.txt", allowed_prefixes=["/workspace/"]) - - def test_memories_prefix(self) -> None: - """Test /memories prefix validation for memory tools.""" - assert ( - validate_path("/memories/notes.txt", allowed_prefixes=["/memories"]) - == "/memories/notes.txt" - ) - - with pytest.raises(ValueError, match="Path must start with"): - validate_path("/other/notes.txt", allowed_prefixes=["/memories"]) - - -class TestTextEditorMiddleware: - """Test text editor middleware functionality.""" - - def test_middleware_initialization(self) -> None: - """Test middleware initializes correctly.""" - middleware = StateClaudeTextEditorMiddleware() - assert middleware.state_schema == AnthropicToolsState - assert middleware.tool_type == "text_editor_20250728" - assert middleware.tool_name == "str_replace_based_edit_tool" - assert middleware.state_key == "text_editor_files" - - # With path restrictions - middleware = StateClaudeTextEditorMiddleware(allowed_path_prefixes=["/workspace"]) - assert middleware.allowed_prefixes == ["/workspace"] - - -class TestMemoryMiddleware: - """Test memory middleware functionality.""" - - def test_middleware_initialization(self) -> None: - """Test middleware initializes correctly.""" - middleware = StateClaudeMemoryMiddleware() - assert middleware.state_schema == AnthropicToolsState - assert middleware.tool_type == "memory_20250818" - assert middleware.tool_name == "memory" - assert middleware.state_key == "memory_files" - assert middleware.system_prompt # Should have default prompt - - def test_custom_system_prompt(self) -> None: - """Test custom system prompt can be set.""" - custom_prompt = "Custom memory instructions" - middleware = StateClaudeMemoryMiddleware(system_prompt=custom_prompt) - assert middleware.system_prompt == custom_prompt - - -class TestFileOperations: - """Test file operation implementations via wrap_tool_call.""" - - def test_view_operation(self) -> None: - """Test view command execution.""" - middleware = StateClaudeTextEditorMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/test.txt": { - "content": ["line1", "line2", "line3"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - } - }, - } - - args = {"command": "view", "path": "/test.txt"} - result = middleware._handle_view(args, state, "test_id") - - assert isinstance(result, Command) - assert result.update is not None - messages = result.update.get("messages", []) - assert len(messages) == 1 - assert isinstance(messages[0], ToolMessage) - assert messages[0].content == "1|line1\n2|line2\n3|line3" - assert messages[0].tool_call_id == "test_id" - - def test_create_operation(self) -> None: - """Test create command execution.""" - middleware = StateClaudeTextEditorMiddleware() - - state: AnthropicToolsState = {"messages": []} - - args = {"command": "create", "path": "/test.txt", "file_text": "line1\nline2"} - result = middleware._handle_create(args, state, "test_id") - - assert isinstance(result, Command) - assert result.update is not None - files = result.update.get("text_editor_files", {}) - assert "/test.txt" in files - assert files["/test.txt"]["content"] == ["line1", "line2"] - - def test_path_prefix_enforcement(self) -> None: - """Test that path prefixes are enforced.""" - middleware = StateClaudeTextEditorMiddleware(allowed_path_prefixes=["/workspace"]) - - state: AnthropicToolsState = {"messages": []} - - # Should fail with /etc/passwd - args = {"command": "create", "path": "/etc/passwd", "file_text": "test"} - - try: - middleware._handle_create(args, state, "test_id") - assert False, "Should have raised ValueError" - except ValueError as e: - assert "Path must start with" in str(e) - - def test_memories_prefix_enforcement(self) -> None: - """Test that /memories prefix is enforced for memory middleware.""" - middleware = StateClaudeMemoryMiddleware() - - state: AnthropicToolsState = {"messages": []} - - # Should fail with /other/path - args = {"command": "create", "path": "/other/path.txt", "file_text": "test"} - - try: - middleware._handle_create(args, state, "test_id") - assert False, "Should have raised ValueError" - except ValueError as e: - assert "/memories" in str(e) - - def test_str_replace_operation(self) -> None: - """Test str_replace command execution.""" - middleware = StateClaudeTextEditorMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/test.txt": { - "content": ["Hello world", "Goodbye world"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - } - }, - } - - args = { - "command": "str_replace", - "path": "/test.txt", - "old_str": "world", - "new_str": "universe", - } - result = middleware._handle_str_replace(args, state, "test_id") - - assert isinstance(result, Command) - files = result.update.get("text_editor_files", {}) - # Should only replace first occurrence - assert files["/test.txt"]["content"] == ["Hello universe", "Goodbye world"] - - def test_insert_operation(self) -> None: - """Test insert command execution.""" - middleware = StateClaudeTextEditorMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/test.txt": { - "content": ["line1", "line2"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - } - }, - } - - args = { - "command": "insert", - "path": "/test.txt", - "insert_line": 0, - "new_str": "inserted", - } - result = middleware._handle_insert(args, state, "test_id") - - assert isinstance(result, Command) - files = result.update.get("text_editor_files", {}) - assert files["/test.txt"]["content"] == ["inserted", "line1", "line2"] - - def test_delete_operation(self) -> None: - """Test delete command execution (memory only).""" - middleware = StateClaudeMemoryMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "memory_files": { - "/memories/test.txt": { - "content": ["line1"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - } - }, - } - - args = {"command": "delete", "path": "/memories/test.txt"} - result = middleware._handle_delete(args, state, "test_id") - - assert isinstance(result, Command) - files = result.update.get("memory_files", {}) - # Deleted files are marked as None in state - assert files.get("/memories/test.txt") is None - - def test_rename_operation(self) -> None: - """Test rename command execution (memory only).""" - middleware = StateClaudeMemoryMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "memory_files": { - "/memories/old.txt": { - "content": ["line1"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - } - }, - } - - args = { - "command": "rename", - "old_path": "/memories/old.txt", - "new_path": "/memories/new.txt", - } - result = middleware._handle_rename(args, state, "test_id") - - assert isinstance(result, Command) - files = result.update.get("memory_files", {}) - # Old path is marked as None (deleted) - assert files.get("/memories/old.txt") is None - # New path has the file data - assert files.get("/memories/new.txt") is not None - assert files["/memories/new.txt"]["content"] == ["line1"] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_file_search.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_file_search.py deleted file mode 100644 index c659ca7840be5..0000000000000 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_file_search.py +++ /dev/null @@ -1,461 +0,0 @@ -"""Unit tests for file search middleware.""" - -import pytest -from langchain.agents.middleware.anthropic_tools import AnthropicToolsState -from langchain.agents.middleware.file_search import StateFileSearchMiddleware -from langchain_core.messages import ToolMessage - - -class TestSearchMiddlewareInitialization: - """Test search middleware initialization.""" - - def test_middleware_initialization(self) -> None: - """Test middleware initializes correctly.""" - middleware = StateFileSearchMiddleware() - assert middleware.state_schema == AnthropicToolsState - assert middleware.state_key == "text_editor_files" - - def test_custom_state_key(self) -> None: - """Test middleware with custom state key.""" - middleware = StateFileSearchMiddleware(state_key="memory_files") - assert middleware.state_key == "memory_files" - - -class TestGlobSearch: - """Test Glob file pattern matching.""" - - def test_glob_basic_pattern(self) -> None: - """Test basic glob pattern matching.""" - middleware = StateFileSearchMiddleware() - - test_state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["print('hello')"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/src/utils.py": { - "content": ["def helper(): pass"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/README.md": { - "content": ["# Project"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - # Call tool function directly (state is injected in real usage) - result = middleware.glob_search.func(pattern="*.py", state=test_state) - - assert isinstance(result, str) - assert "/src/main.py" in result - assert "/src/utils.py" in result - assert "/README.md" not in result - - def test_glob_recursive_pattern(self) -> None: - """Test recursive glob pattern matching.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/src/utils/helper.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/tests/test_main.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.glob_search.func(pattern="**/*.py", state=state) - - assert isinstance(result, str) - lines = result.split("\n") - assert len(lines) == 3 - assert all(".py" in line for line in lines) - - def test_glob_with_base_path(self) -> None: - """Test glob with base path restriction.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/tests/test.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.glob_search.func(pattern="**/*.py", path="/src", state=state) - - assert isinstance(result, str) - assert "/src/main.py" in result - assert "/tests/test.py" not in result - - def test_glob_no_matches(self) -> None: - """Test glob with no matching files.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.glob_search.func(pattern="*.ts", state=state) - - assert isinstance(result, str) - assert result == "No files found" - - def test_glob_sorts_by_modified_time(self) -> None: - """Test that glob results are sorted by modification time.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/old.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/new.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-02T00:00:00", - }, - }, - } - - result = middleware.glob_search.func(pattern="*.py", state=state) - - lines = result.split("\n") - # Most recent first - assert lines[0] == "/new.py" - assert lines[1] == "/old.py" - - -class TestGrepSearch: - """Test Grep content search.""" - - def test_grep_files_with_matches_mode(self) -> None: - """Test grep with files_with_matches output mode.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["def foo():", " pass"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/src/utils.py": { - "content": ["def bar():", " return None"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/README.md": { - "content": ["# Documentation", "No code here"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func(pattern=r"def \w+\(\):", state=state) - - assert isinstance(result, str) - assert "/src/main.py" in result - assert "/src/utils.py" in result - assert "/README.md" not in result - # Should only have file paths, not line content - assert "def foo():" not in result - - def test_grep_content_mode(self) -> None: - """Test grep with content output mode.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["def foo():", " pass", "def bar():"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func( - pattern=r"def \w+\(\):", output_mode="content", state=state - ) - - assert isinstance(result, str) - lines = result.split("\n") - assert len(lines) == 2 - assert lines[0] == "/src/main.py:1:def foo():" - assert lines[1] == "/src/main.py:3:def bar():" - - def test_grep_count_mode(self) -> None: - """Test grep with count output mode.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["TODO: fix this", "print('hello')", "TODO: add tests"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/src/utils.py": { - "content": ["TODO: implement"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func(pattern=r"TODO", output_mode="count", state=state) - - assert isinstance(result, str) - lines = result.split("\n") - assert "/src/main.py:2" in lines - assert "/src/utils.py:1" in lines - - def test_grep_with_include_filter(self) -> None: - """Test grep with include file pattern filter.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["import os"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/src/main.ts": { - "content": ["import os from 'os'"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func(pattern="import", include="*.py", state=state) - - assert isinstance(result, str) - assert "/src/main.py" in result - assert "/src/main.ts" not in result - - def test_grep_with_brace_expansion_filter(self) -> None: - """Test grep with brace expansion in include filter.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.ts": { - "content": ["const x = 1"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/src/App.tsx": { - "content": ["const y = 2"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/src/main.py": { - "content": ["z = 3"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func(pattern="const", include="*.{ts,tsx}", state=state) - - assert isinstance(result, str) - assert "/src/main.ts" in result - assert "/src/App.tsx" in result - assert "/src/main.py" not in result - - def test_grep_with_base_path(self) -> None: - """Test grep with base path restriction.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["import foo"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - "/tests/test.py": { - "content": ["import foo"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func(pattern="import", path="/src", state=state) - - assert isinstance(result, str) - assert "/src/main.py" in result - assert "/tests/test.py" not in result - - def test_grep_no_matches(self) -> None: - """Test grep with no matching content.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["print('hello')"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func(pattern=r"TODO", state=state) - - assert isinstance(result, str) - assert result == "No matches found" - - def test_grep_invalid_regex(self) -> None: - """Test grep with invalid regex pattern.""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": {}, - } - - result = middleware.grep_search.func(pattern=r"[unclosed", state=state) - - assert isinstance(result, str) - assert "Invalid regex pattern" in result - - -class TestSearchWithDifferentBackends: - """Test searching with different backend configurations.""" - - def test_glob_default_backend(self) -> None: - """Test that glob searches the default backend (text_editor_files).""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - "memory_files": { - "/memories/notes.txt": { - "content": [], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.glob_search.func(pattern="**/*", state=state) - - assert isinstance(result, str) - assert "/src/main.py" in result - # Should NOT find memory_files since default backend is text_editor_files - assert "/memories/notes.txt" not in result - - def test_grep_default_backend(self) -> None: - """Test that grep searches the default backend (text_editor_files).""" - middleware = StateFileSearchMiddleware() - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["TODO: implement"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - "memory_files": { - "/memories/tasks.txt": { - "content": ["TODO: review"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func(pattern=r"TODO", state=state) - - assert isinstance(result, str) - assert "/src/main.py" in result - # Should NOT find memory_files since default backend is text_editor_files - assert "/memories/tasks.txt" not in result - - def test_search_with_single_store(self) -> None: - """Test searching with a specific state key.""" - middleware = StateFileSearchMiddleware(state_key="text_editor_files") - - state: AnthropicToolsState = { - "messages": [], - "text_editor_files": { - "/src/main.py": { - "content": ["code"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - "memory_files": { - "/memories/notes.txt": { - "content": ["notes"], - "created_at": "2025-01-01T00:00:00", - "modified_at": "2025-01-01T00:00:00", - }, - }, - } - - result = middleware.grep_search.func(pattern=r".*", state=state) - - assert isinstance(result, str) - assert "/src/main.py" in result - assert "/memories/notes.txt" not in result From 6677d57cddc7807bc0b8e7f516230f6b47d4eeb3 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 15:15:10 -0400 Subject: [PATCH 16/22] HITL changes --- .../langchain/agents/middleware/__init__.py | 9 ++++----- .../langchain/agents/middleware/human_in_the_loop.py | 10 +++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/__init__.py b/libs/langchain_v1/langchain/agents/middleware/__init__.py index 2b0ad2d7bbc47..bbe154e4d68ff 100644 --- a/libs/langchain_v1/langchain/agents/middleware/__init__.py +++ b/libs/langchain_v1/langchain/agents/middleware/__init__.py @@ -5,14 +5,14 @@ ContextEditingMiddleware, ) from .filesystem import FilesystemMiddleware -from .model_call_limit import ModelCallLimitMiddleware -from .model_fallback import ModelFallbackMiddleware -from .pii import PIIDetectionError, PIIMiddleware -from .subagents import SubAgentMiddleware from .human_in_the_loop import ( HumanInTheLoopMiddleware, InterruptOnConfig, ) +from .model_call_limit import ModelCallLimitMiddleware +from .model_fallback import ModelFallbackMiddleware +from .pii import PIIDetectionError, PIIMiddleware +from .subagents import SubAgentMiddleware from .summarization import SummarizationMiddleware from .todo import TodoListMiddleware from .tool_call_limit import ToolCallLimitMiddleware @@ -49,7 +49,6 @@ "ModelResponse", "PIIDetectionError", "PIIMiddleware", - "PlanningMiddleware", "SubAgentMiddleware", "SummarizationMiddleware", "TodoListMiddleware", diff --git a/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py b/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py index a29b23e7ec620..9fffb7ba2a0c1 100644 --- a/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py +++ b/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py @@ -180,14 +180,14 @@ def __init__( """ super().__init__() resolved_configs: dict[str, InterruptOnConfig] = {} - for tool_name, tool_config in interrupt_on.items(): - if isinstance(tool_config, bool): - if tool_config is True: + for tool_name, interrupt_on_config in interrupt_on.items(): + if isinstance(interrupt_on_config, bool): + if interrupt_on_config is True: resolved_configs[tool_name] = InterruptOnConfig( allowed_decisions=["approve", "edit", "reject"] ) - elif tool_config.get("allowed_decisions"): - resolved_configs[tool_name] = tool_config + elif interrupt_on_config.get("allowed_decisions"): + resolved_configs[tool_name] = interrupt_on_config self.interrupt_on = resolved_configs self.description_prefix = description_prefix From 83e7c4ea746b9bbb6ac412b2e37db776f67c4ae7 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 15:22:31 -0400 Subject: [PATCH 17/22] linting, renaming --- .../langchain/agents/middleware/__init__.py | 2 +- .../langchain/agents/middleware/filesystem.py | 13 ++++++---- .../langchain/agents/middleware/subagents.py | 24 ++++++++++++------- .../middleware/{todo.py => todo_list.py} | 0 .../agents/test_middleware_agent.py | 2 +- .../unit_tests/agents/test_todo_middleware.py | 2 +- 6 files changed, 28 insertions(+), 15 deletions(-) rename libs/langchain_v1/langchain/agents/middleware/{todo.py => todo_list.py} (100%) diff --git a/libs/langchain_v1/langchain/agents/middleware/__init__.py b/libs/langchain_v1/langchain/agents/middleware/__init__.py index bbe154e4d68ff..a565a8abbdeee 100644 --- a/libs/langchain_v1/langchain/agents/middleware/__init__.py +++ b/libs/langchain_v1/langchain/agents/middleware/__init__.py @@ -14,7 +14,7 @@ from .pii import PIIDetectionError, PIIMiddleware from .subagents import SubAgentMiddleware from .summarization import SummarizationMiddleware -from .todo import TodoListMiddleware +from .todo_list import TodoListMiddleware from .tool_call_limit import ToolCallLimitMiddleware from .tool_emulator import LLMToolEmulator from .tool_selection import LLMToolSelectorMiddleware diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py index ec4ddaa7e5666..df54d6d4ab567 100644 --- a/libs/langchain_v1/langchain/agents/middleware/filesystem.py +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from langgraph.runtime import Runtime -from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.messages import ToolMessage from langchain_core.tools import BaseTool, InjectedToolCallId, tool from langgraph.config import get_config from langgraph.runtime import Runtime, get_runtime @@ -29,7 +29,12 @@ update_file_data, validate_path, ) -from langchain.agents.middleware.types import AgentMiddleware, AgentState, ModelRequest +from langchain.agents.middleware.types import ( + AgentMiddleware, + AgentState, + ModelRequest, + ModelResponse, +) from langchain.tools.tool_node import InjectedState @@ -526,8 +531,8 @@ def before_model_call(self, request: ModelRequest, runtime: Runtime[Any]) -> Mod def wrap_model_call( self, request: ModelRequest, - handler: Callable[[ModelRequest], AIMessage], - ) -> AIMessage: + handler: Callable[[ModelRequest], ModelResponse], + ) -> ModelResponse: """Update the system prompt to include instructions on using the filesystem.""" if self.system_prompt_extension is not None: request.system_prompt = ( diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index 8e925dd4b7c6a..125567ecd60aa 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -4,19 +4,23 @@ from typing import Annotated, Any, TypedDict, cast from langchain_core.language_models import BaseChatModel -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langchain_core.messages import HumanMessage, ToolMessage from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool, tool from langgraph.types import Command from typing_extensions import NotRequired from langchain.agents.middleware.filesystem import FilesystemMiddleware -from langchain.agents.middleware.planning import PlanningMiddleware -from langchain.agents.middleware.prompt_caching import AnthropicPromptCachingMiddleware from langchain.agents.middleware.summarization import SummarizationMiddleware -from langchain.agents.middleware.types import AgentMiddleware, ModelRequest +from langchain.agents.middleware.todo_list import TodoListMiddleware +from langchain.agents.middleware.types import AgentMiddleware, ModelRequest, ModelResponse from langchain.tools import InjectedState, InjectedToolCallId +try: + from langchain_anthropic.middleware.prompt_caching import AnthropicPromptCachingMiddleware +except ImportError: + AnthropicPromptCachingMiddleware = None + class SubAgent(TypedDict): """A subagent constructed with user-defined parameters.""" @@ -178,16 +182,20 @@ def _get_subagents( from langchain.agents.factory import create_agent default_subagent_middleware = [ - PlanningMiddleware(), + TodoListMiddleware(), FilesystemMiddleware(), SummarizationMiddleware( model=default_subagent_model, max_tokens_before_summary=120000, messages_to_keep=20, ), - AnthropicPromptCachingMiddleware(ttl="5m", unsupported_model_behavior="ignore"), ] + if AnthropicPromptCachingMiddleware is not None: + default_subagent_middleware.append( + AnthropicPromptCachingMiddleware(ttl="5m", unsupported_model_behavior="ignore") + ) + # Create the general-purpose subagent general_purpose_subagent = create_agent( model=default_subagent_model, @@ -351,8 +359,8 @@ def __init__( def wrap_model_call( self, request: ModelRequest, - handler: Callable[[ModelRequest], AIMessage], - ) -> AIMessage: + handler: Callable[[ModelRequest], ModelResponse], + ) -> ModelResponse: """Update the system prompt to include instructions on using subagents.""" if self.system_prompt_extension is not None: request.system_prompt = ( diff --git a/libs/langchain_v1/langchain/agents/middleware/todo.py b/libs/langchain_v1/langchain/agents/middleware/todo_list.py similarity index 100% rename from libs/langchain_v1/langchain/agents/middleware/todo.py rename to libs/langchain_v1/langchain/agents/middleware/todo_list.py diff --git a/libs/langchain_v1/tests/unit_tests/agents/test_middleware_agent.py b/libs/langchain_v1/tests/unit_tests/agents/test_middleware_agent.py index 05f20bee1d3f2..8c9e42a6dba70 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/test_middleware_agent.py +++ b/libs/langchain_v1/tests/unit_tests/agents/test_middleware_agent.py @@ -32,7 +32,7 @@ Action, HumanInTheLoopMiddleware, ) -from langchain.agents.middleware.todo import ( +from langchain.agents.middleware.todo_list import ( TodoListMiddleware, PlanningState, WRITE_TODOS_SYSTEM_PROMPT, diff --git a/libs/langchain_v1/tests/unit_tests/agents/test_todo_middleware.py b/libs/langchain_v1/tests/unit_tests/agents/test_todo_middleware.py index 5f96855d4dac6..1e7ba91f6dc41 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/test_todo_middleware.py +++ b/libs/langchain_v1/tests/unit_tests/agents/test_todo_middleware.py @@ -7,7 +7,7 @@ from langchain_core.language_models.fake_chat_models import GenericFakeChatModel from langchain_core.messages import AIMessage -from langchain.agents.middleware.todo import TodoListMiddleware +from langchain.agents.middleware.todo_list import TodoListMiddleware from langchain.agents.middleware.types import ModelRequest, ModelResponse from langgraph.runtime import Runtime From d642285c5adc4955562d2ea7c484ba965af158eb Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 15:49:43 -0400 Subject: [PATCH 18/22] removing is_async --- .../langchain/agents/middleware/subagents.py | 98 +++++++++---------- 1 file changed, 47 insertions(+), 51 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index 125567ecd60aa..e189e0ac60ff3 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -6,7 +6,7 @@ from langchain_core.language_models import BaseChatModel from langchain_core.messages import HumanMessage, ToolMessage from langchain_core.runnables import Runnable -from langchain_core.tools import BaseTool, tool +from langchain_core.tools import StructuredTool from langgraph.types import Command from typing_extensions import NotRequired @@ -14,7 +14,7 @@ from langchain.agents.middleware.summarization import SummarizationMiddleware from langchain.agents.middleware.todo_list import TodoListMiddleware from langchain.agents.middleware.types import AgentMiddleware, ModelRequest, ModelResponse -from langchain.tools import InjectedState, InjectedToolCallId +from langchain.tools import BaseTool, InjectedState, InjectedToolCallId try: from langchain_anthropic.middleware.prompt_caching import AnthropicPromptCachingMiddleware @@ -234,8 +234,6 @@ def _create_task_tool( default_subagent_model: str | BaseChatModel, default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]], subagents: list[SubAgent | CompiledSubAgent], - *, - is_async: bool = False, ) -> BaseTool: subagent_graphs, subagent_descriptions = _get_subagents( default_subagent_model, default_subagent_tools, subagents @@ -254,50 +252,51 @@ def _return_command_with_state_update(result: dict, tool_call_id: str) -> Comman ) task_tool_description = TASK_TOOL_DESCRIPTION.format(other_agents=subagent_description_str) - if is_async: - - @tool(description=task_tool_description) - async def task( - description: str, - subagent_type: str, - state: Annotated[dict, InjectedState], - tool_call_id: Annotated[str, InjectedToolCallId], - ) -> str | Command: - if subagent_type not in subagent_graphs: - msg = ( - f"Error: invoked agent of type {subagent_type}, " - f"the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" - ) - raise ValueError(msg) - subagent = subagent_graphs[subagent_type] - # Create a new state dict to avoid mutating the original - subagent_state = {k: v for k, v in state.items() if k not in _EXCLUDED_STATE_KEYS} - subagent_state["messages"] = [HumanMessage(content=description)] - result = await subagent.ainvoke(subagent_state) - return _return_command_with_state_update(result, tool_call_id) - else: - - @tool(description=task_tool_description) - def task( - description: str, - subagent_type: str, - state: Annotated[dict, InjectedState], - tool_call_id: Annotated[str, InjectedToolCallId], - ) -> str | Command: - if subagent_type not in subagent_graphs: - msg = ( - f"Error: invoked agent of type {subagent_type}, " - f"the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" - ) - raise ValueError(msg) - subagent = subagent_graphs[subagent_type] - # Create a new state dict to avoid mutating the original - subagent_state = {k: v for k, v in state.items() if k not in _EXCLUDED_STATE_KEYS} - subagent_state["messages"] = [HumanMessage(content=description)] - result = subagent.invoke(subagent_state) - return _return_command_with_state_update(result, tool_call_id) - - return task + + def task( + description: str, + subagent_type: str, + state: Annotated[dict, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> str | Command: + if subagent_type not in subagent_graphs: + msg = ( + f"Error: invoked agent of type {subagent_type}, " + f"the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" + ) + raise ValueError(msg) + subagent = subagent_graphs[subagent_type] + # Create a new state dict to avoid mutating the original + subagent_state = {k: v for k, v in state.items() if k not in _EXCLUDED_STATE_KEYS} + subagent_state["messages"] = [HumanMessage(content=description)] + result = subagent.invoke(subagent_state) + return _return_command_with_state_update(result, tool_call_id) + + async def atask( + description: str, + subagent_type: str, + state: Annotated[dict, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> str | Command: + if subagent_type not in subagent_graphs: + msg = ( + f"Error: invoked agent of type {subagent_type}, " + f"the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" + ) + raise ValueError(msg) + subagent = subagent_graphs[subagent_type] + # Create a new state dict to avoid mutating the original + subagent_state = {k: v for k, v in state.items() if k not in _EXCLUDED_STATE_KEYS} + subagent_state["messages"] = [HumanMessage(content=description)] + result = subagent.invoke(subagent_state) + return _return_command_with_state_update(result, tool_call_id) + + return StructuredTool.from_function( + name="task", + func=task, + coroutine=atask, + description=task_tool_description, + ) class SubAgentMiddleware(AgentMiddleware): @@ -322,7 +321,6 @@ class SubAgentMiddleware(AgentMiddleware): default_subagent_tools: The tools to use for the general-purpose subagent. subagents: A list of additional subagents to provide to the agent. system_prompt_extension: Additional instructions on how the main agent should use subagents. - is_async: Whether the `task` tool should be asynchronous. Example: ```python @@ -343,7 +341,6 @@ def __init__( default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, subagents: list[SubAgent | CompiledSubAgent] | None = None, system_prompt_extension: str | None = None, - is_async: bool = False, ) -> None: """Initialize the SubAgentMiddleware.""" super().__init__() @@ -352,7 +349,6 @@ def __init__( default_subagent_model, default_subagent_tools or [], subagents or [], - is_async=is_async, ) self.tools = [task_tool] From 24e2a95b67051703c35ad99a490a73969c20c280 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 17:02:54 -0400 Subject: [PATCH 19/22] more custom interface --- .../langchain/agents/middleware/subagents.py | 248 ++++++++++++------ .../middleware/test_subagent_middleware.py | 24 +- .../middleware/test_subagent_middleware.py | 20 +- 3 files changed, 184 insertions(+), 108 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index e189e0ac60ff3..6496b7a35bec3 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -10,9 +10,6 @@ from langgraph.types import Command from typing_extensions import NotRequired -from langchain.agents.middleware.filesystem import FilesystemMiddleware -from langchain.agents.middleware.summarization import SummarizationMiddleware -from langchain.agents.middleware.todo_list import TodoListMiddleware from langchain.agents.middleware.types import AgentMiddleware, ModelRequest, ModelResponse from langchain.tools import BaseTool, InjectedState, InjectedToolCallId @@ -22,39 +19,45 @@ AnthropicPromptCachingMiddleware = None -class SubAgent(TypedDict): - """A subagent constructed with user-defined parameters.""" +class AgentSpec(TypedDict): + """Specification for an agent. + + When specifying custom agents, the `default_middleware` from `SubAgentMiddleware` + will be applied first, followed by any `middleware` specified in this spec. + To use only custom middleware without the defaults, pass `default_middleware=[]` + to `SubAgentMiddleware`. + """ name: str - """The name of the subagent.""" + """The name of the agent.""" description: str - """The description of the subagent.""" + """The description of the agent.""" system_prompt: str - """The system prompt to use for the subagent.""" + """The system prompt to use for the agent.""" tools: Sequence[BaseTool | Callable | dict[str, Any]] - """The tools to use for the subagent.""" + """The tools to use for the agent.""" model: NotRequired[str | BaseChatModel] - """The model for the subagent.""" + """The model for the agent. Defaults to `default_model`.""" middleware: NotRequired[list[AgentMiddleware]] - """The middleware to use for the subagent.""" + """Additional middleware to append after `default_middleware`.""" -class CompiledSubAgent(TypedDict): - """A Runnable passed in as a subagent.""" +class CompiledAgentSpec(TypedDict): + """A pre-compiled agent spec.""" name: str - """The name of the subagent.""" + """The name of the agent.""" description: str - """The description of the subagent.""" + """The description of the agent.""" runnable: Runnable - """The Runnable to use for the subagent.""" + """The Runnable to use for the agent.""" DEFAULT_SUBAGENT_PROMPT = "In order to complete the objective that the user asks of you, you have access to a number of standard tools." # noqa: E501 @@ -174,46 +177,61 @@ class CompiledSubAgent(TypedDict): """ # noqa: E501 +DEFAULT_GENERAL_PURPOSE_DESCRIPTION = "General-purpose agent for researching complex questions, searching for files and content, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. This agent has access to all tools as the main agent." # noqa: E501 + + def _get_subagents( - default_subagent_model: str | BaseChatModel, - default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]], - subagents: list[SubAgent | CompiledSubAgent], + default_model: str | BaseChatModel, + default_tools: Sequence[BaseTool | Callable | dict[str, Any]], + subagents: list[AgentSpec | CompiledAgentSpec], + *, + default_middleware: list[AgentMiddleware] | None = None, + general_purpose_agent: bool = True, ) -> tuple[dict[str, Any], list[str]]: + """Create subagent instances from specifications. + + Args: + default_model: Default model for subagents that don't specify one. + default_tools: Default tools for subagents that don't specify tools. + subagents: List of agent specifications or pre-compiled agents. + default_middleware: Middleware to apply to all subagents. If `None`, + no default middleware is applied. + general_purpose_agent: Whether to include a general-purpose subagent. + + Returns: + Tuple of (agent_dict, description_list) where agent_dict maps agent names + to runnable instances and description_list contains formatted descriptions. + """ from langchain.agents.factory import create_agent - default_subagent_middleware = [ - TodoListMiddleware(), - FilesystemMiddleware(), - SummarizationMiddleware( - model=default_subagent_model, - max_tokens_before_summary=120000, - messages_to_keep=20, - ), - ] - - if AnthropicPromptCachingMiddleware is not None: - default_subagent_middleware.append( - AnthropicPromptCachingMiddleware(ttl="5m", unsupported_model_behavior="ignore") - ) + # Use empty list if None (no default middleware) + default_subagent_middleware = default_middleware or [] - # Create the general-purpose subagent - general_purpose_subagent = create_agent( - model=default_subagent_model, - system_prompt=DEFAULT_SUBAGENT_PROMPT, - tools=default_subagent_tools, - middleware=default_subagent_middleware, - ) - agents: dict[str, Any] = {"general-purpose": general_purpose_subagent} + agents: dict[str, Any] = {} subagent_descriptions = [] + + # Create general-purpose agent if enabled + if general_purpose_agent: + general_purpose_subagent = create_agent( + model=default_model, + system_prompt=DEFAULT_SUBAGENT_PROMPT, + tools=default_tools, + middleware=default_subagent_middleware, + ) + agents["general-purpose"] = general_purpose_subagent + # Note: general-purpose description is included in TASK_TOOL_DESCRIPTION template, + # so we don't add it to subagent_descriptions here + + # Process custom subagents for _agent in subagents: subagent_descriptions.append(f"- {_agent['name']}: {_agent['description']}") if "runnable" in _agent: - custom_agent = cast("CompiledSubAgent", _agent) + custom_agent = cast("CompiledAgentSpec", _agent) agents[custom_agent["name"]] = custom_agent["runnable"] continue - _tools = _agent.get("tools", list(default_subagent_tools)) + _tools = _agent.get("tools", list(default_tools)) - subagent_model = _agent.get("model", default_subagent_model) + subagent_model = _agent.get("model", default_model) if "middleware" in _agent: _middleware = [*default_subagent_middleware, *_agent["middleware"]] @@ -231,12 +249,34 @@ def _get_subagents( def _create_task_tool( - default_subagent_model: str | BaseChatModel, - default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]], - subagents: list[SubAgent | CompiledSubAgent], + default_model: str | BaseChatModel, + default_tools: Sequence[BaseTool | Callable | dict[str, Any]], + subagents: list[AgentSpec | CompiledAgentSpec], + *, + default_middleware: list[AgentMiddleware] | None = None, + general_purpose_agent: bool = True, + task_tool_description: str | None = None, ) -> BaseTool: + """Create a task tool for invoking subagents. + + Args: + default_model: Default model for subagents. + default_tools: Default tools for subagents. + subagents: List of subagent specifications. + default_middleware: Middleware to apply to all subagents. + general_purpose_agent: Whether to include general-purpose agent. + task_tool_description: Custom description for the task tool. If `None`, + uses default template. Supports `{other_agents}` placeholder. + + Returns: + A StructuredTool that can invoke subagents by type. + """ subagent_graphs, subagent_descriptions = _get_subagents( - default_subagent_model, default_subagent_tools, subagents + default_model, + default_tools, + subagents, + default_middleware=default_middleware, + general_purpose_agent=general_purpose_agent, ) subagent_description_str = "\n".join(subagent_descriptions) @@ -251,14 +291,10 @@ def _return_command_with_state_update(result: dict, tool_call_id: str) -> Comman } ) - task_tool_description = TASK_TOOL_DESCRIPTION.format(other_agents=subagent_description_str) - - def task( - description: str, - subagent_type: str, - state: Annotated[dict, InjectedState], - tool_call_id: Annotated[str, InjectedToolCallId], - ) -> str | Command: + def _validate_and_prepare_state( + subagent_type: str, description: str, state: dict + ) -> tuple[Runnable, dict]: + """Validate subagent type and prepare state for invocation.""" if subagent_type not in subagent_graphs: msg = ( f"Error: invoked agent of type {subagent_type}, " @@ -269,6 +305,22 @@ def task( # Create a new state dict to avoid mutating the original subagent_state = {k: v for k, v in state.items() if k not in _EXCLUDED_STATE_KEYS} subagent_state["messages"] = [HumanMessage(content=description)] + return subagent, subagent_state + + # Use custom description if provided, otherwise use default template + if task_tool_description is None: + task_tool_description = TASK_TOOL_DESCRIPTION.format(other_agents=subagent_description_str) + elif "{other_agents}" in task_tool_description: + # If custom description has placeholder, format with agent descriptions + task_tool_description = task_tool_description.format(other_agents=subagent_description_str) + + def task( + description: str, + subagent_type: str, + state: Annotated[dict, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> str | Command: + subagent, subagent_state = _validate_and_prepare_state(subagent_type, description, state) result = subagent.invoke(subagent_state) return _return_command_with_state_update(result, tool_call_id) @@ -278,17 +330,8 @@ async def atask( state: Annotated[dict, InjectedState], tool_call_id: Annotated[str, InjectedToolCallId], ) -> str | Command: - if subagent_type not in subagent_graphs: - msg = ( - f"Error: invoked agent of type {subagent_type}, " - f"the only allowed types are {[f'`{k}`' for k in subagent_graphs]}" - ) - raise ValueError(msg) - subagent = subagent_graphs[subagent_type] - # Create a new state dict to avoid mutating the original - subagent_state = {k: v for k, v in state.items() if k not in _EXCLUDED_STATE_KEYS} - subagent_state["messages"] = [HumanMessage(content=description)] - result = subagent.invoke(subagent_state) + subagent, subagent_state = _validate_and_prepare_state(subagent_type, description, state) + result = await subagent.ainvoke(subagent_state) return _return_command_with_state_update(result, tool_call_id) return StructuredTool.from_function( @@ -316,39 +359,76 @@ class SubAgentMiddleware(AgentMiddleware): handle the same tasks as the main agent, but with isolated context. Args: - default_subagent_model: The model to use for the general-purpose subagent. + default_model: The model to use for subagents. Can be a LanguageModelLike or a dict for init_chat_model. - default_subagent_tools: The tools to use for the general-purpose subagent. + default_tools: The tools to use for the default general-purpose subagent. subagents: A list of additional subagents to provide to the agent. - system_prompt_extension: Additional instructions on how the main agent should use subagents. + system_prompt: Full system prompt override. When provided, completely replaces + the agent's system prompt. + default_middleware: Default middleware to apply to all subagents. If `None` (default), + no default middleware is applied. Pass a list to specify custom middleware. + general_purpose_agent: Whether to include the general-purpose agent. Defaults to `True`. + task_tool_description: Custom description for the task tool. If `None`, uses the + default description template. Example: ```python from langchain.agents.middleware.subagents import SubAgentMiddleware from langchain.agents import create_agent - agent = create_agent("openai:gpt-4o", middleware=[SubAgentMiddleware(subagents=[])]) + # Basic usage with defaults (no default middleware) + agent = create_agent( + "openai:gpt-4o", + middleware=[SubAgentMiddleware(default_model="openai:gpt-4o", subagents=[])], + ) + + # Add custom middleware to subagents + agent = create_agent( + "openai:gpt-4o", + middleware=[ + SubAgentMiddleware( + default_model="openai:gpt-4o", + default_middleware=[TodoListMiddleware()], + subagents=[], + ) + ], + ) - # Agent now has access to the `task` tool - result = await agent.invoke({"messages": [HumanMessage("Help me refactor my codebase")]}) + # Disable general-purpose agent + agent = create_agent( + "openai:gpt-4o", + middleware=[ + SubAgentMiddleware( + default_model="openai:gpt-4o", + general_purpose_agent=False, + subagents=[...], + ) + ], + ) ``` """ def __init__( self, *, - default_subagent_model: str | BaseChatModel, - default_subagent_tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, - subagents: list[SubAgent | CompiledSubAgent] | None = None, - system_prompt_extension: str | None = None, + default_model: str | BaseChatModel, + default_tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, + subagents: list[AgentSpec | CompiledAgentSpec] | None = None, + system_prompt: str | None = None, + default_middleware: list[AgentMiddleware] | None = None, + general_purpose_agent: bool = True, + task_tool_description: str | None = None, ) -> None: """Initialize the SubAgentMiddleware.""" super().__init__() - self.system_prompt_extension = system_prompt_extension + self.system_prompt = system_prompt task_tool = _create_task_tool( - default_subagent_model, - default_subagent_tools or [], + default_model, + default_tools or [], subagents or [], + default_middleware=default_middleware, + general_purpose_agent=general_purpose_agent, + task_tool_description=task_tool_description, ) self.tools = [task_tool] @@ -358,10 +438,6 @@ def wrap_model_call( handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: """Update the system prompt to include instructions on using subagents.""" - if self.system_prompt_extension is not None: - request.system_prompt = ( - request.system_prompt + "\n\n" + self.system_prompt_extension - if request.system_prompt - else self.system_prompt_extension - ) + if self.system_prompt is not None: + request.system_prompt = self.system_prompt return handler(request) diff --git a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py index b3dfd2b41e8ab..d4376731e5b0b 100644 --- a/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/integration_tests/agents/middleware/test_subagent_middleware.py @@ -52,8 +52,8 @@ def test_general_purpose_subagent(self): system_prompt="Use the general-purpose subagent to get the weather in a city.", middleware=[ SubAgentMiddleware( - default_subagent_model="claude-sonnet-4-20250514", - default_subagent_tools=[get_weather], + default_model="claude-sonnet-4-20250514", + default_tools=[get_weather], ) ], ) @@ -70,8 +70,8 @@ def test_defined_subagent(self): system_prompt="Use the task tool to call a subagent.", middleware=[ SubAgentMiddleware( - default_subagent_model="claude-sonnet-4-20250514", - default_subagent_tools=[], + default_model="claude-sonnet-4-20250514", + default_tools=[], subagents=[ { "name": "weather", @@ -96,8 +96,8 @@ def test_defined_subagent_tool_calls(self): system_prompt="Use the task tool to call a subagent.", middleware=[ SubAgentMiddleware( - default_subagent_model="claude-sonnet-4-20250514", - default_subagent_tools=[], + default_model="claude-sonnet-4-20250514", + default_tools=[], subagents=[ { "name": "weather", @@ -125,8 +125,8 @@ def test_defined_subagent_custom_model(self): system_prompt="Use the task tool to call a subagent.", middleware=[ SubAgentMiddleware( - default_subagent_model="claude-sonnet-4-20250514", - default_subagent_tools=[], + default_model="claude-sonnet-4-20250514", + default_tools=[], subagents=[ { "name": "weather", @@ -159,8 +159,8 @@ def test_defined_subagent_custom_middleware(self): system_prompt="Use the task tool to call a subagent.", middleware=[ SubAgentMiddleware( - default_subagent_model="claude-sonnet-4-20250514", - default_subagent_tools=[], + default_model="claude-sonnet-4-20250514", + default_tools=[], subagents=[ { "name": "weather", @@ -199,8 +199,8 @@ def test_defined_subagent_custom_runnable(self): system_prompt="Use the task tool to call a subagent.", middleware=[ SubAgentMiddleware( - default_subagent_model="claude-sonnet-4-20250514", - default_subagent_tools=[], + default_model="claude-sonnet-4-20250514", + default_tools=[], subagents=[ { "name": "weather", diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py index d7c721a2c9a48..19814b6fb17b8 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py @@ -9,27 +9,27 @@ class TestSubagentMiddleware: def test_subagent_middleware_init(self): middleware = SubAgentMiddleware( - default_subagent_model="gpt-4o-mini", + default_model="gpt-4o-mini", ) assert middleware is not None - assert middleware.system_prompt_extension == None + assert middleware.system_prompt is None assert len(middleware.tools) == 1 assert middleware.tools[0].name == "task" assert middleware.tools[0].description == TASK_TOOL_DESCRIPTION.format(other_agents="") def test_default_subagent_with_tools(self): middleware = SubAgentMiddleware( - default_subagent_model="gpt-4o-mini", - default_subagent_tools=[], + default_model="gpt-4o-mini", + default_tools=[], ) assert middleware is not None - assert middleware.system_prompt_extension == None + assert middleware.system_prompt is None - def test_default_subagent_custom_system_prompt_extension(self): + def test_default_subagent_custom_system_prompt(self): middleware = SubAgentMiddleware( - default_subagent_model="gpt-4o-mini", - default_subagent_tools=[], - system_prompt_extension="Use the task tool to call a subagent.", + default_model="gpt-4o-mini", + default_tools=[], + system_prompt="Use the task tool to call a subagent.", ) assert middleware is not None - assert middleware.system_prompt_extension == "Use the task tool to call a subagent." + assert middleware.system_prompt == "Use the task tool to call a subagent." From b642d8f06f589c51528ad3621133c9e93cd0b150 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 20:55:02 -0400 Subject: [PATCH 20/22] sub agent --- .../langchain/agents/middleware/subagents.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index 6496b7a35bec3..c85b576a7805a 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -19,7 +19,7 @@ AnthropicPromptCachingMiddleware = None -class AgentSpec(TypedDict): +class SubAgent(TypedDict): """Specification for an agent. When specifying custom agents, the `default_middleware` from `SubAgentMiddleware` @@ -47,7 +47,7 @@ class AgentSpec(TypedDict): """Additional middleware to append after `default_middleware`.""" -class CompiledAgentSpec(TypedDict): +class CompiledSubAgent(TypedDict): """A pre-compiled agent spec.""" name: str @@ -183,7 +183,7 @@ class CompiledAgentSpec(TypedDict): def _get_subagents( default_model: str | BaseChatModel, default_tools: Sequence[BaseTool | Callable | dict[str, Any]], - subagents: list[AgentSpec | CompiledAgentSpec], + subagents: list[SubAgent | CompiledSubAgent], *, default_middleware: list[AgentMiddleware] | None = None, general_purpose_agent: bool = True, @@ -226,7 +226,7 @@ def _get_subagents( for _agent in subagents: subagent_descriptions.append(f"- {_agent['name']}: {_agent['description']}") if "runnable" in _agent: - custom_agent = cast("CompiledAgentSpec", _agent) + custom_agent = cast("CompiledSubAgent", _agent) agents[custom_agent["name"]] = custom_agent["runnable"] continue _tools = _agent.get("tools", list(default_tools)) @@ -251,7 +251,7 @@ def _get_subagents( def _create_task_tool( default_model: str | BaseChatModel, default_tools: Sequence[BaseTool | Callable | dict[str, Any]], - subagents: list[AgentSpec | CompiledAgentSpec], + subagents: list[SubAgent | CompiledSubAgent], *, default_middleware: list[AgentMiddleware] | None = None, general_purpose_agent: bool = True, @@ -413,7 +413,7 @@ def __init__( *, default_model: str | BaseChatModel, default_tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, - subagents: list[AgentSpec | CompiledAgentSpec] | None = None, + subagents: list[SubAgent | CompiledSubAgent] | None = None, system_prompt: str | None = None, default_middleware: list[AgentMiddleware] | None = None, general_purpose_agent: bool = True, From 3757a8a5559fed992cc866eff3fb7e2593d58bd9 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 21:22:15 -0400 Subject: [PATCH 21/22] more improvements --- .../langchain/agents/middleware/subagents.py | 97 +++++++++---------- .../middleware/test_subagent_middleware.py | 11 ++- 2 files changed, 53 insertions(+), 55 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/subagents.py b/libs/langchain_v1/langchain/agents/middleware/subagents.py index c85b576a7805a..c56d1979bed34 100644 --- a/libs/langchain_v1/langchain/agents/middleware/subagents.py +++ b/libs/langchain_v1/langchain/agents/middleware/subagents.py @@ -68,8 +68,7 @@ class CompiledSubAgent(TypedDict): TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. Available agent types and the tools they have access to: -- general-purpose: General-purpose agent for researching complex questions, searching for files and content, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. This agent has access to all tools as the main agent. -{other_agents} +{available_agents} When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. @@ -181,21 +180,21 @@ class CompiledSubAgent(TypedDict): def _get_subagents( + *, default_model: str | BaseChatModel, default_tools: Sequence[BaseTool | Callable | dict[str, Any]], + default_middleware: list[AgentMiddleware] | None, subagents: list[SubAgent | CompiledSubAgent], - *, - default_middleware: list[AgentMiddleware] | None = None, - general_purpose_agent: bool = True, + general_purpose_agent: bool, ) -> tuple[dict[str, Any], list[str]]: """Create subagent instances from specifications. Args: default_model: Default model for subagents that don't specify one. default_tools: Default tools for subagents that don't specify tools. - subagents: List of agent specifications or pre-compiled agents. default_middleware: Middleware to apply to all subagents. If `None`, no default middleware is applied. + subagents: List of agent specifications or pre-compiled agents. general_purpose_agent: Whether to include a general-purpose subagent. Returns: @@ -213,34 +212,33 @@ def _get_subagents( # Create general-purpose agent if enabled if general_purpose_agent: general_purpose_subagent = create_agent( - model=default_model, + default_model, system_prompt=DEFAULT_SUBAGENT_PROMPT, tools=default_tools, middleware=default_subagent_middleware, ) agents["general-purpose"] = general_purpose_subagent - # Note: general-purpose description is included in TASK_TOOL_DESCRIPTION template, - # so we don't add it to subagent_descriptions here + subagent_descriptions.append(f"- general-purpose: {DEFAULT_GENERAL_PURPOSE_DESCRIPTION}") # Process custom subagents - for _agent in subagents: - subagent_descriptions.append(f"- {_agent['name']}: {_agent['description']}") - if "runnable" in _agent: - custom_agent = cast("CompiledSubAgent", _agent) + for agent_ in subagents: + subagent_descriptions.append(f"- {agent_['name']}: {agent_['description']}") + if "runnable" in agent_: + custom_agent = cast("CompiledSubAgent", agent_) agents[custom_agent["name"]] = custom_agent["runnable"] continue - _tools = _agent.get("tools", list(default_tools)) + _tools = agent_.get("tools", list(default_tools)) - subagent_model = _agent.get("model", default_model) + subagent_model = agent_.get("model", default_model) - if "middleware" in _agent: - _middleware = [*default_subagent_middleware, *_agent["middleware"]] + if "middleware" in agent_: + _middleware = [*default_subagent_middleware, *agent_["middleware"]] else: _middleware = default_subagent_middleware - agents[_agent["name"]] = create_agent( + agents[agent_["name"]] = create_agent( subagent_model, - system_prompt=_agent["system_prompt"], + system_prompt=agent_["system_prompt"], tools=_tools, middleware=_middleware, checkpointer=False, @@ -249,33 +247,33 @@ def _get_subagents( def _create_task_tool( + *, default_model: str | BaseChatModel, default_tools: Sequence[BaseTool | Callable | dict[str, Any]], + default_middleware: list[AgentMiddleware] | None, subagents: list[SubAgent | CompiledSubAgent], - *, - default_middleware: list[AgentMiddleware] | None = None, - general_purpose_agent: bool = True, - task_tool_description: str | None = None, + general_purpose_agent: bool, + task_description: str | None = None, ) -> BaseTool: """Create a task tool for invoking subagents. Args: default_model: Default model for subagents. default_tools: Default tools for subagents. - subagents: List of subagent specifications. default_middleware: Middleware to apply to all subagents. + subagents: List of subagent specifications. general_purpose_agent: Whether to include general-purpose agent. - task_tool_description: Custom description for the task tool. If `None`, - uses default template. Supports `{other_agents}` placeholder. + task_description: Custom description for the task tool. If `None`, + uses default template. Supports `{available_agents}` placeholder. Returns: A StructuredTool that can invoke subagents by type. """ subagent_graphs, subagent_descriptions = _get_subagents( - default_model, - default_tools, - subagents, + default_model=default_model, + default_tools=default_tools, default_middleware=default_middleware, + subagents=subagents, general_purpose_agent=general_purpose_agent, ) subagent_description_str = "\n".join(subagent_descriptions) @@ -308,11 +306,11 @@ def _validate_and_prepare_state( return subagent, subagent_state # Use custom description if provided, otherwise use default template - if task_tool_description is None: - task_tool_description = TASK_TOOL_DESCRIPTION.format(other_agents=subagent_description_str) - elif "{other_agents}" in task_tool_description: + if task_description is None: + task_description = TASK_TOOL_DESCRIPTION.format(available_agents=subagent_description_str) + elif "{available_agents}" in task_description: # If custom description has placeholder, format with agent descriptions - task_tool_description = task_tool_description.format(other_agents=subagent_description_str) + task_description = task_description.format(available_agents=subagent_description_str) def task( description: str, @@ -338,7 +336,7 @@ async def atask( name="task", func=task, coroutine=atask, - description=task_tool_description, + description=task_description, ) @@ -362,13 +360,13 @@ class SubAgentMiddleware(AgentMiddleware): default_model: The model to use for subagents. Can be a LanguageModelLike or a dict for init_chat_model. default_tools: The tools to use for the default general-purpose subagent. + default_middleware: Default middleware to apply to all subagents. If `None` (default), + no default middleware is applied. Pass a list to specify custom middleware. subagents: A list of additional subagents to provide to the agent. system_prompt: Full system prompt override. When provided, completely replaces the agent's system prompt. - default_middleware: Default middleware to apply to all subagents. If `None` (default), - no default middleware is applied. Pass a list to specify custom middleware. general_purpose_agent: Whether to include the general-purpose agent. Defaults to `True`. - task_tool_description: Custom description for the task tool. If `None`, uses the + task_description: Custom description for the task tool. If `None`, uses the default description template. Example: @@ -377,31 +375,24 @@ class SubAgentMiddleware(AgentMiddleware): from langchain.agents import create_agent # Basic usage with defaults (no default middleware) - agent = create_agent( - "openai:gpt-4o", - middleware=[SubAgentMiddleware(default_model="openai:gpt-4o", subagents=[])], - ) - - # Add custom middleware to subagents agent = create_agent( "openai:gpt-4o", middleware=[ SubAgentMiddleware( default_model="openai:gpt-4o", - default_middleware=[TodoListMiddleware()], subagents=[], ) ], ) - # Disable general-purpose agent + # Add custom middleware to subagents agent = create_agent( "openai:gpt-4o", middleware=[ SubAgentMiddleware( default_model="openai:gpt-4o", - general_purpose_agent=False, - subagents=[...], + default_middleware=[TodoListMiddleware()], + subagents=[], ) ], ) @@ -413,22 +404,22 @@ def __init__( *, default_model: str | BaseChatModel, default_tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None, + default_middleware: list[AgentMiddleware] | None = None, subagents: list[SubAgent | CompiledSubAgent] | None = None, system_prompt: str | None = None, - default_middleware: list[AgentMiddleware] | None = None, general_purpose_agent: bool = True, - task_tool_description: str | None = None, + task_description: str | None = None, ) -> None: """Initialize the SubAgentMiddleware.""" super().__init__() self.system_prompt = system_prompt task_tool = _create_task_tool( - default_model, - default_tools or [], - subagents or [], + default_model=default_model, + default_tools=default_tools or [], default_middleware=default_middleware, + subagents=subagents or [], general_purpose_agent=general_purpose_agent, - task_tool_description=task_tool_description, + task_description=task_description, ) self.tools = [task_tool] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py index 19814b6fb17b8..2718c1701474f 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_subagent_middleware.py @@ -1,4 +1,8 @@ -from langchain.agents.middleware.subagents import TASK_TOOL_DESCRIPTION, SubAgentMiddleware +from langchain.agents.middleware.subagents import ( + DEFAULT_GENERAL_PURPOSE_DESCRIPTION, + TASK_TOOL_DESCRIPTION, + SubAgentMiddleware, +) import pytest @@ -15,7 +19,10 @@ def test_subagent_middleware_init(self): assert middleware.system_prompt is None assert len(middleware.tools) == 1 assert middleware.tools[0].name == "task" - assert middleware.tools[0].description == TASK_TOOL_DESCRIPTION.format(other_agents="") + expected_desc = TASK_TOOL_DESCRIPTION.format( + available_agents=f"- general-purpose: {DEFAULT_GENERAL_PURPOSE_DESCRIPTION}" + ) + assert middleware.tools[0].description == expected_desc def test_default_subagent_with_tools(self): middleware = SubAgentMiddleware( From 853a498dc6dcd2201cf507d03971abe7cb151f14 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 14 Oct 2025 21:57:36 -0400 Subject: [PATCH 22/22] docstrings and general improvements --- .../langchain/agents/_internal/file_utils.py | 252 ++++++++++--- .../langchain/agents/middleware/filesystem.py | 336 ++++++++++++++---- .../middleware/test_filesystem_middleware.py | 28 +- 3 files changed, 468 insertions(+), 148 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/_internal/file_utils.py b/libs/langchain_v1/langchain/agents/_internal/file_utils.py index 9449a803a24e7..2db1e44048795 100644 --- a/libs/langchain_v1/langchain/agents/_internal/file_utils.py +++ b/libs/langchain_v1/langchain/agents/_internal/file_utils.py @@ -11,6 +11,12 @@ if TYPE_CHECKING: from collections.abc import Sequence +# Constants +MEMORIES_PREFIX = "/memories/" +EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents" +MAX_LINE_LENGTH = 2000 +LINE_NUMBER_WIDTH = 6 + class FileData(TypedDict): """Data structure for storing file contents with metadata.""" @@ -28,14 +34,28 @@ class FileData(TypedDict): def file_data_reducer( left: dict[str, FileData] | None, right: dict[str, FileData | None] ) -> dict[str, FileData]: - """Custom reducer that merges file updates. + """Merge file updates with support for deletions. + + This reducer enables file deletion by treating `None` values in the right + dictionary as deletion markers. It's designed to work with LangGraph's + state management where annotated reducers control how state updates merge. Args: - left: Existing files dict. - right: New files dict to merge (None values delete files). + left: Existing files dictionary. May be `None` during initialization. + right: New files dictionary to merge. Files with `None` values are + treated as deletion markers and removed from the result. Returns: - Merged dict where right overwrites left for matching keys. + Merged dictionary where right overwrites left for matching keys, + and `None` values in right trigger deletions. + + Example: + ```python + existing = {"/file1.txt": FileData(...), "/file2.txt": FileData(...)} + updates = {"/file2.txt": None, "/file3.txt": FileData(...)} + result = file_data_reducer(existing, updates) + # Result: {"/file1.txt": FileData(...), "/file3.txt": FileData(...)} + ``` """ if left is None: # Filter out None values when initializing @@ -43,26 +63,41 @@ def file_data_reducer( # Merge, filtering out None values (deletions) result = {**left} - for k, v in right.items(): - if v is None: - result.pop(k, None) + for key, value in right.items(): + if value is None: + result.pop(key, None) else: - result[k] = v + result[key] = value return result def validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str: """Validate and normalize file path for security. + Ensures paths are safe to use by preventing directory traversal attacks + and enforcing consistent formatting. All paths are normalized to use + forward slashes and start with a leading slash. + Args: - path: The path to validate. - allowed_prefixes: Optional list of allowed path prefixes. + path: The path to validate and normalize. + allowed_prefixes: Optional list of allowed path prefixes. If provided, + the normalized path must start with one of these prefixes. Returns: - Normalized canonical path. + Normalized canonical path starting with `/` and using forward slashes. Raises: - ValueError: If path contains traversal sequences or violates prefix rules. + ValueError: If path contains traversal sequences (`..` or `~`) or does + not start with an allowed prefix when `allowed_prefixes` is specified. + + Example: + ```python + validate_path("foo/bar") # Returns: "/foo/bar" + validate_path("/./foo//bar") # Returns: "/foo/bar" + validate_path("../etc/passwd") # Raises ValueError + validate_path("/data/file.txt", allowed_prefixes=["/data/"]) # OK + validate_path("/etc/file.txt", allowed_prefixes=["/data/"]) # Raises ValueError + ``` """ # Reject paths with traversal attempts if ".." in path or path.startswith("~"): @@ -95,15 +130,30 @@ def format_content_with_line_numbers( format_style: Literal["pipe", "tab"] = "pipe", start_line: int = 1, ) -> str: - r"""Format file content with line numbers. + r"""Format file content with line numbers for display. + + Converts file content to a numbered format similar to `cat -n` output, + with support for two different formatting styles. Args: - content: File content as string or list of lines. - format_style: "pipe" for "1|content" or "tab" for " 1\tcontent". - start_line: Starting line number. + content: File content as a string or list of lines. + format_style: Format style for line numbers: + - `"pipe"`: Compact format like `"1|content"` + - `"tab"`: Right-aligned format like `" 1\tcontent"` (lines truncated at 2000 chars) + start_line: Starting line number (default: 1). Returns: - Formatted content with line numbers. + Formatted content with line numbers prepended to each line. + + Example: + ```python + content = "Hello\nWorld" + format_content_with_line_numbers(content, format_style="pipe") + # Returns: "1|Hello\n2|World" + + format_content_with_line_numbers(content, format_style="tab", start_line=10) + # Returns: " 10\tHello\n 11\tWorld" + ``` """ if isinstance(content, str): lines = content.split("\n") @@ -116,7 +166,11 @@ def format_content_with_line_numbers( if format_style == "pipe": return "\n".join(f"{i + start_line}|{line}" for i, line in enumerate(lines)) - return "\n".join(f"{i + start_line:6d}\t{line[:2000]}" for i, line in enumerate(lines)) + # Tab format with defined width and line truncation + return "\n".join( + f"{i + start_line:{LINE_NUMBER_WIDTH}d}\t{line[:MAX_LINE_LENGTH]}" + for i, line in enumerate(lines) + ) def apply_string_replacement( @@ -126,22 +180,36 @@ def apply_string_replacement( *, replace_all: bool = False, ) -> tuple[str, int]: - """Apply string replacement to content. + """Apply exact string replacement to content. + + Replaces occurrences of a string within content and returns both the + modified content and the number of replacements made. Args: - content: Original content. - old_string: String to replace. + content: Original content to modify. + old_string: String to find and replace. new_string: Replacement string. - replace_all: If True, replace all occurrences. Otherwise, replace first. + replace_all: If `True`, replace all occurrences. If `False`, replace + only the first occurrence (default). Returns: - Tuple of (new_content, replacement_count). + Tuple of `(modified_content, replacement_count)`. + + Example: + ```python + content = "foo bar foo" + apply_string_replacement(content, "foo", "baz", replace_all=False) + # Returns: ("baz bar foo", 1) + + apply_string_replacement(content, "foo", "baz", replace_all=True) + # Returns: ("baz bar baz", 2) + ``` """ if replace_all: count = content.count(old_string) new_content = content.replace(old_string, new_string) else: - count = 1 + count = 1 if old_string in content else 0 new_content = content.replace(old_string, new_string, 1) return new_content, count @@ -152,17 +220,24 @@ def create_file_data( *, created_at: str | None = None, ) -> FileData: - """Create a FileData object from content. + r"""Create a FileData object with automatic timestamp generation. Args: - content: File content as string or list of lines. - created_at: Optional creation timestamp. If None, uses current time. + content: File content as a string or list of lines. + created_at: Optional creation timestamp in ISO 8601 format. + If `None`, uses the current UTC time. Returns: - FileData object. + FileData object with content and timestamps. + + Example: + ```python + file_data = create_file_data("Hello\nWorld") + # Returns: {"content": ["Hello", "World"], "created_at": "2024-...", + # "modified_at": "2024-..."} + ``` """ lines = content.split("\n") if isinstance(content, str) else content - now = datetime.now(timezone.utc).isoformat() return { @@ -176,17 +251,25 @@ def update_file_data( file_data: FileData, content: str | list[str], ) -> FileData: - """Update a FileData object with new content. + """Update FileData with new content while preserving creation timestamp. Args: - file_data: Existing FileData object. - content: New file content as string or list of lines. + file_data: Existing FileData object to update. + content: New file content as a string or list of lines. Returns: - Updated FileData object with new modified_at timestamp. + Updated FileData object with new content and updated `modified_at` + timestamp. The `created_at` timestamp is preserved from the original. + + Example: + ```python + original = create_file_data("Hello") + updated = update_file_data(original, "Hello World") + # updated["created_at"] == original["created_at"] + # updated["modified_at"] > original["modified_at"] + ``` """ lines = content.split("\n") if isinstance(content, str) else content - now = datetime.now(timezone.utc).isoformat() return { @@ -197,26 +280,54 @@ def update_file_data( def file_data_to_string(file_data: FileData) -> str: - """Convert FileData to plain string content. + r"""Convert FileData to plain string content. + + Joins the lines stored in FileData with newline characters to produce + a single string representation of the file content. Args: - file_data: FileData object. + file_data: FileData object containing lines of content. Returns: - File content as string. + File content as a single string with lines joined by newlines. + + Example: + ```python + file_data = { + "content": ["Hello", "World"], + "created_at": "...", + "modified_at": "...", + } + file_data_to_string(file_data) # Returns: "Hello\nWorld" + ``` """ return "\n".join(file_data["content"]) def list_directory(files: dict[str, FileData], path: str) -> list[str]: - """List files in a directory. + """List files in a directory (direct children only). + + Returns only the direct children of the specified directory path, + excluding files in subdirectories. Args: - files: Files dict mapping paths to FileData. - path: Normalized directory path. + files: Dictionary mapping file paths to FileData objects. + path: Normalized directory path to list files from. Returns: - Sorted list of file paths in the directory. + Sorted list of file paths that are direct children of the directory. + + Example: + ```python + files = { + "/dir/file1.txt": FileData(...), + "/dir/file2.txt": FileData(...), + "/dir/subdir/file3.txt": FileData(...), + } + list_directory(files, "/dir") + # Returns: ["/dir/file1.txt", "/dir/file2.txt"] + # Note: /dir/subdir/file3.txt is excluded (not a direct child) + ``` """ # Ensure path ends with / for directory matching dir_path = path if path.endswith("/") else f"{path}/" @@ -234,50 +345,79 @@ def list_directory(files: dict[str, FileData], path: str) -> list[str]: def check_empty_content(content: str) -> str | None: - """Check if file content is empty and return warning message. + """Check if file content is empty and return a warning message. Args: - content: File content. + content: File content to check. Returns: - Warning message if empty, None otherwise. + Warning message string if content is empty or contains only whitespace, + `None` otherwise. + + Example: + ```python + check_empty_content("") # Returns: "System reminder: File exists but has empty contents" + check_empty_content(" ") # Returns: "System reminder: File exists but has empty contents" + check_empty_content("Hello") # Returns: None + ``` """ if not content or content.strip() == "": - return "System reminder: File exists but has empty contents" + return EMPTY_CONTENT_WARNING return None def has_memories_prefix(file_path: str) -> bool: - """Check if file path has the memories prefix. + """Check if a file path is in the longterm memory filesystem. + + Longterm memory files are distinguished by the `/memories/` path prefix. Args: - file_path: File path. + file_path: File path to check. Returns: - True if file path has the memories prefix, False otherwise. + `True` if the file path starts with `/memories/`, `False` otherwise. + + Example: + ```python + has_memories_prefix("/memories/notes.txt") # Returns: True + has_memories_prefix("/temp/file.txt") # Returns: False + ``` """ - return file_path.startswith("/memories/") + return file_path.startswith(MEMORIES_PREFIX) def append_memories_prefix(file_path: str) -> str: - """Append the memories prefix to a file path. + """Add the longterm memory prefix to a file path. Args: - file_path: File path. + file_path: File path to prefix. Returns: - File path with the memories prefix. + File path with `/memories` prepended. + + Example: + ```python + append_memories_prefix("/notes.txt") # Returns: "/memories/notes.txt" + ``` """ return f"/memories{file_path}" def strip_memories_prefix(file_path: str) -> str: - """Strip the memories prefix from a file path. + """Remove the longterm memory prefix from a file path. Args: - file_path: File path. + file_path: File path potentially containing the memories prefix. Returns: - File path without the memories prefix. + File path with `/memories` removed if present at the start. + + Example: + ```python + strip_memories_prefix("/memories/notes.txt") # Returns: "/notes.txt" + strip_memories_prefix("/notes.txt") # Returns: "/notes.txt" + ``` """ - return file_path.replace("/memories", "") + if file_path.startswith(MEMORIES_PREFIX): + return file_path[len(MEMORIES_PREFIX) - 1 :] # Keep the leading slash + return file_path diff --git a/libs/langchain_v1/langchain/agents/middleware/filesystem.py b/libs/langchain_v1/langchain/agents/middleware/filesystem.py index df54d6d4ab567..83d148781def1 100644 --- a/libs/langchain_v1/langchain/agents/middleware/filesystem.py +++ b/libs/langchain_v1/langchain/agents/middleware/filesystem.py @@ -37,6 +37,11 @@ ) from langchain.tools.tool_node import InjectedState +# Constants +LONGTERM_MEMORY_PREFIX = "/memories/" +DEFAULT_READ_OFFSET = 0 +DEFAULT_READ_LIMIT = 2000 + class FilesystemState(AgentState): """State for the filesystem middleware.""" @@ -52,9 +57,7 @@ class FilesystemState(AgentState): - You can optionally provide a path parameter to list files in a specific directory. - This is very useful for exploring the file system and finding the right file to read or edit. - You should almost ALWAYS use this tool before using the Read or Edit tools.""" -LIST_FILES_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( - "\n- Files from the longterm filesystem will be prefixed with the /memories/ path." -) +LIST_FILES_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = f"\n- Files from the longterm filesystem will be prefixed with the {LONGTERM_MEMORY_PREFIX} path." READ_FILE_TOOL_DESCRIPTION = """Reads a file from the filesystem. You can access any file directly by using this tool. Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. @@ -68,9 +71,7 @@ class FilesystemState(AgentState): - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. - You should ALWAYS make sure a file has been read before editing it.""" -READ_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( - "\n- file_paths prefixed with the /memories/ path will be read from the longterm filesystem." -) +READ_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = f"\n- file_paths prefixed with the {LONGTERM_MEMORY_PREFIX} path will be read from the longterm filesystem." EDIT_FILE_TOOL_DESCRIPTION = """Performs exact string replacements in files. @@ -81,7 +82,7 @@ class FilesystemState(AgentState): - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. - The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. - Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.""" -EDIT_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = "\n- You can edit files in the longterm filesystem by prefixing the filename with the /memories/ path." +EDIT_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = f"\n- You can edit files in the longterm filesystem by prefixing the filename with the {LONGTERM_MEMORY_PREFIX} path." WRITE_FILE_TOOL_DESCRIPTION = """Writes to a new file in the filesystem. @@ -91,9 +92,7 @@ class FilesystemState(AgentState): - The write_file tool will create the a new file. - Prefer to edit existing files over creating new ones when possible. - file_paths prefixed with the /memories/ path will be written to the longterm filesystem.""" -WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = ( - "\n- file_paths prefixed with the /memories/ path will be written to the longterm filesystem." -) +WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT = f"\n- file_paths prefixed with the {LONGTERM_MEMORY_PREFIX} path will be written to the longterm filesystem." FILESYSTEM_SYSTEM_PROMPT = """## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file` @@ -104,14 +103,23 @@ class FilesystemState(AgentState): - read_file: read a file from the filesystem - write_file: write to a file in the filesystem - edit_file: edit a file in the filesystem""" -FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT = """ +FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT = f""" You also have access to a longterm filesystem in which you can store files that you want to keep around for longer than the current conversation. -In order to interact with the longterm filesystem, you can use those same tools, but filenames must be prefixed with the /memories/ path. -Remember, to interact with the longterm filesystem, you must prefix the filename with the /memories/ path.""" +In order to interact with the longterm filesystem, you can use those same tools, but filenames must be prefixed with the {LONGTERM_MEMORY_PREFIX} path. +Remember, to interact with the longterm filesystem, you must prefix the filename with the {LONGTERM_MEMORY_PREFIX} path.""" def _get_namespace() -> tuple[str] | tuple[str, str]: + """Get the namespace for longterm filesystem storage. + + Returns a tuple for organizing files in the store. If an assistant_id is available + in the config metadata, returns a 2-tuple of (assistant_id, "filesystem") to provide + per-assistant isolation. Otherwise, returns a 1-tuple of ("filesystem",) for shared storage. + + Returns: + Namespace tuple for store operations, either `(assistant_id, "filesystem")` or `("filesystem",)`. + """ namespace = "filesystem" config = get_config() if config is None: @@ -123,6 +131,17 @@ def _get_namespace() -> tuple[str] | tuple[str, str]: def _get_store(runtime: Runtime[Any]) -> BaseStore: + """Get the store from the runtime, raising an error if unavailable. + + Args: + runtime: The LangGraph runtime containing the store. + + Returns: + The BaseStore instance for longterm file storage. + + Raises: + ValueError: If longterm memory is enabled but no store is available in runtime. + """ if runtime.store is None: msg = "Longterm memory is enabled, but no store is available" raise ValueError(msg) @@ -130,16 +149,27 @@ def _get_store(runtime: Runtime[Any]) -> BaseStore: def _convert_store_item_to_file_data(store_item: Item) -> FileData: + """Convert a store Item to FileData format. + + Args: + store_item: The store Item containing file data. + + Returns: + FileData with content, created_at, and modified_at fields. + + Raises: + ValueError: If required fields are missing or have incorrect types. + """ if "content" not in store_item.value or not isinstance(store_item.value["content"], list): - msg = "Store item does not contain content" + msg = f"Store item does not contain valid content field. Got: {store_item.value.keys()}" raise ValueError(msg) if "created_at" not in store_item.value or not isinstance(store_item.value["created_at"], str): - msg = "Store item does not contain created_at" + msg = f"Store item does not contain valid created_at field. Got: {store_item.value.keys()}" raise ValueError(msg) if "modified_at" not in store_item.value or not isinstance( store_item.value["modified_at"], str ): - msg = "Store item does not contain modified_at" + msg = f"Store item does not contain valid modified_at field. Got: {store_item.value.keys()}" raise ValueError(msg) return FileData( content=store_item.value["content"], @@ -149,6 +179,14 @@ def _convert_store_item_to_file_data(store_item: Item) -> FileData: def _convert_file_data_to_store_item(file_data: FileData) -> dict[str, Any]: + """Convert FileData to a dict suitable for store.put(). + + Args: + file_data: The FileData to convert. + + Returns: + Dictionary with content, created_at, and modified_at fields. + """ return { "content": file_data["content"], "created_at": file_data["created_at"], @@ -157,6 +195,18 @@ def _convert_file_data_to_store_item(file_data: FileData) -> dict[str, Any]: def _get_file_data_from_state(state: FilesystemState, file_path: str) -> FileData: + """Retrieve file data from the agent's state. + + Args: + state: The current filesystem state. + file_path: The path of the file to retrieve. + + Returns: + The FileData for the requested file. + + Raises: + ValueError: If the file is not found in state. + """ mock_filesystem = state.get("files", {}) if file_path not in mock_filesystem: msg = f"File '{file_path}' not found" @@ -165,25 +215,51 @@ def _get_file_data_from_state(state: FilesystemState, file_path: str) -> FileDat def _ls_tool_generator( - custom_description: str | None = None, *, has_longterm_memory: bool + custom_description: str | None = None, *, long_term_memory: bool ) -> BaseTool: + """Generate the ls (list files) tool. + + Args: + custom_description: Optional custom description for the tool. + long_term_memory: Whether to enable longterm memory support. + + Returns: + Configured ls tool that lists files from state and optionally from longterm store. + """ tool_description = LIST_FILES_TOOL_DESCRIPTION if custom_description: tool_description = custom_description - elif has_longterm_memory: + elif long_term_memory: tool_description += LIST_FILES_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT def _get_filenames_from_state(state: FilesystemState) -> list[str]: + """Extract list of filenames from the filesystem state. + + Args: + state: The current filesystem state. + + Returns: + List of file paths in the state. + """ files_dict = state.get("files", {}) return list(files_dict.keys()) def _filter_files_by_path(filenames: list[str], path: str | None) -> list[str]: + """Filter filenames by path prefix. + + Args: + filenames: List of file paths to filter. + path: Optional path prefix to filter by. + + Returns: + Filtered list of file paths matching the prefix. + """ if path is None: return filenames normalized_path = validate_path(path) return [f for f in filenames if f.startswith(normalized_path)] - if has_longterm_memory: + if long_term_memory: @tool(description=tool_description) def ls( @@ -211,15 +287,34 @@ def ls( def _read_file_tool_generator( - custom_description: str | None = None, *, has_longterm_memory: bool + custom_description: str | None = None, *, long_term_memory: bool ) -> BaseTool: + """Generate the read_file tool. + + Args: + custom_description: Optional custom description for the tool. + long_term_memory: Whether to enable longterm memory support. + + Returns: + Configured read_file tool that reads files from state and optionally from longterm store. + """ tool_description = READ_FILE_TOOL_DESCRIPTION if custom_description: tool_description = custom_description - elif has_longterm_memory: + elif long_term_memory: tool_description += READ_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT def _read_file_data_content(file_data: FileData, offset: int, limit: int) -> str: + """Read and format file content with line numbers. + + Args: + file_data: The file data to read. + offset: Line offset to start reading from (0-indexed). + limit: Maximum number of lines to read. + + Returns: + Formatted file content with line numbers, or an error message. + """ content = file_data_to_string(file_data) empty_msg = check_empty_content(content) if empty_msg: @@ -234,14 +329,14 @@ def _read_file_data_content(file_data: FileData, offset: int, limit: int) -> str selected_lines, format_style="tab", start_line=start_idx + 1 ) - if has_longterm_memory: + if long_term_memory: @tool(description=tool_description) def read_file( file_path: str, state: Annotated[FilesystemState, InjectedState], - offset: int = 0, - limit: int = 2000, + offset: int = DEFAULT_READ_OFFSET, + limit: int = DEFAULT_READ_LIMIT, ) -> str: file_path = validate_path(file_path) if has_memories_prefix(file_path): @@ -266,8 +361,8 @@ def read_file( def read_file( file_path: str, state: Annotated[FilesystemState, InjectedState], - offset: int = 0, - limit: int = 2000, + offset: int = DEFAULT_READ_OFFSET, + limit: int = DEFAULT_READ_LIMIT, ) -> str: file_path = validate_path(file_path) try: @@ -280,17 +375,37 @@ def read_file( def _write_file_tool_generator( - custom_description: str | None = None, *, has_longterm_memory: bool + custom_description: str | None = None, *, long_term_memory: bool ) -> BaseTool: + """Generate the write_file tool. + + Args: + custom_description: Optional custom description for the tool. + long_term_memory: Whether to enable longterm memory support. + + Returns: + Configured write_file tool that creates new files in state or longterm store. + """ tool_description = WRITE_FILE_TOOL_DESCRIPTION if custom_description: tool_description = custom_description - elif has_longterm_memory: + elif long_term_memory: tool_description += WRITE_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT def _write_file_to_state( state: FilesystemState, tool_call_id: str, file_path: str, content: str ) -> Command | str: + """Write a new file to the filesystem state. + + Args: + state: The current filesystem state. + tool_call_id: ID of the tool call for generating ToolMessage. + file_path: The path where the file should be written. + content: The content to write to the file. + + Returns: + Command to update state with new file, or error string if file exists. + """ mock_filesystem = state.get("files", {}) existing = mock_filesystem.get(file_path) if existing: @@ -303,7 +418,7 @@ def _write_file_to_state( } ) - if has_longterm_memory: + if long_term_memory: @tool(description=tool_description) def write_file( @@ -343,15 +458,54 @@ def write_file( def _edit_file_tool_generator( - custom_description: str | None = None, *, has_longterm_memory: bool + custom_description: str | None = None, *, long_term_memory: bool ) -> BaseTool: + """Generate the edit_file tool. + + Args: + custom_description: Optional custom description for the tool. + long_term_memory: Whether to enable longterm memory support. + + Returns: + Configured edit_file tool that performs string replacements in files. + """ tool_description = EDIT_FILE_TOOL_DESCRIPTION if custom_description: tool_description = custom_description - elif has_longterm_memory: + elif long_term_memory: tool_description += EDIT_FILE_TOOL_DESCRIPTION_LONGTERM_SUPPLEMENT - if has_longterm_memory: + def _perform_file_edit( + file_data: FileData, + old_string: str, + new_string: str, + *, + replace_all: bool = False, + ) -> tuple[FileData, str] | str: + """Perform string replacement on file data. + + Args: + file_data: The file data to edit. + old_string: String to find and replace. + new_string: Replacement string. + replace_all: If True, replace all occurrences. + + Returns: + Tuple of (updated_file_data, success_message) on success, + or error string on failure. + """ + content = file_data_to_string(file_data) + occurrences = content.count(old_string) + if occurrences == 0: + return f"Error: String not found in file: '{old_string}'" + if occurrences > 1 and not replace_all: + return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context." + new_content = content.replace(old_string, new_string) + new_file_data = update_file_data(file_data, new_content) + result_msg = f"Successfully replaced {occurrences} instance(s) of the string" + return new_file_data, result_msg + + if long_term_memory: @tool(description=tool_description) def edit_file( @@ -365,6 +519,8 @@ def edit_file( ) -> Command | str: file_path = validate_path(file_path) is_longterm_memory = has_memories_prefix(file_path) + + # Retrieve file data from appropriate storage if is_longterm_memory: stripped_file_path = strip_memories_prefix(file_path) runtime = get_runtime() @@ -380,26 +536,25 @@ def edit_file( except ValueError as e: return str(e) - content = file_data_to_string(file_data) - occurrences = content.count(old_string) - if occurrences == 0: - return f"Error: String not found in file: '{old_string}'" - if occurrences > 1 and not replace_all: - return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context." - new_content = content.replace(old_string, new_string) - new_file_data = update_file_data(file_data, new_content) - result_msg = ( - f"Successfully replaced {occurrences} instance(s) of the string in '{file_path}'" - ) + # Perform the edit + result = _perform_file_edit(file_data, old_string, new_string, replace_all=replace_all) + if isinstance(result, str): # Error message + return result + + new_file_data, result_msg = result + full_msg = f"{result_msg} in '{file_path}'" + + # Save to appropriate storage if is_longterm_memory: store.put( namespace, stripped_file_path, _convert_file_data_to_store_item(new_file_data) ) - return result_msg + return full_msg + return Command( update={ "files": {file_path: new_file_data}, - "messages": [ToolMessage(result_msg, tool_call_id=tool_call_id)], + "messages": [ToolMessage(full_msg, tool_call_id=tool_call_id)], } ) else: @@ -415,25 +570,25 @@ def edit_file( replace_all: bool = False, ) -> Command | str: file_path = validate_path(file_path) + + # Retrieve file data from state try: file_data = _get_file_data_from_state(state, file_path) except ValueError as e: return str(e) - content = file_data_to_string(file_data) - occurrences = content.count(old_string) - if occurrences == 0: - return f"Error: String not found in file: '{old_string}'" - if occurrences > 1 and not replace_all: - return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context." - new_content = content.replace(old_string, new_string) - new_file_data = update_file_data(file_data, new_content) - result_msg = ( - f"Successfully replaced {occurrences} instance(s) of the string in '{file_path}'" - ) + + # Perform the edit + result = _perform_file_edit(file_data, old_string, new_string, replace_all=replace_all) + if isinstance(result, str): # Error message + return result + + new_file_data, result_msg = result + full_msg = f"{result_msg} in '{file_path}'" + return Command( update={ "files": {file_path: new_file_data}, - "messages": [ToolMessage(result_msg, tool_call_id=tool_call_id)], + "messages": [ToolMessage(full_msg, tool_call_id=tool_call_id)], } ) @@ -449,23 +604,23 @@ def edit_file( def _get_filesystem_tools( - custom_tool_descriptions: dict[str, str] | None = None, *, has_longterm_memory: bool + custom_tool_descriptions: dict[str, str] | None = None, *, long_term_memory: bool ) -> list[BaseTool]: """Get filesystem tools. Args: - has_longterm_memory: Whether to enable longterm memory support. custom_tool_descriptions: Optional custom descriptions for tools. + long_term_memory: Whether to enable longterm memory support. Returns: - List of configured filesystem tools. + List of configured filesystem tools (ls, read_file, write_file, edit_file). """ if custom_tool_descriptions is None: custom_tool_descriptions = {} tools = [] for tool_name, tool_generator in TOOL_GENERATORS.items(): tool = tool_generator( - custom_tool_descriptions.get(tool_name), has_longterm_memory=has_longterm_memory + custom_tool_descriptions.get(tool_name), long_term_memory=long_term_memory ) tools.append(tool) return tools @@ -474,13 +629,15 @@ def _get_filesystem_tools( class FilesystemMiddleware(AgentMiddleware): """Middleware for providing filesystem tools to an agent. - Args: - use_longterm_memory: Whether to enable longterm memory support. - system_prompt_extension: Optional custom system prompt. - custom_tool_descriptions: Optional custom tool descriptions. + This middleware adds four filesystem tools to the agent: ls, read_file, write_file, + and edit_file. Files can be stored in two locations: + - Short-term: In the agent's state (ephemeral, lasts only for the conversation) + - Long-term: In a persistent store (persists across conversations when enabled) - Returns: - List of configured filesystem tools. + Args: + long_term_memory: Whether to enable longterm memory support. + system_prompt_extension: Optional custom system prompt override. + custom_tool_descriptions: Optional custom tool descriptions override. Raises: ValueError: If longterm memory is enabled but no store is available. @@ -490,7 +647,11 @@ class FilesystemMiddleware(AgentMiddleware): from langchain.agents.middleware.filesystem import FilesystemMiddleware from langchain.agents import create_agent - agent = create_agent(middleware=[FilesystemMiddleware(use_longterm_memory=False)]) + # Short-term memory only + agent = create_agent(middleware=[FilesystemMiddleware(long_term_memory=False)]) + + # With long-term memory + agent = create_agent(middleware=[FilesystemMiddleware(long_term_memory=True)]) ``` """ @@ -499,31 +660,42 @@ class FilesystemMiddleware(AgentMiddleware): def __init__( self, *, - use_longterm_memory: bool = False, + long_term_memory: bool = False, system_prompt_extension: str | None = None, custom_tool_descriptions: dict[str, str] | None = None, ) -> None: """Initialize the filesystem middleware. Args: - use_longterm_memory: Whether to enable longterm memory support. - system_prompt_extension: Optional custom system prompt. - custom_tool_descriptions: Optional custom tool descriptions. + long_term_memory: Whether to enable longterm memory support. + system_prompt_extension: Optional custom system prompt override. + custom_tool_descriptions: Optional custom tool descriptions override. """ - self.use_longterm_memory = use_longterm_memory + self.long_term_memory = long_term_memory self.system_prompt_extension = FILESYSTEM_SYSTEM_PROMPT if system_prompt_extension is not None: self.system_prompt_extension = system_prompt_extension - elif use_longterm_memory: + elif long_term_memory: self.system_prompt_extension += FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT self.tools = _get_filesystem_tools( - custom_tool_descriptions, has_longterm_memory=use_longterm_memory + custom_tool_descriptions, long_term_memory=long_term_memory ) def before_model_call(self, request: ModelRequest, runtime: Runtime[Any]) -> ModelRequest: - """If use_longterm_memory is True, we must have a store available.""" - if self.use_longterm_memory and runtime.store is None: + """Validate that store is available if longterm memory is enabled. + + Args: + request: The model request being processed. + runtime: The LangGraph runtime. + + Returns: + The unmodified model request. + + Raises: + ValueError: If long_term_memory is True but runtime.store is None. + """ + if self.long_term_memory and runtime.store is None: msg = "Longterm memory is enabled, but no store is available" raise ValueError(msg) return request @@ -533,7 +705,15 @@ def wrap_model_call( request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: - """Update the system prompt to include instructions on using the filesystem.""" + """Update the system prompt to include instructions on using the filesystem. + + Args: + request: The model request being processed. + handler: The handler function to call with the modified request. + + Returns: + The model response from the handler. + """ if self.system_prompt_extension is not None: request.system_prompt = ( request.system_prompt + "\n\n" + self.system_prompt_extension diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_filesystem_middleware.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_filesystem_middleware.py index b958c39928cd3..f5e7a77aadee9 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/test_filesystem_middleware.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/test_filesystem_middleware.py @@ -11,14 +11,14 @@ class TestFilesystem: def test_init_local(self): - middleware = FilesystemMiddleware(use_longterm_memory=False) - assert middleware.use_longterm_memory is False + middleware = FilesystemMiddleware(long_term_memory=False) + assert middleware.long_term_memory is False assert middleware.system_prompt_extension == FILESYSTEM_SYSTEM_PROMPT assert len(middleware.tools) == 4 def test_init_longterm(self): - middleware = FilesystemMiddleware(use_longterm_memory=True) - assert middleware.use_longterm_memory is True + middleware = FilesystemMiddleware(long_term_memory=True) + assert middleware.long_term_memory is True assert middleware.system_prompt_extension == ( FILESYSTEM_SYSTEM_PROMPT + FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT ) @@ -26,34 +26,34 @@ def test_init_longterm(self): def test_init_custom_system_prompt_shortterm(self): middleware = FilesystemMiddleware( - use_longterm_memory=False, system_prompt_extension="Custom system prompt" + long_term_memory=False, system_prompt_extension="Custom system prompt" ) - assert middleware.use_longterm_memory is False + assert middleware.long_term_memory is False assert middleware.system_prompt_extension == "Custom system prompt" assert len(middleware.tools) == 4 def test_init_custom_system_prompt_longterm(self): middleware = FilesystemMiddleware( - use_longterm_memory=True, system_prompt_extension="Custom system prompt" + long_term_memory=True, system_prompt_extension="Custom system prompt" ) - assert middleware.use_longterm_memory is True + assert middleware.long_term_memory is True assert middleware.system_prompt_extension == "Custom system prompt" assert len(middleware.tools) == 4 def test_init_custom_tool_descriptions_shortterm(self): middleware = FilesystemMiddleware( - use_longterm_memory=False, custom_tool_descriptions={"ls": "Custom ls tool description"} + long_term_memory=False, custom_tool_descriptions={"ls": "Custom ls tool description"} ) - assert middleware.use_longterm_memory is False + assert middleware.long_term_memory is False assert middleware.system_prompt_extension == FILESYSTEM_SYSTEM_PROMPT ls_tool = next(tool for tool in middleware.tools if tool.name == "ls") assert ls_tool.description == "Custom ls tool description" def test_init_custom_tool_descriptions_longterm(self): middleware = FilesystemMiddleware( - use_longterm_memory=True, custom_tool_descriptions={"ls": "Custom ls tool description"} + long_term_memory=True, custom_tool_descriptions={"ls": "Custom ls tool description"} ) - assert middleware.use_longterm_memory is True + assert middleware.long_term_memory is True assert middleware.system_prompt_extension == ( FILESYSTEM_SYSTEM_PROMPT + FILESYSTEM_SYSTEM_PROMPT_LONGTERM_SUPPLEMENT ) @@ -76,7 +76,7 @@ def test_ls_shortterm(self): ), }, ) - middleware = FilesystemMiddleware(use_longterm_memory=False) + middleware = FilesystemMiddleware(long_term_memory=False) ls_tool = next(tool for tool in middleware.tools if tool.name == "ls") result = ls_tool.invoke({"state": state}) assert result == ["test.txt", "test2.txt"] @@ -107,7 +107,7 @@ def test_ls_shortterm_with_path(self): ), }, ) - middleware = FilesystemMiddleware(use_longterm_memory=False) + middleware = FilesystemMiddleware(long_term_memory=False) ls_tool = next(tool for tool in middleware.tools if tool.name == "ls") result = ls_tool.invoke({"state": state, "path": "pokemon/"}) assert "/pokemon/test2.txt" in result