camel-ai 0.2.71a12__py3-none-any.whl → 0.2.72__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.

Potentially problematic release.


This version of camel-ai might be problematic. Click here for more details.

Files changed (42) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +260 -488
  3. camel/memories/agent_memories.py +39 -0
  4. camel/memories/base.py +8 -0
  5. camel/models/gemini_model.py +30 -2
  6. camel/models/moonshot_model.py +36 -4
  7. camel/models/openai_model.py +29 -15
  8. camel/societies/workforce/prompts.py +24 -14
  9. camel/societies/workforce/single_agent_worker.py +9 -7
  10. camel/societies/workforce/workforce.py +44 -16
  11. camel/storages/vectordb_storages/__init__.py +1 -0
  12. camel/storages/vectordb_storages/surreal.py +415 -0
  13. camel/toolkits/__init__.py +10 -1
  14. camel/toolkits/base.py +57 -1
  15. camel/toolkits/human_toolkit.py +5 -1
  16. camel/toolkits/hybrid_browser_toolkit/config_loader.py +127 -414
  17. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +783 -1626
  18. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +489 -0
  19. camel/toolkits/markitdown_toolkit.py +2 -2
  20. camel/toolkits/message_integration.py +592 -0
  21. camel/toolkits/note_taking_toolkit.py +195 -26
  22. camel/toolkits/openai_image_toolkit.py +5 -5
  23. camel/toolkits/origene_mcp_toolkit.py +97 -0
  24. camel/toolkits/screenshot_toolkit.py +213 -0
  25. camel/toolkits/search_toolkit.py +115 -36
  26. camel/toolkits/terminal_toolkit.py +379 -165
  27. camel/toolkits/video_analysis_toolkit.py +13 -13
  28. camel/toolkits/video_download_toolkit.py +11 -11
  29. camel/toolkits/web_deploy_toolkit.py +1024 -0
  30. camel/types/enums.py +6 -3
  31. camel/types/unified_model_type.py +16 -4
  32. camel/utils/mcp_client.py +8 -0
  33. {camel_ai-0.2.71a12.dist-info → camel_ai-0.2.72.dist-info}/METADATA +6 -3
  34. {camel_ai-0.2.71a12.dist-info → camel_ai-0.2.72.dist-info}/RECORD +36 -36
  35. camel/toolkits/hybrid_browser_toolkit/actions.py +0 -417
  36. camel/toolkits/hybrid_browser_toolkit/agent.py +0 -311
  37. camel/toolkits/hybrid_browser_toolkit/browser_session.py +0 -739
  38. camel/toolkits/hybrid_browser_toolkit/snapshot.py +0 -227
  39. camel/toolkits/hybrid_browser_toolkit/stealth_script.js +0 -0
  40. camel/toolkits/hybrid_browser_toolkit/unified_analyzer.js +0 -1002
  41. {camel_ai-0.2.71a12.dist-info → camel_ai-0.2.72.dist-info}/WHEEL +0 -0
  42. {camel_ai-0.2.71a12.dist-info → camel_ai-0.2.72.dist-info}/licenses/LICENSE +0 -0
