Files
Artemis-Eapil/artemis/tools/explorer_tool.py
T
somew1nd 206a9ce921 feat: initial release of ARTEMIS Android Agent & Automation Platform
ARTEMIS is an autonomous multimodal Android agent and mobile UI automation
platform engineered for physical devices and emulators. It combines state-of-the-art
multimodal Vision-Language Models (VLMs) with hierarchical closed-loop planning,
resilient self-healing, and deep Android subsystem integration.

Key Features & Architectural Highlights:
- Dual-Engine Orchestration:
  * Pro Engine: Multi-agent closed-loop (Planner -> Operator -> Validator/Failure Analyzer -> Summarizer) powered by LangGraph.
  * Flash Engine: Ultra-fast reactive loop for deterministic, low-latency UI execution (<35 steps).
- Multimodal Perception & Grounding:
  * Hybrid visual screenshot grounding, real-time OCR extraction, and XML accessibility hierarchy parsing.
  * Dynamic-First, Coordinate-Fallback locating strategy for maximum resilience against UI drift.
- Autonomous Self-Healing & Diagnostics:
  * Failure analyzer and safety net for popup dismissal, crash recovery, and auto-retry.
  * Diagnostic sub-agents for log analysis, video recording inspection, and object detection.
- Unified CLI & Python SDK:
  * Rich CLI with commands: init, doctor, run, trace, batch, bench, server, and mcp.
  * Fluent Python SDK with Pydantic structured output models and package locking.
- Multi-Provider LLM Architecture:
  * Support for Google Gemini, OpenAI, Anthropic Claude, Vertex AI, OpenRouter, xAI Grok, and local endpoints (Ollama/vLLM).
  * Centralized configuration in config/artemis.jsonc.
- Native MCP & Observability:
  * Model Context Protocol (MCP) server endpoints for AI IDEs.
  * Full trace recording with before/after screenshots, action overlays, and execution replays.

Change-Id: I430b93ee2b76b54cc5a3c6eeccd15d2cf29830a1
2026-08-13 11:46:47 -07:00

