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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|