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:
1 parent
83bcfd54d5
commit
fac4ccb760
146 files changed
+5689
-2033
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
@@ -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,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))
|
||||
|
||||
Reference in new issue
Block a user