diff --git a/src/google/adk/events/event_actions.py b/src/google/adk/events/event_actions.py index dd0ada936bc..32d460d6745 100644 --- a/src/google/adk/events/event_actions.py +++ b/src/google/adk/events/event_actions.py @@ -45,14 +45,71 @@ def _make_json_serializable(obj: Any) -> Any: """Converts an object into a JSON-serializable form. - Used as a fallback when the default Pydantic serialization fails. Delegates to + Used as a fallback when the default Pydantic serialization fails. Walks the + structure recursively and delegates each leaf to `pydantic_core.to_jsonable_python` so rich types (e.g. datetimes, Pydantic models) are serialized faithfully instead of being discarded. Values that - pydantic-core cannot serialize (e.g. Python callables stored in session state) - are replaced with their `repr` via `serialize_unknown=True` so the overall - structure can still be persisted without crashing. + pydantic-core cannot serialize (e.g. Python callables stored in session + state, or Pydantic models whose serializer was not built yet) are replaced + with their `repr` so the overall structure can still be persisted without + crashing. """ - return to_jsonable_python(obj, serialize_unknown=True) + failed_paths: list[str] = [] + + def _convert_key(key: Any) -> str: + if isinstance(key, bool): + return 'true' if key else 'false' + if isinstance(key, tuple): + return ','.join(_convert_key(element) for element in key) + try: + converted = to_jsonable_python(key, serialize_unknown=True) + # pydantic-core unwraps enum keys before key inference and converts + # tuples to lists; comma-join the unwrapped sequence so e.g. an enum + # with a tuple value renders like the equivalent plain tuple key would. + if isinstance(converted, (tuple, list)): + return ','.join(_convert_key(element) for element in converted) + except Exception: # pylint: disable=broad-except + return repr(key) + return converted if isinstance(converted, str) else str(converted) + + def _record_failure(path: tuple[Any, ...]) -> None: + failed_paths.append('.'.join(str(segment) for segment in path) or '') + + def _convert(value: Any, path: tuple[Any, ...] = ()) -> Any: + if isinstance(value, dict): + try: + return { + _convert_key(key): _convert(item, path + (key,)) + for key, item in value.items() + } + except Exception: # pylint: disable=broad-except + # Container subclasses can override protocol methods in a way that + # breaks iteration; fall back instead of letting it escape. + _record_failure(path) + return repr(value) + if isinstance(value, (list, tuple, set, frozenset)): + try: + return [ + _convert(item, path + (index,)) for index, item in enumerate(value) + ] + except Exception: # pylint: disable=broad-except + _record_failure(path) + return repr(value) + try: + return to_jsonable_python(value, serialize_unknown=True) + except Exception: # pylint: disable=broad-except + _record_failure(path) + return repr(value) + + result = _convert(obj) + if failed_paths: + logger.warning( + 'Some values in the state are not JSON-serializable and were' + ' replaced with their string representation in the persisted event.' + ' Affected paths: %s', + ', '.join(failed_paths), + ) + return result class EventCompaction(BaseModel): # type: ignore[misc] diff --git a/tests/unittests/events/test_event_actions.py b/tests/unittests/events/test_event_actions.py index ef864060db9..4b2a3af3948 100644 --- a/tests/unittests/events/test_event_actions.py +++ b/tests/unittests/events/test_event_actions.py @@ -17,11 +17,13 @@ """Unit tests for EventActions serialization and its fallback helper.""" import datetime +from enum import Enum import logging from google.adk.events.event_actions import _make_json_serializable from google.adk.events.event_actions import EventActions from pydantic import BaseModel +from pydantic_core import to_jsonable_python class _Sample(BaseModel): @@ -29,6 +31,21 @@ class _Sample(BaseModel): label: str = 'hi' +class _DeferredModel(BaseModel): + """A Pydantic model whose serializer was not built yet (deferred build). + + google-genai 2.18+ leaves a placeholder (MockValSer) in + `__pydantic_serializer__` when defer_build=True, so serializing the model + raises TypeError before `serialize_unknown` can apply. Replacing it with a + plain object() reproduces the same failure shape. + """ + + x: int = 1 + + +_DeferredModel.__pydantic_serializer__ = object() + + class TestMakeJsonSerializable: """Tests for the `_make_json_serializable` fallback helper.""" @@ -62,6 +79,86 @@ def test_unserializable_value_nested(self): assert result['ok'] == 2 assert isinstance(result['cb'], str) + def test_deferred_model_at_root_is_replaced_with_repr(self, caplog): + """A deferred model at the root is replaced with its repr.""" + with caplog.at_level(logging.WARNING): + result = _make_json_serializable(_DeferredModel()) + + assert isinstance(result, str) + assert '_DeferredModel' in result + assert any('' in record.message for record in caplog.records) + + def test_deferred_model_is_replaced_with_repr(self, caplog): + """A deferred model in a dict is replaced while siblings are preserved.""" + with caplog.at_level(logging.WARNING): + result = _make_json_serializable({'bad': _DeferredModel(), 'ok': 1}) + + assert result['ok'] == 1 + assert isinstance(result['bad'], str) + assert '_DeferredModel' in result['bad'] + assert any( + 'Affected paths: bad' in record.message for record in caplog.records + ) + + def test_deferred_model_nested_in_containers_is_replaced(self, caplog): + """A deferred model in lists and dicts is replaced without crashing.""" + with caplog.at_level(logging.WARNING): + result = _make_json_serializable({ + 'items': [_DeferredModel(), 'ok'], + 'inner': {'deep': _DeferredModel()}, + }) + + assert result['items'][1] == 'ok' + assert isinstance(result['items'][0], str) + assert isinstance(result['inner']['deep'], str) + assert any( + 'items.0, inner.deep' in record.message for record in caplog.records + ) + + def test_tuple_and_set_serialize_to_lists(self): + """Tuples and sets serialize to lists, matching `to_jsonable_python`.""" + assert _make_json_serializable((1, (2, 3))) == [1, [2, 3]] + assert sorted(_make_json_serializable({1, 2})) == [1, 2] + assert _make_json_serializable(frozenset({'a'})) == ['a'] + + def test_healthy_output_matches_to_jsonable_python(self): + """Healthy inputs produce the same output as `to_jsonable_python`.""" + dt = datetime.datetime(2024, 5, 6, tzinfo=datetime.timezone.utc) + + class _EnumWithTupleValue(Enum): + TUP = ('a', 'b') + + values = [ + {'a': 1, 'b': [1, 2], 'c': {'d': 'e'}, 'f': None, 'g': True}, + {'when': dt, 'model': _Sample(), 'n': [1]}, + {'cb': lambda: 1, 'ok': 2}, + [1, 'two', None], + {'t': (1, 2), 's': {'x'}}, + {_EnumWithTupleValue.TUP: 1, (_EnumWithTupleValue.TUP,): 2}, + ] + + for value in values: + assert _make_json_serializable(value) == to_jsonable_python( + value, serialize_unknown=True + ) + + def test_container_with_raising_items_falls_back_to_repr(self, caplog): + """A dict subclass with a broken items() falls back instead of raising.""" + + class _DictWithBoomItems(dict): + + def items(self): + raise RuntimeError('boom') + + value = {'outer': _DictWithBoomItems({'a': 1})} + + with caplog.at_level(logging.WARNING): + result = _make_json_serializable(value) + + assert isinstance(result['outer'], str) + assert 'a' in result['outer'] + assert 'outer' in caplog.text + class TestStateDeltaSerialization: """Tests for the `state_delta` wrap serializer.""" @@ -120,6 +217,34 @@ def test_exclude_is_respected_in_fallback_path(self): assert dumped['state_delta']['ok'] == 2 assert isinstance(dumped['state_delta']['cb'], str) + def test_deferred_pydantic_model_in_state_delta_does_not_raise(self): + """A deferred Pydantic model in state_delta serializes without crashing.""" + dt = datetime.datetime(2024, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) + actions = EventActions( + state_delta={'bad': _DeferredModel(), 'when': dt, 'ok': 'healthy'} + ) + + dumped = actions.model_dump(mode='json') + + assert dumped['state_delta']['ok'] == 'healthy' + assert dumped['state_delta']['when'] == '2024-01-02T03:04:05Z' + assert isinstance(dumped['state_delta']['bad'], str) + assert '_DeferredModel' in dumped['state_delta']['bad'] + + def test_deferred_pydantic_model_in_state_delta_logs_warning(self, caplog): + """A deferred model in state_delta logs the affected key paths.""" + actions = EventActions( + state_delta={'bad': _DeferredModel(), 'nested': {'x': _DeferredModel()}} + ) + + with caplog.at_level(logging.WARNING): + actions.model_dump(mode='json') + + assert any( + 'Affected paths: bad, nested.x' in record.message + for record in caplog.records + ) + class TestAgentStateSerialization: """Tests for the `agent_state` wrap serializer.""" @@ -148,3 +273,13 @@ def test_non_serializable_agent_state_logs_warning(self, caplog): 'Failed to serialize `agent_state`' in record.message for record in caplog.records ) + + def test_deferred_pydantic_model_in_agent_state_does_not_raise(self): + """A deferred Pydantic model in agent_state serializes without crashing.""" + actions = EventActions(agent_state={'bad': _DeferredModel(), 'n': 3}) + + dumped = actions.model_dump(mode='json') + + assert dumped['agent_state']['n'] == 3 + assert isinstance(dumped['agent_state']['bad'], str) + assert '_DeferredModel' in dumped['agent_state']['bad']