@@ -1,739 +0,0 @@
1
- # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
- # Licensed under the Apache License, Version 2.0 (the "License");
3
- # you may not use this file except in compliance with the License.
4
- # You may obtain a copy of the License at
5
- #
6
- # http://www.apache.org/licenses/LICENSE-2.0
7
- #
8
- # Unless required by applicable law or agreed to in writing, software
9
- # distributed under the License is distributed on an "AS IS" BASIS,
10
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
- # See the License for the specific language governing permissions and
12
- # limitations under the License.
13
- # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
- from __future__ import annotations
15
-
16
- import asyncio
17
- from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Tuple
18
-
19
- from camel.logger import get_logger
20
-
21
- from .actions import ActionExecutor
22
- from .config_loader import ConfigLoader
23
- from .snapshot import PageSnapshot
24
-
25
- if TYPE_CHECKING:
26
- from playwright.async_api import (
27
- Browser,
28
- BrowserContext,
29
- Page,
30
- Playwright,
31
- )
32
-
33
- logger = get_logger(__name__)
34
-
35
-
36
- class HybridBrowserSession:
37
- """Lightweight wrapper around Playwright for
38
- browsing with multi-tab support.
39
-
40
- It provides multiple *Page* instances plus helper utilities (snapshot &
41
- executor). Multiple toolkits or agents can reuse this class without
42
- duplicating Playwright setup code.
43
-
44
- This class is a singleton per event-loop and session-id combination.
45
- """
46
-
47
- # Class-level registry for singleton instances
48
- # Format: {(loop_id, session_id): HybridBrowserSession}
49
- _instances: ClassVar[Dict[Tuple[Any, str], "HybridBrowserSession"]] = {}
50
- _instances_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
51
-
52
- _initialized: bool
53
- _creation_params: Dict[str, Any]
54
-
55
- def __new__(
56
- cls,
57
- *,
58
- headless: bool = True,
59
- user_data_dir: Optional[str] = None,
60
- stealth: bool = False,
61
- session_id: Optional[str] = None,
62
- default_timeout: Optional[int] = None,
63
- short_timeout: Optional[int] = None,
64
- navigation_timeout: Optional[int] = None,
65
- network_idle_timeout: Optional[int] = None,
66
- ) -> "HybridBrowserSession":
67
- # Create a unique key for this event loop and session combination
68
- # We defer the event loop lookup to avoid issues with creation
69
- # outside async context
70
- instance = super().__new__(cls)
71
- instance._initialized = False
72
- instance._session_id = session_id or "default"
73
- instance._creation_params = {
74
- "headless": headless,
75
- "user_data_dir": user_data_dir,
76
- "stealth": stealth,
77
- "session_id": session_id,
78
- "default_timeout": default_timeout,
79
- "short_timeout": short_timeout,
80
- "navigation_timeout": navigation_timeout,
81
- "network_idle_timeout": network_idle_timeout,
82
- }
83
- return instance
84
-
85
- @classmethod
86
- async def _get_or_create_instance(
87
- cls,
88
- instance: "HybridBrowserSession",
89
- ) -> "HybridBrowserSession":
90
- """Get or create singleton instance for the current event loop and
91
- session."""
92
- try:
93
- loop = asyncio.get_running_loop()
94
- loop_id = str(id(loop))
95
- except RuntimeError:
96
- # No event loop running, use a unique identifier for sync context
97
- import threading
98
-
99
- loop_id = f"sync_{threading.current_thread().ident}"
100
-
101
- # Ensure session_id is never None for the key
102
- session_id = (
103
- instance._session_id
104
- if instance._session_id is not None
105
- else "default"
106
- )
107
- session_key = (loop_id, session_id)
108
-
109
- # Use class-level lock to protect the instances registry
110
- async with cls._instances_lock:
111
- if session_key in cls._instances:
112
- existing_instance = cls._instances[session_key]
113
- logger.debug(
114
- f"Reusing existing browser session for session_id: "
115
- f"{session_id}"
116
- )
117
- return existing_instance
118
-
119
- # Register this new instance
120
- cls._instances[session_key] = instance
121
- logger.debug(
122
- f"Created new browser session for session_id: {session_id}"
123
- )
124
- return instance
125
-
126
- def __init__(
127
- self,
128
- *,
129
- headless: bool = True,
130
- user_data_dir: Optional[str] = None,
131
- stealth: bool = False,
132
- session_id: Optional[str] = None,
133
- default_timeout: Optional[int] = None,
134
- short_timeout: Optional[int] = None,
135
- navigation_timeout: Optional[int] = None,
136
- network_idle_timeout: Optional[int] = None,
137
- ):
138
- if self._initialized:
139
- return
140
- self._initialized = True
141
-
142
- self._headless = headless
143
- self._user_data_dir = user_data_dir
144
- self._stealth = stealth
145
- self._session_id = session_id or "default"
146
-
147
- # Store timeout configuration for ActionExecutor instances and
148
- # browser operations
149
- self._default_timeout = default_timeout
150
- self._short_timeout = short_timeout
151
- self._navigation_timeout = ConfigLoader.get_navigation_timeout(
152
- navigation_timeout
153
- )
154
- self._network_idle_timeout = ConfigLoader.get_network_idle_timeout(
155
- network_idle_timeout
156
- )
157
-
158
- # Initialize _creation_params to fix linter error
159
- self._creation_params = {
160
- "headless": headless,
161
- "user_data_dir": user_data_dir,
162
- "stealth": stealth,
163
- "session_id": session_id,
164
- "default_timeout": default_timeout,
165
- "short_timeout": short_timeout,
166
- "navigation_timeout": navigation_timeout,
167
- "network_idle_timeout": network_idle_timeout,
168
- }
169
-
170
- self._playwright: Optional[Playwright] = None
171
- self._browser: Optional[Browser] = None
172
- self._context: Optional[BrowserContext] = None
173
- self._page: Optional[Page] = None
174
-
175
- # Multi-tab support
176
- self._pages: List[Page] = [] # All tabs
177
- self._current_tab_index: int = 0 # Current active tab index
178
-
179
- self.snapshot: Optional[PageSnapshot] = None
180
- self.executor: Optional[ActionExecutor] = None
181
-
182
- # Protect browser initialisation against concurrent calls
183
- self._ensure_lock: "asyncio.Lock" = asyncio.Lock()
184
-
185
- # Load stealth script and config on initialization
186
- self._stealth_script: Optional[str] = None
187
- self._stealth_config: Optional[Dict[str, Any]] = None
188
- if self._stealth:
189
- self._stealth_script = self._load_stealth_script()
190
- stealth_config_class = ConfigLoader.get_stealth_config()
191
- self._stealth_config = stealth_config_class.get_stealth_config()
192
-
193
- def _load_stealth_script(self) -> str:
194
- r"""Load the stealth JavaScript script from file."""
195
- import os
196
-
197
- script_path = os.path.join(
198
- os.path.dirname(os.path.abspath(__file__)), "stealth_script.js"
199
- )
200
-
201
- try:
202
- with open(
203
- script_path, "r", encoding='utf-8', errors='replace'
204
- ) as f:
205
- script_content = f.read()
206
-
207
- if not script_content.strip():
208
- raise ValueError(f"Stealth script is empty: {script_path}")
209
-
210
- logger.debug(
211
- f"Loaded stealth script ({len(script_content)} chars)"
212
- )
213
- return script_content
214
- except FileNotFoundError:
215
- logger.error(f"Stealth script not found: {script_path}")
216
- raise FileNotFoundError(f"Stealth script not found: {script_path}")
217
- except Exception as e:
218
- logger.error(f"Error loading stealth script: {e}")
219
- raise RuntimeError(f"Failed to load stealth script: {e}") from e
220
-
221
- # ------------------------------------------------------------------
222
- # Multi-tab management methods
223
- # ------------------------------------------------------------------
224
- async def create_new_tab(self, url: Optional[str] = None) -> int:
225
- r"""Create a new tab and optionally navigate to a URL.
226
-
227
- Args:
228
- url: Optional URL to navigate to in the new tab
229
-
230
- Returns:
231
- int: Index of the newly created tab
232
- """
233
- await self.ensure_browser()
234
-
235
- if self._context is None:
236
- raise RuntimeError("Browser context is not available")
237
-
238
- # Create new page
239
- new_page = await self._context.new_page()
240
-
241
- # Apply stealth modifications if enabled
242
- if self._stealth and self._stealth_script:
243
- try:
244
- await new_page.add_init_script(self._stealth_script)
245
- logger.debug("Applied stealth script to new tab")
246
- except Exception as e:
247
- logger.warning(
248
- f"Failed to apply stealth script to new tab: {e}"
249
- )
250
-
251
- # Add to our pages list
252
- self._pages.append(new_page)
253
- new_tab_index = len(self._pages) - 1
254
-
255
- # Navigate if URL provided
256
- if url:
257
- try:
258
- await new_page.goto(url, timeout=self._navigation_timeout)
259
- await new_page.wait_for_load_state('domcontentloaded')
260
- except Exception as e:
261
- logger.warning(f"Failed to navigate new tab to {url}: {e}")
262
-
263
- logger.info(
264
- f"Created new tab {new_tab_index}, total tabs: {len(self._pages)}"
265
- )
266
- return new_tab_index
267
-
268
- async def register_page(self, new_page: "Page") -> int:
269
- r"""Register a page that was created externally (e.g., by a click).
270
-
271
- Args:
272
- new_page (Page): The new page object to register.
273
-
274
- Returns:
275
- int: The index of the (newly) registered tab.
276
- """
277
- if new_page in self._pages:
278
- try:
279
- # Page is already known, just return its index
280
- return self._pages.index(new_page)
281
- except ValueError:
282
- # Should not happen if `in` check passed, but handle anyway
283
- pass
284
-
285
- # Add new page to our list
286
- self._pages.append(new_page)
287
- new_tab_index = len(self._pages) - 1
288
- logger.info(
289
- f"Registered new tab {new_tab_index} (opened by user action). "
290
- f"Total tabs: {len(self._pages)}"
291
- )
292
- return new_tab_index
293
-
294
- async def switch_to_tab(self, tab_index: int) -> bool:
295
- r"""Switch to a specific tab by index.
296
-
297
- Args:
298
- tab_index: Index of the tab to switch to
299
-
300
- Returns:
301
- bool: True if successful, False if tab index is invalid
302
- """
303
- # Use a more robust bounds check to prevent race conditions
304
- try:
305
- if not self._pages:
306
- logger.warning("No tabs available")
307
- return False
308
-
309
- # Capture current state to avoid race conditions
310
- current_pages = self._pages.copy()
311
- pages_count = len(current_pages)
312
-
313
- if tab_index < 0 or tab_index >= pages_count:
314
- logger.warning(
315
- f"Invalid tab index {tab_index}, available "
316
- f"tabs: {pages_count}"
317
- )
318
- return False
319
-
320
- # Check if the page is still valid
321
- page = current_pages[tab_index]
322
- if page.is_closed():
323
- logger.warning(
324
- f"Tab {tab_index} is closed, removing from list"
325
- )
326
- # Remove closed page from original list
327
- if (
328
- tab_index < len(self._pages)
329
- and self._pages[tab_index] is page
330
- ):
331
- self._pages.pop(tab_index)
332
- # Adjust current tab index if necessary
333
- if self._current_tab_index >= len(self._pages):
334
- self._current_tab_index = max(0, len(self._pages) - 1)
335
- return False
336
-
337
- self._current_tab_index = tab_index
338
- self._page = page
339
-
340
- # Bring the tab to the front in the browser window
341
- await self._page.bring_to_front()
342
-
343
- # Update executor and snapshot for new tab
344
- self.executor = ActionExecutor(
345
- self._page,
346
- self,
347
- default_timeout=self._default_timeout,
348
- short_timeout=self._short_timeout,
349
- )
350
- self.snapshot = PageSnapshot(self._page)
351
-
352
- logger.info(f"Switched to tab {tab_index}")
353
- return True
354
-
355
- except Exception as e:
356
- logger.warning(f"Error switching to tab {tab_index}: {e}")
357
- return False
358
-
359
- async def close_tab(self, tab_index: int) -> bool:
360
- r"""Close a specific tab.
361
-
362
- Args:
363
- tab_index: Index of the tab to close
364
-
365
- Returns:
366
- bool: True if successful, False if tab index is invalid
367
- """
368
- if not self._pages or tab_index < 0 or tab_index >= len(self._pages):
369
- return False
370
-
371
- try:
372
- page = self._pages[tab_index]
373
- if not page.is_closed():
374
- await page.close()
375
-
376
- # Remove from our list
377
- self._pages.pop(tab_index)
378
-
379
- # If we closed the current tab, switch to another one
380
- if tab_index == self._current_tab_index:
381
- if self._pages:
382
- # Switch to the previous tab, or first tab if we closed
383
- # the first one
384
- new_index = max(
385
- 0, min(tab_index - 1, len(self._pages) - 1)
386
- )
387
- await self.switch_to_tab(new_index)
388
- else:
389
- # No tabs left
390
- self._current_tab_index = 0
391
- self._page = None
392
- self.executor = None
393
- self.snapshot = None
394
- elif tab_index < self._current_tab_index:
395
- # Adjust current tab index since we removed a tab before it
396
- self._current_tab_index -= 1
397
-
398
- logger.info(
399
- f"Closed tab {tab_index}, remaining tabs: {len(self._pages)}"
400
- )
401
- return True
402
-
403
- except Exception as e:
404
- logger.warning(f"Error closing tab {tab_index}: {e}")
405
- return False
406
-
407
- async def get_tab_info(self) -> List[Dict[str, Any]]:
408
- r"""Get information about all open tabs.
409
-
410
- Returns:
411
- List of dictionaries containing tab information
412
- """
413
- tab_info = []
414
- for i, page in enumerate(self._pages):
415
- try:
416
- if not page.is_closed():
417
- title = await page.title()
418
- url = page.url
419
- is_current = i == self._current_tab_index
420
- tab_info.append(
421
- {
422
- "index": i,
423
- "title": title,
424
- "url": url,
425
- "is_current": is_current,
426
- }
427
- )
428
- else:
429
- # Mark closed tab for removal
430
- tab_info.append(
431
- {
432
- "index": i,
433
- "title": "[CLOSED]",
434
- "url": "",
435
- "is_current": False,
436
- }
437
- )
438
- except Exception as e:
439
- logger.warning(f"Error getting info for tab {i}: {e}")
440
- tab_info.append(
441
- {
442
- "index": i,
443
- "title": "[ERROR]",
444
- "url": "",
445
- "is_current": False,
446
- }
447
- )
448
-
449
- return tab_info
450
-
451
- async def get_current_tab_index(self) -> int:
452
- r"""Get the index of the current active tab."""
453
- return self._current_tab_index
454
-
455
- # ------------------------------------------------------------------
456
- # Browser lifecycle helpers
457
- # ------------------------------------------------------------------
458
- async def ensure_browser(self) -> None:
459
- r"""Ensure browser is ready. Each session_id gets its own browser
460
- instance."""
461
- # First, get the singleton instance for this session
462
- singleton_instance = await self._get_or_create_instance(self)
463
-
464
- # If this isn't the singleton instance, delegate to the singleton
465
- if singleton_instance is not self:
466
- await singleton_instance.ensure_browser()
467
- # Copy the singleton's browser state to this instance
468
- self._playwright = singleton_instance._playwright
469
- self._browser = singleton_instance._browser
470
- self._context = singleton_instance._context
471
- self._page = singleton_instance._page
472
- self._pages = singleton_instance._pages
473
- self._current_tab_index = singleton_instance._current_tab_index
474
- self.snapshot = singleton_instance.snapshot
475
- self.executor = singleton_instance.executor
476
- return
477
-
478
- # Serialise initialisation to avoid race conditions where multiple
479
- # concurrent coroutine calls create multiple browser instances for
480
- # the same HybridBrowserSession.
481
- async with self._ensure_lock:
482
- await self._ensure_browser_inner()
483
-
484
- # Moved original logic to helper
485
- async def _ensure_browser_inner(self) -> None:
486
- r"""Internal browser initialization logic."""
487
- from playwright.async_api import async_playwright
488
-
489
- if self._page is not None:
490
- return
491
-
492
- self._playwright = await async_playwright().start()
493
-
494
- # Prepare stealth options
495
- launch_options: Dict[str, Any] = {"headless": self._headless}
496
- context_options: Dict[str, Any] = {}
497
- if self._stealth and self._stealth_config:
498
- # Use preloaded stealth configuration
499
- launch_options['args'] = self._stealth_config['launch_args']
500
- context_options.update(self._stealth_config['context_options'])
501
-
502
- if self._user_data_dir:
503
- context = (
504
- await self._playwright.chromium.launch_persistent_context(
505
- user_data_dir=self._user_data_dir,
506
- **launch_options,
507
- **context_options,
508
- )
509
- )
510
- self._context = context
511
- # Get the first (default) page
512
- pages = context.pages
513
- if pages:
514
- self._page = pages[0]
515
- self._pages = list(pages)
516
- else:
517
- self._page = await context.new_page()
518
- self._pages = [self._page]
519
- else:
520
- self._browser = await self._playwright.chromium.launch(
521
- **launch_options
522
- )
523
- self._context = await self._browser.new_context(**context_options)
524
- self._page = await self._context.new_page()
525
- self._pages = [self._page]
526
-
527
- # Apply stealth modifications if enabled
528
- if self._stealth and self._stealth_script:
529
- try:
530
- await self._page.add_init_script(self._stealth_script)
531
- logger.debug("Applied stealth script to main page")
532
- except Exception as e:
533
- logger.warning(f"Failed to apply stealth script: {e}")
534
-
535
- # Set up timeout for navigation
536
- self._page.set_default_navigation_timeout(self._navigation_timeout)
537
- self._page.set_default_timeout(self._navigation_timeout)
538
-
539
- # Initialize utilities
540
- self.snapshot = PageSnapshot(self._page)
541
- self.executor = ActionExecutor(
542
- self._page,
543
- self,
544
- default_timeout=self._default_timeout,
545
- short_timeout=self._short_timeout,
546
- )
547
- self._current_tab_index = 0
548
-
549
- logger.info("Browser session initialized successfully")
550
-
551
- async def close(self) -> None:
552
- r"""Close browser session and clean up resources."""
553
- if self._page is None:
554
- return
555
-
556
- try:
557
- logger.debug("Closing browser session...")
558
- await self._close_session()
559
-
560
- # Remove from singleton registry
561
- try:
562
- try:
563
- loop = asyncio.get_running_loop()
564
- loop_id = str(id(loop))
565
- except RuntimeError:
566
- # Use same logic as _get_or_create_instance
567
- import threading
568
-
569
- loop_id = f"sync_{threading.current_thread().ident}"
570
-
571
- session_id = (
572
- self._session_id
573
- if self._session_id is not None
574
- else "default"
575
- )
576
- session_key = (loop_id, session_id)
577
-
578
- async with self._instances_lock:
579
- if (
580
- session_key in self._instances
581
- and self._instances[session_key] is self
582
- ):
583
- del self._instances[session_key]
584
- logger.debug(
585
- f"Removed session {session_id} from registry"
586
- )
587
-
588
- except Exception as registry_error:
589
- logger.warning(f"Error cleaning up registry: {registry_error}")
590
-
591
- logger.debug("Browser session closed successfully")
592
- except Exception as e:
593
- logger.error(f"Error during browser session close: {e}")
594
- finally:
595
- self._page = None
596
- self._pages = []
597
- self._current_tab_index = 0
598
- self.snapshot = None
599
- self.executor = None
600
-
601
- async def _close_session(self) -> None:
602
- r"""Internal session close logic with thorough cleanup."""
603
- try:
604
- # Close all pages first
605
- pages_to_close = self._pages.copy()
606
- for page in pages_to_close:
607
- try:
608
- if not page.is_closed():
609
- await page.close()
610
- logger.debug(
611
- f"Closed page: "
612
- f"{page.url if hasattr(page, 'url') else 'unknown'}" # noqa:E501
613
- )
614
- except Exception as e:
615
- logger.warning(f"Error closing page: {e}")
616
-
617
- # Clear the pages list
618
- self._pages.clear()
619
-
620
- # Close context with explicit wait
621
- if self._context:
622
- try:
623
- await self._context.close()
624
- logger.debug("Browser context closed")
625
- except Exception as e:
626
- logger.warning(f"Error closing context: {e}")
627
- finally:
628
- self._context = None
629
-
630
- # Close browser with explicit wait
631
- if self._browser:
632
- try:
633
- await self._browser.close()
634
- logger.debug("Browser instance closed")
635
- except Exception as e:
636
- logger.warning(f"Error closing browser: {e}")
637
- finally:
638
- self._browser = None
639
-
640
- # Stop playwright with increased delay for cleanup
641
- if self._playwright:
642
- try:
643
- await self._playwright.stop()
644
- logger.debug("Playwright stopped")
645
-
646
- # Give more time for complete subprocess cleanup
647
- import asyncio
648
-
649
- await asyncio.sleep(0.5)
650
-
651
- except Exception as e:
652
- logger.warning(f"Error stopping playwright: {e}")
653
- finally:
654
- self._playwright = None
655
-
656
- except Exception as e:
657
- logger.error(f"Error during session cleanup: {e}")
658
- finally:
659
- # Ensure all attributes are cleared regardless of errors
660
- self._page = None
661
- self._pages = []
662
- self._context = None
663
- self._browser = None
664
- self._playwright = None
665
-
666
- @classmethod
667
- async def close_all_sessions(cls) -> None:
668
- r"""Close all browser sessions and clean up the singleton registry."""
669
- logger.debug("Closing all browser sessions...")
670
- async with cls._instances_lock:
671
- # Close all active sessions
672
- instances_to_close = list(cls._instances.values())
673
- cls._instances.clear()
674
- logger.debug(f"Closing {len(instances_to_close)} sessions.")
675
-
676
- # Close sessions outside the lock to avoid deadlock
677
- for instance in instances_to_close:
678
- try:
679
- await instance._close_session()
680
- logger.debug(f"Closed session: {instance._session_id}")
681
- except Exception as e:
682
- logger.error(
683
- f"Error closing session {instance._session_id}: {e}"
684
- )
685
-
686
- logger.debug("All browser sessions closed and registry cleared")
687
-
688
- @classmethod
689
- async def close_all(cls) -> None:
690
- """Alias for close_all_sessions for backward compatibility."""
691
- await cls.close_all_sessions()
692
-
693
- # ------------------------------------------------------------------
694
- # Page interaction
695
- # ------------------------------------------------------------------
696
- async def visit(self, url: str) -> str:
697
- r"""Navigate current tab to URL."""
698
- await self.ensure_browser()
699
- page = await self.get_page()
700
-
701
- await page.goto(url, timeout=self._navigation_timeout)
702
- await page.wait_for_load_state('domcontentloaded')
703
-
704
- # Try to wait for network idle
705
- try:
706
- await page.wait_for_load_state(
707
- 'networkidle', timeout=self._network_idle_timeout
708
- )
709
- except Exception:
710
- logger.debug("Network idle timeout - continuing anyway")
711
-
712
- return f"Navigated to {url}"
713
-
714
- async def get_snapshot(
715
- self, *, force_refresh: bool = False, diff_only: bool = False
716
- ) -> str:
717
- r"""Get snapshot for current tab."""
718
- if not self.snapshot:
719
- return "<empty>"
720
- return await self.snapshot.capture(
721
- force_refresh=force_refresh, diff_only=diff_only
722
- )
723
-
724
- async def exec_action(self, action: Dict[str, Any]) -> Dict[str, Any]:
725
- r"""Execute action on current tab."""
726
- if not self.executor:
727
- return {
728
- "success": False,
729
- "message": "No executor available",
730
- "details": {},
731
- }
732
- return await self.executor.execute(action)
733
-
734
- async def get_page(self) -> "Page":
735
- r"""Get current active page."""
736
- await self.ensure_browser()
737
- if self._page is None:
738
- raise RuntimeError("No active page available")
739
- return self._page