From 869fd030c8efda4a564bc388f44ead9e5e5309d4 Mon Sep 17 00:00:00 2001 From: CoffeeDrivenCoder Date: Thu, 20 Aug 2026 19:30:28 +0800 Subject: [PATCH 1/3] Python: recursively serialize nested values --- .../core/agent_framework/_serialization.py | 118 +++++++++--------- .../tests/core/test_serializable_mixin.py | 33 +++++ 2 files changed, 93 insertions(+), 58 deletions(-) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index cb7f3a0db89..7c7a18d065b 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -9,7 +9,7 @@ import re from collections.abc import Mapping, MutableMapping from dataclasses import asdict, is_dataclass -from datetime import date, datetime +from datetime import date, datetime, time from functools import lru_cache from typing import Any, ClassVar, Protocol, TypeGuard, TypeVar, cast, runtime_checkable @@ -19,6 +19,7 @@ ProtocolT = TypeVar("ProtocolT", bound="SerializationProtocol") _JSON_SCALAR_TYPES = (str, int, float, bool, type(None)) _DIRECT_JSON_TYPES = (*_JSON_SCALAR_TYPES, list, dict) +_SKIP_SERIALIZATION = object() # Regex pattern for converting CamelCase to snake_case _CAMEL_TO_SNAKE_PATTERN = re.compile(r"(? TypeGuard[SerializationProtocol]: return callable(getattr(value, "to_dict", None)) and callable(getattr(value, "from_dict", None)) +def _serialize_value( + value: Any, + *, + exclude: set[str] | None, + exclude_none: bool, + attribute_name: str, +) -> Any: + """Recursively serialize a value while preserving skip semantics.""" + if type(value) in _JSON_SCALAR_TYPES: + return value + if isinstance(value, (datetime, date, time)): + return str(value) + if _is_serialization_protocol(value): + serialized = value.to_dict(exclude=exclude, exclude_none=exclude_none) + return _serialize_value( + serialized, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=attribute_name, + ) + if isinstance(value, list): + serialized_list: list[Any] = [] + for item in cast(list[Any], value): + serialized = _serialize_value( + item, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=attribute_name, + ) + if serialized is not _SKIP_SERIALIZATION: + serialized_list.append(serialized) + return serialized_list + if isinstance(value, dict): + serialized_dict: dict[str, Any] = {} + for raw_key, item in cast(dict[Any, Any], value).items(): + serialized = _serialize_value( + item, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=attribute_name, + ) + if serialized is not _SKIP_SERIALIZATION: + serialized_dict[str(raw_key)] = serialized + return serialized_dict + if is_serializable(value): + return value + logger.debug(f"Skipping non-serializable value in attribute '{attribute_name}' of type {type(value).__name__}") + return _SKIP_SERIALIZATION + + class SerializationMixin: """Mixin class providing comprehensive serialization and deserialization capabilities. @@ -335,63 +386,14 @@ def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) if key not in combined_exclude and not key.startswith("_"): if exclude_none and value is None: continue - if type(value) in _JSON_SCALAR_TYPES: - result[key] = value - continue - # Recursively serialize SerializationProtocol objects - if _is_serialization_protocol(value): - result[key] = value.to_dict(exclude=exclude, exclude_none=exclude_none) - continue - # Handle lists containing SerializationProtocol objects - if isinstance(value, list): - value_as_list: list[Any] = [] - for item in cast(list[Any], value): - if type(item) in _JSON_SCALAR_TYPES: - value_as_list.append(item) - continue - if _is_serialization_protocol(item): - value_as_list.append(item.to_dict(exclude=exclude, exclude_none=exclude_none)) - continue - if is_serializable(item): - value_as_list.append(item) - continue - logger.debug( - f"Skipping non-serializable item in list attribute '{key}' of type {type(item).__name__}" - ) - result[key] = value_as_list - continue - # Handle dicts containing SerializationProtocol values - if isinstance(value, dict): - from datetime import date, datetime, time - - serialized_dict: dict[str, Any] = {} - for raw_key, v in cast(dict[Any, Any], value).items(): - dict_key = str(raw_key) - # Convert datetime objects to strings - if isinstance(v, (datetime, date, time)): - serialized_dict[dict_key] = str(v) - continue - if type(v) in _JSON_SCALAR_TYPES: - serialized_dict[dict_key] = v - continue - if _is_serialization_protocol(v): - serialized_dict[dict_key] = v.to_dict(exclude=exclude, exclude_none=exclude_none) - continue - # Check if the value is JSON serializable - if is_serializable(v): - serialized_dict[dict_key] = v - continue - logger.debug( - f"Skipping non-serializable value for key '{dict_key}' in dict attribute '{key}' " - f"of type {type(v).__name__}" - ) - result[key] = serialized_dict - continue - # Directly include JSON serializable values - if is_serializable(value): - result[key] = value - continue - logger.debug(f"Skipping non-serializable attribute '{key}' of type {type(value).__name__}") + serialized = _serialize_value( + value, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=key, + ) + if serialized is not _SKIP_SERIALIZATION: + result[key] = serialized return result diff --git a/python/packages/core/tests/core/test_serializable_mixin.py b/python/packages/core/tests/core/test_serializable_mixin.py index 8cbebdd7927..359157eed75 100644 --- a/python/packages/core/tests/core/test_serializable_mixin.py +++ b/python/packages/core/tests/core/test_serializable_mixin.py @@ -2,6 +2,7 @@ """Tests for SerializationMixin functionality.""" +import json import logging from typing import Any @@ -303,6 +304,38 @@ def __init__(self, items_dict: dict): assert data["items_dict"]["a"]["name"] == "item1" assert data["items_dict"]["b"]["name"] == "item2" + def test_to_dict_recursively_serializes_nested_containers(self): + """Test to_dict serializes protocol objects nested in containers.""" + + class ItemClass(SerializationMixin): + def __init__(self, name: str): + self.name = name + + class ContainerClass(SerializationMixin): + def __init__(self, payload: dict): + self.payload = payload + + container = ContainerClass(payload={"groups": [{"items": [ItemClass(name="item1")]}]}) + + data = container.to_dict() + + assert data["payload"]["groups"][0]["items"][0]["name"] == "item1" + assert json.loads(container.to_json()) == data + + def test_to_dict_skips_non_serializable_values_in_nested_containers(self): + """Test to_dict skips unsupported values at every nesting level.""" + + class NonSerializable: + pass + + class ContainerClass(SerializationMixin): + def __init__(self, payload: dict): + self.payload = payload + + container = ContainerClass(payload={"groups": [{"items": ["valid", NonSerializable()]}]}) + + assert container.to_dict()["payload"] == {"groups": [{"items": ["valid"]}]} + def test_to_dict_with_datetime_in_dict(self): """Test to_dict converts datetime objects in dicts to strings.""" from datetime import datetime From 60d9e3330cd2004fba561f01053211b19c333479 Mon Sep 17 00:00:00 2001 From: CoffeeDrivenCoder Date: Thu, 20 Aug 2026 12:52:32 +0000 Subject: [PATCH 2/3] Python: preserve date/time serialization contexts Keep date and time conversion scoped to dictionary values while retaining recursive protocol and container serialization. --- .../core/agent_framework/_serialization.py | 5 +++-- .../tests/core/test_serializable_mixin.py | 21 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 7c7a18d065b..09ea2b3f4d0 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -162,8 +162,6 @@ def _serialize_value( """Recursively serialize a value while preserving skip semantics.""" if type(value) in _JSON_SCALAR_TYPES: return value - if isinstance(value, (datetime, date, time)): - return str(value) if _is_serialization_protocol(value): serialized = value.to_dict(exclude=exclude, exclude_none=exclude_none) return _serialize_value( @@ -187,6 +185,9 @@ def _serialize_value( if isinstance(value, dict): serialized_dict: dict[str, Any] = {} for raw_key, item in cast(dict[Any, Any], value).items(): + if isinstance(item, (datetime, date, time)): + serialized_dict[str(raw_key)] = str(item) + continue serialized = _serialize_value( item, exclude=exclude, diff --git a/python/packages/core/tests/core/test_serializable_mixin.py b/python/packages/core/tests/core/test_serializable_mixin.py index 359157eed75..038b4586ff1 100644 --- a/python/packages/core/tests/core/test_serializable_mixin.py +++ b/python/packages/core/tests/core/test_serializable_mixin.py @@ -4,6 +4,7 @@ import json import logging +from datetime import date, datetime, time from typing import Any import pytest @@ -336,19 +337,21 @@ def __init__(self, payload: dict): assert container.to_dict()["payload"] == {"groups": [{"items": ["valid"]}]} - def test_to_dict_with_datetime_in_dict(self): - """Test to_dict converts datetime objects in dicts to strings.""" - from datetime import datetime + @pytest.mark.parametrize("value", [datetime(2025, 1, 27, 12), date(2025, 1, 27), time(12)]) + def test_to_dict_only_converts_date_time_in_dict_values(self, value): + """Test to_dict preserves the existing date/time conversion contexts.""" class TestClass(SerializationMixin): - def __init__(self, metadata: dict): - self.metadata = metadata + def __init__(self): + self.top_level = value + self.items = [value] + self.metadata = {"created_at": value} - now = datetime(2025, 1, 27, 12, 0, 0) - obj = TestClass(metadata={"created_at": now}) - data = obj.to_dict() + data = TestClass().to_dict() - assert isinstance(data["metadata"]["created_at"], str) + assert "top_level" not in data + assert data["items"] == [] + assert data["metadata"]["created_at"] == str(value) def test_to_dict_skips_non_serializable_in_dict(self, caplog): """Test to_dict skips non-serializable values in dicts with debug logging.""" From 8f3b8be1fc1fae7126e10f3c5c7a961002922c0b Mon Sep 17 00:00:00 2001 From: CoffeeDrivenCoder Date: Fri, 21 Aug 2026 15:14:13 +0800 Subject: [PATCH 3/3] Python: preserve recursive serialization compatibility --- .../core/agent_framework/_serialization.py | 75 ++++++++++++------- .../tests/core/test_serializable_mixin.py | 43 +++++++++++ 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 09ea2b3f4d0..c6d6890c6d0 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -158,8 +158,12 @@ def _serialize_value( exclude: set[str] | None, exclude_none: bool, attribute_name: str, + active_container_ids: set[int] | None = None, + stringify_dict_keys: bool = False, ) -> Any: """Recursively serialize a value while preserving skip semantics.""" + if active_container_ids is None: + active_container_ids = set() if type(value) in _JSON_SCALAR_TYPES: return value if _is_serialization_protocol(value): @@ -169,34 +173,54 @@ def _serialize_value( exclude=exclude, exclude_none=exclude_none, attribute_name=attribute_name, + active_container_ids=active_container_ids, ) if isinstance(value, list): - serialized_list: list[Any] = [] - for item in cast(list[Any], value): - serialized = _serialize_value( - item, - exclude=exclude, - exclude_none=exclude_none, - attribute_name=attribute_name, - ) - if serialized is not _SKIP_SERIALIZATION: - serialized_list.append(serialized) - return serialized_list + value_as_list = cast(list[Any], value) + container_id = id(value_as_list) + if container_id in active_container_ids: + raise ValueError("Circular reference detected") + active_container_ids.add(container_id) + try: + serialized_list: list[Any] = [] + for item in value_as_list: + serialized = _serialize_value( + item, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=attribute_name, + active_container_ids=active_container_ids, + ) + if serialized is not _SKIP_SERIALIZATION: + serialized_list.append(serialized) + return serialized_list + finally: + active_container_ids.remove(container_id) if isinstance(value, dict): - serialized_dict: dict[str, Any] = {} - for raw_key, item in cast(dict[Any, Any], value).items(): - if isinstance(item, (datetime, date, time)): - serialized_dict[str(raw_key)] = str(item) - continue - serialized = _serialize_value( - item, - exclude=exclude, - exclude_none=exclude_none, - attribute_name=attribute_name, - ) - if serialized is not _SKIP_SERIALIZATION: - serialized_dict[str(raw_key)] = serialized - return serialized_dict + value_as_dict = cast(dict[Any, Any], value) + container_id = id(value_as_dict) + if container_id in active_container_ids: + raise ValueError("Circular reference detected") + active_container_ids.add(container_id) + try: + serialized_dict: dict[Any, Any] = {} + for raw_key, item in value_as_dict.items(): + dict_key = str(raw_key) if stringify_dict_keys else raw_key + if isinstance(item, (datetime, date, time)): + serialized_dict[dict_key] = str(item) + continue + serialized = _serialize_value( + item, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=attribute_name, + active_container_ids=active_container_ids, + ) + if serialized is not _SKIP_SERIALIZATION: + serialized_dict[dict_key] = serialized + return serialized_dict + finally: + active_container_ids.remove(container_id) if is_serializable(value): return value logger.debug(f"Skipping non-serializable value in attribute '{attribute_name}' of type {type(value).__name__}") @@ -392,6 +416,7 @@ def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) exclude=exclude, exclude_none=exclude_none, attribute_name=key, + stringify_dict_keys=isinstance(value, dict), ) if serialized is not _SKIP_SERIALIZATION: result[key] = serialized diff --git a/python/packages/core/tests/core/test_serializable_mixin.py b/python/packages/core/tests/core/test_serializable_mixin.py index 038b4586ff1..756883175a0 100644 --- a/python/packages/core/tests/core/test_serializable_mixin.py +++ b/python/packages/core/tests/core/test_serializable_mixin.py @@ -323,6 +323,49 @@ def __init__(self, payload: dict): assert data["payload"]["groups"][0]["items"][0]["name"] == "item1" assert json.loads(container.to_json()) == data + def test_to_dict_preserves_nested_non_string_dict_keys(self): + """Test recursive serialization preserves non-string keys below the attribute dictionary.""" + + class ContainerClass(SerializationMixin): + def __init__(self, payload: dict): + self.payload = payload + + container = ContainerClass(payload={7: "direct", "nested": {True: "enabled", None: "missing"}}) + + data = container.to_dict() + + assert data["payload"]["7"] == "direct" + assert data["payload"]["nested"] == {True: "enabled", None: "missing"} + json_payload = json.loads(container.to_json())["payload"] + assert json_payload["7"] == "direct" + assert json_payload["nested"] == {"true": "enabled", "null": "missing"} + + def test_to_dict_rejects_circular_list(self): + """Test recursive serialization reports a controlled error for a circular list.""" + + class ContainerClass(SerializationMixin): + def __init__(self, payload: list[Any]): + self.payload = payload + + payload: list[Any] = [] + payload.append(payload) + + with pytest.raises(ValueError, match="Circular reference detected"): + ContainerClass(payload).to_dict() + + def test_to_dict_rejects_circular_dict(self): + """Test recursive serialization reports a controlled error for a circular dictionary.""" + + class ContainerClass(SerializationMixin): + def __init__(self, payload: dict[str, Any]): + self.payload = payload + + payload: dict[str, Any] = {} + payload["self"] = payload + + with pytest.raises(ValueError, match="Circular reference detected"): + ContainerClass(payload).to_dict() + def test_to_dict_skips_non_serializable_values_in_nested_containers(self): """Test to_dict skips unsupported values at every nesting level."""