agentscope-runtime 1.0.2__py3-none-any.whl → 1.0.4__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 (58) hide show
  1. agentscope_runtime/adapters/agentscope/stream.py +2 -9
  2. agentscope_runtime/adapters/ms_agent_framework/__init__.py +0 -0
  3. agentscope_runtime/adapters/ms_agent_framework/message.py +205 -0
  4. agentscope_runtime/adapters/ms_agent_framework/stream.py +418 -0
  5. agentscope_runtime/adapters/utils.py +6 -0
  6. agentscope_runtime/cli/commands/deploy.py +383 -0
  7. agentscope_runtime/common/collections/redis_mapping.py +4 -1
  8. agentscope_runtime/common/container_clients/knative_client.py +466 -0
  9. agentscope_runtime/engine/__init__.py +4 -0
  10. agentscope_runtime/engine/app/agent_app.py +48 -5
  11. agentscope_runtime/engine/constant.py +1 -0
  12. agentscope_runtime/engine/deployers/__init__.py +12 -0
  13. agentscope_runtime/engine/deployers/adapter/a2a/__init__.py +31 -1
  14. agentscope_runtime/engine/deployers/adapter/a2a/a2a_protocol_adapter.py +458 -41
  15. agentscope_runtime/engine/deployers/adapter/a2a/a2a_registry.py +76 -0
  16. agentscope_runtime/engine/deployers/adapter/a2a/nacos_a2a_registry.py +749 -0
  17. agentscope_runtime/engine/deployers/agentrun_deployer.py +2 -2
  18. agentscope_runtime/engine/deployers/fc_deployer.py +1506 -0
  19. agentscope_runtime/engine/deployers/knative_deployer.py +290 -0
  20. agentscope_runtime/engine/deployers/kubernetes_deployer.py +3 -0
  21. agentscope_runtime/engine/deployers/utils/docker_image_utils/dockerfile_generator.py +8 -2
  22. agentscope_runtime/engine/deployers/utils/docker_image_utils/image_factory.py +5 -0
  23. agentscope_runtime/engine/deployers/utils/net_utils.py +65 -0
  24. agentscope_runtime/engine/runner.py +17 -3
  25. agentscope_runtime/engine/schemas/exception.py +24 -0
  26. agentscope_runtime/engine/services/agent_state/redis_state_service.py +61 -8
  27. agentscope_runtime/engine/services/agent_state/state_service_factory.py +2 -5
  28. agentscope_runtime/engine/services/memory/redis_memory_service.py +129 -25
  29. agentscope_runtime/engine/services/session_history/redis_session_history_service.py +160 -34
  30. agentscope_runtime/engine/tracing/wrapper.py +18 -4
  31. agentscope_runtime/sandbox/__init__.py +14 -6
  32. agentscope_runtime/sandbox/box/base/__init__.py +2 -2
  33. agentscope_runtime/sandbox/box/base/base_sandbox.py +51 -1
  34. agentscope_runtime/sandbox/box/browser/__init__.py +2 -2
  35. agentscope_runtime/sandbox/box/browser/browser_sandbox.py +198 -2
  36. agentscope_runtime/sandbox/box/filesystem/__init__.py +2 -2
  37. agentscope_runtime/sandbox/box/filesystem/filesystem_sandbox.py +99 -2
  38. agentscope_runtime/sandbox/box/gui/__init__.py +2 -2
  39. agentscope_runtime/sandbox/box/gui/gui_sandbox.py +117 -1
  40. agentscope_runtime/sandbox/box/mobile/__init__.py +2 -2
  41. agentscope_runtime/sandbox/box/mobile/mobile_sandbox.py +247 -100
  42. agentscope_runtime/sandbox/box/sandbox.py +98 -65
  43. agentscope_runtime/sandbox/box/shared/routers/generic.py +36 -29
  44. agentscope_runtime/sandbox/build.py +50 -57
  45. agentscope_runtime/sandbox/client/__init__.py +6 -1
  46. agentscope_runtime/sandbox/client/async_http_client.py +339 -0
  47. agentscope_runtime/sandbox/client/base.py +74 -0
  48. agentscope_runtime/sandbox/client/http_client.py +108 -329
  49. agentscope_runtime/sandbox/enums.py +7 -0
  50. agentscope_runtime/sandbox/manager/sandbox_manager.py +264 -4
  51. agentscope_runtime/sandbox/manager/server/app.py +7 -1
  52. agentscope_runtime/version.py +1 -1
  53. {agentscope_runtime-1.0.2.dist-info → agentscope_runtime-1.0.4.dist-info}/METADATA +109 -29
  54. {agentscope_runtime-1.0.2.dist-info → agentscope_runtime-1.0.4.dist-info}/RECORD +58 -46
  55. {agentscope_runtime-1.0.2.dist-info → agentscope_runtime-1.0.4.dist-info}/WHEEL +0 -0
  56. {agentscope_runtime-1.0.2.dist-info → agentscope_runtime-1.0.4.dist-info}/entry_points.txt +0 -0
  57. {agentscope_runtime-1.0.2.dist-info → agentscope_runtime-1.0.4.dist-info}/licenses/LICENSE +0 -0
  58. {agentscope_runtime-1.0.2.dist-info → agentscope_runtime-1.0.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,290 @@
1
+ # -*- coding: utf-8 -*-
2
+ import logging
3
+ import os
4
+ from typing import Optional, Dict, List, Union, Any
5
+
6
+ from pydantic import BaseModel, Field
7
+ from .utils.docker_image_utils import (
8
+ ImageFactory,
9
+ RegistryConfig,
10
+ )
11
+ from .adapter.protocol_adapter import ProtocolAdapter
12
+ from .base import DeployManager
13
+ from ...common.container_clients.knative_client import (
14
+ KnativeClient,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class K8sConfig(BaseModel):
21
+ # Kubernetes settings
22
+ k8s_namespace: Optional[str] = Field(
23
+ "agentscope-runtime",
24
+ description="Kubernetes namespace to deploy KService. ",
25
+ )
26
+ kubeconfig_path: Optional[str] = Field(
27
+ None,
28
+ description="Path to kubeconfig file. If not set, will try "
29
+ "in-cluster config or default kubeconfig.",
30
+ )
31
+
32
+
33
+ class BuildConfig(BaseModel):
34
+ """Build configuration"""
35
+
36
+ build_context_dir: str = "/tmp/k8s_build"
37
+ dockerfile_template: str = None
38
+ build_timeout: int = 600 # 10 minutes
39
+ push_timeout: int = 300 # 5 minutes
40
+ cleanup_after_build: bool = True
41
+
42
+
43
+ class KnativeDeployManager(DeployManager):
44
+ """
45
+ Deploy an AgentScope runner as a Knative Service.
46
+ Requires a Kubernetes cluster with Knative Serving installed.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ kube_config: K8sConfig = None,
52
+ registry_config: RegistryConfig = RegistryConfig(),
53
+ build_context_dir: str = "/tmp/k8s_build",
54
+ ):
55
+ """
56
+ Initialize the Knative deployer.
57
+ """
58
+ super().__init__()
59
+ self.kubeconfig = kube_config
60
+ self.registry_config = registry_config
61
+ self.image_factory = ImageFactory()
62
+ self.build_context_dir = build_context_dir
63
+ self._deployed_resources = {}
64
+ self._built_images = {}
65
+
66
+ self.knative_client = KnativeClient(
67
+ config=self.kubeconfig,
68
+ image_registry=self.registry_config.get_full_url(),
69
+ )
70
+
71
+ async def deploy(
72
+ self,
73
+ app=None,
74
+ runner=None,
75
+ stream: bool = True,
76
+ protocol_adapters: Optional[list[ProtocolAdapter]] = None,
77
+ requirements: Optional[Union[str, List[str]]] = None,
78
+ extra_packages: Optional[List[str]] = None,
79
+ base_image: str = "python:3.9-slim",
80
+ environment: Dict = None,
81
+ runtime_config: Dict = None,
82
+ annotations: Dict = None,
83
+ labels: Dict = None,
84
+ port: int = 8080,
85
+ mount_dir: str = None,
86
+ image_name: str = "agent_llm",
87
+ image_tag: str = "latest",
88
+ push_to_registry: bool = False,
89
+ **kwargs,
90
+ ) -> Dict[str, Any]:
91
+ """
92
+ Deploy the runner as a Knative Service.
93
+
94
+ Args:
95
+ app: Agent app to be deployed
96
+ runner: Complete Runner object with agent, environment_manager,
97
+ context_manager
98
+ stream: Enable streaming responses
99
+ protocol_adapters: protocol adapters
100
+ requirements: PyPI dependencies (following _agent_engines.py
101
+ pattern)
102
+ extra_packages: User code directory/file path
103
+ base_image: Docker base image
104
+ port: Container port
105
+ environment: Environment variables dict
106
+ mount_dir: Mount directory
107
+ runtime_config: K8s runtime configuration
108
+ annotations: knative service annotations
109
+ labels: knative service labels
110
+ # Backward compatibility
111
+ image_name: Image name
112
+ image_tag: Image tag
113
+ push_to_registry: Push to registry
114
+ **kwargs: Additional arguments
115
+
116
+ Returns:
117
+ Dict containing deploy_id, url, resource_name
118
+
119
+ Raises:
120
+ RuntimeError: If kservice fails
121
+
122
+ """
123
+ created_resources = []
124
+ deploy_id = self.deploy_id
125
+ try:
126
+ logger.info(f"Starting Knative Service {deploy_id}")
127
+
128
+ # Step 1: Build image with proper error handling
129
+ logger.info("Building runner image...")
130
+ try:
131
+ built_image_name = self.image_factory.build_image(
132
+ app=app,
133
+ runner=runner,
134
+ base_image=base_image,
135
+ build_context_dir=self.build_context_dir,
136
+ registry_config=self.registry_config,
137
+ image_name=image_name,
138
+ image_tag=image_tag,
139
+ push_to_registry=push_to_registry,
140
+ port=port,
141
+ protocol_adapters=protocol_adapters,
142
+ **kwargs,
143
+ )
144
+ if not built_image_name:
145
+ raise RuntimeError(
146
+ "Image build failed - no image name returned",
147
+ )
148
+
149
+ created_resources.append(f"image:{built_image_name}")
150
+ self._built_images[deploy_id] = built_image_name
151
+ logger.info(f"Image built successfully: {built_image_name}")
152
+ except Exception as e:
153
+ logger.error(f"Image build failed: {e}")
154
+ raise RuntimeError(f"Failed to build image: {e}") from e
155
+
156
+ if mount_dir:
157
+ if not os.path.isabs(mount_dir):
158
+ mount_dir = os.path.abspath(mount_dir)
159
+
160
+ volume_bindings = {
161
+ mount_dir: {
162
+ "bind": mount_dir,
163
+ "mode": "rw",
164
+ },
165
+ }
166
+ else:
167
+ volume_bindings = {}
168
+
169
+ resource_name = self.get_resource_name(deploy_id)
170
+
171
+ logger.info(f"Building Knative Service for {deploy_id}")
172
+
173
+ # Create Knative Service
174
+ name, url = self.knative_client.create_kservice(
175
+ name=resource_name,
176
+ image=built_image_name,
177
+ ports=[port],
178
+ volumes=volume_bindings,
179
+ environment=environment,
180
+ runtime_config=runtime_config or {},
181
+ annotations=annotations or {},
182
+ labels=labels or {},
183
+ )
184
+ if not url:
185
+ import traceback
186
+
187
+ raise RuntimeError(
188
+ f"Failed to create resource: "
189
+ f"{resource_name}, {traceback.format_exc()}",
190
+ )
191
+
192
+ logger.info(f"Knative Service url {url} successful")
193
+ self._deployed_resources[deploy_id] = {
194
+ "resource_name": name,
195
+ "config": {
196
+ "runner": runner.__class__.__name__,
197
+ "extra_packages": extra_packages,
198
+ "requirements": requirements, # New format
199
+ "base_image": base_image,
200
+ "port": port,
201
+ "environment": environment,
202
+ "runtime_config": runtime_config,
203
+ "stream": stream,
204
+ "protocol_adapters": protocol_adapters,
205
+ **kwargs,
206
+ },
207
+ }
208
+ return {
209
+ "deploy_id": deploy_id,
210
+ "resource_name": resource_name,
211
+ "url": url,
212
+ }
213
+
214
+ except Exception as e:
215
+ import traceback
216
+
217
+ logger.error(f"Knative Service {deploy_id} failed: {e}")
218
+ # Enhanced rollback with better error handling
219
+ raise RuntimeError(
220
+ f"Knative Service failed: {e}, {traceback.format_exc()}",
221
+ ) from e
222
+
223
+ @staticmethod
224
+ def get_resource_name(deploy_id: str) -> str:
225
+ return f"agent-{deploy_id[:8]}"
226
+
227
+ async def stop(
228
+ self,
229
+ deploy_id: str,
230
+ **kwargs,
231
+ ) -> Dict[str, Any]:
232
+ """Stop Knative Service.
233
+
234
+ Args:
235
+ deploy_id: Deployment identifier
236
+ **kwargs: Additional parameters
237
+
238
+ Returns:
239
+ Dict with success status, message, and details
240
+ """
241
+
242
+ resource_name = self.get_resource_name(deploy_id)
243
+ try:
244
+ # Try to remove the KService
245
+ success = self.knative_client.delete_kservice(resource_name)
246
+
247
+ if success:
248
+ return {
249
+ "success": True,
250
+ "message": f"Knative deployment {resource_name} "
251
+ f"removed",
252
+ "details": {
253
+ "deploy_id": deploy_id,
254
+ "resource_name": resource_name,
255
+ },
256
+ }
257
+ else:
258
+ return {
259
+ "success": False,
260
+ "message": f"Knative deployment {resource_name} not "
261
+ f"found (may already be deleted), Please check the "
262
+ f"detail in cluster",
263
+ "details": {
264
+ "deploy_id": deploy_id,
265
+ "resource_name": resource_name,
266
+ },
267
+ }
268
+ except Exception as e:
269
+ logger.error(
270
+ f"Failed to remove Knative service {resource_name}: {e}",
271
+ )
272
+ return {
273
+ "success": False,
274
+ "message": f"Failed to remove Knative service: {e}",
275
+ "details": {
276
+ "deploy_id": deploy_id,
277
+ "resource_name": resource_name,
278
+ "error": str(e),
279
+ },
280
+ }
281
+
282
+ def get_status(self) -> str:
283
+ """Get KService status"""
284
+ if self.deploy_id not in self._deployed_resources:
285
+ return "not_found"
286
+
287
+ resources = self._deployed_resources[self.deploy_id]
288
+ kservice_name = resources["resource_name"]
289
+
290
+ return self.knative_client.get_kservice_status(kservice_name)
@@ -144,6 +144,7 @@ class KubernetesDeployManager(DeployManager):
144
144
  image_tag: str = "latest",
145
145
  push_to_registry: bool = False,
146
146
  use_cache: bool = True,
147
+ pypi_mirror: Optional[str] = None,
147
148
  **kwargs,
148
149
  ) -> Dict[str, Any]:
149
150
  """
@@ -170,6 +171,7 @@ class KubernetesDeployManager(DeployManager):
170
171
  mount_dir: Mount directory
171
172
  runtime_config: K8s runtime configuration
172
173
  use_cache: Enable build cache (default: True)
174
+ pypi_mirror: PyPI mirror URL for pip package installation
173
175
  # Backward compatibility
174
176
  image_name: Image name
175
177
  image_tag: Image tag
@@ -209,6 +211,7 @@ class KubernetesDeployManager(DeployManager):
209
211
  protocol_adapters=protocol_adapters,
210
212
  custom_endpoints=custom_endpoints,
211
213
  use_cache=use_cache,
214
+ pypi_mirror=pypi_mirror,
212
215
  **kwargs,
213
216
  )
214
217
  if not built_image_name:
@@ -22,6 +22,7 @@ class DockerfileConfig(BaseModel):
22
22
  health_check_endpoint: str = "/health"
23
23
  custom_template: Optional[str] = None
24
24
  platform: Optional[str] = None
25
+ pypi_mirror: Optional[str] = None
25
26
 
26
27
 
27
28
  class DockerfileGenerator:
@@ -73,8 +74,7 @@ COPY . {working_dir}/
73
74
  # Install Python dependencies
74
75
  RUN pip install --no-cache-dir --upgrade pip
75
76
  RUN if [ -f requirements.txt ]; then \\
76
- pip install --no-cache-dir -r requirements.txt \\
77
- -i https://pypi.tuna.tsinghua.edu.cn/simple; fi
77
+ pip install --no-cache-dir -r requirements.txt{pypi_mirror_flag}; fi
78
78
 
79
79
  # Create non-root user for security
80
80
  RUN adduser --disabled-password --gecos '' {user} && \\
@@ -136,6 +136,11 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\
136
136
  f'"--port", "{config.port}"]'
137
137
  )
