feat: release 2.2.0 with PC-only tests and execution reliability fixes

Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
This commit is contained in:
chenmingxuan committed 2026-10-27 01:13:41 +08:00
1 parent fac4ccb760
commit be6e8c2abd
79 files changed
+2263 -277

No files matched your search

+28 -2
View File
@@ -84,7 +84,12 @@ class CheckVerdict(BaseModel):
"""One check item's verdict. The verdict value is never altered by the
release decision (fail-open affects release only)."""
item_text: str = Field(description="The check line's text, quoted verbatim.")
item_text: str = Field(
description=(
"The criterion body, quoted verbatim; exclude the displayed list marker "
"and [verify]/[assert] (on_complete)/(at_end) metadata prefix."
)
)
kind: Literal["verify", "assert"]
status: Literal["passed", "failed", "inconclusive"]
evidence: str = Field(
@@ -470,6 +475,24 @@ def _format_check_items(check_items: list) -> str:
# --- Shared tool loop ----------------------------------------------------------------
def _canonicalize_verdict_items(verdicts: list[CheckVerdict], check_items) -> list[CheckVerdict]:
"""Recognize only exact labels rendered for this review's declared checks."""
expected = {(ci.kind, ci.text) for ci in check_items}
labels = {}
for ci in check_items:
if getattr(ci, "when", None) is None:
continue
label = _format_check_items([ci])
labels[(ci.kind, label)] = ci.text
labels[(ci.kind, label.removeprefix("- "))] = ci.text
return [
v.model_copy(update={"item_text": labels[(v.kind, v.item_text)]})
if (v.kind, v.item_text) not in expected and (v.kind, v.item_text) in labels
else v
for v in verdicts
]
def _completed_text_report(response, check_items) -> CheckReport | None:
"""Reuse a complete Checker verdict, never a draft, tool receipt or partial stream."""
if not isinstance(response, AIMessage) or response.tool_calls or response.invalid_tool_calls:
@@ -495,6 +518,9 @@ def _completed_text_report(response, check_items) -> CheckReport | None:
# and every declared check so an incomplete answer still gets formatted.
if report.test_result is None or not report.test_result.reason.strip():
return None
report = report.model_copy(
update={"verdicts": _canonicalize_verdict_items(report.verdicts, check_items)}
)
expected = {(ci.kind, ci.text) for ci in check_items}
covered = {(v.kind, v.item_text) for v in report.verdicts}
if not expected <= covered:
@@ -532,7 +558,7 @@ def _normalize_report(report: CheckReport, check_items: list) -> CheckReport:
"""Node-side hygiene: vague failed verdicts downgrade to inconclusive; every
expected item gets a verdict (missing ones are inconclusive)."""
verdicts: list[CheckVerdict] = []
for v in report.verdicts:
for v in _canonicalize_verdict_items(report.verdicts, check_items):
if v.status == "failed" and not v.evidence.strip():
verdicts.append(
CheckVerdict(
+52 -5
View File
@@ -575,6 +575,10 @@ class OperatorNode:
state.execution_report = None
report_requested = False
other_tool_failed = False
follow_up_pending = bool(
getattr(state, "injected_instruction", None)
or getattr(state, "operator_feedback", None)
)
for iteration in range(max_iterations):
control = getattr(self.ctx, "task_control", None)
@@ -584,6 +588,14 @@ class OperatorNode:
):
# The device may have changed while another task held its lease.
return None, None, None, False
if (
not follow_up_pending
and not other_tool_failed
and self._pro_ready_for_settlement(state)
):
# Let the graph settle checkpoints and run final review. A
# completed ordinary plan does not need a synthetic device action.
break
if (
not report_requested
and not other_tool_failed
@@ -707,6 +719,9 @@ class OperatorNode:
current_messages.append(response)
logger.info("Exploration final report prepared; returning for final review.")
break
if report_text and not other_tool_failed and self._pro_ready_for_settlement(state):
current_messages.append(response)
break
if getattr(self.ctx, "exploration_id", None):
from artemis.exploration.runtime import publish_response
@@ -751,11 +766,15 @@ class OperatorNode:
"缺少证据时如实说明,不要为了结束再操作设备。"
if report_requested
else "No tool call was returned, so no device action occurred. "
"Use the available tool interface now, not a prose promise or JSON "
"inside text. Use an index from the current element list or "
"normalized coordinates with target_description. If blocked, "
"use an observation/diagnostic tool; do not invent a click or "
"claim success. This recovery has a limited retry budget."
"If the requested operations are already finished, read task_plan "
"and reconcile any stale pending items against the recorded evidence "
"using the note tools. Do not click, refocus, or type merely to "
"produce a tool call or finish the turn. Do not mark genuinely "
"unfinished work complete. Otherwise use the available tool "
"interface for the remaining requested work, not a prose promise "
"or JSON inside text. Use current observations for targets. If "
"blocked, use an observation/diagnostic tool; do not invent an "
"action or claim success. This recovery has a limited retry budget."
)
)
)
@@ -1111,6 +1130,19 @@ class OperatorNode:
logger.warning("Some action calls failed validation. Feeding back to LLM.")
continue
no_progress_attempts = 0
if not other_tool_failed and any(
normalize_name(tc["name"]) in {"save_note", "update_note", "append_note"}
and tc["args"].get("key") == "task_plan"
for tc in other_calls
):
follow_up_pending = False
if (
not follow_up_pending
and not other_tool_failed
and self._pro_ready_for_settlement(state)
):
# Also handle completion in the final permitted helper iteration.
break
else:
if no_progress_attempts:
raise OperatorNoProgressError(
@@ -1272,6 +1304,21 @@ class OperatorNode:
"operator_tool_limit_exceeded": tool_limit_exceeded,
}
def _pro_ready_for_settlement(self, state: State) -> bool:
if getattr(self.ctx, "exploration_id", None):
return False # Exploration must deliver its report before settlement.
if getattr(state, "checker_success", None) is False:
return False
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
return False
snapshot = parse_plan(self._read_task_plan_text() or "")
return bool(
snapshot.all_top_level_done
and not any(item.is_pending or item.is_active for item in snapshot.items)
and (not snapshot.continuous_top_level or getattr(state, "user_stop_requested", False))
)
def _exploration_ready_for_report(self, state: State) -> bool:
if not getattr(self.ctx, "exploration_id", None):
return False
+6
View File
@@ -586,6 +586,12 @@ def render_execution_incident(incident: dict, steps: list) -> str:
" appeared on top of your target (dialog, sheet, banner, keyboard, or a"
" re-laid-out screen)."
)
elif category == "pixel_verification_unavailable":
lines.append(
"Evidence: target verification could not be completed; this does not show that"
" the target disappeared. Restore a working observation before locating the"
" target again. Do not infer a business application failure from this incident."
)
elif category in ("target_disappeared", "pixel_target_disappeared"):
prior_str = (
f" Your last dispatched action was `{prior[0]}` (Step {prior[1]});"
+12 -6
View File
@@ -28,7 +28,7 @@ from artemis.graph.state import State
from artemis.mcp.action_names import to_canonical_call
from artemis.mcp.action_session import ActionSession
from artemis.utils.logger import get_logger
from artemis.utils.input_state import verify_input
from artemis.utils.input_state import expected_input_state, verify_input
logger = get_logger(__name__)
@@ -98,7 +98,12 @@ async def exec_action(
return False, f"MCP call failed: {e}"
action_item.update(execution="completed" if res.ok else "unknown", side_effect="possible")
if tool_name == "input_text" and res.ok:
expected = (
expected_input_state(wire_args["text"], wire_args["clear_exist"])
if tool_name == "input_text"
else None
)
if expected is not None and res.ok:
try:
elements = await session.ui_hierarchy()
from artemis.utils.visualization import format_minimal_list_with_elements
@@ -109,10 +114,11 @@ async def exec_action(
verification = "unknown"
action_item["input_verification"] = verification
action_item["input_verification_note"] = (
"Value presence confirmed in fresh editable node; not business validation."
if verification == "present"
else "Input dispatched; real value is not confirmed. Observe before submitting; do not blindly retype."
f"Expected input state '{expected}' confirmed in fresh editable node; not business validation."
if verification == expected
else f"Input dispatched; expected '{expected}', observed '{verification}'. "
"Observe before submitting; do not blindly retype."
)
if verification != "present":
if verification != expected:
return False, action_item["input_verification_note"]
return (True, "") if res.ok else (False, res.message)
+1
View File
@@ -28,6 +28,7 @@ class ValidationErrorCategory(StrEnum):
TARGET_SHIFTED = "target_shifted"
TARGET_OCCUPIED = "target_occupied"
PIXEL_TARGET_DISAPPEARED = "pixel_target_disappeared"
PIXEL_VERIFICATION_UNAVAILABLE = "pixel_verification_unavailable"
PIXEL_BYPASSED = "pixel_bypassed"
XML_BYPASSED = "xml_bypassed"
GENERAL = "general"
+44 -14
View File
@@ -28,9 +28,11 @@ import asyncio
import base64
import hashlib
import math
from io import BytesIO
from pathlib import Path
from langchain_core.messages import HumanMessage, SystemMessage
from PIL import Image
from artemis.agents.validator.categories import ValidationErrorCategory
from artemis.context import ArtemisContext
@@ -56,6 +58,21 @@ _MAX_ATTEMPTS = 3
_RETRY_DELAY_SECONDS = 0.3
def _annotate_target(image_bytes, coords, coordinate_size, **kwargs) -> bytes:
"""Map device pixels into each encoded screenshot before marking/cropping it."""
if coordinate_size is not None:
width, height = coordinate_size
with Image.open(BytesIO(image_bytes)) as image:
image_width, image_height = image.size
if abs((image_width / image_height) / (width / height) - 1) > 0.01:
raise ValueError("Screenshot orientation/geometry changed; observe the target again.")
x, y = coords
if not (0 <= x < width and 0 <= y < height):
raise ValueError("Target lies outside the observed device screen.")
coords = [round(x * image_width / width), round(y * image_height / height)]
return visualization.crop_and_annotate_target(image_bytes, coords, **kwargs)
def _strict_target(action_item: dict) -> bool:
return any(
action_item.get(key)
@@ -71,7 +88,7 @@ def _strict_target(action_item: dict) -> bool:
def _unverified(action_item: dict, message: str) -> tuple[bool, ValidationErrorCategory, str]:
if _strict_target(action_item):
return False, ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, message
return False, ValidationErrorCategory.PIXEL_VERIFICATION_UNAVAILABLE, message
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
@@ -182,6 +199,8 @@ async def _run_attempt(
action_item: dict,
state: State | None,
attempt: int,
*,
coordinate_size: tuple[int, int] | None = None,
) -> tuple[bool, ValidationErrorCategory, str] | None:
"""Runs one pixel validation attempt.
@@ -190,20 +209,18 @@ async def _run_attempt(
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
except Exception as exc:
raise RuntimeError(f"Failed to acquire live screenshot: {exc}") from exc
if not success_img or not live_screenshot_b64:
raise Exception("Failed to acquire live screenshot from controller/MCP tool.")
if not live_screenshot_b64:
raise RuntimeError("Live screenshot was empty; target identity could not be checked.")
# 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
live_crop_bytes = _annotate_target(
live_bytes, current_coords, coordinate_size, crop_size=None, dot_radius=15
)
# C. Formulate multi-modal verification message
@@ -308,9 +325,19 @@ async def validate_action_precondition_pixel(
if not current_coords or len(current_coords) != 2:
current_coords = orig_coords
device = getattr(ctx, "device", None)
dimensions = (getattr(device, "device_width", None), getattr(device, "device_height", None))
coordinate_size = (
dimensions if all(type(value) is int and value > 0 for value in dimensions) else None
)
try:
neighborhood = visualization.crop_and_annotate_target(
base64.b64decode(pre_screenshot_b64), orig_coords, crop_size=256, dot_radius=0
neighborhood = _annotate_target(
base64.b64decode(pre_screenshot_b64),
orig_coords,
coordinate_size,
crop_size=256,
dot_radius=0,
)
except (ValueError, TypeError, OSError):
neighborhood = pre_screenshot_b64.encode()
@@ -342,8 +369,8 @@ async def validate_action_precondition_pixel(
# 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
orig_crop_bytes = _annotate_target(
orig_bytes, orig_coords, coordinate_size, crop_size=None, dot_radius=15
)
except Exception as e:
logger.error(
@@ -378,6 +405,7 @@ async def validate_action_precondition_pixel(
action_item,
state,
attempt,
coordinate_size=coordinate_size,
)
if verdict is not None:
if not verdict[0]:
@@ -396,7 +424,9 @@ async def validate_action_precondition_pixel(
f" error: {last_error}. Target identity remains unverified."
)
return _unverified(
action_item, "Pixel verification failed; observe and locate the target again."
action_item,
f"Pixel verification unavailable after {_MAX_ATTEMPTS} attempts: {last_error}. "
"The action was not executed; restore observation before locating the target again.",
)
return _unverified(action_item, "Pixel validation did not establish target identity.")