Source code for sqlspec.adapters.duckdb.adk.store

"""DuckDB ADK store for Google Agent Development Kit.

DuckDB is an OLAP database optimized for analytical queries. This adapter provides:
- Embedded session storage with zero-configuration setup
- Excellent performance for analytical queries on session data
- Native JSON type support for flexible state storage
- Perfect for development, testing, and analytical workloads
"""

import contextlib
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Final, Literal, cast

from typing_extensions import NotRequired, TypedDict

from sqlspec.config import ADKConfig
from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options
from sqlspec.extensions.adk.memory.store import BaseSyncADKMemoryStore
from sqlspec.utils.logging import get_logger
from sqlspec.utils.serializers import from_json, to_json

if TYPE_CHECKING:
    from collections.abc import Sequence

    from sqlspec.adapters.duckdb.config import DuckDBConfig
    from sqlspec.extensions.adk import SessionOrderBy, StoredMemory


__all__ = ("DuckdbADKConfig", "DuckdbADKFTSOptions", "DuckdbADKMemoryStore", "DuckdbADKStore")

logger = get_logger("sqlspec.adapters.duckdb.adk.store")

DUCKDB_TABLE_NOT_FOUND_ERROR: Final = "does not exist"
DUCKDB_EVENT_PROJECTION_STRUCT: Final = "STRUCT(author VARCHAR, node_info STRUCT(path VARCHAR))"
DUCKDB_FTS_ALLOWED_OPTIONS: Final = ("stemmer", "stopwords", "ignore", "strip_accents", "lower", "overwrite")
DUCKDB_FTS_CREATE_OPTION_ORDER: Final = ("stemmer", "stopwords", "ignore", "strip_accents", "lower", "overwrite")
DUCKDB_FTS_REFRESH_OPTION_ORDER: Final = ("overwrite", "stemmer", "stopwords", "ignore", "strip_accents", "lower")
DUCKDB_FTS_DEFAULT_OPTIONS: Final[dict[str, str | int]] = {
    "stemmer": "porter",
    "stopwords": "english",
    "strip_accents": 1,
    "lower": 1,
}


[docs] class DuckdbADKFTSOptions(TypedDict): """DuckDB FTS ``PRAGMA create_fts_index`` options for ADK memory search.""" stemmer: NotRequired[str] """Stemmer name. Defaults to ``"porter"``.""" stopwords: NotRequired[str] """Stopword table/name. Defaults to ``"english"``.""" ignore: NotRequired[str] """Regular expression ignored by DuckDB tokenization.""" strip_accents: NotRequired[bool | int] """Whether DuckDB strips accents during tokenization. Defaults to ``1``.""" lower: NotRequired[bool | int] """Whether DuckDB lowercases text during tokenization. Defaults to ``1``.""" overwrite: NotRequired[bool | int] """Whether initial FTS index creation overwrites an existing index. Defaults to ``0``."""
[docs] class DuckdbADKConfig(ADKConfig): """DuckDB-specific ADK extension settings. Use these keys inside ``extension_config["adk"]`` with the DuckDB ADK stores. Shared ADK table and service settings are inherited from :class:`ADKConfig`. """ enable_event_generated_columns: NotRequired[bool] """Create DuckDB virtual generated columns for common ADK event JSON paths.""" enable_event_generated_column_indexes: NotRequired[bool] """Index generated event projection columns when generated columns are enabled.""" memory_fts_options: NotRequired[DuckdbADKFTSOptions] """DuckDB FTS ``PRAGMA create_fts_index`` options for ADK memory search."""
class DuckdbADKStore(BaseSyncADKStore["DuckDBConfig"]): """DuckDB ADK store for Google Agent Development Kit. Implements session and event storage for Google Agent Development Kit using DuckDB's synchronous driver with a synchronous public API. Provides: - Session state management with native JSON type - Event history with single JSON blob (event_data) plus indexed scalars - Native TIMESTAMPTZ type support - Manual cascade delete (DuckDB has no FK CASCADE) - Columnar storage for analytical queries Args: config: DuckDBConfig with extension_config["adk"] settings. """ __slots__ = () def __init__(self, config: "DuckDBConfig") -> None: """Initialize DuckDB ADK store. Args: config: DuckDBConfig instance. """ super().__init__(config) def create_tables(self) -> None: """Create both sessions and events tables if they don't exist.""" if not self.create_schema_enabled: self.reconcile_schema() return self._create_tables() def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new session. Args: session_id: Unique session identifier. app_name: Application name. user_id: User identifier. state: Initial session state. owner_id: Optional owner ID value for owner_id_column (if configured). Returns: Created session record. """ return self._create_session(session_id, app_name, user_id, state, owner_id) def get_session( self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None ) -> "StoredSession | None": """Get session by ID. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. renew_for: If positive, touch the session update timestamp. Returns: Session record or None if not found. """ return self._get_session(app_name, user_id, session_id, renew_for=renew_for) def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: """Update session state. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. state: New state dictionary (replaces existing state). """ self._update_session_state(app_name, user_id, session_id, state) def list_sessions( self, app_name: str, user_id: "str | None" = None, *, order_by: "SessionOrderBy" = "update_time", descending: bool = True, limit: "int | None" = None, offset: "int | None" = None, ) -> "list[StoredSession]": """List sessions for an app, optionally filtered by user. Args: app_name: Application name. user_id: User identifier. If None, lists all sessions for the app. order_by: Timestamp column to sort on. descending: Sort direction for the timestamp column and the id tie-break. limit: Maximum number of sessions to return. offset: Number of leading rows to skip. Returns: List of session records. """ return self._list_sessions( app_name, user_id, order_by=order_by, descending=descending, limit=limit, offset=offset ) def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: """Delete session and all associated events. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. """ self._delete_session(app_name, user_id, session_id) def append_event(self, event_record: StoredEvent) -> None: """Append an event to a session. Args: event_record: Event record to store. """ self._append_event(event_record) def append_event_and_update_state( self, event_record: StoredEvent, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]", *, app_state: "dict[str, Any] | None" = None, user_state: "dict[str, Any] | None" = None, ) -> StoredSession: """Atomically append an event and update the session's durable state. The event insert and state update succeed together or fail together within a single DuckDB transaction; the updated StoredSession is returned via UPDATE...RETURNING. Args: event_record: Event record to store. app_name: Application name. user_id: User identifier. session_id: Session identifier whose state should be updated. state: Post-append durable state snapshot (``temp:`` keys already stripped by the service layer). app_state: Optional app-scoped state snapshot. user_state: Optional user-scoped state snapshot. """ return self._append_event_and_update_state( event_record, app_name, user_id, session_id, state, app_state=app_state, user_state=user_state ) def get_events( self, app_name: str, user_id: str, session_id: str, after_timestamp: "datetime | None" = None, limit: "int | None" = None, ) -> "list[StoredEvent]": """Get events for a session. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. after_timestamp: Only return events after this time. limit: Maximum number of events to return. Returns: List of event records ordered by timestamp ASC. """ return self._get_events(app_name, user_id, session_id, after_timestamp, limit) def delete_expired_events(self, before: "datetime", app_name: "str | None" = None) -> int: """Delete events older than a timestamp, optionally scoped to one application.""" return self._delete_expired_events(before, app_name) def delete_idle_sessions(self, updated_before: "datetime", app_name: "str | None" = None) -> int: """Delete sessions older than a timestamp, optionally scoped to one application.""" return self._delete_idle_sessions(updated_before, app_name) def delete_idle_user_states(self, updated_before: "datetime", app_name: "str | None" = None) -> int: """Delete user state rows older than a timestamp, optionally scoped to one application.""" return self._delete_idle_user_states(updated_before, app_name) def get_app_state(self, app_name: str) -> "dict[str, Any] | None": """Return app-scoped state.""" return self._get_app_state(app_name) def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": """Return user-scoped state.""" return self._get_user_state(app_name, user_id) def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state.""" self._upsert_app_state(app_name, state) def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: """Insert or replace user-scoped state.""" self._upsert_user_state(app_name, user_id, state) def get_metadata(self, key: str) -> "str | None": """Return a metadata value.""" return self._get_metadata(key) def set_metadata(self, key: str, value: str) -> None: """Set a metadata value.""" self._set_metadata(key, value) def _sessions_table_ddl(self) -> str: """Get DuckDB CREATE TABLE SQL for sessions. Returns: SQL statement to create adk_sessions table with indexes. """ owner_id_line = "" if self._owner_id_column_ddl: owner_id_line = f",\n {self._owner_id_column_ddl}" return f""" CREATE TABLE IF NOT EXISTS {self._session_table} ( id VARCHAR PRIMARY KEY, app_name VARCHAR NOT NULL, user_id VARCHAR NOT NULL{owner_id_line}, state JSON NOT NULL, create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user ON {self._session_table}(app_name, user_id); CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time ON {self._session_table}(update_time DESC); """ def _events_table_ddl(self) -> str: """Get DuckDB CREATE TABLE SQL for events. Returns: SQL statement to create adk_event table with indexes. """ generated_columns = self._event_generated_columns_sql() generated_indexes = self._event_generated_column_indexes_sql() return f""" CREATE TABLE IF NOT EXISTS {self._events_table} ( id VARCHAR PRIMARY KEY, app_name VARCHAR NOT NULL, user_id VARCHAR NOT NULL, session_id VARCHAR NOT NULL, invocation_id VARCHAR, timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, event_data JSON NOT NULL{generated_columns}, FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) ); CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session ON {self._events_table}(session_id, timestamp ASC); CREATE INDEX IF NOT EXISTS idx_{self._events_table}_app_timestamp ON {self._events_table}(app_name, timestamp ASC); {generated_indexes} """ def _event_generated_columns_sql(self) -> str: """Return DuckDB ADK generated event projection columns.""" adk_config = _adk_config(self._config) if not adk_config.get("enable_event_generated_columns", False): return "" return f""", author_gc VARCHAR GENERATED ALWAYS AS ((event_data::{DUCKDB_EVENT_PROJECTION_STRUCT}).author) VIRTUAL, node_path_gc VARCHAR GENERATED ALWAYS AS ((event_data::{DUCKDB_EVENT_PROJECTION_STRUCT}).node_info.path) VIRTUAL""" def _event_generated_column_indexes_sql(self) -> str: """Return optional indexes for generated event projection columns.""" adk_config = _adk_config(self._config) if not ( adk_config.get("enable_event_generated_columns", False) and adk_config.get("enable_event_generated_column_indexes", False) ): return "" return f""" CREATE INDEX IF NOT EXISTS idx_{self._events_table}_author_gc ON {self._events_table}(session_id, author_gc, timestamp ASC); CREATE INDEX IF NOT EXISTS idx_{self._events_table}_node_path_gc ON {self._events_table}(session_id, node_path_gc, timestamp ASC); """ def _app_states_table_ddl(self) -> str: """Get DuckDB CREATE TABLE SQL for app-scoped state.""" return f""" CREATE TABLE IF NOT EXISTS {self._app_state_table} ( app_name VARCHAR PRIMARY KEY, state JSON NOT NULL, update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ def _user_states_table_ddl(self) -> str: """Get DuckDB CREATE TABLE SQL for user-scoped state.""" return f""" CREATE TABLE IF NOT EXISTS {self._user_state_table} ( app_name VARCHAR NOT NULL, user_id VARCHAR NOT NULL, state JSON NOT NULL, update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (app_name, user_id) ) """ def _metadata_table_ddl(self) -> str: """Get DuckDB CREATE TABLE SQL for internal ADK metadata.""" return f""" CREATE TABLE IF NOT EXISTS {self._metadata_table} ( key VARCHAR PRIMARY KEY, value VARCHAR NOT NULL ) """ def _drop_app_states_table_sql(self) -> str: """Get DuckDB DROP TABLE SQL for app-scoped state.""" return f"DROP TABLE IF EXISTS {self._app_state_table}" def _drop_user_states_table_sql(self) -> str: """Get DuckDB DROP TABLE SQL for user-scoped state.""" return f"DROP TABLE IF EXISTS {self._user_state_table}" def _drop_metadata_table_sql(self) -> str: """Get DuckDB DROP TABLE SQL for internal ADK metadata.""" return f"DROP TABLE IF EXISTS {self._metadata_table}" def _drop_tables_sql(self) -> "list[str]": """Get DuckDB DROP TABLE SQL statements. Returns: List of SQL statements to drop tables and indexes. """ return [ self._drop_metadata_table_sql(), self._drop_user_states_table_sql(), self._drop_app_states_table_sql(), f"DROP TABLE IF EXISTS {self._events_table}", f"DROP TABLE IF EXISTS {self._session_table}", ] def _create_tables(self) -> None: """Synchronous implementation of create_tables.""" with self._config.provide_connection() as conn: conn.execute(self._sync_sessions_table_ddl()) conn.execute(self._sync_events_table_ddl()) conn.execute(self._app_states_table_ddl()) conn.execute(self._user_states_table_ddl()) conn.execute(self._metadata_table_ddl()) conn.commit() def _sync_sessions_table_ddl(self) -> str: """Synchronous version of DDL generation for use in _create_tables.""" owner_id_line = "" if self._owner_id_column_ddl: owner_id_line = f",\n {self._owner_id_column_ddl}" return f""" CREATE TABLE IF NOT EXISTS {self._session_table} ( id VARCHAR PRIMARY KEY, app_name VARCHAR NOT NULL, user_id VARCHAR NOT NULL{owner_id_line}, state JSON NOT NULL, create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user ON {self._session_table}(app_name, user_id); CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time ON {self._session_table}(update_time DESC); """ def _sync_events_table_ddl(self) -> str: """Synchronous version of DDL generation for use in _create_tables.""" generated_columns = self._event_generated_columns_sql() generated_indexes = self._event_generated_column_indexes_sql() return f""" CREATE TABLE IF NOT EXISTS {self._events_table} ( id VARCHAR PRIMARY KEY, app_name VARCHAR NOT NULL, user_id VARCHAR NOT NULL, session_id VARCHAR NOT NULL, invocation_id VARCHAR, timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, event_data JSON NOT NULL{generated_columns}, FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) ); CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session ON {self._events_table}(session_id, timestamp ASC); CREATE INDEX IF NOT EXISTS idx_{self._events_table}_app_timestamp ON {self._events_table}(app_name, timestamp ASC); {generated_indexes} """ def _create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Synchronous implementation of create_session.""" now = datetime.now(timezone.utc) state_json = to_json(state) params: tuple[Any, ...] if self._owner_id_column_name: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, {self._owner_id_column_name}, state, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?, ?) """ params = (session_id, app_name, user_id, owner_id, state_json, now, now) else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?) """ params = (session_id, app_name, user_id, state_json, now, now) with self._config.provide_connection() as conn: conn.execute(sql, params) conn.commit() return StoredSession( id=session_id, app_name=app_name, user_id=user_id, state=state, create_time=now, update_time=now ) def _get_session( self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None ) -> "StoredSession | None": """Synchronous implementation of get_session.""" if renew_for is not None and self._calculate_expires_at(renew_for) is not None: sql = f""" UPDATE {self._session_table} SET update_time = ? WHERE app_name = ? AND user_id = ? AND id = ? RETURNING id, app_name, user_id, state, create_time, update_time """ params: list[Any] = [datetime.now(timezone.utc), app_name, user_id, session_id] else: sql = f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {self._session_table} WHERE app_name = ? AND user_id = ? AND id = ? """ params = [app_name, user_id, session_id] try: with self._config.provide_connection() as conn: cursor = conn.execute(sql, params) row = cursor.fetchone() if row is None: return None session_id_val, app_name, user_id, state_data, create_time, update_time = row state = from_json(state_data) if state_data else {} return StoredSession( id=session_id_val, app_name=app_name, user_id=user_id, state=state, create_time=create_time, update_time=update_time, ) except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return None raise def _update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: """Synchronous implementation of update_session_state.""" now = datetime.now(timezone.utc) state_json = to_json(state) sql = f""" UPDATE {self._session_table} SET state = ?, update_time = ? WHERE app_name = ? AND user_id = ? AND id = ? """ with self._config.provide_connection() as conn: conn.execute(sql, (state_json, now, app_name, user_id, session_id)) conn.commit() def _delete_session(self, app_name: str, user_id: str, session_id: str) -> None: """Synchronous implementation of delete_session.""" delete_events_sql = f"DELETE FROM {self._events_table} WHERE session_id = ?" delete_session_sql = f"DELETE FROM {self._session_table} WHERE app_name = ? AND user_id = ? AND id = ?" with self._config.provide_connection() as conn: conn.execute(delete_events_sql, (session_id,)) conn.execute(delete_session_sql, (app_name, user_id, session_id)) conn.commit() def _list_sessions( self, app_name: str, user_id: "str | None" = None, *, order_by: "SessionOrderBy" = "update_time", descending: bool = True, limit: "int | None" = None, offset: "int | None" = None, ) -> "list[StoredSession]": """Synchronous implementation of list_sessions.""" column, direction, page_limit, page_offset = normalize_session_list_options(order_by, descending, limit, offset) if page_limit == 0: return [] sql, params = _session_list_query( self._session_table, app_name, user_id, column, direction, page_limit, page_offset ) try: with self._config.provide_connection() as conn: cursor = conn.execute(sql, params) rows = cursor.fetchall() return [ StoredSession( id=row[0], app_name=row[1], user_id=row[2], state=from_json(row[3]) if row[3] else {}, create_time=row[4], update_time=row[5], ) for row in rows ] except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return [] raise def _append_event(self, event_record: StoredEvent) -> None: """Synchronous implementation of append_event.""" event_data_str = to_json(event_record["event_data"]) sql = f""" INSERT INTO {self._events_table} (id, app_name, user_id, session_id, invocation_id, timestamp, event_data) VALUES (?, ?, ?, ?, ?, ?, ?) """ with self._config.provide_connection() as conn: conn.execute( sql, ( event_record["id"], event_record["app_name"], event_record["user_id"], event_record["session_id"], event_record["invocation_id"], event_record["timestamp"], event_data_str, ), ) conn.commit() def _append_event_and_update_state( self, event_record: StoredEvent, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]", *, app_state: "dict[str, Any] | None" = None, user_state: "dict[str, Any] | None" = None, ) -> StoredSession: """Synchronous implementation of append_event_and_update_state.""" now = datetime.now(timezone.utc) state_json = to_json(state) event_data_str = to_json(event_record["event_data"]) insert_sql = f""" INSERT INTO {self._events_table} (id, app_name, user_id, session_id, invocation_id, timestamp, event_data) VALUES (?, ?, ?, ?, ?, ?, ?) """ update_sql = f""" UPDATE {self._session_table} SET state = ?, update_time = ? WHERE app_name = ? AND user_id = ? AND id = ? RETURNING id, app_name, user_id, state, create_time, update_time """ app_upsert_sql = f""" INSERT INTO {self._app_state_table} (app_name, state, update_time) VALUES (?, ?, ?) ON CONFLICT(app_name) DO UPDATE SET state = excluded.state, update_time = excluded.update_time """ user_upsert_sql = f""" INSERT INTO {self._user_state_table} (app_name, user_id, state, update_time) VALUES (?, ?, ?, ?) ON CONFLICT(app_name, user_id) DO UPDATE SET state = excluded.state, update_time = excluded.update_time """ with self._config.provide_connection() as conn: try: conn.execute("BEGIN TRANSACTION") conn.execute( insert_sql, ( event_record["id"], event_record["app_name"], event_record["user_id"], event_record["session_id"], event_record["invocation_id"], event_record["timestamp"], event_data_str, ), ) cursor = conn.execute(update_sql, (state_json, now, app_name, user_id, session_id)) row = cursor.fetchone() if row is None: _raise_session_not_found(session_id) assert row is not None if app_state is not None: conn.execute(app_upsert_sql, (app_name, to_json(app_state), now)) if user_state is not None: conn.execute(user_upsert_sql, (app_name, user_id, to_json(user_state), now)) conn.commit() except Exception: conn.rollback() raise assert row is not None session_id_val, app_name, user_id, state_data, create_time, update_time = row return StoredSession( id=session_id_val, app_name=app_name, user_id=user_id, state=from_json(state_data) if isinstance(state_data, str) else state_data, create_time=create_time, update_time=update_time, ) def _get_events( self, app_name: str, user_id: str, session_id: str, after_timestamp: "datetime | None" = None, limit: "int | None" = None, ) -> "list[StoredEvent]": """Synchronous implementation of get_events.""" if limit == 0: return [] where_clauses = ["s.app_name = ?", "s.user_id = ?", "e.session_id = ?"] params: list[Any] = [app_name, user_id, session_id] if after_timestamp is not None: where_clauses.append("e.timestamp > ?") params.append(after_timestamp) where_clause = " AND ".join(where_clauses) limit_clause = f" LIMIT {limit}" if limit is not None else "" sql = f""" SELECT e.id, e.session_id, e.invocation_id, e.timestamp, e.event_data, s.app_name, s.user_id FROM {self._events_table} e JOIN {self._session_table} s ON e.session_id = s.id WHERE {where_clause} ORDER BY e.timestamp ASC{limit_clause} """ try: with self._config.provide_connection() as conn: cursor = conn.execute(sql, params) rows = cursor.fetchall() return [ StoredEvent( id=row[0], session_id=row[1], invocation_id=row[2], timestamp=row[3], event_data=from_json(row[4]) if isinstance(row[4], str) else row[4], app_name=row[5], user_id=row[6], ) for row in rows ] except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return [] raise def _delete_expired_events(self, before: "datetime", app_name: "str | None" = None) -> int: where = "WHERE timestamp < ?" params: tuple[Any, ...] = (before,) if app_name is not None: where += " AND app_name = ?" params = (before, app_name) count_sql = f"SELECT COUNT(*) FROM {self._events_table} {where}" delete_sql = f"DELETE FROM {self._events_table} {where}" try: with self._config.provide_connection() as conn: count_row = conn.execute(count_sql, params).fetchone() count = int(count_row[0]) if count_row is not None else 0 conn.execute(delete_sql, params) conn.commit() return count except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return 0 raise def _delete_idle_sessions(self, updated_before: "datetime", app_name: "str | None" = None) -> int: where = "WHERE update_time < ?" params: tuple[Any, ...] = (updated_before,) if app_name is not None: where += " AND app_name = ?" params = (updated_before, app_name) count_sql = f"SELECT COUNT(*) FROM {self._session_table} {where}" delete_events_sql = f""" DELETE FROM {self._events_table} WHERE session_id IN (SELECT id FROM {self._session_table} {where}) """ delete_sessions_sql = f"DELETE FROM {self._session_table} {where}" try: with self._config.provide_connection() as conn: count_row = conn.execute(count_sql, params).fetchone() count = int(count_row[0]) if count_row is not None else 0 conn.execute(delete_events_sql, params) conn.execute(delete_sessions_sql, params) conn.commit() return count except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return 0 raise def _delete_idle_user_states(self, updated_before: "datetime", app_name: "str | None" = None) -> int: where = "WHERE update_time < ?" params: tuple[Any, ...] = (updated_before,) if app_name is not None: where += " AND app_name = ?" params = (updated_before, app_name) count_sql = f"SELECT COUNT(*) FROM {self._user_state_table} {where}" delete_sql = f"DELETE FROM {self._user_state_table} {where}" try: with self._config.provide_connection() as conn: count_row = conn.execute(count_sql, params).fetchone() count = int(count_row[0]) if count_row is not None else 0 conn.execute(delete_sql, params) conn.commit() return count except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return 0 raise def _get_app_state(self, app_name: str) -> "dict[str, Any] | None": sql = f"SELECT state FROM {self._app_state_table} WHERE app_name = ?" try: with self._config.provide_connection() as conn: row = conn.execute(sql, (app_name,)).fetchone() if row is None: return None return cast("dict[str, Any]", from_json(row[0]) if isinstance(row[0], str) else row[0]) except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return None raise def _get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": sql = f"SELECT state FROM {self._user_state_table} WHERE app_name = ? AND user_id = ?" try: with self._config.provide_connection() as conn: row = conn.execute(sql, (app_name, user_id)).fetchone() if row is None: return None return cast("dict[str, Any]", from_json(row[0]) if isinstance(row[0], str) else row[0]) except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return None raise def _upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: now = datetime.now(timezone.utc) sql = f""" INSERT INTO {self._app_state_table} (app_name, state, update_time) VALUES (?, ?, ?) ON CONFLICT(app_name) DO UPDATE SET state = excluded.state, update_time = excluded.update_time """ with self._config.provide_connection() as conn: conn.execute(sql, (app_name, to_json(state), now)) conn.commit() def _upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: now = datetime.now(timezone.utc) sql = f""" INSERT INTO {self._user_state_table} (app_name, user_id, state, update_time) VALUES (?, ?, ?, ?) ON CONFLICT(app_name, user_id) DO UPDATE SET state = excluded.state, update_time = excluded.update_time """ with self._config.provide_connection() as conn: conn.execute(sql, (app_name, user_id, to_json(state), now)) conn.commit() def _get_metadata(self, key: str) -> "str | None": sql = f"SELECT value FROM {self._metadata_table} WHERE key = ?" try: with self._config.provide_connection() as conn: row = conn.execute(sql, (key,)).fetchone() return row[0] if row is not None else None except Exception as e: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(e): return None raise def _set_metadata(self, key: str, value: str) -> None: sql = f""" INSERT INTO {self._metadata_table} (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value """ with self._config.provide_connection() as conn: conn.execute(sql, (key, value)) conn.commit() class DuckdbADKMemoryStore(BaseSyncADKMemoryStore["DuckDBConfig"]): """DuckDB ADK memory store using synchronous DuckDB driver with async wrappers. Implements memory entry storage for Google Agent Development Kit using DuckDB's synchronous driver with a synchronous public API. Provides: - Session memory storage with native JSON type - Simple ILIKE search or BM25 full-text search via FTS extension - Native TIMESTAMP type support - Deduplication via event_id unique constraint - Efficient upserts using INSERT OR IGNORE - Columnar storage for analytical queries Args: config: DuckDBConfig with extension_config["adk"] settings. """ __slots__ = () def __init__(self, config: "DuckDBConfig") -> None: """Initialize DuckDB ADK memory store. Args: config: DuckDBConfig instance. """ super().__init__(config) def create_tables(self) -> None: """Create the memory table and indexes if they don't exist.""" if not self._enabled: return if not self.create_schema_enabled: self.reconcile_schema() return self._create_tables() def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with deduplication. After successful inserts, refreshes the FTS index if FTS is enabled. """ return self._insert_memory_entries(entries, owner_id) def search_entries( self, query: str, app_name: str, user_id: str, limit: "int | None" = None, scope_filter: Literal["all", "user", "app"] = "all", embedding: "Sequence[float] | None" = None, ) -> "list[StoredMemory]": """Search memory entries by text query. When FTS is enabled, uses ``match_bm25()`` for BM25-ranked results. Falls back to ILIKE for simple substring matching. """ return self._search_entries(query, app_name, user_id, limit, scope_filter) def delete_entries_by_session(self, session_id: str) -> int: """Delete all memory entries for a specific session.""" return self._delete_entries_by_session(session_id) def delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: """Delete memory entries older than specified days.""" return self._delete_entries_older_than(days, app_name, scope) def _ensure_fts_extension(self, conn: Any) -> bool: """Ensure the DuckDB FTS extension is available for this connection.""" with contextlib.suppress(Exception): conn.execute("INSTALL fts") try: conn.execute("LOAD fts") except Exception as exc: logger.debug("DuckDB FTS extension unavailable: %s", exc) return False return True def _create_fts_index(self, conn: Any) -> None: """Create FTS index for the memory table.""" if not self._ensure_fts_extension(conn): return try: conn.execute(self._fts_index_ddl(overwrite=False)) conn.commit() except Exception as exc: logger.debug("Failed to create DuckDB FTS index: %s", exc) def _refresh_fts_index(self, conn: Any) -> None: """Rebuild the FTS index to reflect recent inserts. DuckDB FTS indexes do not auto-update. This must be called after insert/update/delete operations, NOT on every search. """ if not self._ensure_fts_extension(conn): return try: conn.execute(self._fts_index_ddl(overwrite=True)) conn.commit() except Exception as exc: logger.debug("Failed to refresh DuckDB FTS index: %s", exc) def _fts_index_ddl(self, *, overwrite: bool) -> str: """Return DuckDB FTS index PRAGMA SQL for the memory table.""" return f"PRAGMA create_fts_index('{self._memory_table}', 'id', 'content_text', {self._render_fts_options(overwrite=overwrite)})" def _render_fts_options(self, *, overwrite: bool) -> str: """Render DuckDB FTS options from adapter-local ADK config.""" options: dict[str, object] = dict(DUCKDB_FTS_DEFAULT_OPTIONS) adk_config = _adk_config(self._config) configured_options = adk_config.get("memory_fts_options", {}) if configured_options: _validate_duckdb_fts_options(configured_options) options.update(configured_options) if overwrite: options["overwrite"] = 1 option_order = DUCKDB_FTS_REFRESH_OPTION_ORDER if overwrite else DUCKDB_FTS_CREATE_OPTION_ORDER return ", ".join( f"{option_name}={_format_duckdb_fts_option(options[option_name])}" for option_name in option_order if option_name in options ) def _memory_table_ddl(self) -> str: """Get DuckDB CREATE TABLE SQL for memory entries. Returns: SQL statement to create memory table with indexes. """ owner_id_line = "" if self._owner_id_column_ddl: owner_id_line = f",\n {self._owner_id_column_ddl}" return f""" CREATE TABLE IF NOT EXISTS {self._memory_table} ( id VARCHAR(128) PRIMARY KEY, session_id VARCHAR(128) NOT NULL, app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL, scope VARCHAR(16) NOT NULL DEFAULT 'user', event_id VARCHAR(128) NOT NULL UNIQUE, author VARCHAR(256){owner_id_line}, timestamp TIMESTAMP NOT NULL, content_json JSON NOT NULL, content_text TEXT NOT NULL, metadata_json JSON, inserted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_app_scope_user_time ON {self._memory_table}(app_name, scope, user_id, timestamp DESC); CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_scope ON {self._memory_table}(app_name, scope); CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_session ON {self._memory_table}(session_id); """ def _drop_memory_table_sql(self) -> "list[str]": """Get DuckDB DROP TABLE SQL statements.""" return [f"DROP TABLE IF EXISTS {self._memory_table}"] def _create_tables(self) -> None: """Synchronous implementation of create_tables.""" if not self._enabled: return ddl = self._sync_memory_table_ddl() with self._config.provide_connection() as conn: conn.execute(ddl) if self._use_fts: self._create_fts_index(conn) def _sync_memory_table_ddl(self) -> str: """Synchronous version of DDL generation for use in _create_tables.""" return self._memory_table_ddl() def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Synchronous implementation of insert_memory_entries.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) if not entries: return 0 inserted_count = 0 if self._owner_id_column_name: sql = f""" INSERT INTO {self._memory_table} ( id, session_id, app_name, user_id, scope, event_id, author, {self._owner_id_column_name}, timestamp, content_json, content_text, metadata_json, inserted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_id) DO NOTHING RETURNING 1 """ else: sql = f""" INSERT INTO {self._memory_table} ( id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_id) DO NOTHING RETURNING 1 """ with self._config.provide_connection() as conn: for entry in entries: params: tuple[Any, ...] scope_value = entry.get("scope", "user") metadata_json = entry.get("metadata_json") if self._owner_id_column_name: params = ( entry["id"], entry["session_id"], entry["app_name"], entry["user_id"], scope_value, entry["event_id"], entry.get("author"), owner_id, entry["timestamp"], to_json(entry["content_json"]), entry["content_text"], to_json(metadata_json) if metadata_json is not None else None, entry["inserted_at"], ) else: params = ( entry["id"], entry["session_id"], entry["app_name"], entry["user_id"], scope_value, entry["event_id"], entry.get("author"), entry["timestamp"], to_json(entry["content_json"]), entry["content_text"], to_json(metadata_json) if metadata_json is not None else None, entry["inserted_at"], ) result = conn.execute(sql, params) inserted_count += len(result.fetchall()) conn.commit() if self._use_fts and inserted_count > 0: self._refresh_fts_index(conn) return inserted_count def _search_entries( self, query: str, app_name: str, user_id: str, limit: "int | None" = None, scope_filter: Literal["all", "user", "app"] = "all", ) -> "list[StoredMemory]": """Synchronous implementation of search_entries.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) if not query or not query.strip(): return [] limit_value = limit if limit is not None else self._max_results use_fts = self._use_fts rows: list[Any] | None = None columns: list[str] = [] with self._config.provide_connection() as conn: if use_fts and not self._ensure_fts_extension(conn): use_fts = False if use_fts: where_scope, scope_params = _build_duckdb_scope_where(app_name, user_id, scope_filter, prefix="m") fts_sql = f""" SELECT m.* FROM {self._memory_table} m JOIN ( SELECT id, fts_main_{self._memory_table}.match_bm25(id, ?, fields := 'content_text') AS score FROM {self._memory_table} ) fts ON m.id = fts.id WHERE {where_scope} AND fts.score IS NOT NULL ORDER BY fts.score DESC LIMIT ? """ fts_params = (query, *scope_params, limit_value) try: rows = conn.execute(fts_sql, fts_params).fetchall() columns = [col[0] for col in conn.description or []] except Exception as exc: logger.warning("FTS search failed; falling back to simple search: %s", exc) if rows is None: where_scope, scope_params = _build_duckdb_scope_where(app_name, user_id, scope_filter) sql = f""" SELECT * FROM {self._memory_table} WHERE {where_scope} AND content_text ILIKE ? ORDER BY timestamp DESC LIMIT ? """ params = (*scope_params, f"%{query}%", limit_value) try: rows = conn.execute(sql, params).fetchall() columns = [col[0] for col in conn.description or []] except Exception as exc: if DUCKDB_TABLE_NOT_FOUND_ERROR in str(exc): return [] raise records: list[StoredMemory] = [] for row in rows: record = cast("StoredMemory", dict(zip(columns, row, strict=False))) content_value = record["content_json"] if isinstance(content_value, (str, bytes)): record["content_json"] = from_json(content_value) metadata_value = record.get("metadata_json") if isinstance(metadata_value, (str, bytes)): record["metadata_json"] = from_json(metadata_value) record["embedding"] = None records.append(record) return records def _delete_entries_by_session(self, session_id: str) -> int: """Synchronous implementation of delete_entries_by_session.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) sql = f"DELETE FROM {self._memory_table} WHERE session_id = ? RETURNING 1" with self._config.provide_connection() as conn: result = conn.execute(sql, (session_id,)) deleted_count = len(result.fetchall()) conn.commit() if self._use_fts and deleted_count > 0: self._refresh_fts_index(conn) return deleted_count def _delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: """Synchronous implementation of delete_entries_older_than.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) clauses = ["inserted_at < (CURRENT_TIMESTAMP - (? * INTERVAL '1 day'))"] params: list[Any] = [days] if app_name is not None: clauses.append("app_name = ?") params.append(app_name) if scope is not None: clauses.append("scope = ?") params.append(scope) where_sql = " AND ".join(clauses) sql = f"DELETE FROM {self._memory_table} WHERE {where_sql} RETURNING 1" with self._config.provide_connection() as conn: result = conn.execute(sql, tuple(params)) deleted_count = len(result.fetchall()) conn.commit() if self._use_fts and deleted_count > 0: self._refresh_fts_index(conn) return deleted_count def _raise_session_not_found(session_id: str) -> None: msg = f"Session {session_id} not found during append_event_and_update_state." raise ValueError(msg) def _adk_config(config: Any) -> DuckdbADKConfig: """Return DuckDB ADK extension settings from ``extension_config["adk"]``.""" extension_config = getattr(config, "extension_config", {}) if not isinstance(extension_config, dict): return cast("DuckdbADKConfig", {}) adk_config = extension_config.get("adk", {}) if not isinstance(adk_config, dict): return cast("DuckdbADKConfig", {}) return cast("DuckdbADKConfig", adk_config) def _validate_duckdb_fts_options(options: Mapping[str, object]) -> None: """Validate DuckDB FTS option names before rendering a PRAGMA.""" unknown_options = sorted(set(options) - set(DUCKDB_FTS_ALLOWED_OPTIONS)) if unknown_options: msg = f"Unsupported DuckDB ADK memory_fts_options: {', '.join(unknown_options)}" raise ValueError(msg) def _format_duckdb_fts_option(value: object) -> str: """Render a DuckDB FTS option value.""" if isinstance(value, bool): return "1" if value else "0" if isinstance(value, int): return str(value) if isinstance(value, str): escaped_value = value.replace("'", "''") return f"'{escaped_value}'" msg = f"DuckDB ADK memory_fts_options values must be str, int, or bool; got {type(value).__name__}" raise TypeError(msg) def _build_duckdb_scope_where( app_name: str, user_id: str, scope_filter: Literal["all", "user", "app"], *, prefix: str = "" ) -> tuple[str, tuple[Any, ...]]: pfx = f"{prefix}." if prefix else "" if scope_filter == "all": return f"{pfx}app_name = ? AND (({pfx}scope = 'user' AND {pfx}user_id = ?) OR {pfx}scope = 'app')", ( app_name, user_id, ) if scope_filter == "user": return f"{pfx}app_name = ? AND {pfx}scope = 'user' AND {pfx}user_id = ?", (app_name, user_id) return f"{pfx}app_name = ? AND {pfx}scope = 'app'", (app_name,) def _session_list_query( session_table: str, app_name: str, user_id: "str | None", column: str, direction: str, limit: "int | None", offset: int, ) -> "tuple[str, tuple[Any, ...]]": """Return the bounded session-list query and its bound values.""" params: list[Any] = [app_name] where_clause = "app_name = ?" if user_id is not None: params.append(user_id) where_clause = f"{where_clause} AND user_id = ?" page_clause = "" if limit is not None: params.extend((limit, offset)) page_clause = "\n LIMIT ? OFFSET ?" sql = f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {session_table} WHERE {where_clause} ORDER BY {column} {direction}, id {direction}{page_clause} """ return sql, tuple(params)