Files
Artemis-Eapil/artemis/sdk/builders/agent_config_builder.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

529 lines
20 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.
"""Builder for AgentConfig objects using a fluent interface."""
import copy
import os
from typing import Any
from langchain_core.callbacks.base import Callbacks
from artemis.config import (
ExplorerVersion,
get_default_llm_config,
load_agent_config,
settings,
)
from artemis.context import DevicePlatform
from artemis.sdk.constants import DEFAULT_PROFILE_NAME
from artemis.sdk.types.agent import (
AgentConfig,
AgentProfile,
ServerConfig,
)
from artemis.sdk.types.task import TaskRequestCommon
from artemis.utils.video import detect_video_tools_enabled
class AgentConfigBuilder:
"""Builder class providing a fluent interface for creating AgentConfig objects.
This builder allows for step-by-step construction of an AgentConfig with
clear methods that make the configuration process intuitive and type-safe.
Examples:
>>> builder = AgentConfigBuilder()
>>> config = (builder
... .add_profile(AgentProfile(name="HighReasoning",
llm_config=LLMConfig(...)))
... .add_profile(AgentProfile(name="LowReasoning",
llm_config=LLMConfig(...)))
... .for_device(DevicePlatform.ANDROID, "device123")
... .with_default_task_config(TaskRequestCommon(max_steps=30))
... .with_default_profile("HighReasoning")
... .build()
... )
"""
def __init__(self):
"""Initialize an empty AgentConfigBuilder."""
self._agent_profiles: dict[str, AgentProfile] = {}
self._task_request_defaults: TaskRequestCommon | None = None
self._default_profile: str | AgentProfile | None = None
self._device_id: str | None = None
self._device_platform: DevicePlatform | None = None
self._servers: ServerConfig = get_default_servers()
self._graph_config_callbacks: Callbacks = None
self._video_recording_tools_enabled: bool = detect_video_tools_enabled()
self._force_web_accessibility: bool = False
self._disable_checker: bool = True
self._cloud_mobile_id_or_ref: str | None = None
agent_cfg = load_agent_config()
self._explorer = agent_cfg.explorer
self._explorer_versions = agent_cfg.explorer_versions
self._blacklisted_tools = agent_cfg.blacklisted_tools
self._enable_video_ledger = agent_cfg.video_analyzer.enable_ledger
if agent_cfg.video_analyzer.enabled is not None:
self._video_recording_tools_enabled = agent_cfg.video_analyzer.enabled
else:
self._video_recording_tools_enabled = detect_video_tools_enabled()
self._disable_planner_validation = not agent_cfg.planner_validation.enabled
self._planner_validation_threshold = agent_cfg.planner_validation.similarity_threshold
self._enable_committee = agent_cfg.committee.enabled
self._committee_debate_rounds = agent_cfg.committee.debate_rounds
self._disable_checker = not agent_cfg.checker.enabled
self._checker_max_iterations = agent_cfg.checker.max_iterations
self._checker_max_chat_rounds = agent_cfg.checker.max_chat_rounds
self._outputter = agent_cfg.outputter
self._disable_outputter = not agent_cfg.outputter.enabled
self._flash = agent_cfg.flash
self._pro = agent_cfg.pro
def add_profile(self, profile: AgentProfile, validate: bool = True) -> "AgentConfigBuilder":
"""Add an agent profile to the ARTEMIS agent.
Args:
profile: The agent profile to add
"""
self._agent_profiles[profile.name] = profile
if validate:
profile.llm_config.validate_providers()
return self
def add_profiles(
self,
profiles: list[AgentProfile],
validate: bool = True,
) -> "AgentConfigBuilder":
"""Add multiple agent profiles to the ARTEMIS agent.
Args:
profiles: List of agent profiles to add
"""
for profile in profiles:
self.add_profile(profile=profile, validate=validate)
return self
def with_default_profile(self, profile: str | AgentProfile) -> "AgentConfigBuilder":
"""Set the default agent profile used for tasks.
Args:
profile: The name or instance of the default agent profile
"""
self._default_profile = profile
return self
def for_device(
self,
platform: DevicePlatform,
device_id: str,
) -> "AgentConfigBuilder":
"""Configure the ARTEMIS agent for a specific device.
Args:
platform: The device platform (ANDROID)
device_id: The unique identifier for the device
"""
if self._cloud_mobile_id_or_ref is not None:
raise ValueError(
"Device ID cannot be set when a cloud mobile is already"
" configured.\n> for_device() and for_cloud_mobile() are"
" mutually exclusive"
)
self._device_id = device_id
self._device_platform = platform
return self
def with_default_task_config(self, config: TaskRequestCommon) -> "AgentConfigBuilder":
"""Set the default task configuration.
Args:
config: The task configuration to use as default
"""
self._task_request_defaults = copy.deepcopy(config)
return self
def with_adb_server(self, host: str, port: int | None = None) -> "AgentConfigBuilder":
"""Set the ADB server host and port.
Args:
host: The ADB server host
port: The ADB server port
"""
self._servers.adb_host = host
if port is not None:
self._servers.adb_port = port
return self
def with_servers(self, servers: ServerConfig) -> "AgentConfigBuilder":
"""Set the server settings.
Args:
servers: The server settings to use
"""
self._servers = copy.deepcopy(servers)
return self
def with_graph_config_callbacks(self, callbacks: Callbacks) -> "AgentConfigBuilder":
"""Set the graph config callbacks.
Args:
callbacks: The graph config callbacks to use
"""
self._graph_config_callbacks = callbacks
return self
def with_video_recording_tools(self, enabled: bool = True) -> "AgentConfigBuilder":
"""Enable or disable video recording tools.
Args:
enabled: Whether to enable video recording tools
"""
self._video_recording_tools_enabled = enabled
return self
def with_web_accessibility(self, enabled: bool = True) -> "AgentConfigBuilder":
"""Enable or disable forcing web accessibility for WebViews.
Args:
enabled: Whether to force web accessibility
"""
self._force_web_accessibility = enabled
return self
def with_disable_checker(self, disable: bool = True) -> "AgentConfigBuilder":
"""Temporarily disable the background Checker task (useful for debugging).
Args:
disable: Whether to disable the checker
"""
self._disable_checker = disable
return self
def with_checker(
self,
enabled: bool = True,
max_iterations: int | None = None,
max_chat_rounds: int | None = None,
) -> "AgentConfigBuilder":
"""Configure Checker subgoal verification and rollback settings.
Args:
enabled: Whether checker visual verification is enabled
max_iterations: Optional maximum iterations for Checker reasoning (1-50)
max_chat_rounds: Optional maximum debate rounds with Operator (1-10)
"""
self._disable_checker = not enabled
if max_iterations is not None:
self._checker_max_iterations = max_iterations
if max_chat_rounds is not None:
self._checker_max_chat_rounds = max_chat_rounds
return self
def with_disable_planner_validation(self, disable: bool = True) -> "AgentConfigBuilder":
"""Temporarily disable the background Planner validation task.
Args:
disable: Whether to disable the planner validation
"""
self._disable_planner_validation = disable
return self
def with_planner_validation(
self, enabled: bool = True, similarity_threshold: float | None = None
) -> "AgentConfigBuilder":
"""Configure async Planner validation on milestone changes.
Args:
enabled: Whether planner validation is enabled
similarity_threshold: Optional similarity threshold (0.0 - 1.0)
"""
self._disable_planner_validation = not enabled
if similarity_threshold is not None:
self._planner_validation_threshold = similarity_threshold
return self
def with_enable_committee(self, enabled: bool = True) -> "AgentConfigBuilder":
"""Enable or disable the Committee council debate tool for Operator."""
self._enable_committee = enabled
return self
def with_committee(self, enabled: bool = True, debate_rounds: int = 2) -> "AgentConfigBuilder":
"""Configure Multi-Agent Committee council debate settings.
Args:
enabled: Whether committee debate tool is enabled
debate_rounds: Number of debate rounds (1-5)
"""
self._enable_committee = enabled
self._committee_debate_rounds = debate_rounds
return self
def with_outputter(
self,
enabled: bool = True,
force_synthesis: bool = False,
) -> "AgentConfigBuilder":
"""Configure Outputter post-execution synthesis agent options.
Args:
enabled: Whether Outputter is mounted to synthesize final outputs
force_synthesis: Force Outputter synthesis even if no structured output schema is specified
"""
self._outputter = self._outputter.model_copy(
update={"enabled": enabled, "force_synthesis": force_synthesis}
)
self._disable_outputter = not enabled
return self
def with_disable_outputter(self, disable: bool = True) -> "AgentConfigBuilder":
"""Disable Outputter agent execution."""
self._disable_outputter = disable
self._outputter = self._outputter.model_copy(update={"enabled": not disable})
return self
def with_flash_config(
self,
max_turns: int | None = None,
explorer_mode: ExplorerVersion | None = None,
step_summarizer: bool | None = None,
step_summarizer_model: str | None = None,
prune_history_xml: bool | None = None,
) -> "AgentConfigBuilder":
"""Configure ⚡ Flash execution profile options.
Args:
max_turns: Maximum reactive loop turns before timeout
explorer_mode: Visual perception mode for Flash runner ('flash' 1-shot detection)
step_summarizer: Enable/disable asynchronous visual context compressor
step_summarizer_model: Lightweight model for background step summarization
prune_history_xml: Whether to prune outdated XML trees from historical steps
"""
updates: dict[str, Any] = {}
if max_turns is not None:
updates["max_turns"] = max_turns
if explorer_mode is not None:
updates["explorer_mode"] = explorer_mode
self._explorer = self._explorer.model_copy(update={"flash_mode": explorer_mode})
if (
step_summarizer is not None
or step_summarizer_model is not None
or prune_history_xml is not None
):
sum_updates: dict[str, Any] = {}
if step_summarizer is not None:
sum_updates["enabled"] = step_summarizer
if step_summarizer_model is not None:
sum_updates["model"] = step_summarizer_model
if prune_history_xml is not None:
sum_updates["prune_history_xml"] = prune_history_xml
updates["step_summarizer"] = self._flash.step_summarizer.model_copy(update=sum_updates)
if updates:
self._flash = self._flash.model_copy(update=updates)
return self
def with_flash_step_summarizer(
self,
enabled: bool = True,
model: str | None = None,
prune_history_xml: bool | None = None,
) -> "AgentConfigBuilder":
"""Configure Flash asynchronous step state summarizer options."""
return self.with_flash_config(
step_summarizer=enabled,
step_summarizer_model=model,
prune_history_xml=prune_history_xml,
)
def with_pro_config(
self,
explorer_mode: ExplorerVersion | None = None,
planner_validation: bool | None = None,
committee: bool | None = None,
checker: bool | None = None,
video_ledger: bool | None = None,
) -> "AgentConfigBuilder":
"""Configure 🚀 Pro execution profile options.
Args:
explorer_mode: Visual perception mode for Pro profile ('flash', 'pro', or 'ultra')
planner_validation: Enable/disable planner milestone validation
committee: Enable/disable multi-agent committee debate tool
checker: Enable/disable visual verification & snapshot rollback
video_ledger: Enable/disable screen video action ledger tracking
"""
if explorer_mode is not None:
self._pro = self._pro.model_copy(
update={"explorer": self._pro.explorer.model_copy(update={"mode": explorer_mode})}
)
self._explorer = self._explorer.model_copy(update={"pro_mode": explorer_mode})
if planner_validation is not None:
self.with_planner_validation(enabled=planner_validation)
if committee is not None:
self.with_committee(enabled=committee)
if checker is not None:
self.with_checker(enabled=checker)
if video_ledger is not None:
self._enable_video_ledger = video_ledger
self._pro = self._pro.model_copy(
update={
"video_analyzer": self._pro.video_analyzer.model_copy(
update={"enable_ledger": video_ledger}
)
}
)
return self
def with_explorer(
self,
version: ExplorerVersion | None = None,
default_version: ExplorerVersion | None = None,
flash_mode: ExplorerVersion | None = None,
pro_mode: ExplorerVersion | None = None,
caching: bool | None = None,
versions: dict[str, ExplorerVersion] | None = None,
) -> "AgentConfigBuilder":
"""Configure UI Explorer visual perception options.
Args:
version: Default Explorer version mode ('flash', 'pro', or 'ultra')
default_version: Alias for version ('flash', 'pro', or 'ultra')
flash_mode: Explorer version for Flash execution profile (FlashRunner)
pro_mode: Explorer version for Pro execution profile (Graph/Operator/Validator)
caching: Whether to enable context caching for multi-turn Explorer
versions: Optional explicit per-agent version mapping
"""
target_version = version if version is not None else default_version
updates = {}
if target_version is not None:
updates["default_version"] = target_version
if flash_mode is not None:
updates["flash_mode"] = flash_mode
if pro_mode is not None:
updates["pro_mode"] = pro_mode
if caching is not None:
updates["caching"] = caching
if updates:
self._explorer = self._explorer.model_copy(update=updates)
if versions is not None:
self._explorer_versions = versions
return self
def with_explorer_version(self, version: ExplorerVersion) -> "AgentConfigBuilder":
"""Configure default model version to use for explorer sub-agents ('flash', 'pro', or 'ultra')."""
self._explorer = self._explorer.model_copy(update={"default_version": version})
return self
def with_explorer_versions(self, versions: dict[str, ExplorerVersion]) -> "AgentConfigBuilder":
"""Configure per-agent model versions to use for explorer sub-agents."""
self._explorer_versions = versions
return self
def with_blacklisted_tools(self, tools: dict[str, list[str]]) -> "AgentConfigBuilder":
"""Configure blacklisted tools."""
self._blacklisted_tools = tools
return self
def build(self, validate_profiles: bool = True) -> AgentConfig:
"""Build the ARTEMIS AgentConfig object.
Args:
default_profile: Name of the default agent profile to use
Returns:
A configured AgentConfig object
Raises:
ValueError: If default_profile is specified but not found in
configured profiles
"""
nb_profiles = len(self._agent_profiles)
if isinstance(self._default_profile, str):
profile_name = self._default_profile
default_profile = self._agent_profiles.get(profile_name, None)
if default_profile is None:
raise ValueError(f"Profile '{profile_name}' not found in configured agents")
elif isinstance(self._default_profile, AgentProfile):
default_profile = self._default_profile
if default_profile.name not in self._agent_profiles:
self.add_profile(default_profile, validate=validate_profiles)
elif nb_profiles <= 0:
llm_config = get_default_llm_config()
default_profile = AgentProfile(
name=DEFAULT_PROFILE_NAME,
llm_config=llm_config,
)
self.add_profile(default_profile, validate=validate_profiles)
elif nb_profiles == 1:
# Select the only one available
default_profile = next(iter(self._agent_profiles.values()))
else:
available_profiles = ", ".join(self._agent_profiles.keys())
raise ValueError(
f"You must call with_default_profile() to select one among: {available_profiles}"
)
device_id = (
self._device_id
or os.environ.get("ARTEMIS_DEVICE_ID")
or os.environ.get("ADB_DEVICE_SERIAL")
)
return AgentConfig(
agent_profiles=self._agent_profiles,
task_request_defaults=self._task_request_defaults or TaskRequestCommon(),
default_profile=default_profile,
device_id=device_id,
device_platform=self._device_platform,
servers=self._servers,
graph_config_callbacks=self._graph_config_callbacks,
video_recording_tools_enabled=self._video_recording_tools_enabled,
force_web_accessibility=self._force_web_accessibility,
disable_checker=self._disable_checker,
checker_max_iterations=self._checker_max_iterations,
checker_max_chat_rounds=self._checker_max_chat_rounds,
disable_planner_validation=self._disable_planner_validation,
planner_validation_threshold=self._planner_validation_threshold,
enable_committee=self._enable_committee,
committee_debate_rounds=self._committee_debate_rounds,
disable_outputter=self._disable_outputter,
outputter=self._outputter,
flash=self._flash,
pro=self._pro,
explorer=self._explorer,
explorer_versions=self._explorer_versions,
blacklisted_tools=self._blacklisted_tools,
enable_video_ledger=self._enable_video_ledger,
)
def get_default_agent_config():
return AgentConfigBuilder().build()
def get_default_servers():
host = settings.ADB_HOST or os.environ.get("ADB_HOST", "localhost")
port_val = settings.ADB_PORT
if not port_val:
port_str = os.environ.get("ADB_PORT", "5037")
port_val = int(port_str) if port_str.isdigit() else 5037
return ServerConfig(
adb_host=host,
adb_port=port_val,
)