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
5 changes: 5 additions & 0 deletions src/google/adk/sessions/schemas/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
DEFAULT_MAX_VARCHAR_LENGTH = 256


def _pydantic_serialization_fallback(v: object) -> str:
"""Fallback for ``model_dump_json(fallback=...)`` to handle non-serializable objects."""
return f"<non-serializable: {type(v).__name__}>"


class DynamicJSON(TypeDecorator):
"""A JSON-like type that uses JSONB on PostgreSQL and TEXT with JSON serialization for other databases."""

Expand Down
7 changes: 6 additions & 1 deletion src/google/adk/sessions/schemas/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from .shared import DEFAULT_MAX_VARCHAR_LENGTH
from .shared import DynamicJSON
from .shared import PreciseTimestamp
from .shared import _pydantic_serialization_fallback


class Base(DeclarativeBase):
Expand Down Expand Up @@ -199,7 +200,11 @@ def from_event(cls, session: Session, event: Event) -> StorageEvent:
app_name=session.app_name,
user_id=session.user_id,
timestamp=datetime.fromtimestamp(event.timestamp),
event_data=event.model_dump(exclude_none=True, mode="json"),
event_data=event.model_dump(
exclude_none=True,
mode="json",
fallback=_pydantic_serialization_fallback,
),
)

def to_event(self) -> Event:
Expand Down
6 changes: 5 additions & 1 deletion src/google/adk/sessions/sqlite_session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from typing_extensions import override

from . import _session_util
from .schemas.shared import _pydantic_serialization_fallback
from ..errors.already_exists_error import AlreadyExistsError
from ..events.event import Event
from .base_session_service import BaseSessionService
Expand Down Expand Up @@ -432,7 +433,10 @@ async def append_event(self, session: Session, event: Event) -> Event:
session.id,
event.invocation_id,
event.timestamp,
event.model_dump_json(exclude_none=True),
event.model_dump_json(
exclude_none=True,
fallback=_pydantic_serialization_fallback,
),
),
)
if not has_session_state_delta:
Expand Down
78 changes: 78 additions & 0 deletions tests/unittests/sessions/test_storage_event_serialization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Tests for StorageEvent serialization with non-serializable types.

Regression test for https://github.com/google/adk-python/issues/4724
"""

import time

import pytest

from google.adk.events.event import Event
from google.adk.sessions import Session
from google.adk.sessions.schemas.v1 import StorageEvent


def _make_session() -> Session:
return Session(
app_name="test-app",
user_id="test-user",
id="test-session",
state={},
)


def _make_event(**kwargs) -> Event:
defaults = dict(
invocation_id="inv-1",
author="agent",
timestamp=time.time(),
)
defaults.update(kwargs)
return Event(**defaults)


class TestStorageEventSerialization:
"""Test that StorageEvent.from_event handles non-serializable types."""

def test_basic_event_roundtrip(self):
"""Normal events should serialize and deserialize correctly."""
session = _make_session()
event = _make_event()
storage = StorageEvent.from_event(session, event)
assert storage.id == event.id
assert storage.session_id == session.id

def test_event_with_function_in_state_delta(self):
"""Events with function objects in state_delta should not crash.

This is the core regression test for #4724: when tools attach
non-serializable function references to events, model_dump()
should gracefully degrade instead of raising
PydanticSerializationError.
"""
session = _make_session()
event = _make_event()
# Simulate a function object being attached to state_delta
# (this happens when MCP tools resolve their function references)
event.actions.state_delta["callback"] = lambda x: x

# This should NOT raise PydanticSerializationError
storage = StorageEvent.from_event(session, event)
assert storage.event_data is not None
# The function should be serialized as a placeholder string
actions = storage.event_data.get("actions", {})
state_delta = actions.get("state_delta", actions.get("stateDelta", {}))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Event model is configured to use camelCase aliases for JSON serialization (alias_generator=alias_generators.to_camel). This means state_delta will be serialized as stateDelta. The current code checks for state_delta first, which will always be a miss, before falling back to stateDelta. For clarity and to accurately reflect the expected data structure, it's better to access stateDelta directly.

Suggested change
state_delta = actions.get("state_delta", actions.get("stateDelta", {}))
state_delta = actions.get("stateDelta", {})

assert "non-serializable" in str(state_delta.get("callback", ""))

def test_roundtrip_preserves_serializable_fields(self):
"""Non-serializable fields are replaced but other fields survive."""
session = _make_session()
event = _make_event()
event.actions.state_delta["normal_key"] = "normal_value"
event.actions.state_delta["func_key"] = lambda: None

storage = StorageEvent.from_event(session, event)
restored = storage.to_event()

assert restored.actions.state_delta["normal_key"] == "normal_value"
assert "non-serializable" in str(restored.actions.state_delta.get("func_key", ""))