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:
1 parent
fac4ccb760
commit
be6e8c2abd
79 files changed
+2263
-277
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]});"
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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.")
|
||||
Reference in new issue
Block a user