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.
- dex_agents_cli_core/__init__.py +101 -0
- dex_agents_cli_core/chat.py +538 -0
- dex_agents_cli_core/cli.py +671 -0
- dex_agents_cli_core/config.py +650 -0
- dex_agents_cli_core/config_file.py +210 -0
- dex_agents_cli_core/connectors.py +531 -0
- dex_agents_cli_core/dex_server.py +345 -0
- dex_agents_cli_core/dexcli_install.py +314 -0
- dex_agents_cli_core/environment.py +136 -0
- dex_agents_cli_core/harness.py +445 -0
- dex_agents_cli_core/local_tools/__init__.py +26 -0
- dex_agents_cli_core/local_tools/browser.py +235 -0
- dex_agents_cli_core/local_tools/files.py +512 -0
- dex_agents_cli_core/local_tools/shell.py +85 -0
- dex_agents_cli_core/local_tools/source.py +458 -0
- dex_agents_cli_core/local_tools/web.py +98 -0
- dex_agents_cli_core/py.typed +0 -0
- dex_agents_cli_core/schemas/__init__.py +9 -0
- dex_agents_cli_core/schemas/config.schema.json +315 -0
- dex_agents_cli_core/schemas/session-settings.schema.json +153 -0
- dex_agents_cli_core/tool_policy.py +179 -0
- dex_agents_cli_core/turns.py +235 -0
- dex_agents_cli_core-0.5.0.dist-info/METADATA +479 -0
- dex_agents_cli_core-0.5.0.dist-info/RECORD +26 -0
- dex_agents_cli_core-0.5.0.dist-info/WHEEL +4 -0
- dex_agents_cli_core-0.5.0.dist-info/licenses/LICENSE +105 -0
|
@@ -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
|
+
)
|