from collections import OrderedDict
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional
from rustwright.sync_api import sync_playwright
from .errors import AgentError
from .refs import RefAllocator, RefRegistry
class BrowserSession:
def __init__(
self,
*,
headless: bool = True,
action_timeout_ms: int = 5000,
navigation_timeout_ms: int = 60000,
snapshot_depth: int = 8,
snapshot_max_chars: int = 50000,
mask_password_values: bool = True,
allow_eval: bool = False,
max_image_bytes: int = 5 * 1024 * 1024,
context_options: Optional[Dict[str, Any]] = None,
) -> None:
self.headless = headless
self.action_timeout_ms = action_timeout_ms
self.navigation_timeout_ms = navigation_timeout_ms
self.snapshot_depth = snapshot_depth
self.snapshot_max_chars = snapshot_max_chars
self.mask_password_values = mask_password_values
self.allow_eval = allow_eval
self.max_image_bytes = max_image_bytes
self.context_options = dict(context_options or {})
self.playwright = None self.browser = None self.context = None self._tabs = OrderedDict() self._registries = {} self._ref_allocator = RefAllocator()
self._active_tab_id = None self._next_tab_number = 1
self._dialog_policy = None
@property
def active_tab_id(self) -> Optional[str]:
return self._active_tab_id
def ensure_page(self) -> Any:
raise NotImplementedError
def _tab_id_for_page(self, page: Any) -> Optional[str]:
for tab_id, candidate in self._tabs.items():
if candidate is page:
return tab_id
return None
def _adopt_page(self, page: Any, make_active: bool = True) -> str:
existing = self._tab_id_for_page(page)
if existing is not None:
if make_active:
self._active_tab_id = existing
return existing
tab_id = "t%d" % self._next_tab_number
self._next_tab_number += 1
self._tabs[tab_id] = page
self._registries[tab_id] = RefRegistry(self._ref_allocator)
def on_dialog(dialog: Any, adopted_page: Any = page) -> None:
self._handle_dialog(adopted_page, dialog)
page.on("dialog", on_dialog)
if make_active or self._active_tab_id is None:
self._active_tab_id = tab_id
return tab_id
def _observe_pages(self) -> None:
if self.context is None:
return
for tab_id, page in list(self._tabs.items()):
try:
closed = page.is_closed()
except Exception:
closed = True
if closed:
del self._tabs[tab_id]
self._registries.pop(tab_id, None)
if self._active_tab_id == tab_id:
self._active_tab_id = None
try:
pages = list(self.context.pages)
except Exception:
pages = []
for page in pages:
try:
if page.is_closed():
continue
except Exception:
continue
if self._tab_id_for_page(page) is None:
self._adopt_page(page, make_active=True)
if self._active_tab_id not in self._tabs:
self._active_tab_id = next(reversed(self._tabs), None)
def active_page(self) -> Any:
self.ensure_page()
self._observe_pages()
if self._active_tab_id is None or self._active_tab_id not in self._tabs:
raise AgentError("session_lost", "The browser session has no active tab")
return self._tabs[self._active_tab_id]
def active_registry(self) -> RefRegistry:
self.active_page()
if self._active_tab_id is None:
raise AgentError("session_lost", "The browser session has no active tab")
return self._registries[self._active_tab_id]
def prepare_ref_reservation(self, count: int) -> int:
return self._ref_allocator.prepare(count)
def list_tabs(self) -> List[Dict[str, Any]]:
self.ensure_page()
self._observe_pages()
result = []
for tab_id, page in self._tabs.items():
try:
title = str(page.title())
except Exception:
title = ""
try:
url = str(page.url)
except Exception:
url = ""
result.append(
{
"tab_id": tab_id,
"title": title,
"url": url,
"active": tab_id == self._active_tab_id,
}
)
return result
def new_tab(self, url: Optional[str] = None) -> str:
self.ensure_page()
if self.context is None:
raise AgentError("session_lost", "The browser context is unavailable")
page = self.context.new_page()
tab_id = self._adopt_page(page, make_active=True)
if url is not None:
page.goto(
url,
wait_until="domcontentloaded",
timeout=self.navigation_timeout_ms,
)
return tab_id
def select_tab(self, tab_id: str) -> str:
self.ensure_page()
self._observe_pages()
if tab_id not in self._tabs:
raise AgentError(
"invalid_argument",
"Unknown tab id",
"List tabs and choose an active tab id.",
)
self._active_tab_id = tab_id
return tab_id
def close_tab(self, tab_id: str) -> str:
self.ensure_page()
self._observe_pages()
if tab_id not in self._tabs:
raise AgentError(
"invalid_argument",
"Unknown tab id",
"List tabs and choose an active tab id.",
)
page = self._tabs[tab_id]
page.close()
del self._tabs[tab_id]
self._registries.pop(tab_id, None)
if self._dialog_policy is not None and self._dialog_policy.get("tab_id") == tab_id:
self._dialog_policy = None
if self._active_tab_id == tab_id:
self._active_tab_id = next(reversed(self._tabs), None)
if not self._tabs:
if self.context is None:
raise AgentError("session_lost", "The browser context is unavailable")
replacement = self.context.new_page()
self._adopt_page(replacement, make_active=True)
return tab_id
def arm_dialog(self, action: str, prompt_text: Optional[str] = None) -> None:
self.active_page()
if self._active_tab_id is None:
raise AgentError("session_lost", "The browser session has no active tab")
self._dialog_policy = {
"tab_id": self._active_tab_id,
"action": action,
"prompt_text": prompt_text,
}
def _handle_dialog(self, page: Any, dialog: Any) -> None:
tab_id = self._tab_id_for_page(page)
policy = self._dialog_policy
if policy is None or policy.get("tab_id") != tab_id:
try:
dialog.dismiss()
except Exception:
pass
return
try:
if policy.get("action") == "accept":
dialog.accept(prompt_text=policy.get("prompt_text"))
else:
dialog.dismiss()
finally:
if self._dialog_policy is policy:
self._dialog_policy = None
@contextmanager
def dialog_action_scope(self) -> Iterator[None]:
policy = self._dialog_policy
try:
yield
finally:
if policy is not None and self._dialog_policy is policy:
self._dialog_policy = None
def close(self) -> None:
self._dialog_policy = None
context = self.context
browser = self.browser
playwright = self.playwright
self.context = None
self.browser = None
self.playwright = None
self._tabs.clear()
self._registries.clear()
self._active_tab_id = None
if context is not None:
try:
context.close()
except Exception:
pass
if browser is not None:
try:
browser.close()
except Exception:
pass
if playwright is not None:
try:
playwright.stop()
except Exception:
pass
class OwnedSession(BrowserSession):
def ensure_page(self) -> Any:
if self.context is None:
try:
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(headless=self.headless)
self.context = self.browser.new_context(**self.context_options)
page = self.context.new_page()
self._adopt_page(page, make_active=True)
except Exception:
self.close()
raise AgentError("browser_launch_failed", "The browser could not be started") from None
self._observe_pages()
if not self._tabs:
if self.context is None:
raise AgentError("session_lost", "The browser context is unavailable")
try:
page = self.context.new_page()
self._adopt_page(page, make_active=True)
except Exception:
raise AgentError("session_lost", "The browser could not create a tab") from None
if self._active_tab_id is None:
self._active_tab_id = next(reversed(self._tabs))
return self._tabs[self._active_tab_id]