agentscope-runtime 0.1.5b2__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. agentscope_runtime/common/__init__.py +0 -0
  2. agentscope_runtime/common/collections/in_memory_mapping.py +27 -0
  3. agentscope_runtime/common/collections/redis_mapping.py +42 -0
  4. agentscope_runtime/common/container_clients/__init__.py +0 -0
  5. agentscope_runtime/common/container_clients/agentrun_client.py +1098 -0
  6. agentscope_runtime/common/container_clients/docker_client.py +250 -0
  7. agentscope_runtime/{sandbox/manager → common}/container_clients/kubernetes_client.py +6 -13
  8. agentscope_runtime/engine/__init__.py +12 -0
  9. agentscope_runtime/engine/agents/agentscope_agent.py +567 -0
  10. agentscope_runtime/engine/agents/agno_agent.py +26 -27
  11. agentscope_runtime/engine/agents/autogen_agent.py +13 -8
  12. agentscope_runtime/engine/agents/langgraph_agent.py +52 -9
  13. agentscope_runtime/engine/agents/utils.py +53 -0
  14. agentscope_runtime/engine/app/__init__.py +6 -0
  15. agentscope_runtime/engine/app/agent_app.py +239 -0
  16. agentscope_runtime/engine/app/base_app.py +181 -0
  17. agentscope_runtime/engine/app/celery_mixin.py +92 -0
  18. agentscope_runtime/engine/deployers/adapter/responses/response_api_adapter_utils.py +5 -1
  19. agentscope_runtime/engine/deployers/base.py +1 -0
  20. agentscope_runtime/engine/deployers/cli_fc_deploy.py +39 -20
  21. agentscope_runtime/engine/deployers/kubernetes_deployer.py +12 -5
  22. agentscope_runtime/engine/deployers/local_deployer.py +61 -3
  23. agentscope_runtime/engine/deployers/modelstudio_deployer.py +201 -40
  24. agentscope_runtime/engine/deployers/utils/docker_image_utils/runner_image_factory.py +9 -0
  25. agentscope_runtime/engine/deployers/utils/package_project_utils.py +234 -3
  26. agentscope_runtime/engine/deployers/utils/service_utils/fastapi_factory.py +567 -7
  27. agentscope_runtime/engine/deployers/utils/service_utils/standalone_main.py.j2 +211 -0
  28. agentscope_runtime/engine/deployers/utils/wheel_packager.py +1 -1
  29. agentscope_runtime/engine/helpers/helper.py +60 -41
  30. agentscope_runtime/engine/runner.py +40 -24
  31. agentscope_runtime/engine/schemas/agent_schemas.py +42 -0
  32. agentscope_runtime/engine/schemas/modelstudio_llm.py +14 -14
  33. agentscope_runtime/engine/services/sandbox_service.py +62 -70
  34. agentscope_runtime/engine/services/tablestore_memory_service.py +307 -0
  35. agentscope_runtime/engine/services/tablestore_rag_service.py +143 -0
  36. agentscope_runtime/engine/services/tablestore_session_history_service.py +293 -0
  37. agentscope_runtime/engine/services/utils/__init__.py +0 -0
  38. agentscope_runtime/engine/services/utils/tablestore_service_utils.py +352 -0
  39. agentscope_runtime/engine/tracing/__init__.py +9 -3
  40. agentscope_runtime/engine/tracing/asyncio_util.py +24 -0
  41. agentscope_runtime/engine/tracing/base.py +66 -34
  42. agentscope_runtime/engine/tracing/local_logging_handler.py +45 -31
  43. agentscope_runtime/engine/tracing/message_util.py +528 -0
  44. agentscope_runtime/engine/tracing/tracing_metric.py +20 -8
  45. agentscope_runtime/engine/tracing/tracing_util.py +130 -0
  46. agentscope_runtime/engine/tracing/wrapper.py +794 -169
  47. agentscope_runtime/sandbox/__init__.py +2 -0
  48. agentscope_runtime/sandbox/box/base/__init__.py +4 -0
  49. agentscope_runtime/sandbox/box/base/base_sandbox.py +6 -4
  50. agentscope_runtime/sandbox/box/browser/__init__.py +4 -0
  51. agentscope_runtime/sandbox/box/browser/browser_sandbox.py +10 -14
  52. agentscope_runtime/sandbox/box/dummy/__init__.py +4 -0
  53. agentscope_runtime/sandbox/box/dummy/dummy_sandbox.py +2 -1
  54. agentscope_runtime/sandbox/box/filesystem/__init__.py +4 -0
  55. agentscope_runtime/sandbox/box/filesystem/filesystem_sandbox.py +10 -7
  56. agentscope_runtime/sandbox/box/gui/__init__.py +4 -0
  57. agentscope_runtime/sandbox/box/gui/box/__init__.py +0 -0
  58. agentscope_runtime/sandbox/box/gui/gui_sandbox.py +81 -0
  59. agentscope_runtime/sandbox/box/sandbox.py +5 -2
  60. agentscope_runtime/sandbox/box/shared/routers/generic.py +20 -1
  61. agentscope_runtime/sandbox/box/training_box/__init__.py +4 -0
  62. agentscope_runtime/sandbox/box/training_box/training_box.py +7 -54
  63. agentscope_runtime/sandbox/build.py +143 -58
  64. agentscope_runtime/sandbox/client/http_client.py +87 -59
  65. agentscope_runtime/sandbox/client/training_client.py +0 -1
  66. agentscope_runtime/sandbox/constant.py +27 -1
  67. agentscope_runtime/sandbox/custom/custom_sandbox.py +7 -6
  68. agentscope_runtime/sandbox/custom/example.py +4 -3
  69. agentscope_runtime/sandbox/enums.py +1 -1
  70. agentscope_runtime/sandbox/manager/sandbox_manager.py +212 -106
  71. agentscope_runtime/sandbox/manager/server/app.py +82 -14
  72. agentscope_runtime/sandbox/manager/server/config.py +50 -3
  73. agentscope_runtime/sandbox/model/container.py +12 -23
  74. agentscope_runtime/sandbox/model/manager_config.py +93 -5
  75. agentscope_runtime/sandbox/registry.py +1 -1
  76. agentscope_runtime/sandbox/tools/gui/__init__.py +7 -0
  77. agentscope_runtime/sandbox/tools/gui/tool.py +77 -0
  78. agentscope_runtime/sandbox/tools/mcp_tool.py +6 -2
  79. agentscope_runtime/sandbox/tools/tool.py +4 -0
  80. agentscope_runtime/sandbox/utils.py +124 -0
  81. agentscope_runtime/version.py +1 -1
  82. {agentscope_runtime-0.1.5b2.dist-info → agentscope_runtime-0.2.0.dist-info}/METADATA +246 -111
  83. {agentscope_runtime-0.1.5b2.dist-info → agentscope_runtime-0.2.0.dist-info}/RECORD +96 -80
  84. agentscope_runtime/engine/agents/agentscope_agent/__init__.py +0 -6
  85. agentscope_runtime/engine/agents/agentscope_agent/agent.py +0 -401
  86. agentscope_runtime/engine/agents/agentscope_agent/hooks.py +0 -169
  87. agentscope_runtime/engine/agents/llm_agent.py +0 -51
  88. agentscope_runtime/engine/llms/__init__.py +0 -3
  89. agentscope_runtime/engine/llms/base_llm.py +0 -60
  90. agentscope_runtime/engine/llms/qwen_llm.py +0 -47
  91. agentscope_runtime/sandbox/manager/collections/in_memory_mapping.py +0 -22
  92. agentscope_runtime/sandbox/manager/collections/redis_mapping.py +0 -26
  93. agentscope_runtime/sandbox/manager/container_clients/__init__.py +0 -10
  94. agentscope_runtime/sandbox/manager/container_clients/docker_client.py +0 -422
  95. /agentscope_runtime/{sandbox/manager → common}/collections/__init__.py +0 -0
  96. /agentscope_runtime/{sandbox/manager → common}/collections/base_mapping.py +0 -0
  97. /agentscope_runtime/{sandbox/manager → common}/collections/base_queue.py +0 -0
  98. /agentscope_runtime/{sandbox/manager → common}/collections/base_set.py +0 -0
  99. /agentscope_runtime/{sandbox/manager → common}/collections/in_memory_queue.py +0 -0
  100. /agentscope_runtime/{sandbox/manager → common}/collections/in_memory_set.py +0 -0
  101. /agentscope_runtime/{sandbox/manager → common}/collections/redis_queue.py +0 -0
  102. /agentscope_runtime/{sandbox/manager → common}/collections/redis_set.py +0 -0
  103. /agentscope_runtime/{sandbox/manager → common}/container_clients/base_client.py +0 -0
  104. {agentscope_runtime-0.1.5b2.dist-info → agentscope_runtime-0.2.0.dist-info}/WHEEL +0 -0
  105. {agentscope_runtime-0.1.5b2.dist-info → agentscope_runtime-0.2.0.dist-info}/entry_points.txt +0 -0
  106. {agentscope_runtime-0.1.5b2.dist-info → agentscope_runtime-0.2.0.dist-info}/licenses/LICENSE +0 -0
  107. {agentscope_runtime-0.1.5b2.dist-info → agentscope_runtime-0.2.0.dist-info}/top_level.txt +0 -0
