Files
Artemis-Eapil/artemis/agents/validator/precondition_pixel.py
T
somew1nd 67248c887e feat: improve task execution, verification and packaging
Add execution incidents, action bursts and shared Flash history.

Expose verification and Explorer settings with streamed check results.

Support graceful cancellation, recording recovery and bundled UI resources.

Add CI quality checks and clean up comments and documentation.
2026-09-02 21:45:51 -07:00

282 lines
10 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.
"""Pixel-level Safety Net: VLM comparison of the target crop before execution.
Validates that the visual target button/component at the action coordinates is
still present, visible, and interactive on the live screen compared to the
original (decision-time) screenshot.
Extracted from ``validator.py``; the public entry point remains the
``ValidatorNode`` method, which delegates here. The ``get_llm_fn`` dependency
is injected by the facade so that tests patching
``artemis.agents.validator.validator.get_llm`` keep working.
"""
import asyncio
import base64
from pathlib import Path
from langchain_core.messages import HumanMessage, SystemMessage
from artemis.agents.validator.categories import ValidationErrorCategory
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.services.llm import acomplete_structured
from artemis.utils import visualization
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
# Only interactive, element-focused actions are subject to pixel validation.
PIXEL_VALIDATED_ACTIONS = (
"tap",
"long_press_on",
"focus_and_input_text",
"input_text",
"click_coordinate",
"click",
"long_press",
)
_MAX_ATTEMPTS = 3
_RETRY_DELAY_SECONDS = 0.3
def _init_llm_and_prompt(ctx: ArtemisContext, get_llm_fn):
"""Resolves the VLM and loads the prompt template (once per validation)."""
try:
llm = get_llm_fn(ctx, name="validator_pixel_safety_net")
except Exception:
llm = get_llm_fn(ctx, name="validator")
prompt_path = Path(__file__).parent.joinpath("pixel_safety_net.md")
prompt = prompt_path.read_text(encoding="utf-8")
return llm, prompt
def _build_messages(
prompt: str,
orig_crop_bytes: bytes,
live_crop_bytes: bytes,
action_name,
state: State | None,
) -> list:
"""Formulates the multi-modal verification message pair."""
orig_b64 = base64.b64encode(orig_crop_bytes).decode("utf-8")
live_b64 = base64.b64encode(live_crop_bytes).decode("utf-8")
user_content = [
{"type": "text", "text": "[Image 1 (Reference)]"},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{orig_b64}"},
},
{"type": "text", "text": "[Image 2 (Current State)]"},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{live_b64}"},
},
]
if state:
thoughts = []
native_thought = getattr(state, "operator_native_thinking", None)
if native_thought and native_thought.strip():
thoughts.append(native_thought.strip())
raw_thought = getattr(state, "operator_raw_thinking", None)
if raw_thought and raw_thought.strip():
thoughts.append(raw_thought.strip())
operator_thought = "\n\n".join(thoughts)
if operator_thought.strip():
context_text = (
"[Planned Action & Original Thinking]\nAction:"
f" {action_name}\nOriginal"
f" Thinking:\n{operator_thought.strip()}"
)
user_content.append({"type": "text", "text": f"\n{context_text.strip()}"})
return [
SystemMessage(content=prompt),
HumanMessage(content=user_content),
]
async def _run_attempt(
session,
llm,
prompt: str,
orig_crop_bytes: bytes,
current_coords,
action_name,
state: State | None,
attempt: int,
) -> tuple[bool, ValidationErrorCategory, str] | None:
"""Runs one pixel validation attempt.
Returns a final (passed, category, reason) verdict, or None when the
attempt yielded no verdict (never happens today: every branch is final).
Raises on infrastructure errors so the caller's retry loop can decide.
"""
# A. Take fresh live screenshot (captures state changes / dynamic loading settling)
live_screenshot_b64 = None
try:
live_screenshot_b64 = await session.screenshot_b64()
success_img = True
except Exception:
success_img, live_screenshot_b64 = False, None
if not success_img or not live_screenshot_b64:
raise Exception("Failed to acquire live screenshot from controller/MCP tool.")
# B. Decode and crop the live screen target
live_bytes = base64.b64decode(live_screenshot_b64)
live_crop_bytes = visualization.crop_and_annotate_target(
live_bytes, current_coords, crop_size=None, dot_radius=15
)
# C. Formulate multi-modal verification message
messages = _build_messages(prompt, orig_crop_bytes, live_crop_bytes, action_name, state)
# D. Invoke Universal VLM. Parsing (fences, repair) and one
# corrective re-ask on unparseable JSON are handled by the
# structured-output layer; a StructuredOutputError lands in
# the attempt loop's except like any other attempt failure.
res_json = await acomplete_structured(llm, messages)
if not isinstance(res_json, dict):
raise ValueError(
"Pixel validation response parsed to"
f" {type(res_json).__name__}, expected a JSON object."
)
reasoning = res_json.get("reasoning", "")
is_present = res_json.get("is_present", True)
confidence = res_json.get("confidence", 1.0)
logger.info(
f"Pixel validation attempt {attempt}/{_MAX_ATTEMPTS} result:"
f" is_present={is_present}, confidence={confidence:.2f}, reasoning={reasoning}"
)
# E. Branching based on outcome
if is_present:
logger.info(f"Pixel validation SUCCESS on attempt {attempt}/{_MAX_ATTEMPTS}.")
return True, ValidationErrorCategory.NONE, ""
# Target not present: fail or bypass based on confidence without retry!
if confidence >= 0.7:
reason_msg = (
f"Pixel-level validation failed: {reasoning}"
if reasoning
else (
"Pixel-level validation failed: The target UI"
" element at the exact coordinates has disappeared,"
" changed, or is blocked."
)
)
return (
False,
ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED,
reason_msg,
)
logger.warning(
f"Pixel validation failed but confidence ({confidence:.2f}) is below 0.7. Bypassing check."
)
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
async def validate_action_precondition_pixel(
ctx: ArtemisContext,
session,
action_item: dict,
pre_screenshot_b64: str,
original_coords: list[int] | None = None,
state: State | None = None,
*,
get_llm_fn,
) -> tuple[bool, ValidationErrorCategory, str]:
"""Pixel-level Safety Net entry point (see module docstring)."""
action_name = action_item.get("action")
# 1. White-list filtering: Only validate interactive, element-focused actions
if action_name not in PIXEL_VALIDATED_ACTIONS:
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
current_coords = action_item.get("coordinates")
orig_coords = original_coords or current_coords
if not orig_coords or len(orig_coords) != 2:
logger.info("Pixel safety net skipped: no valid coordinates.")
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
if not current_coords or len(current_coords) != 2:
current_coords = orig_coords
logger.info(
"Pre-execution pixel validation: checking target position"
f" {orig_coords} in original image vs {current_coords} on live"
" screen..."
)
# 2. Decode and crop the static reference (original) image ONCE outside the loop to save CPU
try:
orig_bytes = base64.b64decode(pre_screenshot_b64)
orig_crop_bytes = visualization.crop_and_annotate_target(
orig_bytes, orig_coords, crop_size=None, dot_radius=15
)
except Exception as e:
logger.error(f"Failed to prepare original crop for pixel safety net: {e}. Bypassing check.")
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
# 3. Get LLM Configuration once outside the loop
try:
llm, prompt = _init_llm_and_prompt(ctx, get_llm_fn)
except Exception as e:
logger.error(f"Failed to initialize LLM/prompt for pixel safety net: {e}. Bypassing check.")
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
# 4. Start high-precision execution validation retry loop
last_error = None
for attempt in range(1, _MAX_ATTEMPTS + 1):
logger.info(f"Pre-execution pixel validation: Attempt {attempt}/{_MAX_ATTEMPTS}...")
try:
verdict = await _run_attempt(
session,
llm,
prompt,
orig_crop_bytes,
current_coords,
action_name,
state,
attempt,
)
if verdict is not None:
return verdict
except Exception as attempt_err:
logger.warning(
f"Error during pixel validation attempt {attempt}/{_MAX_ATTEMPTS}: {attempt_err}"
)
last_error = attempt_err
if attempt < _MAX_ATTEMPTS:
await asyncio.sleep(_RETRY_DELAY_SECONDS)
continue
logger.error(
"Pixel validation repeatedly failed with errors. Last"
f" error: {last_error}. Bypassing check to avoid"
" blocking flow."
)
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""