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
This commit is contained in:
somew1nd committed 2026-08-13 11:46:47 -07:00
commit 206a9ce921
229 files changed
+49653

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
# 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.
"""Unified configuration package and single entry point for ARTEMIS settings, paths, and models."""
from artemis.config.agent import (
AgentGlobalConfig,
CheckerConfig,
CommitteeConfig,
ExplorerConfig,
FlashProfileConfig,
OutputterConfig,
PlannerValidationConfig,
ProProfileConfig,
StepSummarizerConfig,
VideoAnalyzerConfig,
load_agent_config,
resolve_explorer_version,
)
from artemis.config.constants import (
AGENT_CONFIG_FILENAME,
DATA_ENGINE_DB_FILENAME,
DEFAULT_ACTION_TIMEOUT,
DEFAULT_ADB_HOST,
DEFAULT_ADB_PORT,
DEFAULT_EDGE_MONITOR_PORT,
DEFAULT_EXPLORER_VERSION,
DEFAULT_GATEWAY_PORT,
DEFAULT_MODEL,
DEFAULT_PROFILE,
DEFAULT_STREAM_PING_INTERVAL,
DOTENV_FILENAME,
ENV_ADB_DEVICE_SERIAL,
ENV_ADB_HOST,
ENV_ADB_PORT,
ENV_ADB_SERVER_SOCKET,
ENV_ANTHROPIC_API_KEY,
ENV_ANTIGRAVITY_APP_DIR,
ENV_ANTIGRAVITY_LS_ADDRESS,
ENV_DATA_ENGINE_DB_PATH,
ENV_EVENTS_OUTPUT_PATH,
ENV_GCP_API_KEY,
ENV_GEMINI_API_KEY,
ENV_GOOGLE_API_KEY,
ENV_KEEP_VIDEOS,
ENV_ARTEMIS_APP_DIR,
ENV_ARTEMIS_CLOUD_GATEWAY_URL,
ENV_ARTEMIS_CLOUD_MODE,
ENV_ARTEMIS_CLOUD_SESSION_ID,
ENV_ARTEMIS_DEBUG,
ENV_ARTEMIS_DEFAULT_MODEL,
ENV_ARTEMIS_DEFAULT_PROFILE,
ENV_ARTEMIS_DEVICE_ID,
ENV_ARTEMIS_EDGE_PORT,
ENV_ARTEMIS_EXPLORER_VERSION,
ENV_ARTEMIS_IPC_PORT,
ENV_ARTEMIS_MCP_SERVER,
ENV_ARTEMIS_TENANT_ID,
ENV_ARTEMIS_TENANT_TOKEN,
ENV_ARTEMIS_TRACES_DIR,
ENV_ARTEMIS_USE_FILE_API,
ENV_ARTEMIS_USE_USER_DIR,
ENV_OPEN_ROUTER_API_KEY,
ENV_OPENAI_API_KEY,
ENV_OPENAI_BASE_URL,
ENV_RESULTS_OUTPUT_PATH,
ENV_XAI_API_KEY,
IPC_PORT_FILENAME,
LLM_CONFIG_FILENAME,
LLM_CONFIG_OVERRIDE_FILENAME,
LS_ADDRESS_FILENAME,
AgentNode,
AgentNodeWithFallback,
ExplorerVersion,
LLMProvider,
LLMUtilsNode,
LLMUtilsNodeWithFallback,
)
from artemis.config.llm import (
CyFunctionDetector,
LLM,
LLMConfig,
LLMConfigUtils,
LLMWithFallback,
deep_merge_llm_config,
get_default_llm_config,
initialize_llm_config,
load_llm_config_override,
parse_llm_config,
validate_vertex_ai_credentials,
)
from artemis.config.output import (
OutputConfig,
prepare_output_files,
record_events,
)
from artemis.config.paths import (
CONFIG_DIR,
GLOBAL_APP_DIR,
GLOBAL_JETSKI_DIR,
ROOT_DIR,
get_app_dir,
get_config_path,
get_data_engine_db_path,
get_default_traces_path,
get_ipc_port_file,
get_ls_address_file,
get_temp_dir,
get_traces_dir,
is_frozen_bundle,
)
from artemis.config.runtime import (
cleanup_temp_dir,
clear_ipc_port,
clear_ls_address,
read_ipc_port,
read_ls_address,
write_ipc_port,
write_ls_address,
)
from artemis.config.settings import (
Settings,
settings,
)
__all__ = [
# Settings & Singleton
"Settings",
"settings",
# Paths & Dirs
"ROOT_DIR",
"CONFIG_DIR",
"GLOBAL_APP_DIR",
"GLOBAL_JETSKI_DIR",
"get_app_dir",
"get_traces_dir",
"get_default_traces_path",
"get_temp_dir",
"get_config_path",
"get_data_engine_db_path",
"get_ipc_port_file",
"get_ls_address_file",
"is_frozen_bundle",
# LLM Config & Models
"CyFunctionDetector",
"LLM",
"LLMWithFallback",
"LLMConfigUtils",
"LLMConfig",
"parse_llm_config",
"initialize_llm_config",
"get_default_llm_config",
"deep_merge_llm_config",
"load_llm_config_override",
"validate_vertex_ai_credentials",
# Agent & Tool Config
"AgentGlobalConfig",
"CheckerConfig",
"CommitteeConfig",
"ExplorerConfig",
"FlashProfileConfig",
"OutputterConfig",
"PlannerValidationConfig",
"ProProfileConfig",
"StepSummarizerConfig",
"VideoAnalyzerConfig",
"load_agent_config",
"resolve_explorer_version",
# Output Config
"OutputConfig",
"prepare_output_files",
"record_events",
# Runtime & IPC State
"read_ipc_port",
"write_ipc_port",
"clear_ipc_port",
"read_ls_address",
"write_ls_address",
"clear_ls_address",
"cleanup_temp_dir",
# Types & Literals
"LLMProvider",
"ExplorerVersion",
"LLMUtilsNode",
"LLMUtilsNodeWithFallback",
"AgentNode",
"AgentNodeWithFallback",
# Filenames
"LLM_CONFIG_FILENAME",
"LLM_CONFIG_OVERRIDE_FILENAME",
"AGENT_CONFIG_FILENAME",
"DATA_ENGINE_DB_FILENAME",
"IPC_PORT_FILENAME",
"LS_ADDRESS_FILENAME",
"DOTENV_FILENAME",
# Defaults
"DEFAULT_ADB_HOST",
"DEFAULT_ADB_PORT",
"DEFAULT_GATEWAY_PORT",
"DEFAULT_EDGE_MONITOR_PORT",
"DEFAULT_ACTION_TIMEOUT",
"DEFAULT_STREAM_PING_INTERVAL",
"DEFAULT_PROFILE",
"DEFAULT_MODEL",
"DEFAULT_EXPLORER_VERSION",
# Environment Variables
"ENV_ARTEMIS_APP_DIR",
"ENV_ANTIGRAVITY_APP_DIR",
"ENV_ARTEMIS_USE_USER_DIR",
"ENV_ARTEMIS_TRACES_DIR",
"ENV_DATA_ENGINE_DB_PATH",
"ENV_ARTEMIS_CLOUD_MODE",
"ENV_ARTEMIS_CLOUD_SESSION_ID",
"ENV_ARTEMIS_CLOUD_GATEWAY_URL",
"ENV_ARTEMIS_TENANT_ID",
"ENV_ARTEMIS_TENANT_TOKEN",
"ENV_ARTEMIS_EDGE_PORT",
"ENV_ARTEMIS_IPC_PORT",
"ENV_ANTIGRAVITY_LS_ADDRESS",
"ENV_ARTEMIS_MCP_SERVER",
"ENV_ADB_DEVICE_SERIAL",
"ENV_ARTEMIS_DEVICE_ID",
"ENV_ADB_HOST",
"ENV_ADB_PORT",
"ENV_ADB_SERVER_SOCKET",
"ENV_GOOGLE_API_KEY",
"ENV_GEMINI_API_KEY",
"ENV_GCP_API_KEY",
"ENV_OPENAI_API_KEY",
"ENV_OPENAI_BASE_URL",
"ENV_ANTHROPIC_API_KEY",
"ENV_OPEN_ROUTER_API_KEY",
"ENV_XAI_API_KEY",
"ENV_ARTEMIS_EXPLORER_VERSION",
"ENV_ARTEMIS_DEFAULT_PROFILE",
"ENV_ARTEMIS_DEFAULT_MODEL",
"ENV_ARTEMIS_USE_FILE_API",
"ENV_KEEP_VIDEOS",
"ENV_ARTEMIS_DEBUG",
"ENV_EVENTS_OUTPUT_PATH",
"ENV_RESULTS_OUTPUT_PATH",
]
+490
View File
@@ -0,0 +1,490 @@
# 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.
"""Agent tools, sub-agent capabilities, and explorer settings configuration."""
import os
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, model_validator
from artemis.config.constants import (
AGENT_CONFIG_FILENAME,
DEFAULT_EXPLORER_VERSION,
ExplorerVersion,
)
from artemis.config.paths import ROOT_DIR, get_config_path
from artemis.utils.file import load_jsonc
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class VideoAnalyzerConfig(BaseModel):
"""Configuration specific to VideoAnalyzer and screen recording."""
enabled: bool | None = Field(
default=None,
description=(
"Whether continuous screen video recording and video analyzer tools "
"are enabled. If None, auto-detected based on scrcpy & ffmpeg availability."
),
)
enable_ledger: bool = Field(
default=True,
description="Whether to enable video action ledger state tracking.",
)
model_config = {"extra": "allow"}
class PlannerValidationConfig(BaseModel):
"""Configuration specific to Planner validation of milestone changes."""
enabled: bool = Field(
default=False,
description="Whether to enable async planner validation when task plan milestones change.",
)
similarity_threshold: float = Field(
default=0.85,
description="Difflib similarity threshold below which milestone text changes trigger re-validation.",
)
model_config = {"extra": "allow"}
class CommitteeConfig(BaseModel):
"""Configuration specific to Multi-Agent Committee / Council."""
enabled: bool = Field(
default=False,
description="Whether to equip Operator with the ask_committee tool.",
)
debate_rounds: int = Field(
default=2,
ge=1,
le=5,
description="Number of debate rounds among council members (1-5).",
)
model_config = {"extra": "allow"}
class CheckerConfig(BaseModel):
"""Configuration specific to Checker subgoal verification and rollback."""
enabled: bool = Field(
default=False,
description="Whether to enable visual verification and snapshot rollback by the Checker agent.",
)
max_iterations: int = Field(
default=20,
ge=1,
le=50,
description="Maximum verification iterations for Checker LLM reasoning and inspection.",
)
max_chat_rounds: int = Field(
default=4,
ge=1,
le=10,
description="Maximum debate/rebuttal rounds between Operator and Checker.",
)
model_config = {"extra": "allow"}
class OutputterConfig(BaseModel):
"""Configuration specific to Outputter post-execution report synthesis agent."""
enabled: bool = Field(
default=True,
description="Whether Outputter agent is mounted for post-execution report/structured output synthesis.",
)
force_synthesis: bool = Field(
default=False,
description="Whether to force Outputter synthesis even when no structured schema is explicitly requested.",
)
model_config = {"extra": "allow"}
class ExplorerConfig(BaseModel):
"""Configuration specific to UI Explorer visual sub-agent (Flash / Pro / Ultra)."""
default_version: ExplorerVersion = Field(
default=DEFAULT_EXPLORER_VERSION,
description="Default Explorer version mode ('flash', 'pro', or 'ultra').",
)
flash_mode: ExplorerVersion = Field(
default="flash",
description="Explorer version mode when main system runs under Flash profile (FlashRunner).",
)
pro_mode: ExplorerVersion = Field(
default="flash",
description="Explorer version mode when main system runs under Pro profile (Graph / Operator / Validator).",
)
caching: bool = Field(
default=True,
description="Whether to enable context caching for multi-turn pro/ultra Explorer.",
)
model_config = {"extra": "allow"}
def resolve(
self,
explicit_version: str | None = None,
agent_name: str | None = None,
profile: str | None = None,
per_agent_overrides: dict[str, str] | None = None,
) -> ExplorerVersion:
"""Resolves active Explorer version with clean precedence rules."""
if explicit_version:
v = str(explicit_version).strip().lower()
if v in ("flash", "pro", "ultra"):
return v # type: ignore
env_v = os.getenv("ARTEMIS_EXPLORER_VERSION", "").strip().lower()
if env_v in ("flash", "pro", "ultra"):
return env_v # type: ignore
if per_agent_overrides and agent_name and agent_name in per_agent_overrides:
v = str(per_agent_overrides[agent_name]).strip().lower()
if v in ("flash", "pro", "ultra"):
return v # type: ignore
if profile in ("flash", "flash_runner") or agent_name in ("flash", "flash_runner"):
return self.flash_mode
if profile in ("pro", "operator", "validator") or agent_name in (
"pro",
"operator",
"validator",
):
return self.pro_mode
return self.default_version
class StepSummarizerConfig(BaseModel):
"""Configuration specific to Flash asynchronous step visual summarization."""
enabled: bool = Field(
default=True,
description="Whether to asynchronously summarize historical steps to replace pruned images.",
)
model: str = Field(
default="gemini-2.5-flash-lite",
description="Lightweight model used for background step state summarization.",
)
prune_history_xml: bool = Field(
default=True,
description="Whether to prune outdated heavy UI XML trees from historical turns.",
)
model_config = {"extra": "allow"}
class FlashProfileConfig(BaseModel):
"""Configuration options specific to the ⚡ Flash execution profile (FlashRunner/ReactiveRunner)."""
max_turns: int = Field(
default=30,
ge=1,
le=100,
description="Maximum reactive turns for Flash execution before timeout.",
)
explorer_mode: ExplorerVersion = Field(
default="flash",
description="Perception mode for Flash runner ('flash' for 1-shot detection).",
)
step_summarizer: StepSummarizerConfig = Field(
default_factory=StepSummarizerConfig,
description="Asynchronous visual context compressor settings.",
)
model_config = {"extra": "allow"}
class ProExplorerConfig(BaseModel):
"""Explorer perception settings under Pro profile."""
mode: ExplorerVersion = Field(
default="flash",
description="Explorer perception mode under Pro profile ('flash', 'pro', or 'ultra').",
)
caching: bool = Field(
default=True,
description="Whether to enable context caching for multi-turn Pro/Ultra exploration.",
)
model_config = {"extra": "allow"}
class ProProfileConfig(BaseModel):
"""Configuration options specific to the 🚀 Pro execution profile (LangGraph / Multi-Agent Closed-Loop)."""
explorer: ProExplorerConfig = Field(
default_factory=ProExplorerConfig,
description="Explorer perception settings for Pro profile.",
)
planner_validation: PlannerValidationConfig = Field(
default_factory=PlannerValidationConfig,
description="Planner milestone consistency validation settings.",
)
committee: CommitteeConfig = Field(
default_factory=CommitteeConfig,
description="Multi-Agent Committee council debate settings.",
)
checker: CheckerConfig = Field(
default_factory=CheckerConfig,
description="Checker visual verification and rollback settings.",
)
video_analyzer: VideoAnalyzerConfig = Field(
default_factory=VideoAnalyzerConfig,
description="Screen video action ledger tracking settings.",
)
model_config = {"extra": "allow"}
class AgentGlobalConfig(BaseModel):
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
# ⚡ Profile-categorized configurations
flash: FlashProfileConfig = Field(
default_factory=FlashProfileConfig,
description="Options for ⚡ Flash reactive runner execution.",
)
pro: ProProfileConfig = Field(
default_factory=ProProfileConfig,
description="Options for 🚀 Pro multi-agent graph execution.",
)
# 🌐 Core Component Configurations
explorer: ExplorerConfig = Field(
default_factory=ExplorerConfig,
description="UI Explorer sub-agent runtime options.",
)
explorer_versions: dict[str, str] = Field(
default_factory=lambda: {"operator": "flash", "validator": "flash"},
description="Per-agent mapping of UI explorer version (e.g. flash, pro, ultra).",
)
planner_validation: PlannerValidationConfig = Field(
default_factory=PlannerValidationConfig,
description="Planner milestone validation runtime options.",
)
committee: CommitteeConfig = Field(
default_factory=CommitteeConfig,
description="Committee council runtime options.",
)
checker: CheckerConfig = Field(
default_factory=CheckerConfig,
description="Checker subgoal verification and rollback runtime options.",
)
outputter: OutputterConfig = Field(
default_factory=OutputterConfig,
description="Outputter synthesis agent runtime options.",
)
blacklisted_tools: dict[str, list[str]] = Field(
default_factory=dict,
description="List of blacklisted tools per agent.",
)
video_analyzer: VideoAnalyzerConfig = Field(
default_factory=VideoAnalyzerConfig,
description="Video analyzer runtime options.",
)
model_config = {"extra": "allow"}
@model_validator(mode="before")
@classmethod
def synchronize_profiles_and_components(cls, data: Any) -> Any:
"""Bidirectionally synchronize between categorized 'flash'/'pro' blocks and component fields."""
if not isinstance(data, dict):
return data
# 1. Sync from pro block to root components if provided
if "pro" in data and isinstance(data["pro"], dict):
pro_data = data["pro"]
if "checker" in pro_data and "checker" not in data:
data["checker"] = pro_data["checker"]
if "committee" in pro_data and "committee" not in data:
data["committee"] = pro_data["committee"]
if "planner_validation" in pro_data and "planner_validation" not in data:
data["planner_validation"] = pro_data["planner_validation"]
if "video_analyzer" in pro_data and "video_analyzer" not in data:
data["video_analyzer"] = pro_data["video_analyzer"]
if "explorer" in pro_data and isinstance(pro_data["explorer"], dict):
exp_pro = pro_data["explorer"]
if "explorer" not in data:
data["explorer"] = {}
if isinstance(data["explorer"], dict):
if "mode" in exp_pro and "pro_mode" not in data["explorer"]:
data["explorer"]["pro_mode"] = exp_pro["mode"]
if "caching" in exp_pro and "caching" not in data["explorer"]:
data["explorer"]["caching"] = exp_pro["caching"]
# 2. Sync from flash block to explorer.flash_mode
if "flash" in data and isinstance(data["flash"], dict):
flash_data = data["flash"]
if "explorer_mode" in flash_data:
if "explorer" not in data:
data["explorer"] = {}
if isinstance(data["explorer"], dict) and "flash_mode" not in data["explorer"]:
data["explorer"]["flash_mode"] = flash_data["explorer_mode"]
# 3. Sync from root components to pro/flash blocks if pro/flash block is omitted
if "checker" in data and ("pro" not in data or "checker" not in data.get("pro", {})):
data.setdefault("pro", {})["checker"] = data["checker"]
if "committee" in data and ("pro" not in data or "committee" not in data.get("pro", {})):
data.setdefault("pro", {})["committee"] = data["committee"]
if "planner_validation" in data and (
"pro" not in data or "planner_validation" not in data.get("pro", {})
):
data.setdefault("pro", {})["planner_validation"] = data["planner_validation"]
if "video_analyzer" in data and (
"pro" not in data or "video_analyzer" not in data.get("pro", {})
):
data.setdefault("pro", {})["video_analyzer"] = data["video_analyzer"]
# 4. Environment variable overrides (highest precedence for runtime switches)
def _parse_bool(var_name: str) -> bool | None:
v = os.getenv(var_name)
if v is None:
return None
v_clean = v.strip().lower()
if v_clean in ("1", "true", "yes", "on"):
return True
if v_clean in ("0", "false", "no", "off"):
return False
return None
chk_env = _parse_bool("ARTEMIS_CHECKER_ENABLED")
if chk_env is not None:
data.setdefault("checker", {})["enabled"] = chk_env
data.setdefault("pro", {}).setdefault("checker", {})["enabled"] = chk_env
comm_env = _parse_bool("ARTEMIS_COMMITTEE_ENABLED")
if comm_env is not None:
data.setdefault("committee", {})["enabled"] = comm_env
data.setdefault("pro", {}).setdefault("committee", {})["enabled"] = comm_env
pv_env = _parse_bool("ARTEMIS_PLANNER_VALIDATION_ENABLED")
if pv_env is not None:
data.setdefault("planner_validation", {})["enabled"] = pv_env
data.setdefault("pro", {}).setdefault("planner_validation", {})["enabled"] = pv_env
out_env = _parse_bool("ARTEMIS_OUTPUTTER_ENABLED")
if out_env is not None:
data.setdefault("outputter", {})["enabled"] = out_env
vid_rec_env = _parse_bool("ARTEMIS_VIDEO_RECORDING_ENABLED") or _parse_bool(
"ARTEMIS_WITH_VIDEO_RECORDING_TOOLS"
)
if vid_rec_env is not None:
data.setdefault("video_analyzer", {})["enabled"] = vid_rec_env
data.setdefault("pro", {}).setdefault("video_analyzer", {})["enabled"] = vid_rec_env
vid_env = _parse_bool("ARTEMIS_VIDEO_LEDGER_ENABLED")
if vid_env is not None:
data.setdefault("video_analyzer", {})["enable_ledger"] = vid_env
data.setdefault("pro", {}).setdefault("video_analyzer", {})["enable_ledger"] = vid_env
return data
def get_explorer_version(
self,
explicit_version: str | None = None,
agent_name: str | None = "operator",
profile: str | None = None,
) -> ExplorerVersion:
"""Resolve Explorer version from this configuration."""
return self.explorer.resolve(
explicit_version=explicit_version,
agent_name=agent_name,
profile=profile,
per_agent_overrides=self.explorer_versions,
)
def resolve_explorer_version(
ctx: Any | None = None,
explicit_version: str | None = None,
agent_or_profile_name: str | None = "operator",
) -> ExplorerVersion:
"""Resolves active Explorer version based on explicit args, env vars, agent_config, and execution setup."""
if explicit_version:
v = str(explicit_version).strip().lower()
if v in ("flash", "pro", "ultra"):
return v # type: ignore
env_v = os.getenv("ARTEMIS_EXPLORER_VERSION", "").strip().lower()
if env_v in ("flash", "pro", "ultra"):
return env_v # type: ignore
if ctx:
agent_cfg = getattr(ctx, "agent_config", None)
exec_setup = getattr(ctx, "execution_setup", None)
if agent_cfg:
if hasattr(agent_cfg, "get_explorer_version"):
return agent_cfg.get_explorer_version(
explicit_version=explicit_version,
agent_name=agent_or_profile_name,
)
if hasattr(agent_cfg, "explorer") and hasattr(agent_cfg.explorer, "resolve"):
overrides = getattr(agent_cfg, "explorer_versions", None)
return agent_cfg.explorer.resolve(
explicit_version=explicit_version,
agent_name=agent_or_profile_name,
per_agent_overrides=overrides,
)
if (
exec_setup
and hasattr(exec_setup, "explorer")
and hasattr(exec_setup.explorer, "resolve")
):
overrides = getattr(exec_setup, "explorer_versions", None)
return exec_setup.explorer.resolve(
explicit_version=explicit_version,
agent_name=agent_or_profile_name,
per_agent_overrides=overrides,
)
return "flash"
def load_agent_config(
config_path: Path | None = None,
) -> AgentGlobalConfig:
"""Load and validate AgentGlobalConfig from artemis.jsonc or agent_config.json.
Args:
config_path: Optional explicit file path to configuration file.
Returns:
Validated AgentGlobalConfig instance.
"""
resolved_path = config_path
if not resolved_path:
for candidate in ("artemis.jsonc", "artemis.json", AGENT_CONFIG_FILENAME):
try:
resolved_path = get_config_path(candidate)
break
except FileNotFoundError:
continue
if not resolved_path:
resolved_path = get_config_path(AGENT_CONFIG_FILENAME, ROOT_DIR / AGENT_CONFIG_FILENAME)
try:
with open(resolved_path, encoding="utf-8") as f:
data = load_jsonc(f)
if isinstance(data, dict) and "agent" in data:
return AgentGlobalConfig.model_validate(data["agent"])
return AgentGlobalConfig.model_validate(data)
except Exception as e:
logger.error(f"Failed to load agent config from '{resolved_path}': {e}")
raise ValueError(f"Failed to parse '{resolved_path}': {e}") from e
+142
View File
@@ -0,0 +1,142 @@
# 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.
"""Centralized constants, environment variable names, and type definitions for ARTEMIS."""
from typing import Literal
# ==============================================================================
# Environment Variable Keys
# ==============================================================================
# Application & Workspace Paths
ENV_ARTEMIS_APP_DIR = "ARTEMIS_APP_DIR"
ENV_ANTIGRAVITY_APP_DIR = "ANTIGRAVITY_APP_DIR"
ENV_ARTEMIS_USE_USER_DIR = "ARTEMIS_USE_USER_DIR"
ENV_ARTEMIS_TRACES_DIR = "ARTEMIS_TRACES_DIR"
ENV_DATA_ENGINE_DB_PATH = "DATA_ENGINE_DB_PATH"
# Cloud Brain & Distributed Execution
ENV_ARTEMIS_CLOUD_MODE = "ARTEMIS_CLOUD_MODE"
ENV_ARTEMIS_CLOUD_SESSION_ID = "ARTEMIS_CLOUD_SESSION_ID"
ENV_ARTEMIS_CLOUD_GATEWAY_URL = "ARTEMIS_CLOUD_GATEWAY_URL"
ENV_ARTEMIS_TENANT_ID = "ARTEMIS_TENANT_ID"
ENV_ARTEMIS_TENANT_TOKEN = "ARTEMIS_TENANT_TOKEN"
ENV_ARTEMIS_EDGE_PORT = "ARTEMIS_EDGE_PORT"
# Runtime State & IPC
ENV_ARTEMIS_IPC_PORT = "ARTEMIS_IPC_PORT"
ENV_ANTIGRAVITY_LS_ADDRESS = "ANTIGRAVITY_LS_ADDRESS"
ENV_ARTEMIS_MCP_SERVER = "ARTEMIS_MCP_SERVER"
# Device & ADB
ENV_ADB_DEVICE_SERIAL = "ADB_DEVICE_SERIAL"
ENV_ARTEMIS_DEVICE_ID = "ARTEMIS_DEVICE_ID"
ENV_ADB_HOST = "ADB_HOST"
ENV_ADB_PORT = "ADB_PORT"
ENV_ADB_SERVER_SOCKET = "ADB_SERVER_SOCKET"
# LLM & Vision OCR API Keys
ENV_GOOGLE_API_KEY = "GOOGLE_API_KEY"
ENV_GEMINI_API_KEY = "GEMINI_API_KEY"
ENV_GCP_API_KEY = "GCP_API_KEY"
ENV_OCR_API_KEY = "OCR_API_KEY"
ENV_VISION_API_KEY = "VISION_API_KEY"
ENV_API_KEY = "API_KEY"
ENV_OPENAI_API_KEY = "OPENAI_API_KEY"
ENV_OPENAI_BASE_URL = "OPENAI_BASE_URL"
ENV_ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY"
ENV_OPEN_ROUTER_API_KEY = "OPEN_ROUTER_API_KEY"
ENV_XAI_API_KEY = "XAI_API_KEY"
# Explorer & Agent Defaults
ENV_ARTEMIS_EXPLORER_VERSION = "ARTEMIS_EXPLORER_VERSION"
ENV_ARTEMIS_DEFAULT_PROFILE = "ARTEMIS_DEFAULT_PROFILE"
ENV_ARTEMIS_DEFAULT_MODEL = "ARTEMIS_DEFAULT_MODEL"
ENV_ARTEMIS_USE_FILE_API = "ARTEMIS_USE_FILE_API"
# Debugging & Output Paths
ENV_KEEP_VIDEOS = "KEEP_VIDEOS"
ENV_ARTEMIS_DEBUG = "ARTEMIS_DEBUG"
ENV_EVENTS_OUTPUT_PATH = "EVENTS_OUTPUT_PATH"
ENV_RESULTS_OUTPUT_PATH = "RESULTS_OUTPUT_PATH"
# ==============================================================================
# Configuration Filenames
# ==============================================================================
ARTEMIS_CONFIG_FILENAME = "artemis.jsonc"
LLM_CONFIG_FILENAME = "llm-config.json"
LLM_CONFIG_OVERRIDE_FILENAME = "llm-config.override.jsonc"
AGENT_CONFIG_FILENAME = "agent_config.json"
DATA_ENGINE_DB_FILENAME = "data_engine.db"
IPC_PORT_FILENAME = ".artemis_ipc_port"
LS_ADDRESS_FILENAME = ".jetski_ls_address"
DOTENV_FILENAME = ".env"
# ==============================================================================
# Default Values & Network Ports
# ==============================================================================
DEFAULT_ADB_HOST = "127.0.0.1"
DEFAULT_ADB_PORT = 5037
DEFAULT_GATEWAY_PORT = 8000
DEFAULT_EDGE_MONITOR_PORT = 8000
DEFAULT_ACTION_TIMEOUT = 30.0
DEFAULT_STREAM_PING_INTERVAL = 15.0
DEFAULT_PROFILE = "pro"
DEFAULT_MODEL = "gemini-2.5-flash"
DEFAULT_EXPLORER_VERSION: Literal["flash", "pro", "ultra"] = "flash"
# ==============================================================================
# Literals and Type Aliases
# ==============================================================================
LLMProvider = Literal[
"openai", "google", "openrouter", "xai", "vertexai", "anthropic", "ollama", "vllm", "custom"
]
ExplorerVersion = Literal["flash", "pro", "ultra"]
LLMUtilsNode = Literal[
"outputter",
"hopper",
"video_analyzer",
"object_detector",
]
LLMUtilsNodeWithFallback = LLMUtilsNode
AgentNode = Literal[
"planner",
"summarizer",
"operator",
"operator_summarizer",
"log_reader_sub_agent",
"log_analyzer",
"diagnoser",
"validator_failure_analyzer",
"checker",
"planner_avatar",
"history_analyzer_expert",
"diagnoser_expert",
"explorer",
"history_analyzer",
"validator_pixel_safety_net",
"validator",
"output_analyzer",
]
AgentNodeWithFallback = AgentNode
+364
View File
@@ -0,0 +1,364 @@
# 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.
"""LLM provider, model hierarchy, fallback chaining, and configuration loaders."""
import os
from pathlib import Path
from typing import Any, Literal
import google.auth
from google.auth.exceptions import DefaultCredentialsError
from pydantic import BaseModel, ValidationError
from artemis.config.constants import (
LLM_CONFIG_FILENAME,
AgentNode,
LLMProvider,
LLMUtilsNode,
)
from artemis.config.paths import ROOT_DIR, get_config_path
from artemis.config.settings import settings
from artemis.utils.file import load_jsonc
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
def validate_vertex_ai_credentials() -> None:
"""Validate Google Application Default Credentials for VertexAI provider."""
try:
_, project = google.auth.default()
if not project:
raise Exception("VertexAI requires a Google Cloud project to be set.")
except DefaultCredentialsError as e:
raise Exception(
f"VertexAI requires valid Google Application Default Credentials (ADC): {e}"
)
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
"""Utility type detector for Cython and C-extension functions in Pydantic."""
pass
class LLM(BaseModel):
"""Base model representing an LLM model provider and runtime parameters."""
model_config = {"ignored_types": (CyFunctionDetector,)}
provider: LLMProvider
model: str
temperature: float | None = None
thinking_budget: int | None = None
thinking_level: Literal["minimal", "low", "medium", "high"] | None = None
reasoning_effort: Literal["none", "low", "medium", "high"] | None = None
include_thoughts: bool | None = None
enable_grounding: bool | None = None
def validate_provider(self, name: str) -> None:
"""Ensure the required API key or credentials exist in settings for this provider."""
if self.provider == "openai":
if not settings.OPENAI_API_KEY:
raise Exception(f"{name} requires OPENAI_API_KEY in .env")
elif self.provider == "google":
if not settings.GOOGLE_API_KEY:
raise Exception(f"{name} requires GOOGLE_API_KEY in .env")
elif self.provider == "vertexai":
validate_vertex_ai_credentials()
elif self.provider == "anthropic":
if not (settings.ANTHROPIC_API_KEY or os.environ.get("ANTHROPIC_API_KEY")):
raise Exception(f"{name} requires ANTHROPIC_API_KEY in .env")
elif self.provider == "openrouter":
if not settings.OPEN_ROUTER_API_KEY:
raise Exception(f"{name} requires OPEN_ROUTER_API_KEY in .env")
elif self.provider == "xai":
if not settings.XAI_API_KEY:
raise Exception(f"{name} requires XAI_API_KEY in .env")
def __str__(self) -> str:
return f"{self.provider}/{self.model}"
class LLMWithFallback(LLM):
"""LLM configuration with automatic secondary fallback and timeout specs."""
fallback: LLM
fix_model: str | None = None
timeout: float | None = None
def __str__(self) -> str:
return f"{self.provider}/{self.model} (fallback: {self.fallback})"
class LLMConfigUtils(BaseModel):
"""Configuration container for auxiliary utility agents/nodes."""
model_config = {"ignored_types": (CyFunctionDetector,)}
outputter: LLMWithFallback
hopper: LLMWithFallback
video_analyzer: LLMWithFallback | None = None
object_detector: LLMWithFallback | None = None
class LLMConfig(BaseModel):
"""Comprehensive LLM configuration mapping every node to primary/fallback models."""
model_config = {"ignored_types": (CyFunctionDetector,)}
planner: LLMWithFallback
utils: LLMConfigUtils
summarizer: LLMWithFallback
operator: LLMWithFallback
operator_summarizer: LLMWithFallback
log_reader_sub_agent: LLMWithFallback
log_analyzer: LLMWithFallback
diagnoser: LLMWithFallback
validator_failure_analyzer: LLMWithFallback
checker: LLMWithFallback
planner_avatar: LLMWithFallback
history_analyzer_expert: LLMWithFallback
diagnoser_expert: LLMWithFallback
explorer: LLMWithFallback
history_analyzer: LLMWithFallback | None = None
validator_pixel_safety_net: LLMWithFallback | None = None
output_analyzer: LLMWithFallback | None = None
def validate_providers(self) -> None:
"""Validate credentials across all configured agent nodes."""
self.planner.validate_provider("Planner")
self.utils.outputter.validate_provider("Outputter")
self.utils.hopper.validate_provider("Hopper")
if self.utils.video_analyzer:
self.utils.video_analyzer.validate_provider("VideoAnalyzer")
if self.utils.object_detector:
self.utils.object_detector.validate_provider("ObjectDetector")
self.summarizer.validate_provider("Summarizer")
self.operator.validate_provider("Operator")
self.operator_summarizer.validate_provider("OperatorSummarizer")
self.log_reader_sub_agent.validate_provider("LogReaderSubAgent")
self.log_analyzer.validate_provider("LogAnalyzer")
self.diagnoser.validate_provider("Diagnoser")
self.validator_failure_analyzer.validate_provider("ValidatorFailureAnalyzer")
self.checker.validate_provider("Checker")
self.planner_avatar.validate_provider("PlannerAvatar")
self.history_analyzer_expert.validate_provider("HistoryAnalyzerExpert")
self.diagnoser_expert.validate_provider("DiagnoserExpert")
self.explorer.validate_provider("Explorer")
if self.history_analyzer:
self.history_analyzer.validate_provider("HistoryAnalyzer")
if self.validator_pixel_safety_net:
self.validator_pixel_safety_net.validate_provider("ValidatorPixelSafetyNet")
if self.output_analyzer:
self.output_analyzer.validate_provider("OutputAnalyzer")
def __str__(self) -> str:
return f"""
📃 Planner: {self.planner}
🧩 Utils:
🔽 Hopper: {self.utils.hopper}
📝 Outputter: {self.utils.outputter}
🎬 Video Analyzer: {self.utils.video_analyzer or "Not configured"}
👁️ Object Detector: {self.utils.object_detector or "Not configured"}
"""
def get_agent(self, item: AgentNode) -> LLMWithFallback:
"""Retrieve model configuration for a specific agent node with sensible defaults."""
val = getattr(self, item)
if val is None:
if item == "history_analyzer":
return self.operator
elif item == "validator_pixel_safety_net":
return LLMWithFallback(
provider="google",
model="gemini-3.5-flash-lite",
temperature=0.0,
fallback=LLM(
provider="google",
model="gemini-3.1-flash-lite",
temperature=0.0,
),
)
elif item == "output_analyzer":
return self.log_analyzer
return val
def get_utils(self, item: LLMUtilsNode) -> LLMWithFallback:
"""Retrieve model configuration for a specific utility node."""
value = getattr(self.utils, item)
if value is None:
raise ValueError(
f"Utils '{item}' is not configured. Please add it to your LLM "
"config or enable it via AgentConfigBuilder."
)
return value
def _expand_default_into_nodes(config_dict: dict) -> dict:
"""Expand unified config format with 'default' and 'nodes' into full LLMConfig schema."""
if "planner" in config_dict and "utils" in config_dict:
return config_dict
default_model_cfg = config_dict.get(
"default",
{
"provider": "google",
"model": "gemini-3.6-flash",
"fallback": {
"provider": "google",
"model": "gemini-3.5-flash",
},
},
)
nodes_override = config_dict.get("nodes", {})
all_agent_nodes = [
"planner",
"summarizer",
"operator",
"operator_summarizer",
"log_reader_sub_agent",
"log_analyzer",
"diagnoser",
"validator_failure_analyzer",
"checker",
"planner_avatar",
"history_analyzer_expert",
"diagnoser_expert",
"explorer",
]
all_utils_nodes = [
"outputter",
"hopper",
"video_analyzer",
"object_detector",
]
result: dict[str, Any] = {}
for node in all_agent_nodes:
node_cfg = dict(default_model_cfg)
if node in nodes_override:
for k, v in nodes_override[node].items():
if isinstance(v, dict) and isinstance(node_cfg.get(k), dict):
node_cfg[k] = {**node_cfg[k], **v}
else:
node_cfg[k] = v
result[node] = node_cfg
utils_dict: dict[str, Any] = {}
for util in all_utils_nodes:
util_cfg = dict(default_model_cfg)
if util in nodes_override:
for k, v in nodes_override[util].items():
if isinstance(v, dict) and isinstance(util_cfg.get(k), dict):
util_cfg[k] = {**util_cfg[k], **v}
else:
util_cfg[k] = v
utils_dict[util] = util_cfg
result["utils"] = utils_dict
return result
def parse_llm_config() -> LLMConfig:
"""Parse and instantiate LLMConfig from artemis.jsonc or llm-config.json."""
config_path = None
for candidate in ("artemis.jsonc", "artemis.json", LLM_CONFIG_FILENAME):
try:
config_path = get_config_path(candidate)
break
except FileNotFoundError:
continue
if not config_path:
config_path = get_config_path(LLM_CONFIG_FILENAME, ROOT_DIR / LLM_CONFIG_FILENAME)
try:
with open(config_path, encoding="utf-8") as f:
config_dict = load_jsonc(f)
expanded_dict = _expand_default_into_nodes(config_dict)
return LLMConfig.model_validate(expanded_dict)
except Exception as e:
logger.error(f"Failed to load or parse llm config: {config_path}. Error: {e}")
raise
def initialize_llm_config() -> LLMConfig:
"""Parse and validate credentials for LLMConfig."""
llm_config = parse_llm_config()
llm_config.validate_providers()
logger.success("LLM config initialized")
return llm_config
def get_default_llm_config() -> LLMConfig:
"""Returns default LLMConfig parsed from standard configuration file."""
return parse_llm_config()
def deep_merge_llm_config(base: LLMConfig, overrides: dict) -> LLMConfig:
"""Recursively merge dictionary overrides into an existing LLMConfig object."""
base_dict = base.model_dump()
def merge(d1: dict, d2: dict) -> None:
for k, v in d2.items():
if k in d1 and isinstance(d1[k], dict) and isinstance(v, dict):
merge(d1[k], v)
else:
d1[k] = v
merge(base_dict, overrides)
return LLMConfig.model_validate(base_dict)
def load_llm_config_override(path: Path | str) -> LLMConfig:
"""Load custom LLM configuration JSON/JSONC overrides on top of default configuration."""
default_config = get_default_llm_config()
resolved_path = Path(path)
if not resolved_path.exists():
try:
resolved_path = get_config_path(str(path))
except Exception:
pass
override_config_dict = {}
if resolved_path.exists():
logger.info(f"Loading custom LLM config from {resolved_path.resolve()}...")
with open(resolved_path, encoding="utf-8") as f:
override_config_dict = load_jsonc(f)
else:
logger.warning(f"Custom LLM config not found at {path} - using default config")
try:
return deep_merge_llm_config(default_config, override_config_dict)
except ValidationError as e:
logger.error(f"Invalid LLM config: {e}")
logger.info("Falling back to default config")
return default_config
+159
View File
@@ -0,0 +1,159 @@
# 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.
"""Structured output schemas, event recording, and output file management."""
import json
import os
from pathlib import Path
from typing import Annotated, Any
import warnings
from pydantic import BaseModel, Field, model_validator
from artemis.config.constants import (
ENV_EVENTS_OUTPUT_PATH,
ENV_RESULTS_OUTPUT_PATH,
)
from artemis.config.llm import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class OutputConfig(BaseModel):
"""Configuration for structured output formatting and schemas."""
model_config = {"ignored_types": (CyFunctionDetector,)}
structured_output: Annotated[
type[BaseModel] | dict | None,
Field(
default=None,
description=(
"Optional structured schema (as a BaseModel or dict) to shape the output. "
"If provided, it takes precedence over 'output_description'."
),
),
]
output_description: Annotated[
str | None,
Field(
default=None,
description=(
"Optional natural language description of the expected output format. "
"Used only if 'structured_output' is not provided. "
"Example: 'Output a JSON with 3 keys: color, price, websiteUrl'."
),
),
]
enable_outputter: Annotated[
bool | None,
Field(
default=None,
description="Explicitly enable or disable Outputter post-execution synthesis.",
),
] = None
force_synthesis: Annotated[
bool,
Field(
default=False,
description="Whether to run Outputter synthesis even without explicit output_description or schema.",
),
] = False
def __str__(self) -> str:
s_builder = ""
if self.structured_output:
s_builder += f"Structured Output: {self.structured_output}\n"
if self.output_description:
s_builder += f"Output Description: {self.output_description}\n"
if self.output_description and self.structured_output:
s_builder += (
"Both 'structured_output' and 'output_description' are provided. "
"'structured_output' will take precedence.\n"
)
return s_builder
@model_validator(mode="after")
def warn_if_both_outputs_provided(self) -> "OutputConfig":
if self.structured_output and self.output_description:
warnings.warn(
"Both 'structured_output' and 'output_description' are provided. "
"'structured_output' will take precedence.",
stacklevel=2,
)
return self
def needs_structured_format(self, default_enabled: bool = True) -> bool:
"""Returns True if Outputter should run to synthesize/structure the output."""
if self.enable_outputter is False:
return False
if self.enable_outputter is True or self.force_synthesis:
return True
if not default_enabled:
return False
return bool(self.structured_output or self.output_description)
def prepare_output_files() -> tuple[str | None, str | None]:
"""Validate and prepare events and results output files from environment variables."""
events_output_path = os.getenv(ENV_EVENTS_OUTPUT_PATH)
results_output_path = os.getenv(ENV_RESULTS_OUTPUT_PATH)
def validate_and_prepare_file(file_path: str) -> str | None:
if not file_path:
return None
path_obj = Path(file_path)
if path_obj.exists() and path_obj.is_dir():
logger.error(f"Error: Path '{file_path}' points to an existing directory, not a file.")
return None
if not path_obj.suffix or file_path.endswith(("/", "\\")):
logger.error(f"Error: Path '{file_path}' appears to be a directory path, not a file.")
return None
try:
path_obj.parent.mkdir(parents=True, exist_ok=True)
path_obj.touch(exist_ok=True)
return file_path
except OSError as e:
logger.error(f"Error creating file '{file_path}': {e}")
return None
validated_events_path = (
validate_and_prepare_file(events_output_path) if events_output_path else None
)
validated_results_path = (
validate_and_prepare_file(results_output_path) if results_output_path else None
)
return validated_events_path, validated_results_path
def record_events(output_path: Path | None, events: list[str] | BaseModel | Any) -> None:
"""Serialize and write execution events to disk."""
if not output_path:
return
if isinstance(events, str):
events_content = events
elif isinstance(events, BaseModel):
events_content = events.model_dump_json(indent=2)
else:
events_content = json.dumps(events, indent=2)
with open(output_path, "w", encoding="utf-8") as f:
f.write(events_content)
+170
View File
@@ -0,0 +1,170 @@
# 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.
"""Centralized workspace, configuration, temporary files, and directory management."""
import os
from pathlib import Path
import sys
from artemis.config.constants import (
DATA_ENGINE_DB_FILENAME,
ENV_ANTIGRAVITY_APP_DIR,
ENV_ARTEMIS_APP_DIR,
ENV_ARTEMIS_TRACES_DIR,
ENV_ARTEMIS_USE_USER_DIR,
IPC_PORT_FILENAME,
LS_ADDRESS_FILENAME,
)
# Project root directory (3 levels up from artemis/config/paths.py)
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_DIR = ROOT_DIR / "config"
def is_frozen_bundle() -> bool:
"""Check if running inside a PyInstaller / Nuitka standalone compiled binary."""
return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
def get_app_dir() -> Path:
"""Returns the central application directory for ARTEMIS configurations, DBs, and traces.
Order of precedence:
1. ARTEMIS_APP_DIR or ANTIGRAVITY_APP_DIR environment variable.
2. Legacy ~/.gemini/jetski directory if it exists.
3. Default ~/.artemis directory.
"""
env_dir = os.getenv(ENV_ARTEMIS_APP_DIR) or os.getenv(ENV_ANTIGRAVITY_APP_DIR)
if env_dir:
path = Path(env_dir)
path.mkdir(parents=True, exist_ok=True)
return path
legacy_dir = Path(os.path.expanduser("~/.gemini/jetski"))
if legacy_dir.exists():
return legacy_dir
default_dir = Path(os.path.expanduser("~/.artemis"))
default_dir.mkdir(parents=True, exist_ok=True)
return default_dir
def get_default_traces_path() -> Path:
"""Returns default traces directory.
Uses user app dir when running as a frozen binary or when app dir env is set.
"""
env_traces = os.getenv(ENV_ARTEMIS_TRACES_DIR)
if env_traces:
traces_dir = Path(env_traces)
elif (
is_frozen_bundle()
or os.getenv(ENV_ARTEMIS_USE_USER_DIR) == "true"
or os.getenv(ENV_ARTEMIS_APP_DIR)
or os.getenv(ENV_ANTIGRAVITY_APP_DIR)
):
traces_dir = get_app_dir() / "traces"
else:
traces_dir = ROOT_DIR / "traces"
traces_dir.mkdir(parents=True, exist_ok=True)
return traces_dir
def get_traces_dir() -> Path:
"""Alias for get_default_traces_path."""
return get_default_traces_path()
def get_temp_dir(subfolder: str | None = None) -> Path:
"""Returns a unified temporary directory for ARTEMIS runtime operations (e.g., screenshots, recordings).
Args:
subfolder: Optional subfolder name under the temp directory.
Returns:
Path object pointing to the initialized temporary directory.
"""
base_temp = get_app_dir() / "temp"
target_dir = base_temp / subfolder if subfolder else base_temp
target_dir.mkdir(parents=True, exist_ok=True)
return target_dir
def get_data_engine_db_path() -> Path:
"""Returns the unified SQLite database path for ARTEMIS DataEngine."""
return get_default_traces_path() / DATA_ENGINE_DB_FILENAME
def get_ipc_port_file() -> Path:
"""Returns the location of the IPC port synchronization file."""
app_dir_file = get_app_dir() / IPC_PORT_FILENAME
if app_dir_file.exists():
return app_dir_file
return ROOT_DIR / IPC_PORT_FILENAME
def get_ls_address_file() -> Path:
"""Returns the location of the Language Server address synchronization file."""
app_dir_file = get_app_dir() / LS_ADDRESS_FILENAME
if app_dir_file.exists():
return app_dir_file
return ROOT_DIR / LS_ADDRESS_FILENAME
def get_config_path(filename: str, default_bundled_path: Path | None = None) -> Path:
"""Resolve a configuration file path across env vars, config dir, project dir, app dir, and bundles.
Resolution Order:
1. Environment variable override: `ARTEMIS_<FILENAME_UPPER>`
2. Central `config/` directory: `<ROOT_DIR>/config/<filename>`
3. Project root directory: `<ROOT_DIR>/<filename>`
4. Custom default bundled path (if provided and exists)
5. User application directory: `<APP_DIR>/<filename>`
"""
env_var = f"ARTEMIS_{filename.upper().replace('.', '_').replace('-', '_')}"
env_path_str = os.getenv(env_var)
if env_path_str:
env_path = Path(env_path_str)
if env_path.exists():
return env_path
# Check config/ directory
config_dir_path = CONFIG_DIR / filename
if config_dir_path.exists():
return config_dir_path
# Check project root directory
project_path = ROOT_DIR / filename
if project_path.exists():
return project_path
# Check bundled fallback
if default_bundled_path and default_bundled_path.exists():
return default_bundled_path
# Check user app directory
app_dir_path = get_app_dir() / filename
if app_dir_path.exists():
return app_dir_path
raise FileNotFoundError(
f"Configuration file '{filename}' not found in config dir ({config_dir_path}), "
f"project root ({project_path}), or app dir ({app_dir_path})."
)
GLOBAL_APP_DIR = get_app_dir()
GLOBAL_JETSKI_DIR = GLOBAL_APP_DIR # Backward-compatibility alias
+142
View File
@@ -0,0 +1,142 @@
# 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.
"""Runtime state coordination, temporary file lifecycles, and IPC synchronization."""
import os
from pathlib import Path
import time
from artemis.config.constants import (
ENV_ANTIGRAVITY_LS_ADDRESS,
ENV_ARTEMIS_IPC_PORT,
)
from artemis.config.paths import (
get_ipc_port_file,
get_ls_address_file,
get_temp_dir,
)
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
def read_ipc_port() -> int | None:
"""Read the active ARTEMIS IPC port from environment or temporary state file."""
env_port = os.getenv(ENV_ARTEMIS_IPC_PORT)
if env_port and env_port.strip().isdigit():
return int(env_port.strip())
port_file = get_ipc_port_file()
if port_file.exists():
try:
content = port_file.read_text(encoding="utf-8").strip()
if content.isdigit():
return int(content)
except Exception as e:
logger.warning(f"Failed to read IPC port from {port_file}: {e}")
return None
def write_ipc_port(port: int) -> Path:
"""Save active IPC port to environment and temporary synchronization file."""
os.environ[ENV_ARTEMIS_IPC_PORT] = str(port)
port_file = get_ipc_port_file()
try:
port_file.parent.mkdir(parents=True, exist_ok=True)
port_file.write_text(str(port), encoding="utf-8")
except Exception as e:
logger.warning(f"Failed to write IPC port file to {port_file}: {e}")
return port_file
def clear_ipc_port() -> None:
"""Remove IPC port synchronization state file."""
port_file = get_ipc_port_file()
if port_file.exists():
try:
port_file.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to remove IPC port file {port_file}: {e}")
def read_ls_address() -> str | None:
"""Read the active Language Server address from environment or state file."""
env_addr = os.getenv(ENV_ANTIGRAVITY_LS_ADDRESS)
if env_addr and env_addr.strip():
return env_addr.strip()
addr_file = get_ls_address_file()
if addr_file.exists():
try:
content = addr_file.read_text(encoding="utf-8").strip()
if content:
return content
except Exception as e:
logger.warning(f"Failed to read LS address from {addr_file}: {e}")
return None
def write_ls_address(address: str) -> Path:
"""Save Language Server address to environment and temporary synchronization file."""
clean_addr = address.strip()
os.environ[ENV_ANTIGRAVITY_LS_ADDRESS] = clean_addr
addr_file = get_ls_address_file()
try:
addr_file.parent.mkdir(parents=True, exist_ok=True)
addr_file.write_text(clean_addr, encoding="utf-8")
except Exception as e:
logger.warning(f"Failed to write LS address file to {addr_file}: {e}")
return addr_file
def clear_ls_address() -> None:
"""Remove Language Server address synchronization state file."""
addr_file = get_ls_address_file()
if addr_file.exists():
try:
addr_file.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to remove LS address file {addr_file}: {e}")
def cleanup_temp_dir(subfolder: str | None = None, max_age_seconds: float | None = None) -> int:
"""Clean up expired temporary runtime files in the central temp directory.
Args:
subfolder: Optional subfolder within the temp directory.
max_age_seconds: Maximum age in seconds before a file is deleted. If None, all files are purged.
Returns:
Number of files successfully deleted.
"""
temp_dir = get_temp_dir(subfolder)
if not temp_dir.exists():
return 0
now = time.time()
deleted_count = 0
for file_path in temp_dir.glob("*"):
if file_path.is_file():
try:
if max_age_seconds is None or (now - file_path.stat().st_mtime) > max_age_seconds:
file_path.unlink(missing_ok=True)
deleted_count += 1
except Exception as e:
logger.warning(f"Could not delete temporary file {file_path}: {e}")
return deleted_count
+199
View File
@@ -0,0 +1,199 @@
# 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.
"""Application settings and environment variable configuration management."""
import os
from pathlib import Path
from dotenv import load_dotenv
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings
from artemis.config.constants import (
DEFAULT_ADB_HOST,
DEFAULT_ADB_PORT,
DEFAULT_EXPLORER_VERSION,
DEFAULT_MODEL,
DEFAULT_PROFILE,
ENV_DATA_ENGINE_DB_PATH,
ENV_GCP_API_KEY,
ENV_GEMINI_API_KEY,
ENV_GOOGLE_API_KEY,
ENV_OPEN_ROUTER_API_KEY,
ENV_OPENAI_API_KEY,
ENV_XAI_API_KEY,
ExplorerVersion,
)
from artemis.config.paths import (
GLOBAL_APP_DIR,
get_app_dir,
get_data_engine_db_path,
get_default_traces_path,
get_temp_dir,
)
from artemis.utils.logger import get_logger
# Load environment configuration from app dir if present, else current working dir
_global_env = GLOBAL_APP_DIR / ".env"
if _global_env.exists():
load_dotenv(dotenv_path=_global_env, verbose=True)
else:
load_dotenv(verbose=True)
logger = get_logger(__name__)
class Settings(BaseSettings):
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
# LLM Provider Authentication
OPENAI_API_KEY: SecretStr | None = None
GOOGLE_API_KEY: SecretStr | None = None
GEMINI_API_KEY: SecretStr | None = None
GCP_API_KEY: SecretStr | None = None
ANTHROPIC_API_KEY: SecretStr | None = None
XAI_API_KEY: SecretStr | None = None
OPEN_ROUTER_API_KEY: SecretStr | None = None
# Google Cloud Vision OCR Authentication
OCR_API_KEY: SecretStr | None = None
VISION_API_KEY: SecretStr | None = None
API_KEY: SecretStr | None = None
# Custom Provider Endpoints
OPENAI_BASE_URL: str | None = None
# Android ADB Connectivity
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
ADB_PORT: int | None = Field(default=DEFAULT_ADB_PORT)
ADB_DEVICE_SERIAL: str | None = None
# Execution Defaults
PROJECT_NAME: str | None = None
ARTEMIS_DEFAULT_PROFILE: str = Field(default=DEFAULT_PROFILE)
ARTEMIS_DEFAULT_MODEL: str = Field(default=DEFAULT_MODEL)
# Explorer Tool Settings
EXPLORER_VERSION: ExplorerVersion = Field(
default=DEFAULT_EXPLORER_VERSION,
description="Active version mode for the UI Explorer tool ('flash', 'pro', or 'ultra')",
)
EXPLORER_CACHING: bool = Field(
default=True,
description="Enable context caching when running multi-turn pro/ultra Explorer",
)
# Paths & Storage
TRACES_PATH: Path = Field(default_factory=get_default_traces_path)
DATA_ENGINE_DB_PATH: Path = Field(default_factory=get_data_engine_db_path)
TEMP_PATH: Path = Field(default_factory=get_temp_dir)
model_config = {"env_file": ".env", "extra": "ignore"}
@model_validator(mode="after")
def fallback_api_keys(self) -> "Settings":
"""Normalize Google / Gemini / GCP and OCR API keys for seamless interoperability."""
if not self.GOOGLE_API_KEY:
if self.GEMINI_API_KEY:
self.GOOGLE_API_KEY = self.GEMINI_API_KEY
elif self.GCP_API_KEY:
self.GOOGLE_API_KEY = self.GCP_API_KEY
# Fallback for OCR & Vision API keys
if not self.OCR_API_KEY:
if self.VISION_API_KEY:
self.OCR_API_KEY = self.VISION_API_KEY
elif self.API_KEY:
self.OCR_API_KEY = self.API_KEY
if not self.API_KEY and self.OCR_API_KEY:
self.API_KEY = self.OCR_API_KEY
return self
def get_api_key(self, provider: str) -> SecretStr | None:
"""Get API key for a specified provider.
Args:
provider: Provider name (e.g., 'google', 'gemini', 'openai', 'ocr', 'vision').
Returns:
SecretStr containing the API key or None if not configured.
"""
provider_lower = provider.lower()
if provider_lower in ("google", "gemini", "vertexai"):
return self.GOOGLE_API_KEY or self.GEMINI_API_KEY or self.GCP_API_KEY
elif provider_lower in ("ocr", "vision", "google_vision"):
return self.OCR_API_KEY or self.VISION_API_KEY or self.API_KEY or self.GOOGLE_API_KEY
elif provider_lower == "openai":
return self.OPENAI_API_KEY
elif provider_lower == "anthropic":
return self.ANTHROPIC_API_KEY
elif provider_lower == "openrouter":
return self.OPEN_ROUTER_API_KEY
elif provider_lower == "xai":
return self.XAI_API_KEY
return None
def set_api_key(self, provider: str, key: str, persist_to_env: bool = False) -> None:
"""Dynamically set an API key at runtime, optionally persisting to .env in the app dir.
Args:
provider: Target provider name.
key: Secret API key string.
persist_to_env: Whether to save the key to the app directory's .env file.
"""
secret = SecretStr(key)
provider_lower = provider.lower()
env_key_name = None
if provider_lower in ("google", "gemini", "vertexai"):
self.GOOGLE_API_KEY = secret
self.GEMINI_API_KEY = secret
self.GCP_API_KEY = secret
env_key_name = ENV_GOOGLE_API_KEY
os.environ[ENV_GOOGLE_API_KEY] = key
os.environ[ENV_GEMINI_API_KEY] = key
os.environ[ENV_GCP_API_KEY] = key
elif provider_lower == "openai":
self.OPENAI_API_KEY = secret
env_key_name = ENV_OPENAI_API_KEY
os.environ[ENV_OPENAI_API_KEY] = key
elif provider_lower == "openrouter":
self.OPEN_ROUTER_API_KEY = secret
env_key_name = ENV_OPEN_ROUTER_API_KEY
os.environ[ENV_OPEN_ROUTER_API_KEY] = key
elif provider_lower == "xai":
self.XAI_API_KEY = secret
env_key_name = ENV_XAI_API_KEY
os.environ[ENV_XAI_API_KEY] = key
if persist_to_env and env_key_name:
try:
env_file = get_app_dir() / ".env"
lines = []
if env_file.exists():
lines = env_file.read_text(encoding="utf-8").splitlines()
lines = [line for line in lines if not line.startswith(f"{env_key_name}=")]
lines.append(f"{env_key_name}={key}")
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
except Exception as e:
logger.warning(f"Could not persist {env_key_name} to .env: {e}")
# Singleton instance
settings = Settings()
# Synchronize DATA_ENGINE_DB_PATH in environment for external sub-processes/tools
if settings.DATA_ENGINE_DB_PATH:
os.environ[ENV_DATA_ENGINE_DB_PATH] = str(settings.DATA_ENGINE_DB_PATH)