Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions haystack/dataclasses/chat_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}. "
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions test/dataclasses/test_chat_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
Loading