138
138
 
139
+ # Prepare PyPI mirror flag
140
+ pypi_mirror_flag = ""
141
+ if config.pypi_mirror:
142
+ pypi_mirror_flag = f" -i {config.pypi_mirror}"
143
+
139
144
  # Format template with configuration values
140
145
  content = template.format(
141
146
  base_image=config.base_image,
@@ -147,6 +152,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\
147
152
  env_vars_section=env_vars_section,
148
153
  startup_command_section=startup_command_section,
149
154
  platform=config.platform,
155
+ pypi_mirror_flag=pypi_mirror_flag,
150
156
  )
151
157
 
152
158
  return content
@@ -40,6 +40,7 @@ class ImageConfig(BaseModel):
40
40
  port: int = 8000
41
41
  env_vars: Dict[str, str] = Field(default_factory=lambda: {})
42
42
  startup_command: Optional[str] = None
43
+ pypi_mirror: Optional[str] = None
43
44
 
44
45
  # Runtime configuration
45
46
  host: str = "0.0.0.0" # Container-friendly default
@@ -218,6 +219,7 @@ class ImageFactory:
218
219
  env_vars=config.env_vars,
219
220
  startup_command=startup_command,
220
221
  platform=config.platform,
222
+ pypi_mirror=config.pypi_mirror,
221
223
  )
