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.
- agent_framework_community_postgres/__init__.py +77 -0
- agent_framework_community_postgres/__main__.py +3 -0
- agent_framework_community_postgres/_checkpoint_storage.py +218 -0
- agent_framework_community_postgres/_cli.py +97 -0
- agent_framework_community_postgres/_client.py +390 -0
- agent_framework_community_postgres/_entra.py +178 -0
- agent_framework_community_postgres/_framework.py +148 -0
- agent_framework_community_postgres/_history_provider.py +261 -0
- agent_framework_community_postgres/_json.py +34 -0
- agent_framework_community_postgres/_leases.py +196 -0
- agent_framework_community_postgres/_migrations.py +235 -0
- agent_framework_community_postgres/_persistence.py +184 -0
- agent_framework_community_postgres/_retention.py +159 -0
- agent_framework_community_postgres/_session_store.py +135 -0
- agent_framework_community_postgres/_store.py +66 -0
- agent_framework_community_postgres/_thread_snapshot_store.py +162 -0
- agent_framework_community_postgres/py.typed +0 -0
- community_agent_framework_postgres-0.1.1.dist-info/METADATA +367 -0
- community_agent_framework_postgres-0.1.1.dist-info/RECORD +21 -0
- community_agent_framework_postgres-0.1.1.dist-info/WHEEL +4 -0
- community_agent_framework_postgres-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|