405 lines
15 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.
import base64
import glob
import json
import os
from pathlib import Path
import re
from typing import Any, Literal
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field, create_model
from artemis.agents.explorer.constants import EXPLORE_DESCRIPTIONS
from artemis.agents.explorer.explorer import Explorer
from artemis.agents.object_detector.object_detector import _run_object_detection
from artemis.config import ExplorerVersion, resolve_explorer_version, settings
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace, trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
from artemis.utils.visualization import draw_dots
class AskExplorerArgs(BaseModel):
"""Arguments schema for invoking the explorer subagent."""
model_config = {"ignored_types": (CyFunctionDetector,)}
query: str = Field(
...,
description=("The target element or information to search for, including descriptions."),
)
context_feedback: str = Field(
"",
description=("Feedback from previous failed attempts or specific instructions"),
)
version: Literal["flash", "pro", "ultra"] | None = Field(
default=None,
description="Explicit explorer version to use (flash, pro, or ultra).",
)
logger = get_logger(__name__)
class AskExplorerTool(ArtemisTool):
"""Universal tool for visually parsing and locating UI elements."""
def __init__(
self,
version: ExplorerVersion | None = None,
agent_name: str = "operator",
description: str | None = None,
category: ToolCategory = "explorer",
):
self.version = version
self.agent_name = agent_name
resolved_v = version or "pro"
explore_info = EXPLORE_DESCRIPTIONS.get(resolved_v, EXPLORE_DESCRIPTIONS["pro"])
desc = description or explore_info["description"]
if "{max_iterations}" in desc:
max_iterations = 8 if resolved_v == "ultra" else 3
desc = desc.format(max_iterations=max_iterations)
super().__init__(
name="ask_explorer",
description=desc,
args_schema=AskExplorerArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
query: str = "",
context_feedback: str = "",
version: Literal["flash", "pro", "ultra"] | None = None,
tool_call_id: str | None = None, # pylint: disable=unused-argument
state: State | None = None,
**kwargs: Any,
) -> Any:
q = query or kwargs.get("Query") or ""
cf = context_feedback or kwargs.get("ContextFeedback") or ""
v = version or self.version
resolved_v = (
resolve_explorer_version(ctx, explicit_version=v, agent_or_profile_name=self.agent_name)
if ctx
else (v or "pro")
)
return await _run_explorer_logic(
ctx=ctx,
state=state,
query=q,
context_feedback=cf,
version=resolved_v,
)
# Universal tool instance & aliases
ask_explorer = AskExplorerTool()
AskExplorer = AskExplorerTool
AskExplorerToolAlias = AskExplorerTool
ToolRegistry.register(ask_explorer)
def get_ask_explorer_tool(
ctx: ArtemisContext,
version: ExplorerVersion | None = None,
agent_name: str = "operator",
) -> BaseTool:
"""Exports ask_explorer as a LangChain BaseTool with dynamic schema."""
resolved_version = resolve_explorer_version(
ctx, explicit_version=version, agent_or_profile_name=agent_name
)
explore_info = EXPLORE_DESCRIPTIONS.get(resolved_version, EXPLORE_DESCRIPTIONS["pro"])
description = explore_info["description"]
if "{max_iterations}" in description:
max_iterations = 8 if resolved_version == "ultra" else 3
description = description.format(max_iterations=max_iterations)
query_description = explore_info["query_description"]
# Dynamically build argument schema so Operator does not see
# context_feedback when flash is active
fields = {
"query": (str, Field(..., description=query_description)),
}
if resolved_version != "flash":
fields["context_feedback"] = (
str,
Field(
"",
description=("Feedback from previous failed attempts or specific instructions"),
),
)
# pylint: disable=invalid-name
DynamicAskExplorerArgs = create_model("AskExplorerArgs", **fields)
tool_instance = AskExplorerTool(
version=resolved_version,
agent_name=agent_name,
description=description,
)
tool_instance.args_schema = DynamicAskExplorerArgs
return trace_langchain_tool(tool_instance.to_langchain_tool(ctx), ctx)
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
async def _run_explorer_logic(
ctx: ArtemisContext,
state: State,
query: str,
context_feedback: str,
version: Literal["flash", "pro", "ultra"],
) -> Any:
if version == "flash":
logger.info(
"Explorer [version='flash'] intercepted -> Direct one-shot object"
" detection (context_feedback ignored)."
)
screenshot_path = state.latest_screenshot
if not screenshot_path:
return "Explorer (Flash) error: No latest_screenshot available in state."
queries = [q.strip() for q in query.split("|") if q.strip()]
if not queries:
queries = [query.strip()]
try:
prompt_path = Path(__file__).parent.parent.joinpath(
"agents", "object_detector", "object_detector.json"
)
with open(prompt_path, encoding="utf-8") as f:
cfg = json.load(f)
templates = [f"{t}\n\n{cfg.get('instructions', '')}" for t in cfg.get("templates", [])]
result_dict = await _run_object_detection(
ctx, screenshot_path, queries, templates, global_timeout=15.0
)
detected_items = result_dict.get("detected", [])
candidates = []
for item in detected_items:
if isinstance(item, dict) and "point" in item and len(item["point"]) == 2:
nx, ny = item["point"]
lbl = item.get("label", query)
candidates.append(
{
"label": lbl,
"coords": [int(nx), int(ny)],
"description": f"{lbl} (flash detection)",
}
)
if not candidates:
failed_queries = result_dict.get("failed", queries)
explorer_output = json.dumps(
{
"candidates": [],
"fallback_message": (
"Flash one-shot detection could not locate:"
f" {', '.join(failed_queries)}. Consider checking"
" visual visibility or layout."
),
}
)
else:
explorer_output = json.dumps({"candidates": candidates})
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Flash one-shot routing failed: {e}")
explorer_output = json.dumps(
{
"candidates": [],
"fallback_message": f"Flash detection error: {e}",
}
)
else:
@trace(type="agent", name="explorer", ctx=ctx)
async def run_explorer_agent(q: str, cf: str, screenshot_path: str, state: State) -> str:
agent = Explorer(ctx)
env_cache = os.getenv("ARTEMIS_EXPLORER_CACHING", "").lower()
if env_cache in ["true", "false"]:
enable_caching = env_cache == "true"
else:
agent_cfg = getattr(ctx, "agent_config", None)
if (
agent_cfg
and hasattr(agent_cfg, "explorer")
and hasattr(agent_cfg.explorer, "caching")
):
enable_caching = agent_cfg.explorer.caching
elif (
ctx and ctx.execution_setup and hasattr(ctx.execution_setup, "explorer_caching")
):
enable_caching = ctx.execution_setup.explorer_caching
else:
enable_caching = getattr(settings, "EXPLORER_CACHING", True)
return await agent.run(
q,
cf,
screenshot_path,
state,
enable_caching=enable_caching,
version=version,
)
screenshot_path = state.latest_screenshot
explorer_output = await run_explorer_agent(query, context_feedback, screenshot_path, state)
try:
data = json.loads(explorer_output)
candidates = data.get("candidates", [])
fallback_message = data.get("fallback_message", "")
if candidates:
# 1. Read Operator's active coordinates list
indexed_points = getattr(state, "indexed_points", None)
if indexed_points is None:
indexed_points = []
state.indexed_points = indexed_points
indexed_elements = getattr(state, "indexed_elements", None)
if indexed_elements is None:
indexed_elements = []
state.indexed_elements = indexed_elements
width = 1080
height = 2400
operator_raw_data = getattr(state, "operator_raw_data", {}) or {}
w_raw = operator_raw_data.get("width")
h_raw = operator_raw_data.get("height")
if isinstance(w_raw, int) and isinstance(h_raw, int):
width = w_raw
height = h_raw
else:
if ctx.device and getattr(ctx.device, "device_width", None):
width = ctx.device.device_width
if ctx.device and getattr(ctx.device, "device_height", None):
height = ctx.device.device_height
registered_info = []
for cand in candidates:
coords = cand.get("coords")
desc = cand.get("description", "Explorer candidate")
if coords and len(coords) == 2:
nx, ny = coords
pixel_x = int(max(0, min(width, nx * width / 1000)))
pixel_y = int(max(0, min(height, ny * height / 1000)))
indexed_points.append([pixel_x, pixel_y])
new_idx = len(indexed_points)
# Update indexed_elements in sync
indexed_elements.append(
{
"index": new_idx,
"center": [pixel_x, pixel_y],
"text": desc,
"bounds": None,
"class": "ExplorerCandidate",
"resource_id": None,
"is_ocr": False,
}
)
registered_info.append(f"- [{new_idx}] '{desc}' at coordinate {coords}")
response_msg = (
"Explorer successfully located the following candidate(s):\n"
+ "\n".join(registered_info)
)
response_msg += (
"\nYou can click/act on them directly by calling perform_action"
" with their respective index."
)
if fallback_message:
response_msg += f"\n\nAdditional Notes from Explorer: {fallback_message}"
# 2. Generate annotated image with ALL points
base_dir = (
Path(ctx.data_engine.base_dir)
if ctx.data_engine and getattr(ctx.data_engine, "base_dir", None)
else settings.TRACES_PATH
)
images_dir = base_dir / "images"
explorer_dir = images_dir / "explorer_tool"
explorer_dir.mkdir(parents=True, exist_ok=True)
image_name = "explorer_output"
existing_files = glob.glob(str(explorer_dir / f"{image_name}_*.jpg"))
max_seq = 0
for f in existing_files:
match = re.search(r"_(\d+)\.jpg$", f)
if match:
max_seq = max(max_seq, int(match.group(1)))
seq = max_seq + 1
output_path = explorer_dir / f"{image_name}_{seq}.jpg"
all_labels = [str(i) for i in range(1, len(indexed_points) + 1)]
try:
draw_dots(
screenshot_path,
indexed_points,
all_labels,
str(output_path),
)
with open(output_path, "rb") as im_f:
img_bytes = im_f.read()
img_b64 = base64.b64encode(img_bytes).decode("utf-8")
return [
{
"type": "text",
"text": f"Explorer replied:\n{response_msg}",
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"},
},
]
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to draw dots or read image in explorer_tool: {e}")
# Fallback to pure text if drawing fails
return f"Explorer replied:\n{response_msg}"
elif fallback_message:
return f"Explorer could not locate the element. Message: {fallback_message}"
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to parse explorer output: {e}")
return f"Explorer failed to provide a valid format. Raw output: {explorer_output}"
return f"Explorer replied:\n{explorer_output}"
ask_explorer_wrapper = ToolWrapper(
tool_fn_getter=get_ask_explorer_tool,
on_success_fn=lambda output: f"Explorer replied:\n{output}",
on_failure_fn=lambda error: f"Explorer failed: {error}",
)