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.
@@ -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})"