feat: prepare 2.2.0 with UI and execution fixes

Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
This commit is contained in:
chenmingxuan committed 2026-10-26 00:44:13 +08:00
1 parent 83bcfd54d5
commit fac4ccb760
146 files changed
+5689 -2033

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
"""Freeze action identity against its decision-time observation."""
import hashlib
import json
from pathlib import Path
def observation_anchor(state, elements: list[dict] | None = None) -> dict:
image = getattr(state, "latest_screenshot", None)
image_name = Path(image).stem if isinstance(image, str) else None
tree = elements if elements is not None else getattr(state, "indexed_elements", None)
tree = tree if isinstance(tree, list) else []
signature = json.dumps([image_name, tree], sort_keys=True, ensure_ascii=False, default=str)
return {
"target_image_name": image_name,
"target_snapshot_id": hashlib.sha256(signature.encode()).hexdigest()[:24],
}
def element_semantics(element: dict, index: int) -> dict:
"""Copy evidence; no live element/index lookup is allowed after dispatch."""
result = {"target_index": index}
for key in ("editable", "input_value_state", "hint", "showing_hint", "focused", "is_hint"):
if key in element:
result[f"target_{key}"] = element[key]
return result
+15 -17
View File
@@ -193,19 +193,22 @@ async def _observe_foreground(
Fast path: the focused window's package equals the target. Otherwise the app also
counts as foreground when the top task belongs to it (see ``ForegroundTask``).
A ``None`` focused package means the window manager is mid-transition and is
reported as "not yet" without consulting the task stack.
With no focused window, a resumed activity in the app's task can still prove
readiness. A merely visible task without a resumed activity is not enough.
Returns (is_foreground, focused_package, foreground_task); the task is only read
when the fast path fails on a non-null focused package.
when the focused window does not already identify the target app.
"""
current_package = await get_current_foreground_package_async(ctx)
if current_package == app_package:
return True, current_package, None
if current_package is None:
return False, None, None
task = await get_foreground_task_async(ctx)
return task is not None and task.owns(app_package), current_package, task
ready = (
task is not None
and task.owns(app_package)
and (current_package is not None or task.resumed_package is not None)
)
return ready, current_package, task
async def _poll_for_app_ready(
@@ -216,8 +219,8 @@ async def _poll_for_app_ready(
) -> tuple[bool, str | None]:
"""Poll for app to be ready after launch.
Treats mCurrentFocus=null as a loading state and keeps polling.
Only fails if we get a different (non-null) package or timeout.
A focused window or resumed foreground task can confirm readiness. If neither
identifies the app, keep polling and return diagnostics on timeout.
Args:
ctx: Mobile use context
@@ -273,7 +276,10 @@ async def launch_app_with_retries(
max_retries: int = 3,
max_poll_seconds: int = 15,
) -> tuple[bool, str | None]:
"""Launch an app with retry logic and smart polling.
"""Launch or bring an app forward with bounded, non-destructive retries.
Stopping or restarting an app is a separate explicit operation; a failed
readiness check must not silently reset the app's running state.
Args:
ctx: Mobile use context
@@ -298,14 +304,6 @@ async def launch_app_with_retries(
span.payload = {"attempt": attempt, "app_package": app_package}
controller = UnifiedMobileController(ctx)
if attempt > 1:
logger.warning(
f"Attempt {attempt - 1} failed. Force stopping"
f" '{app_package}' to clear frozen state before retrying..."
)
await controller.terminate_app(app_package)
await asyncio.sleep(1.0)
launch_error: AppLaunchError | None = None
try:
launch_success = await controller.launch_app(app_package)
+80
View File
@@ -0,0 +1,80 @@
"""Keep editable values separate from accessibility hints and visual labels."""
from typing import Any
def _boolean(node: dict, *keys: str) -> bool | None:
for key in keys:
value = node.get(key)
if isinstance(value, bool):
return value
if isinstance(value, str) and value.lower() in {"true", "false"}:
return value.lower() == "true"
return None
def input_state(node: dict[str, Any]) -> dict[str, Any]:
"""Return value evidence only for an actual editable node, never OCR text.
Equality with a hint is ambiguous on older backends: an application can
legitimately contain that exact value. Only an explicit showing-hint flag
proves that nonempty text is a placeholder.
"""
editable = _boolean(node, "editable", "isEditable")
class_name = str(node.get("class") or node.get("className") or "").lower()
if editable is None and "edittext" in class_name:
editable = True
if editable is not True:
return {}
hint = str(node.get("hint") or node.get("hintText") or node.get("hint-text") or "")
showing = _boolean(node, "showing_hint", "showing-hint-text", "isShowingHintText", "is_hint")
raw = node.get("text")
value = raw if isinstance(raw, str) else None
if showing is True:
hint = hint or value or ""
value = ""
status = "empty"
elif value is None:
status = "unknown"
elif not value:
status = "empty"
elif hint and value == hint and showing is not False:
value = None
status = "unknown"
else:
status = "present"
return {
"editable": True,
"input_value": value,
"input_value_state": status,
"hint": hint,
"showing_hint": showing,
"focused": _boolean(node, "focused"),
"password": _boolean(node, "password"),
}
def verify_input(elements: list[dict], target: dict | None = None) -> str:
"""Verify value presence against a fresh tree, without returning its value.
Resource identity wins over focus. Unidentifiable, masked, or unsupported
inputs remain unknown; visual text elsewhere cannot establish input success.
"""
candidates = []
target = target or {}
resource_id = target.get("target_resource_id")
for element in elements:
state = input_state(element) if "input_value_state" not in element else element
if not state:
continue
if resource_id:
if (element.get("resource_id") or element.get("resource-id")) == resource_id:
candidates.append(state)
elif state.get("focused") is True:
candidates.append(state)
if len(candidates) != 1:
return "unknown"
state = candidates[0]
if state.get("password") is True and state.get("input_value_state") != "empty":
return "unknown"
return str(state["input_value_state"])
+14
View File
@@ -136,6 +136,10 @@ def _clip_bounds(
def _is_semantic_empty(node: dict[str, Any]) -> bool:
"""Check if a node has no semantic value or interactive capability."""
from artemis.utils.input_state import input_state
if input_state(node):
return False
# Check text
text = node.get("text")
if text and str(text).strip():
@@ -170,6 +174,12 @@ def _is_semantic_empty(node: dict[str, Any]) -> bool:
def _merge_complementary_children(children: list[dict], parent_node: dict) -> list[dict]:
"""Strategy 2: Merge complementary siblings under a clickable parent."""
# Text inside an input can be a Compose placeholder or decoration. Never
# promote it into the editable parent's actual value.
from artemis.utils.input_state import input_state
if input_state(parent_node):
return children
# Rule 1: Parent must be clickable or focusable
clickable = parent_node.get("clickable")
focusable = parent_node.get("focusable")
@@ -285,6 +295,10 @@ def _prune_parent_child_redundancy(
parent: dict[str, Any], children: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Strategy 3: Remove redundant children that have the same text as the parent."""
from artemis.utils.input_state import input_state
if input_state(parent) or any(input_state(child) for child in children):
return children
parent_text = str(parent.get("text") or "").strip()
parent_desc = str(
parent.get("content-desc")
+18 -4
View File
@@ -18,6 +18,7 @@ import re
from typing import Any
from artemis.utils.coordinates import parse_swipe_parameters
from artemis.utils.input_state import input_state
from PIL import Image, ImageDraw, ImageFont
@@ -287,13 +288,14 @@ def format_minimal_list_with_points(
for node in fused_xml:
text = node.get("text") or node.get("content-desc") or ""
value_evidence = input_state(node)
bounds_str = node.get("bounds")
bounds = parse_bounds(bounds_str)
ocr_elements = node.get("ocr_elements")
registered = False
if ocr_elements:
if ocr_elements and not value_evidence:
for ocr in ocr_elements:
ocr_bounds = parse_bounds(ocr.get("bounds"))
if ocr_bounds:
@@ -363,13 +365,14 @@ def format_minimal_list_with_elements(
for node in fused_xml:
text = node.get("text") or node.get("content-desc") or ""
value_evidence = input_state(node)
bounds_str = node.get("bounds")
bounds = parse_bounds(bounds_str)
ocr_elements = node.get("ocr_elements")
registered = False
if ocr_elements:
if ocr_elements and not value_evidence:
for ocr in ocr_elements:
ocr_bounds = parse_bounds(ocr.get("bounds"))
if ocr_bounds:
@@ -401,9 +404,15 @@ def format_minimal_list_with_elements(
# An empty input field carries no text but usually a hint ("Search settings");
# a rejected one carries an error ("Password too short"). Both come from the
# accessibility helper's dump and are exactly what the agent needs to see.
hint = str(node.get("hint") or "").strip()
hint = str(value_evidence.get("hint") or node.get("hint") or "").strip()
error = str(node.get("error") or "").strip()
shown = text.strip() or hint
if value_evidence.get("showing_hint") is True:
text = ""
shown = text.strip() or hint or ("(empty input)" if value_evidence else "")
if value_evidence:
# OCR describes pixels, including placeholders. It cannot replace
# the editable node's value evidence or its hit-test bounds.
registered = False
if not registered and shown and bounds:
left, top, right, bottom = bounds
cx, cy = get_center_coordinates(left, top, right, bottom)
@@ -415,6 +424,10 @@ def format_minimal_list_with_elements(
line = f"[{idx}] {kind}: '{shown}' | Bounds: {norm_bounds}"
if error:
line += f" | Error: '{error}'"
if value_evidence:
line += f" | Editable: true | Value: {value_evidence['input_value_state']}"
if value_evidence.get("focused") is not None:
line += f" | Focused: {str(value_evidence['focused']).lower()}"
lines.append(line)
items_bounds.append((idx, (left, top, right, bottom)))
@@ -431,6 +444,7 @@ def format_minimal_list_with_elements(
element["is_hint"] = True
if error:
element["error"] = error
element.update(value_evidence)
elements.append(element)
labels.append(str(idx))
added_items.append((shown, cx, cy))