Skip to content

Commit 36793a9

Browse files
authored
fix: fix serialization of State and PipelineSnapshot (#12113)
1 parent 7f56f9e commit 36793a9

7 files changed

Lines changed: 235 additions & 186 deletions

File tree

haystack/components/agents/state/state.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from typing import Any, get_args
88

99
from haystack.dataclasses import ChatMessage
10-
from haystack.utils import _deserialize_value_with_schema, _serialize_value_with_schema
10+
from haystack.utils import _deserialize_value_with_schema
11+
from haystack.utils.base_serialization import _serialize_with_field_fallback
1112
from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
1213
from haystack.utils.type_serialization import deserialize_type, serialize_type
1314

@@ -194,7 +195,9 @@ def to_dict(self) -> dict[str, Any]:
194195
"""
195196
serialized = {}
196197
serialized["schema"] = _schema_to_dict(self.schema)
197-
serialized["data"] = _serialize_value_with_schema(self._data)
198+
# Field-level fallback so a single non-serializable value omits only that field instead of
199+
# failing the whole State serialization.
200+
serialized["data"] = _serialize_with_field_fallback(self._data, description="the agent's State data")
198201
return serialized
199202

200203
@classmethod

haystack/core/pipeline/breakpoint.py

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@
1313

1414
from haystack import logging
1515
from haystack.core.errors import PipelineInvalidPipelineSnapshotError
16-
from haystack.core.pipeline.utils import _deepcopy_with_exceptions
1716
from haystack.dataclasses.breakpoints import Breakpoint, PipelineSnapshot, PipelineState
18-
from haystack.utils.base_serialization import _serialize_value_with_schema
17+
from haystack.utils.base_serialization import _serialize_with_field_fallback
1918

2019
logger = logging.getLogger(__name__)
2120

@@ -292,54 +291,3 @@ def _transform_json_structure(data: dict[str, Any] | list[Any] | Any) -> Any:
292291

293292
# For other data types, just return the value as is.
294293
return data
295-
296-
297-
def _serialize_with_field_fallback(payload: Any, *, description: str) -> dict[str, Any]:
298-
"""
299-
Serialize a payload and, on failure, retry field-by-field to preserve resumable fields.
300-
301-
If the whole payload serializes, the result is returned as-is. Otherwise, and if the payload is a
302-
mapping, each top-level field is serialized individually and only the failing fields are omitted.
303-
When the payload is not a mapping, or when every field fails to serialize, the helper returns a
304-
structurally valid empty-object payload so that the downstream ``_deserialize_value_with_schema``
305-
can still load it back instead of raising ``DeserializationError`` on a bare ``{}``.
306-
307-
:param payload: The value to serialize.
308-
:param description: Short human-readable label used in warning messages, for example
309-
``"the agent's chat_generator inputs"`` or ``"the inputs of the current pipeline state"``.
310-
:returns: A dict of the form ``{"serialization_schema": ..., "serialized_data": ...}``.
311-
"""
312-
try:
313-
return _serialize_value_with_schema(_deepcopy_with_exceptions(payload))
314-
except Exception as error:
315-
logger.warning(
316-
"Failed to serialize {description}. "
317-
"Haystack will omit only the non-serializable fields when possible. Error: {e}",
318-
description=description,
319-
e=error,
320-
)
321-
322-
serialized_properties: dict[str, Any] = {}
323-
serialized_data: dict[str, Any] = {}
324-
325-
if isinstance(payload, dict):
326-
for field_name, value in payload.items():
327-
try:
328-
serialized_value = _serialize_value_with_schema(_deepcopy_with_exceptions(value))
329-
except Exception as field_error:
330-
logger.warning(
331-
"Failed to serialize the '{field_name}' field of {description}. "
332-
"The field will be omitted from the snapshot. Error: {e}",
333-
field_name=field_name,
334-
description=description,
335-
e=field_error,
336-
)
337-
continue
338-
339-
serialized_properties[field_name] = serialized_value["serialization_schema"]
340-
serialized_data[field_name] = serialized_value["serialized_data"]
341-
342-
return {
343-
"serialization_schema": {"type": "object", "properties": serialized_properties},
344-
"serialized_data": serialized_data,
345-
}

haystack/utils/base_serialization.py

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pydantic
99

1010
from haystack import logging
11-
from haystack.core.errors import DeserializationError
11+
from haystack.core.errors import DeserializationError, SerializationError
1212
from haystack.core.serialization import generate_qualified_class_name, import_class_by_name
1313
from haystack.utils import deserialize_callable, serialize_callable
1414

@@ -60,7 +60,7 @@ def _serialize_value_with_schema(payload: Any) -> dict[str, Any]: # noqa: PLR09
6060
return {"serialization_schema": {"type": "object", "properties": schema}, "serialized_data": data}
6161

6262
# Handle array case - iterate through elements
63-
if isinstance(payload, (list, tuple, set)):
63+
if isinstance(payload, (list, tuple, set, frozenset)):
6464
# Serialize each item in the array
6565
serialized_list = []
6666
for item in payload:
@@ -76,9 +76,12 @@ def _serialize_value_with_schema(payload: Any) -> dict[str, Any]: # noqa: PLR09
7676
else:
7777
base_schema = {"type": "array", "items": {}}
7878

79-
# Add JSON Schema properties to infer sets and tuples
80-
if isinstance(payload, set):
79+
# Add JSON Schema properties to infer sets, frozensets and tuples
80+
if isinstance(payload, (set, frozenset)):
8181
base_schema["uniqueItems"] = True
82+
# `frozen` distinguishes frozenset from set on deserialization
83+
if isinstance(payload, frozenset):
84+
base_schema["frozen"] = True
8285
elif isinstance(payload, tuple):
8386
base_schema["minItems"] = len(payload)
8487
base_schema["maxItems"] = len(payload)
@@ -101,19 +104,71 @@ def _serialize_value_with_schema(payload: Any) -> dict[str, Any]: # noqa: PLR09
101104
type_name = generate_qualified_class_name(type(payload))
102105
return {"serialization_schema": {"type": type_name}, "serialized_data": payload.name}
103106

104-
# Handle arbitrary objects with __dict__
105-
if hasattr(payload, "__dict__"):
106-
type_name = generate_qualified_class_name(type(payload))
107-
schema = {"type": type_name}
108-
serialized_data = {}
109-
for key, value in vars(payload).items():
110-
serialized_value = _serialize_value_with_schema(value)
111-
serialized_data[key] = serialized_value["serialized_data"]
112-
return {"serialization_schema": schema, "serialized_data": serialized_data}
113-
114107
# Handle primitives
115-
schema = {"type": _primitive_schema_type(payload)}
116-
return {"serialization_schema": schema, "serialized_data": payload}
108+
if payload is None or isinstance(payload, (bool, int, float, str)):
109+
return {"serialization_schema": {"type": _primitive_schema_type(payload)}, "serialized_data": payload}
110+
111+
# Unsupported type: raise so callers can omit the field (see `_serialize_with_field_fallback`)
112+
# instead of embedding a live, non-portable object in the output.
113+
raise SerializationError(
114+
f"Cannot serialize value of type '{type(payload).__name__}'. Supported values are primitives "
115+
f"(str, int, float, bool, None), lists/tuples/sets/frozensets/dicts of supported values, Enums, "
116+
f"callables, pydantic models, and objects exposing a 'to_dict' method."
117+
)
118+
119+
120+
def _serialize_with_field_fallback(payload: Any, *, description: str) -> dict[str, Any]:
121+
"""
122+
Serialize a payload and, on failure, retry field-by-field to preserve serializable fields.
123+
124+
If the whole payload serializes, the result is returned as-is. Otherwise, and if the payload is a
125+
mapping, each top-level field is serialized individually and only the failing fields are omitted.
126+
When the payload is not a mapping, or when every field fails to serialize, the helper returns a
127+
structurally valid empty-object payload so that the downstream `_deserialize_value_with_schema`
128+
can still load it back instead of raising `DeserializationError` on a bare `{}`.
129+
130+
:param payload: The value to serialize.
131+
:param description: Short human-readable label used in warning messages, for example
132+
`"the agent's State data"` or `"the inputs of the current pipeline state"`.
133+
:returns: A dict of the form `{"serialization_schema": ..., "serialized_data": ...}`.
134+
"""
135+
# Local import to avoid a circular import at module load time.
136+
from haystack.core.pipeline.utils import _deepcopy_with_exceptions
137+
138+
try:
139+
return _serialize_value_with_schema(_deepcopy_with_exceptions(payload))
140+
except Exception as error:
141+
logger.warning(
142+
"Failed to serialize {description}. "
143+
"Haystack will omit only the non-serializable fields when possible. Error: {e}",
144+
description=description,
145+
e=error,
146+
)
147+
148+
serialized_properties: dict[str, Any] = {}
149+
serialized_data: dict[str, Any] = {}
150+
151+
if isinstance(payload, dict):
152+
for field_name, value in payload.items():
153+
try:
154+
serialized_value = _serialize_value_with_schema(_deepcopy_with_exceptions(value))
155+
except Exception as field_error:
156+
logger.warning(
157+
"Failed to serialize the '{field_name}' field of {description}. "
158+
"The field will be omitted from the snapshot. Error: {e}",
159+
field_name=field_name,
160+
description=description,
161+
e=field_error,
162+
)
163+
continue
164+
165+
serialized_properties[field_name] = serialized_value["serialization_schema"]
166+
serialized_data[field_name] = serialized_value["serialized_data"]
167+
168+
return {
169+
"serialization_schema": {"type": "object", "properties": serialized_properties},
170+
"serialized_data": serialized_data,
171+
}
117172

118173

119174
def _primitive_schema_type(value: Any) -> str:
@@ -183,10 +238,10 @@ def _deserialize_value_with_schema(serialized: dict[str, Any]) -> Any:
183238
_deserialize_value_with_schema({"serialization_schema": schema["items"], "serialized_data": item})
184239
for item in data
185240
]
186-
final_array: list | set | tuple
187-
# Is a set if uniqueItems is True
241+
final_array: list | set | frozenset | tuple
242+
# Is a set or frozenset if uniqueItems is True
188243
if schema.get("uniqueItems") is True:
189-
final_array = set(deserialized_items)
244+
final_array = frozenset(deserialized_items) if schema.get("frozen") is True else set(deserialized_items)
190245
# Is a tuple if minItems and maxItems are set
191246
elif schema.get("minItems") is not None and schema.get("maxItems") is not None:
192247
final_array = tuple(deserialized_items)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed the schema-aware serialization helper used for pipeline snapshots and ``Agent`` ``State``
5+
(``_serialize_value_with_schema``) so it no longer silently passes unsupported objects through as
6+
if they were serialized. Values such as ``datetime``, ``bytes``, ``complex`` and arbitrary objects
7+
without a ``to_dict`` method were previously stored unchanged and mislabeled as strings, which broke
8+
JSON storage and round-tripping of snapshots. Unsupported values now raise a ``SerializationError``,
9+
and the callers that build snapshots (pipeline breakpoints and ``State.to_dict``) catch it to omit
10+
only the offending field while keeping the rest of the payload resumable.
11+
- |
12+
Added support for serializing and deserializing ``frozenset`` values in ``_serialize_value_with_schema``.
13+
A ``frozenset`` now round-trips back to a ``frozenset`` instead of being dropped.

test/components/agents/test_state_class.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
import inspect
6+
import logging
67
from dataclasses import dataclass
8+
from datetime import datetime
79
from typing import Dict, Generic, List, Optional, TypeVar, Union
810

911
import pytest
@@ -512,6 +514,16 @@ def test_state_to_dict_typing_list(self):
512514
},
513515
}
514516

517+
def test_state_to_dict_omits_non_serializable_field(self, caplog):
518+
# A non-serializable value must not crash serialization: only that field is omitted,
519+
# and a warning is logged.
520+
state = State({"good": {"type": int}, "bad": {"type": object}}, {"good": 1, "bad": datetime(2024, 1, 1)})
521+
with caplog.at_level(logging.WARNING):
522+
state_dict = state.to_dict()
523+
assert state_dict["data"]["serialized_data"] == {"good": 1}
524+
assert "bad" not in state_dict["data"]["serialization_schema"]["properties"]
525+
assert any("Failed to serialize the 'bad' field of the agent's State data" in msg for msg in caplog.messages)
526+
515527
def test_state_from_dict_typing_list(self):
516528
state_dict = {
517529
"schema": {

test/core/pipeline/test_breakpoint.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -349,19 +349,24 @@ def to_dict(self):
349349
def test_save_pipeline_snapshot_raises_on_failure(tmp_path, caplog, monkeypatch):
350350
monkeypatch.setenv(HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED, "true")
351351

352+
# Point the snapshot directory below an existing file so creating it fails with a filesystem
353+
# error, exercising the raise_on_failure contract.
354+
blocking_file = tmp_path / "not_a_dir"
355+
blocking_file.write_text("i am a file")
356+
snapshot_path = blocking_file / "snapshots"
357+
352358
snapshot = _create_pipeline_snapshot(
353359
inputs={},
354360
component_inputs={},
355-
break_point=Breakpoint(component_name="comp2", snapshot_file_path=str(tmp_path)),
361+
break_point=Breakpoint(component_name="comp2", snapshot_file_path=str(snapshot_path)),
356362
component_visits={"comp1": 1, "comp2": 0},
357363
original_input_data={},
358364
ordered_component_names=["comp1", "comp2"],
359365
include_outputs_from={"comp1"},
360-
# We use a non-serializable type (bytes) directly in pipeline outputs to trigger the error
361-
pipeline_outputs={"comp1": {"result": b"test"}},
366+
pipeline_outputs={"comp1": {"result": "test"}},
362367
)
363368

364-
with pytest.raises(TypeError):
369+
with pytest.raises(OSError):
365370
_save_pipeline_snapshot(snapshot)
366371

367372
with caplog.at_level(logging.ERROR):

0 commit comments

Comments
 (0)