Files
chenmingxuan fac4ccb760 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.
2026-10-26 00:44:13 +08:00

232 lines
9.2 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import base64
import hashlib
import json
from pathlib import Path
from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.runtime.task_control import TaskControl
from artemis.utils.logger import get_logger
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
from artemis.utils.ocr_xml_fusion import (
_crop_image_remove_status_bar,
_detect_status_bar_height,
_map_coordinates_back,
fuse_ocr_with_xml,
)
logger = get_logger(__name__)
def _should_skip_settling(state: State) -> bool:
"""Determine if we should skip screen settling based on the last executed action.
Purely logical, non-UI-drawing, or wait operations don't require dynamic
settling checks.
"""
if not state or not state.structured_decisions:
# First turn or no prior action, skip settling
return True
try:
actions = json.loads(state.structured_decisions)
if not actions or not isinstance(actions, list):
return True
last_action = actions[-1]
action_name = last_action.get("action")
# Actions that are known to have already completed full wait loops or don't render UI
skip_actions = {
"wait_for_delay",
}
if action_name in skip_actions:
logger.info(f"Last action was '{action_name}'. Skipping dynamic screen settling.")
return True
return False
except Exception as e:
logger.warning(
f"Failed to parse last action for settling heuristic: {e}."
" Defaulting to settling check."
)
return False
@trace(type="agent", name="perception")
def _check_injected_instruction_file(base_dir: str) -> dict | None:
"""Consumes a pending injected-instruction payload, if any.
Returns the payload dict: `instruction` (str | None) plus the explicit
`release_loop` stop signal (bool) declared by the injecting caller.
"""
from artemis.runtime.task_control import consume_instructions
return consume_instructions(base_dir)
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
"""Record the plan's current check lines as editable under user guidance."""
from artemis.utils.notes import get_note_file_path
from artemis.utils.plan_grammar import parse_plan
unprotected = getattr(ctx, "guidance_unprotected_checks", None)
if not isinstance(unprotected, set):
unprotected = set()
try:
plan_path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
text = plan_path.read_text(encoding="utf-8") if plan_path.exists() else ""
except Exception as e:
logger.warning(f"Could not read task_plan for the guidance check-line waiver: {e}")
text = ""
added = {(ci.kind, ci.text) for ci in parse_plan(text).check_items}
unprotected |= added
ctx.guidance_unprotected_checks = unprotected
logger.info(
f"User guidance received: {len(added)} declared check line(s) may now be edited"
" by the Operator."
)
async def perception_node(state: State, ctx: ArtemisContext) -> dict:
"""Perception node (fast path): captures screenshot, extracts XML hierarchy, and performs OCR.
Always executes at the start of an operator cycle.
"""
state = strict_state(state, "perception")
logger.info("Entering Perception Node...")
control = getattr(ctx, "task_control", None)
if isinstance(control, TaskControl):
await control.checkpoint(
on_pause=getattr(ctx, "on_task_pause", None),
on_resume=getattr(ctx, "on_task_resume", None),
)
if not ctx.data_engine.current_step_id:
ctx.data_engine.allocate_step_id()
logger.info(f"Pre-allocated step ID in Perception Node: {ctx.data_engine.current_step_id}")
controller = UnifiedMobileController(ctx)
# Observe before acknowledging guidance. A failed capture must not remove
# instructions that the Operator has never seen.
if not _should_skip_settling(state):
await asyncio.sleep(0.4)
from artemis.exploration.runtime import observe_or_pause
device_data = await observe_or_pause(ctx, controller)
latest_screenshot_b64 = device_data.base64
xml_hierarchy = device_data.elements
# Check for injected instruction without blocking event loop
injected_instruction = None
user_stop_requested = False
if ctx.data_engine and ctx.data_engine.base_dir:
injected_payload = await asyncio.to_thread(
_check_injected_instruction_file, str(ctx.data_engine.base_dir)
)
if injected_payload:
injected_instruction = injected_payload.get("instruction")
user_stop_requested = bool(injected_payload.get("release_loop"))
if isinstance(injected_instruction, str) and injected_instruction.strip():
ctx.rejected_pixel_targets = {}
# User guidance outranks the plan's declared standards: every
# check line that exists right now loses its machine
# restoration for the rest of the run (kept on the run context,
# not the per-turn State), so the Operator can drop or reword
# it in whichever later write it gets to it. Lines added after
# this moment stay protected until the next instruction.
_unprotect_current_check_lines(ctx)
latest_screenshot_bytes = base64.b64decode(latest_screenshot_b64)
# Update context device dimensions dynamically
if ctx.device:
ctx.device.device_width = device_data.width
ctx.device.device_height = device_data.height
logger.info(f"Updated context device dimensions: {device_data.width}x{device_data.height}")
# 3. Perform OCR (if configured, else fallback to pure XML layout)
ocr_results = []
if is_ocr_configured() and getattr(device_data, "platform", "android") != "computer":
screen_height = device_data.height
status_bar_height = _detect_status_bar_height(xml_hierarchy, screen_height)
try:
if status_bar_height > 0:
logger.info(
f"Detected status bar height: {status_bar_height}. Cropping screenshot for OCR."
)
cropped_b64, _, _ = await asyncio.to_thread(
_crop_image_remove_status_bar,
latest_screenshot_b64,
status_bar_height,
)
raw_ocr_results = await perform_ocr(cropped_b64)
ocr_results = _map_coordinates_back(raw_ocr_results, status_bar_height)
else:
logger.info("No status bar detected. Performing OCR on full screenshot.")
ocr_results = await perform_ocr(latest_screenshot_b64)
except Exception as ocr_err:
logger.warning(
f"Perception OCR execution encountered error: {ocr_err}. Proceeding with XML-only layout."
)
ocr_results = []
else:
logger.debug("OCR is not configured. Proceeding with XML-only layout.")
# 4. Fuse OCR with XML (if ocr_results is empty, returns original xml_hierarchy intact)
fused_xml = fuse_ocr_with_xml(xml_hierarchy, ocr_results)
# 5. Save to Data Engine in background thread
image_name = None
screenshot_path = None
if ctx.data_engine and latest_screenshot_bytes:
image_name = hashlib.sha256(latest_screenshot_bytes).hexdigest()
screenshot_path = str(ctx.data_engine.get_image_path(image_name))
asyncio.create_task(
asyncio.to_thread(
ctx.data_engine.get_or_create_image,
latest_screenshot_bytes,
ui_tree=xml_hierarchy,
ocr_result=ocr_results,
)
)
logger.info(f"Offloaded perception data saving to background with image_name: {image_name}")
# Return state update
update = {
"latest_screenshot": screenshot_path,
"latest_ui_hierarchy": fused_xml,
"operator_raw_data": {
"screenshot_b64": latest_screenshot_b64,
"xml_hierarchy": xml_hierarchy if xml_hierarchy is not None else [],
"ocr_results": ocr_results,
"width": device_data.width,
"height": device_data.height,
"observation_text": getattr(device_data, "observation_text", ""),
},
"injected_instruction": injected_instruction,
# sticky_or reducer: once latched True, later False updates keep it True
"user_stop_requested": user_stop_requested,
}
return update