# 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. """Universal ADB command and background task management tools for ARTEMIS. State model ----------- Every automation task owns one :class:`AdbTaskRegistry` (stored on its :class:`~artemis.context.ArtemisContext`). The registry holds the running background processes, the recently finished task logs, and the persistent terminal environments. Keeping this per context matters because the MCP daemon runs concurrent tasks for different devices inside one process: a module-level registry would leak device A's tasks and completion notices into device B's Operator prompt. A module-level default registry still exists for callers that have no context (tests, ad-hoc scripts). Hang protection --------------- Commands are passed to ``adb shell`` as an argument, never through stdin, so the remote shell exits as soon as the script does. ``stdin`` is closed (``DEVNULL``) unless the caller asks for ``Interactive=True``; a command that reads stdin therefore sees EOF instead of blocking forever. Anything that outlives ``WaitMsBeforeAsync`` is moved to a background task with a bounded log buffer, and :func:`shutdown_adb_background_tasks` kills whatever is still running when the automation task ends. """ import asyncio from asyncio.subprocess import Process as AsyncProcess from collections import deque import os import shlex from typing import Any, Literal import uuid from langchain_core.tools import BaseTool from pydantic import BaseModel, Field from artemis.agents.log_analyzer.output_analyzer import TaskOutputAnalyzerNode from artemis.context import ArtemisContext from artemis.controllers.platform_specific_commands_controller import ( get_adb_device, ) from artemis.data_engine.trace import trace_langchain_tool from artemis.drivers.base import BaseDeviceDriver from artemis.tools.base import ArtemisTool, ToolRegistry from artemis.tools.tool_wrapper import ToolWrapper from artemis.utils.logger import get_logger logger = get_logger(__name__) #: Maximum number of finished task records kept per registry. _FINISHED_TASKS_CAP = 50 #: Maximum number of output lines retained for a background task. _BACKGROUND_LOG_MAX_LINES = 20000 #: Seconds to wait for a terminated process before killing it. _TERMINATE_GRACE_SECONDS = 3.0 _EXIT_CODE_MARKER = "===EXIT_CODE===" _ENV_START_MARKER = "===ENV_START===" #: Variables that every Android shell already defines. They are never #: re-exported into a persistent terminal: re-exporting PATH/HOME etc. is #: pointless and some of them (BOOTCLASSPATH, LD_*) are long and fragile. _PERSISTENT_ENV_EXCLUDE = frozenset( { "PATH", "HOME", "HOSTNAME", "USER", "LOGNAME", "SHELL", "TERM", "PWD", "OLDPWD", "SHLVL", "_", "TMPDIR", "HISTFILE", "LD_LIBRARY_PATH", "LD_PRELOAD", "EXTERNAL_STORAGE", "ASEC_MOUNTPOINT", "DOWNLOAD_CACHE", } ) _PERSISTENT_ENV_EXCLUDE_PREFIXES = ( "ANDROID_", "BOOTCLASSPATH", "DEX2OAT", "SYSTEMSERVER", "STANDALONE_", ) #: Guidance shared by the tool descriptions: the commands that hang an #: ``adb shell`` and how to bound them. HANG_GUIDANCE = ( "Streaming or open-ended commands (logcat, top, getevent, screenrecord," " tail -f, an interactive shell) never return on their own: bound them" " (logcat -d, top -n 1, --time-limit) or expect them to be moved to a" " background task. Detach daemons explicitly with" " `nohup CMD >/dev/null 2>&1 &`, otherwise the shell waits for them." ) def _adb_binary() -> str: """Resolves the adb executable through the shared toolchain resolver.""" try: from artemis.toolchain import toolchain return toolchain.find_adb() except Exception: # pylint: disable=broad-exception-caught return "adb" def _is_output_long(output: str) -> bool: """Checks if command output exceeds the max threshold for inline display.""" return len(output.splitlines()) > 500 or len(output) > 50000 def _format_long_output_response(_task_id: str, output: str, intro: str) -> str: """Formats truncated output with guidance to use the analyzer tool.""" lines = output.splitlines() last_200 = "\n".join(lines[-200:]) return ( f"{intro}\nWarning: Output is too long ({len(lines)} lines," f" {len(output)} chars) and has been truncated.\n--- Last 200 lines of" f" output ---\n{last_200}\n------------------------\nYou can use the" " 'analyze_task_output' tool to get more information." ) def _filter_persistent_env(env: dict[str, str]) -> dict[str, str]: """Drops the platform-provided variables from a captured shell environment.""" kept: dict[str, str] = {} for key, value in env.items(): if not key or key in _PERSISTENT_ENV_EXCLUDE: continue if key.startswith(_PERSISTENT_ENV_EXCLUDE_PREFIXES): continue if not key.replace("_", "a").isalnum(): continue kept[key] = value return kept # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- class AdbTaskRegistry: """Per-task bookkeeping for background processes, finished logs and envs.""" def __init__(self) -> None: self.background: dict[str, BackgroundTask] = {} self.finished: dict[str, dict[str, Any]] = {} self.persistent_envs: dict[str, dict[str, str]] = {} # -- finished task cache ------------------------------------------------- # pylint: disable=too-many-arguments,too-many-positional-arguments def register_finished( self, task_id: str, command: str, cwd: str | None, terminal_id: str | None, status: str, exit_code: int | None, output: str, ) -> None: """Caches execution details for a finished task.""" self.finished[task_id] = { "task_id": task_id, "command": command, "cwd": cwd, "terminal_id": terminal_id, "status": status, "exit_code": exit_code, "output": output, "notified": False, } while len(self.finished) > _FINISHED_TASKS_CAP: oldest_key = next(iter(self.finished)) del self.finished[oldest_key] def get_task_info(self, task_id: str) -> dict[str, Any] | None: """Returns metadata and logs for a running or finished task, if known.""" if task_id in self.background: t = self.background[task_id] return { "command": t.command, "cwd": t.cwd, "terminal_id": t.terminal_id, "status": t.status, "output": "".join(t.stdout_log), } return self.finished.get(task_id) # -- prompt helpers ------------------------------------------------------ def active_task_summaries(self) -> list[dict[str, Any]]: """Compact view of running tasks for the Operator prompt.""" return [ { "task_id": tid, "command": t.command, "cwd": t.cwd, "terminal_id": t.terminal_id, "output_line_count": len(t.stdout_log), } for tid, t in self.background.items() ] def pop_unnotified_finished(self) -> list[dict[str, Any]]: """Returns finished tasks not yet shown to the model and marks them shown.""" newly_finished: list[dict[str, Any]] = [] for tid, tinfo in self.finished.items(): if tinfo.get("notified", False): continue newly_finished.append( { "task_id": tid, "command": tinfo.get("command", ""), "status": tinfo.get("status", "completed"), "output_text": tinfo.get("output", ""), } ) tinfo["notified"] = True return newly_finished # -- lifecycle ----------------------------------------------------------- async def shutdown(self, ctx: ArtemisContext | None = None) -> int: """Terminates every running background task. Returns the number killed.""" tasks = list(self.background.values()) for task in tasks: try: await task.stop(ctx, status="killed") except Exception as e: # pylint: disable=broad-exception-caught logger.warning(f"Failed to stop background ADB task {task.task_id}: {e}") return len(tasks) #: Registry used when no context is available. _DEFAULT_REGISTRY = AdbTaskRegistry() # Backward-compatibility aliases onto the default registry's dictionaries. _BACKGROUND_TASKS = _DEFAULT_REGISTRY.background _FINISHED_TASKS_LOGS = _DEFAULT_REGISTRY.finished _PERSISTENT_ENVIRONMENTS = _DEFAULT_REGISTRY.persistent_envs def get_adb_task_registry(ctx: ArtemisContext | None) -> AdbTaskRegistry: """Returns the registry bound to ``ctx``, creating it on first use.""" if ctx is None: return _DEFAULT_REGISTRY existing = getattr(ctx, "adb_task_registry", None) if isinstance(existing, AdbTaskRegistry): return existing registry = AdbTaskRegistry() try: setattr(ctx, "adb_task_registry", registry) except (AttributeError, TypeError): return _DEFAULT_REGISTRY return registry async def shutdown_adb_background_tasks(ctx: ArtemisContext | None) -> int: """Kills the background ADB tasks of ``ctx``. Safe to call when none exist.""" existing = getattr(ctx, "adb_task_registry", None) if ctx is not None else None if not isinstance(existing, AdbTaskRegistry): return 0 killed = await existing.shutdown(ctx) if killed: logger.info(f"Terminated {killed} background ADB task(s) at task end.") return killed # pylint: disable=too-many-arguments,too-many-positional-arguments def _register_finished_task( task_id: str, command: str, cwd: str | None, terminal_id: str | None, status: str, exit_code: int | None, output: str, ctx: ArtemisContext | None = None, ): """Caches execution details for finished tasks (registry of ``ctx``).""" get_adb_task_registry(ctx).register_finished( task_id, command, cwd, terminal_id, status, exit_code, output ) def _get_task_info(task_id: str, ctx: ArtemisContext | None) -> dict[str, Any] | None: """Retrieves metadata and logs for a running or finished task. Looks in the context registry, then the default registry, then the persisted background task table. """ registry = get_adb_task_registry(ctx) info = registry.get_task_info(task_id) if info is None and registry is not _DEFAULT_REGISTRY: info = _DEFAULT_REGISTRY.get_task_info(task_id) if info is not None: return info if ctx and ctx.data_engine: db_tasks = ctx.data_engine.get_all_background_tasks() for db_t in db_tasks: if db_t.get("task_id") == task_id: return { "command": db_t.get("summary") or "", "cwd": None, "terminal_id": None, "status": db_t.get("status") or "completed", "output": db_t.get("logs") or "", } return None # --------------------------------------------------------------------------- # Background task # --------------------------------------------------------------------------- # pylint: disable=too-many-instance-attributes class BackgroundTask: """Represents an active or completing background ADB shell task.""" # pylint: disable=too-many-arguments,too-many-positional-arguments def __init__( self, task_id: str, command: str, process: AsyncProcess, terminal_id: str | None = None, cwd: str | None = None, interactive: bool = False, registry: AdbTaskRegistry | None = None, ): self.task_id = task_id self.command = command self.process = process self.terminal_id = terminal_id self.cwd = cwd self.interactive = interactive self.registry = registry self.stdout_log: deque[str] = deque(maxlen=_BACKGROUND_LOG_MAX_LINES) self.status = "running" self.exit_code: int | None = None self.trace_id: uuid.UUID | None = None self.listener_task: asyncio.Task | None = None self._stop_requested = False self._finalized = False async def start(self, ctx: ArtemisContext): """Starts the background output listener task.""" self.listener_task = asyncio.create_task(self._listen(ctx)) async def stop(self, ctx: ArtemisContext | None, status: str = "killed") -> None: """Terminates the process (kill after a grace period) and records the result.""" self._stop_requested = True self.status = status proc = self.process if getattr(proc, "returncode", None) is None: try: proc.terminate() except (ProcessLookupError, OSError): pass try: await asyncio.wait_for(proc.wait(), timeout=_TERMINATE_GRACE_SECONDS) except TimeoutError: try: proc.kill() except (ProcessLookupError, OSError): pass try: await asyncio.wait_for(proc.wait(), timeout=_TERMINATE_GRACE_SECONDS) except TimeoutError: pass except Exception as exc: # pylint: disable=broad-exception-caught # Shutdown must never raise; the exit code is read back below. logger.debug( f"Waiting for background ADB task {self.task_id} to exit failed: {exc}", exc_info=True, ) if self.listener_task is not None and not self.listener_task.done(): self.listener_task.cancel() try: await self.listener_task except asyncio.CancelledError: pass self.exit_code = getattr(proc, "returncode", None) self._finalize(ctx) def _finalize(self, ctx: ArtemisContext | None) -> None: """Moves the task from the active map to the finished cache exactly once.""" if self._finalized: return self._finalized = True registry = self.registry or get_adb_task_registry(ctx) output_text = "".join(self.stdout_log) _, parsed_env, _ = self.parse_output(output_text) if self.terminal_id and parsed_env is not None: registry.persistent_envs[self.terminal_id] = _filter_persistent_env(parsed_env) logger.info( "Updated persistent env for Android terminal" f" '{self.terminal_id}' from background task." ) if ctx and getattr(ctx, "data_engine", None): try: ctx.data_engine.unregister_background_task(self.task_id, self.status) except Exception as e: # pylint: disable=broad-exception-caught logger.debug(f"Failed to unregister background task {self.task_id}: {e}") registry.register_finished( task_id=self.task_id, command=self.command, cwd=self.cwd, terminal_id=self.terminal_id, status=self.status, exit_code=self.exit_code, output=output_text, ) registry.background.pop(self.task_id, None) async def _listen(self, ctx: ArtemisContext): """Reads process output streams and updates lifecycle status.""" try: while True: line = await self.process.stdout.readline() if not line: break decoded = line.decode(errors="replace") self.stdout_log.append(decoded) if ctx and getattr(ctx, "data_engine", None) and self.trace_id: ctx.data_engine.stream_output(self.trace_id, decoded) self.exit_code = await self.process.wait() if self._stop_requested: return _, _, script_exit = self.parse_output("".join(self.stdout_log)) if self.terminal_id and script_exit is not None: self.exit_code = script_exit self.status = "completed" if self.exit_code == 0 else "failed" logger.info( f"Background ADB task {self.task_id} completed with exit code {self.exit_code}" ) except asyncio.CancelledError: return except Exception as e: # pylint: disable=broad-exception-caught logger.error(f"Error in background task {self.task_id} listener: {e}") self.status = "error" finally: if not self._stop_requested: self._finalize(ctx) @staticmethod def parse_output(output: str) -> tuple[str, dict[str, str] | None, int | None]: """Separates stdout lines from the exit-code marker and exported env. Returns ``(clean_output, env_or_None, script_exit_code_or_None)``. """ clean_lines: list[str] = [] env_lines: list[str] = [] exit_code: int | None = None in_env = False for line in output.splitlines(): if _EXIT_CODE_MARKER in line: raw = line.split(_EXIT_CODE_MARKER, 1)[1].strip() try: exit_code = int(raw) except ValueError: exit_code = None continue if _ENV_START_MARKER in line: in_env = True continue if in_env: env_lines.append(line) else: clean_lines.append(line) clean_output = "\n".join(clean_lines) parsed_env: dict[str, str] | None = None if in_env: parsed_env = {} for el in env_lines: if "=" in el: k, v = el.split("=", 1) parsed_env[k] = v return clean_output, parsed_env, exit_code # Backwards compatibility alias _parse_output = parse_output # --------------------------------------------------------------------------- # run_adb_command # --------------------------------------------------------------------------- class RunAdbCommandArgs(BaseModel): """Arguments schema for executing ADB shell commands.""" CommandLine: str = Field( ..., description=( "The exact shell command line string to execute inside the Android device shell." ), ) Cwd: str = Field( default="/data/local/tmp", description="The directory on the Android device to run the command in.", ) RunPersistent: bool = Field( default=False, description=( "Set to true to run this command in a persistent terminal session" " that preserves environment variables between invocations on the" " phone." ), ) RequestedTerminalID: str | None = Field( default=None, description=( "Optional ID of a persistent terminal to reuse. Reuses environment" " variables if provided." ), ) WaitMsBeforeAsync: int = Field( default=5000, description=( "Number of milliseconds to wait for the command to finish" " synchronously before sending it to the background." ), ) Interactive: bool = Field( default=False, description=( "Keep stdin open so 'manage_task send_input' can feed the command" " later. Default false: stdin is closed and commands that read it" " see EOF instead of hanging." ), ) def _build_phone_script( cmd_line: str, cwd: str, env_vars: dict[str, str], run_persistent: bool, ) -> str: """Builds the script passed as the single ``adb shell`` argument.""" script = f"cd {shlex.quote(cwd)}\n" for k, v in env_vars.items(): script += f"export {k}={shlex.quote(v)}\n" script += f"{cmd_line}\n" if run_persistent: script += ( "_artemis_ec=$?\n" f'echo "{_EXIT_CODE_MARKER}$_artemis_ec"\n' f'echo "{_ENV_START_MARKER}"\n' "env\n" ) return script class RunAdbCommandTool(ArtemisTool): """Universal tool for executing ADB shell commands on an Android device.""" def __init__(self): super().__init__( name="run_adb_command", description=( "[SHELL] Executes a shell command directly on the Android mobile " "device via ADB shell. Runs synchronously and transitions to a " "background task if execution takes longer than WaitMsBeforeAsync. " "Supports persistent environments (environment variables) on the " f"phone across invocations. {HANG_GUIDANCE}" ), args_schema=RunAdbCommandArgs, category="system", ) # pylint: disable=too-many-arguments,too-many-locals,too-many-branches,too-many-statements,too-many-return-statements,too-many-positional-arguments,too-many-boolean-expressions async def execute( self, driver: BaseDeviceDriver | None = None, ctx: ArtemisContext | None = None, CommandLine: str | None = None, Cwd: str = "/data/local/tmp", RunPersistent: bool = False, RequestedTerminalID: str | None = None, WaitMsBeforeAsync: int = 5000, Interactive: bool = False, **kwargs: Any, ) -> str: # Extract command line supporting alternative key formats cmd_line = ( CommandLine if CommandLine is not None else (kwargs.get("command_line") or kwargs.get("command") or kwargs.get("cmd") or "") ) cwd = Cwd or kwargs.get("cwd") or "/data/local/tmp" run_persistent = ( RunPersistent if RunPersistent is not False else kwargs.get("run_persistent", False) ) requested_terminal_id = ( RequestedTerminalID if RequestedTerminalID is not None else kwargs.get("requested_terminal_id") ) wait_ms = ( WaitMsBeforeAsync if WaitMsBeforeAsync != 5000 else kwargs.get("wait_ms_before_async", WaitMsBeforeAsync) ) interactive = bool(Interactive or kwargs.get("interactive", False)) wait_seconds = max(float(wait_ms) / 1000.0, 0.05) # Fallback to driver's execute_shell if no full ArtemisContext is available if ( (ctx is None or not hasattr(ctx, "device") or not ctx.device) and driver is not None and hasattr(driver, "execute_shell") and not run_persistent ): return await driver.execute_shell(cmd_line, timeout_seconds=wait_seconds) registry = get_adb_task_registry(ctx) device_id = None if ctx and hasattr(ctx, "device") and ctx.device: device_id = getattr(ctx.device, "device_id", None) elif driver and hasattr(driver, "device_id"): device_id = getattr(driver, "device_id", None) if not device_id: device_id = "default_device" # Prepare environment terminal_id = None run_env = os.environ.copy() # Local host env to run adb command android_env_vars: dict[str, str] = {} if run_persistent: terminal_id = requested_terminal_id or f"term_{uuid.uuid4().hex[:8]}" android_env_vars = registry.persistent_envs.setdefault(terminal_id, {}) phone_script = _build_phone_script(cmd_line, cwd, android_env_vars, run_persistent) # In Cloud Mode or when adb client is virtualized: no host subprocess, # therefore no background handoff. The wait window is a hard timeout. adb_client = getattr(ctx, "adb_client", None) if ctx else None if os.environ.get("ARTEMIS_CLOUD_MODE") == "1" or ( adb_client is not None and hasattr(adb_client, "_bridge") ): try: device = get_adb_device(ctx) output = await asyncio.wait_for( asyncio.to_thread(device.shell, phone_script), timeout=wait_seconds, ) except TimeoutError: return ( f"Error: Command did not finish within {wait_seconds:.1f}s." " Background tasks are not available on this device" " connection; bound the command (logcat -d, top -n 1," " --time-limit) and retry." ) except Exception as e: # pylint: disable=broad-exception-caught logger.error(f"Failed to execute virtual adb shell for '{cmd_line}': {e}") return f"Failed to execute adb command: {e}" clean_output, new_env, script_exit = BackgroundTask.parse_output(str(output)) if run_persistent and new_env is not None: registry.persistent_envs[terminal_id] = _filter_persistent_env(new_env) intro = "ADB command completed" if script_exit is not None: intro += f" with exit code {script_exit}" intro += "." if run_persistent: intro += f" TerminalID: {terminal_id}." body = clean_output if clean_output.strip() else "(empty output)" return f"{intro}\nOutput:\n{body}" # We spawn the host subprocess: adb -s shell