Skip to content

Commit 64fb0d2

Browse files
authored
feat: Add support to deserialize chat messages serialized with pydantic (#12025)
1 parent bfb7a28 commit 64fb0d2

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

haystack/dataclasses/chat_message.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ def _deserialize_content_part(part: dict[str, Any]) -> ChatMessageContentT:
232232
if serialization_key in part:
233233
return cls.from_dict(part[serialization_key])
234234

235+
# Support for Pydantic's model_dump() output, which produces a flat dictionary without wrapping keys.
236+
if "tool_name" in part and "arguments" in part:
237+
return ToolCall.from_dict(part)
238+
if "result" in part and "origin" in part:
239+
return ToolCallResult.from_dict(part)
240+
if "reasoning_text" in part:
241+
return ReasoningContent.from_dict(part)
242+
if "base64_image" in part:
243+
return ImageContent.from_dict(part)
244+
if "base64_data" in part:
245+
return FileContent.from_dict(part)
246+
235247
# NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
236248
msg = (
237249
f"Unsupported content part in the serialized ChatMessage: {part}. "
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
enhancements:
3+
- |
4+
``ChatMessage.from_dict`` now also accepts the format Pydantic produces when it auto-serializes
5+
``ChatMessage`` as a plain dataclass (e.g. via ``model_dump`` on a Pydantic model with ``ChatMessage``
6+
fields). In this format, content parts appear without their wrapping key (for example
7+
``{"tool_name": "search", "arguments": {}}`` instead of ``{"tool_call": {"tool_name": "search",
8+
"arguments": {}}}``) and are identified by their required field names. This makes it possible to
9+
round-trip ``ChatMessage`` objects that were implicitly serialized as part of larger Pydantic models.

test/dataclasses/test_chat_message.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import json
66
import warnings
77
from collections.abc import Sequence
8+
from typing import Any
89

910
import pytest
11+
from pydantic import BaseModel
1012

1113
from haystack.dataclasses.chat_message import (
1214
ChatMessage,
@@ -756,6 +758,85 @@ def test_to_trace_dict_with_file_content(self, base64_pdf_string):
756758
}
757759

758760

761+
class MessageEnvelope(BaseModel):
762+
message: ChatMessage
763+
764+
765+
class TestFromDictPydanticDump:
766+
"""
767+
`ChatMessage.from_dict` supports the format Pydantic produces when it auto-serializes ChatMessage as a plain
768+
dataclass: raw dataclass fields (`_role`, `_content`, ...) with unwrapped content parts.
769+
"""
770+
771+
def _pydantic_dump(self, message: ChatMessage) -> dict[str, Any]:
772+
return MessageEnvelope(message=message).model_dump(mode="json")["message"]
773+
774+
def test_text_message(self):
775+
message = ChatMessage.from_user("What is the answer?", meta={"some": "info"}, name="virginia")
776+
assert ChatMessage.from_dict(self._pydantic_dump(message)) == message
777+
778+
def test_tool_call_message(self):
779+
message = ChatMessage.from_assistant(
780+
tool_calls=[ToolCall(tool_name="mytool", arguments={"a": 1}, id="123", extra={"call_id": "123"})]
781+
)
782+
assert ChatMessage.from_dict(self._pydantic_dump(message)) == message
783+
784+
def test_tool_result_message(self):
785+
message = ChatMessage.from_tool(
786+
tool_result="42", origin=ToolCall(tool_name="mytool", arguments={"a": 1}, id="123"), error=False
787+
)
788+
assert ChatMessage.from_dict(self._pydantic_dump(message)) == message
789+
790+
def test_reasoning_message(self):
791+
message = ChatMessage.from_assistant(
792+
"Answer", reasoning=ReasoningContent(reasoning_text="Thinking...", extra={"key": "value"})
793+
)
794+
assert ChatMessage.from_dict(self._pydantic_dump(message)) == message
795+
796+
def test_image_message(self, base64_image_string):
797+
message = ChatMessage.from_user(
798+
content_parts=[
799+
TextContent(text="What is in this image?"),
800+
ImageContent(base64_image=base64_image_string, mime_type="image/png", detail="auto"),
801+
]
802+
)
803+
assert ChatMessage.from_dict(self._pydantic_dump(message)) == message
804+
805+
def test_file_message(self):
806+
message = ChatMessage.from_user(
807+
content_parts=[
808+
TextContent(text="Summarize this file."),
809+
FileContent(base64_data="aGVsbG8=", mime_type="text/plain", filename="hello.txt"),
810+
]
811+
)
812+
assert ChatMessage.from_dict(self._pydantic_dump(message)) == message
813+
814+
def test_multiple_messages(self, base64_image_string):
815+
class Response(BaseModel):
816+
messages: list[ChatMessage]
817+
818+
tool_call = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
819+
messages = [
820+
ChatMessage.from_user("What is the answer?"),
821+
ChatMessage.from_assistant(
822+
"Let me check.",
823+
meta={"some": "info"},
824+
tool_calls=[tool_call],
825+
reasoning=ReasoningContent(reasoning_text="Let me think about it..."),
826+
),
827+
ChatMessage.from_tool(tool_result="42", origin=tool_call),
828+
ChatMessage.from_user(
829+
content_parts=[
830+
ImageContent(base64_image=base64_image_string, mime_type="image/png"),
831+
FileContent(base64_data="aGVsbG8=", mime_type="text/plain", filename="hello.txt"),
832+
]
833+
),
834+
]
835+
836+
dumped = Response(messages=messages).model_dump(mode="json")
837+
assert [ChatMessage.from_dict(message) for message in dumped["messages"]] == messages
838+
839+
759840
class TestToOpenaiDictFormat:
760841
def test_to_openai_dict_format_system_message(self):
761842
message = ChatMessage.from_system("You are good assistant")

0 commit comments

Comments
 (0)