community-agent-framework-postgres 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ """``AgentSession`` snapshots in PostgreSQL, one row per opaque session-store id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, ClassVar, cast
7
+
8
+ from agent_framework import AgentSession, SecretString, SessionStore
9
+ from psycopg import sql
10
+ from psycopg.rows import dict_row
11
+
12
+ from ._client import ClientHandle, PostgresClient
13
+ from ._entra import EntraCredential
14
+ from ._json import encode_jsonb
15
+ from ._retention import EXPIRES_AT, PurgeReport, RetentionPolicy, purge_rows
16
+ from ._store import BaseStore
17
+
18
+
19
+ class PostgresSessionStore(SessionStore, BaseStore):
20
+ """PostgreSQL-backed ``SessionStore``.
21
+
22
+ ``set`` stores ``AgentSession.to_dict()`` (custom state goes through
23
+ ``register_state_type`` codecs) as JSONB; ``get`` rebuilds a fresh session with
24
+ ``AgentSession.from_dict``. Restore a session with the same agent and provider
25
+ configuration that created it, as the framework documents. ``SessionStore`` is
26
+ marked experimental upstream; this implementation follows it.
27
+ Writes are last-writer-wins, as with the framework's ``SessionStore``; serialize
28
+ concurrent runs of one session in the host.
29
+ """
30
+
31
+ SNAPSHOT_VERSION: ClassVar[str] = "1"
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ application_id: str,
37
+ connection_string: str | SecretString | None = None,
38
+ client: PostgresClient | ClientHandle | None = None,
39
+ credential: EntraCredential | None = None,
40
+ env_file_path: str | None = None,
41
+ env_file_encoding: str | None = None,
42
+ schema: str = "public",
43
+ table_prefix: str = "af_",
44
+ retention: RetentionPolicy | None = None,
45
+ pool_kwargs: Mapping[str, Any] | None = None,
46
+ ) -> None:
47
+ SessionStore.__init__(self)
48
+ BaseStore.__init__(
49
+ self,
50
+ application_id=application_id,
51
+ connection_string=connection_string,
52
+ client=client,
53
+ credential=credential,
54
+ env_file_path=env_file_path,
55
+ env_file_encoding=env_file_encoding,
56
+ schema=schema,
57
+ table_prefix=table_prefix,
58
+ retention=retention,
59
+ pool_kwargs=pool_kwargs,
60
+ )
61
+
62
+ async def get(self, session_id: str) -> AgentSession | None:
63
+ """A fresh copy of the stored session, or ``None`` when absent or purged.
64
+
65
+ Raises:
66
+ ValueError: The row holds a snapshot this release cannot read. Delete it to start over.
67
+ """
68
+ SessionStore.validate_session_id(session_id)
69
+ async with (
70
+ self._client.connection(transactional=False) as connection,
71
+ connection.cursor(row_factory=dict_row) as cursor,
72
+ ):
73
+ await cursor.execute(
74
+ sql.SQL(
75
+ "SELECT snapshot, snapshot_version, purged_at FROM {sessions}"
76
+ " WHERE application_id = %(app)s AND session_id = %(id)s"
77
+ ).format(sessions=self._names.table("sessions")),
78
+ {"app": self.application_id, "id": session_id},
79
+ )
80
+ row = await cursor.fetchone()
81
+ if row is None or row["purged_at"] is not None:
82
+ return None
83
+ if row["snapshot_version"] != self.SNAPSHOT_VERSION:
84
+ raise ValueError(
85
+ f"Unsupported session snapshot version {row['snapshot_version']!r}; expected {self.SNAPSHOT_VERSION!r}."
86
+ )
87
+ if not isinstance(row["snapshot"], dict):
88
+ raise ValueError("The stored session snapshot is not an object.")
89
+ return AgentSession.from_dict(cast(dict[str, Any], row["snapshot"]))
90
+
91
+ async def set(self, session_id: str, session: AgentSession) -> None:
92
+ """Store the session, replacing any existing snapshot."""
93
+ SessionStore.validate_session_id(session_id)
94
+ snapshot = encode_jsonb(session.to_dict(), what="Session state")
95
+ async with self._client.connection() as connection:
96
+ await connection.execute(
97
+ sql.SQL(
98
+ "INSERT INTO {sessions} (application_id, session_id, snapshot, snapshot_version, expires_at)"
99
+ " VALUES (%(app)s, %(id)s, %(snapshot)s, %(version)s, {expires})"
100
+ " ON CONFLICT (application_id, session_id) DO UPDATE SET"
101
+ " snapshot = EXCLUDED.snapshot, snapshot_version = EXCLUDED.snapshot_version,"
102
+ " updated_at = now(),"
103
+ " expires_at = EXCLUDED.expires_at, purged_at = NULL"
104
+ ).format(sessions=self._names.table("sessions"), expires=EXPIRES_AT),
105
+ {
106
+ "app": self.application_id,
107
+ "id": session_id,
108
+ "snapshot": snapshot,
109
+ "version": self.SNAPSHOT_VERSION,
110
+ "ttl": self._ttl(),
111
+ },
112
+ )
113
+
114
+ async def delete(self, session_id: str) -> None:
115
+ """Remove the stored session, if any."""
116
+ SessionStore.validate_session_id(session_id)
117
+ async with self._client.connection() as connection:
118
+ await connection.execute(
119
+ sql.SQL("DELETE FROM {sessions} WHERE application_id = %(app)s AND session_id = %(id)s").format(
120
+ sessions=self._names.table("sessions")
121
+ ),
122
+ {"app": self.application_id, "id": session_id},
123
+ )
124
+
125
+ async def purge(self) -> PurgeReport:
126
+ """Purge expired sessions in this store's mode, whatever policy stamped them."""
127
+ count = await purge_rows(
128
+ self._client,
129
+ table=self._names.table("sessions"),
130
+ where=sql.SQL("application_id = %(app)s"),
131
+ params={"app": self.application_id},
132
+ mode=self._retention.mode,
133
+ tombstone=sql.SQL("snapshot = NULL"),
134
+ )
135
+ return PurgeReport({f"{self._names.prefix}sessions": count})
@@ -0,0 +1,66 @@
1
+ """What every store shares: one client, one naming scheme, one retention policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import timedelta
7
+ from typing import Any, Self
8
+
9
+ from agent_framework import SecretString
10
+
11
+ from ._client import (
12
+ ClientHandle,
13
+ PostgresClient,
14
+ TableNames,
15
+ create_client,
16
+ require_text,
17
+ )
18
+ from ._entra import EntraCredential
19
+ from ._framework import log_untested_once
20
+ from ._retention import RetentionPolicy
21
+
22
+
23
+ class BaseStore:
24
+ def __init__(
25
+ self,
26
+ *,
27
+ application_id: str,
28
+ connection_string: str | SecretString | None,
29
+ client: PostgresClient | ClientHandle | None,
30
+ credential: EntraCredential | None = None,
31
+ env_file_path: str | None,
32
+ env_file_encoding: str | None,
33
+ schema: str,
34
+ table_prefix: str,
35
+ retention: RetentionPolicy | None,
36
+ pool_kwargs: Mapping[str, Any] | None = None,
37
+ ) -> None:
38
+ log_untested_once()
39
+ self.application_id = require_text(application_id, "application_id")
40
+ self._names = TableNames(schema=schema, prefix=table_prefix)
41
+ self._client: ClientHandle = create_client(
42
+ connection_string,
43
+ client=client,
44
+ env_file_path=env_file_path,
45
+ env_file_encoding=env_file_encoding,
46
+ credential=credential,
47
+ pool_kwargs=pool_kwargs,
48
+ )
49
+ self._retention = retention or RetentionPolicy()
50
+
51
+ @property
52
+ def retention(self) -> RetentionPolicy:
53
+ return self._retention
54
+
55
+ def _ttl(self) -> timedelta | None:
56
+ return self._retention.ttl
57
+
58
+ async def close(self) -> None:
59
+ """Close the pool this store created; a borrowed client is left alone."""
60
+ await self._client.close()
61
+
62
+ async def __aenter__(self) -> Self:
63
+ return self
64
+
65
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
66
+ await self.close()
@@ -0,0 +1,162 @@
1
+ """Latest AG-UI Thread Snapshots in PostgreSQL, keyed by scope and thread id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from agent_framework import SecretString
9
+ from psycopg import sql
10
+ from psycopg.rows import dict_row
11
+ from psycopg.types.json import Jsonb
12
+
13
+ from ._client import ClientHandle, PostgresClient, require_text
14
+ from ._entra import EntraCredential
15
+ from ._json import encode_jsonb
16
+ from ._retention import EXPIRES_AT, PurgeReport, RetentionPolicy, purge_rows
17
+ from ._store import BaseStore
18
+
19
+ try:
20
+ from agent_framework_ag_ui import AGUIThreadSnapshot, AGUIThreadSnapshotStore, SnapshotScope
21
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
22
+ raise ImportError(
23
+ "PostgresAGUIThreadSnapshotStore needs agent-framework-ag-ui. "
24
+ "Install community-agent-framework-postgres[ag-ui]."
25
+ ) from exc
26
+
27
+
28
+ class PostgresAGUIThreadSnapshotStore(BaseStore):
29
+ """PostgreSQL-backed ``AGUIThreadSnapshotStore``.
30
+
31
+ One row per ``(scope, thread_id)`` holding the latest snapshot; ``save`` replaces it
32
+ (last writer wins, as the in-memory store documents). ``scope`` must be the
33
+ application's authorization boundary; a thread id alone never reads a row.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ application_id: str,
40
+ connection_string: str | SecretString | None = None,
41
+ client: PostgresClient | ClientHandle | None = None,
42
+ credential: EntraCredential | None = None,
43
+ env_file_path: str | None = None,
44
+ env_file_encoding: str | None = None,
45
+ schema: str = "public",
46
+ table_prefix: str = "af_",
47
+ retention: RetentionPolicy | None = None,
48
+ pool_kwargs: Mapping[str, Any] | None = None,
49
+ ) -> None:
50
+ super().__init__(
51
+ application_id=application_id,
52
+ connection_string=connection_string,
53
+ client=client,
54
+ credential=credential,
55
+ env_file_path=env_file_path,
56
+ env_file_encoding=env_file_encoding,
57
+ schema=schema,
58
+ table_prefix=table_prefix,
59
+ retention=retention,
60
+ pool_kwargs=pool_kwargs,
61
+ )
62
+
63
+ def _params(self, scope: str, thread_id: str) -> dict[str, Any]:
64
+ return {
65
+ "app": self.application_id,
66
+ "scope": require_text(scope, "scope"),
67
+ "thread": require_text(thread_id, "thread_id"),
68
+ }
69
+
70
+ _KEY = sql.SQL("application_id = %(app)s AND scope = %(scope)s AND thread_id = %(thread)s")
71
+
72
+ @staticmethod
73
+ def _jsonb(value: Any, what: str) -> Jsonb | None:
74
+ return encode_jsonb(value, what=what) if value is not None else None
75
+
76
+ async def save(self, *, scope: str, thread_id: str, snapshot: AGUIThreadSnapshot) -> None:
77
+ """Store the latest snapshot for the thread, replacing the previous one."""
78
+ values = {
79
+ "messages": encode_jsonb(list(snapshot.messages), what="Thread snapshot messages"),
80
+ "state": self._jsonb(snapshot.state, "Thread snapshot state"),
81
+ "interrupt": self._jsonb(snapshot.interrupt, "Thread snapshot interrupt"),
82
+ "session_state": self._jsonb(snapshot.session_state, "Thread snapshot session state"),
83
+ }
84
+ async with self._client.connection() as connection:
85
+ await connection.execute(
86
+ sql.SQL(
87
+ "INSERT INTO {snapshots} (application_id, scope, thread_id, messages, state, interrupt,"
88
+ " session_state, expires_at)"
89
+ " VALUES (%(app)s, %(scope)s, %(thread)s, %(messages)s, %(state)s, %(interrupt)s,"
90
+ " %(session_state)s, {expires})"
91
+ " ON CONFLICT (application_id, scope, thread_id) DO UPDATE SET"
92
+ " messages = EXCLUDED.messages, state = EXCLUDED.state, interrupt = EXCLUDED.interrupt,"
93
+ " session_state = EXCLUDED.session_state,"
94
+ " updated_at = now(), expires_at = EXCLUDED.expires_at, purged_at = NULL"
95
+ ).format(snapshots=self._names.table("thread_snapshots"), expires=EXPIRES_AT),
96
+ self._params(scope, thread_id) | values | {"ttl": self._ttl()},
97
+ )
98
+
99
+ async def get(self, *, scope: str, thread_id: str) -> AGUIThreadSnapshot | None:
100
+ """The latest snapshot, or ``None`` when absent or purged."""
101
+ async with (
102
+ self._client.connection(transactional=False) as connection,
103
+ connection.cursor(row_factory=dict_row) as cursor,
104
+ ):
105
+ await cursor.execute(
106
+ sql.SQL(
107
+ "SELECT messages, state, interrupt, session_state, purged_at FROM {snapshots} WHERE {key}"
108
+ ).format(snapshots=self._names.table("thread_snapshots"), key=self._KEY),
109
+ self._params(scope, thread_id),
110
+ )
111
+ row = await cursor.fetchone()
112
+ if row is None or row["purged_at"] is not None:
113
+ return None
114
+ return AGUIThreadSnapshot(
115
+ messages=row["messages"], state=row["state"], interrupt=row["interrupt"], session_state=row["session_state"]
116
+ )
117
+
118
+ async def delete(self, *, scope: str, thread_id: str) -> bool:
119
+ """Remove the thread's snapshot; ``True`` when a live one existed.
120
+
121
+ A purged row is removed too, and returns ``False``.
122
+ """
123
+ async with self._client.connection() as connection:
124
+ cursor = await connection.execute(
125
+ sql.SQL("DELETE FROM {snapshots} WHERE {key} RETURNING purged_at IS NULL").format(
126
+ snapshots=self._names.table("thread_snapshots"), key=self._KEY
127
+ ),
128
+ self._params(scope, thread_id),
129
+ )
130
+ row = await cursor.fetchone()
131
+ return row is not None and bool(row[0])
132
+
133
+ async def clear(self, *, scope: SnapshotScope | None = None) -> None:
134
+ """Remove one scope's snapshots, or every snapshot of this application when ``scope`` is ``None``.
135
+
136
+ The signature is the ``AGUIThreadSnapshotStore`` protocol's. An explicit empty scope raises ``ValueError``.
137
+ """
138
+ if scope is None:
139
+ query = sql.SQL("DELETE FROM {snapshots} WHERE application_id = %(app)s")
140
+ params: dict[str, Any] = {"app": self.application_id}
141
+ else:
142
+ query = sql.SQL("DELETE FROM {snapshots} WHERE application_id = %(app)s AND scope = %(scope)s")
143
+ params = {"app": self.application_id, "scope": require_text(scope, "scope")}
144
+ async with self._client.connection() as connection:
145
+ await connection.execute(query.format(snapshots=self._names.table("thread_snapshots")), params)
146
+
147
+ async def purge(self) -> PurgeReport:
148
+ """Purge expired snapshots in this store's mode, whatever policy stamped them."""
149
+ count = await purge_rows(
150
+ self._client,
151
+ table=self._names.table("thread_snapshots"),
152
+ where=sql.SQL("application_id = %(app)s"),
153
+ params={"app": self.application_id},
154
+ mode=self._retention.mode,
155
+ tombstone=sql.SQL("messages = NULL, state = NULL, interrupt = NULL, session_state = NULL"),
156
+ )
157
+ return PurgeReport({f"{self._names.prefix}thread_snapshots": count})
158
+
159
+
160
+ if TYPE_CHECKING:
161
+ # The assignment is the test; pyright checks protocol compatibility.
162
+ _PROTOCOL_CHECK: type[AGUIThreadSnapshotStore] = PostgresAGUIThreadSnapshotStore
File without changes