Skip to content

Commit 757ef22

Browse files
Raman369AIharanrk
authored andcommitted
fix(sessions): prevent PydanticSerializationError when session state contains non-serializable objects
Merge google#4748 ## Summary Fixes google#4724 `DatabaseSessionService.append_event` crashes with `PydanticSerializationError: Unable to serialize unknown type: <class 'function'>` when session state contains non-JSON-serializable objects (e.g. Python callables stored via MCP tool callbacks). **Root cause:** `EventActions.state_delta` is typed as `dict[str, object]` and `agent_state` as `dict[str, Any]` — both accept arbitrary Python objects. When `StorageEvent.from_event()` calls `event.model_dump(mode="json")`, Pydantic cannot serialize callables and raises `PydanticSerializationError`, crashing the agent. **Fix:** Add Pydantic `@field_serializer` decorators for `state_delta` and `agent_state` in `EventActions`. A helper `_make_json_serializable()` recursively walks the dict/list structure and replaces any non-JSON-serializable leaf value with a descriptive string (`<not serializable: typename>`), so the event is safely persisted without crashing and without losing any serializable data. ## Changes - `src/google/adk/events/event_actions.py` - Added `_make_json_serializable()` helper - Added `@field_serializer('state_delta')` on `EventActions` - Added `@field_serializer('agent_state')` on `EventActions` ## Test plan - [ ] Existing unit tests pass - [ ] Create an agent with MCP tools using `DatabaseSessionService`; confirm `append_event` no longer crashes when state contains callable values - [ ] Confirm serializable state values are persisted correctly (no regression) Co-authored-by: Haran Rajkumar <haranrk@google.com> COPYBARA_INTEGRATE_REVIEW=google#4748 from Raman369AI:fix/database-session-pydantic-serialization-error f2e73e9 PiperOrigin-RevId: 944235500
1 parent 834a2e1 commit 757ef22

2 files changed

Lines changed: 215 additions & 2 deletions

File tree

src/google/adk/events/event_actions.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
from __future__ import annotations
1616

17+
import logging
1718
from typing import Any
19+
from typing import cast
1820
from typing import Optional
1921
from typing import Union
2022

@@ -23,13 +25,31 @@
2325
from pydantic import BaseModel
2426
from pydantic import ConfigDict
2527
from pydantic import Field
28+
from pydantic import field_serializer
29+
from pydantic import SerializerFunctionWrapHandler
30+
from pydantic_core import to_jsonable_python
2631

2732
from ..auth.auth_tool import AuthConfig
2833
from ..tools.tool_confirmation import ToolConfirmation
2934
from .ui_widget import UiWidget
3035

36+
logger = logging.getLogger('google_adk.' + __name__)
3137

32-
class EventCompaction(BaseModel):
38+
39+
def _make_json_serializable(obj: Any) -> Any:
40+
"""Converts an object into a JSON-serializable form.
41+
42+
Used as a fallback when the default Pydantic serialization fails. Delegates to
43+
`pydantic_core.to_jsonable_python` so rich types (e.g. datetimes, Pydantic
44+
models) are serialized faithfully instead of being discarded. Values that
45+
pydantic-core cannot serialize (e.g. Python callables stored in session state)
46+
are replaced with their `repr` via `serialize_unknown=True` so the overall
47+
structure can still be persisted without crashing.
48+
"""
49+
return to_jsonable_python(obj, serialize_unknown=True)
50+
51+
52+
class EventCompaction(BaseModel): # type: ignore[misc]
3353
"""The compaction of the events."""
3454

