feat(anthropic): support memory and context management features (#33146)

https://docs.claude.com/en/docs/build-with-claude/context-editing

---------

Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
ccurme
2025-09-29 15:42:38 -04:00
committed by GitHub
parent 839a18e112
commit f9bae40475
6 changed files with 140 additions and 21 deletions

View File

@@ -12,7 +12,7 @@ from operator import itemgetter
from typing import Any, Callable, Literal, Optional, Union, cast
import anthropic
from langchain_core._api import beta, deprecated
from langchain_core._api import deprecated
from langchain_core.callbacks import (
AsyncCallbackManagerForLLMRun,
CallbackManagerForLLMRun,
@@ -91,6 +91,7 @@ def _is_builtin_tool(tool: Any) -> bool:
"web_search_",
"web_fetch_",
"code_execution_",
"memory_",
]
return any(tool_type.startswith(prefix) for prefix in _builtin_tool_prefixes)
@@ -1193,6 +1194,25 @@ class ChatAnthropic(BaseChatModel):
Total tokens: 408
Context management:
Anthropic supports a context editing feature that will automatically manage the
model's context window (e.g., by clearing tool results).
See `Anthropic documentation <https://docs.claude.com/en/docs/build-with-claude/context-editing>`__
for details and configuration options.
.. code-block:: python
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(
model="claude-sonnet-4-5-20250929",
betas=["context-management-2025-06-27"],
context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},
)
llm_with_tools = llm.bind_tools([{"type": "web_search_20250305", "name": "web_search"}])
response = llm_with_tools.invoke("Search for recent developments in AI")
Built-in tools:
See LangChain `docs <https://python.langchain.com/docs/integrations/chat/anthropic/#built-in-tools>`__
for more detail.
@@ -1413,6 +1433,11 @@ class ChatAnthropic(BaseChatModel):
"name": "example-mcp"}]``
"""
context_management: Optional[dict[str, Any]] = None
"""Configuration for
`context management <https://docs.claude.com/en/docs/build-with-claude/context-editing>`__.
"""
@property
def _llm_type(self) -> str:
"""Return type of chat model."""
@@ -1565,6 +1590,7 @@ class ChatAnthropic(BaseChatModel):
"top_p": self.top_p,
"stop_sequences": stop or self.stop_sequences,
"betas": self.betas,
"context_management": self.context_management,
"mcp_servers": self.mcp_servers,
"system": system,
**self.model_kwargs,
@@ -2219,7 +2245,6 @@ class ChatAnthropic(BaseChatModel):
return RunnableMap(raw=llm) | parser_with_fallback
return llm | output_parser
@beta()
def get_num_tokens_from_messages(
self,
messages: list[BaseMessage],
@@ -2234,8 +2259,8 @@ class ChatAnthropic(BaseChatModel):
messages: The message inputs to tokenize.
tools: If provided, sequence of dict, BaseModel, function, or BaseTools
to be converted to tool schemas.
kwargs: Additional keyword arguments are passed to the
:meth:`~langchain_anthropic.chat_models.ChatAnthropic.bind` method.
kwargs: Additional keyword arguments are passed to the Anthropic
``messages.count_tokens`` method.
Basic usage:
@@ -2270,7 +2295,7 @@ class ChatAnthropic(BaseChatModel):
def get_weather(location: str) -> str:
\"\"\"Get the current weather in a given location
Args:
Args:
location: The city and state, e.g. San Francisco, CA
\"\"\"
return "Sunny"
@@ -2288,15 +2313,24 @@ class ChatAnthropic(BaseChatModel):
Uses Anthropic's `token counting API <https://docs.anthropic.com/en/docs/build-with-claude/token-counting>`__ to count tokens in messages.
""" # noqa: E501
""" # noqa: D214,E501
formatted_system, formatted_messages = _format_messages(messages)
if isinstance(formatted_system, str):
kwargs["system"] = formatted_system
if tools:
kwargs["tools"] = [convert_to_anthropic_tool(tool) for tool in tools]
if self.context_management is not None:
kwargs["context_management"] = self.context_management
response = self._client.beta.messages.count_tokens(
betas=["token-counting-2024-11-01"],
if self.betas is not None:
beta_response = self._client.beta.messages.count_tokens(
betas=self.betas,
model=self.model,
messages=formatted_messages, # type: ignore[arg-type]
**kwargs,
)
return beta_response.input_tokens
response = self._client.messages.count_tokens(
model=self.model,
messages=formatted_messages, # type: ignore[arg-type]
**kwargs,
@@ -2409,7 +2443,7 @@ def _make_message_chunk_from_anthropic_event(
# Capture model name, but don't include usage_metadata yet
# as it will be properly reported in message_delta with complete info
if hasattr(event.message, "model"):
response_metadata = {"model_name": event.message.model}
response_metadata: dict[str, Any] = {"model_name": event.message.model}
else:
response_metadata = {}
@@ -2510,13 +2544,16 @@ def _make_message_chunk_from_anthropic_event(
# Process final usage metadata and completion info
elif event.type == "message_delta" and stream_usage:
usage_metadata = _create_usage_metadata(event.usage)
response_metadata = {
"stop_reason": event.delta.stop_reason,
"stop_sequence": event.delta.stop_sequence,
}
if context_management := getattr(event, "context_management", None):
response_metadata["context_management"] = context_management.model_dump()
message_chunk = AIMessageChunk(
content="",
usage_metadata=usage_metadata,
response_metadata={
"stop_reason": event.delta.stop_reason,
"stop_sequence": event.delta.stop_sequence,
},
response_metadata=response_metadata,
)
# Unhandled event types (e.g., `content_block_stop`, `ping` events)
# https://docs.anthropic.com/en/docs/build-with-claude/streaming#other-events

View File

@@ -7,7 +7,7 @@ authors = []
license = { text = "MIT" }
requires-python = ">=3.9.0,<4.0.0"
dependencies = [
"anthropic>=0.67.0,<1.0.0",
"anthropic>=0.69.0,<1.0.0",
"langchain-core>=0.3.76,<2.0.0",
"pydantic>=2.7.4,<3.0.0",
]

View File

@@ -1485,6 +1485,50 @@ def test_search_result_top_level() -> None:
assert any("citations" in block for block in result.content)
def test_memory_tool() -> None:
llm = ChatAnthropic(
model="claude-sonnet-4-5-20250929", # type: ignore[call-arg]
betas=["context-management-2025-06-27"],
)
llm_with_tools = llm.bind_tools([{"type": "memory_20250818", "name": "memory"}])
response = llm_with_tools.invoke("What are my interests?")
assert isinstance(response, AIMessage)
assert response.tool_calls
assert response.tool_calls[0]["name"] == "memory"
@pytest.mark.vcr
def test_context_management() -> None:
# TODO: update example to trigger action
llm = ChatAnthropic(
model="claude-sonnet-4-5-20250929", # type: ignore[call-arg]
betas=["context-management-2025-06-27"],
context_management={
"edits": [
{
"type": "clear_tool_uses_20250919",
"trigger": {"type": "input_tokens", "value": 10},
"clear_at_least": {"type": "input_tokens", "value": 5},
}
]
},
)
llm_with_tools = llm.bind_tools(
[{"type": "web_search_20250305", "name": "web_search"}]
)
input_message = {"role": "user", "content": "Search for recent developments in AI"}
response = llm_with_tools.invoke([input_message])
assert response.response_metadata.get("context_management")
# Test streaming
full: Optional[BaseMessageChunk] = None
for chunk in llm_with_tools.stream([input_message]):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
assert full.response_metadata.get("context_management")
def test_async_shared_client() -> None:
llm = ChatAnthropic(model="claude-3-5-haiku-latest") # type: ignore[call-arg]
_ = asyncio.run(llm.ainvoke("Hello"))

View File

@@ -1065,9 +1065,21 @@ def test_get_num_tokens_from_messages_passes_kwargs() -> None:
with patch.object(anthropic, "Client") as _client:
llm.get_num_tokens_from_messages([HumanMessage("foo")], foo="bar")
assert (
_client.return_value.beta.messages.count_tokens.call_args.kwargs["foo"] == "bar"
assert _client.return_value.messages.count_tokens.call_args.kwargs["foo"] == "bar"
llm = ChatAnthropic(
model="claude-sonnet-4-5-20250929",
betas=["context-management-2025-06-27"],
context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},
)
with patch.object(anthropic, "Client") as _client:
llm.get_num_tokens_from_messages([HumanMessage("foo")])
call_args = _client.return_value.beta.messages.count_tokens.call_args.kwargs
assert call_args["betas"] == ["context-management-2025-06-27"]
assert call_args["context_management"] == {
"edits": [{"type": "clear_tool_uses_20250919"}]
}
def test_usage_metadata_standardization() -> None:
@@ -1217,6 +1229,22 @@ def test_cache_control_kwarg() -> None:
]
def test_context_management_in_payload() -> None:
llm = ChatAnthropic(
model="claude-sonnet-4-5-20250929", # type: ignore[call-arg]
betas=["context-management-2025-06-27"],
context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},
)
llm_with_tools = llm.bind_tools(
[{"type": "web_search_20250305", "name": "web_search"}]
)
input_message = HumanMessage("Search for recent developments in AI")
payload = llm_with_tools._get_request_payload([input_message]) # type: ignore[attr-defined]
assert payload["context_management"] == {
"edits": [{"type": "clear_tool_uses_20250919"}]
}
def test_anthropic_model_params() -> None:
llm = ChatAnthropic(model="claude-3-5-haiku-latest")

View File

@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.9.0, <4.0.0"
resolution-markers = [
"python_full_version >= '3.13' and platform_python_implementation == 'PyPy'",
@@ -21,20 +21,21 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.67.0"
version = "0.69.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "docstring-parser" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/08/ee91464cd821e6fca52d9a23be44815c95edd3c1cf1e844b2c5e85f0d57f/anthropic-0.67.0.tar.gz", hash = "sha256:d1531b210ea300c73423141d29bcee20fcd24ef9f426f6437c0a5d93fc98fb8e", size = 441639, upload-time = "2025-09-10T14:47:18.137Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/9d/9ad1778b95f15c5b04e7d328c1b5f558f1e893857b7c33cd288c19c0057a/anthropic-0.69.0.tar.gz", hash = "sha256:c604d287f4d73640f40bd2c0f3265a2eb6ce034217ead0608f6b07a8bc5ae5f2", size = 480622, upload-time = "2025-09-29T16:53:45.282Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/9d/9adbda372710918cc8271d089a2ceae4d977a125f90bc3c4b456bca4f281/anthropic-0.67.0-py3-none-any.whl", hash = "sha256:f80a81ec1132c514215f33d25edeeab1c4691ad5361b391ebb70d528b0605b55", size = 317126, upload-time = "2025-09-10T14:47:16.351Z" },
{ url = "https://files.pythonhosted.org/packages/9b/38/75129688de5637eb5b383e5f2b1570a5cc3aecafa4de422da8eea4b90a6c/anthropic-0.69.0-py3-none-any.whl", hash = "sha256:1f73193040f33f11e27c2cd6ec25f24fe7c3f193dc1c5cde6b7a08b18a16bcc5", size = 337265, upload-time = "2025-09-29T16:53:43.686Z" },
]
[[package]]
@@ -257,6 +258,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
@@ -497,7 +507,7 @@ typing = [
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.67.0,<1.0.0" },
{ name = "anthropic", specifier = ">=0.69.0,<1.0.0" },
{ name = "langchain-core", editable = "../../core" },
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
]