agentscope-runtime 1.0.1__py3-none-any.whl → 1.0.3__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.
- agentscope_runtime/adapters/agentscope/message.py +32 -7
- agentscope_runtime/adapters/agentscope/stream.py +121 -91
- agentscope_runtime/adapters/agno/__init__.py +0 -0
- agentscope_runtime/adapters/agno/message.py +30 -0
- agentscope_runtime/adapters/agno/stream.py +122 -0
- agentscope_runtime/adapters/langgraph/__init__.py +12 -0
- agentscope_runtime/adapters/langgraph/message.py +257 -0
- agentscope_runtime/adapters/langgraph/stream.py +205 -0
- agentscope_runtime/cli/__init__.py +7 -0
- agentscope_runtime/cli/cli.py +63 -0
- agentscope_runtime/cli/commands/__init__.py +2 -0
- agentscope_runtime/cli/commands/chat.py +815 -0
- agentscope_runtime/cli/commands/deploy.py +1074 -0
- agentscope_runtime/cli/commands/invoke.py +58 -0
- agentscope_runtime/cli/commands/list_cmd.py +103 -0
- agentscope_runtime/cli/commands/run.py +176 -0
- agentscope_runtime/cli/commands/sandbox.py +128 -0
- agentscope_runtime/cli/commands/status.py +60 -0
- agentscope_runtime/cli/commands/stop.py +185 -0
- agentscope_runtime/cli/commands/web.py +166 -0
- agentscope_runtime/cli/loaders/__init__.py +6 -0
- agentscope_runtime/cli/loaders/agent_loader.py +295 -0
- agentscope_runtime/cli/state/__init__.py +10 -0
- agentscope_runtime/cli/utils/__init__.py +18 -0
- agentscope_runtime/cli/utils/console.py +378 -0
- agentscope_runtime/cli/utils/validators.py +118 -0
- agentscope_runtime/common/collections/redis_mapping.py +4 -1
- agentscope_runtime/engine/app/agent_app.py +55 -9
- agentscope_runtime/engine/deployers/__init__.py +1 -0
- agentscope_runtime/engine/deployers/adapter/a2a/__init__.py +56 -1
- agentscope_runtime/engine/deployers/adapter/a2a/a2a_protocol_adapter.py +449 -41
- agentscope_runtime/engine/deployers/adapter/a2a/a2a_registry.py +273 -0
- agentscope_runtime/engine/deployers/adapter/a2a/nacos_a2a_registry.py +640 -0
- agentscope_runtime/engine/deployers/agentrun_deployer.py +152 -22
- agentscope_runtime/engine/deployers/base.py +27 -2
- agentscope_runtime/engine/deployers/kubernetes_deployer.py +161 -31
- agentscope_runtime/engine/deployers/local_deployer.py +188 -25
- agentscope_runtime/engine/deployers/modelstudio_deployer.py +109 -18
- agentscope_runtime/engine/deployers/state/__init__.py +9 -0
- agentscope_runtime/engine/deployers/state/manager.py +388 -0
- agentscope_runtime/engine/deployers/state/schema.py +96 -0
- agentscope_runtime/engine/deployers/utils/build_cache.py +736 -0
- agentscope_runtime/engine/deployers/utils/detached_app.py +105 -30
- agentscope_runtime/engine/deployers/utils/docker_image_utils/docker_image_builder.py +31 -10
- agentscope_runtime/engine/deployers/utils/docker_image_utils/dockerfile_generator.py +23 -10
- agentscope_runtime/engine/deployers/utils/docker_image_utils/image_factory.py +35 -2
- agentscope_runtime/engine/deployers/utils/k8s_utils.py +241 -0
- agentscope_runtime/engine/deployers/utils/net_utils.py +65 -0
- agentscope_runtime/engine/deployers/utils/package.py +56 -6
- agentscope_runtime/engine/deployers/utils/service_utils/fastapi_factory.py +16 -2
- agentscope_runtime/engine/deployers/utils/service_utils/process_manager.py +155 -5
- agentscope_runtime/engine/deployers/utils/wheel_packager.py +107 -123
- agentscope_runtime/engine/runner.py +30 -9
- agentscope_runtime/engine/schemas/exception.py +604 -0
- agentscope_runtime/engine/services/agent_state/redis_state_service.py +61 -8
- agentscope_runtime/engine/services/agent_state/state_service_factory.py +2 -5
- agentscope_runtime/engine/services/memory/redis_memory_service.py +129 -25
- agentscope_runtime/engine/services/session_history/redis_session_history_service.py +160 -34
- agentscope_runtime/sandbox/box/mobile/mobile_sandbox.py +113 -39
- agentscope_runtime/sandbox/box/shared/routers/mcp_utils.py +20 -4
- agentscope_runtime/sandbox/build.py +50 -57
- agentscope_runtime/sandbox/utils.py +2 -0
- agentscope_runtime/version.py +1 -1
- {agentscope_runtime-1.0.1.dist-info → agentscope_runtime-1.0.3.dist-info}/METADATA +31 -8
- {agentscope_runtime-1.0.1.dist-info → agentscope_runtime-1.0.3.dist-info}/RECORD +69 -36
- {agentscope_runtime-1.0.1.dist-info → agentscope_runtime-1.0.3.dist-info}/entry_points.txt +1 -0
- {agentscope_runtime-1.0.1.dist-info → agentscope_runtime-1.0.3.dist-info}/WHEEL +0 -0
- {agentscope_runtime-1.0.1.dist-info → agentscope_runtime-1.0.3.dist-info}/licenses/LICENSE +0 -0
- {agentscope_runtime-1.0.1.dist-info → agentscope_runtime-1.0.3.dist-info}/top_level.txt +0 -0
|
@@ -43,13 +43,10 @@ class StateServiceFactory(ServiceFactory[StateService]):
|
|
|
43
43
|
|
|
44
44
|
StateServiceFactory.register_backend(
|
|
45
45
|
"in_memory",
|
|
46
|
-
|
|
46
|
+
InMemoryStateService,
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
StateServiceFactory.register_backend(
|
|
50
50
|
"redis",
|
|
51
|
-
|
|
52
|
-
redis_url=kwargs.get("redis_url", "redis://localhost:6379/0"),
|
|
53
|
-
redis_client=kwargs.get("redis_client"),
|
|
54
|
-
),
|
|
51
|
+
RedisStateService,
|
|
55
52
|
)
|
|
@@ -17,23 +17,70 @@ class RedisMemoryService(MemoryService):
|
|
|
17
17
|
self,
|
|
18
18
|
redis_url: str = "redis://localhost:6379/0",
|
|
19
19
|
redis_client: Optional[aioredis.Redis] = None,
|
|
20
|
+
socket_timeout: Optional[float] = 5.0,
|
|
21
|
+
socket_connect_timeout: Optional[float] = 5.0,
|
|
22
|
+
max_connections: Optional[int] = 50,
|
|
23
|
+
retry_on_timeout: bool = True,
|
|
24
|
+
ttl_seconds: Optional[int] = 3600, # 1 hour in seconds
|
|
25
|
+
max_messages_per_session: Optional[int] = None,
|
|
26
|
+
health_check_interval: Optional[float] = 30.0,
|
|
27
|
+
socket_keepalive: bool = True,
|
|
20
28
|
):
|
|
29
|
+
"""
|
|
30
|
+
Initialize RedisMemoryService.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
redis_url: Redis connection URL
|
|
34
|
+
redis_client: Optional pre-configured Redis client
|
|
35
|
+
socket_timeout: Socket timeout in seconds (default: 5.0)
|
|
36
|
+
socket_connect_timeout: Socket connect timeout in seconds
|
|
37
|
+
(default: 5.0)
|
|
38
|
+
max_connections: Maximum number of connections in the pool
|
|
39
|
+
(default: 50)
|
|
40
|
+
retry_on_timeout: Whether to retry on timeout (default: True)
|
|
41
|
+
ttl_seconds: Time-to-live in seconds for memory data.
|
|
42
|
+
If None, data never expires (default: 3600, i.e., 1 hour)
|
|
43
|
+
max_messages_per_session: Maximum number of messages stored per
|
|
44
|
+
session_id field within a user's Redis memory hash.
|
|
45
|
+
If None, no limit (default: None)
|
|
46
|
+
health_check_interval: Interval in seconds for health checks
|
|
47
|
+
on idle connections (default: 30.0).
|
|
48
|
+
Connections idle longer than this will be checked before reuse.
|
|
49
|
+
Set to 0 to disable.
|
|
50
|
+
socket_keepalive: Enable TCP keepalive to prevent
|
|
51
|
+
silent disconnections (default: True)
|
|
52
|
+
"""
|
|
21
53
|
self._redis_url = redis_url
|
|
22
54
|
self._redis = redis_client
|
|
23
55
|
self._DEFAULT_SESSION_ID = "default"
|
|
56
|
+
self._socket_timeout = socket_timeout
|
|
57
|
+
self._socket_connect_timeout = socket_connect_timeout
|
|
58
|
+
self._max_connections = max_connections
|
|
59
|
+
self._retry_on_timeout = retry_on_timeout
|
|
60
|
+
self._ttl_seconds = ttl_seconds
|
|
61
|
+
self._max_messages_per_session = max_messages_per_session
|
|
62
|
+
self._health_check_interval = health_check_interval
|
|
63
|
+
self._socket_keepalive = socket_keepalive
|
|
24
64
|
|
|
25
65
|
async def start(self) -> None:
|
|
26
|
-
"""Starts the Redis connection
|
|
66
|
+
"""Starts the Redis connection with proper timeout
|
|
67
|
+
and connection pool settings."""
|
|
27
68
|
if self._redis is None:
|
|
28
69
|
self._redis = aioredis.from_url(
|
|
29
70
|
self._redis_url,
|
|
30
71
|
decode_responses=True,
|
|
72
|
+
socket_timeout=self._socket_timeout,
|
|
73
|
+
socket_connect_timeout=self._socket_connect_timeout,
|
|
74
|
+
max_connections=self._max_connections,
|
|
75
|
+
retry_on_timeout=self._retry_on_timeout,
|
|
76
|
+
health_check_interval=self._health_check_interval,
|
|
77
|
+
socket_keepalive=self._socket_keepalive,
|
|
31
78
|
)
|
|
32
79
|
|
|
33
80
|
async def stop(self) -> None:
|
|
34
81
|
"""Closes the Redis connection."""
|
|
35
82
|
if self._redis:
|
|
36
|
-
await self._redis.
|
|
83
|
+
await self._redis.aclose()
|
|
37
84
|
self._redis = None
|
|
38
85
|
|
|
39
86
|
async def health(self) -> bool:
|
|
@@ -73,14 +120,27 @@ class RedisMemoryService(MemoryService):
|
|
|
73
120
|
existing_json = await self._redis.hget(key, field)
|
|
74
121
|
existing_msgs = self._deserialize(existing_json)
|
|
75
122
|
all_msgs = existing_msgs + messages
|
|
123
|
+
|
|
124
|
+
# Limit the number of messages per session to prevent memory issues
|
|
125
|
+
if self._max_messages_per_session is not None:
|
|
126
|
+
if len(all_msgs) > self._max_messages_per_session:
|
|
127
|
+
# Keep only the most recent messages
|
|
128
|
+
all_msgs = all_msgs[-self._max_messages_per_session :]
|
|
129
|
+
|
|
76
130
|
await self._redis.hset(key, field, self._serialize(all_msgs))
|
|
77
131
|
|
|
78
|
-
|
|
132
|
+
# Set TTL for the key if configured
|
|
133
|
+
if self._ttl_seconds is not None:
|
|
134
|
+
await self._redis.expire(key, self._ttl_seconds)
|
|
135
|
+
|
|
136
|
+
async def search_memory( # pylint: disable=too-many-branches
|
|
79
137
|
self,
|
|
80
138
|
user_id: str,
|
|
81
139
|
messages: list,
|
|
82
140
|
filters: Optional[Dict[str, Any]] = None,
|
|
83
141
|
) -> list:
|
|
142
|
+
if not self._redis:
|
|
143
|
+
raise RuntimeError("Redis connection is not available")
|
|
84
144
|
key = self._user_key(user_id)
|
|
85
145
|
if (
|
|
86
146
|
not messages
|
|
@@ -96,29 +156,52 @@ class RedisMemoryService(MemoryService):
|
|
|
96
156
|
|
|
97
157
|
keywords = set(query.lower().split())
|
|
98
158
|
|
|
99
|
-
|
|
100
|
-
hash_keys = await self._redis.hkeys(key)
|
|
101
|
-
for session_id in hash_keys:
|
|
102
|
-
msgs_json = await self._redis.hget(key, session_id)
|
|
103
|
-
msgs = self._deserialize(msgs_json)
|
|
104
|
-
all_msgs.extend(msgs)
|
|
105
|
-
|
|
159
|
+
# Process messages in batches to avoid loading all into memory at once
|
|
106
160
|
matched_messages = []
|
|
107
|
-
|
|
108
|
-
candidate_content = await self.get_query_text(msg)
|
|
109
|
-
if candidate_content:
|
|
110
|
-
msg_content_lower = candidate_content.lower()
|
|
111
|
-
if any(keyword in msg_content_lower for keyword in keywords):
|
|
112
|
-
matched_messages.append(msg)
|
|
161
|
+
hash_keys = await self._redis.hkeys(key)
|
|
113
162
|
|
|
163
|
+
# Get top_k limit early to optimize memory usage
|
|
164
|
+
top_k = None
|
|
114
165
|
if (
|
|
115
166
|
filters
|
|
116
167
|
and "top_k" in filters
|
|
117
168
|
and isinstance(filters["top_k"], int)
|
|
118
169
|
):
|
|
119
|
-
|
|
170
|
+
top_k = filters["top_k"]
|
|
120
171
|
|
|
121
|
-
|
|
172
|
+
# Process each session separately to reduce memory footprint
|
|
173
|
+
for session_id in hash_keys:
|
|
174
|
+
msgs_json = await self._redis.hget(key, session_id)
|
|
175
|
+
if not msgs_json:
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
msgs = self._deserialize(msgs_json)
|
|
179
|
+
except Exception:
|
|
180
|
+
# Skip corrupted message data
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
# Match messages in this session
|
|
184
|
+
for msg in msgs:
|
|
185
|
+
candidate_content = await self.get_query_text(msg)
|
|
186
|
+
if candidate_content:
|
|
187
|
+
msg_content_lower = candidate_content.lower()
|
|
188
|
+
if any(
|
|
189
|
+
keyword in msg_content_lower for keyword in keywords
|
|
190
|
+
):
|
|
191
|
+
matched_messages.append(msg)
|
|
192
|
+
|
|
193
|
+
# Apply top_k filter if specified
|
|
194
|
+
if top_k is not None:
|
|
195
|
+
result = matched_messages[-top_k:]
|
|
196
|
+
else:
|
|
197
|
+
result = matched_messages
|
|
198
|
+
|
|
199
|
+
# Refresh TTL on read to extend lifetime of actively used data,
|
|
200
|
+
# if a TTL is configured and there is existing data for this key.
|
|
201
|
+
if self._ttl_seconds is not None and hash_keys:
|
|
202
|
+
await self._redis.expire(key, self._ttl_seconds)
|
|
203
|
+
|
|
204
|
+
return result
|
|
122
205
|
|
|
123
206
|
async def get_query_text(self, message: Message) -> str:
|
|
124
207
|
if message:
|
|
@@ -133,20 +216,39 @@ class RedisMemoryService(MemoryService):
|
|
|
133
216
|
user_id: str,
|
|
134
217
|
filters: Optional[Dict[str, Any]] = None,
|
|
135
218
|
) -> list:
|
|
219
|
+
if not self._redis:
|
|
220
|
+
raise RuntimeError("Redis connection is not available")
|
|
136
221
|
key = self._user_key(user_id)
|
|
137
|
-
all_msgs = []
|
|
138
|
-
hash_keys = await self._redis.hkeys(key)
|
|
139
|
-
for session_id in sorted(hash_keys):
|
|
140
|
-
msgs_json = await self._redis.hget(key, session_id)
|
|
141
|
-
msgs = self._deserialize(msgs_json)
|
|
142
|
-
all_msgs.extend(msgs)
|
|
143
|
-
|
|
144
222
|
page_num = filters.get("page_num", 1) if filters else 1
|
|
145
223
|
page_size = filters.get("page_size", 10) if filters else 10
|
|
146
224
|
|
|
147
225
|
start_index = (page_num - 1) * page_size
|
|
148
226
|
end_index = start_index + page_size
|
|
149
227
|
|
|
228
|
+
# Optimize: Calculate which sessions we need to load
|
|
229
|
+
# For simplicity, we still load all but could be optimized further
|
|
230
|
+
# to only load sessions that contain the requested page range
|
|
231
|
+
all_msgs = []
|
|
232
|
+
hash_keys = await self._redis.hkeys(key)
|
|
233
|
+
for session_id in sorted(hash_keys):
|
|
234
|
+
msgs_json = await self._redis.hget(key, session_id)
|
|
235
|
+
if msgs_json:
|
|
236
|
+
try:
|
|
237
|
+
msgs = self._deserialize(msgs_json)
|
|
238
|
+
all_msgs.extend(msgs)
|
|
239
|
+
except json.JSONDecodeError:
|
|
240
|
+
# Skip corrupted message data
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
# Early exit optimization: if we've loaded enough messages
|
|
244
|
+
# to cover the requested page, we can stop (but this assumes
|
|
245
|
+
# we need all previous messages for proper ordering)
|
|
246
|
+
# For now, we keep loading all for correctness
|
|
247
|
+
|
|
248
|
+
# Refresh TTL on active use to keep memory alive,
|
|
249
|
+
# mirroring get_session behavior
|
|
250
|
+
if self._ttl_seconds is not None and hash_keys:
|
|
251
|
+
await self._redis.expire(key, self._ttl_seconds)
|
|
150
252
|
return all_msgs[start_index:end_index]
|
|
151
253
|
|
|
152
254
|
async def delete_memory(
|
|
@@ -154,6 +256,8 @@ class RedisMemoryService(MemoryService):
|
|
|
154
256
|
user_id: str,
|
|
155
257
|
session_id: Optional[str] = None,
|
|
156
258
|
) -> None:
|
|
259
|
+
if not self._redis:
|
|
260
|
+
raise RuntimeError("Redis connection is not available")
|
|
157
261
|
key = self._user_key(user_id)
|
|
158
262
|
if session_id:
|
|
159
263
|
await self._redis.hdel(key, session_id)
|
|
@@ -15,23 +15,73 @@ class RedisSessionHistoryService(SessionHistoryService):
|
|
|
15
15
|
self,
|
|
16
16
|
redis_url: str = "redis://localhost:6379/0",
|
|
17
17
|
redis_client: Optional[aioredis.Redis] = None,
|
|
18
|
+
socket_timeout: Optional[float] = 5.0,
|
|
19
|
+
socket_connect_timeout: Optional[float] = 5.0,
|
|
20
|
+
max_connections: Optional[int] = 50,
|
|
21
|
+
retry_on_timeout: bool = True,
|
|
22
|
+
ttl_seconds: Optional[int] = 3600, # 1 hour in seconds
|
|
23
|
+
max_messages_per_session: Optional[int] = None,
|
|
24
|
+
health_check_interval: Optional[float] = 30.0,
|
|
25
|
+
socket_keepalive: bool = True,
|
|
18
26
|
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize RedisSessionHistoryService.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
redis_url: Redis connection URL
|
|
32
|
+
redis_client: Optional pre-configured Redis client
|
|
33
|
+
socket_timeout: Socket timeout in seconds (default: 5.0)
|
|
34
|
+
socket_connect_timeout: Socket connect timeout in seconds
|
|
35
|
+
(default: 5.0)
|
|
36
|
+
max_connections: Maximum number of connections in the pool
|
|
37
|
+
(default: 50)
|
|
38
|
+
retry_on_timeout: Whether to retry on timeout (default: True)
|
|
39
|
+
ttl_seconds: Time-to-live in seconds for session data.
|
|
40
|
+
If None, data never expires (default: 3600, i.e., 1 hour)
|
|
41
|
+
max_messages_per_session: Maximum number of messages per session.
|
|
42
|
+
If None, no limit (default: None)
|
|
43
|
+
health_check_interval: Interval in seconds for health checks on
|
|
44
|
+
idle connections (default: 30.0).
|
|
45
|
+
Connections idle longer than this will be checked before reuse.
|
|
46
|
+
Set to 0 to disable.
|
|
47
|
+
socket_keepalive: Enable TCP keepalive to prevent
|
|
48
|
+
silent disconnections (default: True)
|
|
49
|
+
"""
|
|
19
50
|
self._redis_url = redis_url
|
|
20
51
|
self._redis = redis_client
|
|
52
|
+
self._socket_timeout = socket_timeout
|
|
53
|
+
self._socket_connect_timeout = socket_connect_timeout
|
|
54
|
+
self._max_connections = max_connections
|
|
55
|
+
self._retry_on_timeout = retry_on_timeout
|
|
56
|
+
self._ttl_seconds = ttl_seconds
|
|
57
|
+
self._max_messages_per_session = max_messages_per_session
|
|
58
|
+
self._health_check_interval = health_check_interval
|
|
59
|
+
self._socket_keepalive = socket_keepalive
|
|
21
60
|
|
|
22
61
|
async def start(self):
|
|
62
|
+
"""Starts the Redis connection with proper timeout and connection
|
|
63
|
+
pool settings."""
|
|
23
64
|
if self._redis is None:
|
|
24
65
|
self._redis = aioredis.from_url(
|
|
25
66
|
self._redis_url,
|
|
26
67
|
decode_responses=True,
|
|
68
|
+
socket_timeout=self._socket_timeout,
|
|
69
|
+
socket_connect_timeout=self._socket_connect_timeout,
|
|
70
|
+
max_connections=self._max_connections,
|
|
71
|
+
retry_on_timeout=self._retry_on_timeout,
|
|
72
|
+
health_check_interval=self._health_check_interval,
|
|
73
|
+
socket_keepalive=self._socket_keepalive,
|
|
27
74
|
)
|
|
28
75
|
|
|
29
76
|
async def stop(self):
|
|
30
77
|
if self._redis:
|
|
31
|
-
await self._redis.
|
|
78
|
+
await self._redis.aclose()
|
|
32
79
|
self._redis = None
|
|
33
80
|
|
|
34
81
|
async def health(self) -> bool:
|
|
82
|
+
"""Checks the health of the service."""
|
|
83
|
+
if not self._redis:
|
|
84
|
+
return False
|
|
35
85
|
try:
|
|
36
86
|
pong = await self._redis.ping()
|
|
37
87
|
return pong is True or pong == "PONG"
|
|
@@ -41,8 +91,9 @@ class RedisSessionHistoryService(SessionHistoryService):
|
|
|
41
91
|
def _session_key(self, user_id: str, session_id: str):
|
|
42
92
|
return f"session:{user_id}:{session_id}"
|
|
43
93
|
|
|
44
|
-
def
|
|
45
|
-
|
|
94
|
+
def _session_pattern(self, user_id: str):
|
|
95
|
+
"""Generate the pattern for scanning session keys for a user."""
|
|
96
|
+
return f"session:{user_id}:*"
|
|
46
97
|
|
|
47
98
|
def _session_to_json(self, session: Session) -> str:
|
|
48
99
|
return session.model_dump_json()
|
|
@@ -55,6 +106,8 @@ class RedisSessionHistoryService(SessionHistoryService):
|
|
|
55
106
|
user_id: str,
|
|
56
107
|
session_id: Optional[str] = None,
|
|
57
108
|
) -> Session:
|
|
109
|
+
if not self._redis:
|
|
110
|
+
raise RuntimeError("Redis connection is not available")
|
|
58
111
|
if session_id and session_id.strip():
|
|
59
112
|
sid = session_id.strip()
|
|
60
113
|
else:
|
|
@@ -64,7 +117,11 @@ class RedisSessionHistoryService(SessionHistoryService):
|
|
|
64
117
|
key = self._session_key(user_id, sid)
|
|
65
118
|
|
|
66
119
|
await self._redis.set(key, self._session_to_json(session))
|
|
67
|
-
|
|
120
|
+
|
|
121
|
+
# Set TTL for the session key if configured
|
|
122
|
+
if self._ttl_seconds is not None:
|
|
123
|
+
await self._redis.expire(key, self._ttl_seconds)
|
|
124
|
+
|
|
68
125
|
return session
|
|
69
126
|
|
|
70
127
|
async def get_session(
|
|
@@ -72,31 +129,63 @@ class RedisSessionHistoryService(SessionHistoryService):
|
|
|
72
129
|
user_id: str,
|
|
73
130
|
session_id: str,
|
|
74
131
|
) -> Optional[Session]:
|
|
132
|
+
if not self._redis:
|
|
133
|
+
raise RuntimeError("Redis connection is not available")
|
|
75
134
|
key = self._session_key(user_id, session_id)
|
|
76
135
|
session_json = await self._redis.get(key)
|
|
77
136
|
if session_json is None:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
session = self._session_from_json(session_json)
|
|
141
|
+
except Exception:
|
|
142
|
+
# Return None for corrupted session data
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
# Refresh TTL when accessing the session
|
|
146
|
+
if self._ttl_seconds is not None:
|
|
147
|
+
await self._redis.expire(key, self._ttl_seconds)
|
|
148
|
+
|
|
149
|
+
return session
|
|
83
150
|
|
|
84
151
|
async def delete_session(self, user_id: str, session_id: str):
|
|
152
|
+
if not self._redis:
|
|
153
|
+
raise RuntimeError("Redis connection is not available")
|
|
85
154
|
key = self._session_key(user_id, session_id)
|
|
86
155
|
await self._redis.delete(key)
|
|
87
|
-
await self._redis.srem(self._index_key(user_id), session_id)
|
|
88
156
|
|
|
89
157
|
async def list_sessions(self, user_id: str) -> list[Session]:
|
|
90
|
-
|
|
91
|
-
|
|
158
|
+
"""List all sessions for a user by scanning session keys.
|
|
159
|
+
|
|
160
|
+
Uses SCAN to find all session:{user_id}:* keys. Expired sessions
|
|
161
|
+
naturally disappear as their keys expire, avoiding stale entries.
|
|
162
|
+
"""
|
|
163
|
+
if not self._redis:
|
|
164
|
+
raise RuntimeError("Redis connection is not available")
|
|
165
|
+
pattern = self._session_pattern(user_id)
|
|
92
166
|
sessions = []
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
167
|
+
cursor = 0
|
|
168
|
+
|
|
169
|
+
while True:
|
|
170
|
+
cursor, keys = await self._redis.scan(
|
|
171
|
+
cursor,
|
|
172
|
+
match=pattern,
|
|
173
|
+
count=100,
|
|
174
|
+
)
|
|
175
|
+
for key in keys:
|
|
176
|
+
session_json = await self._redis.get(key)
|
|
177
|
+
if session_json:
|
|
178
|
+
try:
|
|
179
|
+
session = self._session_from_json(session_json)
|
|
180
|
+
session.messages = []
|
|
181
|
+
sessions.append(session)
|
|
182
|
+
except Exception:
|
|
183
|
+
# Skip corrupted session data
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if cursor == 0:
|
|
187
|
+
break
|
|
188
|
+
|
|
100
189
|
return sessions
|
|
101
190
|
|
|
102
191
|
async def append_message(
|
|
@@ -109,6 +198,8 @@ class RedisSessionHistoryService(SessionHistoryService):
|
|
|
109
198
|
List[Dict[str, Any]],
|
|
110
199
|
],
|
|
111
200
|
):
|
|
201
|
+
if not self._redis:
|
|
202
|
+
raise RuntimeError("Redis connection is not available")
|
|
112
203
|
if not isinstance(message, list):
|
|
113
204
|
message = [message]
|
|
114
205
|
norm_message = []
|
|
@@ -125,21 +216,50 @@ class RedisSessionHistoryService(SessionHistoryService):
|
|
|
125
216
|
key = self._session_key(user_id, session_id)
|
|
126
217
|
|
|
127
218
|
session_json = await self._redis.get(key)
|
|
128
|
-
if session_json:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
f"Warning: Session {session.id} not found in storage for "
|
|
136
|
-
f"append_message.",
|
|
219
|
+
if session_json is None:
|
|
220
|
+
# Session expired or not found, treat as a new session
|
|
221
|
+
# Create a new session with the current messages
|
|
222
|
+
stored_session = Session(
|
|
223
|
+
id=session_id,
|
|
224
|
+
user_id=user_id,
|
|
225
|
+
messages=norm_message.copy(),
|
|
137
226
|
)
|
|
227
|
+
else:
|
|
228
|
+
try:
|
|
229
|
+
stored_session = self._session_from_json(session_json)
|
|
230
|
+
stored_session.messages.extend(norm_message)
|
|
231
|
+
except Exception:
|
|
232
|
+
# Session data corrupted, treat as a new session
|
|
233
|
+
stored_session = Session(
|
|
234
|
+
id=session_id,
|
|
235
|
+
user_id=user_id,
|
|
236
|
+
messages=norm_message.copy(),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Limit the number of messages per session to prevent memory issues
|
|
240
|
+
if self._max_messages_per_session is not None:
|
|
241
|
+
if len(stored_session.messages) > self._max_messages_per_session:
|
|
242
|
+
# Keep only the most recent messages
|
|
243
|
+
stored_session.messages = stored_session.messages[
|
|
244
|
+
-self._max_messages_per_session :
|
|
245
|
+
]
|
|
246
|
+
# Keep the in-memory session in sync with the stored session
|
|
247
|
+
session.messages = session.messages[
|
|
248
|
+
-self._max_messages_per_session :
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
await self._redis.set(key, self._session_to_json(stored_session))
|
|
252
|
+
|
|
253
|
+
# Set TTL for the session key if configured
|
|
254
|
+
if self._ttl_seconds is not None:
|
|
255
|
+
await self._redis.expire(key, self._ttl_seconds)
|
|
138
256
|
|
|
139
257
|
async def delete_user_sessions(self, user_id: str) -> None:
|
|
140
258
|
"""
|
|
141
259
|
Deletes all session history data for a specific user.
|
|
142
260
|
|
|
261
|
+
Uses SCAN to find all session keys for the user and deletes them.
|
|
262
|
+
|
|
143
263
|
Args:
|
|
144
264
|
user_id (str): The ID of the user whose session history data should
|
|
145
265
|
be deleted
|
|
@@ -147,11 +267,17 @@ class RedisSessionHistoryService(SessionHistoryService):
|
|
|
147
267
|
if not self._redis:
|
|
148
268
|
raise RuntimeError("Redis connection is not available")
|
|
149
269
|
|
|
150
|
-
|
|
151
|
-
|
|
270
|
+
pattern = self._session_pattern(user_id)
|
|
271
|
+
cursor = 0
|
|
152
272
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
273
|
+
while True:
|
|
274
|
+
cursor, keys = await self._redis.scan(
|
|
275
|
+
cursor,
|
|
276
|
+
match=pattern,
|
|
277
|
+
count=100,
|
|
278
|
+
)
|
|
279
|
+
if keys:
|
|
280
|
+
await self._redis.delete(*keys)
|
|
156
281
|
|
|
157
|
-
|
|
282
|
+
if cursor == 0:
|
|
283
|
+
break
|