File size: 14,694 Bytes
5655a42 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | """Pure parsing helpers for the cua-driver backend: MCP result flattening, ``list_windows`` /
``get_window_state`` payload normalisation, key combos. No I/O, no module state β every function
depends only on its inputs, so the MCP and CLI transports share them safely."""
from __future__ import annotations
import contextlib
import json
import re
from typing import Any, Dict, List, Optional, Tuple
from tools.computer_use.backend import ActionResult, UIElement, image_dimensions_from_bytes
# Linux/X11 surfaces GNOME Shell / desktop backdrop windows ahead of real app windows with no useful z-order; they
# are targetable but capture as empty, so default capture skips them.
_NON_APP_WINDOW_TITLE_PREFIXES = ("@!", "Desktop", "gnome-shell", "GNOME Shell") # "@!" = GNOME helpers
_ELEMENT_LINE_RE = re.compile(
r'^\s*(?:-\s+)?\[(\d+)\]\s+(\w+)'
r'(?:'
r'\s*=\s*"([^"]*)"' # = "value"
r'|\s+"([^"]*)"' # "value"
r'|\s+\((?!\d+\))([^)]*)\)' # (value) but not a pure-digit (order) number
r')?'
r'(?:\s+(?:\(\d+\)\s+)?id=([^\s\[\]]+))?', # optional id=value (after an optional (order))
re.MULTILINE,
)
"""get_window_state AX-tree markdown line: ``[N] AXRole`` + label in one of four forms (``= "value"``, ``"quoted"``,
``(paren)``, ``id=Label`` optionally after an ``(order)`` number). A parenthesised pure-digit group is an ORDER
index, not a label, and is excluded so the id= label wins. Group 1 index, group 2 role, groups 3-6 the label."""
def _mcp_field(obj, snake: str, camel: str, default=None):
"""Read an MCP model field across the 1.x -> 2.x rename: mcp 2.0 exposes snake_case attributes and keeps
camelCase only as a serialization alias, so ``getattr(result, "isError", False)`` is False for every 2.x result
and a denied call looks like success. Deliberately duplicated from ``tools.mcp_tool.mcp_field`` so computer_use
never loads the much larger config-driven MCP client module."""
return getattr(obj, snake, getattr(obj, camel, default))
def _action_result_from(name: str, ok: bool, message: str, meta: Dict[str, Any],
structured: Dict[str, Any], *, requested_delivery: Optional[str] = None) -> ActionResult:
"""Build an ActionResult, lifting cua-driver's structured verdict. structuredContent is canonical, the flattened
``meta`` copy the fallback. Every structured field is additive: a driver that omits one leaves the attribute
``None`` so old drivers see unchanged behavior.
See the action response shape in cua-driver's mcp-tool-notes and NousResearch/hermes-agent#67052.
"""
sc = structured if isinstance(structured, dict) else {}
def _raw(key: str) -> Any:
return sc.get(key) if key in sc else meta.get(key)
def _typed(value: Any, typ) -> Any:
return value if isinstance(value, typ) else None
return ActionResult(
ok=ok, action=name, message=message, meta=meta,
verified=_typed(_raw("verified"), bool), effect=_typed(_raw("effect"), str),
escalation=_typed(_raw("escalation"), dict), path=_typed(_raw("path"), str),
degraded=_typed(_raw("degraded"), bool),
# What we asked for; the driver's `path` records the rung that ran.
delivery_mode=_typed(requested_delivery, str),
# Refusal/limitation code β drivers spell it "code" or "reason_code".
code=_typed(_raw("code") or _raw("reason_code"), str),
)
def _z_index_uninformative(windows: List[Dict[str, Any]]) -> bool:
"""True when every window shares the same z_index (common on Linux/X11)."""
return len({w.get("z_index", 0) for w in windows}) <= 1
def _parse_xprop_net_active_window(stdout: str) -> Optional[int]:
"""Parse ``xprop -root _NET_ACTIVE_WINDOW`` stdout into a window id: the ``window id # 0x...`` form, falling
back to the first hex token."""
text = stdout or ""
match = re.search(r"window id # (0x[0-9a-fA-F]+)", text) or re.search(r"(0x[0-9a-fA-F]+)", text)
return int(match.group(1), 16) if match else None
def _is_real_app_window(w: Dict[str, Any]) -> bool:
"""Return False for desktop/shell helper windows that capture as empty."""
title = w.get("title", "")
return not any(title.startswith(p) or title.lower().startswith(p.lower()) for p in _NON_APP_WINDOW_TITLE_PREFIXES)
def _parse_elements_from_tree(markdown: str) -> List[UIElement]:
"""Parse UIElements from get_window_state AX-tree markdown β last-resort fallback for drivers without
``structuredContent.elements``. Bounds are always ``(0, 0, 0, 0)`` (the markdown carries none), fine for
element-index clicks since the driver resolves the frame.
Last-resort fallback for cua-driver builds that don't carry the canonical ``structuredContent.elements``
array (see ``_parse_elements_from_structured`` β Surface 2 of #47072 prefers that path).
"""
return [
# groups 3-6: value / quoted / paren / id= label (first non-None wins)
UIElement(index=int(m.group(1)), role=m.group(2),
label=m.group(3) or m.group(4) or m.group(5) or m.group(6) or "", bounds=(0, 0, 0, 0))
for m in _ELEMENT_LINE_RE.finditer(markdown)
]
def _parse_elements_from_structured(raw_elements: List[Dict[str, Any]]) -> List[UIElement]:
"""Read the canonical ``structuredContent.elements`` array: ``element_index``, ``role``, ``label`` and, when
AT-SPI / AXFrame returned usable bounds, ``frame`` ``{x, y, w, h}`` β so real pixel bounds survive (the
markdown path loses them). Malformed entries are skipped.
Surface 2 of NousResearch/hermes-agent#47072: read the canonical ``structuredContent.elements`` array
cua-driver-rs emits on every ``get_window_state`` response (trycua/cua#1961).
"""
elements: List[UIElement] = []
for raw in raw_elements:
idx = raw.get("element_index") if isinstance(raw, dict) else None
if not isinstance(idx, int):
continue
role, label, frame, token = (raw.get(k) for k in ("role", "label", "frame", "element_token"))
bounds: Tuple[int, int, int, int] = (0, 0, 0, 0)
with contextlib.suppress(TypeError, ValueError):
if isinstance(frame, dict) and frame:
bounds = tuple(int(frame.get(k, 0)) for k in ("x", "y", "w", "h")) # type: ignore[assignment]
elements.append(UIElement(
index=idx, role=role if isinstance(role, str) else "", label=label if isinstance(label, str) else "",
bounds=bounds,
# Opaque `s{snapshot_hex}:{index}` token β the driver owns parse + LRU semantics.
element_token=token if isinstance(token, str) and token else None,
))
return elements
def _image_dimensions_from_bytes(raw: bytes) -> Tuple[int, int]:
"""Best-effort PNG/JPEG dimension sniff; ``(0, 0)`` when unreadable or non-positive."""
dims = image_dimensions_from_bytes(raw)
return dims if dims and dims[0] > 0 and dims[1] > 0 else (0, 0)
def _split_tree_text(full_text: str) -> Tuple[str, str]:
"""Split get_window_state text into (summary_line, tree_markdown)."""
summary, _, tree = full_text.partition("\n")
return summary, tree
_MODIFIER_NAMES = frozenset({"cmd", "command", "shift", "option", "alt", "ctrl", "control", "fn"})
_KEY_ALIASES = {"command": "cmd", "alt": "option", "control": "ctrl"}
def _parse_key_combo(keys: str) -> Tuple[Optional[str], List[str]]:
"""Parse 'cmd+s' / 'ctrl-alt-t' into (key, modifiers); last non-modifier wins."""
modifiers: List[str] = []
key = None
for part in (p.strip().lower() for p in re.split(r'[+\-]', keys) if p.strip()):
normalized = _KEY_ALIASES.get(part, part)
if normalized in _MODIFIER_NAMES:
modifiers.append(normalized)
else:
key = part
return key, modifiers
def _tool_envelope(data: Any, images: List[str], structured: Any, is_error: bool,
image_mime_types: Optional[List[str]] = None) -> Dict[str, Any]:
"""The normalised tool-result dict every transport emits: ``{data, images, [image_mime_types,] structuredContent,
isError}``. ``image_mime_types`` is only present when the transport can report it (MCP image parts)."""
out: Dict[str, Any] = {"data": data, "images": images}
if image_mime_types is not None:
out["image_mime_types"] = image_mime_types
out["structuredContent"], out["isError"] = structured, is_error
return out
def _extract_tool_result(mcp_result: Any) -> Dict[str, Any]:
"""Flatten an mcp CallToolResult into ``{data, images, image_mime_types, structuredContent, isError}``. ``data``
is the joined text parts (parsed as JSON when it looks like JSON); ``image_mime_types`` is parallel to
``images`` with ``""`` where the part carried no mimeType (older drivers β callers then sniff the base64 prefix).
`image_mime_types` is the explicit `mimeType` cua-driver emits on every image part as of trycua/cua#1961
(Surface 7 of NousResearch/hermes-agent#47072). Each entry corresponds index-for-index with `images`; an
empty string entry signals the part carried no mimeType (older cua-driver build), and the caller should
fall back to base64-prefix sniffing.
"""
data: Any = None
images: List[str] = []
image_mime_types: List[str] = []
text_chunks: List[str] = []
for part in getattr(mcp_result, "content", []) or []:
ptype = getattr(part, "type", None)
if ptype == "text":
text_chunks.append(getattr(part, "text", "") or "")
elif ptype == "image" and getattr(part, "data", None):
images.append(part.data)
image_mime_types.append(_mcp_field(part, "mime_type", "mimeType") or "")
if text_chunks:
joined = "\n".join(t for t in text_chunks if t)
try:
data = json.loads(joined) if joined.strip().startswith(("{", "[")) else joined
except json.JSONDecodeError:
data = joined
return _tool_envelope(
data, images, _mcp_field(mcp_result, "structured_content", "structuredContent") or None,
# Identity, not truthiness: mocks/proxies synthesize truthy attributes.
_mcp_field(mcp_result, "is_error", "isError", False) is True, image_mime_types)
def _image_from_tool_result(out: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
"""Pull ``(b64, mime_type)`` out of a flattened tool result. cua-driver delivers screenshots as an MCP ``image``
part (``out["images"]``) or as ``screenshot_png_b64`` in structuredContent (newer builds, CLI transport);
checking both keeps capture() robust to the driver moving it."""
images = out.get("images") or []
if images and images[0]:
mimes = out.get("image_mime_types") or []
return images[0], (mimes[0] if mimes and mimes[0] else None)
structured = out.get("structuredContent") or {}
b64 = structured.get("screenshot_png_b64") or structured.get("png_b64")
if b64:
return b64, (structured.get("screenshot_mime_type") or structured.get("mime_type") or None)
return None, None
def _int_or_none(value: Any) -> Optional[int]:
"""``int(value)`` for int/numeric-str inputs; None for bools, other types and malformed strings."""
if isinstance(value, bool) or not isinstance(value, (int, str)):
return None
try:
return int(value)
except ValueError:
return None
def _positive_int(value: Any) -> Optional[int]:
"""Return a positive integer, rejecting booleans and malformed values."""
parsed = _int_or_none(value)
return parsed if parsed is not None and parsed > 0 else None
def _is_placeholder_id(value: Any) -> bool:
"""True when *value* is a schema-filler id (``0`` / negative) rather than a target: some providers zero-fill
every optional integer, and treating that as targeting would drop the caller's ``app=``. Non-numeric values
are NOT placeholders β they still reach validation."""
parsed = _int_or_none(value)
return parsed is not None and parsed <= 0
def _ingest_windows(raw_windows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Normalise cua-driver ``list_windows`` entries, dropping unusable ones. Every downstream call needs integer
``pid`` and ``window_id``; on X11 the PID comes from the optional ``_NET_WM_PID`` property, so root/panel/popup
windows report ``pid: null`` β skip those instead of aborting the enumeration. ``z_index``: higher = closer to
front; Wayland's null (undefined stacking) sorts lowest so real windows stay above the desktop."""
windows: List[Dict[str, Any]] = []
for w in raw_windows:
if not isinstance(w, dict): # untrusted compatibility envelopes
continue
pid_int, window_id_int = _positive_int(w.get("pid")), _positive_int(w.get("window_id"))
if pid_int is None or window_id_int is None:
continue
z_raw, app_name, title = w.get("z_index"), w.get("app_name", ""), w.get("title", "")
windows.append({
"app_name": app_name if isinstance(app_name, str) else "",
"pid": pid_int,
"window_id": window_id_int,
# Only explicit False means off-screen; null (Linux 0.6.x) means unknown.
"off_screen": w.get("is_on_screen") is False,
"title": title if isinstance(title, str) else "",
"z_index": z_raw if isinstance(z_raw, (int, float)) and not isinstance(z_raw, bool) else 0,
})
return windows
def _windows_from_tool_result(out: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return list_windows payloads across cua-driver result shapes: structuredContent.windows, then ``windows`` /
``_legacy_windows`` in the text payload, then on the envelope itself."""
candidates = ((out.get("structuredContent"), ("windows",)),
(out.get("data"), ("windows", "_legacy_windows")),
(out, ("windows", "_legacy_windows")))
for container, keys in candidates:
if isinstance(container, dict):
for key in keys:
value = container.get(key)
if isinstance(value, list) and value:
return value
return []
def _apps_from_windows(windows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
apps: List[Dict[str, Any]] = []
seen: set[tuple[str, int]] = set()
for summary in _ingest_windows(windows):
name, key = summary["app_name"], (summary["app_name"], summary["pid"])
if name and key not in seen:
seen.add(key)
apps.append({"name": name, "pid": summary["pid"]})
return apps
|