From 0ddab9ff20faa3bee52892b9b2ce87c9316ef2e4 Mon Sep 17 00:00:00 2001 From: Chester Curme Date: Tue, 12 Aug 2025 10:59:50 -0400 Subject: [PATCH] start on duplicate content --- libs/core/langchain_core/messages/ai.py | 68 +++++++++++------- libs/core/langchain_core/messages/base.py | 69 +++++++++++-------- libs/core/langchain_core/messages/human.py | 7 +- libs/core/langchain_core/messages/system.py | 7 +- libs/core/langchain_core/messages/tool.py | 7 +- .../language_models/chat_models/test_base.py | 44 ++++-------- libs/core/tests/unit_tests/test_messages.py | 19 +++-- 7 files changed, 128 insertions(+), 93 deletions(-) diff --git a/libs/core/langchain_core/messages/ai.py b/libs/core/langchain_core/messages/ai.py index 9d4a1e01879..6983143b6fa 100644 --- a/libs/core/langchain_core/messages/ai.py +++ b/libs/core/langchain_core/messages/ai.py @@ -202,11 +202,16 @@ class AIMessage(BaseMessage): **kwargs: Any, ) -> None: """Specify content as a positional arg or content_blocks for typing support.""" - if content_blocks is not None: + if content is not None and content_blocks is None: + super().__init__(content=content, **kwargs) + elif content is None and content_blocks is not None: super().__init__( content=cast("Union[str, list[Union[str, dict]]]", content_blocks), + content_blocks=content_blocks, **kwargs, ) + elif content is not None and content_blocks is not None: + super().__init__(content=content, content_blocks=content_blocks, **kwargs) else: super().__init__(content=content, **kwargs) @@ -218,32 +223,45 @@ class AIMessage(BaseMessage): "invalid_tool_calls": self.invalid_tool_calls, } - @property - def content_blocks(self) -> list[types.ContentBlock]: - """Return content blocks of the message.""" - blocks = super().content_blocks + @model_validator(mode="after") + def _init_content_blocks(self) -> Self: + """Assign the content as a list of standard ContentBlocks. - # Add from tool_calls if missing from content - content_tool_call_ids = { - block.get("id") - for block in self.content - if isinstance(block, dict) and block.get("type") == "tool_call" - } - for tool_call in self.tool_calls: - if (id_ := tool_call.get("id")) and id_ not in content_tool_call_ids: - tool_call_block: types.ToolCall = { - "type": "tool_call", - "id": id_, - "name": tool_call["name"], - "args": tool_call["args"], - } - if "index" in tool_call: - tool_call_block["index"] = tool_call["index"] - if "extras" in tool_call: - tool_call_block["extras"] = tool_call["extras"] - blocks.append(tool_call_block) + To use this property, the corresponding chat model must support + ``message_version="v1"`` or higher: - return blocks + .. code-block:: python + + from langchain.chat_models import init_chat_model + llm = init_chat_model("...", message_version="v1") + + otherwise, does best-effort parsing to standard types. + """ + if not self.content_blocks: + self.content_blocks = self._init_text_content(self.content) + + if self.tool_calls or self.invalid_tool_calls: + # Add from tool_calls if missing from content + content_tool_call_ids = { + block.get("id") + for block in self.content_blocks + if isinstance(block, dict) and block.get("type") == "tool_call" + } + for tool_call in self.tool_calls: + if (id_ := tool_call.get("id")) and id_ not in content_tool_call_ids: + tool_call_block: types.ToolCall = { + "type": "tool_call", + "id": id_, + "name": tool_call["name"], + "args": tool_call["args"], + } + if "index" in tool_call: + tool_call_block["index"] = tool_call["index"] + if "extras" in tool_call: + tool_call_block["extras"] = tool_call["extras"] + self.content_blocks.append(tool_call_block) + + return self # TODO: remove this logic if possible, reducing breaking nature of changes @model_validator(mode="before") diff --git a/libs/core/langchain_core/messages/base.py b/libs/core/langchain_core/messages/base.py index 13b12f764d1..ff88dd248eb 100644 --- a/libs/core/langchain_core/messages/base.py +++ b/libs/core/langchain_core/messages/base.py @@ -4,7 +4,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Optional, Union, cast, overload -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator +from typing_extensions import Self from langchain_core.load.serializable import Serializable from langchain_core.messages import content_blocks as types @@ -27,6 +28,9 @@ class BaseMessage(Serializable): content: Union[str, list[Union[str, dict]]] """The string contents of the message.""" + content_blocks: list[types.ContentBlock] = Field(default_factory=list) + """The content of the message as a list of standard ContentBlocks.""" + additional_kwargs: dict = Field(default_factory=dict) """Reserved for additional payload data associated with the message. @@ -84,8 +88,14 @@ class BaseMessage(Serializable): **kwargs: Any, ) -> None: """Specify content as a positional arg or content_blocks for typing support.""" - if content_blocks is not None: - super().__init__(content=content_blocks, **kwargs) + if content is not None and content_blocks is None: + super().__init__(content=content, **kwargs) + elif content is None and content_blocks is not None: + super().__init__( + content=content_blocks, content_blocks=content_blocks, **kwargs + ) + elif content is not None and content_blocks is not None: + super().__init__(content=content, content_blocks=content_blocks, **kwargs) else: super().__init__(content=content, **kwargs) @@ -106,9 +116,30 @@ class BaseMessage(Serializable): """ return ["langchain", "schema", "messages"] - @property - def content_blocks(self) -> list[types.ContentBlock]: - """Return the content as a list of standard ContentBlocks. + @staticmethod + def _init_text_content( + content: Union[str, list[Union[str, dict]]], + ) -> list[types.ContentBlock]: + """Parse string content into a list of ContentBlocks.""" + blocks: list[types.ContentBlock] = [] + content = [content] if isinstance(content, str) and content else content + for item in content: + if isinstance(item, str): + blocks.append({"type": "text", "text": item}) + elif isinstance(item, dict): + item_type = item.get("type") + if item_type not in types.KNOWN_BLOCK_TYPES: + blocks.append({"type": "non_standard", "value": item}) + else: + blocks.append(cast("types.ContentBlock", item)) + else: + pass + + return blocks + + @model_validator(mode="after") + def _init_content_blocks(self) -> Self: + """Assign the content as a list of standard ContentBlocks. To use this property, the corresponding chat model must support ``message_version="v1"`` or higher: @@ -120,29 +151,11 @@ class BaseMessage(Serializable): otherwise, does best-effort parsing to standard types. """ - blocks: list[types.ContentBlock] = [] - content = ( - [self.content] - if isinstance(self.content, str) and self.content - else self.content - ) - for item in content: - if isinstance(item, str): - blocks.append({"type": "text", "text": item}) - elif isinstance(item, dict): - item_type = item.get("type") - if item_type not in types.KNOWN_BLOCK_TYPES: - msg = ( - f"Non-standard content block type '{item_type}'. Ensure " - "the model supports `output_version='v1'` or higher and " - "that this attribute is set on initialization." - ) - raise ValueError(msg) - blocks.append(cast("types.ContentBlock", item)) - else: - pass + if not self.content_blocks: + blocks = self._init_text_content(self.content) + self.content_blocks = blocks - return blocks + return self def text(self) -> str: """Get the text content of the message. diff --git a/libs/core/langchain_core/messages/human.py b/libs/core/langchain_core/messages/human.py index 041db0cdb97..5383bb964d4 100644 --- a/libs/core/langchain_core/messages/human.py +++ b/libs/core/langchain_core/messages/human.py @@ -64,11 +64,16 @@ class HumanMessage(BaseMessage): **kwargs: Any, ) -> None: """Specify content as a positional arg or content_blocks for typing support.""" - if content_blocks is not None: + if content is not None and content_blocks is None: + super().__init__(content=content, **kwargs) + elif content is None and content_blocks is not None: super().__init__( content=cast("Union[str, list[Union[str, dict]]]", content_blocks), + content_blocks=content_blocks, **kwargs, ) + elif content is not None and content_blocks is not None: + super().__init__(content=content, content_blocks=content_blocks, **kwargs) else: super().__init__(content=content, **kwargs) diff --git a/libs/core/langchain_core/messages/system.py b/libs/core/langchain_core/messages/system.py index c8a5bbae5c8..976ea55d156 100644 --- a/libs/core/langchain_core/messages/system.py +++ b/libs/core/langchain_core/messages/system.py @@ -57,11 +57,16 @@ class SystemMessage(BaseMessage): **kwargs: Any, ) -> None: """Specify content as a positional arg or content_blocks for typing support.""" - if content_blocks is not None: + if content is not None and content_blocks is None: + super().__init__(content=content, **kwargs) + elif content is None and content_blocks is not None: super().__init__( content=cast("Union[str, list[Union[str, dict]]]", content_blocks), + content_blocks=content_blocks, **kwargs, ) + elif content is not None and content_blocks is not None: + super().__init__(content=content, content_blocks=content_blocks, **kwargs) else: super().__init__(content=content, **kwargs) diff --git a/libs/core/langchain_core/messages/tool.py b/libs/core/langchain_core/messages/tool.py index 14177181480..78b4f14c3a9 100644 --- a/libs/core/langchain_core/messages/tool.py +++ b/libs/core/langchain_core/messages/tool.py @@ -158,11 +158,16 @@ class ToolMessage(BaseMessage, ToolOutputMixin): **kwargs: Any, ) -> None: """Specify content as a positional arg or content_blocks for typing support.""" - if content_blocks is not None: + if content is not None and content_blocks is None: + super().__init__(content=content, **kwargs) + elif content is None and content_blocks is not None: super().__init__( content=cast("Union[str, list[Union[str, dict]]]", content_blocks), + content_blocks=content_blocks, **kwargs, ) + elif content is not None and content_blocks is not None: + super().__init__(content=content, content_blocks=content_blocks, **kwargs) else: super().__init__(content=content, **kwargs) diff --git a/libs/core/tests/unit_tests/language_models/chat_models/test_base.py b/libs/core/tests/unit_tests/language_models/chat_models/test_base.py index 37b05ed8255..12cd42ddc32 100644 --- a/libs/core/tests/unit_tests/language_models/chat_models/test_base.py +++ b/libs/core/tests/unit_tests/language_models/chat_models/test_base.py @@ -443,19 +443,11 @@ def test_trace_images_in_openai_format() -> None: ] tracer = FakeChatModelStartTracer() response = llm.invoke(messages, config={"callbacks": [tracer]}) - assert tracer.messages == [ - [ - [ - HumanMessage( - content=[ - { - "type": "image_url", - "image_url": {"url": "https://example.com/image.png"}, - } - ] - ) - ] - ] + assert tracer.messages[0][0][0].content == [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + } ] # Test no mutation assert response.content == [ @@ -486,23 +478,15 @@ def test_trace_content_blocks_with_no_type_key() -> None: ] tracer = FakeChatModelStartTracer() response = llm.invoke(messages, config={"callbacks": [tracer]}) - assert tracer.messages == [ - [ - [ - HumanMessage( - [ - { - "type": "text", - "text": "Hello", - }, - { - "type": "cachePoint", - "cachePoint": {"type": "default"}, - }, - ] - ) - ] - ] + assert tracer.messages[0][0][0].content == [ + { + "type": "text", + "text": "Hello", + }, + { + "type": "cachePoint", + "cachePoint": {"type": "default"}, + }, ] # Test no mutation assert response.content == [ diff --git a/libs/core/tests/unit_tests/test_messages.py b/libs/core/tests/unit_tests/test_messages.py index d6857aef32d..ec17985c31b 100644 --- a/libs/core/tests/unit_tests/test_messages.py +++ b/libs/core/tests/unit_tests/test_messages.py @@ -3,6 +3,7 @@ import uuid from typing import Optional, Union import pytest +from pydantic import ValidationError from typing_extensions import get_args from langchain_core.documents import Document @@ -1267,10 +1268,14 @@ def test_typed_init() -> None: # Test we get type errors for malformed blocks (type checker will complain if # below type-ignores are unused). - _ = AIMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item] - _ = HumanMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item] - _ = SystemMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item] - _ = ToolMessage( - content_blocks=[{"type": "text", "bad": "Hello"}], # type: ignore[list-item] - tool_call_id="abc123", - ) + with pytest.raises(ValidationError): + _ = AIMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item] + with pytest.raises(ValidationError): + _ = HumanMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item] + with pytest.raises(ValidationError): + _ = SystemMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item] + with pytest.raises(ValidationError): + _ = ToolMessage( + content_blocks=[{"type": "text", "bad": "Hello"}], # type: ignore[list-item] + tool_call_id="abc123", + )