@@ -1,47 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- import os
3
-
4
- from openai import Client, AsyncClient
5
-
6
- from .base_llm import BaseLLM
7
-
8
-
9
- class QwenLLM(BaseLLM):
10
- """
11
- QwenLLM is a class that provides a wrapper around the Qwen LLM model.
12
- """
13
-
14
- base_url = None
15
-
16
- def __init__(
17
- self,
18
- model_name: str = "qwen-turbo",
19
- api_key: str = None,
20
- **kwargs,
21
- ):
22
- """
23
- Initialize the QwenLLM class.
24
-
25
- Args:
26
- model_name (str): The name of the Qwen LLM model to use.
27
- Defaults to "qwen-turbo".
28
- api_key (str): The API key for Qwen service.
29
- If None, will read from DASHSCOPE_API_KEY environment variable.
30
- """
31
- super().__init__(model_name, **kwargs)
32
-
33
- if api_key is None:
34
- api_key = os.getenv("DASHSCOPE_API_KEY")
35
- if self.base_url is None:
36
- default_base_url = (
37
- "https://dashscope.aliyuncs.com/compatible-mode/v1"
38
- )
39
- self.base_url = os.getenv("DASHSCOPE_BASE_URL", default_base_url)
40
- self.client = Client(
41
- api_key=api_key,
42
- base_url=self.base_url,
43
- )
44
- self.async_client = AsyncClient(
45
- api_key=api_key,
46
- base_url=self.base_url,
47
- )
@@ -1,22 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- from .base_mapping import Mapping
3
-
4
-
5
- class InMemoryMapping(Mapping):
6
- def __init__(self):
7
- self.store = {}
8
-
9
- def set(self, key: str, value: dict):
10
- self.store[key] = value
11
-
12
- def get(self, key: str) -> dict:
13
- return self.store.get(key)
14
-
15
- def delete(self, key: str):
16
- if key in self.store:
17
- del self.store[key]
18
-
19
- def scan(self, prefix: str):
20
- for key in list(self.store.keys()):
21
- if key.startswith(prefix):
22
- yield key
@@ -1,26 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- import json
3
-
4
- from .base_mapping import Mapping
5
-
6
-
7
- class RedisMapping(Mapping):
8
- def __init__(self, redis_client):
9
- self.client = redis_client
10
-
11
- def set(self, key: str, value: dict):
12
- self.client.set(key, json.dumps(value))
13
-
14
- def get(self, key: str) -> dict:
15
- value = self.client.get(key)
16
- return json.loads(value) if value else None
17
-
18
- def delete(self, key: str):
19
- self.client.delete(key)
20
-
21
- def scan(self, prefix: str):
22
- cursor = 0
23
- while cursor != 0:
24
- cursor, keys = self.client.scan(cursor=cursor, match=f"{prefix}*")
25
- for key in keys:
26
- yield key.decode("utf-8")
@@ -1,10 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- from .base_client import BaseClient
3
- from .docker_client import DockerClient
4
- from .kubernetes_client import KubernetesClient
5
-
6
- __all__ = [
7
- "BaseClient",
8
- "DockerClient",
9
- "KubernetesClient",
10
- ]
@@ -1,422 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- import traceback
3
- import logging
4
- import platform
5
- import socket
6
- import subprocess
7
-
8
- import docker
9
-
10
- from .base_client import BaseClient
11
- from ..collections import RedisSetCollection, InMemorySetCollection
12
-
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
-
17
- def is_port_available(port):
18
- """
19
- Check if a given port is available (not in use) on the local system.
20
-
21
- Args:
22
- port (int): The port number to check.
23
-
24
- Returns:
25
- bool: True if the port is available, False if it is in use.
26
- """
27
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
28
- try:
29
- s.bind(("", port))
30
- # Port is available
31
- return True
32
- except OSError:
33
- # Port is in use
34
- return False
35
-
36
-
37
- def sweep_port(port):
38
- """Sweep all processes found listening on a given port.
39
-
40
- Args:
41
- port (int): The port number.
42
-
43
- Returns:
44
- bool: True if successful, False if failed.
45
- """
46
- try:
47
- system = platform.system().lower()
48
- if system == "windows":
49
- return _sweep_port_windows(port)
50
- else:
51
- return _sweep_port_unix(port)
52
- except Exception as e:
53
- logger.error(
54
- f"An error occurred while killing processes on port {port}: {e}",
55
- )
56
- return False
57
-
58
-
59
- def _sweep_port_unix(port):
60
- """
61
- Sweep all processes found listening on a given port.
62
-
63
- Args:
64
- port (int): The port number.
65
-
66
- Returns:
67
- int: Number of processes swept (terminated).
68
- """
69
- try:
70
- # Use lsof to find the processes using the port
71
- # TODO: support windows
72
- result = subprocess.run(
73
- ["lsof", "-i", f":{port}"],
74
- capture_output=True,
75
- text=True,
76
- check=True,
77
- )
78
-
79
- # Parse the output
80
- lines = result.stdout.strip().split("\n")
81
- if len(lines) <= 1:
82
- # No process is using the port
83
- return True
84
-
85
- # Iterate over each line (excluding the header) and kill each process
86
- killed_count = 0
87
- for line in lines[1:]:
88
- parts = line.split()
89
- if len(parts) > 1:
90
- pid = parts[1]
91
-
92
- # Kill the process using the PID
93
- subprocess.run(["kill", "-9", pid], check=False)
94
- killed_count += 1
95
-
96
- if not is_port_available(port):
97
- logger.warning(
98
- f"Port {port} is still in use after killing processes.",
99
- )
100
-
101
- return True
102
-
103
- except Exception as e:
104
- logger.error(
105
- f"An error occurred while killing processes on port {port}: {e}",
106
- )
107
- return False
108
-
109
-
110
- def _sweep_port_windows(port):
111
- """
112
- Windows implementation using netstat and taskkill
113
- """
114
- try:
115
- # Use netstat to find the processes using the port
116
- result = subprocess.run(
117
- ["netstat", "-ano"],
118
- capture_output=True,
119
- text=True,
120
- check=True,
121
- )
122
-
123
- # Parse the output to find processes using the specific port
124
- lines = result.stdout.strip().split("\n")
125
- pids_to_kill = set()
126
-
127
- for line in lines:
128
- if f":{port}" in line and "LISTENING" in line:
129
- parts = line.split()
130
- if len(parts) >= 5:
131
- pid = parts[-1] # PID is usually the last column
132
- if pid.isdigit(): # Ensure it's a valid PID
133
- pids_to_kill.add(pid)
134
-
135
- if not pids_to_kill:
136
- return True
137
-
138
- # Kill the processes
139
- killed_count = 0
140
- for pid in pids_to_kill:
141
- try:
142
- result = subprocess.run(
143
- ["taskkill", "/PID", pid, "/F"],
144
- capture_output=True,
145
- text=True,
146
- check=False,
147
- )
148
- if result.returncode == 0:
149
- killed_count += 1
150
- except Exception as e:
151
- logger.debug(f"Failed to kill process {pid}: {e}")
152
- continue
153
-
154
- if not is_port_available(port):
155
- logger.warning(
156
- f"Port {port} is still in use after killing processes.",
157
- )
158
-
159
- return True
160
-
161
- except subprocess.CalledProcessError as e:
162
- logger.error(f"netstat command failed: {e}")
163
- return False
164
- except Exception as e:
165
- logger.error(f"Error in Windows port sweep: {e}")
166
- return False
167
-
168
-
169
- class DockerClient(BaseClient):
170
- def __init__(self, config=None):
171
- self.config = config
172
- self.port_range = range(*self.config.port_range)
173
-
174
- if self.config.redis_enabled:
175
- import redis
176
-
177
- redis_client = redis.Redis(
178
- host=self.config.redis_server,
179
- port=self.config.redis_port,
180
- db=self.config.redis_db,
181
- username=self.config.redis_user,
182
- password=self.config.redis_password,
183
- decode_responses=True,
184
- )
185
- try:
186
- redis_client.ping()
187
- except ConnectionError as e:
188
- raise RuntimeError(
189
- "Unable to connect to the Redis server.",
190
- ) from e
191
-
192
- self.port_set = RedisSetCollection(
193
- redis_client,
194
- set_name=self.config.redis_port_key,
195
- )
196
- else:
197
- self.port_set = InMemorySetCollection()
198
-
199
- try:
200
- self.client = docker.from_env()
201
- except Exception as e:
202
- raise RuntimeError(
203
- f"Docker client initialization failed: {str(e)}\n"
204
- "Solutions:\n"
205
- "• Ensure Docker is running\n"
206
- "• Check Docker permissions\n"
207
- "• For Colima: "
208
- "export DOCKER_HOST=unix://$HOME/.colima/docker.sock",
209
- ) from e
210
-
211
- def _try_pull_from_acr(self, image):
212
- """
213
- Attempt to pull the image from the Alibaba Cloud Container Registry
214
- (ACR) and retag it.
215
- """
216
- try:
217
- acr_registry = "agentscope-registry.ap-southeast-1.cr.aliyuncs.com"
218
- acr_image = f"{acr_registry}/{image}"
219
-
220
- logger.info(
221
- f"Attempting to pull from ACR: {acr_image}, it might take "
222
- f"several minutes.",
223
- )
224
- self.client.images.pull(acr_image)
225
- logger.info(f"Successfully pulled image from ACR: {acr_image}")
226
-
227
- # Retag the image
228
- acr_img_obj = self.client.images.get(acr_image)
229
- acr_img_obj.tag(image)
230
- logger.debug(f"Successfully tagged image as: {image}")
231
-
232
- # Optionally remove the original tag to save space
233
- try:
234
- self.client.images.remove(acr_image)
235
- logger.debug(f"Removed original tag: {acr_image}")
236
- except Exception as e:
237
- logger.debug(f"Failed to remove original tag: {e}")
238
- return True
239
- except Exception as e:
240
- logger.error(
241
- f"Failed to pull from ACR: {e}, {traceback.format_exc()}",
242
- )
243
- return False
244
-
245
- def create(
246
- self,
247
- image,
248
- name=None,
249
- ports=None,
250
- volumes=None,
251
- environment=None,
252
- runtime_config=None,
253
- ):
254
- """Create a new Docker container."""
255
- if runtime_config is None:
256
- runtime_config = {}
257
-
258
- port_mapping = {}
259
-
260
- if ports:
261
- free_port = self._find_free_ports(len(ports))
262
- for container_port, host_port in zip(ports, free_port):
263
- port_mapping[container_port] = host_port
264
-
265
- try:
266
- try:
267
- # Check if the image exists locally
268
- self.client.images.get(image)
269
- logger.debug(f"Image '{image}' found locally.")
270
- except docker.errors.ImageNotFound:
271
- logger.info(
272
- f"Image '{image}' not found locally. "
273
- f"Attempting to pull it...",
274
- )
275
- try:
276
- logger.info(
277
- f"Attempting to pull: {image}, "
278
- f"it might take several minutes.",
279
- )
280
- self.client.images.pull(image)
281
- logger.debug(
282
- f"Image '{image}' successfully pulled from default "
283
- f"registry.",
284
- )
285
- pull_success = True
286
- except docker.errors.APIError as e:
287
- logger.warning(
288
- f"Failed to pull from default registry: {e}",
289
- )
290
- logger.warning("Trying to pull from ACR fallback...")
291
-
292
- pull_success = self._try_pull_from_acr(image)
293
-
294
- if not pull_success:
295
- logger.error(
296
- f"Failed to pull image '{image}' from both "
297
- f"default and ACR",
298
- )
299
- return None, None, None
300
-
301
- except docker.errors.APIError as e:
302
- logger.error(f"Error occurred while checking the image: {e}")
303
- return None, None, None
304
-
305
- # Create and run the container
306
- container = self.client.containers.run(
307
- image,
308
- detach=True,
309
- ports=port_mapping,
310
- name=name,
311
- volumes=volumes,
312
- environment=environment,
313
- **runtime_config,
314
- )
315
- container.reload()
316
- _id = container.id
317
- return _id, list(port_mapping.values()), "localhost"
318
- except Exception as e:
319
- logger.error(f"An error occurred: {e}, {traceback.format_exc()}")
320
- return None, None, None
321
-
322
- def start(self, container_id):
323
- """Start a Docker container."""
324
- try:
325
- container = self.client.containers.get(
326
- container_id,
327
- )
328
-
329
- # Check whether the ports are occupied by other processes
330
- port_mapping = container.attrs["NetworkSettings"]["Ports"]
331
- for _, mappings in port_mapping.items():
332
- if mappings is not None:
333
- for mapping in mappings:
334
- host_port = int(mapping["HostPort"])
335
- if is_port_available(host_port):
336
- continue
337
- sweep_port(host_port["HostPort"])
338
-
339
- container.start()
340
- return True
341
- except Exception as e:
342
- logger.error(f"An error occurred: {e}, {traceback.format_exc()}")
343
- return False
344
-
345
- def stop(self, container_id, timeout=None):
346
- """Stop a Docker container."""
347
- try:
348
- container = self.client.containers.get(
349
- container_id,
350
- )
351
- container.stop(timeout=timeout)
352
- return True
353
- except Exception as e:
354
- logger.error(f"An error occurred: {e}, {traceback.format_exc()}")
355
- return False
356
-
357
- def remove(self, container_id, force=False):
358
- """Remove a Docker container."""
359
- try:
360
- container = self.client.containers.get(
361
- container_id,
362
- )
363
- # Remove ports
364
- port_mapping = container.attrs["NetworkSettings"]["Ports"]
365
- container.remove(force=force)
366
-
367
- # Iterate over each port and its mappings
368
- for _, mappings in port_mapping.items():
369
- if mappings is not None:
370
- for mapping in mappings:
371
- host_port = int(mapping["HostPort"])
372
- self.port_set.remove(host_port)
373
-
374
- return True
375
- except Exception as e:
376
- logger.error(f"An error occurred: {e}, {traceback.format_exc()}")
377
- return False
378
-
379
- def inspect(self, container_id):
380
- """Inspect a Docker container."""
381
- try:
382
- # Get the container object
383
- container = self.client.containers.get(container_id)
384
- # Access the detailed information
385
- return container.attrs
386
- except Exception:
387
- return None
388
-
389
- def get_status(self, container_id):
390
- """Get the current status of the specified container."""
391
- container_attrs = self.inspect(container_id=container_id)
392
- if container_attrs:
393
- return container_attrs["State"]["Status"]
394
- return None
395
-
396
- def _find_free_ports(self, n):
397
- free_ports = []
398
-
399
- for port in self.port_range:
400
- if len(free_ports) >= n:
401
- break # We have found enough ports
402
-
403
- if not self.port_set.add(port):
404
- continue
405
-
406
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
407
- try:
408
- s.bind(("", port))
409
- free_ports.append(port) # Port is available
410
-
411
- except OSError:
412
- # Bind failed, port is in use
413
- self.port_set.remove(port)
414
- # Try the next one
415
- continue
416
-
417
- if len(free_ports) < n:
418
- raise RuntimeError(
419
- "Not enough free ports available in the specified range.",
420
- )
421
-
422
- return free_ports