3555
model_config = ConfigDict(
@@ -49,7 +69,7 @@ class EventCompaction(BaseModel):
4969
"""The compacted content of the events."""
5070

5171

52-
class EventActions(BaseModel):
72+
class EventActions(BaseModel): # type: ignore[misc]
5373
"""Represents the actions attached to an event."""
5474

5575
model_config = ConfigDict(
@@ -68,6 +88,27 @@ class EventActions(BaseModel):
6888
state_delta: dict[str, Any] = Field(default_factory=dict)
6989
"""Indicates that the event is updating the state with the given delta."""
7090

91+
@field_serializer('state_delta', mode='wrap') # type: ignore[misc, untyped-decorator]
92+
def _serialize_state_delta(
93+
self, value: dict[str, object], handler: SerializerFunctionWrapHandler
94+
) -> dict[str, Any]:
95+
# Use a wrap serializer so the default serialization (which honors callers'
96+
# `exclude`/`include` directives, e.g. the conformance harness excluding
97+
# internal `_adk_*` keys) is preserved. Only fall back to sanitization when
98+
# the value contains objects Pydantic cannot serialize (e.g. callables).
99+
try:
100+
return cast(dict[str, Any], handler(value))
101+
except Exception: # pylint: disable=broad-except
102+
logger.warning(
103+
'Failed to serialize `state_delta`; some values are not'
104+
' JSON-serializable (e.g. callables) and will be replaced with a'
105+
' string representation in the persisted event.',
106+
exc_info=True,
107+
)
108+
# Re-run the handler on the sanitized value so that caller `exclude` /
109+
# `include` directives are still applied to the fallback output.
110+
return cast(dict[str, Any], handler(_make_json_serializable(value)))
111+
71112
artifact_delta: dict[str, int] = Field(default_factory=dict)
72113
"""Indicates that the event is updating an artifact. key is the filename,
73114
value is the version."""
@@ -108,6 +149,28 @@ class EventActions(BaseModel):
108149
"""The agent state at the current event, used for checkpoint and resume. This
109150
should only be set by ADK workflow."""
110151

152+
@field_serializer('agent_state', mode='wrap') # type: ignore[misc, untyped-decorator]
153+
def _serialize_agent_state(
154+
self,
155+
value: Optional[dict[str, Any]],
156+
handler: SerializerFunctionWrapHandler,
157+
) -> Optional[dict[str, Any]]:
158+
if value is None:
159+
return None
160+
# See `_serialize_state_delta` for why a wrap serializer is used.
161+
try:
162+
return cast(Optional[dict[str, Any]], handler(value))
163+
except Exception: # pylint: disable=broad-except
164+
logger.warning(
165+
'Failed to serialize `agent_state`; some values are not'
166+
' JSON-serializable (e.g. callables) and will be replaced with a'
167+
' string representation in the persisted event.',
168+
exc_info=True,
169+
)
170+
# Re-run the handler on the sanitized value so that caller `exclude` /
171+
# `include` directives are still applied to the fallback output.
172+
return cast(dict[str, Any], handler(_make_json_serializable(value)))
173+
111174
rewind_before_invocation_id: Optional[str] = None
112175
"""The invocation id to rewind to. This is only set for rewind event."""
113176

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
"""Unit tests for EventActions serialization and its fallback helper."""
18+
19+
import datetime
20+
import logging
21+
22+
from google.adk.events.event_actions import _make_json_serializable
23+
from google.adk.events.event_actions import EventActions
24+
from pydantic import BaseModel
25+
26+
27+
class _Sample(BaseModel):
28+
x: int = 5
29+
label: str = 'hi'
30+
31+
32+
class TestMakeJsonSerializable:
33+
"""Tests for the `_make_json_serializable` fallback helper."""
34+
35+
def test_plain_values_are_unchanged(self):
36+
value = {'a': 1, 'b': [1, 2], 'c': {'d': 'e'}, 'f': None, 'g': True}
37+
assert _make_json_serializable(value) == value
38+
39+
def test_datetime_is_preserved_not_discarded(self):
40+
dt = datetime.datetime(2024, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
41+
assert _make_json_serializable(dt) == '2024-01-02T03:04:05Z'
42+
43+
def test_pydantic_model_is_serialized_to_dict(self):
44+
assert _make_json_serializable(_Sample()) == {'x': 5, 'label': 'hi'}
45+
46+
def test_nested_rich_types_are_serialized(self):
47+
dt = datetime.datetime(2024, 5, 6, tzinfo=datetime.timezone.utc)
48+
result = _make_json_serializable({'when': dt, 'model': _Sample(), 'n': [1]})
49+
assert result == {
50+
'when': '2024-05-06T00:00:00Z',
51+
'model': {'x': 5, 'label': 'hi'},
52+
'n': [1],
53+
}
54+
55+
def test_unserializable_value_is_replaced_with_repr(self):
56+
result = _make_json_serializable(lambda: 1)
57+
assert isinstance(result, str)
58+
assert 'function' in result
59+
60+
def test_unserializable_value_nested(self):
61+
result = _make_json_serializable({'cb': lambda: 1, 'ok': 2})
62+
assert result['ok'] == 2
63+
assert isinstance(result['cb'], str)
64+
65+
66+
class TestStateDeltaSerialization:
67+
"""Tests for the `state_delta` wrap serializer."""
68+
69+
def test_serializable_state_delta_round_trips(self):
70+
actions = EventActions(state_delta={'a': 1, 'b': [1, 2], 'c': {'d': 'e'}})
71+
dumped = actions.model_dump(mode='json')
72+
assert dumped['state_delta'] == {'a': 1, 'b': [1, 2], 'c': {'d': 'e'}}
73+
74+
def test_non_serializable_state_delta_does_not_raise(self):
75+
actions = EventActions(state_delta={'cb': lambda: 1, 'ok': 2})
76+
dumped = actions.model_dump(mode='json')
77+
assert dumped['state_delta']['ok'] == 2
78+
assert isinstance(dumped['state_delta']['cb'], str)
79+
80+
def test_non_serializable_state_delta_logs_warning(self, caplog):
81+
actions = EventActions(state_delta={'cb': lambda: 1})
82+
with caplog.at_level(logging.WARNING):
83+
actions.model_dump(mode='json')
84+
assert any(
85+
'Failed to serialize `state_delta`' in record.message
86+
for record in caplog.records
87+
)
88+
89+
def test_datetime_preserved_when_fallback_triggered(self):
90+
# A callable forces the fallback path; the datetime must still serialize
91+
# faithfully rather than being discarded.
92+
dt = datetime.datetime(2024, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
93+
actions = EventActions(state_delta={'when': dt, 'cb': lambda: 1})
94+
dumped = actions.model_dump(mode='json')
95+
assert dumped['state_delta']['when'] == '2024-01-02T03:04:05Z'
96+
97+
def test_exclude_is_respected_for_serializable_state(self):
98+
actions = EventActions(
99+
state_delta={'_adk_replay_config': {'dir': '/x'}, 'foo': 1}
100+
)
101+
dumped = actions.model_dump(
102+
mode='json', exclude={'state_delta': {'_adk_replay_config': True}}
103+
)
104+
assert dumped['state_delta'] == {'foo': 1}
105+
106+
def test_exclude_is_respected_in_fallback_path(self):
107+
# Even when sanitization is required (callable present), caller `exclude`
108+
# directives must still be applied to the fallback output.
109+
actions = EventActions(
110+
state_delta={
111+
'_adk_replay_config': {'dir': '/x'},
112+
'cb': lambda: 1,
113+
'ok': 2,
114+
}
115+
)
116+
dumped = actions.model_dump(
117+
mode='json', exclude={'state_delta': {'_adk_replay_config': True}}
118+
)
119+
assert '_adk_replay_config' not in dumped['state_delta']
120+
assert dumped['state_delta']['ok'] == 2
121+
assert isinstance(dumped['state_delta']['cb'], str)
122+
123+
124+
class TestAgentStateSerialization:
125+
"""Tests for the `agent_state` wrap serializer."""
126+
127+
def test_none_agent_state_serializes_to_none(self):
128+
assert EventActions().model_dump(mode='json')['agent_state'] is None
129+
130+
def test_serializable_agent_state_round_trips(self):
131+
actions = EventActions(agent_state={'a': 1, 'b': 'two'})
132+
assert actions.model_dump(mode='json')['agent_state'] == {
133+
'a': 1,
134+
'b': 'two',
135+
}
136+
137+
def test_non_serializable_agent_state_does_not_raise(self):
138+
actions = EventActions(agent_state={'cb': lambda: 1, 'n': 3})
139+
dumped = actions.model_dump(mode='json')
140+
assert dumped['agent_state']['n'] == 3
141+
assert isinstance(dumped['agent_state']['cb'], str)
142+
143+
def test_non_serializable_agent_state_logs_warning(self, caplog):
144+
actions = EventActions(agent_state={'cb': lambda: 1})
145+
with caplog.at_level(logging.WARNING):
146+
actions.model_dump(mode='json')
147+
assert any(
148+
'Failed to serialize `agent_state`' in record.message
149+
for record in caplog.records
150+
)

0 commit comments

Comments
 (0)