feat: add agentic video analysis and clarify action results
Report device actions as dispatched and preserve full failure details. Route Flash through McpActionExecutor and use ToolFailure across helper tools and agent loops instead of inferring failure from response text. Require model-provided descriptions for coordinate targets. Keep observed element metadata separate from those descriptions in safety checks, visual summaries, history, and frontend labels. Add Gemini Interactions API video analysis with configurable processing mode, chunk size, and timeouts. Centralize Google provider capabilities and token usage normalization, and update google-genai to 2.22.0. Preserve system instructions on follow-up interactions, replan pending chunks when falling back to static processing, and reject unanswered native video and audio segments instead of caching placeholder results. Keep helper-tool failure status intact through history tools and committee calls. Validation: 1,762 Python unit tests passed (3 skipped), 40 frontend formatter tests passed, and the production UI build completed. Ruff lint, formatting, the quality ratchet, and git diff checks passed.
This commit is contained in:
1 parent
611d1a0947
commit
a45daf23f8
120 files changed
+6301
-1710
No files matched your search
+91
-126
@@ -14,22 +14,12 @@
|
||||
|
||||
"""MCP-backed action executor for the FlashRunner.
|
||||
|
||||
Same public contract as the legacy ``MobileActionExecutor`` --
|
||||
``execute(name, args, tool_call_id, state) -> ToolExecutionResult`` -- but execution
|
||||
routes through the in-process action MCP session, and dispatch is table-driven so a
|
||||
backend's extension tools are callable without touching this file.
|
||||
Device actions use the in-process MCP session. Argument normalization,
|
||||
coordinate descriptions, smart swipes, post-action observations, state updates,
|
||||
tracing, and helper tools are handled here on the agent side.
|
||||
|
||||
Kept agent-side (by design, not omission): argument normalization, element-index
|
||||
resolution against LangGraph ``State``, smart-swipe resolution, act-then-observe
|
||||
capture and ``State`` write-back, tracing (a ``ContextVar`` cannot cross the server's
|
||||
task boundary -- see the plan's R2), and the non-device tools (``read_note``,
|
||||
``list_notes``, ``ask_explorer``, ``video_analyzer`` and the shared history tools
|
||||
``search_history`` / ``replay_steps`` / ``get_step_screenshot``).
|
||||
|
||||
Status is carried by ``ActionResult.ok`` -- the historical substring sniffing on
|
||||
``"Error"``/``"Failed"`` is gone, so typing the literal text "Failed" into a field no
|
||||
longer marks the step as failed, and a successful tap with a failed screenshot is no
|
||||
longer misreported as an error.
|
||||
Device action status comes from ``ActionResult.ok``; helper tools report failures
|
||||
through ``ToolFailure`` or an explicit status.
|
||||
"""
|
||||
|
||||
import ast
|
||||
@@ -38,6 +28,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from artemis.core.tool_failure import ToolFailure, is_tool_failure
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.data_engine.trace import TraceSpan
|
||||
@@ -56,7 +47,6 @@ from artemis.utils.coordinates import (
|
||||
compute_smart_swipe_coordinates,
|
||||
parse_swipe_parameters,
|
||||
)
|
||||
from artemis.utils.element_hit_test import hit_test_semantics
|
||||
from artemis.utils.logger import get_logger
|
||||
from artemis.utils.notes import (
|
||||
format_list_notes_failure,
|
||||
@@ -153,19 +143,13 @@ class McpActionExecutor:
|
||||
img_bytes: bytes | None = None
|
||||
shot_path: str | None = None
|
||||
xml_list: str | None = None
|
||||
target_semantics: dict[str, Any] | None = None
|
||||
|
||||
target_semantics: dict[str, Any] = {}
|
||||
with span:
|
||||
try:
|
||||
session = await self._session_or_start()
|
||||
wire_name, wire_args, finalize = self._translate(raw_name, args, state)
|
||||
# Record-time enrichment: bare-coordinate targeted actions get
|
||||
# best-effort element semantics from the pre-action frame (the
|
||||
# post-action observe below overwrites state.indexed_elements,
|
||||
# so this must happen before the device call).
|
||||
target_semantics = self._target_semantics(raw_name, wire_args, state)
|
||||
if target_semantics:
|
||||
span.payload = {"args": {**args, **target_semantics}}
|
||||
wire_name, wire_args, finalize, target_semantics = self._translate(
|
||||
raw_name, args, state
|
||||
)
|
||||
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
|
||||
|
||||
if extension:
|
||||
@@ -233,71 +217,11 @@ class McpActionExecutor:
|
||||
},
|
||||
)
|
||||
|
||||
def _target_semantics(
|
||||
self, raw_name: str, wire_args: dict[str, Any], state: Any
|
||||
) -> dict[str, Any] | None:
|
||||
"""Best-effort element semantics for a coordinate-targeted action.
|
||||
|
||||
Hit tests the normalized (0-1000) target point(s) against the
|
||||
pre-action frame's indexed elements. Single-target actions get the
|
||||
flat ``target_*`` fields; multi-point actions (``click_sequence``,
|
||||
``swipe``) hit test every point best-effort (M5), hoisting the FIRST
|
||||
point's semantics to the main ``target_*`` label and keeping the
|
||||
per-point results alongside. Returns None for actions without a
|
||||
resolvable coordinate target; degrades to
|
||||
``target_label_source: "none"`` when no element data covers the point.
|
||||
"""
|
||||
try:
|
||||
elements = getattr(state, "indexed_elements", None) if state else None
|
||||
|
||||
if raw_name in ("click", "long_press", "input_text"):
|
||||
target = wire_args.get("target")
|
||||
if not (isinstance(target, (list, tuple)) and len(target) == 2):
|
||||
return None
|
||||
return self._hit_test_point(elements, target)
|
||||
|
||||
if raw_name == "click_sequence":
|
||||
sequence = wire_args.get("sequence")
|
||||
if not (isinstance(sequence, (list, tuple)) and sequence):
|
||||
return None
|
||||
points = [self._hit_test_point(elements, p) for p in sequence]
|
||||
semantics: dict[str, Any] = dict(points[0])
|
||||
semantics["points_semantics"] = points
|
||||
return semantics
|
||||
|
||||
if raw_name == "swipe":
|
||||
start = wire_args.get("start")
|
||||
end = wire_args.get("end")
|
||||
if not (isinstance(start, (list, tuple)) and len(start) == 2):
|
||||
return None
|
||||
semantics = dict(self._hit_test_point(elements, start))
|
||||
semantics["start_semantics"] = self._hit_test_point(elements, start)
|
||||
if isinstance(end, (list, tuple)) and len(end) == 2:
|
||||
semantics["end_semantics"] = self._hit_test_point(elements, end)
|
||||
return semantics
|
||||
|
||||
return None
|
||||
except Exception as enrich_err:
|
||||
logger.debug(f"Target semantics enrichment skipped for {raw_name}: {enrich_err}")
|
||||
return {"target_label_source": "none"}
|
||||
|
||||
def _hit_test_point(self, elements: Any, target: Any) -> dict[str, Any]:
|
||||
"""Hit tests one normalized (0-1000) point; never raises."""
|
||||
try:
|
||||
width = getattr(self.ctx.device, "device_width", 1080) if self.ctx.device else 1080
|
||||
height = getattr(self.ctx.device, "device_height", 2400) if self.ctx.device else 2400
|
||||
x_px = int(max(0, min(width - 1, float(target[0]) * width / 1000)))
|
||||
y_px = int(max(0, min(height - 1, float(target[1]) * height / 1000)))
|
||||
return hit_test_semantics(elements, x_px, y_px)
|
||||
except Exception:
|
||||
return {"target_label_source": "none"}
|
||||
|
||||
@staticmethod
|
||||
def _observe_despite_failure(raw_name: str, res: ActionResult) -> bool:
|
||||
"""Failures that still observe the screen, mirroring the legacy executor.
|
||||
"""Capture the screen after launch failures and wait_for_text timeouts.
|
||||
|
||||
A failed app launch and a wait_for_text timeout captured the screen; argument
|
||||
and package errors returned without one.
|
||||
Argument and package lookup errors do not require an observation.
|
||||
"""
|
||||
if raw_name == "manage_app":
|
||||
return res.code not in (ActionCode.PACKAGE_NOT_FOUND, ActionCode.INVALID_ARGS)
|
||||
@@ -309,16 +233,19 @@ class McpActionExecutor:
|
||||
|
||||
def _translate(
|
||||
self, raw_name: str, args: dict[str, Any], state: Any
|
||||
) -> tuple[str, dict[str, Any], Any]:
|
||||
"""Maps agent-facing args to wire args; returns (name, args, finalize).
|
||||
) -> tuple[str, dict[str, Any], Any, dict[str, Any]]:
|
||||
"""Maps agent-facing args to wire args; returns (name, args, finalize, recorded).
|
||||
|
||||
``finalize(res)`` post-processes the ActionResult message where the historical
|
||||
wording depended on client-side context (direction swipes).
|
||||
``finalize(res)`` adds client-side context to swipe result messages.
|
||||
``recorded`` contains validated target descriptions in the Pro Operator's
|
||||
record format. Focused input and directional swipes have no coordinate
|
||||
target, so they do not record a description.
|
||||
"""
|
||||
if raw_name == "click":
|
||||
target = self._require_pair(
|
||||
args.get("target") or args.get("coordinates") or [], "click"
|
||||
)
|
||||
recorded = self._require_description(args, "click")
|
||||
return (
|
||||
"click",
|
||||
{
|
||||
@@ -327,12 +254,14 @@ class McpActionExecutor:
|
||||
"delay_ms": args.get("delay_ms", 100),
|
||||
},
|
||||
None,
|
||||
recorded,
|
||||
)
|
||||
|
||||
if raw_name == "long_press":
|
||||
target = self._require_pair(
|
||||
args.get("target") or args.get("coordinates") or [], "long press"
|
||||
)
|
||||
recorded = self._require_description(args, "long press")
|
||||
return (
|
||||
"long_press",
|
||||
{
|
||||
@@ -340,13 +269,16 @@ class McpActionExecutor:
|
||||
"duration_ms": args.get("duration_ms", args.get("duration", 1000)),
|
||||
},
|
||||
None,
|
||||
recorded,
|
||||
)
|
||||
|
||||
if raw_name == "input_text":
|
||||
raw_target = args.get("target") or args.get("coordinates")
|
||||
target = None
|
||||
recorded = {}
|
||||
if raw_target:
|
||||
target = list(self._require_pair(raw_target, "input text"))
|
||||
recorded = self._require_description(args, "input text")
|
||||
return (
|
||||
"input_text",
|
||||
{
|
||||
@@ -355,23 +287,27 @@ class McpActionExecutor:
|
||||
"clear_exist": args.get("clear_exist", True),
|
||||
},
|
||||
None,
|
||||
recorded,
|
||||
)
|
||||
|
||||
if raw_name == "click_sequence":
|
||||
sequence = self._resolve_sequence(args.get("sequence") or [])
|
||||
recorded = self._require_descriptions(args, len(sequence))
|
||||
return (
|
||||
"click_sequence",
|
||||
{
|
||||
"sequence": self._resolve_sequence(args.get("sequence") or [], state),
|
||||
"sequence": sequence,
|
||||
"delay_ms": args.get("delay_ms", 50),
|
||||
},
|
||||
None,
|
||||
recorded,
|
||||
)
|
||||
|
||||
if raw_name == "swipe":
|
||||
return self._translate_swipe(args, state)
|
||||
|
||||
if raw_name == "press_key":
|
||||
return "press_key", {"key": args.get("key", "BACK")}, None
|
||||
return "press_key", {"key": args.get("key", "BACK")}, None, {}
|
||||
|
||||
if raw_name == "manage_app":
|
||||
return (
|
||||
@@ -381,10 +317,11 @@ class McpActionExecutor:
|
||||
"app_name": args.get("app_name", ""),
|
||||
},
|
||||
None,
|
||||
{},
|
||||
)
|
||||
|
||||
if raw_name == "wait_for_delay":
|
||||
return "wait_for_delay", {"time_in_ms": args.get("time_in_ms", 1000)}, None
|
||||
return "wait_for_delay", {"time_in_ms": args.get("time_in_ms", 1000)}, None, {}
|
||||
|
||||
if raw_name == "wait_for_text":
|
||||
return (
|
||||
@@ -395,10 +332,11 @@ class McpActionExecutor:
|
||||
"timeout_ms": args.get("timeout_ms", 5000),
|
||||
},
|
||||
None,
|
||||
{},
|
||||
)
|
||||
|
||||
# Backend extension: pass the arguments straight through.
|
||||
return raw_name, dict(args), None
|
||||
return raw_name, dict(args), None, {}
|
||||
|
||||
@staticmethod
|
||||
def _require_pair(raw: Any, label: str) -> tuple[int, int]:
|
||||
@@ -412,8 +350,40 @@ class McpActionExecutor:
|
||||
raise _ArgError(f"{prefix}: Invalid target format: {raw}")
|
||||
return int(target[0]), int(target[1])
|
||||
|
||||
def _resolve_sequence(self, sequence: Any, state: Any) -> list[list[int]]:
|
||||
"""Resolves click_sequence entries (indices or pairs) to normalized pairs."""
|
||||
@staticmethod
|
||||
def _require_description(args: dict[str, Any], label: str) -> dict[str, Any]:
|
||||
"""Validate and trim the coordinate target description for the action record."""
|
||||
description = args.get("target_description")
|
||||
if not (isinstance(description, str) and description.strip()):
|
||||
raise _ArgError(
|
||||
f"Error during {label}: 'target_description' is required (what the"
|
||||
" target is, in a few words)."
|
||||
)
|
||||
return {"target_description": description.strip()}
|
||||
|
||||
@staticmethod
|
||||
def _require_descriptions(args: dict[str, Any], count: int) -> dict[str, Any]:
|
||||
"""``click_sequence``: one description per sequence entry, in order.
|
||||
|
||||
Returns the cleaned statements as the recorded ``target_descriptions`` field.
|
||||
"""
|
||||
descriptions = args.get("target_descriptions")
|
||||
if isinstance(descriptions, str):
|
||||
descriptions = [descriptions]
|
||||
if (
|
||||
not isinstance(descriptions, (list, tuple))
|
||||
or len(descriptions) != count
|
||||
or not all(isinstance(d, str) and d.strip() for d in descriptions)
|
||||
):
|
||||
raise _ArgError(
|
||||
"Error during click sequence: 'target_descriptions' is required, one"
|
||||
f" non-empty entry per sequence entry ({count} expected)."
|
||||
)
|
||||
return {"target_descriptions": [d.strip() for d in descriptions]}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_sequence(sequence: Any) -> list[list[int]]:
|
||||
"""Normalize click_sequence entries to coordinate pairs; reject element indices."""
|
||||
if isinstance(sequence, str):
|
||||
sequence_str = sequence.strip()
|
||||
try:
|
||||
@@ -427,31 +397,26 @@ class McpActionExecutor:
|
||||
if not isinstance(sequence, (list, tuple)):
|
||||
raise _ArgError(f"Error during click sequence: Invalid sequence format: {sequence}")
|
||||
|
||||
width = getattr(self.ctx.device, "device_width", 1080) if self.ctx.device else 1080
|
||||
height = getattr(self.ctx.device, "device_height", 2400) if self.ctx.device else 2400
|
||||
|
||||
resolved: list[list[int]] = []
|
||||
for raw_target in sequence:
|
||||
for position, raw_target in enumerate(sequence, start=1):
|
||||
target = normalize_coordinate_target(raw_target)
|
||||
if isinstance(target, int):
|
||||
idx = target
|
||||
points = getattr(state, "indexed_points", []) or []
|
||||
if not 1 <= idx <= len(points):
|
||||
raise _ArgError(
|
||||
"Error during click sequence: Index"
|
||||
f" {idx} is out of range (available: {len(points)})"
|
||||
)
|
||||
px, py = int(points[idx - 1][0]), int(points[idx - 1][1])
|
||||
nx = int(max(0, min(1000, round(px * 1000 / max(1, width)))))
|
||||
ny = int(max(0, min(1000, round(py * 1000 / max(1, height)))))
|
||||
elif isinstance(target, (list, tuple)) and len(target) == 2:
|
||||
if isinstance(target, (int, float)) and not isinstance(target, bool):
|
||||
raise _ArgError(
|
||||
f"Error during click sequence: entry {position} ({raw_target!r}) is an"
|
||||
" element index; click_sequence takes normalized [x, y] coordinate"
|
||||
" pairs only. Use the element's coordinates from the element list,"
|
||||
" or ask_explorer to locate it."
|
||||
)
|
||||
if isinstance(target, (list, tuple)) and len(target) == 2:
|
||||
nx, ny = int(target[0]), int(target[1])
|
||||
else:
|
||||
raise _ArgError(f"Error during click sequence: Invalid target format: {raw_target}")
|
||||
resolved.append([nx, ny])
|
||||
return resolved
|
||||
|
||||
def _translate_swipe(self, args: dict[str, Any], state: Any) -> tuple[str, dict[str, Any], Any]:
|
||||
def _translate_swipe(
|
||||
self, args: dict[str, Any], state: Any
|
||||
) -> tuple[str, dict[str, Any], Any, dict[str, Any]]:
|
||||
width = getattr(self.ctx.device, "device_width", 1080) if self.ctx.device else 1080
|
||||
height = getattr(self.ctx.device, "device_height", 2400) if self.ctx.device else 2400
|
||||
default_duration = args.get("duration", 400)
|
||||
@@ -477,7 +442,7 @@ class McpActionExecutor:
|
||||
def finalize(res: ActionResult) -> str:
|
||||
if not res.ok:
|
||||
return f"Error swiping {dir_name}: {res.detail}"
|
||||
return f"Swipe completed successfully. Swiped {dir_name}."
|
||||
return f"Swiped {dir_name}."
|
||||
|
||||
return (
|
||||
"swipe",
|
||||
@@ -487,15 +452,17 @@ class McpActionExecutor:
|
||||
"duration_ms": smart_dur,
|
||||
},
|
||||
finalize,
|
||||
{},
|
||||
)
|
||||
|
||||
if kind == "coords":
|
||||
x1, y1, x2, y2 = target
|
||||
recorded = self._require_description(args, "swipe")
|
||||
|
||||
def finalize(res: ActionResult) -> str:
|
||||
if not res.ok:
|
||||
return f"Error dragging: {res.detail}"
|
||||
return f"Swipe completed successfully. Swiped from [{x1}, {y1}] to [{x2}, {y2}]."
|
||||
return f"Swiped from [{x1}, {y1}] to [{x2}, {y2}] (normalized)."
|
||||
|
||||
return (
|
||||
"swipe",
|
||||
@@ -505,6 +472,7 @@ class McpActionExecutor:
|
||||
"duration_ms": final_duration,
|
||||
},
|
||||
finalize,
|
||||
recorded,
|
||||
)
|
||||
|
||||
raise _ArgError(f"Error during swipe: Invalid direction: {args}")
|
||||
@@ -537,7 +505,6 @@ class McpActionExecutor:
|
||||
)
|
||||
elif raw_name in HISTORY_TOOL_NAMES:
|
||||
text, blocks = await self._history_tool(raw_name, args)
|
||||
ok = True
|
||||
else:
|
||||
text, ok = await self._ask_explorer(
|
||||
# ``task_description`` is the pre-contract argument name
|
||||
@@ -551,13 +518,11 @@ class McpActionExecutor:
|
||||
span.status = "failed"
|
||||
span.error = text
|
||||
|
||||
# Note texts come from our own failure formatters, so the historical
|
||||
# "Error" marker check is reliable there (unlike free-form device
|
||||
# output); the Explorer and the video analyzer report their status
|
||||
# explicitly and a history lookup's "no match" / "not found" answer
|
||||
# is not an error.
|
||||
# The Explorer and the video analyzer report their status explicitly;
|
||||
# note and history texts carry it structurally (a ``ToolFailure``), so a
|
||||
# history lookup's "no match" answer is an answer, not an error.
|
||||
if ok is None:
|
||||
ok = "Error" not in (text or "")
|
||||
ok = not is_tool_failure(text)
|
||||
return ToolExecutionResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=name,
|
||||
@@ -597,13 +562,13 @@ class McpActionExecutor:
|
||||
"""
|
||||
tool = history_tool_by_name(raw_name)
|
||||
if tool is None:
|
||||
return f"Error: Tool '{raw_name}' not supported.", None
|
||||
return ToolFailure(f"Error: Tool '{raw_name}' not supported."), None
|
||||
accepted = {k: v for k, v in args.items() if k in tool.args_schema.model_fields}
|
||||
try:
|
||||
result = await tool.execute(ctx=self.ctx, **accepted)
|
||||
except Exception as e:
|
||||
logger.error(f"Error running {raw_name}: {e}")
|
||||
return f"{raw_name} failed: {e}", None
|
||||
return ToolFailure(f"{raw_name} failed: {e}"), None
|
||||
text, images = split_multimodal_result(result)
|
||||
if images:
|
||||
return text or "Screenshot attached.", result
|
||||
|
||||
Reference in new issue
Block a user