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:
commit
206a9ce921
229 files changed
+49653
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user