agentlink-sdk 1.0.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.
- agentlink_sdk/__init__.py +87 -0
- agentlink_sdk/browser.py +617 -0
- agentlink_sdk/client.py +672 -0
- agentlink_sdk/config.py +164 -0
- agentlink_sdk/desktop.py +559 -0
- agentlink_sdk/exceptions.py +86 -0
- agentlink_sdk/logger.py +157 -0
- agentlink_sdk/models/__init__.py +29 -0
- agentlink_sdk/models/results.py +297 -0
- agentlink_sdk/models/sandbox.py +121 -0
- agentlink_sdk/transport/__init__.py +9 -0
- agentlink_sdk/transport/mcp.py +503 -0
- agentlink_sdk-1.0.0.dist-info/METADATA +106 -0
- agentlink_sdk-1.0.0.dist-info/RECORD +15 -0
- agentlink_sdk-1.0.0.dist-info/WHEEL +4 -0
agentlink_sdk/client.py
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentLink SDK Main Client
|
|
3
|
+
|
|
4
|
+
Provides a high-level, Pythonic API for interacting with the
|
|
5
|
+
China Mobile Cloud AgentLink MCP Gateway.
|
|
6
|
+
|
|
7
|
+
Supports:
|
|
8
|
+
- Sandbox lifecycle management (create, destroy, list, reuse)
|
|
9
|
+
- Browser automation (navigate, click, type, screenshot, evaluate)
|
|
10
|
+
- Windows desktop control (click, type, PowerShell, screenshot)
|
|
11
|
+
- Generic MCP tool invocation
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import warnings
|
|
18
|
+
from typing import Any, Dict, List, Optional, Union
|
|
19
|
+
|
|
20
|
+
from .browser import BrowserController
|
|
21
|
+
from .config import AgentLinkConfig, DEFAULT_MCP_URL
|
|
22
|
+
from .desktop import DesktopController
|
|
23
|
+
from .exceptions import (
|
|
24
|
+
ConfigurationError,
|
|
25
|
+
SandboxError,
|
|
26
|
+
)
|
|
27
|
+
from .logger import get_logger, _log_operation_start, _log_operation_success, _log_operation_error
|
|
28
|
+
from .models.results import SandboxResult, ToolResult
|
|
29
|
+
from .models.sandbox import IMAGE_BROWSER, IMAGE_WINDOWS, SandboxSession, SandboxType
|
|
30
|
+
from .transport.mcp import McpTransport
|
|
31
|
+
|
|
32
|
+
logger = get_logger("client")
|
|
33
|
+
|
|
34
|
+
# Default sandbox readiness wait time (seconds)
|
|
35
|
+
DEFAULT_SANDBOX_READY_WAIT = 3
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AgentLink:
|
|
39
|
+
"""
|
|
40
|
+
Main client for the AgentLink MCP Gateway.
|
|
41
|
+
|
|
42
|
+
Provides a high-level API for cloud sandbox management and automation.
|
|
43
|
+
Supports browser automation, Windows desktop control, and generic
|
|
44
|
+
MCP tool invocation.
|
|
45
|
+
|
|
46
|
+
Features:
|
|
47
|
+
- Automatic sandbox creation and caching (reuse across calls)
|
|
48
|
+
- Thread-safe sandbox cache
|
|
49
|
+
- MCP Streamable HTTP protocol (JSON-RPC 2.0)
|
|
50
|
+
- Retry logic and timeout handling
|
|
51
|
+
- Convenience wrappers for browser and Windows operations
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
```python
|
|
55
|
+
from agentlink_sdk import AgentLink
|
|
56
|
+
|
|
57
|
+
al = AgentLink(api_key="your-api-key")
|
|
58
|
+
|
|
59
|
+
# Browser automation
|
|
60
|
+
al.browser.go("https://www.baidu.com")
|
|
61
|
+
al.browser.capture().save("page.png")
|
|
62
|
+
|
|
63
|
+
# Desktop control
|
|
64
|
+
al.desktop.shell("Get-Date")
|
|
65
|
+
al.desktop.capture().save("desktop.png")
|
|
66
|
+
|
|
67
|
+
# Clean up
|
|
68
|
+
al.cleanup()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Environment Variables:
|
|
72
|
+
AGENTLINK_API_KEY: API key for authentication.
|
|
73
|
+
AGENTLINK_MCP_URL: MCP gateway endpoint URL (optional).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
api_key: Optional[str] = None,
|
|
79
|
+
mcp_url: Optional[str] = None,
|
|
80
|
+
config: Optional[AgentLinkConfig] = None,
|
|
81
|
+
request_timeout: Union[int, float] = 30,
|
|
82
|
+
max_retries: int = 2,
|
|
83
|
+
sandbox_id: Optional[str] = None,
|
|
84
|
+
sandbox_type: Union[str, SandboxType] = SandboxType.BROWSER,
|
|
85
|
+
):
|
|
86
|
+
"""
|
|
87
|
+
Initialize the AgentLink client.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
api_key: API key for authentication. Can also be set via
|
|
91
|
+
AGENTLINK_API_KEY environment variable.
|
|
92
|
+
mcp_url: MCP gateway URL. Defaults to the AgentLink cloud endpoint.
|
|
93
|
+
Can also be set via AGENTLINK_MCP_URL environment variable.
|
|
94
|
+
config: Optional pre-built configuration object. If provided,
|
|
95
|
+
api_key and mcp_url are ignored.
|
|
96
|
+
request_timeout: Timeout for MCP tool calls in seconds (default: 30).
|
|
97
|
+
max_retries: Maximum number of retries for failed operations (default: 2).
|
|
98
|
+
sandbox_id: Optional existing sandbox ID to reuse. If provided,
|
|
99
|
+
the client will use this sandbox instead of creating
|
|
100
|
+
a new one on first operation.
|
|
101
|
+
sandbox_type: Type of the sandbox (default: browser). Used together
|
|
102
|
+
with sandbox_id to register the correct sandbox type.
|
|
103
|
+
"""
|
|
104
|
+
if config is not None:
|
|
105
|
+
self._config = config
|
|
106
|
+
else:
|
|
107
|
+
resolved_key = api_key or os.environ.get("AGENTLINK_API_KEY")
|
|
108
|
+
resolved_url = mcp_url or os.environ.get("AGENTLINK_MCP_URL", DEFAULT_MCP_URL)
|
|
109
|
+
|
|
110
|
+
if not resolved_key:
|
|
111
|
+
raise ConfigurationError(
|
|
112
|
+
"API key is required. Pass api_key parameter or set "
|
|
113
|
+
"AGENTLINK_API_KEY environment variable."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
self._config = AgentLinkConfig(
|
|
117
|
+
api_key=resolved_key,
|
|
118
|
+
mcp_url=resolved_url,
|
|
119
|
+
request_timeout=request_timeout,
|
|
120
|
+
max_retries=max_retries,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
self._transport = McpTransport(self._config)
|
|
124
|
+
self._sandbox_cache: Dict[str, List[SandboxSession]] = {}
|
|
125
|
+
self._lock = threading.Lock()
|
|
126
|
+
# Per-image_id locks for fine-grained locking during sandbox creation
|
|
127
|
+
self._image_locks: Dict[str, threading.Lock] = {}
|
|
128
|
+
self._image_locks_lock = threading.Lock() # Protects _image_locks dict
|
|
129
|
+
|
|
130
|
+
# Pre-populate sandbox cache if sandbox_id is provided
|
|
131
|
+
if sandbox_id:
|
|
132
|
+
if isinstance(sandbox_type, str):
|
|
133
|
+
sandbox_type = SandboxType(sandbox_type)
|
|
134
|
+
image_id = sandbox_type.get_image_id()
|
|
135
|
+
session = SandboxSession(
|
|
136
|
+
sandbox_id=sandbox_id,
|
|
137
|
+
image_id=image_id,
|
|
138
|
+
created_at=time.time(),
|
|
139
|
+
)
|
|
140
|
+
self._sandbox_cache.setdefault(image_id, []).append(session)
|
|
141
|
+
logger.info("Reusing existing sandbox: %s (image=%s)", sandbox_id, image_id)
|
|
142
|
+
|
|
143
|
+
# Lazy-initialized controller namespaces
|
|
144
|
+
self._browser: Optional[BrowserController] = None
|
|
145
|
+
self._desktop: Optional[DesktopController] = None
|
|
146
|
+
|
|
147
|
+
# Stores the exception from __exit__ cleanup (if any), so callers
|
|
148
|
+
# can inspect it after a ``with`` block to detect sandbox leaks.
|
|
149
|
+
self.cleanup_error: Optional[Exception] = None
|
|
150
|
+
|
|
151
|
+
# Flag to track whether the client has been closed (for idempotency)
|
|
152
|
+
self._closed: bool = False
|
|
153
|
+
|
|
154
|
+
# ---- Controller properties ----
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def browser(self) -> BrowserController:
|
|
158
|
+
"""
|
|
159
|
+
Browser automation controller.
|
|
160
|
+
|
|
161
|
+
Returns a :class:`BrowserController` for browser sandbox interactions.
|
|
162
|
+
Automatically creates / reuses a browser sandbox as needed.
|
|
163
|
+
|
|
164
|
+
Example::
|
|
165
|
+
|
|
166
|
+
al.browser.go("https://example.com")
|
|
167
|
+
al.browser.capture().save("screenshot.png")
|
|
168
|
+
"""
|
|
169
|
+
if self._browser is None:
|
|
170
|
+
self._browser = BrowserController(self, IMAGE_BROWSER)
|
|
171
|
+
return self._browser
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def desktop(self) -> DesktopController:
|
|
175
|
+
"""
|
|
176
|
+
Windows desktop automation controller.
|
|
177
|
+
|
|
178
|
+
Returns a :class:`DesktopController` for Windows sandbox interactions.
|
|
179
|
+
Automatically creates / reuses a Windows sandbox as needed.
|
|
180
|
+
|
|
181
|
+
Example::
|
|
182
|
+
|
|
183
|
+
al.desktop.shell("Get-Date")
|
|
184
|
+
al.desktop.capture().save("desktop.png")
|
|
185
|
+
"""
|
|
186
|
+
if self._desktop is None:
|
|
187
|
+
self._desktop = DesktopController(self, IMAGE_WINDOWS)
|
|
188
|
+
return self._desktop
|
|
189
|
+
|
|
190
|
+
# ---- Sandbox Lifecycle ----
|
|
191
|
+
|
|
192
|
+
def create(
|
|
193
|
+
self,
|
|
194
|
+
sandbox_type: Union[str, SandboxType] = SandboxType.BROWSER,
|
|
195
|
+
image_id: Optional[str] = None,
|
|
196
|
+
wait: int = DEFAULT_SANDBOX_READY_WAIT,
|
|
197
|
+
) -> SandboxResult:
|
|
198
|
+
"""
|
|
199
|
+
Create a new sandbox session.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
sandbox_type: Type of sandbox to create ("browser" or "windows").
|
|
203
|
+
Ignored if image_id is provided.
|
|
204
|
+
image_id: Explicit image ID. Overrides sandbox_type if provided.
|
|
205
|
+
wait: Seconds to wait for sandbox readiness (default: 3).
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
SandboxResult: Result containing the sandbox ID.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
SandboxError: If sandbox creation fails.
|
|
212
|
+
"""
|
|
213
|
+
if image_id is None:
|
|
214
|
+
if isinstance(sandbox_type, str):
|
|
215
|
+
sandbox_type = SandboxType(sandbox_type)
|
|
216
|
+
image_id = sandbox_type.get_image_id()
|
|
217
|
+
|
|
218
|
+
_log_operation_start("Creating sandbox", f"image={image_id}")
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
result = self._transport.call_tool(image_id, "create_sandbox", {})
|
|
222
|
+
|
|
223
|
+
if not result.success:
|
|
224
|
+
detail = result.error or "Unknown error"
|
|
225
|
+
# Include data payload in error log if present (may contain
|
|
226
|
+
# server-side details such as stack trace or error codes)
|
|
227
|
+
if result.data is not None:
|
|
228
|
+
logger.error(
|
|
229
|
+
"Create sandbox failed: %s | data=%s | content=%s",
|
|
230
|
+
detail, result.data, result.content,
|
|
231
|
+
)
|
|
232
|
+
_log_operation_error("Create sandbox", detail)
|
|
233
|
+
return SandboxResult(success=False, error=detail)
|
|
234
|
+
|
|
235
|
+
# Extract sandbox ID
|
|
236
|
+
sandbox_id: Optional[str] = None
|
|
237
|
+
if isinstance(result.data, dict):
|
|
238
|
+
sandbox_id = str(result.data.get("resourceId", result.data.get("sandboxId", "")))
|
|
239
|
+
elif isinstance(result.data, str):
|
|
240
|
+
sandbox_id = result.data.strip()
|
|
241
|
+
else:
|
|
242
|
+
sandbox_id = result.text.strip()
|
|
243
|
+
|
|
244
|
+
if not sandbox_id:
|
|
245
|
+
_log_operation_error("Create sandbox", "No sandbox ID in response")
|
|
246
|
+
return SandboxResult(success=False, error="No sandbox ID in response")
|
|
247
|
+
|
|
248
|
+
# Wait for readiness
|
|
249
|
+
if wait > 0:
|
|
250
|
+
logger.info("Waiting %ds for sandbox readiness...", wait)
|
|
251
|
+
time.sleep(wait)
|
|
252
|
+
|
|
253
|
+
# Cache the session
|
|
254
|
+
session = SandboxSession(
|
|
255
|
+
sandbox_id=sandbox_id,
|
|
256
|
+
image_id=image_id,
|
|
257
|
+
created_at=time.time(),
|
|
258
|
+
)
|
|
259
|
+
with self._lock:
|
|
260
|
+
self._sandbox_cache.setdefault(image_id, []).append(session)
|
|
261
|
+
|
|
262
|
+
_log_operation_success("Sandbox created", f"id={sandbox_id}")
|
|
263
|
+
return SandboxResult(
|
|
264
|
+
sandbox_id=sandbox_id,
|
|
265
|
+
success=True,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
_log_operation_error("Create sandbox", str(e))
|
|
270
|
+
raise SandboxError(f"Failed to create sandbox: {e}", image_id=image_id) from e
|
|
271
|
+
|
|
272
|
+
def delete(
|
|
273
|
+
self,
|
|
274
|
+
sandbox_id: Optional[str] = None,
|
|
275
|
+
image_id: Optional[str] = None,
|
|
276
|
+
) -> bool:
|
|
277
|
+
"""
|
|
278
|
+
Delete/destroy a sandbox session.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
sandbox_id: The sandbox ID to destroy. If not provided,
|
|
282
|
+
destroys the last cached sandbox for the given image_id.
|
|
283
|
+
image_id: The image ID associated with the sandbox.
|
|
284
|
+
Required if sandbox_id is not provided.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
bool: True if successfully destroyed, False otherwise.
|
|
288
|
+
"""
|
|
289
|
+
# If no sandbox_id, try to get from cache using image_id
|
|
290
|
+
if sandbox_id is None:
|
|
291
|
+
if image_id is None:
|
|
292
|
+
logger.warning("Either sandbox_id or image_id must be provided")
|
|
293
|
+
return False
|
|
294
|
+
with self._lock:
|
|
295
|
+
sessions = self._sandbox_cache.get(image_id, [])
|
|
296
|
+
session = sessions.pop() if sessions else None
|
|
297
|
+
if not sessions:
|
|
298
|
+
self._sandbox_cache.pop(image_id, None)
|
|
299
|
+
if session:
|
|
300
|
+
sandbox_id = session.sandbox_id
|
|
301
|
+
else:
|
|
302
|
+
logger.warning("No sandbox ID provided and no cached sandbox found for %s", image_id)
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
# If image_id is not provided, try to find it from cache by sandbox_id
|
|
306
|
+
if image_id is None:
|
|
307
|
+
with self._lock:
|
|
308
|
+
for img_id, sessions in self._sandbox_cache.items():
|
|
309
|
+
for s in sessions:
|
|
310
|
+
if s.sandbox_id == sandbox_id:
|
|
311
|
+
image_id = img_id
|
|
312
|
+
break
|
|
313
|
+
if image_id is not None:
|
|
314
|
+
break
|
|
315
|
+
if image_id is None:
|
|
316
|
+
logger.warning(
|
|
317
|
+
"Cannot determine image_id for sandbox %s. "
|
|
318
|
+
"Please provide image_id explicitly.",
|
|
319
|
+
sandbox_id,
|
|
320
|
+
)
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
_log_operation_start("Destroying sandbox", f"id={sandbox_id}")
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
result = self._transport.call_tool(
|
|
327
|
+
image_id, "destroy_sandbox", {"sandboxId": sandbox_id}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Remove from cache
|
|
331
|
+
with self._lock:
|
|
332
|
+
sessions = self._sandbox_cache.get(image_id, [])
|
|
333
|
+
self._sandbox_cache[image_id] = [
|
|
334
|
+
s for s in sessions if s.sandbox_id != sandbox_id
|
|
335
|
+
]
|
|
336
|
+
if not self._sandbox_cache[image_id]:
|
|
337
|
+
self._sandbox_cache.pop(image_id, None)
|
|
338
|
+
|
|
339
|
+
if result.success:
|
|
340
|
+
_log_operation_success("Sandbox destroyed", f"id={sandbox_id}")
|
|
341
|
+
else:
|
|
342
|
+
_log_operation_error("Destroy sandbox", result.error or "Unknown error")
|
|
343
|
+
|
|
344
|
+
return result.success
|
|
345
|
+
|
|
346
|
+
except Exception as e:
|
|
347
|
+
_log_operation_error("Destroy sandbox", str(e))
|
|
348
|
+
# Still remove from cache to avoid stale references
|
|
349
|
+
with self._lock:
|
|
350
|
+
sessions = self._sandbox_cache.get(image_id, [])
|
|
351
|
+
self._sandbox_cache[image_id] = [
|
|
352
|
+
s for s in sessions if s.sandbox_id != sandbox_id
|
|
353
|
+
]
|
|
354
|
+
if not self._sandbox_cache.get(image_id):
|
|
355
|
+
self._sandbox_cache.pop(image_id, None)
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
def _fetch_resource_url(self, sandbox_id: str, image_id: str) -> Optional[str]:
|
|
359
|
+
"""Fetch the preview URL for a sandbox session (no caching).
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
sandbox_id: The sandbox session ID.
|
|
363
|
+
image_id: The image ID to use for the request.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
str: Preview URL, or None if unavailable.
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
result = self._transport.call_tool(
|
|
370
|
+
image_id, "get_sandbox_url", {"sandboxId": sandbox_id}
|
|
371
|
+
)
|
|
372
|
+
if result.success:
|
|
373
|
+
url = result.text.strip() or (
|
|
374
|
+
result.data.get("resourceUrl") if isinstance(result.data, dict) else None
|
|
375
|
+
)
|
|
376
|
+
return url
|
|
377
|
+
return None
|
|
378
|
+
except Exception as e:
|
|
379
|
+
logger.warning("Failed to fetch resource URL: %s", e)
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
def get_url(self, sandbox_id: str, image_id: Optional[str] = None) -> Optional[str]:
|
|
383
|
+
"""
|
|
384
|
+
Get the preview URL for a sandbox.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
sandbox_id: The sandbox session ID.
|
|
388
|
+
image_id: The image ID. If not provided, the method will
|
|
389
|
+
look up the image ID from the sandbox cache.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
str: Preview URL, or None if not available.
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
ValueError: If *image_id* is not provided and *sandbox_id*
|
|
396
|
+
is not found in the sandbox cache.
|
|
397
|
+
"""
|
|
398
|
+
# Auto-detect image_id from sandbox cache if not provided
|
|
399
|
+
if image_id is None:
|
|
400
|
+
with self._lock:
|
|
401
|
+
for img_id, sessions in self._sandbox_cache.items():
|
|
402
|
+
for s in sessions:
|
|
403
|
+
if s.sandbox_id == sandbox_id:
|
|
404
|
+
image_id = img_id
|
|
405
|
+
break
|
|
406
|
+
if image_id is not None:
|
|
407
|
+
break
|
|
408
|
+
if image_id is None:
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"image_id not provided and sandbox '{sandbox_id}' not found "
|
|
411
|
+
f"in cache; please pass image_id explicitly"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return self._fetch_resource_url(sandbox_id, image_id)
|
|
415
|
+
|
|
416
|
+
def list_sessions(self) -> List[SandboxSession]:
|
|
417
|
+
"""
|
|
418
|
+
List all currently cached (active) sandbox sessions.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
List[SandboxSession]: List of active sessions.
|
|
422
|
+
"""
|
|
423
|
+
with self._lock:
|
|
424
|
+
return [s for sessions in self._sandbox_cache.values() for s in sessions]
|
|
425
|
+
|
|
426
|
+
def cleanup(self) -> List[str]:
|
|
427
|
+
"""
|
|
428
|
+
Destroy all cached sandboxes.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
List[str]: List of destroyed sandbox IDs.
|
|
432
|
+
"""
|
|
433
|
+
destroyed = []
|
|
434
|
+
with self._lock:
|
|
435
|
+
# Collect all (image_id, sandbox_id) pairs
|
|
436
|
+
pairs = [
|
|
437
|
+
(img_id, session.sandbox_id)
|
|
438
|
+
for img_id, sessions in self._sandbox_cache.items()
|
|
439
|
+
for session in sessions
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
for img_id, sbx_id in pairs:
|
|
443
|
+
if self.delete(sandbox_id=sbx_id, image_id=img_id):
|
|
444
|
+
destroyed.append(sbx_id)
|
|
445
|
+
|
|
446
|
+
_log_operation_success("Cleanup complete", f"destroyed {len(destroyed)} sandboxes")
|
|
447
|
+
return destroyed
|
|
448
|
+
|
|
449
|
+
# ---- Generic Tool Calls ----
|
|
450
|
+
|
|
451
|
+
def call_tool(
|
|
452
|
+
self,
|
|
453
|
+
tool_name: str,
|
|
454
|
+
image_id: str,
|
|
455
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
456
|
+
) -> ToolResult:
|
|
457
|
+
"""
|
|
458
|
+
Call any MCP tool on the specified sandbox type.
|
|
459
|
+
|
|
460
|
+
Automatically creates/reuses a sandbox if sandboxId is needed
|
|
461
|
+
but not provided in the arguments.
|
|
462
|
+
|
|
463
|
+
This is a **low-level** API that returns :class:`ToolResult`.
|
|
464
|
+
For a higher-level experience, use ``al.browser.*`` or ``al.desktop.*``.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
tool_name: Name of the MCP tool to call.
|
|
468
|
+
image_id: Sandbox image ID (e.g. ``IMAGE_BROWSER`` or ``IMAGE_WINDOWS``).
|
|
469
|
+
arguments: Tool arguments dict.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
ToolResult: Parsed result from the tool call.
|
|
473
|
+
"""
|
|
474
|
+
args = dict(arguments or {})
|
|
475
|
+
if "sandboxId" not in args:
|
|
476
|
+
args["sandboxId"] = self._acquire_sandbox(image_id)
|
|
477
|
+
return self._transport.call_tool(image_id, tool_name, args)
|
|
478
|
+
|
|
479
|
+
def list_tools(self, image_id: str) -> ToolResult:
|
|
480
|
+
"""
|
|
481
|
+
List available MCP tools for the given sandbox type.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
image_id: Sandbox image ID (e.g. ``IMAGE_BROWSER`` or ``IMAGE_WINDOWS``).
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
ToolResult: Result containing the list of available tools.
|
|
488
|
+
"""
|
|
489
|
+
return self._transport.list_tools(image_id)
|
|
490
|
+
|
|
491
|
+
# ---- Internal methods ----
|
|
492
|
+
|
|
493
|
+
def _invoke(
|
|
494
|
+
self,
|
|
495
|
+
image_id: str,
|
|
496
|
+
tool_name: str,
|
|
497
|
+
arguments: Dict[str, Any],
|
|
498
|
+
):
|
|
499
|
+
"""
|
|
500
|
+
Call an MCP tool with automatic sandbox injection.
|
|
501
|
+
|
|
502
|
+
If ``sandboxId`` is missing from *arguments*, a sandbox is
|
|
503
|
+
created or reused and the ID is injected automatically.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
image_id: Sandbox image ID.
|
|
507
|
+
tool_name: MCP tool name.
|
|
508
|
+
arguments: Tool arguments dict.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
ActionResult: Parsed outcome.
|
|
512
|
+
"""
|
|
513
|
+
from .models.results import ActionResult
|
|
514
|
+
|
|
515
|
+
if "sandboxId" not in arguments:
|
|
516
|
+
sid = self._acquire_sandbox(image_id)
|
|
517
|
+
arguments = {"sandboxId": sid, **arguments}
|
|
518
|
+
|
|
519
|
+
raw = self._transport.call_tool(image_id, tool_name, arguments)
|
|
520
|
+
return ActionResult(
|
|
521
|
+
success=raw.success,
|
|
522
|
+
text=raw.text,
|
|
523
|
+
data=raw.data,
|
|
524
|
+
error=raw.error,
|
|
525
|
+
raw=raw.content,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def _invoke_raw(
|
|
529
|
+
self,
|
|
530
|
+
image_id: str,
|
|
531
|
+
tool_name: str,
|
|
532
|
+
arguments: Dict[str, Any],
|
|
533
|
+
) -> dict:
|
|
534
|
+
"""
|
|
535
|
+
Call an MCP tool and return the raw JSON-RPC response.
|
|
536
|
+
|
|
537
|
+
Used for operations (e.g. screenshots) that need to parse
|
|
538
|
+
binary / image data directly.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
image_id: Sandbox image ID.
|
|
542
|
+
tool_name: MCP tool name.
|
|
543
|
+
arguments: Tool arguments dict.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
dict: Raw JSON-RPC response.
|
|
547
|
+
"""
|
|
548
|
+
if "sandboxId" not in arguments:
|
|
549
|
+
sid = self._acquire_sandbox(image_id)
|
|
550
|
+
arguments = {"sandboxId": sid, **arguments}
|
|
551
|
+
|
|
552
|
+
return self._transport.call_tool_raw(image_id, tool_name, arguments)
|
|
553
|
+
|
|
554
|
+
def _get_image_lock(self, image_id: str) -> threading.Lock:
|
|
555
|
+
"""Get or create a lock for the specified image_id.
|
|
556
|
+
|
|
557
|
+
Uses double-checked locking to safely create per-image locks.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
image_id: The sandbox image ID.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
threading.Lock: A lock specific to the image_id.
|
|
564
|
+
"""
|
|
565
|
+
# First check without lock (fast path)
|
|
566
|
+
lock = self._image_locks.get(image_id)
|
|
567
|
+
if lock is not None:
|
|
568
|
+
return lock
|
|
569
|
+
|
|
570
|
+
# Acquire lock to safely create new image lock
|
|
571
|
+
with self._image_locks_lock:
|
|
572
|
+
# Double-check after acquiring lock
|
|
573
|
+
lock = self._image_locks.get(image_id)
|
|
574
|
+
if lock is None:
|
|
575
|
+
lock = threading.Lock()
|
|
576
|
+
self._image_locks[image_id] = lock
|
|
577
|
+
return lock
|
|
578
|
+
|
|
579
|
+
def _acquire_sandbox(self, image_id: str) -> str:
|
|
580
|
+
"""
|
|
581
|
+
Get an existing cached sandbox or create a new one.
|
|
582
|
+
|
|
583
|
+
This is the primary method for sandbox reuse.
|
|
584
|
+
Returns the first available cached sandbox for the given image_id.
|
|
585
|
+
|
|
586
|
+
Uses double-checked locking (DCL) with per-image_id fine-grained locks
|
|
587
|
+
to prevent duplicate sandbox creation under concurrent access while
|
|
588
|
+
minimizing lock contention across different image types.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
image_id: The sandbox image ID.
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
str: The sandbox ID.
|
|
595
|
+
"""
|
|
596
|
+
# First check: fast path without any lock
|
|
597
|
+
sessions = self._sandbox_cache.get(image_id, [])
|
|
598
|
+
if sessions:
|
|
599
|
+
session = sessions[0]
|
|
600
|
+
logger.debug("Reusing cached sandbox: %s", session)
|
|
601
|
+
return session.sandbox_id
|
|
602
|
+
|
|
603
|
+
# Acquire per-image lock for creation phase
|
|
604
|
+
image_lock = self._get_image_lock(image_id)
|
|
605
|
+
with image_lock:
|
|
606
|
+
# Second check: re-check cache after acquiring lock (DCL)
|
|
607
|
+
# Another thread may have created the sandbox while we waited
|
|
608
|
+
sessions = self._sandbox_cache.get(image_id, [])
|
|
609
|
+
if sessions:
|
|
610
|
+
session = sessions[0]
|
|
611
|
+
logger.debug("Reusing cached sandbox (after lock): %s", session)
|
|
612
|
+
return session.sandbox_id
|
|
613
|
+
|
|
614
|
+
# Still not cached — create new sandbox while holding the lock
|
|
615
|
+
# This ensures only one thread creates a sandbox per image_id
|
|
616
|
+
result = self.create(image_id=image_id)
|
|
617
|
+
if not result.success:
|
|
618
|
+
raise SandboxError(
|
|
619
|
+
f"Failed to create sandbox: {result.error}",
|
|
620
|
+
image_id=image_id,
|
|
621
|
+
)
|
|
622
|
+
return result.sandbox_id
|
|
623
|
+
|
|
624
|
+
# ---- Context Manager ----
|
|
625
|
+
|
|
626
|
+
def __enter__(self) -> "AgentLink":
|
|
627
|
+
return self
|
|
628
|
+
|
|
629
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
630
|
+
"""Clean up all sandboxes on exit.
|
|
631
|
+
|
|
632
|
+
Delegates to :meth:`close` which is idempotent. If cleanup fails,
|
|
633
|
+
the exception is stored in :attr:`cleanup_error` and a
|
|
634
|
+
:class:`RuntimeWarning` is emitted so that the caller can detect
|
|
635
|
+
potential sandbox leaks.
|
|
636
|
+
"""
|
|
637
|
+
self.close()
|
|
638
|
+
|
|
639
|
+
def close(self):
|
|
640
|
+
"""
|
|
641
|
+
Close the client and release all resources.
|
|
642
|
+
|
|
643
|
+
Automatically destroys all cached sandboxes before closing the
|
|
644
|
+
transport connection. If cleanup fails, the exception is stored
|
|
645
|
+
in :attr:`cleanup_error` and a :class:`RuntimeWarning` is emitted.
|
|
646
|
+
|
|
647
|
+
This method is idempotent — calling it multiple times is safe.
|
|
648
|
+
Subsequent calls after the first are no-ops.
|
|
649
|
+
"""
|
|
650
|
+
if self._closed:
|
|
651
|
+
return
|
|
652
|
+
self._closed = True
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
self.cleanup()
|
|
656
|
+
except Exception as e:
|
|
657
|
+
self.cleanup_error = e
|
|
658
|
+
logger.warning("Error during cleanup in close(): %s", e)
|
|
659
|
+
warnings.warn(
|
|
660
|
+
f"Sandbox cleanup failed: {e}. "
|
|
661
|
+
f"Sandboxes may have leaked. "
|
|
662
|
+
f"Inspect .cleanup_error for details.",
|
|
663
|
+
RuntimeWarning,
|
|
664
|
+
stacklevel=1,
|
|
665
|
+
)
|
|
666
|
+
finally:
|
|
667
|
+
self._transport.close()
|
|
668
|
+
|
|
669
|
+
def __repr__(self) -> str:
|
|
670
|
+
with self._lock:
|
|
671
|
+
n_sessions = sum(len(sessions) for sessions in self._sandbox_cache.values())
|
|
672
|
+
return f"AgentLink(url={self._config.mcp_url!r}, sessions={n_sessions})"
|