agent-portal 0.0.2__tar.gz
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.
- agent_portal-0.0.2/PKG-INFO +32 -0
- agent_portal-0.0.2/README.md +18 -0
- agent_portal-0.0.2/agent_portal/__init__.py +3 -0
- agent_portal-0.0.2/agent_portal/__main__.py +5 -0
- agent_portal-0.0.2/agent_portal/browser.py +393 -0
- agent_portal-0.0.2/agent_portal/cli.py +164 -0
- agent_portal-0.0.2/agent_portal/config.py +31 -0
- agent_portal-0.0.2/agent_portal/doctor.py +165 -0
- agent_portal-0.0.2/agent_portal/exceptions.py +39 -0
- agent_portal-0.0.2/agent_portal/logging_utils.py +33 -0
- agent_portal-0.0.2/agent_portal/models.py +160 -0
- agent_portal-0.0.2/agent_portal/plugin_system.py +42 -0
- agent_portal-0.0.2/agent_portal/runtime.py +732 -0
- agent_portal-0.0.2/agent_portal/server.py +349 -0
- agent_portal-0.0.2/pyproject.toml +29 -0
- agent_portal-0.0.2/tests/test_config.py +24 -0
- agent_portal-0.0.2/tests/test_doctor.py +19 -0
- agent_portal-0.0.2/tests/test_runtime.py +122 -0
- agent_portal-0.0.2/tests/test_server.py +53 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-portal
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Python runtime package for Agent Portal.
|
|
5
|
+
Author: Magnexis
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Dist: playwright>=1.54.0
|
|
10
|
+
Project-URL: Homepage, https://github.com/magnexis/agent-portal
|
|
11
|
+
Project-URL: Issues, https://github.com/magnexis/agent-portal/issues
|
|
12
|
+
Project-URL: Repository, https://github.com/magnexis/agent-portal.git
|
|
13
|
+
|
|
14
|
+
# Agent Portal Python Runtime
|
|
15
|
+
|
|
16
|
+
This package contains the local Agent Portal runtime.
|
|
17
|
+
|
|
18
|
+
It provides:
|
|
19
|
+
|
|
20
|
+
- the local HTTP runtime server
|
|
21
|
+
- Playwright-backed browser control
|
|
22
|
+
- agent steering and action policy enforcement
|
|
23
|
+
- runtime doctor checks
|
|
24
|
+
- report generation
|
|
25
|
+
- plugin manifest discovery and validation
|
|
26
|
+
|
|
27
|
+
Install locally with:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -e ./python
|
|
31
|
+
```
|
|
32
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Agent Portal Python Runtime
|
|
2
|
+
|
|
3
|
+
This package contains the local Agent Portal runtime.
|
|
4
|
+
|
|
5
|
+
It provides:
|
|
6
|
+
|
|
7
|
+
- the local HTTP runtime server
|
|
8
|
+
- Playwright-backed browser control
|
|
9
|
+
- agent steering and action policy enforcement
|
|
10
|
+
- runtime doctor checks
|
|
11
|
+
- report generation
|
|
12
|
+
- plugin manifest discovery and validation
|
|
13
|
+
|
|
14
|
+
Install locally with:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e ./python
|
|
18
|
+
```
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .exceptions import BrowserOperationError
|
|
7
|
+
from .logging_utils import build_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BrowserController:
|
|
11
|
+
def __init__(self, screenshot_directory: Path, timeout_ms: int = 5_000) -> None:
|
|
12
|
+
self.screenshot_directory = screenshot_directory
|
|
13
|
+
self.timeout_ms = timeout_ms
|
|
14
|
+
self._page = None
|
|
15
|
+
self._browser = None
|
|
16
|
+
self._context = None
|
|
17
|
+
self._playwright = None
|
|
18
|
+
self._console_errors: list[str] = []
|
|
19
|
+
self._network_errors: list[str] = []
|
|
20
|
+
self.logger = build_logger("agent_portal.browser")
|
|
21
|
+
|
|
22
|
+
def available(self) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
import playwright.sync_api # noqa: F401
|
|
25
|
+
return True
|
|
26
|
+
except Exception:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
def start(self) -> None:
|
|
30
|
+
if self._page is not None:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from playwright.sync_api import sync_playwright
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
raise BrowserOperationError(
|
|
37
|
+
"Python Playwright is not installed.",
|
|
38
|
+
module="agent_portal.browser",
|
|
39
|
+
likely_cause="The Python runtime dependencies have not been installed.",
|
|
40
|
+
suggested_fix="Run `pip install -e ./python`.",
|
|
41
|
+
can_continue=False,
|
|
42
|
+
) from exc
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
self._playwright = sync_playwright().start()
|
|
46
|
+
self._browser = self._playwright.chromium.launch(headless=True)
|
|
47
|
+
self._context = self._browser.new_context()
|
|
48
|
+
self._page = self._context.new_page()
|
|
49
|
+
self._page.set_default_timeout(self.timeout_ms)
|
|
50
|
+
self._page.on("console", self._handle_console_message)
|
|
51
|
+
self._page.on("pageerror", self._handle_page_error)
|
|
52
|
+
self._page.on("requestfailed", self._handle_request_failed)
|
|
53
|
+
self.logger.info("Browser session started")
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
self.stop()
|
|
56
|
+
raise BrowserOperationError(
|
|
57
|
+
"Browser failed to launch because Chromium is not installed or not available.",
|
|
58
|
+
module="agent_portal.browser",
|
|
59
|
+
likely_cause="Playwright Chromium is missing or corrupted.",
|
|
60
|
+
suggested_fix="Run `python -m playwright install chromium`.",
|
|
61
|
+
can_continue=False,
|
|
62
|
+
) from exc
|
|
63
|
+
|
|
64
|
+
def stop(self) -> None:
|
|
65
|
+
if self._context is not None:
|
|
66
|
+
self._context.close()
|
|
67
|
+
if self._browser is not None:
|
|
68
|
+
self._browser.close()
|
|
69
|
+
if self._playwright is not None:
|
|
70
|
+
self._playwright.stop()
|
|
71
|
+
self._browser = None
|
|
72
|
+
self._context = None
|
|
73
|
+
self._page = None
|
|
74
|
+
self._playwright = None
|
|
75
|
+
self._console_errors = []
|
|
76
|
+
self._network_errors = []
|
|
77
|
+
|
|
78
|
+
def open_url(self, url: str) -> None:
|
|
79
|
+
page = self._require_page()
|
|
80
|
+
try:
|
|
81
|
+
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
|
82
|
+
page.wait_for_load_state("networkidle", timeout=self.timeout_ms)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
raise BrowserOperationError(
|
|
85
|
+
f"Failed to open `{url}`.",
|
|
86
|
+
module="agent_portal.browser",
|
|
87
|
+
likely_cause="The page did not finish loading, the dev server is down, or navigation timed out.",
|
|
88
|
+
suggested_fix="Verify the target URL is reachable and increase the browser timeout if the app is slow.",
|
|
89
|
+
can_continue=True,
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
def close_page(self) -> None:
|
|
93
|
+
self.stop()
|
|
94
|
+
|
|
95
|
+
def refresh(self) -> None:
|
|
96
|
+
page = self._require_page()
|
|
97
|
+
try:
|
|
98
|
+
page.reload(wait_until="domcontentloaded", timeout=self.timeout_ms)
|
|
99
|
+
page.wait_for_load_state("networkidle", timeout=self.timeout_ms)
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
raise BrowserOperationError(
|
|
102
|
+
"Page refresh failed.",
|
|
103
|
+
module="agent_portal.browser",
|
|
104
|
+
likely_cause="The page became unavailable or reload timed out.",
|
|
105
|
+
suggested_fix="Verify the page is reachable and retry the refresh.",
|
|
106
|
+
can_continue=True,
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
def back(self) -> None:
|
|
110
|
+
page = self._require_page()
|
|
111
|
+
try:
|
|
112
|
+
page.go_back(wait_until="domcontentloaded", timeout=self.timeout_ms)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
raise BrowserOperationError(
|
|
115
|
+
"Browser back navigation failed.",
|
|
116
|
+
module="agent_portal.browser",
|
|
117
|
+
likely_cause="There is no prior history entry or the navigation failed.",
|
|
118
|
+
suggested_fix="Open a page first or verify browser history before retrying.",
|
|
119
|
+
can_continue=True,
|
|
120
|
+
) from exc
|
|
121
|
+
|
|
122
|
+
def forward(self) -> None:
|
|
123
|
+
page = self._require_page()
|
|
124
|
+
try:
|
|
125
|
+
page.go_forward(wait_until="domcontentloaded", timeout=self.timeout_ms)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
raise BrowserOperationError(
|
|
128
|
+
"Browser forward navigation failed.",
|
|
129
|
+
module="agent_portal.browser",
|
|
130
|
+
likely_cause="There is no forward history entry or the navigation failed.",
|
|
131
|
+
suggested_fix="Navigate backward before trying to move forward again.",
|
|
132
|
+
can_continue=True,
|
|
133
|
+
) from exc
|
|
134
|
+
|
|
135
|
+
def screenshot(self, name: str) -> str:
|
|
136
|
+
page = self._require_page()
|
|
137
|
+
self.screenshot_directory.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
file_path = self.screenshot_directory / f"{name}.png"
|
|
139
|
+
try:
|
|
140
|
+
page.screenshot(path=str(file_path), full_page=True)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
raise BrowserOperationError(
|
|
143
|
+
"Screenshot capture failed.",
|
|
144
|
+
module="agent_portal.browser",
|
|
145
|
+
likely_cause="The page crashed or the screenshot path is not writable.",
|
|
146
|
+
suggested_fix="Check browser health and filesystem permissions for the screenshot directory.",
|
|
147
|
+
can_continue=True,
|
|
148
|
+
) from exc
|
|
149
|
+
return str(file_path)
|
|
150
|
+
|
|
151
|
+
def click(self, selector: str) -> None:
|
|
152
|
+
locator = self._resolve_locator(selector)
|
|
153
|
+
self._ensure_actionable(locator, selector)
|
|
154
|
+
try:
|
|
155
|
+
locator.click(timeout=self.timeout_ms)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
raise BrowserOperationError(
|
|
158
|
+
f"Click failed for `{selector}`.",
|
|
159
|
+
module="agent_portal.browser",
|
|
160
|
+
likely_cause="The element is covered, detached, or the page is still changing.",
|
|
161
|
+
suggested_fix="Wait for the element to stabilize or use a more specific selector.",
|
|
162
|
+
can_continue=True,
|
|
163
|
+
) from exc
|
|
164
|
+
|
|
165
|
+
def type_text(self, selector: str, value: str) -> None:
|
|
166
|
+
locator = self._resolve_locator(selector)
|
|
167
|
+
self._ensure_actionable(locator, selector)
|
|
168
|
+
try:
|
|
169
|
+
locator.fill(value, timeout=self.timeout_ms)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
raise BrowserOperationError(
|
|
172
|
+
f"Typing failed for `{selector}`.",
|
|
173
|
+
module="agent_portal.browser",
|
|
174
|
+
likely_cause="The target is read-only, disabled, or not a fillable element.",
|
|
175
|
+
suggested_fix="Use an input selector that resolves to a visible text field.",
|
|
176
|
+
can_continue=True,
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
def hover(self, selector: str) -> None:
|
|
180
|
+
locator = self._resolve_locator(selector)
|
|
181
|
+
self._ensure_actionable(locator, selector)
|
|
182
|
+
try:
|
|
183
|
+
locator.hover(timeout=self.timeout_ms)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
raise BrowserOperationError(
|
|
186
|
+
f"Hover failed for `{selector}`.",
|
|
187
|
+
module="agent_portal.browser",
|
|
188
|
+
likely_cause="The element is hidden or no longer attached to the page.",
|
|
189
|
+
suggested_fix="Wait for the element to become visible before hovering.",
|
|
190
|
+
can_continue=True,
|
|
191
|
+
) from exc
|
|
192
|
+
|
|
193
|
+
def scroll(self, selector: str | None = None) -> None:
|
|
194
|
+
page = self._require_page()
|
|
195
|
+
try:
|
|
196
|
+
if selector:
|
|
197
|
+
self._resolve_locator(selector).scroll_into_view_if_needed(timeout=self.timeout_ms)
|
|
198
|
+
return
|
|
199
|
+
page.mouse.wheel(0, 800)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
raise BrowserOperationError(
|
|
202
|
+
"Scroll failed.",
|
|
203
|
+
module="agent_portal.browser",
|
|
204
|
+
likely_cause="The page is not interactive or the target element no longer exists.",
|
|
205
|
+
suggested_fix="Retry after the page settles or scroll the main page instead of a stale element.",
|
|
206
|
+
can_continue=True,
|
|
207
|
+
) from exc
|
|
208
|
+
|
|
209
|
+
def wait(self, selector: str) -> None:
|
|
210
|
+
try:
|
|
211
|
+
self._resolve_locator(selector).wait_for(state="visible", timeout=self.timeout_ms)
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise BrowserOperationError(
|
|
214
|
+
f"Wait timed out for `{selector}`.",
|
|
215
|
+
module="agent_portal.browser",
|
|
216
|
+
likely_cause="The element never became visible or the selector is incorrect.",
|
|
217
|
+
suggested_fix="Use a more stable selector or increase the browser timeout for slower pages.",
|
|
218
|
+
can_continue=True,
|
|
219
|
+
) from exc
|
|
220
|
+
|
|
221
|
+
def inspect(self) -> dict[str, Any]:
|
|
222
|
+
page = self._require_page()
|
|
223
|
+
return {
|
|
224
|
+
"url": page.url,
|
|
225
|
+
"title": page.title(),
|
|
226
|
+
"dom": page.content(),
|
|
227
|
+
"accessibilityTree": self.read_accessibility_tree(),
|
|
228
|
+
"consoleErrors": self.read_console(),
|
|
229
|
+
"networkErrors": self.read_network(),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
def current_url(self) -> str | None:
|
|
233
|
+
return self._page.url if self._page else None
|
|
234
|
+
|
|
235
|
+
def current_title(self) -> str | None:
|
|
236
|
+
return self._page.title() if self._page else None
|
|
237
|
+
|
|
238
|
+
def read_console(self) -> list[str]:
|
|
239
|
+
return list(self._console_errors)
|
|
240
|
+
|
|
241
|
+
def read_network(self) -> list[str]:
|
|
242
|
+
return list(self._network_errors)
|
|
243
|
+
|
|
244
|
+
def read_dom(self) -> str:
|
|
245
|
+
return self._require_page().content()
|
|
246
|
+
|
|
247
|
+
def read_accessibility_tree(self) -> Any:
|
|
248
|
+
page = self._require_page()
|
|
249
|
+
accessibility = getattr(page, "accessibility", None)
|
|
250
|
+
if accessibility is None:
|
|
251
|
+
return None
|
|
252
|
+
try:
|
|
253
|
+
return accessibility.snapshot()
|
|
254
|
+
except Exception:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def read_text(self, selector: str) -> str | None:
|
|
258
|
+
locator = self._resolve_locator(selector)
|
|
259
|
+
try:
|
|
260
|
+
return locator.text_content(timeout=self.timeout_ms)
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
raise BrowserOperationError(
|
|
263
|
+
f"Failed to read text for `{selector}`.",
|
|
264
|
+
module="agent_portal.browser",
|
|
265
|
+
likely_cause="The target no longer exists or text content could not be resolved.",
|
|
266
|
+
suggested_fix="Target a stable visible element and retry.",
|
|
267
|
+
can_continue=True,
|
|
268
|
+
) from exc
|
|
269
|
+
|
|
270
|
+
def execute(self, script: str) -> Any:
|
|
271
|
+
page = self._require_page()
|
|
272
|
+
try:
|
|
273
|
+
return page.evaluate(script)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
raise BrowserOperationError(
|
|
276
|
+
"Script execution failed.",
|
|
277
|
+
module="agent_portal.browser",
|
|
278
|
+
likely_cause="The script threw an error or referenced unavailable browser state.",
|
|
279
|
+
suggested_fix="Validate the script against the current page and retry with a simpler expression.",
|
|
280
|
+
can_continue=True,
|
|
281
|
+
) from exc
|
|
282
|
+
|
|
283
|
+
def inspect_element(self, selector: str) -> dict[str, Any]:
|
|
284
|
+
locator = self._resolve_locator(selector)
|
|
285
|
+
try:
|
|
286
|
+
text = locator.text_content(timeout=self.timeout_ms)
|
|
287
|
+
html = locator.evaluate("(node) => node.outerHTML")
|
|
288
|
+
is_visible = locator.is_visible()
|
|
289
|
+
return {
|
|
290
|
+
"selector": selector,
|
|
291
|
+
"text": text,
|
|
292
|
+
"html": html,
|
|
293
|
+
"visible": is_visible,
|
|
294
|
+
}
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
raise BrowserOperationError(
|
|
297
|
+
f"Element inspection failed for `{selector}`.",
|
|
298
|
+
module="agent_portal.browser",
|
|
299
|
+
likely_cause="The element became detached or could not be serialized.",
|
|
300
|
+
suggested_fix="Retry with a more specific selector after the page settles.",
|
|
301
|
+
can_continue=True,
|
|
302
|
+
) from exc
|
|
303
|
+
|
|
304
|
+
def _resolve_locator(self, selector: str):
|
|
305
|
+
page = self._require_page()
|
|
306
|
+
candidates = [selector]
|
|
307
|
+
normalized = selector.lstrip("#")
|
|
308
|
+
candidates.extend(
|
|
309
|
+
[
|
|
310
|
+
f"#{normalized}",
|
|
311
|
+
f"[name='{normalized}']",
|
|
312
|
+
f"[aria-label='{normalized}']",
|
|
313
|
+
f"text={normalized}",
|
|
314
|
+
]
|
|
315
|
+
)
|
|
316
|
+
for candidate in candidates:
|
|
317
|
+
locator = page.locator(candidate).first
|
|
318
|
+
try:
|
|
319
|
+
if locator.count() > 0:
|
|
320
|
+
return locator
|
|
321
|
+
except Exception:
|
|
322
|
+
continue
|
|
323
|
+
lowered = normalized.lower()
|
|
324
|
+
try:
|
|
325
|
+
role_candidates = [
|
|
326
|
+
page.get_by_role("button", name=normalized).first,
|
|
327
|
+
page.get_by_role("link", name=normalized).first,
|
|
328
|
+
page.get_by_role("textbox", name=normalized).first,
|
|
329
|
+
]
|
|
330
|
+
for locator in role_candidates:
|
|
331
|
+
if locator.count() > 0:
|
|
332
|
+
return locator
|
|
333
|
+
if lowered.startswith("//"):
|
|
334
|
+
xpath_locator = page.locator(f"xpath={selector}").first
|
|
335
|
+
if xpath_locator.count() > 0:
|
|
336
|
+
return xpath_locator
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
raise BrowserOperationError(
|
|
340
|
+
f"Element could not be found for selector `{selector}`.",
|
|
341
|
+
module="agent_portal.browser",
|
|
342
|
+
likely_cause="The selector is stale, hidden, or never existed on the page.",
|
|
343
|
+
suggested_fix="Try a more specific selector, aria-label, visible text, or role target.",
|
|
344
|
+
can_continue=True,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def _ensure_actionable(self, locator: Any, selector: str) -> None:
|
|
348
|
+
try:
|
|
349
|
+
locator.wait_for(state="visible", timeout=self.timeout_ms)
|
|
350
|
+
if locator.is_disabled():
|
|
351
|
+
raise BrowserOperationError(
|
|
352
|
+
f"Element `{selector}` is disabled.",
|
|
353
|
+
module="agent_portal.browser",
|
|
354
|
+
likely_cause="The control is present but not interactive yet.",
|
|
355
|
+
suggested_fix="Wait for the app to finish loading or target an enabled element.",
|
|
356
|
+
can_continue=True,
|
|
357
|
+
)
|
|
358
|
+
except BrowserOperationError:
|
|
359
|
+
raise
|
|
360
|
+
except Exception as exc:
|
|
361
|
+
raise BrowserOperationError(
|
|
362
|
+
f"Element `{selector}` is not actionable.",
|
|
363
|
+
module="agent_portal.browser",
|
|
364
|
+
likely_cause="The target is hidden, detached, or blocked by a modal.",
|
|
365
|
+
suggested_fix="Retry after the page settles or target a visible element inside the active dialog.",
|
|
366
|
+
can_continue=True,
|
|
367
|
+
) from exc
|
|
368
|
+
|
|
369
|
+
def _require_page(self):
|
|
370
|
+
if self._page is None:
|
|
371
|
+
raise BrowserOperationError(
|
|
372
|
+
"Browser session is not ready.",
|
|
373
|
+
module="agent_portal.browser",
|
|
374
|
+
likely_cause="The runtime has not launched the browser yet.",
|
|
375
|
+
suggested_fix="Start the runtime and open a page before issuing browser actions.",
|
|
376
|
+
can_continue=False,
|
|
377
|
+
)
|
|
378
|
+
return self._page
|
|
379
|
+
|
|
380
|
+
def _handle_console_message(self, message: Any) -> None:
|
|
381
|
+
if getattr(message, "type", "") == "error":
|
|
382
|
+
self._console_errors.append(message.text)
|
|
383
|
+
self._console_errors = self._console_errors[-20:]
|
|
384
|
+
|
|
385
|
+
def _handle_page_error(self, error: Any) -> None:
|
|
386
|
+
self._console_errors.append(str(error))
|
|
387
|
+
self._console_errors = self._console_errors[-20:]
|
|
388
|
+
|
|
389
|
+
def _handle_request_failed(self, request: Any) -> None:
|
|
390
|
+
failure = request.failure
|
|
391
|
+
error_text = failure["errorText"] if isinstance(failure, dict) else "unknown failure"
|
|
392
|
+
self._network_errors.append(f"{request.method} {request.url} - {error_text}")
|
|
393
|
+
self._network_errors = self._network_errors[-20:]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from .config import load_config, save_default_config
|
|
12
|
+
from .doctor import run_doctor
|
|
13
|
+
from .plugin_system import discover_plugins, validate_plugin_manifest
|
|
14
|
+
from .runtime import PortalRuntime
|
|
15
|
+
from .server import serve
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
parser = build_parser()
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
workspace = Path.cwd()
|
|
22
|
+
save_default_config(workspace)
|
|
23
|
+
config = load_config(workspace)
|
|
24
|
+
host = args.host or config.runtime_host
|
|
25
|
+
port = args.port or config.runtime_port
|
|
26
|
+
runtime_url = f"http://{host}:{port}"
|
|
27
|
+
|
|
28
|
+
config.runtime_host = host
|
|
29
|
+
config.runtime_port = port
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
if args.command == "start":
|
|
33
|
+
serve(PortalRuntime(workspace, config))
|
|
34
|
+
return
|
|
35
|
+
if args.command == "stop":
|
|
36
|
+
print_json_or_text(post_json(f"{runtime_url}/control/stop"), args.json)
|
|
37
|
+
return
|
|
38
|
+
if args.command == "status":
|
|
39
|
+
print_json_or_text(get_json(f"{runtime_url}/status"), args.json)
|
|
40
|
+
return
|
|
41
|
+
if args.command == "doctor":
|
|
42
|
+
report = run_doctor(workspace)
|
|
43
|
+
payload = {"checks": [asdict(check) for check in report.checks]}
|
|
44
|
+
print_json_or_text(payload, args.json)
|
|
45
|
+
return
|
|
46
|
+
if args.command == "open":
|
|
47
|
+
payload = {"url": args.url}
|
|
48
|
+
print_json_or_text(post_json(f"{runtime_url}/browser/open", payload), args.json)
|
|
49
|
+
return
|
|
50
|
+
if args.command == "screenshot":
|
|
51
|
+
payload = {"label": args.label}
|
|
52
|
+
print_json_or_text(post_json(f"{runtime_url}/browser/screenshot", payload), args.json)
|
|
53
|
+
return
|
|
54
|
+
if args.command == "report":
|
|
55
|
+
print_json_or_text(post_json(f"{runtime_url}/report/generate"), args.json)
|
|
56
|
+
return
|
|
57
|
+
if args.command == "plugins":
|
|
58
|
+
if args.plugins_command == "list":
|
|
59
|
+
payload = [str(path) for path in discover_plugins(workspace)]
|
|
60
|
+
print_json_or_text(payload, args.json)
|
|
61
|
+
return
|
|
62
|
+
if args.plugins_command == "validate":
|
|
63
|
+
results = {
|
|
64
|
+
str(path): validate_plugin_manifest(path)
|
|
65
|
+
for path in discover_plugins(workspace)
|
|
66
|
+
}
|
|
67
|
+
print_json_or_text(results, args.json)
|
|
68
|
+
return
|
|
69
|
+
if args.command == "mcp":
|
|
70
|
+
mcp_cli = load_mcp_cli_module()
|
|
71
|
+
argv = [args.mcp_command]
|
|
72
|
+
if args.host or args.port:
|
|
73
|
+
argv.extend(["--runtime-url", runtime_url])
|
|
74
|
+
if args.json:
|
|
75
|
+
argv.append("--json")
|
|
76
|
+
old_argv = sys.argv[:]
|
|
77
|
+
try:
|
|
78
|
+
sys.argv = ["agent-portal-mcp", *argv]
|
|
79
|
+
mcp_cli.main()
|
|
80
|
+
finally:
|
|
81
|
+
sys.argv = old_argv
|
|
82
|
+
return
|
|
83
|
+
except (HTTPError, URLError) as exc:
|
|
84
|
+
print_json_or_text(
|
|
85
|
+
{
|
|
86
|
+
"error": "Runtime request failed",
|
|
87
|
+
"details": str(exc),
|
|
88
|
+
"suggestedFix": f"Start the runtime with `agent-portal --host {host} --port {port} start`.",
|
|
89
|
+
},
|
|
90
|
+
True if args.json else False,
|
|
91
|
+
)
|
|
92
|
+
raise SystemExit(1) from exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
96
|
+
parser = argparse.ArgumentParser(prog="agent-portal")
|
|
97
|
+
parser.add_argument("--json", action="store_true")
|
|
98
|
+
parser.add_argument("--verbose", action="store_true")
|
|
99
|
+
parser.add_argument("--debug", action="store_true")
|
|
100
|
+
parser.add_argument("--host")
|
|
101
|
+
parser.add_argument("--port", type=int)
|
|
102
|
+
parser.add_argument("--profile")
|
|
103
|
+
|
|
104
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
105
|
+
subparsers.add_parser("start")
|
|
106
|
+
subparsers.add_parser("stop")
|
|
107
|
+
subparsers.add_parser("status")
|
|
108
|
+
subparsers.add_parser("doctor")
|
|
109
|
+
|
|
110
|
+
open_parser = subparsers.add_parser("open")
|
|
111
|
+
open_parser.add_argument("url")
|
|
112
|
+
|
|
113
|
+
screenshot_parser = subparsers.add_parser("screenshot")
|
|
114
|
+
screenshot_parser.add_argument("--label", default="manual")
|
|
115
|
+
|
|
116
|
+
subparsers.add_parser("report")
|
|
117
|
+
|
|
118
|
+
plugins_parser = subparsers.add_parser("plugins")
|
|
119
|
+
plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_command", required=True)
|
|
120
|
+
plugins_subparsers.add_parser("list")
|
|
121
|
+
plugins_subparsers.add_parser("validate")
|
|
122
|
+
|
|
123
|
+
mcp_parser = subparsers.add_parser("mcp")
|
|
124
|
+
mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True)
|
|
125
|
+
mcp_subparsers.add_parser("start")
|
|
126
|
+
mcp_subparsers.add_parser("doctor")
|
|
127
|
+
return parser
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_json(url: str) -> object:
|
|
131
|
+
with urlopen(url) as response:
|
|
132
|
+
return json.loads(response.read().decode("utf8"))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def post_json(url: str, payload: dict[str, object] | None = None) -> object:
|
|
136
|
+
data = json.dumps(payload or {}).encode("utf8")
|
|
137
|
+
request = Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
138
|
+
with urlopen(request) as response:
|
|
139
|
+
return json.loads(response.read().decode("utf8"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def print_json_or_text(payload: object, json_output: bool) -> None:
|
|
143
|
+
if json_output:
|
|
144
|
+
print(json.dumps(payload, indent=2))
|
|
145
|
+
return
|
|
146
|
+
if isinstance(payload, dict):
|
|
147
|
+
for key, value in payload.items():
|
|
148
|
+
print(f"{key}: {value}")
|
|
149
|
+
return
|
|
150
|
+
if isinstance(payload, list):
|
|
151
|
+
for entry in payload:
|
|
152
|
+
print(f"- {entry}")
|
|
153
|
+
return
|
|
154
|
+
print(payload)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load_mcp_cli_module():
|
|
158
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
159
|
+
mcp_src = repo_root / "packages" / "agent-portal-mcp"
|
|
160
|
+
if str(mcp_src) not in sys.path:
|
|
161
|
+
sys.path.insert(0, str(mcp_src))
|
|
162
|
+
from agent_portal_mcp import cli as mcp_cli # type: ignore
|
|
163
|
+
|
|
164
|
+
return mcp_cli
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .models import RuntimeConfigModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_PATH = "agent-portal.config.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_config(base_path: Path | None = None) -> RuntimeConfigModel:
|
|
14
|
+
root = base_path or Path.cwd()
|
|
15
|
+
config_path = root / DEFAULT_CONFIG_PATH
|
|
16
|
+
if not config_path.exists():
|
|
17
|
+
return RuntimeConfigModel()
|
|
18
|
+
|
|
19
|
+
raw = json.loads(config_path.read_text(encoding="utf8"))
|
|
20
|
+
return RuntimeConfigModel(**raw)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def save_default_config(base_path: Path | None = None) -> Path:
|
|
24
|
+
root = base_path or Path.cwd()
|
|
25
|
+
config_path = root / DEFAULT_CONFIG_PATH
|
|
26
|
+
if not config_path.exists():
|
|
27
|
+
config_path.write_text(
|
|
28
|
+
json.dumps(asdict(RuntimeConfigModel()), indent=2),
|
|
29
|
+
encoding="utf8",
|
|
30
|
+
)
|
|
31
|
+
return config_path
|