Skip to content
Open
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
67 changes: 62 additions & 5 deletions src/google/adk/events/event_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<root>')

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]
Expand Down
135 changes: 135 additions & 0 deletions tests/unittests/events/test_event_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,35 @@
"""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):
x: int = 5
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."""

Expand Down Expand Up @@ -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('<root>' 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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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']