seedcode-cli 6.1.5__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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""ComputerController: the façade the desktop tools drive.
|
|
2
|
+
|
|
3
|
+
Owns the driver modules (injectable for tests), validates every coordinate
|
|
4
|
+
against the live virtual-desktop geometry (never a blind click), paces
|
|
5
|
+
actions, and verifies each mutating action by reporting post-action state —
|
|
6
|
+
the active window and the element under the pointer — so the agent loop can
|
|
7
|
+
confirm outcomes instead of assuming them. Focus operations retry once
|
|
8
|
+
before surfacing a failure.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..utils.logger import get_logger
|
|
17
|
+
from . import browser as browser_driver
|
|
18
|
+
from . import keyboard as keyboard_driver
|
|
19
|
+
from . import mouse as mouse_driver
|
|
20
|
+
from . import registry as registry_driver
|
|
21
|
+
from . import screen as screen_driver
|
|
22
|
+
from . import vision as vision_driver
|
|
23
|
+
from . import windows as windows_driver
|
|
24
|
+
|
|
25
|
+
_log = get_logger("computer")
|
|
26
|
+
|
|
27
|
+
# One agent turn may not wait forever; desktop_wait is clamped to this.
|
|
28
|
+
MAX_WAIT_S = 30.0
|
|
29
|
+
# Settle time after mutating actions before state is read for verification.
|
|
30
|
+
_VERIFY_DELAY_S = 0.3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ComputerError(Exception):
|
|
34
|
+
"""A desktop action failed; the message is fed back to the model."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ComputerController:
|
|
38
|
+
"""High-level desktop actions with validation and built-in verification."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
mouse: Any = mouse_driver,
|
|
43
|
+
keyboard: Any = keyboard_driver,
|
|
44
|
+
screen: Any = screen_driver,
|
|
45
|
+
windows: Any = windows_driver,
|
|
46
|
+
vision: Any = vision_driver,
|
|
47
|
+
registry: Any = registry_driver,
|
|
48
|
+
browser: Any = browser_driver,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.mouse = mouse
|
|
51
|
+
self.keyboard = keyboard
|
|
52
|
+
self.screen = screen
|
|
53
|
+
self.windows = windows
|
|
54
|
+
self.vision = vision
|
|
55
|
+
self.registry = registry
|
|
56
|
+
self.browser = browser
|
|
57
|
+
# Declare per-monitor-DPI-awareness v2 once, before any coordinate is
|
|
58
|
+
# read or acted on, so UIA geometry, screenshots, and pointer clicks
|
|
59
|
+
# all share one physical-pixel space (see :mod:`.dpi`). Best-effort and
|
|
60
|
+
# idempotent; a no-op off win32.
|
|
61
|
+
from . import dpi as _dpi
|
|
62
|
+
|
|
63
|
+
self.dpi = _dpi.ensure_dpi_awareness()
|
|
64
|
+
|
|
65
|
+
# --- coordinates ---------------------------------------------------------
|
|
66
|
+
def validate_point(self, x: Any, y: Any) -> tuple[int, int]:
|
|
67
|
+
"""Coerce and bounds-check a coordinate pair against the desktop."""
|
|
68
|
+
try:
|
|
69
|
+
px, py = int(x), int(y)
|
|
70
|
+
except (TypeError, ValueError):
|
|
71
|
+
raise ComputerError(f"Coordinates must be integers, got ({x!r}, {y!r}).")
|
|
72
|
+
geo = self.screen.geometry()
|
|
73
|
+
if not geo.contains(px, py):
|
|
74
|
+
raise ComputerError(
|
|
75
|
+
f"({px}, {py}) is outside the desktop "
|
|
76
|
+
f"({geo.left}..{geo.left + geo.width - 1}, "
|
|
77
|
+
f"{geo.top}..{geo.top + geo.height - 1}). "
|
|
78
|
+
"Take a fresh desktop_see snapshot for current coordinates."
|
|
79
|
+
)
|
|
80
|
+
return px, py
|
|
81
|
+
|
|
82
|
+
# --- verification helpers ------------------------------------------------
|
|
83
|
+
def _state_after(self, x: int | None = None, y: int | None = None) -> str:
|
|
84
|
+
"""Post-action state line appended to every mutating result."""
|
|
85
|
+
time.sleep(_VERIFY_DELAY_S)
|
|
86
|
+
active = self.windows.active_window()
|
|
87
|
+
parts = [f"active window: {active.describe() if active else '(none)'}"]
|
|
88
|
+
if x is not None and y is not None:
|
|
89
|
+
parts.append(f"element at ({x}, {y}): {self.vision.element_at(x, y)}")
|
|
90
|
+
return "[verify] " + "; ".join(parts)
|
|
91
|
+
|
|
92
|
+
# --- mouse ---------------------------------------------------------------
|
|
93
|
+
def mouse_move(self, x: Any, y: Any) -> str:
|
|
94
|
+
px, py = self.validate_point(x, y)
|
|
95
|
+
self.mouse.move(px, py)
|
|
96
|
+
return f"Moved pointer to ({px}, {py}).\n" + self._state_after(px, py)
|
|
97
|
+
|
|
98
|
+
def mouse_click(self, x: Any, y: Any, button: str = "left", double: bool = False) -> str:
|
|
99
|
+
px, py = self.validate_point(x, y)
|
|
100
|
+
if button not in ("left", "right"):
|
|
101
|
+
raise ComputerError("button must be 'left' or 'right'.")
|
|
102
|
+
self.mouse.click(px, py, button=button, double=double)
|
|
103
|
+
kind = "Double-clicked" if double else f"{button.capitalize()}-clicked"
|
|
104
|
+
return f"{kind} at ({px}, {py}).\n" + self._state_after(px, py)
|
|
105
|
+
|
|
106
|
+
def mouse_drag(self, x1: Any, y1: Any, x2: Any, y2: Any) -> str:
|
|
107
|
+
sx, sy = self.validate_point(x1, y1)
|
|
108
|
+
ex, ey = self.validate_point(x2, y2)
|
|
109
|
+
self.mouse.drag(sx, sy, ex, ey)
|
|
110
|
+
return f"Dragged ({sx}, {sy}) -> ({ex}, {ey}).\n" + self._state_after(ex, ey)
|
|
111
|
+
|
|
112
|
+
def mouse_scroll(self, amount: Any, x: Any = None, y: Any = None) -> str:
|
|
113
|
+
try:
|
|
114
|
+
notches = int(amount)
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
raise ComputerError(f"Scroll amount must be an integer, got {amount!r}.")
|
|
117
|
+
notches = max(-50, min(50, notches))
|
|
118
|
+
point = None
|
|
119
|
+
if x is not None and y is not None:
|
|
120
|
+
point = self.validate_point(x, y)
|
|
121
|
+
self.mouse.scroll(notches, *(point or (None, None)))
|
|
122
|
+
where = f" at {point}" if point else ""
|
|
123
|
+
return f"Scrolled {notches} notches{where}.\n" + self._state_after(*(point or (None, None)))
|
|
124
|
+
|
|
125
|
+
# --- keyboard ------------------------------------------------------------
|
|
126
|
+
def type_text(self, text: str) -> str:
|
|
127
|
+
try:
|
|
128
|
+
self.keyboard.type_text(str(text))
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
raise ComputerError(str(exc))
|
|
131
|
+
shown = str(text) if len(str(text)) <= 60 else str(text)[:60] + "..."
|
|
132
|
+
return f"Typed {len(str(text))} chars ({shown!r}).\n" + self._state_after()
|
|
133
|
+
|
|
134
|
+
def hotkey(self, keys: list[str]) -> str:
|
|
135
|
+
try:
|
|
136
|
+
self.keyboard.hotkey([str(k) for k in keys])
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise ComputerError(str(exc))
|
|
139
|
+
return f"Pressed {'+'.join(str(k) for k in keys)}.\n" + self._state_after()
|
|
140
|
+
|
|
141
|
+
# --- windows -------------------------------------------------------------
|
|
142
|
+
def list_windows(self) -> str:
|
|
143
|
+
windows = self.windows.list_windows()
|
|
144
|
+
if not windows:
|
|
145
|
+
return "No open windows found."
|
|
146
|
+
return "Open windows:\n" + "\n".join(f" - {w.describe()}" for w in windows)
|
|
147
|
+
|
|
148
|
+
def focus_window(self, title: str) -> str:
|
|
149
|
+
"""Focus with one retry — Windows foreground rules are flaky."""
|
|
150
|
+
last_error: Exception | None = None
|
|
151
|
+
for attempt in (1, 2):
|
|
152
|
+
try:
|
|
153
|
+
self.windows.focus_window(title)
|
|
154
|
+
time.sleep(_VERIFY_DELAY_S)
|
|
155
|
+
active = self.windows.active_window()
|
|
156
|
+
if active and title.strip().lower() in active.title.lower():
|
|
157
|
+
return f"Focused window.\n[verify] active window: {active.describe()}"
|
|
158
|
+
last_error = None
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
last_error = exc
|
|
161
|
+
time.sleep(0.4) # let the window manager settle, then retry
|
|
162
|
+
if last_error is not None:
|
|
163
|
+
raise ComputerError(f"Could not focus '{title}': {last_error}")
|
|
164
|
+
active = self.windows.active_window()
|
|
165
|
+
raise ComputerError(
|
|
166
|
+
f"Focus did not stick on '{title}'. Active window is "
|
|
167
|
+
f"{active.describe() if active else '(none)'} — check desktop_windows."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def open_app(self, target: str) -> str:
|
|
171
|
+
try:
|
|
172
|
+
message = self.windows.open_app(target)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
raise ComputerError(str(exc))
|
|
175
|
+
time.sleep(1.0) # give the app a moment to show a window
|
|
176
|
+
return message + "\n" + self._state_after()
|
|
177
|
+
|
|
178
|
+
def close_app(self, title: str, force: bool = False) -> str:
|
|
179
|
+
try:
|
|
180
|
+
message = self.windows.close_window(title, force=force)
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
raise ComputerError(str(exc))
|
|
183
|
+
return message + "\n" + self._state_after()
|
|
184
|
+
|
|
185
|
+
# --- screen & vision -----------------------------------------------------
|
|
186
|
+
def screenshot(
|
|
187
|
+
self,
|
|
188
|
+
region: tuple[int, int, int, int] | None = None,
|
|
189
|
+
monitor: int | None = None,
|
|
190
|
+
save_to=None,
|
|
191
|
+
) -> str:
|
|
192
|
+
try:
|
|
193
|
+
path = self.screen.capture(region=region, monitor=monitor, save_to=save_to)
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
raise ComputerError(f"Screenshot failed: {exc}")
|
|
196
|
+
return str(path)
|
|
197
|
+
|
|
198
|
+
def screen_info(self) -> str:
|
|
199
|
+
from . import dpi as _dpi
|
|
200
|
+
|
|
201
|
+
geo = self.screen.geometry()
|
|
202
|
+
lines = [
|
|
203
|
+
f"Virtual desktop: {geo.width}x{geo.height} at ({geo.left}, {geo.top})",
|
|
204
|
+
f"Monitors: {len(geo.monitors)}",
|
|
205
|
+
]
|
|
206
|
+
for m in geo.monitors:
|
|
207
|
+
primary = " (primary)" if m.primary else ""
|
|
208
|
+
# Report each monitor's scale so the model understands why a
|
|
209
|
+
# 1920-wide panel may only be 1280 logical px, etc.
|
|
210
|
+
scale = _dpi.scale_for_point(m.left + m.width // 2, m.top + m.height // 2)
|
|
211
|
+
scale_txt = f" @ {int(scale * 100)}%" if scale != 1.0 else ""
|
|
212
|
+
lines.append(
|
|
213
|
+
f" - Monitor {m.index}: {m.width}x{m.height} at "
|
|
214
|
+
f"({m.left}, {m.top}){scale_txt}{primary}"
|
|
215
|
+
)
|
|
216
|
+
lines.append(getattr(self, "dpi", _dpi.ensure_dpi_awareness()).describe())
|
|
217
|
+
return "\n".join(lines)
|
|
218
|
+
|
|
219
|
+
def see(self, window_title: str | None = None) -> str:
|
|
220
|
+
"""UI-tree snapshot: what is on screen, with clickable coordinates."""
|
|
221
|
+
try:
|
|
222
|
+
title, elements = self.vision.snapshot(window_title)
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
raise ComputerError(str(exc))
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
raise ComputerError(f"Could not read the UI tree: {exc}")
|
|
227
|
+
return self.vision.describe_snapshot(title, elements)
|
|
228
|
+
|
|
229
|
+
def wait(self, seconds: Any) -> str:
|
|
230
|
+
try:
|
|
231
|
+
duration = float(seconds)
|
|
232
|
+
except (TypeError, ValueError):
|
|
233
|
+
raise ComputerError(f"Wait time must be a number, got {seconds!r}.")
|
|
234
|
+
duration = max(0.0, min(duration, MAX_WAIT_S))
|
|
235
|
+
time.sleep(duration)
|
|
236
|
+
return f"Waited {duration:g}s.\n" + self._state_after()
|
|
237
|
+
|
|
238
|
+
# --- registry ------------------------------------------------------------
|
|
239
|
+
def registry_read(self, path: str, name: str = "") -> str:
|
|
240
|
+
try:
|
|
241
|
+
return self.registry.read_value(path, name)
|
|
242
|
+
except FileNotFoundError:
|
|
243
|
+
raise ComputerError(f"Registry key or value not found: {path} : {name}")
|
|
244
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
245
|
+
raise ComputerError(f"Registry read failed: {exc}")
|
|
246
|
+
|
|
247
|
+
def registry_list(self, path: str) -> str:
|
|
248
|
+
try:
|
|
249
|
+
return self.registry.list_key(path)
|
|
250
|
+
except FileNotFoundError:
|
|
251
|
+
raise ComputerError(f"Registry key not found: {path}")
|
|
252
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
253
|
+
raise ComputerError(f"Registry list failed: {exc}")
|
|
254
|
+
|
|
255
|
+
def registry_write(self, path: str, name: str, value: str, value_type: str) -> str:
|
|
256
|
+
try:
|
|
257
|
+
return self.registry.write_value(path, name, value, value_type)
|
|
258
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
259
|
+
raise ComputerError(f"Registry write failed: {exc}")
|
|
260
|
+
|
|
261
|
+
def registry_delete(self, path: str, name: str) -> str:
|
|
262
|
+
try:
|
|
263
|
+
return self.registry.delete_value(path, name)
|
|
264
|
+
except FileNotFoundError:
|
|
265
|
+
raise ComputerError(f"Registry key or value not found: {path} : {name}")
|
|
266
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
267
|
+
raise ComputerError(f"Registry delete failed: {exc}")
|
|
268
|
+
|
|
269
|
+
# --- browser -------------------------------------------------------------
|
|
270
|
+
def browser_navigate(self, url: str) -> str:
|
|
271
|
+
"""Open a URL in the user's default browser."""
|
|
272
|
+
try:
|
|
273
|
+
return self.browser.navigate(url)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
raise ComputerError(f"Browser navigation failed: {exc}")
|
|
276
|
+
|
|
277
|
+
def browser_search(self, query: str, engine: str = "google") -> str:
|
|
278
|
+
"""Run a web search in the user's default browser."""
|
|
279
|
+
try:
|
|
280
|
+
return self.browser.search(query, engine=engine)
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
raise ComputerError(f"Browser search failed: {exc}")
|
|
283
|
+
|
|
284
|
+
def browser_default(self) -> str:
|
|
285
|
+
"""Report which browser is the system default."""
|
|
286
|
+
try:
|
|
287
|
+
return self.browser.default_browser().describe()
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
raise ComputerError(f"Could not determine the default browser: {exc}")
|
|
290
|
+
|
|
291
|
+
def browser_click(self, selector: str, selector_type: str = "css") -> str:
|
|
292
|
+
"""Click element in browser."""
|
|
293
|
+
try:
|
|
294
|
+
return self.browser.click_element(selector, selector_type)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
raise ComputerError(f"Browser click failed: {exc}")
|
|
297
|
+
|
|
298
|
+
def browser_type(self, selector: str, text: str, selector_type: str = "css") -> str:
|
|
299
|
+
"""Type into browser element."""
|
|
300
|
+
try:
|
|
301
|
+
return self.browser.type_in_element(selector, text, selector_type)
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
raise ComputerError(f"Browser typing failed: {exc}")
|
|
304
|
+
|
|
305
|
+
def browser_info(self) -> str:
|
|
306
|
+
"""Get current browser page info."""
|
|
307
|
+
try:
|
|
308
|
+
return self.browser.get_page_info()
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
raise ComputerError(f"Browser info failed: {exc}")
|
|
311
|
+
|
|
312
|
+
def browser_find(self, selector: str, selector_type: str = "css") -> str:
|
|
313
|
+
"""Find elements in browser."""
|
|
314
|
+
try:
|
|
315
|
+
return self.browser.find_elements(selector, selector_type)
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
raise ComputerError(f"Browser find failed: {exc}")
|
|
318
|
+
|
|
319
|
+
def browser_close(self) -> str:
|
|
320
|
+
"""Close browser."""
|
|
321
|
+
try:
|
|
322
|
+
return self.browser.close_browser()
|
|
323
|
+
except Exception as exc:
|
|
324
|
+
raise ComputerError(f"Browser close failed: {exc}")
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Skill dispatcher: the coordinator between AI decisions and deterministic work.
|
|
2
|
+
|
|
3
|
+
The dispatcher is the single choke point through which every AI-selected action
|
|
4
|
+
flows. It:
|
|
5
|
+
|
|
6
|
+
1. looks up the skill (or builds an ad-hoc action for a semantic UI verb),
|
|
7
|
+
2. executes it deterministically via the :class:`SkillContext`,
|
|
8
|
+
3. verifies the declared outcome with the :class:`VerificationEngine`,
|
|
9
|
+
4. on failure, runs the :class:`RecoveryEngine`'s local strategy ladder,
|
|
10
|
+
5. records every stage in an :class:`ExecutionLog`,
|
|
11
|
+
6. returns a :class:`DispatchResult` — verified success, or a failure carrying
|
|
12
|
+
a replan hint the AI planner uses (and only then) to think again.
|
|
13
|
+
|
|
14
|
+
The AI never sees the inner steps or any coordinates. It selects a skill by
|
|
15
|
+
name or issues a semantic UI verb with element *descriptions*; the dispatcher
|
|
16
|
+
and the engines below it do the rest.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .logbook import ExecutionLog
|
|
25
|
+
from .recovery import RecoveryEngine
|
|
26
|
+
from .skills import Outcome, Skill, SkillContext, SkillError, SkillRegistry
|
|
27
|
+
from .verifier import VerificationEngine
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class DispatchResult:
|
|
32
|
+
ok: bool
|
|
33
|
+
detail: str
|
|
34
|
+
log: ExecutionLog
|
|
35
|
+
replan_hint: str | None = None
|
|
36
|
+
|
|
37
|
+
def for_model(self) -> str:
|
|
38
|
+
"""Concise text the AI reads back — verified evidence, never raw steps."""
|
|
39
|
+
head = self.detail
|
|
40
|
+
if not self.ok and self.replan_hint:
|
|
41
|
+
return f"{head}\nReplan hint: {self.replan_hint}"
|
|
42
|
+
return head
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Semantic UI verbs the AI may issue directly (no named skill). Each maps to a
|
|
46
|
+
# deterministic body over the resolver + controller; every target is a
|
|
47
|
+
# *description*, resolved to coordinates internally.
|
|
48
|
+
class _SemanticActions:
|
|
49
|
+
"""Ad-hoc, description-driven UI actions built on the resolver."""
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def click(ctx: SkillContext, params: dict[str, Any], *, button="left", double=False) -> Outcome:
|
|
53
|
+
target = str(params.get("target", "")).strip()
|
|
54
|
+
if not target:
|
|
55
|
+
raise SkillError("ui action requires a 'target' description")
|
|
56
|
+
detail = ctx.click_described(target, button=button, double=double)
|
|
57
|
+
return Outcome(detail, {"element": target})
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def type(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
|
|
61
|
+
target = str(params.get("target", "")).strip()
|
|
62
|
+
text = str(params.get("text", ""))
|
|
63
|
+
secret = bool(params.get("secret", False))
|
|
64
|
+
if not target:
|
|
65
|
+
raise SkillError("ui_type requires a 'target' description")
|
|
66
|
+
detail = ctx.type_into(target, text, secret=secret)
|
|
67
|
+
return Outcome(detail)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def wait_for(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
|
|
71
|
+
target = str(params.get("target") or params.get("description", "")).strip()
|
|
72
|
+
if not target:
|
|
73
|
+
raise SkillError("ui_wait_for requires a 'target' description")
|
|
74
|
+
return Outcome(f"waiting for {target}", {"element": target})
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def assert_(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
|
|
78
|
+
target = str(params.get("target") or params.get("description", "")).strip()
|
|
79
|
+
if not target:
|
|
80
|
+
raise SkillError("ui_assert requires a 'target' description")
|
|
81
|
+
return Outcome(f"asserting {target}", {"element": target})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_SEMANTIC = {
|
|
85
|
+
"ui_click": lambda c, p: _SemanticActions.click(c, p),
|
|
86
|
+
"ui_double_click": lambda c, p: _SemanticActions.click(c, p, double=True),
|
|
87
|
+
"ui_right_click": lambda c, p: _SemanticActions.click(c, p, button="right"),
|
|
88
|
+
"ui_type": lambda c, p: _SemanticActions.type(c, p),
|
|
89
|
+
"ui_wait_for": lambda c, p: _SemanticActions.wait_for(c, p),
|
|
90
|
+
"ui_assert": lambda c, p: _SemanticActions.assert_(c, p),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Semantic verbs that actually manipulate the UI. Observation verbs
|
|
94
|
+
# (ui_wait_for, ui_assert) are harmless on a browser and stay allowed.
|
|
95
|
+
_MUTATING_SEMANTIC = ("ui_click", "ui_double_click", "ui_right_click", "ui_type")
|
|
96
|
+
|
|
97
|
+
# Window-title fragments that identify a browser. Titles end with the browser
|
|
98
|
+
# name on every mainstream platform ("YouTube — Google Chrome").
|
|
99
|
+
_BROWSER_MARKERS = (
|
|
100
|
+
"google chrome", "chrome", "microsoft edge", "edge", "mozilla firefox",
|
|
101
|
+
"firefox", "brave", "opera", "vivaldi", "chromium", "safari",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_browser_window(title: str | None) -> bool:
|
|
106
|
+
"""Whether a window title belongs to a web browser."""
|
|
107
|
+
low = (title or "").strip().lower()
|
|
108
|
+
if not low:
|
|
109
|
+
return False
|
|
110
|
+
return any(marker in low for marker in _BROWSER_MARKERS)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _browser_skill_names(registry: SkillRegistry) -> str:
|
|
114
|
+
"""The browser skills to point the planner at, from the live registry."""
|
|
115
|
+
wanted = (
|
|
116
|
+
"youtube_play", "youtube_search", "google_search", "web_search",
|
|
117
|
+
"open_url", "new_tab", "close_tab", "switch_tab", "back", "forward",
|
|
118
|
+
"refresh",
|
|
119
|
+
)
|
|
120
|
+
available = [name for name in wanted if registry.get(name) is not None]
|
|
121
|
+
return ", ".join(available)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SkillDispatcher:
|
|
125
|
+
"""Coordinates execute → verify → recover for one action at a time."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
controller: Any,
|
|
130
|
+
resolver: Any,
|
|
131
|
+
state: Any,
|
|
132
|
+
permissions: Any,
|
|
133
|
+
registry: SkillRegistry,
|
|
134
|
+
verifier: VerificationEngine | None = None,
|
|
135
|
+
recovery: RecoveryEngine | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
self._ctx = SkillContext(
|
|
138
|
+
controller=controller, resolver=resolver, state=state, permissions=permissions
|
|
139
|
+
)
|
|
140
|
+
self._registry = registry
|
|
141
|
+
self._verifier = verifier or VerificationEngine(
|
|
142
|
+
vision=getattr(controller, "vision", None),
|
|
143
|
+
windows=getattr(controller, "windows", None),
|
|
144
|
+
)
|
|
145
|
+
self._recovery = recovery or RecoveryEngine(controller=controller, state=state)
|
|
146
|
+
|
|
147
|
+
def _refuse_browser_ui(self, verb: str, params: dict[str, Any]) -> str | None:
|
|
148
|
+
"""The refusal message for a ui_* verb on a browser, or None to allow.
|
|
149
|
+
|
|
150
|
+
Deliberately fails *open*: if the foreground window cannot be read, the
|
|
151
|
+
action proceeds. A guard that blocked on uncertainty would break native
|
|
152
|
+
automation on any machine whose window driver is unavailable.
|
|
153
|
+
"""
|
|
154
|
+
controller = getattr(self._ctx, "controller", None)
|
|
155
|
+
windows = getattr(controller, "windows", None)
|
|
156
|
+
if windows is None:
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
active = windows.active_window()
|
|
160
|
+
except Exception:
|
|
161
|
+
return None
|
|
162
|
+
title = getattr(active, "title", None)
|
|
163
|
+
if not _is_browser_window(title):
|
|
164
|
+
return None
|
|
165
|
+
target = str(params.get("target", "")).strip()
|
|
166
|
+
skills = _browser_skill_names(self._registry)
|
|
167
|
+
return (
|
|
168
|
+
f"{verb} is not available on browser windows"
|
|
169
|
+
f'{f" (target: {target!r})" if target else ""}. '
|
|
170
|
+
"Browser work is handled by complete skills that navigate, dismiss "
|
|
171
|
+
"popups, select results, and verify the outcome in one step — "
|
|
172
|
+
"clicking through a page yourself is slower and breaks whenever the "
|
|
173
|
+
f"site changes. Use computer_run with one of: {skills}. "
|
|
174
|
+
"For example, to play a song: "
|
|
175
|
+
'computer_run(skill="youtube_play", params={"query": "<song name>"}).'
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def dispatch(
|
|
179
|
+
self, name: str, params: dict[str, Any] | None = None, expected: dict[str, Any] | None = None
|
|
180
|
+
) -> DispatchResult:
|
|
181
|
+
"""Run one skill or semantic UI verb end-to-end with verification.
|
|
182
|
+
|
|
183
|
+
Every outcome — success, failure, and everything the engine did on the
|
|
184
|
+
way — is appended to the structured execution log on disk.
|
|
185
|
+
"""
|
|
186
|
+
result = self._dispatch(name, params, expected)
|
|
187
|
+
result.log.persist(label=str(name).strip().lower())
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
def _dispatch(
|
|
191
|
+
self, name: str, params: dict[str, Any] | None = None, expected: dict[str, Any] | None = None
|
|
192
|
+
) -> DispatchResult:
|
|
193
|
+
log = ExecutionLog()
|
|
194
|
+
params = params or {}
|
|
195
|
+
name = str(name).strip().lower()
|
|
196
|
+
|
|
197
|
+
skill = self._registry.get(name)
|
|
198
|
+
semantic = _SEMANTIC.get(name)
|
|
199
|
+
if skill is None and semantic is None:
|
|
200
|
+
log.done(f"unknown skill or action '{name}'", ok=False)
|
|
201
|
+
return DispatchResult(
|
|
202
|
+
False, f"No such skill '{name}'.", log,
|
|
203
|
+
replan_hint="Choose a skill from the catalog or a ui_* action.",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
label = skill.name if skill else name
|
|
207
|
+
log.plan(f"selected {label} {params}")
|
|
208
|
+
|
|
209
|
+
# --- browser guard ---------------------------------------------------
|
|
210
|
+
# Browser workflows are owned end-to-end by the browser skills. Letting
|
|
211
|
+
# the AI click its way around a web page is what this architecture
|
|
212
|
+
# exists to prevent: it burns turns, breaks on every layout change, and
|
|
213
|
+
# drags OCR into a problem a URL already solves. So a mutating ui_*
|
|
214
|
+
# verb aimed at a browser window is refused with a pointer to the skill
|
|
215
|
+
# that does the whole job.
|
|
216
|
+
if name in _MUTATING_SEMANTIC:
|
|
217
|
+
refusal = self._refuse_browser_ui(name, params)
|
|
218
|
+
if refusal is not None:
|
|
219
|
+
log.done(refusal, ok=False)
|
|
220
|
+
return DispatchResult(False, refusal, log, replan_hint=refusal)
|
|
221
|
+
|
|
222
|
+
# --- execute ---------------------------------------------------------
|
|
223
|
+
def _run() -> Outcome:
|
|
224
|
+
if skill is not None:
|
|
225
|
+
return skill.run(self._ctx, params)
|
|
226
|
+
return semantic(self._ctx, params)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
outcome = _run()
|
|
230
|
+
except SkillError as exc:
|
|
231
|
+
log.execute(str(exc), ok=False)
|
|
232
|
+
log.done(f"{label} failed: {exc}", ok=False)
|
|
233
|
+
return DispatchResult(False, str(exc), log, replan_hint=str(exc))
|
|
234
|
+
except Exception as exc: # permission denial, driver error, ...
|
|
235
|
+
log.execute(f"{type(exc).__name__}: {exc}", ok=False)
|
|
236
|
+
log.done(f"{label} failed: {exc}", ok=False)
|
|
237
|
+
return DispatchResult(
|
|
238
|
+
False, f"{label} could not run: {exc}", log,
|
|
239
|
+
replan_hint=f"{type(exc).__name__}: {exc}",
|
|
240
|
+
)
|
|
241
|
+
log.execute(outcome.detail)
|
|
242
|
+
|
|
243
|
+
# Caller-supplied expectation overrides the skill's own.
|
|
244
|
+
want = expected or outcome.expected
|
|
245
|
+
|
|
246
|
+
# --- verify ----------------------------------------------------------
|
|
247
|
+
result = self._verifier.verify(want)
|
|
248
|
+
if result.ok:
|
|
249
|
+
log.verify(result.detail)
|
|
250
|
+
log.done(outcome.detail)
|
|
251
|
+
return DispatchResult(True, outcome.detail, log)
|
|
252
|
+
log.verify(result.detail, ok=False)
|
|
253
|
+
|
|
254
|
+
# --- recover ---------------------------------------------------------
|
|
255
|
+
recovery = self._recovery.recover(
|
|
256
|
+
action=lambda: _run().detail,
|
|
257
|
+
verify=lambda: self._verifier.verify(want),
|
|
258
|
+
window_title=outcome.window_title,
|
|
259
|
+
app_target=outcome.app_target,
|
|
260
|
+
)
|
|
261
|
+
if recovery.recovered:
|
|
262
|
+
log.recover(f"{recovery.detail} (tried: {', '.join(recovery.strategies_tried)})")
|
|
263
|
+
log.done(outcome.detail)
|
|
264
|
+
return DispatchResult(True, outcome.detail, log)
|
|
265
|
+
|
|
266
|
+
log.recover(recovery.detail, ok=False)
|
|
267
|
+
hint = (
|
|
268
|
+
f"'{label}' executed but verification failed ({result.detail}); local "
|
|
269
|
+
f"recovery ({', '.join(recovery.strategies_tried) or 'none'}) did not help."
|
|
270
|
+
)
|
|
271
|
+
log.done(hint, ok=False)
|
|
272
|
+
return DispatchResult(False, hint, log, replan_hint=hint)
|