# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Utility functions for session service."""
from __future__ import annotations
import logging
from typing import Any
from typing import cast
from typing import Iterable
from typing import TypeVar
from pydantic import BaseModel
from pydantic_core import to_jsonable_python
from ..events.event import Event
from ..events.event_actions import _make_json_serializable
from .state import State
logger = logging.getLogger("google_adk." + __name__)
M = TypeVar("M", bound=BaseModel)
_reported_lossy_storage: set[str] = set()
def decode_model(data: object | None, model_cls: type[M]) -> M | None:
"""Decodes a pydantic model object from a JSON dictionary."""
# Guard against primitive non-dict values (e.g. a legacy/corrupted "null" string
# persisted in place of SQL NULL). Passing those to model_validate would
# raise a ValidationError and break session replay in get_session().
# We allow dicts and other objects (like PydanticNamespace in tests).
if data is None or isinstance(
data, (str, int, float, bool, list, set, tuple, bytes)
):
return None
return model_cls.model_validate(data)
def extract_state_delta(
state: dict[str, Any],
) -> dict[str, dict[str, Any]]:
"""Extracts app, user, and session state deltas from a state dictionary."""
deltas: dict[str, dict[str, Any]] = {
"app": {},
"user": {},
"session": {},
}
if state:
for key in state.keys():
if key.startswith(State.APP_PREFIX):
deltas["app"][key.removeprefix(State.APP_PREFIX)] = state[key]
elif key.startswith(State.USER_PREFIX):
deltas["user"][key.removeprefix(State.USER_PREFIX)] = state[key]
elif not key.startswith(State.TEMP_PREFIX):
deltas["session"][key] = state[key]
return deltas
[docs]
def make_json_safe_state(state: dict[str, Any]) -> dict[str, Any]:
"""Coerces a state dictionary into a JSON-serializable form.
Rich types such as datetimes and Pydantic models are serialized faithfully.
Only when that fails is the dictionary re-serialized with the offending values
(e.g. callables) replaced by their string representation, and a warning
logged, so that a lossy write is diagnosable rather than silent.
"""
try:
return cast(dict[str, Any], to_jsonable_python(state))
except Exception: # pylint: disable=broad-except
logger.warning(
"Failed to serialize session state; some values are not"
" JSON-serializable (e.g. callables) and will be replaced with a"
" string representation in the persisted state.",
exc_info=True,
)
return cast(dict[str, Any], _make_json_serializable(state))
def event_fields_not_stored(stored_fields: Iterable[str]) -> list[str]:
"""Returns the event fields lost by a backend that keeps only `stored_fields`.
A backend that writes an event as a fixed set of named fields has nowhere to
put a field added to `Event` afterwards. Callers pass what their layout
keeps, which is frozen, and the loss is derived from `Event`, so it stays
accurate as `Event` grows rather than going stale.
Args:
stored_fields: Names of the event fields the backend's layout can hold.
"""
return sorted(set(Event.model_fields) - set(stored_fields))
def warn_event_fields_not_stored(
stored_fields: Iterable[str], *, cause: str, remedy: str
) -> None:
"""Reports, once per cause, the event fields a backend cannot store.
Nothing downstream can tell a field the backend dropped from one that was
never set, so an agent reading one back misbehaves with no error anywhere.
Args:
stored_fields: Names of the event fields the backend's layout can hold.
cause: Why the backend is storing an event this way, as a sentence opener.
remedy: What the reader can do to stop losing the fields.
"""
dropped = event_fields_not_stored(stored_fields)
if not dropped or cause in _reported_lossy_storage:
return
_reported_lossy_storage.add(cause)
logger.warning(
"%s. %d event fields are dropped on write and read back as unset: %s. %s",
cause,
len(dropped),
", ".join(dropped),
remedy,
)
def extract_json_safe_state_delta(
state: dict[str, Any],
) -> dict[str, dict[str, Any]]:
"""Extracts state deltas coerced into a JSON-serializable form.
Services that persist state to a JSON column must use this rather than
`extract_state_delta`: a value that cannot be serialized is replaced with its
string representation instead of failing the whole write.
"""
return cast(
dict[str, dict[str, Any]],
make_json_safe_state(extract_state_delta(state)),
)