diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index 28f57e1ccea..35463c88a74 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -250,15 +250,31 @@ def _merge_messages( ): curr = HumanMessage(curr.content) # type: ignore[misc] else: + tool_content = curr.content + cache_ctrl = None + # Extract cache_control from content blocks and hoist it + # to the tool_result level. Anthropic's API does not + # support cache_control on tool_result content sub-blocks. + if isinstance(tool_content, list): + cleaned = [] + for block in tool_content: + if isinstance(block, dict) and "cache_control" in block: + cache_ctrl = block["cache_control"] + block = { + k: v for k, v in block.items() if k != "cache_control" + } + cleaned.append(block) + tool_content = cleaned + tool_result: dict = { + "type": "tool_result", + "content": tool_content, + "tool_use_id": curr.tool_call_id, + "is_error": curr.status == "error", + } + if cache_ctrl: + tool_result["cache_control"] = cache_ctrl curr = HumanMessage( # type: ignore[misc] - [ - { - "type": "tool_result", - "content": curr.content, - "tool_use_id": curr.tool_call_id, - "is_error": curr.status == "error", - }, - ], + [tool_result], ) last = merged[-1] if merged else None if any( diff --git a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py index 7ac7b9ff9fb..eab0f4aed21 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import os from collections.abc import Callable from typing import Any, Literal, cast @@ -412,6 +413,91 @@ def test__merge_messages_mutation() -> None: assert messages == original_messages +def test__merge_messages_tool_message_cache_control() -> None: + """Test that cache_control is hoisted from content blocks to tool_result level.""" + # Test with cache_control in content block + messages = [ + ToolMessage( + content=[ + { + "type": "text", + "text": "tool output", + "cache_control": {"type": "ephemeral"}, + } + ], + tool_call_id="1", + ) + ] + original_messages = [copy.deepcopy(m) for m in messages] + expected = [ + HumanMessage( + [ + { + "type": "tool_result", + "content": [{"type": "text", "text": "tool output"}], + "tool_use_id": "1", + "is_error": False, + "cache_control": {"type": "ephemeral"}, + } + ] + ) + ] + actual = _merge_messages(messages) + assert expected == actual + # Verify no mutation + assert messages == original_messages + + # Test with multiple content blocks, cache_control on last one + messages = [ + ToolMessage( + content=[ + {"type": "text", "text": "first output"}, + { + "type": "text", + "text": "second output", + "cache_control": {"type": "ephemeral"}, + }, + ], + tool_call_id="2", + ) + ] + expected = [ + HumanMessage( + [ + { + "type": "tool_result", + "content": [ + {"type": "text", "text": "first output"}, + {"type": "text", "text": "second output"}, + ], + "tool_use_id": "2", + "is_error": False, + "cache_control": {"type": "ephemeral"}, + } + ] + ) + ] + actual = _merge_messages(messages) + assert expected == actual + + # Test without cache_control + messages = [ToolMessage(content="simple output", tool_call_id="3")] + expected = [ + HumanMessage( + [ + { + "type": "tool_result", + "content": "simple output", + "tool_use_id": "3", + "is_error": False, + } + ] + ) + ] + actual = _merge_messages(messages) + assert expected == actual + + def test__format_image() -> None: url = "dummyimage.com/600x400/000/fff" with pytest.raises(ValueError):