Skip to content

Commit 49c8ae0

Browse files
committed
feat(py): add FirestoreSessionStore to google-cloud plugin
1 parent 2a7c10a commit 49c8ae0

4 files changed

Lines changed: 288 additions & 7 deletions

File tree

py/plugins/evaluators/README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ ai = Genkit(plugins=[GenkitEval()])
2424

2525
# Run evaluation with genkit eval-flow or programmatically
2626
evaluator = await ai.registry.resolve_evaluator('genkitEval/regex')
27-
result = await evaluator.run(input={
28-
'dataset': [
29-
{'input': 'sample', 'output': 'banana', 'reference': 'ba?a?a'},
30-
{'input': 'sample', 'output': 'apple', 'reference': 'ba?a?a'},
31-
],
32-
'evalRunId': 'test',
33-
})
27+
result = await evaluator.run(
28+
input={
29+
'dataset': [
30+
{'input': 'sample', 'output': 'banana', 'reference': 'ba?a?a'},
31+
{'input': 'sample', 'output': 'apple', 'reference': 'ba?a?a'},
32+
],
33+
'evalRunId': 'test',
34+
}
35+
)
3436
```
3537

3638
## Evaluators
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copyright 2026 Google LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Firestore session store module for Google Cloud plugin."""
5+
6+
from genkit.plugins.google_cloud.session_store.firestore import FirestoreSessionStore
7+
8+
__all__ = ['FirestoreSessionStore']
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Copyright 2026 Google LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Firestore-backed session store for agent snapshots.
5+
6+
Layout mirrors the JS ``FirestoreSessionStore`` collection structure (snapshots +
7+
pointers under a per-tenant prefix). This Python implementation stores each
8+
snapshot as a full document for now — diff/checkpoint sharding can land later
9+
for very long sessions.
10+
11+
Paths (default collection ``genkit-sessions``, prefix ``global``):
12+
13+
genkit-sessions/{prefix}/snapshots/{snapshotId}
14+
genkit-sessions-pointers/{prefix}/pointers/{sessionId}
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import asyncio
20+
import json
21+
from collections.abc import Callable
22+
from datetime import datetime, timezone
23+
from typing import Any, Generic, TypeVar
24+
25+
from genkit._ai._agents._session import SessionStore, SnapshotSubscriber
26+
from genkit._ai._agents._session_stores import (
27+
SaveFn,
28+
Subs,
29+
_apply_save,
30+
_notify,
31+
_require_one_selector,
32+
_select_leaf,
33+
_session_id_of,
34+
_subscribe,
35+
)
36+
from genkit._core._error import GenkitError
37+
from genkit._core._typing import SessionSnapshot, SnapshotStatus
38+
39+
StateT = TypeVar('StateT')
40+
41+
DEFAULT_COLLECTION = 'genkit-sessions'
42+
DEFAULT_PREFIX = 'global'
43+
44+
45+
def _sanitize(value: Any) -> Any: # noqa: ANN401
46+
"""Drop None members so Firestore accepts the payload."""
47+
return json.loads(json.dumps(value, default=str))
48+
49+
50+
class FirestoreSessionStore(SessionStore[StateT], SnapshotSubscriber, Generic[StateT]):
51+
"""Persist agent snapshots in Cloud Firestore.
52+
53+
Uses Application Default Credentials (or ``FIRESTORE_EMULATOR_HOST`` for
54+
the emulator). Pass ``snapshot_path_prefix`` to isolate tenants when session
55+
ids may collide across users.
56+
"""
57+
58+
def __init__(
59+
self,
60+
*,
61+
client: Any | None = None, # noqa: ANN401 — google.cloud.firestore.Client
62+
collection: str = DEFAULT_COLLECTION,
63+
snapshot_path_prefix: Callable[[], str] | None = None,
64+
reject_ambiguous_session: bool = False,
65+
) -> None:
66+
"""Initialize the Firestore session store."""
67+
if client is None:
68+
from google.cloud import firestore # noqa: PLC0415
69+
70+
client = firestore.Client()
71+
self._client = client
72+
self._collection = collection
73+
self._prefix_fn = snapshot_path_prefix or (lambda: DEFAULT_PREFIX)
74+
self._reject_ambiguous = reject_ambiguous_session
75+
self._lock = asyncio.Lock()
76+
self._subs: Subs = {}
77+
78+
def _prefix(self) -> str:
79+
return self._prefix_fn()
80+
81+
def _snapshots_col(self) -> Any: # noqa: ANN401
82+
return self._client.collection(self._collection).document(self._prefix()).collection('snapshots')
83+
84+
def _pointers_col(self) -> Any: # noqa: ANN401
85+
return self._client.collection(f'{self._collection}-pointers').document(self._prefix()).collection('pointers')
86+
87+
def _snapshot_ref(self, snapshot_id: str) -> Any: # noqa: ANN401
88+
return self._snapshots_col().document(snapshot_id)
89+
90+
def _pointer_ref(self, session_id: str) -> Any: # noqa: ANN401
91+
return self._pointers_col().document(session_id)
92+
93+
async def get_snapshot(
94+
self,
95+
*,
96+
snapshot_id: str | None = None,
97+
session_id: str | None = None,
98+
) -> SessionSnapshot | None:
99+
"""Retrieve a session snapshot by snapshot ID or session ID."""
100+
_require_one_selector(snapshot_id, session_id)
101+
if snapshot_id is not None:
102+
snap = await asyncio.to_thread(self._read_snapshot_sync, snapshot_id)
103+
return snap.model_copy(deep=True) if snap is not None else None
104+
105+
assert session_id is not None
106+
pointer = await asyncio.to_thread(self._read_pointer_sync, session_id)
107+
if pointer is not None:
108+
snap = await asyncio.to_thread(self._read_snapshot_sync, pointer['currentSnapshotId'])
109+
return snap.model_copy(deep=True) if snap is not None else None
110+
111+
# Fallback: scan snapshots for this session (dev/small deployments).
112+
owned = await asyncio.to_thread(self._list_session_snapshots_sync, session_id)
113+
leaf = _select_leaf(owned, session_id, reject_ambiguous=self._reject_ambiguous)
114+
return leaf.model_copy(deep=True) if leaf is not None else None
115+
116+
async def save_snapshot(self, snapshot_id: str | None, fn: SaveFn) -> SessionSnapshot | None:
117+
"""Save or update a session snapshot in Firestore."""
118+
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+
)
122+
next_snapshot = _apply_save(existing, snapshot_id, fn)
123+
if next_snapshot is None:
124+
return None
125+
126+
sid = next_snapshot.snapshot_id
127+
session_id = _session_id_of(next_snapshot)
128+
if not session_id:
129+
raise GenkitError(
130+
status='INVALID_ARGUMENT',
131+
message="FirestoreSessionStore requires 'sessionId' on the snapshot.",
132+
)
133+
134+
await asyncio.to_thread(self._write_snapshot_sync, next_snapshot)
135+
await asyncio.to_thread(
136+
self._maybe_update_pointer_sync,
137+
session_id,
138+
sid,
139+
is_new=existing is None,
140+
)
141+
_notify(self._subs, sid, next_snapshot.status)
142+
return next_snapshot
143+
144+
async def on_snapshot_status_change(self, snapshot_id: str) -> asyncio.Queue[SnapshotStatus | None]:
145+
"""Subscribe to status changes for a session snapshot."""
146+
async with self._lock:
147+
current = await asyncio.to_thread(self._read_snapshot_sync, snapshot_id)
148+
q = await _subscribe(self._subs, snapshot_id, current)
149+
# Firestore listener keeps cross-instance abort working for detached turns.
150+
self._start_listener(snapshot_id, q)
151+
return q
152+
153+
def _read_snapshot_sync(self, snapshot_id: str) -> SessionSnapshot | None:
154+
doc = self._snapshot_ref(snapshot_id).get()
155+
if not doc.exists:
156+
return None
157+
return SessionSnapshot.model_validate(doc.to_dict())
158+
159+
def _write_snapshot_sync(self, snapshot: SessionSnapshot) -> None:
160+
assert snapshot.snapshot_id is not None
161+
self._snapshot_ref(snapshot.snapshot_id).set(_sanitize(snapshot.model_dump(by_alias=True, exclude_none=True)))
162+
163+
def _read_pointer_sync(self, session_id: str) -> dict[str, str] | None:
164+
doc = self._pointer_ref(session_id).get()
165+
if not doc.exists:
166+
return None
167+
data = doc.to_dict() or {}
168+
current = data.get('currentSnapshotId')
169+
return {'currentSnapshotId': current} if current else None
170+
171+
def _maybe_update_pointer_sync(self, session_id: str, snapshot_id: str, *, is_new: bool) -> None:
172+
ref = self._pointer_ref(session_id)
173+
existing = ref.get()
174+
pointer = existing.to_dict() if existing.exists else None
175+
if is_new or not pointer or pointer.get('currentSnapshotId') == snapshot_id:
176+
ref.set(
177+
_sanitize({
178+
'currentSnapshotId': snapshot_id,
179+
'updatedAt': datetime.now(timezone.utc).isoformat(),
180+
})
181+
)
182+
183+
def _list_session_snapshots_sync(self, session_id: str) -> list[SessionSnapshot]:
184+
# Prefix scan via collection listing — fine for moderate session counts.
185+
snaps: list[SessionSnapshot] = []
186+
for doc in self._snapshots_col().stream():
187+
try:
188+
snap = SessionSnapshot.model_validate(doc.to_dict())
189+
except (ValueError, TypeError):
190+
continue
191+
if _session_id_of(snap) == session_id:
192+
snaps.append(snap)
193+
return snaps
194+
195+
def _start_listener(self, snapshot_id: str, q: asyncio.Queue[SnapshotStatus | None]) -> None:
196+
ref = self._snapshot_ref(snapshot_id)
197+
loop = asyncio.get_event_loop()
198+
199+
def on_snapshot(doc_snapshot: Any) -> None: # noqa: ANN401
200+
if not doc_snapshot.exists:
201+
return
202+
data = doc_snapshot.to_dict() or {}
203+
status_val = data.get('status')
204+
if status_val is None:
205+
return
206+
try:
207+
status = SnapshotStatus(status_val)
208+
except ValueError:
209+
return
210+
loop.call_soon_threadsafe(lambda: _notify(self._subs, snapshot_id, status))
211+
212+
ref.on_snapshot(on_snapshot)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright 2026 Google LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Tests for FirestoreSessionStore."""
5+
6+
from unittest.mock import MagicMock
7+
8+
import pytest
9+
from genkit._core._typing import SessionSnapshot
10+
from genkit.plugins.google_cloud.session_store.firestore import FirestoreSessionStore
11+
12+
13+
@pytest.mark.asyncio
14+
async def test_firestore_session_store_save_and_get() -> None:
15+
"""Test saving and getting a session snapshot using a mock Firestore client."""
16+
mock_client = MagicMock()
17+
mock_doc_snapshot = MagicMock()
18+
19+
# Configure mock chain so any ref.get() returns mock_doc_snapshot
20+
mock_doc_ref = MagicMock()
21+
mock_doc_ref.get.return_value = mock_doc_snapshot
22+
23+
mock_col = MagicMock()
24+
mock_col.document.return_value = mock_doc_ref
25+
26+
mock_doc_ref.collection.return_value = mock_col
27+
mock_client.collection.return_value = mock_col
28+
29+
store = FirestoreSessionStore(client=mock_client)
30+
31+
snapshot_data = {
32+
'snapshotId': 'snap-123',
33+
'sessionId': 'sess-456',
34+
'createdAt': '2026-07-03T00:00:00Z',
35+
}
36+
mock_doc_snapshot.exists = True
37+
mock_doc_snapshot.to_dict.return_value = snapshot_data
38+
39+
# Test get_snapshot
40+
retrieved = await store.get_snapshot(snapshot_id='snap-123')
41+
assert retrieved is not None
42+
assert retrieved.snapshot_id == 'snap-123'
43+
assert retrieved.session_id == 'sess-456'
44+
45+
# Test save_snapshot
46+
mock_doc_snapshot.exists = False
47+
mock_doc_snapshot.to_dict.return_value = None
48+
49+
def save_fn(existing: SessionSnapshot | None) -> SessionSnapshot:
50+
return SessionSnapshot(
51+
snapshot_id='snap-789',
52+
session_id='sess-456',
53+
created_at='2026-07-03T00:00:01Z',
54+
)
55+
56+
saved = await store.save_snapshot(None, save_fn)
57+
assert saved is not None
58+
assert saved.session_id == 'sess-456'
59+
assert isinstance(saved.snapshot_id, str)

0 commit comments

Comments
 (0)