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.
448 lines
18 KiB
Python
448 lines
18 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""FlashRunner over the session transcript ledger (Pro-identical history).
|
|
|
|
Covers: the ``video_analyzer`` availability gate (same as the Pro graph) and
|
|
its prompt segment, the unbounded-by-default turn limit, the Pro-shaped
|
|
observation tail (``# CURRENT OBSERVATION [T+mm:ss]`` header, screenshot,
|
|
``--- Visible UI Elements ---`` list), and the end-to-end loop: committed
|
|
turns carry text-only tool messages plus an ``--- Action Execution Result
|
|
(T+mm:ss) ---`` message, every recorded step of a multi-action turn is
|
|
registered on the committed turn, the final-turn tool restriction only
|
|
applies to bounded loops, and a no-tool-call turn is nudged, not terminated.
|
|
"""
|
|
|
|
import base64
|
|
import re
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
|
|
from artemis.agents.flash.runner import FlashRunner, _TurnRecord
|
|
from artemis.agents.validator.tool_declarations import ToolExecutionResult
|
|
from artemis.context import ArtemisContext
|
|
from artemis.graph.state import State
|
|
from artemis.memory.transcript import (
|
|
EXECUTION_RESULT_MARKER,
|
|
PRO_UI_LIST_MARKER,
|
|
TranscriptLedger,
|
|
)
|
|
|
|
OBSERVATION_HEADER_RE = re.compile(r"^# CURRENT OBSERVATION \[T\+\d{2,}:\d{2}\]$")
|
|
RESULT_RE = re.compile(
|
|
rf"^{re.escape(EXECUTION_RESULT_MARKER)} \(T\+\d{{2,}}:\d{{2}}\) ---\nStatus: dispatched$"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_context():
|
|
ctx = Mock(spec=ArtemisContext)
|
|
ctx.llm_config = Mock()
|
|
mock_llm_cfg = Mock()
|
|
mock_llm_cfg.model = "gemini-2.5-flash"
|
|
mock_llm_cfg.temperature = 0.1
|
|
ctx.llm_config.get_agent.return_value = mock_llm_cfg
|
|
ctx.device = Mock()
|
|
ctx.device.device_width = 1080
|
|
ctx.device.device_height = 2400
|
|
ctx.data_engine = None
|
|
ctx.adb_client = None
|
|
ctx.driver = Mock()
|
|
return ctx
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# video_analyzer availability (same gate as the Pro graph)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_video_analyzer_bound_only_when_recording_tools_enabled(mock_context):
|
|
with patch("artemis.controllers.unified_controller.get_driver"):
|
|
mock_context.execution_setup = Mock()
|
|
mock_context.execution_setup.video_recording_tools_enabled = True
|
|
names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()]
|
|
assert "video_analyzer" in names
|
|
assert names[-1] == "report_task_status"
|
|
|
|
mock_context.execution_setup.video_recording_tools_enabled = False
|
|
names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()]
|
|
assert "video_analyzer" not in names
|
|
|
|
# A Mock attribute (truthy but not True) must not enable the tool.
|
|
mock_context.execution_setup = Mock()
|
|
names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()]
|
|
assert "video_analyzer" not in names
|
|
|
|
|
|
HISTORY_TOOLS = {"search_history", "replay_steps", "get_step_screenshot"}
|
|
|
|
|
|
def test_history_tools_bound_only_with_a_data_engine_session(mock_context):
|
|
with patch("artemis.controllers.unified_controller.get_driver"):
|
|
# No DataEngine: nothing to read, the tools stay out (as in Pro).
|
|
names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()]
|
|
assert not (HISTORY_TOOLS & set(names))
|
|
prompt = FlashRunner(mock_context, goal="g")._render_system_prompt(
|
|
FlashRunner(mock_context, goal="g")._get_tools()
|
|
)
|
|
assert "search_history" not in prompt and "replay_steps" not in prompt
|
|
|
|
mock_context.data_engine = Mock()
|
|
with patch("artemis.tools.history._recall_config", return_value=None):
|
|
runner = FlashRunner(mock_context, goal="g")
|
|
tools = runner._get_tools()
|
|
names = [t.name for t in tools]
|
|
assert HISTORY_TOOLS <= set(names)
|
|
assert names[-1] == "report_task_status"
|
|
|
|
# Same declarations as the LangChain tools: derived from one args schema.
|
|
search = next(t for t in tools if t.name == "search_history")
|
|
assert set(search.parameters["properties"]) == {"query", "step_range", "max_results"}
|
|
assert search.parameters["required"] == []
|
|
replay = next(t for t in tools if t.name == "replay_steps")
|
|
assert set(replay.parameters["properties"]) == {"start_step", "end_step"}
|
|
assert replay.parameters["required"] == ["start_step"]
|
|
shot = next(t for t in tools if t.name == "get_step_screenshot")
|
|
assert shot.parameters["properties"]["which"]["enum"] == ["pre", "post", "overlay"]
|
|
|
|
prompt = runner._render_system_prompt(tools)
|
|
assert "`search_history`" in prompt
|
|
assert "`replay_steps`" in prompt
|
|
assert "`get_step_screenshot`" in prompt
|
|
|
|
|
|
def test_search_history_alone_follows_the_recall_config_gate(mock_context):
|
|
from types import SimpleNamespace
|
|
|
|
with patch("artemis.controllers.unified_controller.get_driver"):
|
|
mock_context.data_engine = Mock()
|
|
with patch(
|
|
"artemis.tools.history._recall_config",
|
|
return_value=SimpleNamespace(enabled=False),
|
|
):
|
|
names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()]
|
|
assert "search_history" not in names
|
|
assert {"replay_steps", "get_step_screenshot"} <= set(names)
|
|
|
|
|
|
def test_video_analyzer_prompt_segment_follows_availability(mock_context):
|
|
with patch("artemis.controllers.unified_controller.get_driver"):
|
|
runner = FlashRunner(mock_context, goal="g")
|
|
mock_context.execution_setup = Mock()
|
|
mock_context.execution_setup.video_recording_tools_enabled = True
|
|
with_video = runner._render_system_prompt(runner._get_tools())
|
|
assert "`video_analyzer`" in with_video
|
|
|
|
mock_context.execution_setup.video_recording_tools_enabled = False
|
|
without_video = runner._render_system_prompt(runner._get_tools())
|
|
assert "video_analyzer" not in without_video
|
|
# Session-clock / history teaching is unconditional.
|
|
assert "T+mm:ss" in without_video
|
|
assert "Action Execution Result" in without_video
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Turn limit: unbounded by default, explicit caps honoured
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_turn_limit_semantics(mock_context):
|
|
with patch("artemis.controllers.unified_controller.get_driver"):
|
|
assert FlashRunner(mock_context, goal="g", max_turns=0).turn_limit is None
|
|
assert FlashRunner(mock_context, goal="g", max_turns=7).turn_limit == 7
|
|
with patch("artemis.agents.flash.runner.load_agent_config", side_effect=RuntimeError):
|
|
assert FlashRunner(mock_context, goal="g").turn_limit is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Observation tail: Pro shape with the session-relative header
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_observation_tail_has_pro_shape(mock_context):
|
|
with patch("artemis.controllers.unified_controller.get_driver"):
|
|
runner = FlashRunner(mock_context, goal="Open Settings")
|
|
ledger = TranscriptLedger()
|
|
|
|
tail = runner._build_tail(ledger, 1, b"IMG", "[1] Settings")
|
|
texts = [b["text"] for b in tail.content if b["type"] == "text"]
|
|
assert texts[0] == "Your objective is: Open Settings"
|
|
assert OBSERVATION_HEADER_RE.match(texts[1])
|
|
assert texts[2] == "--- Current Screenshot ---"
|
|
assert texts[3] == f"{PRO_UI_LIST_MARKER}\n[1] Settings"
|
|
assert sum(1 for b in tail.content if b["type"] == "image_url") == 1
|
|
|
|
later = runner._build_tail(
|
|
ledger, 5, None, None, injected="[INJECTED] stop", notices=["nudge"], is_final=True
|
|
)
|
|
texts = [b["text"] for b in later.content]
|
|
assert OBSERVATION_HEADER_RE.match(texts[0])
|
|
assert "Your objective is" not in "".join(texts)
|
|
assert texts[1:] == [
|
|
"nudge",
|
|
"[INJECTED] stop",
|
|
"[WARNING] This is your final turn; only 'report_task_status' is available.",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Injected instruction: verbatim text for the record and the lens, wrapped
|
|
# notice for the observation tail only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_injected_instruction_splits_verbatim_text_from_operator_notice(
|
|
mock_context, tmp_path
|
|
):
|
|
"""The user's words are what the step record, the chunk ledger and the
|
|
visual lens receive (Pro parity); the 'You MUST immediately ...' directive
|
|
is operator-facing and rides on the observation tail alone."""
|
|
import json
|
|
|
|
(tmp_path / "injected_instruction.json").write_text(
|
|
json.dumps({"instruction": "Skip the popup and log in", "release_loop": True}),
|
|
encoding="utf-8",
|
|
)
|
|
mock_context.data_engine = Mock()
|
|
mock_context.data_engine.base_dir = str(tmp_path)
|
|
|
|
with patch("artemis.controllers.unified_controller.get_driver"):
|
|
runner = FlashRunner(mock_context, goal="Log in")
|
|
instruction, notice = await runner._read_injected_instruction()
|
|
|
|
assert instruction == "Skip the popup and log in"
|
|
assert notice.startswith(
|
|
"[REAL-TIME INJECTED INSTRUCTION from user]: Skip the popup and log in"
|
|
)
|
|
assert "You MUST immediately follow this instruction" in notice
|
|
assert "authorized stopping any ongoing monitoring loop" in notice
|
|
assert not (tmp_path / "injected_instruction.json").exists()
|
|
|
|
with patch("artemis.controllers.unified_controller.get_driver"):
|
|
assert await runner._read_injected_instruction() == (None, None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end loop over the ledger (mocked model / executor)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _exec_result(tc_id, name, post_bytes, ui="[1] next"):
|
|
return ToolExecutionResult(
|
|
tool_call_id=tc_id,
|
|
tool_name=name,
|
|
status="success",
|
|
text_summary=f"{name} executed",
|
|
screenshot_bytes=post_bytes,
|
|
ui_elements_text=ui,
|
|
)
|
|
|
|
|
|
def _make_runner(mock_context, responses, max_turns=None):
|
|
runner = FlashRunner(mock_context, goal="Open Settings", max_turns=max_turns)
|
|
runner.summarizer = None
|
|
runner._init_llm = Mock(return_value=Mock())
|
|
# Snapshot each invocation: the runner appends to the same message list
|
|
# after the call, so the recorded call args would otherwise drift.
|
|
runner.calls = []
|
|
pending = list(responses)
|
|
|
|
async def _invoke(llm, tools, messages):
|
|
runner.calls.append({"tools": list(tools), "messages": list(messages)})
|
|
return pending.pop(0)
|
|
|
|
runner._invoke_model = AsyncMock(side_effect=_invoke)
|
|
runner.executor.execute = AsyncMock(
|
|
side_effect=lambda name, args, tc_id, state: _exec_result(
|
|
tc_id, name, f"IMG-{tc_id}".encode()
|
|
)
|
|
)
|
|
return runner
|
|
|
|
|
|
def _report(status="completed", tc_id="tc-report", **extra):
|
|
return AIMessage(
|
|
content="Done.",
|
|
tool_calls=[
|
|
{"name": "report_task_status", "args": {"status": status, **extra}, "id": tc_id}
|
|
],
|
|
)
|
|
|
|
|
|
_INITIAL_OBSERVATION = ("shot0.png", b"IMG0", "[1] Settings")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_builds_prompt_from_ledger_with_session_offsets(mock_context):
|
|
"""Turn 2 must see: the static system prefix, the committed turn 1 (its
|
|
tail, the AI message, text-only tool messages and an execution-result
|
|
message with a ``T+`` offset) and a fresh observation tail carrying the
|
|
last action's post screenshot — the Pro transcript shape, with no cap."""
|
|
responses = [
|
|
AIMessage(
|
|
content="I will tap then wait.",
|
|
tool_calls=[
|
|
{
|
|
"name": "click",
|
|
"args": {"target": [500, 600], "target_description": "Settings"},
|
|
"id": "tc1",
|
|
},
|
|
{"name": "wait_for_delay", "args": {"seconds": 1}, "id": "tc2"},
|
|
],
|
|
),
|
|
_report(explanation="ok"),
|
|
]
|
|
with (
|
|
patch("artemis.controllers.unified_controller.get_driver"),
|
|
patch(
|
|
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
|
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
|
),
|
|
):
|
|
runner = _make_runner(mock_context, responses, max_turns=0)
|
|
assert runner.turn_limit is None
|
|
result = await runner.run(State(initial_goal="Open Settings"))
|
|
|
|
assert result == {"status": "completed", "explanation": "ok"}
|
|
assert runner._invoke_model.await_count == 2
|
|
|
|
turn2_messages = runner.calls[1]["messages"]
|
|
assert isinstance(turn2_messages[0], SystemMessage)
|
|
assert "T+mm:ss" in turn2_messages[0].content
|
|
|
|
tail1 = turn2_messages[1]
|
|
assert isinstance(tail1, HumanMessage)
|
|
tail1_texts = [b["text"] for b in tail1.content if b["type"] == "text"]
|
|
assert tail1_texts[0] == "Your objective is: Open Settings"
|
|
assert OBSERVATION_HEADER_RE.match(tail1_texts[1])
|
|
|
|
assert isinstance(turn2_messages[2], AIMessage)
|
|
tool_msgs = [m for m in turn2_messages if isinstance(m, ToolMessage)]
|
|
assert [m.content for m in tool_msgs] == ["click executed", "wait_for_delay executed"]
|
|
|
|
result_msg = turn2_messages[5]
|
|
assert isinstance(result_msg, HumanMessage)
|
|
assert RESULT_RE.match(result_msg.content[0]["text"])
|
|
|
|
tail2 = turn2_messages[6]
|
|
tail2_texts = [b["text"] for b in tail2.content if b["type"] == "text"]
|
|
assert OBSERVATION_HEADER_RE.match(tail2_texts[0])
|
|
assert "Your objective is" not in "".join(tail2_texts)
|
|
images = [b for b in tail2.content if b["type"] == "image_url"]
|
|
assert len(images) == 1
|
|
assert images[0]["image_url"]["url"].endswith(base64.b64encode(b"IMG-tc2").decode())
|
|
assert tail2_texts[1] == "--- Current Screenshot ---"
|
|
assert tail2_texts[2] == f"{PRO_UI_LIST_MARKER}\n[1] next"
|
|
assert len(turn2_messages) == 7
|
|
|
|
# Both actions of the multi-action turn were registered on the committed turn.
|
|
ledger = mock_context.transcript_ledger
|
|
assert ledger.unchunked_turns()[0]["step_keys"] == ["tc1", "tc2"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_final_turn_restricts_tools_and_fails_without_report(mock_context):
|
|
responses = [AIMessage(content="I give up.", tool_calls=[])]
|
|
with (
|
|
patch("artemis.controllers.unified_controller.get_driver"),
|
|
patch(
|
|
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
|
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
|
),
|
|
):
|
|
runner = _make_runner(mock_context, responses, max_turns=1)
|
|
result = await runner.run(State(initial_goal="Open Settings"))
|
|
|
|
assert result == {"status": "failed", "explanation": "I give up."}
|
|
tools = runner.calls[0]["tools"]
|
|
assert [t.name for t in tools] == ["report_task_status"]
|
|
tail = runner.calls[0]["messages"][-1]
|
|
assert any("final turn" in b.get("text", "") for b in tail.content)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_no_tool_call_turn_is_nudged_not_terminated(mock_context):
|
|
responses = [AIMessage(content="thinking only", tool_calls=[]), _report()]
|
|
with (
|
|
patch("artemis.controllers.unified_controller.get_driver"),
|
|
patch(
|
|
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
|
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
|
),
|
|
):
|
|
runner = _make_runner(mock_context, responses, max_turns=0)
|
|
result = await runner.run(State(initial_goal="Open Settings"))
|
|
|
|
assert result == {"status": "completed"}
|
|
turn2_messages = runner.calls[1]["messages"]
|
|
# Unbounded loop: no final-turn restriction was ever applied.
|
|
tools = runner.calls[1]["tools"]
|
|
assert "click" in [t.name for t in tools]
|
|
tail2_texts = [b["text"] for b in turn2_messages[-1].content if b["type"] == "text"]
|
|
assert any("did not call any tools" in t for t in tail2_texts)
|
|
# A helper-only / empty turn commits without an execution-result message.
|
|
assert not any(
|
|
isinstance(m, HumanMessage)
|
|
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
|
|
for m in turn2_messages
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _TurnRecord.result: the turn's execution result in the validator-report shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_turn_record_result_is_none_for_helper_only_turns():
|
|
assert _TurnRecord().result() is None
|
|
|
|
|
|
def test_turn_record_result_is_dispatched_when_every_action_was_accepted():
|
|
record = _TurnRecord(
|
|
actions=[("click", "success", "Tapped at [500, 500]"), ("swipe", "success", "Swiped up.")]
|
|
)
|
|
assert record.result() == {"status": "dispatched"}
|
|
|
|
|
|
def test_turn_record_failed_result_keeps_an_executor_message_that_names_the_action():
|
|
"""Executor messages already name the action; they are not prefixed again."""
|
|
record = _TurnRecord(
|
|
actions=[
|
|
("click", "success", "Tapped at [500, 500]"),
|
|
("swipe", "error", "Error executing swipe: direction 'sideways' is not valid"),
|
|
]
|
|
)
|
|
assert record.result() == {
|
|
"status": "failed",
|
|
"error": "Error executing swipe: direction 'sideways' is not valid",
|
|
}
|
|
|
|
|
|
def test_turn_record_failed_result_prefixes_an_error_that_does_not_name_the_action():
|
|
record = _TurnRecord(actions=[("type_text", "error", "Device is offline")])
|
|
assert record.result() == {"status": "failed", "error": "type_text: Device is offline"}
|
|
|
|
|
|
def test_turn_record_reports_the_first_failure_of_a_multi_action_turn():
|
|
record = _TurnRecord(
|
|
actions=[
|
|
("click", "error", "click rejected: coordinates out of bounds"),
|
|
("swipe", "error", "never reached"),
|
|
]
|
|
)
|
|
assert record.result()["error"] == "click rejected: coordinates out of bounds"
|