Files
GUI-Agent/SystemTest-Agent/app/agent_core/tools.py
T

791 lines
31 KiB
Python

from __future__ import annotations
import hashlib
import html
import platform
import re
import ssl
import time
import webbrowser
import ctypes
import ctypes.wintypes
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urldefrag, urljoin, urlparse
from urllib.request import Request, urlopen
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
UNVERIFIED_HTTPS_CONTEXT = ssl._create_unverified_context()
MAX_HTML_BYTES = 1_500_000
MAX_CHILD_TASKS = 8
def network_error_message(exc: BaseException, timeout_seconds: float) -> str:
reason = getattr(exc, "reason", exc)
if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError):
return f"timed out after {timeout_seconds:g}s"
return str(reason) or type(reason).__name__
@dataclass
class ToolResult:
ok: bool
result: dict[str, Any] = field(default_factory=dict)
error: str = ""
duration_seconds: float = 0.0
def clean_text(value: Any, max_length: int = 160) -> str:
text = html.unescape(re.sub(r"\s+", " ", str(value or ""))).strip()
return text[:max_length].strip()
def decode_body(raw: bytes, content_type: str) -> str:
charset = "utf-8"
match = re.search(r"charset=([^;\s]+)", content_type or "", re.IGNORECASE)
if match:
charset = match.group(1).strip("\"'")
for candidate in [charset, "utf-8", "gbk", "latin-1"]:
try:
return raw.decode(candidate, errors="replace")
except LookupError:
continue
return raw.decode("utf-8", errors="replace")
def extract_title(raw: bytes, content_type: str) -> str:
text = decode_body(raw, content_type)
match = TITLE_RE.search(text)
if not match:
return ""
return clean_text(match.group(1), 120)
def normalize_url(value: Any) -> str:
text = str(value or "").strip()
if text and "://" not in text:
text = f"http://{text}"
return text
def origin_tuple(url: str) -> tuple[str, str, int]:
parsed = urlparse(url)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return parsed.scheme, (parsed.hostname or "").lower(), port
def same_origin(base_url: str, candidate_url: str) -> bool:
try:
return origin_tuple(base_url) == origin_tuple(candidate_url)
except ValueError:
return False
def safe_join_url(base_url: str, href: str) -> str | None:
text = str(href or "").strip()
if not text or text.startswith(("#", "javascript:", "mailto:", "tel:", "data:")):
return None
joined = urljoin(base_url, text)
joined, _fragment = urldefrag(joined)
parsed = urlparse(joined)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
if not same_origin(base_url, joined):
return None
return joined
def stable_id(*parts: Any, prefix: str = "agent") -> str:
raw = "|".join(str(part or "") for part in parts)
return f"{prefix}-{hashlib.sha1(raw.encode('utf-8')).hexdigest()[:12]}"
def risk_for_text(text: str) -> str:
lowered = text.lower()
high = ["delete", "remove", "drop", "pay", "refund", "logout", "删除", "清空", "支付", "付款", "退款", "注销"]
medium = ["save", "submit", "create", "update", "edit", "upload", "保存", "提交", "新增", "修改", "上传"]
if any(word in lowered for word in high):
return "high"
if any(word in lowered for word in medium):
return "medium"
return "low"
class WebPageParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title_parts: list[str] = []
self.links: list[dict[str, Any]] = []
self.forms: list[dict[str, Any]] = []
self.buttons: list[dict[str, Any]] = []
self._in_title = False
self._anchors: list[dict[str, Any]] = []
self._form: dict[str, Any] | None = None
self._button: dict[str, Any] | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
attr = {name.lower(): value or "" for name, value in attrs}
if tag == "title":
self._in_title = True
elif tag == "a":
self._anchors.append({"href": attr.get("href", ""), "text_parts": []})
elif tag == "form":
self._form = {
"action": attr.get("action", ""),
"method": (attr.get("method") or "get").upper(),
"id": attr.get("id", ""),
"name": attr.get("name", ""),
"class": attr.get("class", ""),
"text_parts": [],
"fields": [],
}
elif self._form is not None and tag in {"input", "select", "textarea"}:
self._form["fields"].append(
{
"tag": tag,
"type": (attr.get("type") or tag).lower(),
"name": attr.get("name", ""),
"id": attr.get("id", ""),
"placeholder": attr.get("placeholder", ""),
"aria_label": attr.get("aria-label", ""),
}
)
elif tag == "button":
self._button = {
"type": attr.get("type", ""),
"id": attr.get("id", ""),
"name": attr.get("name", ""),
"class": attr.get("class", ""),
"text_parts": [],
"in_form": self._form is not None,
}
def handle_data(self, data: str) -> None:
if self._in_title:
self.title_parts.append(data)
if self._anchors:
self._anchors[-1]["text_parts"].append(data)
if self._form is not None:
self._form["text_parts"].append(data)
if self._button is not None:
self._button["text_parts"].append(data)
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if tag == "title":
self._in_title = False
elif tag == "a" and self._anchors:
anchor = self._anchors.pop()
self.links.append({"href": anchor.get("href", ""), "text": clean_text(" ".join(anchor.get("text_parts") or []), 120)})
elif tag == "button" and self._button is not None:
button = self._button
button["text"] = clean_text(" ".join(button.get("text_parts") or []), 120)
self.buttons.append(button)
self._button = None
elif tag == "form" and self._form is not None:
form = self._form
form["text"] = clean_text(" ".join(form.get("text_parts") or []), 160)
self.forms.append(form)
self._form = None
def looks_like_login_form(form: dict[str, Any]) -> bool:
fields = form.get("fields") or []
text = " ".join(
[
str(form.get("id") or ""),
str(form.get("name") or ""),
str(form.get("class") or ""),
str(form.get("text") or ""),
*[
" ".join(
[
str(field.get("type") or ""),
str(field.get("name") or ""),
str(field.get("id") or ""),
str(field.get("placeholder") or ""),
str(field.get("aria_label") or ""),
]
)
for field in fields
],
]
).lower()
return "password" in text or any(keyword in text for keyword in ["login", "signin", "sign in", "登录", "账号", "密码"])
class WebTools:
def open_in_browser(self, url: str, wait_seconds: float = 1.5) -> ToolResult:
target_url = normalize_url(url)
if not target_url:
return ToolResult(ok=False, error="Missing target URL.")
started = time.monotonic()
try:
opened = bool(webbrowser.open(target_url, new=2, autoraise=True))
if wait_seconds > 0:
time.sleep(min(max(wait_seconds, 0.0), 5.0))
return ToolResult(
ok=opened,
result={"url": target_url, "opened": opened},
error="" if opened else "Default browser did not accept the URL.",
duration_seconds=round(time.monotonic() - started, 3),
)
except Exception as exc:
return ToolResult(
ok=False,
error=str(exc),
duration_seconds=round(time.monotonic() - started, 3),
)
def observe_page(self, url: str, timeout_seconds: float = 8) -> ToolResult:
target_url = normalize_url(url)
if not target_url:
return ToolResult(ok=False, error="Missing target URL.")
request = Request(
target_url,
headers={
"Accept": "text/html,application/xhtml+xml,*/*",
"User-Agent": "SystemTest-Agent/0.1 WebTools",
},
)
started = time.monotonic()
try:
with urlopen(request, timeout=timeout_seconds, context=UNVERIFIED_HTTPS_CONTEXT) as response:
raw = response.read(MAX_HTML_BYTES)
status = response.status
final_url = response.geturl()
content_type = response.headers.get("Content-Type", "")
except HTTPError as exc:
raw = exc.read(MAX_HTML_BYTES)
status = exc.code
final_url = exc.geturl()
content_type = exc.headers.get("Content-Type", "")
except (URLError, TimeoutError, OSError) as exc:
return ToolResult(
ok=False,
error=f"Target URL is not reachable: {network_error_message(exc, timeout_seconds)}",
duration_seconds=round(time.monotonic() - started, 3),
)
text = decode_body(raw, content_type)
parser = WebPageParser()
try:
parser.feed(text)
except Exception:
pass
title = clean_text(" ".join(parser.title_parts), 120) or extract_title(raw, content_type)
duration = round(time.monotonic() - started, 3)
has_login_form = any(looks_like_login_form(form) for form in parser.forms)
auth_challenge = status in {401, 403}
return ToolResult(
ok=status < 500,
duration_seconds=duration,
result={
"url": target_url,
"final_url": final_url,
"http_status": status,
"content_type": content_type,
"page_title": title,
"elapsed_seconds": duration,
"links": parser.links[:50],
"forms": parser.forms[:20],
"buttons": parser.buttons[:50],
"requires_auth": auth_challenge,
"auth_challenge": auth_challenge,
"has_login_form": has_login_form,
},
)
def discover_child_tasks(
self,
parent_task: dict[str, Any],
observation: dict[str, Any],
*,
max_children: int = MAX_CHILD_TASKS,
max_depth: int = 2,
) -> list[dict[str, Any]]:
payload = parent_task.get("payload") if isinstance(parent_task.get("payload"), dict) else {}
parent_external_id = str(parent_task.get("external_id") or parent_task.get("id") or "")
root_external_id = str(parent_task.get("root_external_id") or payload.get("root_external_id") or parent_external_id)
parent_depth = int(parent_task.get("depth") or payload.get("depth") or 0)
discovery_max_depth = int(payload.get("discovery_max_depth") or max_depth)
child_depth = parent_depth + 1
if child_depth > discovery_max_depth:
return []
page_url = observation.get("final_url") or observation.get("url") or payload.get("target_url") or ""
children: list[dict[str, Any]] = []
seen_urls: set[str] = set()
for link in observation.get("links") or []:
if len(children) >= max_children:
break
href = safe_join_url(page_url, str(link.get("href") or ""))
if not href or href in seen_urls:
continue
seen_urls.add(href)
label = clean_text(link.get("text"), 80) or urlparse(href).path.strip("/") or href
risk = risk_for_text(label)
children.append(
self._child_task(
parent_external_id,
root_external_id,
child_depth,
label,
f"探索页面:{label}",
f"访问并检查页面“{label}”是否可达,记录标题、状态码和可交互入口。",
href,
"module",
risk,
"html_link",
discovery_max_depth,
)
)
for index, form in enumerate(observation.get("forms") or [], start=1):
if len(children) >= max_children:
break
action_url = safe_join_url(page_url, str(form.get("action") or "")) or page_url
is_login = looks_like_login_form(form)
label = "登录表单" if is_login else clean_text(form.get("name") or form.get("id") or form.get("text"), 60) or f"表单 {index}"
risk = "medium" if is_login else risk_for_text(label)
child = self._child_task(
parent_external_id,
root_external_id,
child_depth,
label,
f"识别功能:{label}",
f"分析{label}的输入项与提交入口;P0 只确认元素存在,不执行真实提交。",
action_url,
"feature",
risk,
"html_form",
discovery_max_depth,
)
child["payload"]["preconditions"] = ["需要测试账号/密码"] if is_login else []
children.append(child)
for button in observation.get("buttons") or []:
if len(children) >= max_children:
break
label = clean_text(button.get("text") or button.get("name") or button.get("id"), 60)
if not label:
continue
risk = risk_for_text(label)
child = self._child_task(
parent_external_id,
root_external_id,
child_depth,
label,
f"识别入口:{label}",
f"确认入口“{label}”存在;高风险入口只记录,不执行破坏性动作。",
page_url,
"feature",
risk,
"html_button",
discovery_max_depth,
)
if risk == "high":
child["failure_policy"] = "pause"
children.append(child)
unique: dict[str, dict[str, Any]] = {}
for child in children:
unique.setdefault(str(child["external_id"]), child)
return list(unique.values())[:max_children]
def _child_task(
self,
parent_external_id: str,
root_external_id: str,
depth: int,
module: str,
title: str,
objective: str,
target_url: str,
level: str,
risk: str,
source: str,
discovery_max_depth: int,
) -> dict[str, Any]:
external_id = stable_id(root_external_id, parent_external_id, source, target_url, module)
payload = {
"task_type": "smart_discovery_p0",
"instruction": objective,
"objective": objective,
"target_url": target_url,
"source": source,
"level": level,
"depth": depth,
"risk_level": risk,
"allow_child_discovery": level == "module" and depth < discovery_max_depth,
"discovery_max_depth": discovery_max_depth,
"open_in_browser": True,
"capture_screenshot": True,
"hierarchy": {
"id": external_id,
"parent_id": parent_external_id,
"root_id": root_external_id,
"depth": depth,
"level": level,
},
}
return {
"external_id": external_id,
"title": title,
"module": module,
"parent_external_id": parent_external_id,
"root_external_id": root_external_id,
"depth": depth,
"task_level": level,
"risk_level": risk,
"priority": 40 + depth * 10 + (40 if risk == "high" else 20 if risk == "medium" else 0),
"failure_policy": "pause" if risk == "high" else "skip",
"max_retries": 2,
"timeout_seconds": 60,
"tags": ["agent-discovered", "p0"],
"payload": payload,
}
class GuiTools:
def __init__(self) -> None:
self._user32 = ctypes.windll.user32 if self.available() else None
self._enum_proc_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM) if self.available() else None
if self._user32 and self._enum_proc_type:
self._user32.EnumWindows.argtypes = [ctypes.c_void_p, ctypes.wintypes.LPARAM]
self._user32.EnumWindows.restype = ctypes.c_bool
self._user32.EnumChildWindows.argtypes = [ctypes.wintypes.HWND, ctypes.c_void_p, ctypes.wintypes.LPARAM]
self._user32.EnumChildWindows.restype = ctypes.c_bool
def available(self) -> bool:
return platform.system() == "Windows" and hasattr(ctypes, "windll")
def backend_status(self) -> dict[str, Any]:
if not self.available():
return {
"available": False,
"backend": "ctypes-win32",
"reason": "Windows GUI APIs are unavailable on this platform.",
}
return {
"available": True,
"backend": "ctypes-win32",
"capabilities": [
"list_windows",
"inspect_window_controls",
"capture_desktop",
"capture_window",
"focus_window",
"click_window_point",
],
}
def list_windows(self, limit: int = 80) -> ToolResult:
started = time.monotonic()
if not self.available():
return ToolResult(ok=False, error=self.backend_status()["reason"])
try:
windows = self._enum_windows(limit=limit)
return ToolResult(
ok=True,
result={"windows": windows, "backend": self.backend_status()},
duration_seconds=round(time.monotonic() - started, 3),
)
except Exception as exc:
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
def inspect_window(self, window_id: str, limit: int = 220) -> ToolResult:
started = time.monotonic()
if not self.available():
return ToolResult(ok=False, error=self.backend_status()["reason"])
try:
hwnd = self._parse_hwnd(window_id)
if not self._user32.IsWindow(hwnd):
return ToolResult(ok=False, error=f"Window not found: {window_id}")
return ToolResult(
ok=True,
result={
"window": self._window_info(hwnd),
"controls": self._enum_child_windows(hwnd, limit=limit),
"backend": self.backend_status(),
},
duration_seconds=round(time.monotonic() - started, 3),
)
except Exception as exc:
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
def capture_desktop(self, save_dir: Path, prefix: str = "desktop") -> ToolResult:
started = time.monotonic()
try:
from PIL import ImageGrab
save_dir.mkdir(parents=True, exist_ok=True)
image = ImageGrab.grab(all_screens=True)
path = save_dir / f"{prefix}_{int(time.time() * 1000)}.png"
image.save(path)
return ToolResult(
ok=True,
result={"image_path": str(path), "width": image.width, "height": image.height},
duration_seconds=round(time.monotonic() - started, 3),
)
except Exception as exc:
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
def capture_window(self, window_id: str, save_dir: Path, prefix: str = "window") -> ToolResult:
started = time.monotonic()
if not self.available():
return ToolResult(ok=False, error=self.backend_status()["reason"])
try:
from PIL import ImageGrab
hwnd = self._parse_hwnd(window_id)
if not self._user32.IsWindow(hwnd):
return ToolResult(ok=False, error=f"Window not found: {window_id}")
rect = self._rect(hwnd)
if rect["width"] <= 0 or rect["height"] <= 0:
return ToolResult(ok=False, error=f"Window has invalid bounds: {window_id}")
save_dir.mkdir(parents=True, exist_ok=True)
image = ImageGrab.grab(bbox=(rect["left"], rect["top"], rect["right"], rect["bottom"]))
path = save_dir / f"{prefix}_{window_id}_{int(time.time() * 1000)}.png"
image.save(path)
return ToolResult(
ok=True,
result={
"image_path": str(path),
"window": self._window_info(hwnd),
"width": image.width,
"height": image.height,
},
duration_seconds=round(time.monotonic() - started, 3),
)
except Exception as exc:
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
def focus_window(self, window_id: str) -> ToolResult:
started = time.monotonic()
if not self.available():
return ToolResult(ok=False, error=self.backend_status()["reason"])
try:
hwnd = self._parse_hwnd(window_id)
if not self._user32.IsWindow(hwnd):
return ToolResult(ok=False, error=f"Window not found: {window_id}")
self._user32.ShowWindow(hwnd, 9) # SW_RESTORE
ok = bool(self._user32.SetForegroundWindow(hwnd))
return ToolResult(
ok=ok,
result={"window": self._window_info(hwnd), "focused": ok},
error="" if ok else "SetForegroundWindow returned false.",
duration_seconds=round(time.monotonic() - started, 3),
)
except Exception as exc:
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
def click_window_point(self, window_id: str, x: float, y: float, normalized: bool = True) -> ToolResult:
started = time.monotonic()
if not self.available():
return ToolResult(ok=False, error=self.backend_status()["reason"])
try:
hwnd = self._parse_hwnd(window_id)
if not self._user32.IsWindow(hwnd):
return ToolResult(ok=False, error=f"Window not found: {window_id}")
rect = self._rect(hwnd)
if normalized:
target_x = int(rect["left"] + max(0.0, min(1.0, float(x))) * rect["width"])
target_y = int(rect["top"] + max(0.0, min(1.0, float(y))) * rect["height"])
else:
target_x = int(x)
target_y = int(y)
self.focus_window(window_id)
self._user32.SetCursorPos(target_x, target_y)
self._user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN
self._user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP
return ToolResult(
ok=True,
result={"window_id": str(window_id), "x": target_x, "y": target_y, "normalized": normalized},
duration_seconds=round(time.monotonic() - started, 3),
)
except Exception as exc:
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
def _parse_hwnd(self, window_id: str) -> int:
text = str(window_id).strip()
if text.lower().startswith("0x"):
return int(text, 16)
return int(text)
def _window_text(self, hwnd: int, max_length: int = 512) -> str:
length = min(max(self._user32.GetWindowTextLengthW(hwnd), 0) + 1, max_length)
buffer = ctypes.create_unicode_buffer(length)
self._user32.GetWindowTextW(hwnd, buffer, length)
return clean_text(buffer.value, 200)
def _class_name(self, hwnd: int) -> str:
buffer = ctypes.create_unicode_buffer(256)
self._user32.GetClassNameW(hwnd, buffer, 256)
return clean_text(buffer.value, 120)
def _rect(self, hwnd: int) -> dict[str, int]:
rect = ctypes.wintypes.RECT()
self._user32.GetWindowRect(hwnd, ctypes.byref(rect))
return {
"left": int(rect.left),
"top": int(rect.top),
"right": int(rect.right),
"bottom": int(rect.bottom),
"width": max(0, int(rect.right - rect.left)),
"height": max(0, int(rect.bottom - rect.top)),
}
def _process_id(self, hwnd: int) -> int:
pid = ctypes.wintypes.DWORD()
self._user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
return int(pid.value)
def _window_info(self, hwnd: int) -> dict[str, Any]:
return {
"id": str(int(hwnd)),
"hex_id": hex(int(hwnd)),
"title": self._window_text(hwnd),
"class_name": self._class_name(hwnd),
"process_id": self._process_id(hwnd),
"visible": bool(self._user32.IsWindowVisible(hwnd)),
"enabled": bool(self._user32.IsWindowEnabled(hwnd)),
"minimized": bool(self._user32.IsIconic(hwnd)),
"rect": self._rect(hwnd),
}
def _enum_windows(self, limit: int = 80) -> list[dict[str, Any]]:
windows: list[dict[str, Any]] = []
enum_proc = self._enum_proc_type
@enum_proc
def callback(hwnd: int, _lparam: int) -> bool:
if len(windows) >= limit:
return False
if not self._user32.IsWindowVisible(hwnd):
return True
info = self._window_info(hwnd)
rect = info["rect"]
if rect["width"] < 40 or rect["height"] < 40:
return True
if not info["title"]:
return True
if info["title"] or info["class_name"]:
info["index"] = len(windows) + 1
windows.append(info)
return True
self._user32.EnumWindows(ctypes.cast(callback, ctypes.c_void_p), ctypes.wintypes.LPARAM(0))
return windows
def _enum_child_windows(self, hwnd: int, limit: int = 220) -> list[dict[str, Any]]:
controls: list[dict[str, Any]] = []
enum_proc = self._enum_proc_type
@enum_proc
def callback(child_hwnd: int, _lparam: int) -> bool:
if len(controls) >= limit:
return False
rect = self._rect(child_hwnd)
if rect["width"] <= 0 or rect["height"] <= 0:
return True
text = self._window_text(child_hwnd)
class_name = self._class_name(child_hwnd)
if not text and not class_name:
return True
controls.append(
{
"id": str(int(child_hwnd)),
"hex_id": hex(int(child_hwnd)),
"index": len(controls) + 1,
"text": text,
"class_name": class_name,
"visible": bool(self._user32.IsWindowVisible(child_hwnd)),
"enabled": bool(self._user32.IsWindowEnabled(child_hwnd)),
"rect": rect,
}
)
return True
self._user32.EnumChildWindows(hwnd, ctypes.cast(callback, ctypes.c_void_p), ctypes.wintypes.LPARAM(0))
return controls
class AgentToolRegistry:
def __init__(self) -> None:
self.web = WebTools()
self.gui = GuiTools()
def descriptions(self) -> list[dict[str, Any]]:
gui_status = self.gui.backend_status()
return [
{
"name": "open_in_browser",
"scope": "web",
"available": True,
"description": "Open the target URL in the controlled device default browser so the operator can see the test start.",
},
{
"name": "observe_web_page",
"scope": "web",
"available": True,
"description": "Open a web page, read status/title, and list links/forms/buttons without side effects.",
},
{
"name": "discover_web_subtasks",
"scope": "web",
"available": True,
"description": "Create hierarchy-aware P0 child tasks from observed links/forms/buttons.",
},
{
"name": "list_windows",
"scope": "gui",
"available": gui_status["available"],
"description": "List visible Windows desktop application windows using a dependency-free ctypes Win32 backend.",
},
{
"name": "inspect_window_controls",
"scope": "gui",
"available": gui_status["available"],
"description": "Enumerate Win32 child controls for a selected window.",
},
{
"name": "capture_desktop",
"scope": "gui",
"available": True,
"description": "Capture the desktop screenshot with PIL ImageGrab and save it as local evidence.",
},
{
"name": "capture_window",
"scope": "gui",
"available": gui_status["available"],
"description": "Capture a selected window region and save it as local evidence.",
},
{
"name": "focus_window",
"scope": "gui",
"available": gui_status["available"],
"description": "Bring a window to foreground. This is an action tool and should be used only when the task allows it.",
},
{
"name": "click_window_point",
"scope": "gui",
"available": gui_status["available"],
"description": "Click a normalized or absolute point in a selected window. This is guarded and not used by default in P0 observation.",
},
{
"name": "no_action",
"scope": "agent",
"available": True,
"description": "Finish, fail, or wait for user input without executing a UI action.",
},
]