Skip to content

Commit 8123438

Browse files
CopilotHarold-lkk
andcommitted
Add BrowserSessionManager, BrowserSnapshot action, AiSnapshotSerializer, and tests
Co-authored-by: Harold-lkk <24622904+Harold-lkk@users.noreply.github.com>
1 parent d1e6cf0 commit 8123438

File tree

4 files changed

+1408
-0
lines changed

4 files changed

+1408
-0
lines changed

lagent/actions/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from .arxiv_search import ArxivSearch, AsyncArxivSearch
33
from .base_action import AsyncActionMixin, BaseAction, tool_api
44
from .bing_map import AsyncBINGMap, BINGMap
5+
from .browser_session import BrowserSession, BrowserSessionManager, BrowserTarget
6+
from .browser_snapshot import AiSnapshotSerializer, BrowserSnapshot, SnapshotStats
57
from .builtin_actions import FinishAction, InvalidAction, NoAction
68
from .google_scholar_search import AsyncGoogleScholar, GoogleScholar
79
from .google_search import AsyncGoogleSearch, GoogleSearch
@@ -24,6 +26,12 @@
2426
'AsyncBINGMap',
2527
'ArxivSearch',
2628
'AsyncArxivSearch',
29+
'BrowserSession',
30+
'BrowserSessionManager',
31+
'BrowserSnapshot',
32+
'BrowserTarget',
33+
'AiSnapshotSerializer',
34+
'SnapshotStats',
2735
'GoogleSearch',
2836
'AsyncGoogleSearch',
2937
'GoogleScholar',

