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] <open-swe@users.noreply.github.com>
This commit is contained in:
Nishitha Mandopen-swe[bot] committed 2026-08-05 23:16:11 +00:00
1 parent 89cc9c5cbb
commit 0854c51ac1
8 files changed
+170 -31

No files matched your search

@@ -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",
@@ -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,
@@ -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,
},
@@ -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 = []
@@ -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."""
@@ -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:
@@ -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:
@@ -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."""