start on duplicate content

This commit is contained in:
Chester Curme
2025-08-12 10:59:50 -04:00
parent 91b2bb3417
commit 0ddab9ff20
7 changed files with 128 additions and 93 deletions

View File

@@ -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")

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 == [

View File

@@ -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",
)