swiftpy-framework 0.1.0__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.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,864 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import asyncio
5
+ import datetime
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import time
10
+ import weakref
11
+ from collections.abc import AsyncIterator, Sequence
12
+ from contextlib import asynccontextmanager
13
+ from typing import Any, ClassVar
14
+
15
+ import redis.asyncio as aioredis
16
+ from redis.exceptions import RedisError
17
+
18
+ from swiftpy.ai.conversation import Conversation
19
+ from swiftpy.ai.exceptions import AIConnectionError
20
+ from swiftpy.ai.providers.base import Message, ToolCall
21
+ from swiftpy.core.config import Config
22
+ from swiftpy.core.container import get_container
23
+ from swiftpy.database.identifiers import (
24
+ MAX_IDENTIFIER_LENGTH,
25
+ quote_identifier,
26
+ validate_identifier,
27
+ )
28
+ from swiftpy.database.pool import DatabasePool
29
+ from swiftpy.database.transaction import get_active_connection
30
+ from swiftpy.support.redis_config import resolve_redis_params
31
+
32
+ logger = logging.getLogger("swiftpy.ai.agents.memory")
33
+
34
+
35
+ def _bounded_index_name(table_name: str, suffix: str = "_session") -> str:
36
+ """Return a PostgreSQL identifier within MAX_IDENTIFIER_LENGTH (63 bytes)."""
37
+ candidate = f"idx_{table_name}{suffix}"
38
+ if len(candidate.encode()) <= MAX_IDENTIFIER_LENGTH:
39
+ return validate_identifier(candidate)
40
+ digest = hashlib.sha256(f"{table_name}:{suffix}".encode()).hexdigest()[:8]
41
+ tail = f"_{digest}{suffix}"
42
+ prefix_budget = MAX_IDENTIFIER_LENGTH - len(tail.encode())
43
+ trimmed_prefix = f"idx_{table_name}"[:prefix_budget].rstrip("_")
44
+ return validate_identifier(f"{trimmed_prefix}{tail}")
45
+
46
+
47
+ def _resolve_expiry(ttl: float | None, default_ttl: float | None) -> datetime.datetime | None:
48
+ effective = ttl if ttl is not None else default_ttl
49
+ if effective is None or effective <= 0:
50
+ return None
51
+ return datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=float(effective))
52
+
53
+
54
+ def _serialize_tool_call(tc: ToolCall) -> dict[str, Any]:
55
+ """Serialize a ToolCall into a JSON-compatible dictionary."""
56
+ return {
57
+ "id": tc.id,
58
+ "type": "function",
59
+ "function": {
60
+ "name": tc.name,
61
+ "arguments": (
62
+ json.dumps(tc.arguments)
63
+ if isinstance(tc.arguments, dict)
64
+ else str(tc.arguments)
65
+ ),
66
+ },
67
+ }
68
+
69
+
70
+ def _deserialize_tool_call(item: dict[str, Any]) -> ToolCall:
71
+ """Deserialize a dictionary into a ToolCall instance."""
72
+ tc_id = str(item.get("id") or "")
73
+ fn = item.get("function")
74
+ if isinstance(fn, dict):
75
+ name = str(fn.get("name") or "")
76
+ args_raw = fn.get("arguments")
77
+ if isinstance(args_raw, str):
78
+ try:
79
+ args = json.loads(args_raw)
80
+ except (json.JSONDecodeError, TypeError, ValueError):
81
+ args = {"raw": args_raw}
82
+ elif isinstance(args_raw, dict):
83
+ args = args_raw
84
+ else:
85
+ args = {}
86
+ else:
87
+ name = str(item.get("name") or "")
88
+ args_raw = item.get("arguments")
89
+ args = args_raw if isinstance(args_raw, dict) else {}
90
+ return ToolCall(id=tc_id, name=name, arguments=args)
91
+
92
+
93
+ def _serialize_message(msg: Message) -> dict[str, Any]:
94
+ """Serialize a Message instance into a JSON-compatible dictionary."""
95
+ payload: dict[str, Any] = {
96
+ "role": msg.role.lower(),
97
+ "content": msg.content,
98
+ }
99
+ if msg.name:
100
+ payload["name"] = msg.name
101
+ if msg.tool_call_id:
102
+ payload["tool_call_id"] = msg.tool_call_id
103
+ if msg.tool_calls:
104
+ payload["tool_calls"] = [_serialize_tool_call(tc) for tc in msg.tool_calls]
105
+ return payload
106
+
107
+
108
+ def _deserialize_message(data: dict[str, Any]) -> Message:
109
+ """Deserialize a dictionary into a validated Message instance."""
110
+ role = str(data.get("role", "user"))
111
+ content = str(data.get("content", ""))
112
+ name = data.get("name")
113
+ tool_call_id = data.get("tool_call_id")
114
+ raw_tool_calls = data.get("tool_calls")
115
+ tool_calls: list[ToolCall] | None = None
116
+ if isinstance(raw_tool_calls, list):
117
+ tool_calls = [_deserialize_tool_call(item) for item in raw_tool_calls if isinstance(item, dict)]
118
+
119
+ return Message(
120
+ role=role,
121
+ content=content,
122
+ name=str(name) if name is not None else None,
123
+ tool_calls=tool_calls,
124
+ tool_call_id=str(tool_call_id) if tool_call_id is not None else None,
125
+ )
126
+
127
+
128
+ class BaseMemoryBackend(abc.ABC):
129
+ """Abstract protocol for agent memory backends."""
130
+
131
+ @abc.abstractmethod
132
+ async def load_messages(self, session_id: str) -> list[Message]:
133
+ """Load ordered messages for a given session."""
134
+ raise NotImplementedError
135
+
136
+ @abc.abstractmethod
137
+ async def save_messages(
138
+ self,
139
+ session_id: str,
140
+ messages: Sequence[Message],
141
+ ttl: int | None = None,
142
+ ) -> None:
143
+ """Persist ordered messages for a given session."""
144
+ raise NotImplementedError
145
+
146
+ @abc.abstractmethod
147
+ async def append_message(
148
+ self,
149
+ session_id: str,
150
+ message: Message,
151
+ ttl: int | None = None,
152
+ ) -> None:
153
+ """Append a single message to the session's memory."""
154
+ raise NotImplementedError
155
+
156
+ @abc.abstractmethod
157
+ async def clear(self, session_id: str) -> None:
158
+ """Delete all messages and state for the given session."""
159
+ raise NotImplementedError
160
+
161
+ @abc.abstractmethod
162
+ async def exists(self, session_id: str) -> bool:
163
+ """Check if any memory exists for the given session."""
164
+ raise NotImplementedError
165
+
166
+ @abc.abstractmethod
167
+ async def get_state(self, session_id: str, key: str, default: Any = None) -> Any:
168
+ """Get a state variable for the session."""
169
+ raise NotImplementedError
170
+
171
+ @abc.abstractmethod
172
+ async def set_state(self, session_id: str, key: str, value: Any) -> None:
173
+ """Set a state variable for the session."""
174
+ raise NotImplementedError
175
+
176
+ @abc.abstractmethod
177
+ async def get_all_state(self, session_id: str) -> dict[str, Any]:
178
+ """Get the full state dictionary for the session."""
179
+ raise NotImplementedError
180
+
181
+
182
+ class InMemoryMemoryBackend(BaseMemoryBackend):
183
+ """
184
+ In-memory agent memory backend for unit tests, local debugging,
185
+ and ephemeral agent executions with optional TTL expiration.
186
+ """
187
+
188
+ def __init__(self, default_ttl: float | None = None) -> None:
189
+ if default_ttl is not None and (
190
+ isinstance(default_ttl, bool)
191
+ or not isinstance(default_ttl, (int, float))
192
+ or float(default_ttl) <= 0
193
+ ):
194
+ raise ValueError(f"default_ttl must be a positive number, got {default_ttl}")
195
+ self.default_ttl = default_ttl
196
+ self._messages: dict[str, list[dict[str, Any]]] = {}
197
+ self._state: dict[str, dict[str, Any]] = {}
198
+ self._msg_expires: dict[str, float] = {}
199
+ self._state_expires: dict[str, float] = {}
200
+
201
+ def _prune_expired(self, session_id: str) -> None:
202
+ now = time.monotonic()
203
+ msg_exp = self._msg_expires.get(session_id)
204
+ if msg_exp is not None and now >= msg_exp:
205
+ self._msg_expires.pop(session_id, None)
206
+ self._messages.pop(session_id, None)
207
+ state_exp = self._state_expires.get(session_id)
208
+ if state_exp is not None and now >= state_exp:
209
+ self._state_expires.pop(session_id, None)
210
+ self._state.pop(session_id, None)
211
+
212
+ def _apply_ttl(
213
+ self,
214
+ expiry_map: dict[str, float],
215
+ session_id: str,
216
+ ttl: float | None,
217
+ ) -> None:
218
+ effective = ttl if ttl is not None else self.default_ttl
219
+ if effective is not None and effective > 0:
220
+ expiry_map[session_id] = time.monotonic() + float(effective)
221
+ else:
222
+ expiry_map.pop(session_id, None)
223
+
224
+ async def load_messages(self, session_id: str) -> list[Message]:
225
+ self._prune_expired(session_id)
226
+ stored = self._messages.get(session_id, [])
227
+ return [_deserialize_message(item) for item in stored]
228
+
229
+ async def save_messages(
230
+ self,
231
+ session_id: str,
232
+ messages: Sequence[Message],
233
+ ttl: int | None = None,
234
+ ) -> None:
235
+ self._messages[session_id] = [_serialize_message(m) for m in messages]
236
+ self._apply_ttl(self._msg_expires, session_id, ttl)
237
+
238
+ async def append_message(
239
+ self,
240
+ session_id: str,
241
+ message: Message,
242
+ ttl: int | None = None,
243
+ ) -> None:
244
+ self._prune_expired(session_id)
245
+ if session_id not in self._messages:
246
+ self._messages[session_id] = []
247
+ self._messages[session_id].append(_serialize_message(message))
248
+ self._apply_ttl(self._msg_expires, session_id, ttl)
249
+
250
+ async def clear(self, session_id: str) -> None:
251
+ self._messages.pop(session_id, None)
252
+ self._state.pop(session_id, None)
253
+ self._msg_expires.pop(session_id, None)
254
+ self._state_expires.pop(session_id, None)
255
+
256
+ async def exists(self, session_id: str) -> bool:
257
+ self._prune_expired(session_id)
258
+ return session_id in self._messages or session_id in self._state
259
+
260
+ async def get_state(self, session_id: str, key: str, default: Any = None) -> Any:
261
+ self._prune_expired(session_id)
262
+ return self._state.get(session_id, {}).get(key, default)
263
+
264
+ async def set_state(
265
+ self,
266
+ session_id: str,
267
+ key: str,
268
+ value: Any,
269
+ ttl: int | None = None,
270
+ ) -> None:
271
+ self._prune_expired(session_id)
272
+ if session_id not in self._state:
273
+ self._state[session_id] = {}
274
+ self._state[session_id][key] = value
275
+ self._apply_ttl(self._state_expires, session_id, ttl)
276
+
277
+ async def get_all_state(self, session_id: str) -> dict[str, Any]:
278
+ self._prune_expired(session_id)
279
+ return dict(self._state.get(session_id, {}))
280
+
281
+
282
+ class RedisMemoryBackend(BaseMemoryBackend):
283
+ """
284
+ Redis-backed agent memory backend for distributed, horizontally scalable
285
+ persistence across calls and worker nodes, with shared pooled clients and
286
+ atomic hash-based state updates.
287
+ """
288
+
289
+ _POOLS: ClassVar[
290
+ weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, aioredis.ConnectionPool]]
291
+ ] = weakref.WeakKeyDictionary()
292
+
293
+ def __init__(
294
+ self,
295
+ redis_client: aioredis.Redis | None = None,
296
+ config: Config | None = None,
297
+ key_prefix: str = "agent:memory",
298
+ default_ttl: int | None = None,
299
+ ) -> None:
300
+ self._client = redis_client
301
+ self._config = config
302
+ self._key_prefix = key_prefix
303
+ self.default_ttl = default_ttl
304
+ self._owns_client = redis_client is None
305
+
306
+ def _msg_key(self, session_id: str) -> str:
307
+ clean = session_id.strip()
308
+ return f"{self._key_prefix}:{clean}:messages"
309
+
310
+ def _state_key(self, session_id: str) -> str:
311
+ clean = session_id.strip()
312
+ return f"{self._key_prefix}:{clean}:state"
313
+
314
+ @classmethod
315
+ def _get_or_create_pool(cls, url: str) -> aioredis.ConnectionPool:
316
+ loop = asyncio.get_running_loop()
317
+ loop_pools = cls._POOLS.get(loop)
318
+ if loop_pools is None:
319
+ loop_pools = {}
320
+ cls._POOLS[loop] = loop_pools
321
+ pool = loop_pools.get(url)
322
+ if pool is None:
323
+ pool = aioredis.ConnectionPool.from_url(
324
+ url,
325
+ decode_responses=True,
326
+ socket_timeout=5.0,
327
+ socket_connect_timeout=5.0,
328
+ )
329
+ loop_pools[url] = pool
330
+ return pool
331
+
332
+ async def get_client(self) -> aioredis.Redis:
333
+ if self._client is None:
334
+ cfg = self._config or Config.get_instance()
335
+ params = resolve_redis_params(cfg, section="ai")
336
+ redis_url = str(params["url"])
337
+ pool = self._get_or_create_pool(redis_url)
338
+ self._client = aioredis.Redis(connection_pool=pool)
339
+ return self._client
340
+
341
+ async def close(self) -> None:
342
+ if self._owns_client and self._client is not None:
343
+ try:
344
+ await self._client.aclose(close_connection_pool=False)
345
+ except (RedisError, OSError) as exc:
346
+ logger.debug("Failed to close Redis client gracefully: %s", exc)
347
+ finally:
348
+ self._client = None
349
+
350
+ async def load_messages(self, session_id: str) -> list[Message]:
351
+ client = await self.get_client()
352
+ key = self._msg_key(session_id)
353
+ try:
354
+ items = await client.lrange(key, 0, -1)
355
+ except RedisError as exc:
356
+ raise AIConnectionError(f"Failed to load agent memory from Redis: {exc}") from exc
357
+
358
+ if not items:
359
+ return []
360
+ result: list[Message] = []
361
+ for raw in items:
362
+ try:
363
+ data = json.loads(raw)
364
+ if isinstance(data, dict):
365
+ result.append(_deserialize_message(data))
366
+ except (ValueError, TypeError) as exc:
367
+ raise ValueError(f"Corrupted agent memory data in Redis for session '{session_id}': {exc}") from exc
368
+ return result
369
+
370
+ async def save_messages(
371
+ self,
372
+ session_id: str,
373
+ messages: Sequence[Message],
374
+ ttl: int | None = None,
375
+ ) -> None:
376
+ client = await self.get_client()
377
+ key = self._msg_key(session_id)
378
+ effective_ttl = ttl if ttl is not None else self.default_ttl
379
+ payloads = [json.dumps(_serialize_message(m)) for m in messages]
380
+ try:
381
+ pipe = client.pipeline()
382
+ pipe.delete(key)
383
+ if payloads:
384
+ pipe.rpush(key, *payloads)
385
+ if effective_ttl is not None and effective_ttl > 0:
386
+ pipe.expire(key, effective_ttl)
387
+ await pipe.execute()
388
+ except RedisError as exc:
389
+ raise AIConnectionError(f"Failed to save agent memory to Redis: {exc}") from exc
390
+
391
+ async def append_message(
392
+ self,
393
+ session_id: str,
394
+ message: Message,
395
+ ttl: int | None = None,
396
+ ) -> None:
397
+ client = await self.get_client()
398
+ key = self._msg_key(session_id)
399
+ effective_ttl = ttl if ttl is not None else self.default_ttl
400
+ payload = json.dumps(_serialize_message(message))
401
+ try:
402
+ pipe = client.pipeline()
403
+ pipe.rpush(key, payload)
404
+ if effective_ttl is not None and effective_ttl > 0:
405
+ pipe.expire(key, effective_ttl)
406
+ await pipe.execute()
407
+ except RedisError as exc:
408
+ raise AIConnectionError(f"Failed to append agent message to Redis: {exc}") from exc
409
+
410
+ async def clear(self, session_id: str) -> None:
411
+ client = await self.get_client()
412
+ msg_key = self._msg_key(session_id)
413
+ state_key = self._state_key(session_id)
414
+ try:
415
+ await client.delete(msg_key, state_key)
416
+ except RedisError as exc:
417
+ raise AIConnectionError(f"Failed to clear agent memory in Redis: {exc}") from exc
418
+
419
+ async def exists(self, session_id: str) -> bool:
420
+ client = await self.get_client()
421
+ msg_key = self._msg_key(session_id)
422
+ state_key = self._state_key(session_id)
423
+ try:
424
+ return bool(await client.exists(msg_key, state_key))
425
+ except RedisError as exc:
426
+ raise AIConnectionError(f"Failed to check agent memory in Redis: {exc}") from exc
427
+
428
+ async def get_state(self, session_id: str, key: str, default: Any = None) -> Any:
429
+ client = await self.get_client()
430
+ state_key = self._state_key(session_id)
431
+ try:
432
+ raw = await client.hget(state_key, key)
433
+ except RedisError as exc:
434
+ raise AIConnectionError(f"Failed to get agent state from Redis: {exc}") from exc
435
+ if raw is None:
436
+ return default
437
+ try:
438
+ return json.loads(raw)
439
+ except (json.JSONDecodeError, TypeError, ValueError):
440
+ return raw
441
+
442
+ async def set_state(
443
+ self,
444
+ session_id: str,
445
+ key: str,
446
+ value: Any,
447
+ ttl: int | None = None,
448
+ ) -> None:
449
+ client = await self.get_client()
450
+ state_key = self._state_key(session_id)
451
+ payload = json.dumps(value)
452
+ effective_ttl = ttl if ttl is not None else self.default_ttl
453
+ try:
454
+ pipe = client.pipeline()
455
+ pipe.hset(state_key, key, payload)
456
+ if effective_ttl is not None and effective_ttl > 0:
457
+ pipe.expire(state_key, effective_ttl)
458
+ await pipe.execute()
459
+ except RedisError as exc:
460
+ raise AIConnectionError(f"Failed to set agent state in Redis: {exc}") from exc
461
+
462
+ async def get_all_state(self, session_id: str) -> dict[str, Any]:
463
+ client = await self.get_client()
464
+ state_key = self._state_key(session_id)
465
+ try:
466
+ raw_map = await client.hgetall(state_key)
467
+ except RedisError as exc:
468
+ raise AIConnectionError(f"Failed to get agent state from Redis: {exc}") from exc
469
+ if not raw_map:
470
+ return {}
471
+ decoded: dict[str, Any] = {}
472
+ for k, raw_val in raw_map.items():
473
+ try:
474
+ decoded[str(k)] = json.loads(raw_val)
475
+ except (json.JSONDecodeError, TypeError, ValueError):
476
+ decoded[str(k)] = raw_val
477
+ return decoded
478
+
479
+
480
+ class DatabaseMemoryBackend(BaseMemoryBackend):
481
+ """
482
+ Database-backed agent memory backend storing persistent conversation
483
+ and agent state in PostgreSQL using parameterized queries, active transaction
484
+ connection reuse, bounded index names, and optional TTL expiration.
485
+ """
486
+
487
+ def __init__(
488
+ self,
489
+ pool: DatabasePool | None = None,
490
+ table_name: str = "agent_memories",
491
+ default_ttl: int | None = None,
492
+ ) -> None:
493
+ if default_ttl is not None and (
494
+ isinstance(default_ttl, bool)
495
+ or not isinstance(default_ttl, (int, float))
496
+ or float(default_ttl) <= 0
497
+ ):
498
+ raise ValueError(f"default_ttl must be a positive number, got {default_ttl}")
499
+ self._pool = pool
500
+ self.default_ttl = default_ttl
501
+ self.table_name = validate_identifier(table_name)
502
+ messages_table = (
503
+ f"{table_name}_messages"
504
+ if not table_name.endswith("_messages")
505
+ else table_name
506
+ )
507
+ state_table = (
508
+ f"{table_name}_state"
509
+ if not table_name.endswith("_state")
510
+ else f"{table_name}_kv"
511
+ )
512
+ self.messages_table = validate_identifier(messages_table)
513
+ self.state_table = validate_identifier(state_table)
514
+ self._messages_index = _bounded_index_name(self.messages_table, "_session")
515
+ self._table_checked = False
516
+
517
+ def _get_pool(self) -> DatabasePool:
518
+ if self._pool is not None:
519
+ return self._pool
520
+ try:
521
+ container = get_container()
522
+ self._pool = container.resolve(DatabasePool)
523
+ return self._pool
524
+ except Exception as exc:
525
+ raise RuntimeError(f"DatabasePool could not be resolved for agent memory: {exc}") from exc
526
+
527
+ @asynccontextmanager
528
+ async def _connection(self) -> AsyncIterator[Any]:
529
+ active = get_active_connection()
530
+ if active is not None:
531
+ yield active
532
+ return
533
+
534
+ db_pool = self._get_pool()
535
+ try:
536
+ raw_pool = db_pool.get_pool()
537
+ except RuntimeError:
538
+ raw_pool = await db_pool.init()
539
+
540
+ async with raw_pool.acquire() as conn:
541
+ yield conn
542
+
543
+ async def ensure_table(self) -> None:
544
+ """Create the storage tables and TTL columns if they do not already exist."""
545
+ if self._table_checked:
546
+ return
547
+ msg_table_q = quote_identifier(self.messages_table)
548
+ state_table_q = quote_identifier(self.state_table)
549
+ idx_name_q = quote_identifier(self._messages_index)
550
+ messages_sql = f"""
551
+ CREATE TABLE IF NOT EXISTS {msg_table_q} (
552
+ id BIGSERIAL PRIMARY KEY,
553
+ session_id VARCHAR(255) NOT NULL,
554
+ role VARCHAR(50) NOT NULL,
555
+ content TEXT NOT NULL,
556
+ name VARCHAR(255),
557
+ tool_call_id VARCHAR(255),
558
+ tool_calls JSONB,
559
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
560
+ expires_at TIMESTAMPTZ
561
+ );
562
+ ALTER TABLE {msg_table_q} ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
563
+ CREATE INDEX IF NOT EXISTS {idx_name_q} ON {msg_table_q} (session_id, id);
564
+ """
565
+ state_sql = f"""
566
+ CREATE TABLE IF NOT EXISTS {state_table_q} (
567
+ session_id VARCHAR(255) NOT NULL,
568
+ key VARCHAR(255) NOT NULL,
569
+ value JSONB NOT NULL,
570
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
571
+ expires_at TIMESTAMPTZ,
572
+ PRIMARY KEY (session_id, key)
573
+ );
574
+ ALTER TABLE {state_table_q} ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
575
+ """
576
+ async with self._connection() as conn:
577
+ await conn.execute(messages_sql)
578
+ await conn.execute(state_sql)
579
+ if get_active_connection() is None:
580
+ self._table_checked = True
581
+
582
+ async def load_messages(self, session_id: str) -> list[Message]:
583
+ await self.ensure_table()
584
+ sql = f"""
585
+ SELECT role, content, name, tool_call_id, tool_calls
586
+ FROM {quote_identifier(self.messages_table)}
587
+ WHERE session_id = $1
588
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
589
+ ORDER BY id ASC;
590
+ """
591
+ async with self._connection() as conn:
592
+ rows = await conn.fetch(sql, session_id)
593
+
594
+ messages: list[Message] = []
595
+ for r in rows:
596
+ raw_tc = r["tool_calls"]
597
+ tool_calls = None
598
+ if raw_tc:
599
+ if isinstance(raw_tc, str):
600
+ try:
601
+ raw_tc = json.loads(raw_tc)
602
+ except (json.JSONDecodeError, TypeError, ValueError):
603
+ raw_tc = []
604
+ if isinstance(raw_tc, list):
605
+ tool_calls = [_deserialize_tool_call(tc) for tc in raw_tc if isinstance(tc, dict)]
606
+ messages.append(
607
+ Message(
608
+ role=r["role"],
609
+ content=r["content"],
610
+ name=r["name"],
611
+ tool_call_id=r["tool_call_id"],
612
+ tool_calls=tool_calls,
613
+ )
614
+ )
615
+ return messages
616
+
617
+ async def save_messages(
618
+ self,
619
+ session_id: str,
620
+ messages: Sequence[Message],
621
+ ttl: int | None = None,
622
+ ) -> None:
623
+ await self.ensure_table()
624
+ expires_at = _resolve_expiry(ttl, self.default_ttl)
625
+ delete_sql = f"DELETE FROM {quote_identifier(self.messages_table)} WHERE session_id = $1;"
626
+ insert_sql = f"""
627
+ INSERT INTO {quote_identifier(self.messages_table)}
628
+ (session_id, role, content, name, tool_call_id, tool_calls, expires_at)
629
+ VALUES ($1, $2, $3, $4, $5, $6, $7);
630
+ """
631
+ async with self._connection() as conn, conn.transaction():
632
+ await conn.execute(delete_sql, session_id)
633
+ for msg in messages:
634
+ tc_json = (
635
+ json.dumps([_serialize_tool_call(tc) for tc in msg.tool_calls])
636
+ if msg.tool_calls
637
+ else None
638
+ )
639
+ await conn.execute(
640
+ insert_sql,
641
+ session_id,
642
+ msg.role,
643
+ msg.content,
644
+ msg.name,
645
+ msg.tool_call_id,
646
+ tc_json,
647
+ expires_at,
648
+ )
649
+
650
+ async def append_message(
651
+ self,
652
+ session_id: str,
653
+ message: Message,
654
+ ttl: int | None = None,
655
+ ) -> None:
656
+ await self.ensure_table()
657
+ expires_at = _resolve_expiry(ttl, self.default_ttl)
658
+ insert_sql = f"""
659
+ INSERT INTO {quote_identifier(self.messages_table)}
660
+ (session_id, role, content, name, tool_call_id, tool_calls, expires_at)
661
+ VALUES ($1, $2, $3, $4, $5, $6, $7);
662
+ """
663
+ tc_json = (
664
+ json.dumps([_serialize_tool_call(tc) for tc in message.tool_calls])
665
+ if message.tool_calls
666
+ else None
667
+ )
668
+ async with self._connection() as conn:
669
+ await conn.execute(
670
+ insert_sql,
671
+ session_id,
672
+ message.role,
673
+ message.content,
674
+ message.name,
675
+ message.tool_call_id,
676
+ tc_json,
677
+ expires_at,
678
+ )
679
+
680
+ async def clear(self, session_id: str) -> None:
681
+ await self.ensure_table()
682
+ async with self._connection() as conn, conn.transaction():
683
+ await conn.execute(
684
+ f"DELETE FROM {quote_identifier(self.messages_table)} WHERE session_id = $1;",
685
+ session_id,
686
+ )
687
+ await conn.execute(
688
+ f"DELETE FROM {quote_identifier(self.state_table)} WHERE session_id = $1;",
689
+ session_id,
690
+ )
691
+
692
+ async def exists(self, session_id: str) -> bool:
693
+ await self.ensure_table()
694
+ sql = f"""
695
+ SELECT (
696
+ EXISTS(
697
+ SELECT 1 FROM {quote_identifier(self.messages_table)}
698
+ WHERE session_id = $1
699
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
700
+ )
701
+ OR EXISTS(
702
+ SELECT 1 FROM {quote_identifier(self.state_table)}
703
+ WHERE session_id = $1
704
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
705
+ )
706
+ ) AS has_memory;
707
+ """
708
+ async with self._connection() as conn:
709
+ val = await conn.fetchval(sql, session_id)
710
+ return bool(val)
711
+
712
+ async def get_state(self, session_id: str, key: str, default: Any = None) -> Any:
713
+ await self.ensure_table()
714
+ sql = f"""
715
+ SELECT value FROM {quote_identifier(self.state_table)}
716
+ WHERE session_id = $1
717
+ AND key = $2
718
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP);
719
+ """
720
+ async with self._connection() as conn:
721
+ row = await conn.fetchrow(sql, session_id, key)
722
+ if not row or row["value"] is None:
723
+ return default
724
+ raw = row["value"]
725
+ if isinstance(raw, str):
726
+ try:
727
+ return json.loads(raw)
728
+ except (json.JSONDecodeError, TypeError, ValueError):
729
+ return raw
730
+ return raw
731
+
732
+ async def set_state(
733
+ self,
734
+ session_id: str,
735
+ key: str,
736
+ value: Any,
737
+ ttl: int | None = None,
738
+ ) -> None:
739
+ await self.ensure_table()
740
+ expires_at = _resolve_expiry(ttl, self.default_ttl)
741
+ payload = json.dumps(value)
742
+ sql = f"""
743
+ INSERT INTO {quote_identifier(self.state_table)}
744
+ (session_id, key, value, updated_at, expires_at)
745
+ VALUES ($1, $2, $3::jsonb, CURRENT_TIMESTAMP, $4)
746
+ ON CONFLICT (session_id, key)
747
+ DO UPDATE SET
748
+ value = EXCLUDED.value,
749
+ updated_at = CURRENT_TIMESTAMP,
750
+ expires_at = EXCLUDED.expires_at;
751
+ """
752
+ async with self._connection() as conn:
753
+ await conn.execute(sql, session_id, key, payload, expires_at)
754
+
755
+ async def get_all_state(self, session_id: str) -> dict[str, Any]:
756
+ await self.ensure_table()
757
+ sql = f"""
758
+ SELECT key, value FROM {quote_identifier(self.state_table)}
759
+ WHERE session_id = $1
760
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP);
761
+ """
762
+ async with self._connection() as conn:
763
+ rows = await conn.fetch(sql, session_id)
764
+ result: dict[str, Any] = {}
765
+ for r in rows:
766
+ raw = r["value"]
767
+ if isinstance(raw, str):
768
+ try:
769
+ result[r["key"]] = json.loads(raw)
770
+ except (json.JSONDecodeError, TypeError, ValueError):
771
+ result[r["key"]] = raw
772
+ else:
773
+ result[r["key"]] = raw
774
+ return result
775
+
776
+
777
+ class AgentMemory:
778
+ """
779
+ Session-scoped memory manager providing an ergonomic API for agents.
780
+ Wraps an interchangeable backend (InMemory, Redis, Database).
781
+ """
782
+
783
+ def __init__(
784
+ self,
785
+ backend: BaseMemoryBackend | None = None,
786
+ session_id: str = "default",
787
+ ) -> None:
788
+ self._backend: BaseMemoryBackend = backend if backend is not None else InMemoryMemoryBackend()
789
+ self.session_id: str = session_id
790
+
791
+ @property
792
+ def backend(self) -> BaseMemoryBackend:
793
+ """Return the underlying storage backend."""
794
+ return self._backend
795
+
796
+ async def get_messages(self) -> list[Message]:
797
+ """Retrieve all ordered messages for this agent session."""
798
+ return await self._backend.load_messages(self.session_id)
799
+
800
+ async def save_messages(self, messages: Sequence[Message], ttl: int | None = None) -> None:
801
+ """Persist ordered messages for this agent session."""
802
+ await self._backend.save_messages(self.session_id, messages, ttl=ttl)
803
+
804
+ async def add_message(self, message: Message, ttl: int | None = None) -> None:
805
+ """Append a message to this agent session."""
806
+ await self._backend.append_message(self.session_id, message, ttl=ttl)
807
+
808
+ async def add_user(self, content: str) -> None:
809
+ """Append a user message."""
810
+ await self.add_message(Message(role="user", content=content))
811
+
812
+ async def add_assistant(
813
+ self,
814
+ content: str,
815
+ tool_calls: list[ToolCall] | None = None,
816
+ ) -> None:
817
+ """Append an assistant response message."""
818
+ await self.add_message(Message(role="assistant", content=content, tool_calls=tool_calls))
819
+
820
+ async def add_system(self, content: str) -> None:
821
+ """Append a system message."""
822
+ await self.add_message(Message(role="system", content=content))
823
+
824
+ async def add_tool_result(self, content: str, name: str, tool_call_id: str) -> None:
825
+ """Append a tool execution result message."""
826
+ await self.add_message(Message(role="tool", content=content, name=name, tool_call_id=tool_call_id))
827
+
828
+ async def clear(self) -> None:
829
+ """Clear all messages and state for this agent session."""
830
+ await self._backend.clear(self.session_id)
831
+
832
+ async def exists(self) -> bool:
833
+ """Check if memory exists for this agent session."""
834
+ return await self._backend.exists(self.session_id)
835
+
836
+ async def set_state(self, key: str, value: Any) -> None:
837
+ """Set a state variable for this agent session."""
838
+ await self._backend.set_state(self.session_id, key, value)
839
+
840
+ async def get_state(self, key: str, default: Any = None) -> Any:
841
+ """Get a state variable for this agent session."""
842
+ return await self._backend.get_state(self.session_id, key, default)
843
+
844
+ async def get_all_state(self) -> dict[str, Any]:
845
+ """Get the full state dictionary for this agent session."""
846
+ return await self._backend.get_all_state(self.session_id)
847
+
848
+ async def to_conversation(self) -> Conversation:
849
+ """Export session memory into a Conversation container."""
850
+ messages = await self.get_messages()
851
+ return Conversation(messages=messages)
852
+
853
+ def fork(self, new_session_id: str) -> AgentMemory:
854
+ """Return a new AgentMemory instance sharing the same backend under a different session ID."""
855
+ return AgentMemory(backend=self._backend, session_id=new_session_id)
856
+
857
+
858
+ __all__ = [
859
+ "AgentMemory",
860
+ "BaseMemoryBackend",
861
+ "DatabaseMemoryBackend",
862
+ "InMemoryMemoryBackend",
863
+ "RedisMemoryBackend",
864
+ ]