From abf1fd26c3469634c2938987521921aab7c82fac Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Thu, 16 Jul 2026 08:01:54 +0200 Subject: [PATCH] Add support to deserialize chat messages serialized with pydantic --- haystack/dataclasses/chat_message.py | 12 +++ ...m-dict-pydantic-dump-5b12e4b1d4985ec1.yaml | 9 +++ test/dataclasses/test_chat_message.py | 81 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 releasenotes/notes/chatmessage-from-dict-pydantic-dump-5b12e4b1d4985ec1.yaml diff --git a/haystack/dataclasses/chat_message.py b/haystack/dataclasses/chat_message.py index 39da893f665..4e619a90eb9 100644 --- a/haystack/dataclasses/chat_message.py +++ b/haystack/dataclasses/chat_message.py @@ -232,6 +232,18 @@ def _deserialize_content_part(part: dict[str, Any]) -> ChatMessageContentT: if serialization_key in part: return cls.from_dict(part[serialization_key]) + # Support for Pydantic's model_dump() output, which produces a flat dictionary without wrapping keys. + if "tool_name" in part and "arguments" in part: + return ToolCall.from_dict(part) + if "result" in part and "origin" in part: + return ToolCallResult.from_dict(part) + if "reasoning_text" in part: + return ReasoningContent.from_dict(part) + if "base64_image" in part: + return ImageContent.from_dict(part) + if "base64_data" in part: + return FileContent.from_dict(part) + # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs msg = ( f"Unsupported content part in the serialized ChatMessage: {part}. " diff --git a/releasenotes/notes/chatmessage-from-dict-pydantic-dump-5b12e4b1d4985ec1.yaml b/releasenotes/notes/chatmessage-from-dict-pydantic-dump-5b12e4b1d4985ec1.yaml new file mode 100644 index 00000000000..90dfffc2def --- /dev/null +++ b/releasenotes/notes/chatmessage-from-dict-pydantic-dump-5b12e4b1d4985ec1.yaml @@ -0,0 +1,9 @@ +--- +enhancements: + - | + ``ChatMessage.from_dict`` now also accepts the format Pydantic produces when it auto-serializes + ``ChatMessage`` as a plain dataclass (e.g. via ``model_dump`` on a Pydantic model with ``ChatMessage`` + fields). In this format, content parts appear without their wrapping key (for example + ``{"tool_name": "search", "arguments": {}}`` instead of ``{"tool_call": {"tool_name": "search", + "arguments": {}}}``) and are identified by their required field names. This makes it possible to + round-trip ``ChatMessage`` objects that were implicitly serialized as part of larger Pydantic models. diff --git a/test/dataclasses/test_chat_message.py b/test/dataclasses/test_chat_message.py index 52c600446ee..279e8c51002 100644 --- a/test/dataclasses/test_chat_message.py +++ b/test/dataclasses/test_chat_message.py @@ -5,8 +5,10 @@ import json import warnings from collections.abc import Sequence +from typing import Any import pytest +from pydantic import BaseModel from haystack.dataclasses.chat_message import ( ChatMessage, @@ -756,6 +758,85 @@ def test_to_trace_dict_with_file_content(self, base64_pdf_string): } +class MessageEnvelope(BaseModel): + message: ChatMessage + + +class TestFromDictPydanticDump: + """ + `ChatMessage.from_dict` supports the format Pydantic produces when it auto-serializes ChatMessage as a plain + dataclass: raw dataclass fields (`_role`, `_content`, ...) with unwrapped content parts. + """ + + def _pydantic_dump(self, message: ChatMessage) -> dict[str, Any]: + return MessageEnvelope(message=message).model_dump(mode="json")["message"] + + def test_text_message(self): + message = ChatMessage.from_user("What is the answer?", meta={"some": "info"}, name="virginia") + assert ChatMessage.from_dict(self._pydantic_dump(message)) == message + + def test_tool_call_message(self): + message = ChatMessage.from_assistant( + tool_calls=[ToolCall(tool_name="mytool", arguments={"a": 1}, id="123", extra={"call_id": "123"})] + ) + assert ChatMessage.from_dict(self._pydantic_dump(message)) == message + + def test_tool_result_message(self): + message = ChatMessage.from_tool( + tool_result="42", origin=ToolCall(tool_name="mytool", arguments={"a": 1}, id="123"), error=False + ) + assert ChatMessage.from_dict(self._pydantic_dump(message)) == message + + def test_reasoning_message(self): + message = ChatMessage.from_assistant( + "Answer", reasoning=ReasoningContent(reasoning_text="Thinking...", extra={"key": "value"}) + ) + assert ChatMessage.from_dict(self._pydantic_dump(message)) == message + + def test_image_message(self, base64_image_string): + message = ChatMessage.from_user( + content_parts=[ + TextContent(text="What is in this image?"), + ImageContent(base64_image=base64_image_string, mime_type="image/png", detail="auto"), + ] + ) + assert ChatMessage.from_dict(self._pydantic_dump(message)) == message + + def test_file_message(self): + message = ChatMessage.from_user( + content_parts=[ + TextContent(text="Summarize this file."), + FileContent(base64_data="aGVsbG8=", mime_type="text/plain", filename="hello.txt"), + ] + ) + assert ChatMessage.from_dict(self._pydantic_dump(message)) == message + + def test_multiple_messages(self, base64_image_string): + class Response(BaseModel): + messages: list[ChatMessage] + + tool_call = ToolCall(id="123", tool_name="mytool", arguments={"a": 1}) + messages = [ + ChatMessage.from_user("What is the answer?"), + ChatMessage.from_assistant( + "Let me check.", + meta={"some": "info"}, + tool_calls=[tool_call], + reasoning=ReasoningContent(reasoning_text="Let me think about it..."), + ), + ChatMessage.from_tool(tool_result="42", origin=tool_call), + ChatMessage.from_user( + content_parts=[ + ImageContent(base64_image=base64_image_string, mime_type="image/png"), + FileContent(base64_data="aGVsbG8=", mime_type="text/plain", filename="hello.txt"), + ] + ), + ] + + dumped = Response(messages=messages).model_dump(mode="json") + assert [ChatMessage.from_dict(message) for message in dumped["messages"]] == messages + + class TestToOpenaiDictFormat: def test_to_openai_dict_format_system_message(self): message = ChatMessage.from_system("You are good assistant")