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
45 changes: 29 additions & 16 deletions src/google/adk/cli/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,11 +495,14 @@ class UpdateMemoryRequest(common.BaseModel):


class UpdateSessionRequest(common.BaseModel):
"""Request to update session state without running the agent."""
"""Request to update session state and/or metadata without running the agent."""

state_delta: dict[str, Any]
state_delta: Optional[dict[str, Any]] = None
"""The state changes to apply to the session."""

title: Optional[str] = None
"""A new human-readable title to set on the session."""


class AppInfo(common.BaseModel):
name: str
Expand Down Expand Up @@ -1301,23 +1304,33 @@ async def update_session(
if not session:
raise HTTPException(status_code=404, detail="Session not found")

# Create an event to record the state change
import uuid
# Apply state changes (if provided) as an event so they flow through the
# normal state-update path and event history.
if req.state_delta is not None:
import uuid

from ..events.event import Event
from ..events.event import EventActions
from ..events.event import Event
from ..events.event import EventActions

state_update_event = Event(
invocation_id="p-" + str(uuid.uuid4()),
author="user",
actions=EventActions(state_delta=req.state_delta),
)
state_update_event = Event(
invocation_id="p-" + str(uuid.uuid4()),
author="user",
actions=EventActions(state_delta=req.state_delta),
)
# This will automatically update the session state through
# __update_session_state.
await self.session_service.append_event(
session=session, event=state_update_event
)

# Append the event to the session
# This will automatically update the session state through __update_session_state
await self.session_service.append_event(
session=session, event=state_update_event
)
# Apply a title change (if provided) as out-of-band session metadata.
if req.title is not None:
session = await self.session_service.update_session_title(
app_name=app_name,
user_id=user_id,
session_id=session_id,
title=req.title,
)

return session

Expand Down
28 changes: 28 additions & 0 deletions src/google/adk/sessions/base_session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,34 @@ async def get_user_state(
'call get_session on each result to access the merged state.'
)

async def update_session_title(
self, *, app_name: str, user_id: str, session_id: str, title: str
) -> Session:
"""Sets a human-readable title on an existing session.

The title is client-provided metadata (e.g. for rendering a chat-history
list); the service only stores it. It is surfaced as ``Session.title`` by
both ``get_session`` and ``list_sessions``.

Args:
app_name: The name of the app.
user_id: The ID of the user.
session_id: The ID of the session to update.
title: The new title to store on the session.

Returns:
The updated Session.

Raises:
SessionNotFoundError: When the session does not exist.
NotImplementedError: When the concrete ``BaseSessionService``
implementation does not support session titles (e.g. a managed backend
that does not expose a client-set title).
"""
raise NotImplementedError(
f'{type(self).__name__} does not support update_session_title.'
)

async def append_event(self, session: Session, event: Event) -> Event:
"""Appends an event to a session object."""
if event.partial:
Expand Down
104 changes: 103 additions & 1 deletion src/google/adk/sessions/database_session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@
try:
from sqlalchemy import delete
from sqlalchemy import event
from sqlalchemy import inspect
from sqlalchemy import MetaData
from sqlalchemy import select
from sqlalchemy import text
from sqlalchemy import update
from sqlalchemy.engine import Connection
from sqlalchemy.engine import make_url
from sqlalchemy.exc import ArgumentError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio import AsyncSession as DatabaseSessionFactory
Expand Down Expand Up @@ -168,9 +172,75 @@ def _ensure_schema_indexes_exist(
index.create(bind=connection, checkfirst=True)


def _ensure_schema_columns_exist(
connection: Connection, metadata: MetaData
) -> None:
"""Adds missing additive, nullable columns to existing tables in place.

``metadata.create_all`` only creates missing *tables*; it never alters an
existing table to add a newly declared column. This reconciles that gap for
additive nullable columns (mirroring ``_ensure_schema_indexes_exist`` for
indexes), so a database created before a column was introduced gains it on
the next connect instead of failing every subsequent read/write.

Only nullable columns are added: a non-nullable column would require a data
backfill and a real schema migration, so it is skipped with a warning.
"""
logger.debug("Ensuring schema columns exist for metadata tables.")
inspector = inspect(connection)
existing_tables = set(inspector.get_table_names())
preparer = connection.dialect.identifier_preparer
for table in metadata.sorted_tables:
if table.name not in existing_tables:
# create_all just made it with every declared column; nothing to do.
continue
existing_columns = {
column["name"] for column in inspector.get_columns(table.name)
}
for column in table.columns:
if column.name in existing_columns:
continue
if not column.nullable:
logger.warning(
"Cannot add non-nullable column %s.%s in place; a schema"
" migration is required.",
table.name,
column.name,
)
continue
column_type = column.type.compile(dialect=connection.dialect)
add_column = text(
f"ALTER TABLE {preparer.quote(table.name)} ADD COLUMN"
f" {preparer.quote(column.name)} {column_type}"
)
try:
# SAVEPOINT so a concurrent add on another instance does not poison
# the surrounding setup transaction (mirrors _get_or_create_state).
with connection.begin_nested():
connection.execute(add_column)
logger.info(
"Added missing column %s.%s to existing table.",
table.name,
column.name,
)
except SQLAlchemyError:
refreshed_columns = {
column_info["name"]
for column_info in inspect(connection).get_columns(table.name)
}
if column.name not in refreshed_columns:
raise
logger.debug(
"Column %s.%s already present after a concurrent add.",
table.name,
column.name,
)


def _setup_database_schema(connection: Connection, metadata: MetaData) -> None:
"""Ensures tables and indexes declared in metadata exist."""
"""Ensures tables, columns, and indexes declared in metadata exist."""
metadata.create_all(bind=connection)
_ensure_schema_columns_exist(connection, metadata)
_ensure_schema_indexes_exist(connection, metadata)


Expand Down Expand Up @@ -650,6 +720,38 @@ async def get_session(
)
return session

@override
async def update_session_title(
self, *, app_name: str, user_id: str, session_id: str, title: str
) -> Session:
await self.prepare_tables()
schema = self._get_schema_classes()
async with self._rollback_on_exception_session() as sql_session:
storage_session = await sql_session.get(
schema.StorageSession, (app_name, user_id, session_id)
)
if storage_session is None:
raise SessionNotFoundError(f"Session {session_id} not found.")
# Preserve update_time by passing it explicitly so onupdate=func.now()
# does not fire: a title is display metadata and must not invalidate an
# in-flight copy of the session, which would trip the stale-writer check
# on the next append_event.
await sql_session.execute(
update(schema.StorageSession)
.where(schema.StorageSession.app_name == app_name)
.where(schema.StorageSession.user_id == user_id)
.where(schema.StorageSession.id == session_id)
.values(title=title, update_time=storage_session.update_time)
)
await sql_session.commit()

session = await self.get_session(
app_name=app_name, user_id=user_id, session_id=session_id
)
if session is None:
raise SessionNotFoundError(f"Session {session_id} not found.")
return session

@override
async def list_sessions(
self, *, app_name: str, user_id: Optional[str] = None
Expand Down
16 changes: 16 additions & 0 deletions src/google/adk/sessions/in_memory_session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from . import _session_util
from ..errors.already_exists_error import AlreadyExistsError
from ..errors.session_not_found_error import SessionNotFoundError
from ..events.event import Event
from ..features import FeatureName
from ..features import is_feature_enabled
Expand Down Expand Up @@ -312,6 +313,21 @@ def _delete_session_impl(

self.sessions[app_name][user_id].pop(session_id)

@override
async def update_session_title(
self, *, app_name: str, user_id: str, session_id: str, title: str
) -> Session:
if (
app_name not in self.sessions
or user_id not in self.sessions[app_name]
or session_id not in self.sessions[app_name][user_id]
):
raise SessionNotFoundError(f'Session {session_id} not found.')
session = self.sessions[app_name][user_id][session_id]
session.title = title
copied_session = _copy_session(session)
return self._merge_state(app_name, user_id, copied_session)

@override
async def get_user_state(
self, *, app_name: str, user_id: str
Expand Down
8 changes: 8 additions & 0 deletions src/google/adk/sessions/schemas/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ class StorageSession(Base):
PreciseTimestamp, default=func.now(), onupdate=func.now()
)

# Optional client-set human-readable title (e.g. for a chat-history list).
# Nullable and additive: existing databases are reconciled in-place by
# DatabaseSessionService._ensure_schema_columns_exist on connect.
title: Mapped[str | None] = mapped_column(
String(DEFAULT_MAX_VARCHAR_LENGTH), nullable=True
)

storage_events: Mapped[list[StorageEvent]] = relationship(
"StorageEvent",
back_populates="storage_session",
Expand Down Expand Up @@ -169,6 +176,7 @@ def to_session(
last_update_time=self.get_update_timestamp(
is_sqlite=is_sqlite, is_postgresql=is_postgresql
),
title=self.title,
)
session._storage_update_marker = self.get_update_marker()
return session
Expand Down
9 changes: 9 additions & 0 deletions src/google/adk/sessions/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ class Session(BaseModel):
),
examples=[1_742_000_000.0],
)
title: str | None = Field(
default=None,
description=(
"Optional human-readable title for the session, e.g. for display in"
" a chat-history list. Client-set via update_session_title; the"
" service only stores it."
),
examples=["Debugging the payments outage"],
)

_storage_update_marker: str | None = PrivateAttr(default=None)
"""Internal storage revision marker used for stale-session detection."""
Loading