222
224
 
223
225
  dockerfile_path = self.dockerfile_generator.create_dockerfile(
@@ -314,6 +316,7 @@ class ImageFactory:
314
316
  embed_task_processor: bool = True,
315
317
  extra_startup_args: Optional[Dict[str, Union[str, int, bool]]] = None,
316
318
  use_cache: bool = True,
319
+ pypi_mirror: Optional[str] = None,
317
320
  **kwargs,
318
321
  ) -> str:
319
322
  """
@@ -339,6 +342,7 @@ class ImageFactory:
339
342
  embed_task_processor: Whether to embed task processor
340
343
  extra_startup_args: Additional startup arguments
341
344
  use_cache: Enable build cache (default: True)
345
+ pypi_mirror: PyPI mirror URL for pip package installation
342
346
  **kwargs: Additional configuration options
343
347
 
344
348
  Returns:
@@ -373,6 +377,7 @@ class ImageFactory:
373
377
  host=host,
374
378
  embed_task_processor=embed_task_processor,
375
379
  extra_startup_args=extra_startup_args or {},
380
+ pypi_mirror=pypi_mirror,
376
381
  **kwargs,
377
382
  )
378
383
 
@@ -0,0 +1,65 @@
1
+ # -*- coding: utf-8 -*-
2
+ import ipaddress
3
+ import os
4
+ import socket
5
+ from typing import Optional
6
+
7
+ import psutil
8
+
9
+
10
+ def get_first_non_loopback_ip() -> Optional[str]:
11
+ """Get the first non-loopback IP address from network interfaces.
12
+
13
+ - Selects the interface with the lowest index
14
+ - Only considers interfaces that are up
15
+ - Supports IPv4/IPv6 based on environment variable
16
+ - Falls back to socket.gethostbyname() if no address found
17
+
18
+ Returns:
19
+ str | None: The first non-loopback IP address, or None if not found
20
+ """
21
+ result = None
22
+ lowest_index = float("inf")
23
+
24
+ use_ipv6 = os.environ.get("USE_IPV6", "false").lower() == "true"
25
+ target_family = socket.AF_INET6 if use_ipv6 else socket.AF_INET
26
+
27
+ net_if_stats = psutil.net_if_stats()
28
+
29
+ for index, (interface, addrs) in enumerate(
30
+ psutil.net_if_addrs().items(),
31
+ ):
32
+ stats = net_if_stats.get(interface)
33
+ if stats is None or not stats.isup:
34
+ continue
35
+
36
+ if index < lowest_index or result is None:
37
+ lowest_index = index
38
+ else:
39
+ continue
40
+
41
+ for addr in addrs:
42
+ if addr.family != target_family:
43
+ continue
44
+
45
+ try:
46
+ ip_obj = ipaddress.ip_address(
47
+ addr.address.split("%")[0],
48
+ )
49
+ if ip_obj.is_loopback:
50
+ continue
51
+ result = addr.address
52
+ except ValueError:
53
+ continue
54
+
55
+ if result is not None:
56
+ return result
57
+
58
+ try:
59
+ hostname = socket.gethostname()
60
+ fallback_ip = socket.gethostbyname(hostname)
61
+ return fallback_ip
62
+ except socket.error:
63
+ pass
64
+
65
+ return None
@@ -272,6 +272,18 @@ class Runner:
272
272
  kwargs.update(
273
273
  {"msgs": await message_to_agno_message(request.input)},
274
274
  )
275
+ elif self.framework_type == "ms_agent_framework":
276
+ from ..adapters.ms_agent_framework.stream import (
277
+ adapt_ms_agent_framework_message_stream,
278
+ )
279
+ from ..adapters.ms_agent_framework.message import (
280
+ message_to_ms_agent_framework_message,
281
+ )
282
+
283
+ stream_adapter = adapt_ms_agent_framework_message_stream
284
+ kwargs.update(
285
+ {"msgs": message_to_ms_agent_framework_message(request.input)},
286
+ )
275
287
  # TODO: support other frameworks
276
288
  else:
277
289
 
@@ -282,6 +294,7 @@ class Runner:
282
294
 
283
295
  stream_adapter = identity_stream_adapter
284
296
 
297
+ error = None
285
298
  try:
286
299
  async for event in stream_adapter(
287
300
  source_stream=self._call_handler_streaming(
@@ -301,8 +314,6 @@ class Runner:
301
314
  e = UnknownAgentException(original_exception=e)
302
315
  error = Error(code=e.code, message=e.message)
303
316
  logger.error(f"{error.model_dump()}: {traceback.format_exc()}")
304
- yield seq_gen.yield_with_sequence(response.failed(error))
305
- return
306
317
 
307
318
  # Obtain token usage
308
319
  try:
@@ -312,4 +323,7 @@ class Runner:
312
323
  # Avoid empty message
313
324
  pass
314
325
 
315
- yield seq_gen.yield_with_sequence(response.completed())
326
+ if error:
327
+ yield seq_gen.yield_with_sequence(response.failed(error))
328
+ else:
329
+ yield seq_gen.yield_with_sequence(response.completed())
@@ -578,3 +578,27 @@ class UnknownAgentException(AgentRuntimeErrorException):
578
578
  message,
579
579
  details,
580
580
  )
581
+
582
+
583
+ class ModelQuotaExceededException(AgentRuntimeErrorException):
584
+ """Model quota exceeded"""
585
+
586
+ def __init__(
587
+ self,
588
+ model_name: str,
589
+ details: Optional[Dict[str, Any]] = None,
590
+ ):
591
+ message = f"Model quota exceeded: {model_name}"
592
+ super().__init__("MODEL_QUOTA_EXCEEDED", message, details)
593
+
594
+
595
+ class ModelContextLengthExceededException(AgentRuntimeErrorException):
596
+ """Model context length exceeded"""
597
+
598
+ def __init__(
599
+ self,
600
+ model_name: str,
601
+ details: Optional[Dict[str, Any]] = None,
602
+ ):
603
+ message = f"Model context length exceeded: {model_name}"
604
+ super().__init__("MODEL_CONTEXT_LENGTH_EXCEEDED", message, details)
@@ -21,29 +21,68 @@ class RedisStateService(StateService):
21
21
  self,
22
22
  redis_url: str = "redis://localhost:6379/0",
23
23
  redis_client: Optional[aioredis.Redis] = None,
24
+ socket_timeout: Optional[float] = 5.0,
25
+ socket_connect_timeout: Optional[float] = 5.0,
26
+ max_connections: Optional[int] = None,
27
+ retry_on_timeout: bool = True,
28
+ ttl_seconds: Optional[int] = 3600, # 1 hour in seconds
29
+ health_check_interval: Optional[float] = 30.0,
30
+ socket_keepalive: bool = True,
24
31
  ):
32
+ """
33
+ Initialize RedisStateService.
34
+
35
+ Args:
36
+ redis_url: Redis connection URL
37
+ redis_client: Optional pre-configured Redis client
38
+ socket_timeout: Socket timeout in seconds (default: 5.0)
39
+ socket_connect_timeout: Socket connect timeout in seconds
40
+ (default: 5.0)
41
+ max_connections: Maximum number of connections in the pool
42
+ (default: None)
43
+ retry_on_timeout: Whether to retry on timeout (default: True)
44
+ ttl_seconds: Time-to-live in seconds for state data. If None,
45
+ data never expires (default: 3600, i.e., 1 hour)
46
+ health_check_interval: Interval in seconds for health checks on
47
+ 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
+ """
25
53
  self._redis_url = redis_url
26
54
  self._redis = redis_client
27
- self._health = False
55
+ self._socket_timeout = socket_timeout
56
+ self._socket_connect_timeout = socket_connect_timeout
57
+ self._max_connections = max_connections
58
+ self._retry_on_timeout = retry_on_timeout
59
+ self._ttl_seconds = ttl_seconds
60
+ self._health_check_interval = health_check_interval
61
+ self._socket_keepalive = socket_keepalive
28
62
 
29
63
  async def start(self) -> None:
30
- """Initialize the Redis connection."""
64
+ """Starts the Redis connection with proper timeout and connection
65
+ pool settings."""
31
66
  if self._redis is None:
32
67
  self._redis = aioredis.from_url(
33
68
  self._redis_url,
34
69
  decode_responses=True,
70
+ socket_timeout=self._socket_timeout,
71
+ socket_connect_timeout=self._socket_connect_timeout,
72
+ max_connections=self._max_connections,
73
+ retry_on_timeout=self._retry_on_timeout,
74
+ health_check_interval=self._health_check_interval,
75
+ socket_keepalive=self._socket_keepalive,
35
76
  )
36
- self._health = True
37
77
 
38
78
  async def stop(self) -> None:
39
- """Close the Redis connection."""
79
+ """Closes the Redis connection."""
40
80
  if self._redis:
41
- await self._redis.close()
81
+ await self._redis.aclose()
42
82
  self._redis = None
43
- self._health = False
44
83
 
45
84
  async def health(self) -> bool:
46
- """Service health check."""
85
+ """Checks the health of the service."""
47
86
  if not self._redis:
48
87
  return False
49
88
  try:
@@ -81,6 +120,11 @@ class RedisStateService(StateService):
81
120
  round_id = 1
82
121
 
83
122
  await self._redis.hset(key, round_id, json.dumps(state))
123
+
124
+ # Set TTL for the state key if configured
125
+ if self._ttl_seconds is not None:
126
+ await self._redis.expire(key, self._ttl_seconds)
127
+
84
128
  return round_id
85
129
 
86
130
  async def export_state(
@@ -110,4 +154,13 @@ class RedisStateService(StateService):
110
154
 
111
155
  if state_json is None:
112
156
  return None
113
- return json.loads(state_json)
157
+
158
+ # Refresh TTL when accessing the state
159
+ if self._ttl_seconds is not None:
160
+ await self._redis.expire(key, self._ttl_seconds)
161
+
162
+ try:
163
+ return json.loads(state_json)
164
+ except json.JSONDecodeError:
165
+ # Return None for corrupted state data instead of raising exception
166
+ return None
@@ -43,13 +43,10 @@ class StateServiceFactory(ServiceFactory[StateService]):
43
43
 
44
44
  StateServiceFactory.register_backend(
45
45
  "in_memory",
46
- lambda **kwargs: InMemoryStateService(),
46
+ InMemoryStateService,
47
47
  )
48
48
 
49
49
  StateServiceFactory.register_backend(
50
50
  "redis",
51
- lambda **kwargs: RedisStateService(
52
- redis_url=kwargs.get("redis_url", "redis://localhost:6379/0"),
53
- redis_client=kwargs.get("redis_client"),
54
- ),
51
+ RedisStateService,
55
52
  )