lagent/actions/browser_session.py

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
"""Browser session manager for Lagent browser tools.
2+
3+
Manages Playwright browser sessions, tabs, element ref registries, and
4+
artifact directories (screenshots, downloads, traces) in a thread-safe way.
5+
"""
6+
7+
import os
8+
import threading
9+
import uuid
10+
from dataclasses import dataclass, field
11+
from typing import Any, Dict, List, Optional
12+
13+
try:
14+
from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright
15+
PLAYWRIGHT_AVAILABLE = True
16+
except ImportError:
17+
PLAYWRIGHT_AVAILABLE = False
18+
19+
20+
@dataclass
21+
class BrowserTarget:
22+
"""Represents a single browser tab/page within a session."""
23+
24+
target_id: str
25+
page: Any # playwright Page object
26+
url: str = ''
27+
title: str = ''
28+
29+
def refresh_info(self) -> None:
30+
"""Update url/title from the live page."""
31+
try:
32+
self.url = self.page.url
33+
self.title = self.page.title()
34+
except Exception:
35+
pass
36+
37+
38+
@dataclass
39+
class BrowserSession:
40+
"""Represents a managed browser session.
41+
42+
Attributes:
43+
session_id: unique identifier for this session.
44+
browser: Playwright Browser instance.
45+
context: Playwright BrowserContext instance.
46+
targets: mapping from target_id to BrowserTarget.
47+
active_target_id: target_id of the currently active tab.
48+
refs: mapping from ref string (e.g. ``"r1"``) to element info dict.
49+
artifact_dir: directory path for storing screenshots/downloads/traces.
50+
"""
51+
52+
session_id: str
53+
browser: Any # playwright Browser
54+
context: Any # playwright BrowserContext
55+
targets: Dict[str, 'BrowserTarget'] = field(default_factory=dict)
56+
active_target_id: Optional[str] = None
57+
refs: Dict[str, dict] = field(default_factory=dict)
58+
artifact_dir: Optional[str] = None
59+
60+
@property
61+
def active_page(self) -> Optional[Any]:
62+
"""Return the Playwright Page for the active target, or ``None``."""
63+
if self.active_target_id and self.active_target_id in self.targets:
64+
return self.targets[self.active_target_id].page
65+
# Fallback: first available target
66+
if self.targets:
67+
return next(iter(self.targets.values())).page
68+
return None
69+
70+
def set_active_by_url(self, url: str) -> bool:
71+
"""Switch the active target to the first tab whose URL matches.
72+
73+
Args:
74+
url (str): URL (or prefix) to match.
75+
76+
Returns:
77+
bool: ``True`` if a matching target was found and activated.
78+
"""
79+
for tid, target in self.targets.items():
80+
target.refresh_info()
81+
if target.url == url or target.url.startswith(url):
82+
self.active_target_id = tid
83+
return True
84+
return False
85+
86+
def set_active_by_index(self, index: int) -> bool:
87+
"""Switch the active target by zero-based tab index.
88+
89+
Args:
90+
index (int): zero-based index into :attr:`targets`.
91+
92+
Returns:
93+
bool: ``True`` if the index was valid.
94+
"""
95+
keys = list(self.targets.keys())
96+
if 0 <= index < len(keys):
97+
self.active_target_id = keys[index]
98+
return True
99+
return False
100+
101+
def bind_refs(self, elements: List[dict]) -> None:
102+
"""Register interactive elements as named refs.
103+
104+
Args:
105+
elements (list[dict]): element info dicts produced by the
106+
snapshot serializer. Each dict must contain at least a
107+
``selector`` key that can be used to re-locate the element.
108+
"""
109+
self.refs.clear()
110+
for idx, el in enumerate(elements):
111+
ref_id = f'r{idx + 1}'
112+
self.refs[ref_id] = el
113+
114+
def resolve_ref(self, ref: str) -> Optional[dict]:
115+
"""Return element info for a ref string such as ``"r1"``.
116+
117+
Args:
118+
ref (str): ref identifier.
119+
120+
Returns:
121+
dict | None: element info dict, or ``None`` if not found.
122+
"""
123+
return self.refs.get(ref)
124+
125+
126+
class BrowserSessionManager:
127+
"""Thread-safe singleton manager for Playwright browser sessions.
128+
129+
Usage::
130+
131+
manager = BrowserSessionManager()
132+
session = manager.get_or_create_session('my-session')
133+
page = session.active_page
134+
# ... do stuff with page ...
135+
manager.close_session('my-session')
136+
"""
137+
138+
_instance: Optional['BrowserSessionManager'] = None
139+
_class_lock: threading.Lock = threading.Lock()
140+
141+
def __new__(cls) -> 'BrowserSessionManager':
142+
with cls._class_lock:
143+
if cls._instance is None:
144+
inst = super().__new__(cls)
145+
inst._sessions: Dict[str, BrowserSession] = {}
146+
inst._lock = threading.Lock()
147+
inst._playwright = None
148+
inst._playwright_ctx = None
149+
cls._instance = inst
150+
return cls._instance
151+
152+
# ------------------------------------------------------------------
153+
# Internal helpers
154+
# ------------------------------------------------------------------
155+
156+
def _ensure_playwright(self) -> None:
157+
if not PLAYWRIGHT_AVAILABLE:
158+
raise RuntimeError(
159+
'playwright is not installed. '
160+
'Install it with: pip install playwright && playwright install'
161+
)
162+
if self._playwright is None:
163+
self._playwright_ctx = sync_playwright()
164+
self._playwright = self._playwright_ctx.start()
165+
166+
def _make_artifact_dir(self, session_id: str, base: Optional[str]) -> str:
167+
root = base or os.path.join(os.getcwd(), '.browser_artifacts')
168+
artifact_dir = os.path.join(root, session_id)
169+
os.makedirs(artifact_dir, exist_ok=True)
170+
return artifact_dir
171+
172+
# ------------------------------------------------------------------
173+
# Public API
174+
# ------------------------------------------------------------------
175+
176+
def create_session(
177+
self,
178+
session_id: Optional[str] = None,
179+
artifact_dir: Optional[str] = None,
180+
browser_type: str = 'chromium',
181+
headless: bool = True,
182+
**launch_kwargs: Any,
183+
) -> BrowserSession:
184+
"""Launch a new browser and create a session.
185+
186+
Args:
187+
session_id (str | None): identifier for the session. A random
188+
UUID is used when not provided.
189+
artifact_dir (str | None): root directory for browser artifacts.
190+
Defaults to ``<cwd>/.browser_artifacts/<session_id>``.
191+
browser_type (str): Playwright browser type – ``'chromium'``,
192+
``'firefox'``, or ``'webkit'``. Defaults to ``'chromium'``.
193+
headless (bool): run the browser in headless mode. Defaults to
194+
``True``.
195+
**launch_kwargs: extra keyword arguments forwarded to
196+
``browser_type.launch()``.
197+
198+
Returns:
199+
BrowserSession: the newly created session.
200+
201+
Raises:
202+
RuntimeError: if ``playwright`` is not installed, or if
203+
*session_id* is already in use.
204+
"""
205+
with self._lock:
206+
self._ensure_playwright()
207+
session_id = session_id or str(uuid.uuid4())
208+
if session_id in self._sessions:
209+
raise RuntimeError(f"Session '{session_id}' already exists.")
210+
211+
launcher = getattr(self._playwright, browser_type)
212+
browser: Browser = launcher.launch(headless=headless, **launch_kwargs)
213+
context: BrowserContext = browser.new_context()
214+
page: Page = context.new_page()
215+
216+
target_id = str(uuid.uuid4())
217+
target = BrowserTarget(target_id=target_id, page=page)
218+
target.refresh_info()
219+
220+
art_dir = self._make_artifact_dir(session_id, artifact_dir)
221+
session = BrowserSession(
222+
session_id=session_id,
223+
browser=browser,
224+
context=context,
225+
targets={target_id: target},
226+
active_target_id=target_id,
227+
artifact_dir=art_dir,
228+
)
229+
self._sessions[session_id] = session
230+
return session
231+
232+
def get_session(self, session_id: str) -> Optional[BrowserSession]:
233+
"""Return an existing session by ID, or ``None`` if not found.
234+
235+
Args:
236+
session_id (str): session identifier.
237+
238+
Returns:
239+
BrowserSession | None: the session object.
240+
"""
241+
with self._lock:
242+
return self._sessions.get(session_id)
243+
244+
def get_or_create_session(
245+
self,
246+
session_id: str,
247+
**kwargs: Any,
248+
) -> BrowserSession:
249+
"""Return an existing session or create a new one.
250+
251+
Args:
252+
session_id (str): session identifier.
253+
**kwargs: forwarded to :meth:`create_session` when creating.
254+
255+
Returns:
256+
BrowserSession: existing or newly created session.
257+
"""
258+
with self._lock:
259+
session = self._sessions.get(session_id)
260+
if session is not None:
261+
return session
262+
return self.create_session(session_id=session_id, **kwargs)
263+
264+
def list_sessions(self) -> List[str]:
265+
"""Return a list of all active session IDs.
266+
267+
Returns:
268+
list[str]: session identifiers.
269+
"""
270+
with self._lock:
271+
return list(self._sessions.keys())
272+
273+
def open_tab(self, session_id: str, url: Optional[str] = None) -> str:
274+
"""Open a new tab in an existing session.
275+
276+
Args:
277+
session_id (str): session identifier.
278+
url (str | None): optional URL to navigate the new tab to.
279+
280+
Returns:
281+
str: the new target ID.
282+
283+
Raises:
284+
KeyError: if *session_id* does not exist.
285+
"""
286+
with self._lock:
287+
session = self._sessions[session_id]
288+
page: Page = session.context.new_page()
289+
if url:
290+
page.goto(url)
291+
target_id = str(uuid.uuid4())
292+
target = BrowserTarget(target_id=target_id, page=page)
293+
target.refresh_info()
294+
session.targets[target_id] = target
295+
session.active_target_id = target_id
296+
return target_id
297+
298+
def close_tab(self, session_id: str, target_id: str) -> None:
299+
"""Close a specific tab within a session.
300+
301+
Args:
302+
session_id (str): session identifier.
303+
target_id (str): target identifier to close.
304+
305+
Raises:
306+
KeyError: if either *session_id* or *target_id* does not exist.
307+
"""
308+
with self._lock:
309+
session = self._sessions[session_id]
310+
target = session.targets.pop(target_id)
311+
try:
312+
target.page.close()
313+
except Exception:
314+
pass
315+
if session.active_target_id == target_id:
316+
session.active_target_id = next(iter(session.targets), None)
317+
318+
def close_session(self, session_id: str) -> None:
319+
"""Close a browser session and release all resources.
320+
321+
Args:
322+
session_id (str): session identifier. No-op if not found.
323+
"""
324+
with self._lock:
325+
session = self._sessions.pop(session_id, None)
326+
if session is None:
327+
return
328+
try:
329+
session.browser.close()
330+
except Exception:
331+
pass
332+
333+
def close_all(self) -> None:
334+
"""Close all sessions and stop the Playwright process."""
335+
with self._lock:
336+
session_ids = list(self._sessions.keys())
337+
for sid in session_ids:
338+
self.close_session(sid)
339+
with self._lock:
340+
if self._playwright is not None:
341+
try:
342+
self._playwright_ctx.stop()
343+
except Exception:
344+
pass
345+
self._playwright = None
346+
self._playwright_ctx = None

0 commit comments

Comments
 (0)