From 0854c51ac10e25030d4da34ff17c37fd5bbcad65 Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:16:11 +0000 Subject: [PATCH] fix(langchain,anthropic): fix batch of near-trivial bugs Closes #34274 Closes #38718 Closes #36409 Closes #35852 Closes #38465 - LLMToolEmulator: remove redundant double-check when building tools_to_emulate, and make model a required keyword argument instead of silently defaulting to an Anthropic model that required langchain-anthropic to be installed. - Re-export PIIMatch from langchain.agents.middleware so custom PII detector authors don't need the private _redaction module. - Add a state_schema parameter to wrap_tool_call, matching the other middleware decorators. - Fix the Claude file-tool dispatch in langchain-anthropic to populate old_path alongside path, fixing a KeyError on every rename command. Co-authored-by: open-swe[bot] --- .../langchain/agents/middleware/__init__.py | 3 +- .../agents/middleware/tool_emulator.py | 28 ++++--- .../langchain/agents/middleware/types.py | 17 +++- .../middleware/core/test_wrap_tool_call.py | 25 +++++- .../middleware/implementations/test_pii.py | 17 +++- .../implementations/test_tool_emulator.py | 20 ++--- .../middleware/anthropic_tools.py | 8 +- .../middleware/test_anthropic_tools.py | 83 +++++++++++++++++++ 8 files changed, 170 insertions(+), 31 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/__init__.py b/libs/langchain_v1/langchain/agents/middleware/__init__.py index f2d6163fb7..3b642e5398 100644 --- a/libs/langchain_v1/langchain/agents/middleware/__init__.py +++ b/libs/langchain_v1/langchain/agents/middleware/__init__.py @@ -11,7 +11,7 @@ from langchain.agents.middleware.human_in_the_loop import ( from langchain.agents.middleware.model_call_limit import ModelCallLimitMiddleware from langchain.agents.middleware.model_fallback import ModelFallbackMiddleware from langchain.agents.middleware.model_retry import ModelRetryMiddleware -from langchain.agents.middleware.pii import PIIDetectionError, PIIMiddleware +from langchain.agents.middleware.pii import PIIDetectionError, PIIMatch, PIIMiddleware from langchain.agents.middleware.provider_tool_search import ProviderToolSearchMiddleware from langchain.agents.middleware.shell_tool import ( CodexSandboxExecutionPolicy, @@ -70,6 +70,7 @@ __all__ = [ "ModelRetryMiddleware", "OutputAgentState", "PIIDetectionError", + "PIIMatch", "PIIMiddleware", "ProviderToolSearchMiddleware", "RedactionRule", diff --git a/libs/langchain_v1/langchain/agents/middleware/tool_emulator.py b/libs/langchain_v1/langchain/agents/middleware/tool_emulator.py index fe0b1766ea..5a5626ae55 100644 --- a/libs/langchain_v1/langchain/agents/middleware/tool_emulator.py +++ b/libs/langchain_v1/langchain/agents/middleware/tool_emulator.py @@ -37,7 +37,7 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex ```python from langchain.agents.middleware import LLMToolEmulator - middleware = LLMToolEmulator() + middleware = LLMToolEmulator(model="anthropic:claude-sonnet-4-5-20250929") agent = create_agent( model="openai:gpt-5.5", @@ -49,7 +49,10 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex !!! example "Emulate specific tools by name" ```python - middleware = LLMToolEmulator(tools=["get_weather", "get_user_location"]) + middleware = LLMToolEmulator( + tools=["get_weather", "get_user_location"], + model="anthropic:claude-sonnet-4-5-20250929", + ) ``` !!! example "Use a custom model for emulation" @@ -63,7 +66,10 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex !!! example "Emulate specific tools by passing tool instances" ```python - middleware = LLMToolEmulator(tools=[get_weather, get_user_location]) + middleware = LLMToolEmulator( + tools=[get_weather, get_user_location], + model="anthropic:claude-sonnet-4-5-20250929", + ) ``` """ @@ -78,7 +84,7 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex self, *, tools: list[str | BaseTool] | None = None, - model: str | BaseChatModel | None = None, + model: str | BaseChatModel, ) -> None: """Initialize the tool emulator. @@ -90,7 +96,8 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex If empty list, no tools will be emulated. model: Model to use for emulation. - Defaults to `'anthropic:claude-sonnet-4-5-20250929'`. + Required, since this middleware should not implicitly depend on + any specific model provider's package being installed. Can be a model identifier string or `BaseChatModel` instance. """ @@ -101,7 +108,7 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex self.emulate_all = tools is None self.tools_to_emulate: set[str] = set() - if not self.emulate_all and tools is not None: + if tools is not None: for tool in tools: if isinstance(tool, str): self.tools_to_emulate.add(tool) @@ -110,12 +117,9 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex self.tools_to_emulate.add(tool.name) # Initialize emulator model - if model is None: - self.model = init_chat_model("anthropic:claude-sonnet-4-5-20250929", temperature=1) - elif isinstance(model, BaseChatModel): - self.model = model - else: - self.model = init_chat_model(model, temperature=1) + self.model = ( + model if isinstance(model, BaseChatModel) else init_chat_model(model, temperature=1) + ) def wrap_tool_call( self, diff --git a/libs/langchain_v1/langchain/agents/middleware/types.py b/libs/langchain_v1/langchain/agents/middleware/types.py index c8d7f2b38f..c18bc24ee6 100644 --- a/libs/langchain_v1/langchain/agents/middleware/types.py +++ b/libs/langchain_v1/langchain/agents/middleware/types.py @@ -2001,6 +2001,7 @@ def wrap_tool_call( def wrap_tool_call( func: None = None, *, + state_schema: type[StateT] | None = None, tools: list[BaseTool] | None = None, name: str | None = None, ) -> Callable[ @@ -2012,6 +2013,7 @@ def wrap_tool_call( def wrap_tool_call( func: _CallableReturningToolResponse | None = None, *, + state_schema: type[StateT] | None = None, tools: list[BaseTool] | None = None, name: str | None = None, ) -> ( @@ -2034,6 +2036,9 @@ def wrap_tool_call( `Command`. Can be sync or async. + state_schema: Optional custom state schema type. + + If not provided, uses the default `AgentState` schema. tools: Additional tools to register with this middleware. name: Middleware class name. @@ -2097,6 +2102,14 @@ def wrap_tool_call( save_cache(request, result) return result ``` + + !!! example "With custom state schema" + + ```python + @wrap_tool_call(state_schema=MyCustomState) + def custom_wrap_tool_call(request, handler): + return handler(request) + ``` """ def decorator( @@ -2125,7 +2138,7 @@ def wrap_tool_call( middleware_name, (AgentMiddleware,), { - "state_schema": AgentState, + "state_schema": state_schema or AgentState, "tools": tools or [], "awrap_tool_call": async_wrapped, }, @@ -2149,7 +2162,7 @@ def wrap_tool_call( middleware_name, (AgentMiddleware,), { - "state_schema": AgentState, + "state_schema": state_schema or AgentState, "tools": tools or [], "wrap_tool_call": wrapped, }, diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_wrap_tool_call.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_wrap_tool_call.py index 9620bf400e..e1a5e3dc69 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_wrap_tool_call.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_wrap_tool_call.py @@ -6,7 +6,7 @@ focusing on the handler pattern (not generators). import time from collections.abc import Callable -from typing import Any +from typing import Any, TypedDict from langchain_core.messages import HumanMessage, ToolCall, ToolMessage from langchain_core.tools import BaseTool, tool @@ -14,7 +14,7 @@ from langgraph.checkpoint.memory import InMemorySaver from langgraph.types import Command from langchain.agents.factory import create_agent -from langchain.agents.middleware.types import ToolCallRequest, wrap_tool_call +from langchain.agents.middleware.types import AgentMiddleware, ToolCallRequest, wrap_tool_call from tests.unit_tests.agents.model import FakeToolCallingModel @@ -74,6 +74,27 @@ def test_wrap_tool_call_basic_passthrough() -> None: assert "Results for: test" in tool_messages[0].content +def test_wrap_tool_call_with_custom_state_schema() -> None: + """Test `state_schema` is accepted for consistency with other middleware decorators. + + `before_model`, `after_model`, `wrap_model_call`, `before_agent`, and + `after_agent` all support a `state_schema` parameter. + """ + + class CustomState(TypedDict): + messages: list[Any] + custom_field: str + + @wrap_tool_call(state_schema=CustomState) # type: ignore[type-var] + def middleware_with_schema( + request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]] + ) -> ToolMessage | Command[Any]: + return handler(request) + + assert isinstance(middleware_with_schema, AgentMiddleware) + assert middleware_with_schema.state_schema == CustomState + + def test_wrap_tool_call_logging() -> None: """Test logging tool call execution with wrap_tool_call decorator.""" call_log = [] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_pii.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_pii.py index 1f7f2162f7..59ee35ddeb 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_pii.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_pii.py @@ -19,7 +19,9 @@ from langgraph.stream._types import ProtocolEvent from langgraph.stream.transformers import MessagesTransformer from langchain.agents import AgentState +from langchain.agents import middleware as middleware_package from langchain.agents.factory import create_agent +from langchain.agents.middleware import PIIMatch as PublicPIIMatch from langchain.agents.middleware._redaction import RedactionRule from langchain.agents.middleware.pii import ( PIIDetectionError, @@ -35,10 +37,23 @@ from langchain.agents.middleware.pii import ( from tests.unit_tests.agents.model import FakeToolCallingModel # ============================================================================ -# Detection Function Tests +# Public Export Tests # ============================================================================ +class TestPIIMatchPublicExport: + """Test that `PIIMatch` is importable from the public middleware package. + + Regression test: `PIIMatch` was only exported from the private + `_redaction` module, forcing custom-detector authors to import from it + directly instead of `langchain.agents.middleware`. + """ + + def test_pii_match_importable_from_middleware_package(self) -> None: + assert PublicPIIMatch is PIIMatch + assert "PIIMatch" in middleware_package.__all__ + + class TestEmailDetection: """Test email detection.""" diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_emulator.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_emulator.py index d2a28c975f..4e87692c2e 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_emulator.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_emulator.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Sequence from itertools import cycle from typing import Any, Literal +import pytest from langchain_core.language_models import LanguageModelInput from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.language_models.fake_chat_models import GenericFakeChatModel @@ -460,17 +461,14 @@ class TestLLMToolEmulatorModelConfiguration: # Should use the custom model for emulation assert isinstance(result["messages"][-1], AIMessage) - def test_default_model_used_when_none(self) -> None: - """Test that default model is used when model=None.""" - # Just test that initialization doesn't fail - don't require anthropic package - # The actual default model requires langchain_anthropic which may not be installed - try: - emulator = LLMToolEmulator(tools=["get_weather"], model=None) - assert emulator.model is not None - except ImportError: - # If anthropic isn't installed, that's fine for this unit test - # The integration tests will verify the full functionality - pass + def test_model_is_required(self) -> None: + """Test that omitting `model` raises a clear error instead of an ImportError. + + `LLMToolEmulator` should never implicitly depend on a specific model + provider's package being installed, so `model` is a required argument. + """ + with pytest.raises(TypeError): + LLMToolEmulator(tools=["get_weather"]) # type: ignore[call-arg] class TestLLMToolEmulatorAsync: diff --git a/libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py b/libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py index ce83f980d5..ef557cb88e 100644 --- a/libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py +++ b/libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py @@ -240,7 +240,9 @@ class _StateClaudeFileToolMiddleware(AgentMiddleware): Command for state update or string result. """ # Build args dict for handler methods - args: dict[str, Any] = {"path": path} + # `old_path` is populated for `_handle_rename`, which reads the source + # path under that key instead of `path`. + args: dict[str, Any] = {"path": path, "old_path": path} if file_text is not None: args["file_text"] = file_text if old_str is not None: @@ -742,7 +744,9 @@ class _FilesystemClaudeFileToolMiddleware(AgentMiddleware): Command for message update or string result. """ # Build args dict for handler methods - args: dict[str, Any] = {"path": path} + # `old_path` is populated for `_handle_rename`, which reads the source + # path under that key instead of `path`. + args: dict[str, Any] = {"path": path, "old_path": path} if file_text is not None: args["file_text"] = file_text if old_str is not None: diff --git a/libs/partners/anthropic/tests/unit_tests/middleware/test_anthropic_tools.py b/libs/partners/anthropic/tests/unit_tests/middleware/test_anthropic_tools.py index 212c0eb659..f69ee111bc 100644 --- a/libs/partners/anthropic/tests/unit_tests/middleware/test_anthropic_tools.py +++ b/libs/partners/anthropic/tests/unit_tests/middleware/test_anthropic_tools.py @@ -1,13 +1,17 @@ """Unit tests for Anthropic text editor and memory tool middleware.""" +import tempfile +from pathlib import Path from unittest.mock import MagicMock import pytest from langchain_core.messages import SystemMessage, ToolMessage +from langgraph.prebuilt import ToolRuntime from langgraph.types import Command from langchain_anthropic.middleware.anthropic_tools import ( AnthropicToolsState, + FilesystemClaudeTextEditorMiddleware, StateClaudeMemoryMiddleware, StateClaudeTextEditorMiddleware, _validate_path, @@ -309,6 +313,85 @@ class TestFileOperations: assert files.get("/memories/new.txt") is not None assert files["/memories/new.txt"]["content"] == ["line1"] + def test_rename_via_tool_dispatch(self) -> None: + """End-to-end: renaming through the actual `file_tool` dispatch. + + Regression test for the dispatch building `args` with the source path + under `"path"` while `_handle_rename` read `args["old_path"]`, which + raised `KeyError` on every rename command. + """ + middleware = StateClaudeTextEditorMiddleware() + state: AnthropicToolsState = { + "messages": [], + "text_editor_files": { + "/notes/old.txt": { + "content": ["hello"], + "created_at": "2025-01-01T00:00:00", + "modified_at": "2025-01-01T00:00:00", + } + }, + } + (file_tool,) = middleware.tools + + result = file_tool.invoke( + { + "command": "rename", + "path": "/notes/old.txt", + "new_path": "/notes/new.txt", + "runtime": ToolRuntime( + context=None, + state=state, + config={}, + stream_writer=lambda _: None, + tool_call_id="tc-1", + store=None, + ), + } + ) + + assert isinstance(result, Command) + assert result.update is not None + files = result.update["text_editor_files"] + assert files["/notes/old.txt"] is None + assert files["/notes/new.txt"]["content"] == ["hello"] + message = result.update["messages"][0] + assert isinstance(message, ToolMessage) + assert "renamed" in message.content + + +class TestFilesystemRenameViaToolDispatch: + """End-to-end tests for filesystem-backed rename through `file_tool`.""" + + def test_rename_via_tool_dispatch(self) -> None: + """Regression test mirroring `TestFileOperations.test_rename_via_tool_dispatch` + for the filesystem-backed middleware, which has its own dispatch closure + and `_handle_rename` implementation. + """ + with tempfile.TemporaryDirectory() as root: + (Path(root) / "old.txt").write_text("hello") + middleware = FilesystemClaudeTextEditorMiddleware(root_path=root) + (file_tool,) = middleware.tools + + result = file_tool.invoke( + { + "command": "rename", + "path": "/old.txt", + "new_path": "/new.txt", + "runtime": ToolRuntime( + context=None, + state={}, + config={}, + stream_writer=lambda _: None, + tool_call_id="tc-2", + store=None, + ), + } + ) + + assert isinstance(result, Command) + assert not (Path(root) / "old.txt").exists() + assert (Path(root) / "new.txt").read_text() == "hello" + class TestSystemMessageHandling: """Test system message handling in wrap_model_call."""