# 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. """Explorer agent facade. This module is the stable entry point (and ``mock.patch`` target namespace) for the Explorer agent. The implementation is split across sibling modules: - ``tiers``: the flash / pro / ultra tier table (single source of truth). - ``tool_declarations``: static tool schemas (universal + native). - ``perception_tools``: the ``exec_*`` perception/vision tool method group. - ``universal_runner``: the LangChain-based ``_run_universal`` loop. - ``run_setup``: flash mode and the pre-loop setup phases of ``run``. - ``native_runner``: the native Gemini reasoning loop of ``run``. All historical module-level names (``settings``, ``StorageManager``, ``genai``, ``_run_object_detection``, ``draw_dots``, ``perform_ocr``, ``is_ocr_configured``, ``get_llm``, ``UNIVERSAL_EXPLORER_TOOLS``, ...) remain importable and patchable here; split-out code resolves them through this module at call time (see ``artemis.agents.explorer._facade``). """ import asyncio import json import os from typing import Any, Literal from google import genai from google.genai import types from artemis.agents.explorer.geometry import ( FALLBACK_SCREEN_SIZE, is_valid_norm_point, norm_to_pixel, pixel_to_norm, ) from artemis.agents.explorer.native_runner import NativeRunnerMixin from artemis.agents.explorer.perception_tools import PerceptionToolsMixin from artemis.agents.explorer.run_setup import RunSetupMixin from artemis.agents.explorer.screen_index import normalize_text, ScreenElement, ScreenIndex from artemis.agents.explorer.tiers import ExplorerTier, get_tier from artemis.agents.explorer.tool_declarations import ( NATIVE_EXPLORER_TOOL_DECLARATIONS, UNIVERSAL_EXPLORER_TOOLS, ) from artemis.agents.explorer.universal_runner import UniversalRunnerMixin from artemis.agents.object_detector.object_detector import _run_object_detection from artemis.config import settings from artemis.context import ArtemisContext from artemis.data_engine.storage import StorageManager from artemis.data_engine.trace import trace from artemis.graph.state import State from artemis.llm.reliability import ( LLMExhaustedError, LLMPermanentError, classify_failure, retry_policy_for, ) from artemis.services.llm import _record_llm_event, _record_llm_retry, get_llm from artemis.utils.logger import get_logger from artemis.utils.ocr_api import is_ocr_configured, perform_ocr from artemis.utils.visualization import draw_dots logger = get_logger(__name__) #: Public surface plus the late-bound collaborators the split-out modules #: resolve through this facade (``mock.patch`` targets for the test suite). __all__ = [ "DEFAULT_EXPLORER_MODEL", "Explorer", "NATIVE_EXPLORER_TOOL_DECLARATIONS", "StorageManager", "UNIVERSAL_EXPLORER_TOOLS", "_generate_content_with_reliability", "_run_object_detection", "draw_dots", "genai", "get_llm", "is_ocr_configured", "logger", "perform_ocr", "settings", ] #: Model used when the LLM configuration carries no Explorer entry. DEFAULT_EXPLORER_MODEL = "gemini-3.8-flash" #: Environment override for Gemini explicit context caching ("true" / "false"). CACHING_ENV_VAR = "ARTEMIS_EXPLORER_CACHING" #: How far outside its labeled element (as a fraction of the screen, per side) #: a candidate's point may fall and still inherit that element's bounds. LABEL_TOLERANCE_RATIO = 0.02 async def _generate_content_with_reliability(operation, *, label: str = "Explorer model call"): """Run one native google-genai model call under the shared reliability layer. Retry decisions are owned by ``classify_failure``/``retry_policy_for`` (artemis.llm.reliability) instead of a blanket exponential-backoff loop: non-retryable categories (auth, bad request) are raised immediately as ``LLMPermanentError`` and exhausted retryable categories surface as ``LLMExhaustedError``. Only the retry/error-classification policy is centralized here; the google-genai transport itself stays untouched. ``operation`` must be a zero-argument callable returning a fresh awaitable per attempt. """ attempt = 0 while True: try: return await operation() except Exception as call_err: attempt += 1 failure = classify_failure(call_err) if not failure.retryable: logger.error(f"{label} permanently failed [{failure.category.value}]: {call_err}") _record_llm_event( "llm_gave_up", { "source": "explorer", "error": str(call_err)[:1000], "category": failure.category.value, "retryable": False, }, status="failed", ) raise LLMPermanentError( f"{label} failed [{failure.category.value}]: {call_err}", failure=failure, cause=call_err, ) from call_err policy = retry_policy_for(failure.category) if attempt >= policy.max_attempts: logger.error( f"{label} exhausted {attempt} attempt(s) [{failure.category.value}]: {call_err}" ) _record_llm_event( "llm_gave_up", { "source": "explorer", "error": str(call_err)[:1000], "category": failure.category.value, "retryable": True, "attempts": attempt, }, status="failed", ) raise LLMExhaustedError( f"{label} exhausted {attempt} attempt(s)" f" [{failure.category.value}]: {call_err}", failure=failure, cause=call_err, ) from call_err delay = policy.delay_for(attempt) logger.warning( f"{label} failed [{failure.category.value}] on attempt" f" {attempt}/{policy.max_attempts}: {call_err}." f" Retrying in {delay:.2f}s..." ) _record_llm_retry( str(call_err), delay, attempt=attempt, max_retries=policy.max_attempts, source="explorer", ) await asyncio.sleep(delay) class Explorer(PerceptionToolsMixin, UniversalRunnerMixin, RunSetupMixin, NativeRunnerMixin): """Locates UI elements on a screenshot for the ``ask_explorer`` tool. The tier (flash / pro / ultra) is applied per ``run`` call from :data:`artemis.agents.explorer.tiers.EXPLORER_TIERS`; it decides the engine, the turn budget, the exposed perception tools and the caching default. Construction is side-effect free: no network client is created until the native Gemini branch actually needs one. """ DENYLIST_TEMPLATE = ( "\n# TOOL DENYLIST\n- The following tools are denylisted and cannot be used: {tools}\n" ) TOOLS = NATIVE_EXPLORER_TOOL_DECLARATIONS def __init__(self, ctx: ArtemisContext): self.ctx = ctx self.global_label_idx = 1 self.width, self.height = FALLBACK_SCREEN_SIZE self.image_name: str | None = None self.screenshot_path: str | None = None self.image_pool: dict[str, dict[str, Any]] = {} self.next_img_id = 1 self.http_client = None self.client = None self.turn_latencies: list[float] = [] self.turn_cached_tokens: list[int] = [] self.trace_history: list[dict[str, Any]] = [] # Rebuilt per ``run``: the queryable snapshot of the screen, and the # element behind every label the model has been shown so far. self.screen_index: ScreenIndex = ScreenIndex.empty(self.width, self.height) self.label_registry: dict[str, ScreenElement] = {} # No tier is applied until ``run``; until then only the user's own # denylist (plus unconfigured OCR) restricts the tool set. self.tier: ExplorerTier | None = None self.max_turns = 0 self._user_denylist = self._load_user_denylist() self.denylisted_tools = self._compute_denylist(None) self.model_name = self._resolve_model_name() self.use_native_gemini = self._detect_native_engine() # ------------------------------------------------------------------ # # Engine selection (no side effects) # ------------------------------------------------------------------ # def _resolve_model_name(self) -> str: """Returns the configured Explorer model name without a provider prefix.""" llm_config = getattr(self.ctx, "llm_config", None) llm_cfg = getattr(llm_config, "explorer", None) if llm_config else None model = getattr(llm_cfg, "model", None) if llm_cfg else None model_name = model if isinstance(model, str) and model else DEFAULT_EXPLORER_MODEL if "/" in model_name: model_name = model_name.split("/")[-1] return model_name def _detect_native_engine(self) -> bool: """True when the native google-genai SDK should drive the reasoning loop. A Gemini model alone is not enough: without a Google API key (or a client already shared on the context) the SDK cannot be constructed, so such configurations run through the universal LangChain engine. """ if "gemini" not in self.model_name.lower(): return False if getattr(self.ctx, "_genai_client", None) is not None: return True api_key = getattr(settings, "GOOGLE_API_KEY", None) return bool(api_key and api_key.get_secret_value()) def _ensure_genai_client(self): """Returns the context-level GenAI client, creating it on first use. Only the native branch calls this, so universal configurations never touch ``genai`` (and never fail on a missing Google key). The client is stored on the context for connection pooling across agents. """ client = getattr(self.ctx, "_genai_client", None) if client is None: logger.info("Initializing GenAI client on context for connection pooling (Explorer)") api_key = getattr(settings, "GOOGLE_API_KEY", None) client = genai.Client(api_key=api_key.get_secret_value() if api_key else None) self.ctx._genai_client = client self.client = client return client # ------------------------------------------------------------------ # # Tier application # ------------------------------------------------------------------ # def _load_user_denylist(self) -> frozenset[str]: """Reads the user-configured Explorer denylist from the agent config.""" try: agent_cfg = getattr(self.ctx, "agent_config", None) denylists = getattr(agent_cfg, "denylisted_tools", None) if agent_cfg else None configured = denylists.get("explorer", []) if isinstance(denylists, dict) else [] return frozenset(str(name) for name in configured) except (TypeError, AttributeError): return frozenset() def _compute_denylist(self, tier: ExplorerTier | None) -> set[str]: """Tools the model must not see: tier-hidden, user-denylisted, unconfigured OCR.""" denylist = set(self._user_denylist) if tier is not None: denylist |= tier.hidden_tools if not is_ocr_configured(): denylist.add("get_ocr_list") return denylist def _apply_tier(self, tier: ExplorerTier) -> None: """Adopts the tier's turn budget and tool exposure for this run.""" self.tier = tier self.max_turns = tier.max_turns self.denylisted_tools = self._compute_denylist(tier) def _resolve_caching(self, tier: ExplorerTier, enable_caching: bool | None) -> bool: """Resolves Gemini explicit context caching for this run. Precedence: explicit argument, ``ARTEMIS_EXPLORER_CACHING`` env override, the agent configuration, the execution setup, the global settings, then the tier default. Configuration values are only trusted when they are real booleans so a mocked or malformed config (``None``, sentinel objects) falls through instead of forcing a value. """ if isinstance(enable_caching, bool): return enable_caching env_value = os.getenv(CACHING_ENV_VAR, "").strip().lower() if env_value in ("true", "false"): return env_value == "true" for source in ( getattr(self.ctx, "agent_config", None), getattr(self.ctx, "execution_setup", None), ): value = getattr(getattr(source, "explorer", None), "caching", None) if isinstance(value, bool): return value value = getattr(settings, "EXPLORER_CACHING", None) if isinstance(value, bool): return value return tier.caching def _hidden_tool_names(self) -> set[str]: """Denylisted tools plus OCR when it is unconfigured, evaluated at call time. OCR availability is re-checked here (not only in ``_apply_tier``) so callers that reset ``denylisted_tools`` still never see ``get_ocr_list`` without a configured OCR backend. """ hidden = set(self.denylisted_tools) if not is_ocr_configured(): hidden.add("get_ocr_list") return hidden def get_exposed_tools(self, only_submit: bool = False) -> list[types.FunctionDeclaration]: """Native tool declarations the model may see for the current tier.""" hidden = self._hidden_tool_names() tools = [tool for tool in self.TOOLS if tool.name not in hidden] if only_submit: tools = [tool for tool in tools if tool.name == "submit_answer"] return tools def _prune_historical_images(self, contents, keep_last=1): image_parts = [] for content in contents: for part in content.parts: if getattr(part, "inline_data", None) or getattr(part, "file_data", None): image_parts.append(part) if len(image_parts) <= keep_last: return to_keep_ids = {id(p) for p in image_parts[-keep_last:]} for content in contents: content.parts = [ types.Part.from_text( text=("[Image pruned to maintain visual focus on latest state]") ) if (getattr(p, "inline_data", None) or getattr(p, "file_data", None)) and id(p) not in to_keep_ids else p for p in content.parts ] # ------------------------------------------------------------------ # # Label bounds and the structural pre-pass # ------------------------------------------------------------------ # def _norm_bounds(self, element: ScreenElement) -> list[int] | None: """The element's bounds on the 0-1000 grid, or None when they collapse. A box that rounds to zero width or height is useless to the caller's hit test, so it is withheld rather than shipped as a degenerate rect. """ left, top, right, bottom = element.bounds n_left, n_top = pixel_to_norm(left, top, self.width, self.height) n_right, n_bottom = pixel_to_norm(right, bottom, self.width, self.height) if n_right <= n_left or n_bottom <= n_top: return None return [n_left, n_top, n_right, n_bottom] def _enrich_candidates(self, candidates: list[Any]) -> list[Any]: """Attaches the registered element's bounds to candidates that point at it. The label alone is not trusted: models occasionally reuse a label for a different spot, so the coordinates must also fall inside the element (padded by :data:`LABEL_TOLERANCE_RATIO` of the screen to absorb rounding). Unknown labels, detection-only labels and points outside the element pass through untouched. """ enriched: list[Any] = [] for cand in candidates: element = ( self.label_registry.get(str(cand.get("label"))) if isinstance(cand, dict) else None ) coords = cand.get("coords") if element is not None else None if element is None or not is_valid_norm_point(coords): enriched.append(cand) continue px, py = norm_to_pixel(int(coords[0]), int(coords[1]), self.width, self.height) left, top, right, bottom = element.bounds pad_x = self.width * LABEL_TOLERANCE_RATIO pad_y = self.height * LABEL_TOLERANCE_RATIO inside = left - pad_x <= px <= right + pad_x and top - pad_y <= py <= bottom + pad_y norm_bounds = self._norm_bounds(element) if inside else None if norm_bounds is None: enriched.append(cand) continue enriched.append({**cand, "bounds": norm_bounds, "source": element.source}) return enriched def _structural_prepass(self, query: str) -> tuple[list[dict[str, Any]], list[str]]: """Resolves the ``|``-separated parts of ``query`` that exactly name one element. Returns ``(candidates, unresolved_parts)``. Only a *unique* exact label match is taken: several matches mean the caller's wording does not disambiguate, which is exactly what the model is for. """ parts = [part.strip() for part in query.split("|") if part.strip()] candidates: list[dict[str, Any]] = [] unresolved: list[str] = [] for part in parts: hits = self.screen_index.exact_matches(part) if len(hits) != 1: unresolved.append(part) continue element = hits[0] label = f"T{len(candidates) + 1}" self._register_label(label, element) cx, cy = element.center candidate: dict[str, Any] = { "label": label, "coords": list(pixel_to_norm(cx, cy, self.width, self.height)), } norm_bounds = self._norm_bounds(element) if norm_bounds is not None: candidate["bounds"] = norm_bounds candidate["source"] = element.source candidate["description"] = ( f"{element.text} (exact text match)" if normalize_text(part) == normalize_text(element.text) else f"{part} (exact text match: '{element.text}')" ) candidates.append(candidate) return candidates, unresolved @staticmethod def _merge_prepass(prepass: list[dict[str, Any]], raw: str) -> str: """Prepends pre-pass candidates to the engine's outcome JSON. An engine answer that is not outcome-shaped (a bare error string) becomes the ``fallback_message`` so the structural hits survive it. """ if not prepass: return raw try: data = json.loads(raw) except (TypeError, ValueError): data = None if not isinstance(data, dict): return json.dumps( {"candidates": prepass, "fallback_message": str(raw)}, ensure_ascii=False ) engine_candidates = data.get("candidates") or [] return json.dumps( { "candidates": [*prepass, *engine_candidates], "fallback_message": str(data.get("fallback_message") or ""), }, ensure_ascii=False, ) # ------------------------------------------------------------------ # # Entry point # ------------------------------------------------------------------ # @trace(type="agent", name="explorer") async def run( self, query: str, context_feedback: str, screenshot_path: str, state: State, minimal_list: str = "", enable_caching: bool | None = None, version: Literal["flash", "pro", "ultra"] = "pro", ) -> str: """Locates ``query`` on ``screenshot_path`` under the ``version`` tier. Every tier first indexes the screen in memory and tries the structural pre-pass; only the parts of the query it cannot settle reach the tier's engine. Returns the JSON string ``{"candidates": [...], "fallback_message": "..."}`` consumed by ``artemis.tools.explorer_tool``. """ tier = get_tier(version) self._apply_tier(tier) self.screenshot_path = screenshot_path self.global_label_idx = 1 self.label_registry = {} try: fused_xml = await self._load_screen(screenshot_path, state, tier) prepass, unresolved = self._structural_prepass(query) if prepass and not unresolved: logger.info( f"Explorer pre-pass answered {query!r} from the UI tree without a model call." ) return json.dumps( {"candidates": prepass, "fallback_message": ""}, ensure_ascii=False ) engine_query = " | ".join(unresolved) if prepass else query if tier.is_oneshot: raw = await self._run_flash(engine_query, screenshot_path) else: raw = await self._run_loop( tier, engine_query, context_feedback, screenshot_path, state, minimal_list, enable_caching, fused_xml, ) return self._merge_prepass(prepass, raw) finally: await self._close_http_client() async def _run_loop( self, tier: ExplorerTier, query: str, context_feedback: str, screenshot_path: str, state: State, minimal_list: str, enable_caching: bool | None, fused_xml: list[dict[str, Any]], ) -> str: """Shared setup for the loop engines, then the engine-specific loop.""" self._init_image_pool(screenshot_path) prompt_template, prompt_error = self._build_prompt_template(tier) if prompt_error is not None: return self._failure_outcome(prompt_error) minimal_list, img_to_read = self._prepare_initial_annotation( fused_xml, screenshot_path, minimal_list, self.image_name ) if not self.use_native_gemini: return await self._run_universal( query=query, context_feedback=context_feedback, screenshot_path=img_to_read, state=state, minimal_list=minimal_list, prompt_template=prompt_template, max_turns=self.max_turns, ) client = self._ensure_genai_client() model_name, temperature, thinking_level, _fallback_model = self._resolve_model_params() return await self._run_native( client=client, query=query, context_feedback=context_feedback, minimal_list=minimal_list, img_to_read=img_to_read, enable_caching=self._resolve_caching(tier, enable_caching), prompt_template=prompt_template, model_name=model_name, temperature=temperature, thinking_level=thinking_level, max_turns=self.max_turns, ) async def _close_http_client(self) -> None: """Releases the on-demand OCR HTTP client; both engines share this path.""" client, self.http_client = self.http_client, None if client is None: return try: await client.aclose() logger.info("Closed Explorer HTTP client.") except Exception as close_err: logger.warning(f"Failed to close Explorer HTTP client: {close_err}")