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:
somew1nd committed 2026-09-08 17:05:54 -07:00
1 parent 611d1a0947
commit a45daf23f8
120 files changed
+6301 -1710

No files matched your search

+1 -1
View File
@@ -313,7 +313,7 @@ class ScrubEdgeCompressor:
# Defensive fallback: never leave ToolMessage or HumanMessage content empty
if not new_blocks:
new_blocks = [{"type": "text", "text": "Action completed." if rec["is_tool"] else ""}]
new_blocks = [{"type": "text", "text": "Action dispatched." if rec["is_tool"] else ""}]
msg.content = new_blocks
self._frozen.add(rec["idx"])
+2 -2
View File
@@ -23,7 +23,7 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
---
# 3. READING YOUR HISTORY & THE SESSION CLOCK
- **Conversation = execution history**: The earlier turns of this conversation are your raw execution history. Each observation opens with `# CURRENT OBSERVATION [T+mm:ss]`, and each turn that executed a device action ends with an `--- Action Execution Result (T+mm:ss) ---` message (`Status: success` means the action was dispatched and observed; `Status: failed` carries the `Error:` line). Every timestamp is a session-relative `T+mm:ss` offset from the start of this session; it is the only clock you have, for pacing waits as well as for reasoning about elapsed time.
- **Conversation = execution history**: The earlier turns of this conversation are your raw execution history. Each observation opens with `# CURRENT OBSERVATION [T+mm:ss]`, and each turn that executed a device action ends with an `--- Action Execution Result (T+mm:ss) ---` message (`Status: dispatched` means the device accepted the action; whether it had the intended effect is yours to judge from the observation that follows. `Status: failed` carries the `Error:` line). Every timestamp is a session-relative `T+mm:ss` offset from the start of this session; it is the only clock you have, for pacing waits as well as for reasoning about elapsed time.
- **Compression**: Screenshots older than a few turns are replaced by short `--- Historical Visual Transition ---` summaries and old `--- Visible UI Elements ---` lists are dropped. Long spans render as `[Era N | Steps a–b | T+.. → T+..]` blocks (a merged synopsis, segment titles, and a per-step action ledger). Because of this, if you spot critical text that you will need later (e.g., verification codes, specific titles, search lists), **you must explicitly verbalize and write those details into your text reasoning** so they persist across turns.
{% if "search_history" in available_tools %}- **History Lookup (`search_history`)**: Compressed history keeps the step numbers and `T+` offsets but not every detail. When you need exactly what you did or saw at an earlier step (a value you read, the result of an action, which screen you were on), call `search_history` with keywords and/or the `[start, end]` step range shown in a chunk block; it searches the full stored history (screen descriptions, actions, results, reasoning, tool calls, notes, on-screen text) and every hit carries its step number. It is a pre-decision helper: read its answer, then act.{% endif %}
{% if "replay_steps" in available_tools %}- **Step Replay (`replay_steps`)**: Replays one earlier step (or a short range) exactly as your context showed it — screen description, reasoning, tool calls with their results, the action and its execution result. Use the step numbers shown in the compressed history{% if "search_history" in available_tools %} or returned by `search_history`{% endif %}.{% endif %}
@@ -39,7 +39,7 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
---
# 5. ACTION RULES
1. **Normalized Coordinates (`0-1000` Scale)**: Always use normalized `[x, y]` coordinates in `0-1000` scale for any location-based target (e.g., `target=[500, 600]` or `click_sequence=[[300, 400], [500, 600]]`).
1. **Normalized Coordinates (`0-1000` Scale)**: Always use normalized `[x, y]` coordinates in `0-1000` scale for any location-based target (e.g., `target=[500, 600]` or `click_sequence=[[300, 400], [500, 600]]`). Every coordinate target must carry `target_description` (one entry per point for `click_sequence`): a short, meaningful name for what you are aiming at, as a user would call it (e.g. 'play button', 'video body'), never a generic 'element' or 'button'. Nothing is inferred on your behalf: it is recorded as your own statement, and your history shows it marked as self-described.
2. **Do not determine element coordinates based on guesswork.** If the element is visible in the screenshot but its coordinates are missing from the provided `--- Visible UI Elements ---` list, please use the `ask_explorer` tool to locate the element first.
{% if "manage_app" in available_tools %}3. **App Launching**: To start or reset an application, always use `manage_app(action="launch", app_name="...")` or `action="stop"`. Do not manually swipe across home screens.{% endif %}
+13 -1
View File
@@ -5,7 +5,7 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
# PERSPECTIVE & FORMAT CONSTRAINTS
1. **First-Person Perspective**: Write strictly from the agent's first-person perspective using **"I"** (e.g., "In Step {{ step_number }}, I tapped...", "I swiped up on... and observed..."). NEVER use third-person terms like "The agent", "The operator", or "The system".
2. **Single Continuous Paragraph**: Your output MUST be exactly **one compact, continuous paragraph** (1–3 sentences, 35–65 words).
2. **Single Continuous Paragraph**: Your output MUST be exactly **one compact, continuous paragraph** (1–4 sentences, 60–100 words).
3. **No Lists or Formatting**: Do NOT use bullet points, numbered lists, markdown headers, bold labels, or line breaks in your output. Your output must NEVER contain `---` separators or section-marker lines — those belong to the input, not the summary.
---
@@ -21,6 +21,15 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
---
# OPERATOR FOCUS (ATTENTION GUIDANCE)
The input may open with an `--- [0] OPERATOR FOCUS ... ---` block: the operator's own words about this step — the task goal, the active sub-goal, a user instruction, and the operator's reasoning naming the target it chose and the screen change it expected. The action line shows the operator's target as `'...' (self-described)` (its own statement) or as `'...'` (an element observed in the UI tree). Use all of this to direct your attention, never to supply facts:
1. **Focused details are mandatory**: Every visible detail that relates to the named target, the stated expectation, the sub-goal or the goal — exact labels, values, amounts, counts, toggle/selection/enabled/loading states, positions — must be transcribed verbatim. Never paraphrase or skip them. If the target control is visible, name its exact label.
2. **Absence is a fact**: If something the operator expected or looked for is NOT visible, say so plainly (e.g., "no confirmation dialog is visible", "the 'Pay' button is not on this screen"). Never describe an expected element or change merely because the operator expected it.
3. **The rest stays complete**: Focus adds emphasis; it never narrows the description. Keep describing the whole screen and the whole transition at full detail.
4. **Not evidence**: The focus block is intention, not observation. A self-described target is what the operator believed it aimed at; describe what the red marker actually landed on.
---
# EXAMPLES OF HIGH-QUALITY SUMMARIES
- **Click / Navigation**:
@@ -35,6 +44,9 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
- **Modal / Alert State**:
"In Step 4, I tapped the 'Confirm Booking' button; an alert dialog titled 'Payment Method Required' appeared over the view with an 'Add Card' option."
- **With operator focus** (action line `Tapped 'Wi-Fi toggle' (self-described) at [880, 410]`, reasoning expected the toggle to switch on):
"In Step 6, I tapped the switch marked in red on the 'Wi-Fi' row, which read 'Off' before the tap; afterwards the switch showed 'On' with 'Searching for networks…' beneath it, the network list below stayed empty, and the remaining rows ('Mobile network', 'Hotspot & tethering') were unchanged."
---
# TASK
@@ -12,7 +12,7 @@ You are the Step Summarizer for an Android UI automation agent. Exactly ONE scre
# PERSPECTIVE & FORMAT CONSTRAINTS
1. **First-Person Perspective**: Write strictly from the agent's first-person perspective using **"I"** (e.g., "In Step {{ step_number }}, I tapped... on a screen showing..."). NEVER use third-person terms like "The agent", "The operator", or "The system".
2. **Single Continuous Paragraph**: Your output MUST be exactly **one compact, continuous paragraph** (1–3 sentences, 35–65 words).
2. **Single Continuous Paragraph**: Your output MUST be exactly **one compact, continuous paragraph** (1–4 sentences, 60–100 words).
3. **No Lists or Formatting**: Do NOT use bullet points, numbered lists, markdown headers, bold labels, or line breaks. Your output must NEVER contain `---` separators or section-marker lines — those belong to the input, not the summary.
---
@@ -25,6 +25,15 @@ You are the Step Summarizer for an Android UI automation agent. Exactly ONE scre
---
# OPERATOR FOCUS (ATTENTION GUIDANCE)
The input may open with an `--- [0] OPERATOR FOCUS ... ---` block: the operator's own words about this step — the task goal, the active sub-goal, a user instruction, and the operator's reasoning naming the target it chose and the screen change it expected. The action line shows the operator's target as `'...' (self-described)` (its own statement) or as `'...'` (an element observed in the UI tree). Use all of this to direct your attention, never to supply facts:
1. **Focused details are mandatory**: Every visible detail that relates to the named target, the stated expectation (as it stands on THIS frame only), the sub-goal or the goal — exact labels, values, amounts, counts, toggle/selection/enabled/loading states, positions — must be transcribed verbatim. Never paraphrase or skip them. If the target control is visible, name its exact label.
2. **Absence is a fact**: If something the operator expected or looked for is NOT visible, say so plainly (e.g., "no confirmation dialog is visible", "the 'Pay' button is not on this screen"). Never describe an expected element or change merely because the operator expected it.
3. **The rest stays complete**: Focus adds emphasis; it never narrows the description. Keep describing the whole screen and the whole transition at full detail.
4. **Not evidence**: The focus block is intention, not observation. A self-described target is what the operator believed it aimed at; describe what the red marker actually landed on.
---
# EXAMPLES OF HIGH-QUALITY SINGLE-FRAME SUMMARIES
- **Click on a list row**:
@@ -36,6 +45,9 @@ You are the Step Summarizer for an Android UI automation agent. Exactly ONE scre
- **Swipe / Scroll**:
"In Step 3, I swiped up starting from the red-marked point on the flight results list, which at that moment displayed 4 morning flights priced from '$310' under the header 'Best departing flights'."
- **With operator focus** (action line `Tapped 'Wi-Fi toggle' (self-described) at [880, 410]`, sub-goal "Enable Wi-Fi"):
"In Step 6, I tapped the switch marked in red at the right end of the 'Wi-Fi' row, which read 'Off' at that moment; the Network & internet screen also listed 'Mobile network' with 'T-Mobile' beneath it and 'Hotspot & tethering' with 'Off', and no network list or dialog was visible."
---
# TASK
+38 -23
View File
@@ -44,7 +44,7 @@ from langchain_core.messages import (
ToolMessage,
)
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.agents.flash.summarizer import VisualStepSummarizer, build_focus_context
from artemis.agents.validator.tool_declarations import (
ASK_EXPLORER_TOOL,
CLICK_SEQUENCE_TOOL,
@@ -123,8 +123,10 @@ class _TurnRecord:
return None
for name, status, text in self.actions:
if status != "success":
return {"status": "failed", "error": f"{name}: {text}"}
return {"status": "success"}
# Executor messages name the action themselves ("Error
# executing click: ..."); prefix only the ones that don't.
return {"status": "failed", "error": text if name in text else f"{name}: {text}"}
return {"status": "dispatched"}
class FlashRunner:
@@ -284,10 +286,17 @@ class FlashRunner:
# Per-turn helpers (observe / think)
# ------------------------------------------------------------------
async def _read_injected_instruction(self) -> str | None:
"""Returns the real-time injected instruction text for this turn, if any."""
async def _read_injected_instruction(self) -> tuple[str | None, str | None]:
"""Consumes this turn's injected instruction: ``(instruction, notice)``.
``instruction`` is the user's text verbatim — what the step record,
the chunk ledger and the visual-transition lens receive (the same
shape Pro stamps). ``notice`` wraps it with the operator-facing
directive and rides only on the observation tail. Both are None
when nothing was injected.
"""
if not (self.ctx.data_engine and self.ctx.data_engine.base_dir):
return None
return None, None
try:
injected_payload = await asyncio.to_thread(
_check_injected_instruction_file,
@@ -295,21 +304,22 @@ class FlashRunner:
)
except (OSError, ValueError, AttributeError) as e:
logger.warning(f"Failed to check injected instruction in FlashRunner: {e}")
return None
return None, None
if not (injected_payload and injected_payload.get("instruction")):
return None
injected_text = (
return None, None
instruction = str(injected_payload["instruction"])
notice = (
"[REAL-TIME INJECTED INSTRUCTION from user]:"
f" {injected_payload['instruction']}\nYou MUST immediately"
f" {instruction}\nYou MUST immediately"
" follow this instruction and adjust your plan/actions."
)
if injected_payload.get("release_loop"):
injected_text += (
notice += (
"\nThe user has explicitly authorized stopping any"
" ongoing monitoring loop; you may now wrap up and"
" complete the task."
)
return injected_text
return instruction, notice
def _build_tail(
self,
@@ -597,16 +607,13 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end
# Record-time enrichment computed by the executor from
# the pre-action frame (target_text / target_class /
# target_resource_id / target_label_source).
target_semantics = (exec_result.metadata or {}).get("target_semantics")
if isinstance(target_semantics, dict):
action_dict.update(target_semantics)
# The model's own statement of what it aimed at (target_description),
# validated and shaped by the executor into the fields Pro records.
action_dict.update((exec_result.metadata or {}).get("target_semantics") or {})
succeeded = exec_result.status == "success"
last_execution_result = {
"status": "success" if succeeded else "failed",
"status": "dispatched" if succeeded else "failed",
"result": exec_result.text_summary,
}
if not succeeded:
@@ -704,6 +711,13 @@ class FlashRunner:
exec_outcome=exec_result.text_summary,
action_key=str(tc_id),
data_engine_step_id=recorded_step_id,
# The model's own reasoning for this turn steers the lens's
# attention; the target it named rides on ``args``.
focus=build_focus_context(
intent=raw_text,
goal=self.goal,
injected_instruction=injected,
),
)
if post_img_bytes:
@@ -719,7 +733,7 @@ class FlashRunner:
content = (
raw_blocks
if isinstance(raw_blocks, list) and raw_blocks
else exec_result.text_summary or f"Action '{name}' completed."
else exec_result.text_summary or f"Action '{name}' dispatched."
)
messages.extend(
tool_result_messages(
@@ -868,8 +882,9 @@ class FlashRunner:
self._commit_turn(ledger, previous_turn)
previous_turn = None
# Check for real-time injected instructions
injected = await self._read_injected_instruction()
# Check for real-time injected instructions: the verbatim text
# is stamped on the step, the wrapped notice goes to the tail.
injected, injected_notice = await self._read_injected_instruction()
# Tool restriction on the final turn (bounded loops only)
is_final = limit is not None and turns == limit
@@ -878,7 +893,7 @@ class FlashRunner:
turns,
current_pre_screenshot_bytes,
current_xml_list,
injected=injected,
injected=injected_notice,
notices=pending_notices,
is_final=is_final,
)
+113 -9
View File
@@ -39,6 +39,7 @@ from artemis.memory.step_memory import JobKey, StepMemoryService
from artemis.services.llm import RobustChatModelWrapper, get_google_llm, get_llm
from artemis.services.token_meter import record_llm_usage
from artemis.utils.logger import get_logger
from artemis.utils.task_tree import format_actions_clean
from artemis.utils.visualization import draw_action_overlay_on_image
logger = get_logger(__name__)
@@ -62,6 +63,79 @@ def degenerate_summary_reason(text: str) -> str | None:
return None
#: Header of the operator-focus block that leads the lens input. It shares
#: the ``--- [`` shape of the frame headers so an echoed header trips the
#: same degenerate-output guard.
FOCUS_BLOCK_HEADER = "--- [0] OPERATOR FOCUS (context for attention, not evidence) ---"
#: Cap on the operator reasoning excerpt carried in the focus block. The
#: expectation statement closes the reasoning, so the tail is kept intact
#: and the cut lands in the middle.
FOCUS_INTENT_MAX_CHARS = 900
FOCUS_INTENT_TAIL_CHARS = 600
def _cap_intent(text: str) -> str:
if len(text) <= FOCUS_INTENT_MAX_CHARS:
return text
head_len = FOCUS_INTENT_MAX_CHARS - FOCUS_INTENT_TAIL_CHARS
head, tail = text[:head_len], text[-FOCUS_INTENT_TAIL_CHARS:]
cut = len(text) - len(head) - len(tail)
return f"{head}\n[… {cut} characters cut here …]\n{tail}"
def build_focus_context(
*,
intent: str | None = None,
goal: str | None = None,
subgoal: str | None = None,
injected_instruction: str | None = None,
) -> dict[str, str] | None:
"""Collect operator context to guide the visual summary's focus.
Target descriptions remain on the action. Return None if all fields are empty.
"""
focus: dict[str, str] = {}
if isinstance(goal, str) and goal.strip():
focus["goal"] = goal.strip()
if isinstance(subgoal, str) and subgoal.strip():
focus["subgoal"] = subgoal.strip()
if isinstance(injected_instruction, str) and injected_instruction.strip():
focus["injected_instruction"] = injected_instruction.strip()
if isinstance(intent, str) and intent.strip():
focus["intent"] = _cap_intent(intent.strip())
return focus or None
def render_focus_block(focus: dict[str, str], *, dual: bool = True) -> str:
"""Plain-text focus block (goal, sub-goal, instruction, then reasoning).
``dual=False`` (a single decision frame, every Pro step) labels the
reasoning so its expectation is read as the operator's aim only: the
outcome is not in the frame and must not be described.
"""
lines = [FOCUS_BLOCK_HEADER]
if focus.get("goal"):
lines.append(f"Task goal: {focus['goal']}")
if focus.get("subgoal"):
lines.append(f"Active sub-goal: {focus['subgoal']}")
if focus.get("injected_instruction"):
lines.append(f"User instruction at this step: {focus['injected_instruction']}")
if focus.get("intent"):
if dual:
lines.append(
"Operator reasoning for this step (why this target, what screen"
" change it expected):"
)
else:
lines.append(
"Operator reasoning for this step (why this target; the expected"
" outcome is NOT in this frame and must not be described):"
)
lines.append(focus["intent"])
return "\n".join(lines)
class VisualStepSummarizer(StepMemoryService):
"""Visual-transition lens on top of the shared step-memory runtime.
@@ -148,6 +222,7 @@ class VisualStepSummarizer(StepMemoryService):
*,
action_key: str | None = None,
data_engine_step_id: UUID | str | None = None,
focus: dict[str, str] | None = None,
) -> None:
"""Dispatches an asynchronous summarization task without blocking the caller.
@@ -155,6 +230,10 @@ class VisualStepSummarizer(StepMemoryService):
tool_call_id (``action_key``) is retained as an alias so the message
compressor can keep querying by it. Callers with neither provide the
step ordinal, matching the legacy keying.
``focus`` (:func:`build_focus_context`) is the operator's own context
for the step; the lens renders it ahead of the frames so the details
the operator was after are transcribed rather than summarized away.
"""
key: JobKey
aliases: tuple[JobKey, ...] = ()
@@ -173,9 +252,32 @@ class VisualStepSummarizer(StepMemoryService):
"post_img_bytes": post_img_bytes,
"exec_outcome": exec_outcome,
"data_engine_step_id": data_engine_step_id,
"focus": focus,
}
self.submit(key, payload, aliases=aliases)
@staticmethod
def _action_phrase(action_name: str, action_args: dict[str, Any] | None) -> str:
"""The action as every other history reader sees it (``format_actions_clean``).
A described coordinate target renders as ``'play button' (self-described)``,
an observed element as ``'Play'``; a Pro burst lists every member.
"""
args = dict(action_args or {})
extra = args.pop("additional_actions", None)
# Flash's coordinate swipe names its points ``start``/``end``; the
# history renderer reads ``start_coordinates``/``end_coordinates``.
if action_name == "swipe" and "start" in args and "end" in args:
args.setdefault("start_coordinates", args["start"])
args.setdefault("end_coordinates", args["end"])
try:
first = {"action": action_name, **args}
actions = [first, *extra] if isinstance(extra, list) and extra else first
return format_actions_clean(actions)
except Exception as exc:
logger.debug(f"Action phrase rendering fell back to raw args: {exc}", exc_info=True)
return f"{action_name}({action_args})"
def _meter_lens_call(self, response: Any) -> None:
"""Meter one raw-model lens call as an ``llm_usage`` trace, best-effort.
@@ -236,15 +338,15 @@ class VisualStepSummarizer(StepMemoryService):
dual = bool(pre_bytes) and bool(post_bytes)
template = self._prompt_template if dual else self._single_prompt_template
rendered_prompt = Template(template).render(step_number=step_number)
content_blocks: list[dict[str, Any]] = [
{
"type": "text",
"text": (
f"Step {step_number} Physical Action: {action_name}({action_args})\n"
f"Controller Outcome: {exec_outcome}"
),
}
lead_lines = [
f"Step {step_number} Physical Action: {self._action_phrase(action_name, action_args)}",
f"Action arguments (verbatim): {action_name}({action_args})",
f"Controller Outcome: {exec_outcome}",
]
focus = input_data.get("focus")
if isinstance(focus, dict) and focus:
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
content_blocks: list[dict[str, Any]] = [{"type": "text", "text": "\n".join(lead_lines)}]
if pre_bytes:
# 🎨 Visually mark the exact action (tap ripple, sequence numbers, swipe arrow) on the BEFORE screenshot
@@ -318,10 +420,12 @@ class VisualStepSummarizer(StepMemoryService):
f"VisualStepSummarizer: Generated summary for Step {step_number}: {summary_text[:80]}..."
)
# Free binary image buffers from memory once summary is secured
# Free binary image buffers (and the focus text) once the
# summary is secured; a landed job is never re-rendered.
if key in self._step_inputs:
self._step_inputs[key]["pre_img_bytes"] = None
self._step_inputs[key]["post_img_bytes"] = None
self._step_inputs[key]["focus"] = None
# Update DataEngine telemetry if active
if self.ctx.data_engine: