mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 09:25:14 +03:00
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:
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."""
|
||||
|
||||
|
||||
+9
-11
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user