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
@@ -28,6 +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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -93,6 +94,25 @@ async def exec_action(
|
||||
try:
|
||||
res = await session.call(tool_name, wire_args)
|
||||
except Exception as e:
|
||||
action_item.update(execution="unknown", side_effect="possible")
|
||||
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:
|
||||
try:
|
||||
elements = await session.ui_hierarchy()
|
||||
from artemis.utils.visualization import format_minimal_list_with_elements
|
||||
|
||||
_, indexed, _ = format_minimal_list_with_elements(elements, width, height)
|
||||
verification = verify_input(indexed, action_item)
|
||||
except (RuntimeError, ValueError, TypeError, OSError):
|
||||
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."
|
||||
)
|
||||
if verification != "present":
|
||||
return False, action_item["input_verification_note"]
|
||||
return (True, "") if res.ok else (False, res.message)
|
||||
@@ -128,33 +128,14 @@ async def _run_precondition_gate(
|
||||
|
||||
|
||||
async def _attempt_local_execution(
|
||||
node, session, action_item: dict, action_name, attempts_log: list, *, max_local_retries: int
|
||||
node, session, action_item: dict, action_name, attempts_log: list
|
||||
) -> tuple[bool, str]:
|
||||
"""Executes the action locally with a short retry loop."""
|
||||
success = False
|
||||
error_msg = ""
|
||||
for attempt in range(max_local_retries):
|
||||
try:
|
||||
exec_success, exec_error = await node._exec_action(session, action_item)
|
||||
except Exception as e:
|
||||
exec_success = False
|
||||
exec_error = str(e)
|
||||
|
||||
# No effect polling: "dispatched" only says the device accepted the
|
||||
# command; whether it had the intended effect is the Operator's call.
|
||||
if exec_success:
|
||||
success = True
|
||||
error_msg = ""
|
||||
attempts_log.append("Dispatched")
|
||||
break
|
||||
else:
|
||||
success = False
|
||||
error_msg = exec_error
|
||||
attempts_log.append(error_msg)
|
||||
|
||||
if attempt < max_local_retries - 1:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
"""Dispatch once; a device error is not evidence that no input occurred."""
|
||||
try:
|
||||
success, error_msg = await node._exec_action(session, action_item)
|
||||
except Exception as exc:
|
||||
success, error_msg = False, str(exc)
|
||||
attempts_log.append("Dispatched" if success else error_msg)
|
||||
return success, error_msg
|
||||
|
||||
|
||||
@@ -232,19 +213,14 @@ async def _process_action(
|
||||
attempts_log.append(outcome.error_msg)
|
||||
|
||||
if validation_passed:
|
||||
# 2. Local execution: a vetted action gets a short retry loop, a burst
|
||||
# member fires exactly once (retries would break the burst's timing).
|
||||
if burst:
|
||||
max_local_retries = 1
|
||||
else:
|
||||
max_local_retries = 1 if action_name == "launch_app" else 2
|
||||
# 2. Send one input. An uncertain transport/device outcome must not
|
||||
# trigger a second physical dispatch, in either tier.
|
||||
outcome.success, outcome.error_msg = await _attempt_local_execution(
|
||||
node,
|
||||
session,
|
||||
action_item,
|
||||
action_name,
|
||||
attempts_log,
|
||||
max_local_retries=max_local_retries,
|
||||
)
|
||||
|
||||
if not outcome.success:
|
||||
|
||||
@@ -5,11 +5,13 @@ Read the `[Target]` block first to determine which rule applies.
|
||||
|
||||
Rules
|
||||
1. `Kind: specific UI control` — the target has a label, resource id, class or bounds. Judge whether that exact control remains visible at the red dot in Image 2. Ignore video, map, animation or background changes beneath it. A control that has hidden, faded, collapsed, moved or become covered is not present, even if tapping the same spot could restore it.
|
||||
2. `Kind: described target` — the Operator aimed at a coordinate and stated what it is (`Operator's description`). That statement is the Operator's belief, not an observation: confirm against Image 1 what actually sits under the red dot. If the description names a control (button, icon, link, handle, field, knob) and Image 1 shows one there, apply Rule 1 to that control. If the description names a surface (video body, backdrop, blank area, map) or Image 1 shows no distinct control, apply the surface rule.
|
||||
2. `Kind: described target` — the Operator aimed at a coordinate and stated what it is (`Operator's description`). That statement is the Operator's belief, not an observation: confirm against Image 1 what actually sits under the red dot. If the description or original thinking aims at a control (button, icon, link, handle, field, knob), require that exact control under the red dot in BOTH images. A missing control, a nearby control, or blank space below the control MUST return is_present:false; NEVER fall back to the surface rule for a control intent. Apply the surface rule only when the intended action explicitly targets a surface (video body, backdrop, blank area, map), with no conflicting control intent in the original thinking. Renaming the same point does not establish a new target.
|
||||
3. `Kind: coordinates only` — no target metadata at all. Identify what appears under the red dot in both images. If Image 1 shows a distinct button, icon, link or handle, apply Rule 1. Apply the surface rule only when Image 1 shows blank space, a backdrop, or the body of a video, animation, map or live stream.
|
||||
4. Surface rule. Typical intents: tap the video body to wake its hidden controls, tap the backdrop to dismiss a dialog. Surfaces are expected to change between frames: ignore natural frame changes and the appearance or fading of overlay controls. Output `{"is_present": true, "confidence": 1.0}` unless an unexpected blocking popup now covers the red dot.
|
||||
4. Surface rule. Typical intents: tap the video body to wake its hidden controls, tap the backdrop to dismiss a dialog. Surfaces are expected to change between frames: ignore natural frame changes and the appearance or fading of overlay controls. Output `{"is_present": true, "confidence": 1.0, "target_kind": "surface", "target_matches": true}` only when that intended surface is verified in both images and no unexpected blocking popup covers the red dot.
|
||||
5. Use `[Planned Action & Original Thinking]` to resolve whether the target was a control or its underlying surface. Do not treat a missing control as present because another tap could restore it.
|
||||
|
||||
Output
|
||||
Return EXACTLY this raw JSON and nothing else. Keep reasoning strictly under 35 words.
|
||||
{"reasoning": "brief explanation", "is_present": boolean, "confidence": float}
|
||||
{"reasoning": "brief explanation", "is_present": boolean, "confidence": float, "target_kind": "control" or "surface", "target_matches": boolean}
|
||||
|
||||
target_matches means the intended target is positively identified at the red dot in both images. A stable blank area is not a match for an intended field or button. When identity is uncertain, return target_matches:false and is_present:false.
|
||||
@@ -26,6 +26,8 @@ is injected by the facade so that tests patching
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
@@ -54,6 +56,25 @@ _MAX_ATTEMPTS = 3
|
||||
_RETRY_DELAY_SECONDS = 0.3
|
||||
|
||||
|
||||
def _strict_target(action_item: dict) -> bool:
|
||||
return any(
|
||||
action_item.get(key)
|
||||
for key in (
|
||||
"target_text",
|
||||
"target_resource_id",
|
||||
"target_class",
|
||||
"target_bounds",
|
||||
"target_description",
|
||||
)
|
||||
) or action_item.get("action") in {"input_text", "focus_and_input_text"}
|
||||
|
||||
|
||||
def _unverified(action_item: dict, message: str) -> tuple[bool, ValidationErrorCategory, str]:
|
||||
if _strict_target(action_item):
|
||||
return False, ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, message
|
||||
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
|
||||
|
||||
|
||||
def _init_llm_and_prompt(ctx: ArtemisContext, get_llm_fn):
|
||||
"""Resolves the VLM and loads the prompt template (once per validation)."""
|
||||
try:
|
||||
@@ -199,8 +220,33 @@ async def _run_attempt(
|
||||
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)
|
||||
is_present = res_json.get("is_present")
|
||||
confidence = res_json.get("confidence")
|
||||
if (
|
||||
not isinstance(is_present, bool)
|
||||
or isinstance(confidence, bool)
|
||||
or not isinstance(confidence, (int, float))
|
||||
or not math.isfinite(confidence)
|
||||
or not 0 <= confidence <= 1
|
||||
):
|
||||
raise ValueError(
|
||||
"Pixel validation must include boolean is_present and confidence in [0, 1]."
|
||||
)
|
||||
if _strict_target(action_item) and (
|
||||
res_json.get("target_matches") is not True
|
||||
or res_json.get("target_kind") not in {"control", "surface"}
|
||||
):
|
||||
is_present = False
|
||||
reasoning = reasoning or "The intended target was not positively identified."
|
||||
if (
|
||||
action_item.get("action") in {"input_text", "focus_and_input_text"}
|
||||
or any(
|
||||
action_item.get(key)
|
||||
for key in ("target_text", "target_resource_id", "target_class", "target_bounds")
|
||||
)
|
||||
) and res_json.get("target_kind") != "control":
|
||||
is_present = False
|
||||
reasoning = "A recorded UI control cannot be validated as a blank surface."
|
||||
|
||||
logger.info(
|
||||
f"Pixel validation attempt {attempt}/{_MAX_ATTEMPTS} result:"
|
||||
@@ -208,12 +254,12 @@ async def _run_attempt(
|
||||
)
|
||||
|
||||
# E. Branching based on outcome
|
||||
if is_present:
|
||||
if is_present and (confidence >= 0.7 or not _strict_target(action_item)):
|
||||
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:
|
||||
if confidence >= 0.7 or _strict_target(action_item):
|
||||
reason_msg = (
|
||||
f"Pixel-level validation failed: {reasoning}"
|
||||
if reasoning
|
||||
@@ -257,11 +303,36 @@ async def validate_action_precondition_pixel(
|
||||
|
||||
if not orig_coords or len(orig_coords) != 2:
|
||||
logger.info("Pixel safety net skipped: no valid coordinates.")
|
||||
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
|
||||
return _unverified(action_item, "No valid coordinates to verify the intended target.")
|
||||
|
||||
if not current_coords or len(current_coords) != 2:
|
||||
current_coords = orig_coords
|
||||
|
||||
try:
|
||||
neighborhood = visualization.crop_and_annotate_target(
|
||||
base64.b64decode(pre_screenshot_b64), orig_coords, crop_size=256, dot_radius=0
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
neighborhood = pre_screenshot_b64.encode()
|
||||
# A changing clock elsewhere on screen is not fresh evidence for a
|
||||
# previously rejected blank point. Grounded control metadata still allows
|
||||
# a new validation; this does not restrict ordinary repeated actions.
|
||||
key = hashlib.sha256(repr(orig_coords).encode() + neighborhood).hexdigest()
|
||||
rejected = getattr(ctx, "rejected_pixel_targets", None)
|
||||
if not isinstance(rejected, dict):
|
||||
rejected = {}
|
||||
ctx.rejected_pixel_targets = rejected
|
||||
grounded = bool(action_item.get("target_bounds") and action_item.get("target_class"))
|
||||
if key in rejected and not grounded:
|
||||
return (
|
||||
False,
|
||||
ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED,
|
||||
(
|
||||
"This point was already rejected for this observation. A renamed description "
|
||||
"is not new evidence; locate the intended control again or obtain fresh target evidence."
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Pre-execution pixel validation: checking target position"
|
||||
f" {orig_coords} in original image vs {current_coords} on live"
|
||||
@@ -275,15 +346,23 @@ async def validate_action_precondition_pixel(
|
||||
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, ""
|
||||
logger.error(
|
||||
f"Failed to prepare original crop for pixel safety net: {e}. Target unverified."
|
||||
)
|
||||
return _unverified(
|
||||
action_item, "Reference screenshot unavailable; target identity is unverified."
|
||||
)
|
||||
|
||||
# 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, ""
|
||||
logger.error(
|
||||
f"Failed to initialize LLM/prompt for pixel safety net: {e}. Target unverified."
|
||||
)
|
||||
return _unverified(
|
||||
action_item, "Pixel validator unavailable; target identity is unverified."
|
||||
)
|
||||
|
||||
# 4. Start high-precision execution validation retry loop
|
||||
last_error = None
|
||||
@@ -301,6 +380,8 @@ async def validate_action_precondition_pixel(
|
||||
attempt,
|
||||
)
|
||||
if verdict is not None:
|
||||
if not verdict[0]:
|
||||
rejected[key] = True
|
||||
return verdict
|
||||
except Exception as attempt_err:
|
||||
logger.warning(
|
||||
@@ -312,9 +393,10 @@ async def validate_action_precondition_pixel(
|
||||
continue
|
||||
logger.error(
|
||||
"Pixel validation repeatedly failed with errors. Last"
|
||||
f" error: {last_error}. Bypassing check to avoid"
|
||||
" blocking flow."
|
||||
f" error: {last_error}. Target identity remains unverified."
|
||||
)
|
||||
return _unverified(
|
||||
action_item, "Pixel verification failed; observe and locate the target again."
|
||||
)
|
||||
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
|
||||
|
||||
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
|
||||
return _unverified(action_item, "Pixel validation did not establish target identity.")
|
||||
Reference in new issue
Block a user