dex-agents-cli-core 0.5.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,235 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ """The browser tools: one headless Chromium page per session, read as text snapshots."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import sys
16
+ from urllib.parse import urlsplit
17
+
18
+ from playwright.async_api import Browser, Page, Playwright, async_playwright
19
+ from playwright.async_api import Error as PlaywrightError
20
+
21
+ from dex_agents import ToolExecutionError
22
+
23
+ _ACTION_TIMEOUT_MILLISECONDS = 15_000
24
+ _NAVIGATION_TIMEOUT_MILLISECONDS = 30_000
25
+ _MAX_SNAPSHOT_CHARACTERS = 50_000
26
+ _MISSING_BROWSER_MARKER = "Executable doesn't exist"
27
+ _INSTALL_TIMEOUT_SECONDS = 600
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+
31
+ class BrowserUnavailableError(Exception):
32
+ """Report that the headless browser cannot start or be installed."""
33
+
34
+
35
+ class BrowserSessions:
36
+ """Keep one page per agent session in one headless browser, started on first use.
37
+
38
+ The browser is Playwright's own Chromium when it is installed, else the
39
+ system Chrome, else Chromium that Playwright downloads on first use.
40
+
41
+ Pages live in this Worker process only. After a Worker restart a session
42
+ has no page until the agent opens one again.
43
+ """
44
+
45
+ def __init__(self) -> None:
46
+ """Create the sessions without starting a browser."""
47
+ self._start_lock = asyncio.Lock()
48
+ self._playwright: Playwright | None = None
49
+ self._browser: Browser | None = None
50
+ self._pages: dict[str, Page] = {}
51
+
52
+ async def open_page(self, session_id: str, url: str) -> str:
53
+ """Open a URL in the session's page.
54
+
55
+ Args:
56
+ session_id: The agent session, which owns one page.
57
+ url: An ``http`` or ``https`` URL. Other schemes, such as ``file``,
58
+ are refused, so the browser never reads local files.
59
+
60
+ Returns:
61
+ The page's text snapshot.
62
+ """
63
+ parts = urlsplit(url)
64
+ if parts.scheme not in ("http", "https") or not parts.hostname:
65
+ raise ToolExecutionError(f"{url} is not an http or https URL")
66
+ page = self._pages.get(session_id)
67
+ if page is None or page.is_closed():
68
+ browser = await self._ensure_browser()
69
+ page = await browser.new_page()
70
+ page.set_default_timeout(_ACTION_TIMEOUT_MILLISECONDS)
71
+ self._pages[session_id] = page
72
+ try:
73
+ await page.goto(url, timeout=_NAVIGATION_TIMEOUT_MILLISECONDS)
74
+ except PlaywrightError as error:
75
+ raise ToolExecutionError(f"cannot open {url}: {error.message}") from error
76
+ return await self.snapshot(session_id)
77
+
78
+ async def snapshot(self, session_id: str) -> str:
79
+ """Return the page's URL, title, and accessibility snapshot.
80
+
81
+ Args:
82
+ session_id: The agent session.
83
+
84
+ Returns:
85
+ The snapshot text.
86
+ """
87
+ page = self._page(session_id)
88
+ try:
89
+ tree = await page.locator("body").aria_snapshot()
90
+ title = await page.title()
91
+ except PlaywrightError as error:
92
+ raise ToolExecutionError(f"cannot read the page: {error.message}") from error
93
+ text = f"url: {page.url}\ntitle: {title}\n\n{tree}"
94
+ if len(text) > _MAX_SNAPSHOT_CHARACTERS:
95
+ omitted = len(text) - _MAX_SNAPSHOT_CHARACTERS
96
+ text = f"{text[:_MAX_SNAPSHOT_CHARACTERS]}\n... ({omitted} more characters)"
97
+ return text
98
+
99
+ async def click(self, session_id: str, selector: str) -> str:
100
+ """Click an element and return the page's snapshot afterwards.
101
+
102
+ Args:
103
+ session_id: The agent session.
104
+ selector: A Playwright selector.
105
+
106
+ Returns:
107
+ The snapshot text.
108
+ """
109
+ page = self._page(session_id)
110
+ try:
111
+ await page.locator(selector).first.click()
112
+ await page.wait_for_load_state()
113
+ except PlaywrightError as error:
114
+ raise ToolExecutionError(f"cannot click {selector}: {error.message}") from error
115
+ return await self.snapshot(session_id)
116
+
117
+ async def fill(self, session_id: str, selector: str, value: str) -> str:
118
+ """Fill a form field and return the page's snapshot afterwards.
119
+
120
+ Args:
121
+ session_id: The agent session.
122
+ selector: A Playwright selector of an input, text area, or editable element.
123
+ value: The text to enter.
124
+
125
+ Returns:
126
+ The snapshot text.
127
+ """
128
+ page = self._page(session_id)
129
+ try:
130
+ await page.locator(selector).first.fill(value)
131
+ except PlaywrightError as error:
132
+ raise ToolExecutionError(f"cannot fill {selector}: {error.message}") from error
133
+ return await self.snapshot(session_id)
134
+
135
+ async def submit(self, session_id: str, selector: str) -> str:
136
+ """Submit the form that contains an element, and return the next page's snapshot.
137
+
138
+ Args:
139
+ session_id: The agent session.
140
+ selector: A Playwright selector of the form or an element inside it.
141
+
142
+ Returns:
143
+ The snapshot text.
144
+ """
145
+ page = self._page(session_id)
146
+ try:
147
+ async with page.expect_navigation(timeout=_NAVIGATION_TIMEOUT_MILLISECONDS):
148
+ await page.locator(selector).first.evaluate(
149
+ "element => (element.form ?? element.closest('form') ?? element)"
150
+ ".requestSubmit()"
151
+ )
152
+ except PlaywrightError as error:
153
+ raise ToolExecutionError(f"cannot submit {selector}: {error.message}") from error
154
+ return await self.snapshot(session_id)
155
+
156
+ async def close(self) -> None:
157
+ """Close every page and stop the browser."""
158
+ if self._browser is not None:
159
+ await self._browser.close()
160
+ self._browser = None
161
+ if self._playwright is not None:
162
+ await self._playwright.stop()
163
+ self._playwright = None
164
+ self._pages.clear()
165
+
166
+ def _page(self, session_id: str) -> Page:
167
+ page = self._pages.get(session_id)
168
+ if page is None or page.is_closed():
169
+ raise ToolExecutionError("no page is open in this session; call browser_navigate first")
170
+ return page
171
+
172
+ async def _ensure_browser(self) -> Browser:
173
+ async with self._start_lock:
174
+ if self._browser is not None:
175
+ return self._browser
176
+ if self._playwright is None:
177
+ self._playwright = await async_playwright().start()
178
+ try:
179
+ self._browser = await self._launch(self._playwright)
180
+ except BrowserUnavailableError as error:
181
+ raise ToolExecutionError(str(error)) from error
182
+ return self._browser
183
+
184
+ async def _launch(self, playwright: Playwright) -> Browser:
185
+ """Start Playwright's Chromium, else the system Chrome, else download Chromium first."""
186
+ try:
187
+ return await playwright.chromium.launch(headless=True)
188
+ except PlaywrightError as error:
189
+ if _MISSING_BROWSER_MARKER not in error.message:
190
+ raise BrowserUnavailableError(
191
+ f"cannot start the browser: {error.message}"
192
+ ) from error
193
+ try:
194
+ return await playwright.chromium.launch(headless=True, channel="chrome")
195
+ except PlaywrightError:
196
+ _LOGGER.info("no system Chrome; downloading Playwright's headless Chromium")
197
+ await install_browser()
198
+ try:
199
+ return await playwright.chromium.launch(headless=True)
200
+ except PlaywrightError as error:
201
+ raise BrowserUnavailableError(f"cannot start the browser: {error.message}") from error
202
+
203
+
204
+ async def install_browser() -> None:
205
+ """Download Playwright's headless Chromium, as the browser tools do on first use.
206
+
207
+ Raises:
208
+ BrowserUnavailableError: If the download fails.
209
+ """
210
+ _LOGGER.info("downloading the headless browser for the browser tools")
211
+ process = await asyncio.create_subprocess_exec(
212
+ sys.executable,
213
+ "-m",
214
+ "playwright",
215
+ "install",
216
+ "--only-shell",
217
+ "chromium",
218
+ stdin=asyncio.subprocess.DEVNULL,
219
+ stdout=asyncio.subprocess.PIPE,
220
+ stderr=asyncio.subprocess.STDOUT,
221
+ )
222
+ try:
223
+ output, _ = await asyncio.wait_for(process.communicate(), _INSTALL_TIMEOUT_SECONDS)
224
+ except TimeoutError as error:
225
+ # Another install, even of another project, may hold Playwright's cache lock.
226
+ process.kill()
227
+ await process.wait()
228
+ raise BrowserUnavailableError(
229
+ f"the headless browser download did not finish in {_INSTALL_TIMEOUT_SECONDS}s"
230
+ ) from error
231
+ if process.returncode != 0:
232
+ detail = output.decode("utf-8", errors="replace").strip().splitlines()[-1:]
233
+ raise BrowserUnavailableError(
234
+ "cannot download the headless browser: " + (detail[0] if detail else "no output")
235
+ )