agentscope-runtime 1.0.5__py3-none-any.whl → 1.1.0b2__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 (71) hide show
  1. agentscope_runtime/__init__.py +3 -0
  2. agentscope_runtime/adapters/agentscope/message.py +36 -295
  3. agentscope_runtime/adapters/agentscope/stream.py +89 -2
  4. agentscope_runtime/adapters/agno/message.py +11 -2
  5. agentscope_runtime/adapters/agno/stream.py +1 -0
  6. agentscope_runtime/adapters/langgraph/__init__.py +1 -3
  7. agentscope_runtime/adapters/langgraph/message.py +11 -106
  8. agentscope_runtime/adapters/langgraph/stream.py +1 -0
  9. agentscope_runtime/adapters/ms_agent_framework/message.py +11 -1
  10. agentscope_runtime/adapters/ms_agent_framework/stream.py +1 -0
  11. agentscope_runtime/adapters/text/stream.py +1 -0
  12. agentscope_runtime/common/container_clients/agentrun_client.py +0 -3
  13. agentscope_runtime/common/container_clients/boxlite_client.py +26 -15
  14. agentscope_runtime/common/container_clients/fc_client.py +0 -11
  15. agentscope_runtime/common/utils/deprecation.py +14 -17
  16. agentscope_runtime/common/utils/logging.py +44 -0
  17. agentscope_runtime/engine/app/agent_app.py +5 -5
  18. agentscope_runtime/engine/app/celery_mixin.py +43 -4
  19. agentscope_runtime/engine/deployers/adapter/agui/__init__.py +8 -1
  20. agentscope_runtime/engine/deployers/adapter/agui/agui_adapter_utils.py +6 -1
  21. agentscope_runtime/engine/deployers/adapter/agui/agui_protocol_adapter.py +2 -2
  22. agentscope_runtime/engine/deployers/utils/service_utils/fastapi_factory.py +13 -0
  23. agentscope_runtime/engine/runner.py +31 -6
  24. agentscope_runtime/engine/schemas/agent_schemas.py +28 -0
  25. agentscope_runtime/engine/services/sandbox/sandbox_service.py +41 -9
  26. agentscope_runtime/sandbox/box/base/base_sandbox.py +4 -0
  27. agentscope_runtime/sandbox/box/browser/browser_sandbox.py +4 -0
  28. agentscope_runtime/sandbox/box/dummy/dummy_sandbox.py +9 -2
  29. agentscope_runtime/sandbox/box/filesystem/filesystem_sandbox.py +4 -0
  30. agentscope_runtime/sandbox/box/gui/gui_sandbox.py +5 -1
  31. agentscope_runtime/sandbox/box/mobile/mobile_sandbox.py +4 -0
  32. agentscope_runtime/sandbox/box/sandbox.py +122 -13
  33. agentscope_runtime/sandbox/client/async_http_client.py +1 -0
  34. agentscope_runtime/sandbox/client/base.py +0 -1
  35. agentscope_runtime/sandbox/client/http_client.py +0 -2
  36. agentscope_runtime/sandbox/manager/heartbeat_mixin.py +486 -0
  37. agentscope_runtime/sandbox/manager/sandbox_manager.py +740 -153
  38. agentscope_runtime/sandbox/manager/server/app.py +18 -11
  39. agentscope_runtime/sandbox/manager/server/config.py +10 -2
  40. agentscope_runtime/sandbox/mcp_server.py +0 -1
  41. agentscope_runtime/sandbox/model/__init__.py +2 -1
  42. agentscope_runtime/sandbox/model/container.py +90 -3
  43. agentscope_runtime/sandbox/model/manager_config.py +45 -1
  44. agentscope_runtime/version.py +1 -1
  45. {agentscope_runtime-1.0.5.dist-info → agentscope_runtime-1.1.0b2.dist-info}/METADATA +36 -54
  46. {agentscope_runtime-1.0.5.dist-info → agentscope_runtime-1.1.0b2.dist-info}/RECORD +50 -69
  47. {agentscope_runtime-1.0.5.dist-info → agentscope_runtime-1.1.0b2.dist-info}/WHEEL +1 -1
  48. agentscope_runtime/adapters/agentscope/long_term_memory/__init__.py +0 -6
  49. agentscope_runtime/adapters/agentscope/long_term_memory/_long_term_memory_adapter.py +0 -258
  50. agentscope_runtime/adapters/agentscope/memory/__init__.py +0 -6
  51. agentscope_runtime/adapters/agentscope/memory/_memory_adapter.py +0 -152
  52. agentscope_runtime/engine/services/agent_state/__init__.py +0 -25
  53. agentscope_runtime/engine/services/agent_state/redis_state_service.py +0 -166
  54. agentscope_runtime/engine/services/agent_state/state_service.py +0 -179
  55. agentscope_runtime/engine/services/agent_state/state_service_factory.py +0 -52
  56. agentscope_runtime/engine/services/memory/__init__.py +0 -33
  57. agentscope_runtime/engine/services/memory/mem0_memory_service.py +0 -128
  58. agentscope_runtime/engine/services/memory/memory_service.py +0 -292
  59. agentscope_runtime/engine/services/memory/memory_service_factory.py +0 -126
  60. agentscope_runtime/engine/services/memory/redis_memory_service.py +0 -290
  61. agentscope_runtime/engine/services/memory/reme_personal_memory_service.py +0 -109
  62. agentscope_runtime/engine/services/memory/reme_task_memory_service.py +0 -11
  63. agentscope_runtime/engine/services/memory/tablestore_memory_service.py +0 -301
  64. agentscope_runtime/engine/services/session_history/__init__.py +0 -32
  65. agentscope_runtime/engine/services/session_history/redis_session_history_service.py +0 -283
  66. agentscope_runtime/engine/services/session_history/session_history_service.py +0 -267
  67. agentscope_runtime/engine/services/session_history/session_history_service_factory.py +0 -73
  68. agentscope_runtime/engine/services/session_history/tablestore_session_history_service.py +0 -288
  69. {agentscope_runtime-1.0.5.dist-info → agentscope_runtime-1.1.0b2.dist-info}/entry_points.txt +0 -0
  70. {agentscope_runtime-1.0.5.dist-info → agentscope_runtime-1.1.0b2.dist-info}/licenses/LICENSE +0 -0
  71. {agentscope_runtime-1.0.5.dist-info → agentscope_runtime-1.1.0b2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,486 @@
1
+ # -*- coding: utf-8 -*-
2
+ import asyncio
3
+ import inspect
4
+ import time
5
+ import secrets
6
+ from typing import Optional, List
7
+ from functools import wraps
8
+
9
+ import logging
10
+ from redis.exceptions import ResponseError
11
+
12
+ from ..model import ContainerModel, ContainerState
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def touch_session(identity_arg: str = "identity"):
18
+ """Decorator factory that updates session heartbeat derived from identity.
19
+
20
+ This decorator extracts ``identity`` (or the argument named by
21
+ ``identity_arg``) from the wrapped function call, resolves
22
+ ``session_ctx_id``, updates heartbeat, and triggers restore when needed.
23
+
24
+ .. important:: Any exceptions raised during the touch process are ignored.
25
+
26
+ Args:
27
+ identity_arg (`str`):
28
+ The keyword/parameter name that carries the identity.
29
+
30
+ Returns:
31
+ `callable`:
32
+ A decorator that wraps the target function (sync or async).
33
+ """
34
+
35
+ def decorator(func):
36
+ if asyncio.iscoroutinefunction(func):
37
+
38
+ @wraps(func)
39
+ async def async_wrapper(self, *args, **kwargs):
40
+ try:
41
+ bound = inspect.signature(func).bind_partial(
42
+ self,
43
+ *args,
44
+ **kwargs,
45
+ )
46
+ identity = bound.arguments.get(identity_arg)
47
+ if identity is not None:
48
+ session_ctx_id = self.get_session_ctx_id_by_identity(
49
+ identity,
50
+ )
51
+ if session_ctx_id:
52
+ self.update_heartbeat(session_ctx_id)
53
+ if self.needs_restore(session_ctx_id):
54
+ if hasattr(self, "restore_session"):
55
+ self.restore_session(session_ctx_id)
56
+ except Exception as e:
57
+ logger.debug(f"touch_session failed (ignored): {e}")
58
+
59
+ return await func(self, *args, **kwargs)
60
+
61
+ return async_wrapper
62
+
63
+ @wraps(func)
64
+ def sync_wrapper(self, *args, **kwargs):
65
+ try:
66
+ bound = inspect.signature(func).bind_partial(
67
+ self,
68
+ *args,
69
+ **kwargs,
70
+ )
71
+ identity = bound.arguments.get(identity_arg)
72
+ if identity is not None:
73
+ session_ctx_id = self.get_session_ctx_id_by_identity(
74
+ identity,
75
+ )
76
+ if session_ctx_id:
77
+ self.update_heartbeat(session_ctx_id)
78
+ if self.needs_restore(session_ctx_id):
79
+ if hasattr(self, "restore_session"):
80
+ self.restore_session(session_ctx_id)
81
+ except Exception as e:
82
+ logger.debug(f"touch_session failed (ignored): {e}")
83
+
84
+ return func(self, *args, **kwargs)
85
+
86
+ return sync_wrapper
87
+
88
+ return decorator
89
+
90
+
91
+ class HeartbeatMixin:
92
+ """Mixin that provides heartbeat, recycle markers, and a distributed lock.
93
+
94
+ This mixin stores heartbeat timestamps and recycle markers in
95
+ ``ContainerModel`` records persisted through ``container_mapping``.
96
+ It also supports a Redis-based distributed lock for reaping/heartbeat
97
+ operations.
98
+
99
+ .. important:: The host class must provide required attributes/methods.
100
+
101
+ Host class requirements:
102
+ - ``self.container_mapping`` (Mapping-like with set/get/delete/scan)
103
+ - ``self.session_mapping`` (Mapping-like with set/get/delete/scan)
104
+ - ``self.get_info(identity) -> dict`` compatible with
105
+ ``ContainerModel(**dict)``
106
+ - ``self.config.redis_enabled`` (`bool`)
107
+ - ``self.config.heartbeat_lock_ttl`` (`int`)
108
+ - ``self.redis_client`` (redis client or ``None``)
109
+ - ``self.restore_session(session_ctx_id)`` (optional, for restore)
110
+
111
+ """
112
+
113
+ _REDIS_RELEASE_LOCK_LUA = """if redis.call("GET", KEYS[1]) == ARGV[1] then
114
+ return redis.call("DEL", KEYS[1])
115
+ else
116
+ return 0
117
+ end
118
+ """
119
+
120
+ def _list_container_names_by_session(
121
+ self,
122
+ session_ctx_id: str,
123
+ ) -> List[str]:
124
+ """List container names bound to the given session context id.
125
+
126
+ Args:
127
+ session_ctx_id (`str`):
128
+ The session context id.
129
+
130
+ Returns:
131
+ `List[str]`:
132
+ A list of container names for the session, or an empty list.
133
+ """
134
+ if not session_ctx_id:
135
+ return []
136
+ # session_mapping stores container_name list
137
+ try:
138
+ return self.session_mapping.get(session_ctx_id) or []
139
+ except Exception as e:
140
+ logger.warning(
141
+ f"_list_container_names_by_session "
142
+ f"failed for session_ctx_id={session_ctx_id}: {e}",
143
+ exc_info=True,
144
+ )
145
+ return []
146
+
147
+ def _load_container_model(self, identity: str) -> Optional[ContainerModel]:
148
+ """Load a `ContainerModel` from storage by container identity.
149
+
150
+ Args:
151
+ identity (`str`):
152
+ The container identity (typically container name).
153
+
154
+ Returns:
155
+ `Optional[ContainerModel]`:
156
+ The loaded model, or ``None`` if it cannot be loaded.
157
+ """
158
+ try:
159
+ info_dict = self.get_info(identity)
160
+ return ContainerModel(**info_dict)
161
+ except Exception as e:
162
+ logger.debug(f"_load_container_model failed for {identity}: {e}")
163
+ return None
164
+
165
+ def _save_container_model(self, model: ContainerModel) -> None:
166
+ """Persist a `ContainerModel` back into ``container_mapping``.
167
+
168
+ Args:
169
+ model (`ContainerModel`):
170
+ The model to persist.
171
+
172
+ Returns:
173
+ `None`:
174
+ No return value.
175
+ """
176
+ # IMPORTANT: persist back into container_mapping
177
+ self.container_mapping.set(model.container_name, model.model_dump())
178
+
179
+ # ---------- heartbeat ----------
180
+ def update_heartbeat(
181
+ self,
182
+ session_ctx_id: str,
183
+ ts: Optional[float] = None,
184
+ ) -> float:
185
+ """Update heartbeat timestamp for all RUNNING containers of a session.
186
+
187
+ The timestamp is written into ``ContainerModel.last_active_at`` and
188
+ ``updated_at`` is refreshed.
189
+
190
+ Args:
191
+ session_ctx_id (`str`):
192
+ The session context id.
193
+ ts (`Optional[float]`, optional):
194
+ The timestamp to write. If ``None``, uses ``time.time()``.
195
+
196
+ Returns:
197
+ `float`:
198
+ The timestamp that was written.
199
+ """
200
+ if not session_ctx_id:
201
+ raise ValueError("session_ctx_id is required")
202
+
203
+ ts = float(ts if ts is not None else time.time())
204
+ now = time.time()
205
+
206
+ container_names = self._list_container_names_by_session(session_ctx_id)
207
+ for cname in list(container_names):
208
+ model = self._load_container_model(cname)
209
+ if not model:
210
+ continue
211
+
212
+ # only update heartbeat for RUNNING containers
213
+ if model.state != ContainerState.RUNNING:
214
+ continue
215
+
216
+ model.last_active_at = ts
217
+ model.updated_at = now
218
+
219
+ # keep session_ctx_id consistent (migration safety)
220
+ model.session_ctx_id = session_ctx_id
221
+
222
+ self._save_container_model(model)
223
+
224
+ return ts
225
+
226
+ def get_heartbeat(self, session_ctx_id: str) -> Optional[float]:
227
+ """Get session-level heartbeat as max(last_active_at) of RUNNING items.
228
+
229
+ Args:
230
+ session_ctx_id (`str`):
231
+ The session context id.
232
+
233
+ Returns:
234
+ `Optional[float]`:
235
+ The maximum heartbeat timestamp, or ``None`` if unavailable.
236
+ """
237
+ if not session_ctx_id:
238
+ return None
239
+
240
+ container_names = self._list_container_names_by_session(session_ctx_id)
241
+ last_vals = []
242
+ for cname in list(container_names):
243
+ model = self._load_container_model(cname)
244
+ if not model:
245
+ continue
246
+
247
+ if model.state != ContainerState.RUNNING:
248
+ continue
249
+
250
+ if model.last_active_at is not None:
251
+ last_vals.append(float(model.last_active_at))
252
+
253
+ return max(last_vals) if last_vals else None
254
+
255
+ # ---------- recycled marker ----------
256
+ def mark_session_recycled(
257
+ self,
258
+ session_ctx_id: str,
259
+ ts: Optional[float] = None,
260
+ reason: str = "heartbeat_timeout",
261
+ ) -> float:
262
+ """Mark all containers of a session as recycled.
263
+
264
+ This only updates stored metadata; it does not stop/remove containers.
265
+
266
+ Args:
267
+ session_ctx_id (`str`):
268
+ The session context id.
269
+ ts (`Optional[float]`, optional):
270
+ The recycle timestamp. If ``None``, uses ``time.time()``.
271
+ reason (`str`):
272
+ The recycle reason.
273
+
274
+ Returns:
275
+ `float`:
276
+ The timestamp that was written.
277
+ """
278
+ if not session_ctx_id:
279
+ raise ValueError("session_ctx_id is required")
280
+
281
+ ts = float(ts if ts is not None else time.time())
282
+ now = time.time()
283
+
284
+ container_names = self._list_container_names_by_session(session_ctx_id)
285
+ for cname in list(container_names):
286
+ model = self._load_container_model(cname)
287
+ if not model:
288
+ continue
289
+
290
+ # if already released, don't flip back
291
+ if model.state == ContainerState.RELEASED:
292
+ continue
293
+
294
+ model.state = ContainerState.RECYCLED
295
+ model.recycled_at = ts
296
+ model.recycle_reason = reason
297
+ model.updated_at = now
298
+
299
+ model.session_ctx_id = session_ctx_id
300
+ self._save_container_model(model)
301
+
302
+ return ts
303
+
304
+ def clear_container_recycle_marker(
305
+ self,
306
+ identity: str,
307
+ *,
308
+ set_state: Optional[ContainerState] = None,
309
+ ) -> None:
310
+ """Clear recycle marker for a single container and set its state.
311
+
312
+ This resets:
313
+ - ``recycled_at`` to ``None``
314
+ - ``recycle_reason`` to ``None``
315
+
316
+ .. important:: This only updates the stored record; it does not manage
317
+ real container lifecycle and session mapping.
318
+
319
+ Args:
320
+ identity (`str`):
321
+ The container identity.
322
+ set_state (`ContainerState`):
323
+ The state to set on the container record.
324
+
325
+ Returns:
326
+ `None`:
327
+ No return value.
328
+ """
329
+ model = self._load_container_model(identity)
330
+ if not model:
331
+ return
332
+
333
+ model.recycled_at = None
334
+ model.recycle_reason = None
335
+ if set_state:
336
+ model.state = set_state
337
+
338
+ model.updated_at = time.time()
339
+ self._save_container_model(model)
340
+
341
+ def needs_restore(self, session_ctx_id: str) -> bool:
342
+ """Check whether any container in the session is marked for restore.
343
+
344
+ A session is considered needing restore if any bound container is in
345
+ ``ContainerState.RECYCLED`` or has ``recycled_at`` set.
346
+
347
+ Args:
348
+ session_ctx_id (`str`):
349
+ The session context id.
350
+
351
+ Returns:
352
+ `bool`:
353
+ ``True`` if restore is needed, otherwise ``False``.
354
+ """
355
+ if not session_ctx_id:
356
+ return False
357
+
358
+ container_names = self._list_container_names_by_session(session_ctx_id)
359
+ for cname in list(container_names):
360
+ model = self._load_container_model(cname)
361
+ if not model:
362
+ continue
363
+ if (
364
+ model.state == ContainerState.RECYCLED
365
+ or model.recycled_at is not None
366
+ ):
367
+ return True
368
+ return False
369
+
370
+ # ---------- helpers ----------
371
+ def get_session_ctx_id_by_identity(self, identity: str) -> Optional[str]:
372
+ """Resolve ``session_ctx_id`` from a container identity.
373
+
374
+ It prefers the top-level ``session_ctx_id`` field on `ContainerModel`,
375
+ and falls back to ``meta['session_ctx_id']`` for older payloads.
376
+
377
+ Args:
378
+ identity (`str`):
379
+ The container identity.
380
+
381
+ Returns:
382
+ `Optional[str]`:
383
+ The resolved session context id, or ``None`` if not found.
384
+ """
385
+ try:
386
+ info_dict = self.get_info(identity)
387
+ except RuntimeError as exc:
388
+ logger.debug(
389
+ f"get_session_ctx_id_by_identity: container not found for "
390
+ f"identity {identity}: {exc}",
391
+ )
392
+
393
+ return None
394
+
395
+ info = ContainerModel(**info_dict)
396
+
397
+ # NEW: prefer top-level field
398
+ if info.session_ctx_id:
399
+ return info.session_ctx_id
400
+
401
+ # fallback for older payloads
402
+ return (info.meta or {}).get("session_ctx_id")
403
+
404
+ # ---------- redis distributed lock ----------
405
+ def _heartbeat_lock_key(self, session_ctx_id: str) -> str:
406
+ """Build the Redis key used for heartbeat locking.
407
+
408
+ Args:
409
+ session_ctx_id (`str`):
410
+ The session context id.
411
+
412
+ Returns:
413
+ `str`:
414
+ The redis lock key.
415
+ """
416
+ return f"heartbeat_lock:{session_ctx_id}"
417
+
418
+ def acquire_heartbeat_lock(self, session_ctx_id: str) -> Optional[str]:
419
+ """Acquire a heartbeat lock for a session.
420
+
421
+ In Redis mode, it uses ``SET key token NX EX ttl``.
422
+ In non-Redis mode, it returns a fixed token ``"inmemory"``.
423
+
424
+ Args:
425
+ session_ctx_id (`str`):
426
+ The session context id.
427
+
428
+ Returns:
429
+ `Optional[str]`:
430
+ The lock token if acquired, otherwise ``None``.
431
+ """
432
+ if not self.config.redis_enabled or self.redis_client is None:
433
+ return "inmemory"
434
+
435
+ key = self._heartbeat_lock_key(session_ctx_id)
436
+ token = secrets.token_hex(16)
437
+ ok = self.redis_client.set(
438
+ key,
439
+ token,
440
+ nx=True,
441
+ ex=int(self.config.heartbeat_lock_ttl),
442
+ )
443
+ return token if ok else None
444
+
445
+ def release_heartbeat_lock(self, session_ctx_id: str, token: str) -> bool:
446
+ """Release a heartbeat lock if the token matches.
447
+
448
+ It uses a Lua script to ensure only the owner token can release the
449
+ lock.
450
+ If Redis does not support ``EVAL``, it falls back to a GET+DEL check.
451
+
452
+ Args:
453
+ session_ctx_id (`str`):
454
+ The session context id.
455
+ token (`str`):
456
+ The lock token returned by `acquire_heartbeat_lock`.
457
+
458
+ Returns:
459
+ `bool`:
460
+ ``True`` if the lock was released (or non-Redis mode), else
461
+ ``False``.
462
+ """
463
+ if not self.config.redis_enabled or self.redis_client is None:
464
+ return True
465
+
466
+ key = self._heartbeat_lock_key(session_ctx_id)
467
+ try:
468
+ res = self.redis_client.eval(
469
+ self._REDIS_RELEASE_LOCK_LUA,
470
+ 1,
471
+ key,
472
+ token,
473
+ )
474
+ return bool(res)
475
+ except ResponseError as e:
476
+ msg = str(e).lower()
477
+ if "unknown command" in msg and "eval" in msg:
478
+ val = self.redis_client.get(key)
479
+ if val == token:
480
+ return bool(self.redis_client.delete(key))
481
+ return False
482
+ logger.warning(f"Failed to release heartbeat lock {key}: {e}")
483
+ raise
484
+ except Exception as e:
485
+ logger.warning(f"Failed to release heartbeat lock {key}: {e}")
486
+ return False