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

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

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

213 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
from enum import Enum
import logging
import os
from pathlib import Path
import queue
import sys
import threading
from colorama import Fore, Style, init
from artemis.data_engine.context_vars import CURRENT_TRACE_ID
init(autoreset=True)
class LogLevel(Enum):
DEBUG = ("DEBUG", Fore.MAGENTA, "🔍")
INFO = ("INFO", Fore.WHITE, "ℹ")
SUCCESS = ("SUCCESS", Fore.GREEN, "✓")
WARNING = ("WARNING", Fore.YELLOW, "⚠")
ERROR = ("ERROR", Fore.RED, "❌")
CRITICAL = ("CRITICAL", Fore.RED + Style.BRIGHT, "💥")
class DataEngineHandler(logging.Handler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._log_queue = queue.Queue(maxsize=5000)
self._worker_thread = threading.Thread(target=self._drain_queue, daemon=True)
self._worker_thread.start()
def _drain_queue(self):
while True:
item = self._log_queue.get()
if item is None:
break
engine_mod = sys.modules.get("artemis.data_engine.engine")
if engine_mod and getattr(engine_mod, "_CURRENT_DATA_ENGINE", None):
_CURRENT_DATA_ENGINE = engine_mod._CURRENT_DATA_ENGINE
if _CURRENT_DATA_ENGINE and _CURRENT_DATA_ENGINE.current_session_id:
record_name, message, level_name, formatted, parent_id = item
_CURRENT_DATA_ENGINE.record_trace(
type="log",
name=record_name,
payload={
"message": message,
"level": level_name,
"formatted": formatted,
},
status="success",
parent_trace_id=parent_id,
)
self._log_queue.task_done()
def emit(self, record):
engine_mod = sys.modules.get("artemis.data_engine.engine")
if not engine_mod or not getattr(engine_mod, "_CURRENT_DATA_ENGINE", None):
return
_CURRENT_DATA_ENGINE = engine_mod._CURRENT_DATA_ENGINE
if _CURRENT_DATA_ENGINE and _CURRENT_DATA_ENGINE.current_session_id:
log_entry = self.format(record)
log_level = getattr(record, "log_level", None)
if log_level and hasattr(log_level, "value"):
level_name, _color, _symbol = log_level.value
else:
level_name = record.levelname
parent_id = CURRENT_TRACE_ID.get()
try:
self._log_queue.put_nowait(
(
record.name,
record.getMessage(),
level_name,
log_entry,
parent_id,
)
)
except queue.Full as e:
raise RuntimeError(
"DataEngine log queue is full - cannot accept more log"
" entries without dropping records"
) from e
class ArtemisLogger:
def __init__(
self,
name: str,
console_level: str = "INFO",
):
"""Initialize the ARTEMIS logger.
Args:
name: Logger name (usually __name__)
console_level: Minimum level for console output
"""
self.name = name
self.logger = logging.getLogger(name)
self.logger.setLevel(logging.DEBUG)
self.logger.handlers.clear()
self.logger.propagate = False
self._setup_console_handler(console_level)
# Add DataEngineHandler
data_engine_handler = DataEngineHandler()
data_engine_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(fmt="%(asctime)s | %(name)s | %(levelname)s | %(message)s")
data_engine_handler.setFormatter(formatter)
self.logger.addHandler(data_engine_handler)
if os.environ.get("ARTEMIS_MCP_SERVER") == "true":
traces_dir = os.environ.get("ARTEMIS_TRACES_PATH", "traces")
log_path = Path(traces_dir) / "mcp_server.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
def _setup_console_handler(self, level: str):
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(getattr(logging, level.upper()))
console_formatter = ColoredFormatter()
console_handler.setFormatter(console_formatter)
self.logger.addHandler(console_handler)
def debug(self, message: str, **kwargs):
self.logger.debug(message, extra={"log_level": LogLevel.DEBUG}, **kwargs)
def info(self, message: str, **kwargs):
self.logger.info(message, extra={"log_level": LogLevel.INFO}, **kwargs)
def success(self, message: str, **kwargs):
self.logger.info(message, extra={"log_level": LogLevel.SUCCESS}, **kwargs)
def warning(self, message: str, **kwargs):
self.logger.warning(message, extra={"log_level": LogLevel.WARNING}, **kwargs)
def error(self, message: str, **kwargs):
self.logger.error(message, extra={"log_level": LogLevel.ERROR}, **kwargs)
def critical(self, message: str, **kwargs):
self.logger.critical(message, extra={"log_level": LogLevel.CRITICAL}, **kwargs)
def header(self, message: str, **_kwargs):
separator = "=" * 60
colored_separator = f"{Fore.CYAN}{separator}{Style.RESET_ALL}"
colored_message = f"{Fore.CYAN}{message}{Style.RESET_ALL}"
print(colored_separator)
print(colored_message)
print(colored_separator)
self.logger.info(f"\n{separator}\n{message}\n{separator}")
class ColoredFormatter(logging.Formatter):
def format(self, record):
log_level = getattr(record, "log_level", LogLevel.INFO)
_level_name, color, symbol = log_level.value
colored_message = f"{color}{symbol} {record.getMessage()}{Style.RESET_ALL}"
return colored_message
_loggers = {}
def get_logger(
name: str,
console_level: str = "INFO",
) -> ArtemisLogger:
"""Get or create a logger instance.
Args:
name: Logger name (usually __name__)
log_file: Path to log file (defaults to logs/{name}.log)
console_level: Minimum level for console output
file_level: Minimum level for file output
enable_file_logging: Whether to enable file logging
Returns:
ArtemisLogger instance
"""
if name not in _loggers:
_loggers[name] = ArtemisLogger(
name=name,
console_level=console_level,
)
return _loggers[name]