1717from __future__ import annotations
1818
1919import asyncio
20+ import inspect
2021import json
2122from collections .abc import Callable
2223from datetime import datetime , timezone
23- from typing import Any , Generic , TypeVar
24+ from typing import TYPE_CHECKING , Any , Generic , TypeVar
25+
26+ from google .cloud import firestore
27+
28+ if TYPE_CHECKING :
29+ from google .cloud .firestore import AsyncClient , Client
2430
2531from genkit ._ai ._agents ._session import SessionStore , SnapshotSubscriber
2632from genkit ._ai ._agents ._session_stores import (
@@ -58,23 +64,37 @@ class FirestoreSessionStore(SessionStore[StateT], SnapshotSubscriber, Generic[St
5864 def __init__ (
5965 self ,
6066 * ,
61- client : Any | None = None , # noqa: ANN401 — google.cloud.firestore.Client
67+ client : AsyncClient | Client | None = None ,
6268 collection : str = DEFAULT_COLLECTION ,
6369 snapshot_path_prefix : Callable [[], str ] | None = None ,
6470 reject_ambiguous_session : bool = False ,
6571 ) -> None :
6672 """Initialize the Firestore session store."""
6773 if client is None :
68- from google .cloud import firestore # noqa: PLC0415
69-
70- client = firestore .Client ()
74+ client = firestore .AsyncClient ()
7175 self ._client = client
7276 self ._collection = collection
7377 self ._prefix_fn = snapshot_path_prefix or (lambda : DEFAULT_PREFIX )
7478 self ._reject_ambiguous = reject_ambiguous_session
7579 self ._lock = asyncio .Lock ()
7680 self ._subs : Subs = {}
7781
82+ def _is_async_client (self ) -> bool :
83+ """True if the client is AsyncClient or returns coroutines (`doc.get()`)."""
84+ if hasattr (self ._client , '__dict__' ) and '_is_async' in self ._client .__dict__ :
85+ return bool (getattr (self ._client , '_is_async' , False ))
86+ cls = type (self ._client )
87+ cls_name = cls .__name__
88+ cls_module = cls .__module__
89+ if 'async_client' in cls_module or 'AsyncClient' in cls_name or 'AsyncMock' in cls_name :
90+ return True
91+ if cls_name == 'MagicMock' or cls_name == 'Client' or 'firestore_v1.client' in cls_module :
92+ return False
93+ try :
94+ return inspect .isawaitable (self ._client .collection ('a' ).document ('b' ).get ())
95+ except Exception :
96+ return False
97+
7898 def _prefix (self ) -> str :
7999 return self ._prefix_fn ()
80100
@@ -99,26 +119,24 @@ async def get_snapshot(
99119 """Retrieve a session snapshot by snapshot ID or session ID."""
100120 _require_one_selector (snapshot_id , session_id )
101121 if snapshot_id is not None :
102- snap = await asyncio . to_thread ( self ._read_snapshot_sync , snapshot_id )
122+ snap = await self ._read_snapshot ( snapshot_id )
103123 return snap .model_copy (deep = True ) if snap is not None else None
104124
105125 assert session_id is not None
106- pointer = await asyncio . to_thread ( self ._read_pointer_sync , session_id )
126+ pointer = await self ._read_pointer ( session_id )
107127 if pointer is not None :
108- snap = await asyncio . to_thread ( self ._read_snapshot_sync , pointer ['currentSnapshotId' ])
128+ snap = await self ._read_snapshot ( pointer ['currentSnapshotId' ])
109129 return snap .model_copy (deep = True ) if snap is not None else None
110130
111131 # Fallback: scan snapshots for this session (dev/small deployments).
112- owned = await asyncio . to_thread ( self ._list_session_snapshots_sync , session_id )
132+ owned = await self ._list_session_snapshots ( session_id )
113133 leaf = _select_leaf (owned , session_id , reject_ambiguous = self ._reject_ambiguous )
114134 return leaf .model_copy (deep = True ) if leaf is not None else None
115135
116136 async def save_snapshot (self , snapshot_id : str | None , fn : SaveFn ) -> SessionSnapshot | None :
117137 """Save or update a session snapshot in Firestore."""
118138 async with self ._lock :
119- existing = (
120- await asyncio .to_thread (self ._read_snapshot_sync , snapshot_id ) if snapshot_id is not None else None
121- )
139+ existing = await self ._read_snapshot (snapshot_id ) if snapshot_id is not None else None
122140 next_snapshot = _apply_save (existing , snapshot_id , fn )
123141 if next_snapshot is None :
124142 return None
@@ -131,9 +149,8 @@ async def save_snapshot(self, snapshot_id: str | None, fn: SaveFn) -> SessionSna
131149 message = "FirestoreSessionStore requires 'sessionId' on the snapshot." ,
132150 )
133151
134- await asyncio .to_thread (self ._write_snapshot_sync , next_snapshot )
135- await asyncio .to_thread (
136- self ._maybe_update_pointer_sync ,
152+ await self ._write_snapshot (next_snapshot )
153+ await self ._maybe_update_pointer (
137154 session_id ,
138155 sid ,
139156 is_new = existing is None ,
@@ -144,12 +161,67 @@ async def save_snapshot(self, snapshot_id: str | None, fn: SaveFn) -> SessionSna
144161 async def on_snapshot_status_change (self , snapshot_id : str ) -> asyncio .Queue [SnapshotStatus | None ]:
145162 """Subscribe to status changes for a session snapshot."""
146163 async with self ._lock :
147- current = await asyncio . to_thread ( self ._read_snapshot_sync , snapshot_id )
164+ current = await self ._read_snapshot ( snapshot_id )
148165 q = await _subscribe (self ._subs , snapshot_id , current )
149166 # Firestore listener keeps cross-instance abort working for detached turns.
150167 self ._start_listener (snapshot_id , q )
151168 return q
152169
170+ async def _read_snapshot (self , snapshot_id : str ) -> SessionSnapshot | None :
171+ if self ._is_async_client ():
172+ doc = await self ._snapshot_ref (snapshot_id ).get ()
173+ if not doc .exists :
174+ return None
175+ return SessionSnapshot .model_validate (doc .to_dict ())
176+ return await asyncio .to_thread (self ._read_snapshot_sync , snapshot_id )
177+
178+ async def _write_snapshot (self , snapshot : SessionSnapshot ) -> None :
179+ if self ._is_async_client ():
180+ assert snapshot .snapshot_id is not None
181+ await self ._snapshot_ref (snapshot .snapshot_id ).set (
182+ _sanitize (snapshot .model_dump (by_alias = True , exclude_none = True ))
183+ )
184+ else :
185+ await asyncio .to_thread (self ._write_snapshot_sync , snapshot )
186+
187+ async def _read_pointer (self , session_id : str ) -> dict [str , str ] | None :
188+ if self ._is_async_client ():
189+ doc = await self ._pointer_ref (session_id ).get ()
190+ if not doc .exists :
191+ return None
192+ data = doc .to_dict () or {}
193+ current = data .get ('currentSnapshotId' )
194+ return {'currentSnapshotId' : current } if current else None
195+ return await asyncio .to_thread (self ._read_pointer_sync , session_id )
196+
197+ async def _maybe_update_pointer (self , session_id : str , snapshot_id : str , * , is_new : bool ) -> None :
198+ if self ._is_async_client ():
199+ ref = self ._pointer_ref (session_id )
200+ existing = await ref .get ()
201+ pointer = existing .to_dict () if existing .exists else None
202+ if is_new or not pointer or pointer .get ('currentSnapshotId' ) == snapshot_id :
203+ await ref .set (
204+ _sanitize ({
205+ 'currentSnapshotId' : snapshot_id ,
206+ 'updatedAt' : datetime .now (timezone .utc ).isoformat (),
207+ })
208+ )
209+ else :
210+ await asyncio .to_thread (self ._maybe_update_pointer_sync , session_id , snapshot_id , is_new = is_new )
211+
212+ async def _list_session_snapshots (self , session_id : str ) -> list [SessionSnapshot ]:
213+ if self ._is_async_client ():
214+ snaps : list [SessionSnapshot ] = []
215+ async for doc in self ._snapshots_col ().stream ():
216+ try :
217+ snap = SessionSnapshot .model_validate (doc .to_dict ())
218+ except (ValueError , TypeError ):
219+ continue
220+ if _session_id_of (snap ) == session_id :
221+ snaps .append (snap )
222+ return snaps
223+ return await asyncio .to_thread (self ._list_session_snapshots_sync , session_id )
224+
153225 def _read_snapshot_sync (self , snapshot_id : str ) -> SessionSnapshot | None :
154226 doc = self ._snapshot_ref (snapshot_id ).get ()
155227 if not doc .exists :
0 commit comments