feat: initial release of ARTEMIS Android Agent & Automation Platform

ARTEMIS is an autonomous multimodal Android agent and mobile UI automation
platform engineered for physical devices and emulators. It combines state-of-the-art
multimodal Vision-Language Models (VLMs) with hierarchical closed-loop planning,
resilient self-healing, and deep Android subsystem integration.

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

Change-Id: I430b93ee2b76b54cc5a3c6eeccd15d2cf29830a1
This commit is contained in:
somew1nd committed 2026-08-13 11:46:47 -07:00
commit 206a9ce921
229 files changed
+49653

No files matched your search

+836
View File
@@ -0,0 +1,836 @@
# 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."""
import asyncio
from asyncio.subprocess import Process as AsyncProcess
import os
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__)
# Global persistent Android shell environments
_PERSISTENT_ENVIRONMENTS: dict[str, dict[str, str]] = {}
# Global background tasks
_BACKGROUND_TASKS: dict[str, "BackgroundTask"] = {}
# Finished tasks history logs cache (to support Action='analyze' even after tasks are unregistered)
_FINISHED_TASKS_LOGS: dict[str, dict[str, Any]] = {}
# 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,
):
"""Caches execution details for finished tasks."""
_FINISHED_TASKS_LOGS[task_id] = {
"task_id": task_id,
"command": command,
"cwd": cwd,
"terminal_id": terminal_id,
"status": status,
"exit_code": exit_code,
"output": output,
"notified": False,
}
# Keep memory bounded
if len(_FINISHED_TASKS_LOGS) > 10:
oldest_key = next(iter(_FINISHED_TASKS_LOGS))
del _FINISHED_TASKS_LOGS[oldest_key]
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 _get_task_info(task_id: str, ctx: ArtemisContext) -> dict[str, Any] | None:
"""Retrieves metadata and logs for a running or finished task."""
if task_id in _BACKGROUND_TASKS:
t = _BACKGROUND_TASKS[task_id]
return {
"command": t.command,
"cwd": t.cwd,
"terminal_id": t.terminal_id,
"status": t.status,
"output": "".join(t.stdout_log),
}
if task_id in _FINISHED_TASKS_LOGS:
return _FINISHED_TASKS_LOGS[task_id]
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
# 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,
):
self.task_id = task_id
self.command = command
self.process = process
self.terminal_id = terminal_id
self.cwd = cwd
self.stdout_log: list[str] = []
self.status = "running"
self.exit_code: int | None = None
self.trace_id: uuid.UUID | None = None
self.listener_task: asyncio.Task | None = None
async def start(self, ctx: ArtemisContext):
"""Starts the background output listener task."""
self.listener_task = asyncio.create_task(self._listen(ctx))
async def _listen(self, ctx: ArtemisContext):
"""Reads process output streams and updates lifecycle status."""
try:
# Read stdout/stderr merged
while True:
line = await self.process.stdout.readline()
if not line:
break
decoded = line.decode()
self.stdout_log.append(decoded)
if ctx and ctx.data_engine and getattr(self, "trace_id", None):
ctx.data_engine.stream_output(self.trace_id, decoded)
self.exit_code = await self.process.wait()
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}"
)
# Post-process output to extract env if persistent
output_text = "".join(self.stdout_log)
_, parsed_env = self.parse_output(output_text)
if self.terminal_id and parsed_env:
_PERSISTENT_ENVIRONMENTS[self.terminal_id] = parsed_env
logger.info(
"Updated persistent env for Android terminal"
f" '{self.terminal_id}' from background task."
)
# Update status in DataEngine
if ctx and ctx.data_engine:
ctx.data_engine.unregister_background_task(
task_id=self.task_id,
status=self.status,
)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Error in background task {self.task_id} listener: {e}")
self.status = "error"
if ctx and ctx.data_engine:
ctx.data_engine.unregister_background_task(self.task_id, "failed")
finally:
# Cache logs in memory before removing task from active tasks
full_output = "".join(self.stdout_log)
_register_finished_task(
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=full_output,
)
if self.task_id in _BACKGROUND_TASKS:
del _BACKGROUND_TASKS[self.task_id]
@staticmethod
def parse_output(output: str) -> tuple[str, dict[str, str] | None]:
"""Separates stdout lines from exported environment variables."""
clean_lines = []
env_lines = []
in_env = False
for line in output.splitlines():
if "===EXIT_CODE===" in line:
continue
if "===ENV_START===" 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 = None
if env_lines:
parsed_env = {}
for el in env_lines:
if "=" in el:
k, v = el.split("=", 1)
parsed_env[k] = v
return clean_output, parsed_env
# Backwards compatibility alias
_parse_output = parse_output
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."
),
)
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. Can run synchronously or transition to a "
"background task if execution takes longer than WaitMsBeforeAsync. "
"Supports persistent environments (environment variables) on the "
"phone across invocations."
),
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,
**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)
)
# 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)
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 = {}
if run_persistent:
terminal_id = requested_terminal_id or f"term_{uuid.uuid4().hex[:8]}"
if terminal_id in _PERSISTENT_ENVIRONMENTS:
android_env_vars = _PERSISTENT_ENVIRONMENTS[terminal_id]
else:
_PERSISTENT_ENVIRONMENTS[terminal_id] = android_env_vars
# Construct command to run inside adb shell
# We inject the cached environment variables by prepending 'export K=V' declarations
env_prepends = ""
for k, v in android_env_vars.items():
env_prepends += f"export {k}='{v}'\n"
# Construct final command script
phone_script = f"cd '{cwd}'\n" + env_prepends + f"{cmd_line}\n"
if run_persistent:
phone_script += (
'_exit_code=$?\necho "===EXIT_CODE===$_exit_code"\necho "===ENV_START==="\nenv'
)
# In Cloud Mode or when adb client is virtualized
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 = device.shell(phone_script)
clean_output, new_env = BackgroundTask.parse_output(output)
if run_persistent and new_env:
android_env_vars.update(new_env)
return (
clean_output
if clean_output.strip()
else "Command executed successfully with empty output."
)
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}"
# We spawn the host subprocess: adb -s <serial> shell
# Pass phone_script as an argument so it doesn't hold the shell open if we keep stdin open.
try:
process = await asyncio.create_subprocess_exec(
"adb",
"-s",
device_id,
"shell",
phone_script,
cwd=os.getcwd(),
env=run_env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.PIPE,
)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to spawn adb shell process for command '{cmd_line}': {e}")
return f"Failed to spawn adb process: {e}"
# Wait for wait_ms milliseconds
wait_seconds = wait_ms / 1000.0
try:
stdout_data = []
async def read_limit():
while True:
line = await process.stdout.readline()
if not line:
break
stdout_data.append(line.decode())
await process.wait()
await asyncio.wait_for(read_limit(), timeout=wait_seconds)
# If we reached here, the process completed within the wait window
exit_code = process.returncode
output_text = "".join(stdout_data)
# Clean output and extract environment
clean_output = output_text
parsed_env = None
if run_persistent:
clean_output, parsed_env = BackgroundTask.parse_output(output_text)
if parsed_env:
_PERSISTENT_ENVIRONMENTS[terminal_id] = parsed_env
intro = f"ADB command completed with exit code {exit_code}."
if run_persistent:
intro += f" TerminalID: {terminal_id}."
if _is_output_long(clean_output):
task_id = f"task_sync_{uuid.uuid4().hex[:8]}"
intro += f" TaskId: {task_id}."
_register_finished_task(
task_id=task_id,
command=cmd_line,
cwd=cwd,
terminal_id=terminal_id,
status="completed" if exit_code == 0 else "failed",
exit_code=exit_code,
output=clean_output,
)
return _format_long_output_response(task_id, clean_output, intro)
return f"{intro}\nOutput:\n{clean_output}"
except TimeoutError:
# Process did not finish in time, move to background
task_id = f"task_{process.pid}"
trace_id = uuid.uuid4()
bg_task = BackgroundTask(
task_id=task_id,
command=cmd_line,
process=process,
terminal_id=terminal_id,
cwd=cwd,
)
bg_task.stdout_log = list(stdout_data)
bg_task.trace_id = trace_id
_BACKGROUND_TASKS[task_id] = bg_task
# Register in DataEngine
if ctx and ctx.data_engine:
ctx.data_engine.register_background_task(
task_id=task_id,
summary=f"ADB Command: {cmd_line[:50]}...",
trace_id=trace_id,
)
if stdout_data:
ctx.data_engine.stream_output(trace_id, "".join(stdout_data))
if ctx:
await bg_task.start(ctx)
response_msg = f"ADB command was sent to the background as a task.\nTaskId: {task_id}\n"
if run_persistent:
response_msg += f"TerminalID: {terminal_id}\n"
response_msg += "Use the 'manage_task' tool to check its status or terminate it."
return response_msg
# Universal tool instance & aliases
run_adb_command = RunAdbCommandTool()
RunAdbCommand = RunAdbCommandTool
ToolRegistry.register(run_adb_command)
def get_run_adb_command_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports run_adb_command as a LangChain BaseTool."""
return trace_langchain_tool(run_adb_command.to_langchain_tool(ctx), ctx)
class ManageTaskArgs(BaseModel):
"""Arguments schema for managing background ADB tasks."""
Action: Literal["list", "list_finished", "kill", "status", "send_input"] = Field(
...,
description=(
"The action to perform: 'list' (list running tasks),"
" 'list_finished' (list completed tasks), 'kill', 'status',"
" 'send_input'."
),
)
TaskId: str | None = Field(
default=None,
description=("The task ID to manage (required for kill, status, and send_input)."),
)
Input: str | None = Field(
default=None,
description=("The input to send to the task (required when Action is 'send_input')."),
)
class ManageTaskTool(ArtemisTool):
"""Universal tool for managing background ADB shell tasks."""
def __init__(self):
super().__init__(
name="manage_task",
description=("[SHELL] Manage background ADB shell tasks launched via run_adb_command."),
args_schema=ManageTaskArgs,
category="system",
)
# pylint: disable=too-many-arguments,too-many-locals,too-many-branches,too-many-statements,too-many-return-statements,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
Action: Literal["list", "list_finished", "kill", "status", "send_input"] = "list",
TaskId: str | None = None,
Input: str | None = None,
**kwargs: Any,
) -> str:
action = Action or kwargs.get("action", "list")
task_id = TaskId if TaskId is not None else kwargs.get("task_id")
input_str = Input if Input is not None else kwargs.get("input")
if action == "list":
active_tasks = []
for tid, t in _BACKGROUND_TASKS.items():
active_tasks.append(
f"- {tid}: Command='{t.command[:40]}...', Phone"
f" Cwd='{t.cwd}', TerminalID='{t.terminal_id}'"
)
if not active_tasks:
return "No active background tasks running."
return "Active background tasks:\n" + "\n".join(active_tasks)
if action == "list_finished":
finished_tasks = []
for tid, t in _FINISHED_TASKS_LOGS.items():
finished_tasks.append(
f"- {tid}: Command='{t['command'][:40]}...', Status='{t['status']}'"
)
if not finished_tasks:
return "No recently finished tasks."
return "Recently finished tasks (up to 10):\n" + "\n".join(finished_tasks)
if not task_id:
return "Error: TaskId is required for status, kill, or send_input actions."
task = _BACKGROUND_TASKS.get(task_id)
if action == "status":
task_info = _get_task_info(task_id, ctx)
if not task_info:
return f"Task {task_id} not found."
output_text = task_info.get("output", "")
if "===ENV_START===" in output_text:
output_text = output_text.split("===ENV_START===")[0]
intro = (
f"Task: {task_id}\n"
f"Status: {task_info.get('status')}\n"
f"Command: {task_info.get('command')}\n"
f"Cwd: {task_info.get('cwd')}\n"
f"TerminalID: {task_info.get('terminal_id')}"
)
if _is_output_long(output_text):
return _format_long_output_response(task_id, output_text, intro)
return f"{intro}\nAccumulated output:\n{output_text}"
if action == "kill":
if not task:
return f"Task {task_id} is not active or already finished."
try:
task.process.terminate()
await task.process.wait()
task.status = "killed"
if ctx and ctx.data_engine:
ctx.data_engine.unregister_background_task(task_id, "killed")
if task_id in _BACKGROUND_TASKS:
del _BACKGROUND_TASKS[task_id]
return f"Task {task_id} successfully terminated."
except Exception as e: # pylint: disable=broad-exception-caught
return f"Failed to terminate task {task_id}: {e}"
if action == "send_input":
if not task:
return f"Task {task_id} is not active."
if not input_str:
return "Error: Input is required for send_input action."
try:
task.process.stdin.write(input_str.encode())
await task.process.stdin.drain()
return f"Input successfully sent to task {task_id}."
except Exception as e: # pylint: disable=broad-exception-caught
return f"Failed to send input: {e}"
return "Unsupported action."
# Universal tool instance & aliases
manage_task = ManageTaskTool()
ManageTask = ManageTaskTool
ToolRegistry.register(manage_task)
def get_manage_task_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports manage_task as a LangChain BaseTool."""
return trace_langchain_tool(manage_task.to_langchain_tool(ctx), ctx)
class RunShortAdbCommandArgs(BaseModel):
"""Arguments schema for short ADB shell commands."""
CommandLine: str = Field(
...,
description=(
"The exact shell command line string to execute inside the Android device shell."
),
)
class RunShortAdbCommandTool(ArtemisTool):
"""Universal tool for executing short, synchronous ADB shell commands."""
def __init__(self, name: str = "run_short_adb_command"):
super().__init__(
name=name,
description=(
"[SHELL] Executes a short, synchronous shell command directly on the Android "
"mobile device via ADB shell. Returns the command output. Times out after 3 "
"seconds. Do not use this tool to execute actions that change the phone state "
"such as clicking, swiping, or navigating back."
),
args_schema=RunShortAdbCommandArgs,
category="system",
)
# pylint: disable=too-many-locals,too-many-return-statements
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
CommandLine: str | None = None,
**kwargs: Any,
) -> str:
cmd_line = (
CommandLine
if CommandLine is not None
else (kwargs.get("command_line") or kwargs.get("command") or kwargs.get("cmd") or "")
)
# 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")
):
return await driver.execute_shell(cmd_line, timeout_seconds=3.0)
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"
run_env = os.environ.copy()
phone_script = f"cd /data/local/tmp\n{cmd_line}\n"
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 = device.shell(phone_script)
return f"ADB command completed.\nOutput:\n{output}"
except Exception as e: # pylint: disable=broad-exception-caught
return f"Error running command: {e}"
try:
process = await asyncio.create_subprocess_exec(
"adb",
"-s",
device_id,
"shell",
phone_script,
cwd=os.getcwd(),
env=run_env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.PIPE,
)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to spawn adb shell process for command '{cmd_line}': {e}")
return f"Failed to spawn adb process: {e}"
try:
stdout_data, _ = await asyncio.wait_for(process.communicate(), timeout=3.0)
exit_code = process.returncode
output_text = stdout_data.decode()
return f"ADB command completed with exit code {exit_code}.\nOutput:\n{output_text}"
except TimeoutError:
try:
process.terminate()
await process.wait()
except Exception: # pylint: disable=broad-exception-caught
pass
return "Error: Command timed out after 3 seconds."
except Exception as e: # pylint: disable=broad-exception-caught
return f"Error running command: {e}"
# Universal tool instance & aliases
run_short_adb_command = RunShortAdbCommandTool()
RunShortAdbCommand = RunShortAdbCommandTool
ToolRegistry.register(run_short_adb_command)
def get_run_short_adb_command_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports run_short_adb_command as a LangChain BaseTool."""
return trace_langchain_tool(
run_short_adb_command.to_langchain_tool(ctx, name="run_adb_command"),
ctx,
)
# Expose Wrappers
run_adb_command_wrapper = ToolWrapper(
tool_fn_getter=get_run_adb_command_tool,
on_success_fn=lambda output: output,
on_failure_fn=lambda err: f"ADB command failed: {err}",
)
manage_task_wrapper = ToolWrapper(
tool_fn_getter=get_manage_task_tool,
on_success_fn=lambda output: output,
on_failure_fn=lambda err: f"Task management failed: {err}",
)
class AnalyzeTaskOutputArgs(BaseModel):
"""Arguments schema for output analysis requests."""
TaskId: str = Field(
...,
description="The task ID whose output you want to analyze.",
)
Query: str = Field(
...,
description=(
"The question or query to ask the output analyzer about the task's full output log."
),
)
class AnalyzeTaskOutputTool(ArtemisTool):
"""Universal tool for analyzing the full, long output of an ADB task."""
def __init__(self):
super().__init__(
name="analyze_task_output",
description=(
"[ANALYSIS] Analyzes the full, long output of a completed or running ADB task. "
"Use this tool when you receive a message indicating that a task's output "
"is too long and was truncated."
),
args_schema=AnalyzeTaskOutputArgs,
category="system",
)
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
TaskId: str | None = None,
Query: str | None = None,
**kwargs: Any,
) -> str:
task_id = TaskId or kwargs.get("task_id") or ""
query = Query or kwargs.get("query") or ""
task_info = _get_task_info(task_id, ctx)
if not task_info:
return f"Error: Task {task_id} not found."
analyzer = TaskOutputAnalyzerNode(ctx)
result = await analyzer.run(
command=task_info.get("command", ""),
output_text=task_info.get("output", ""),
query=query,
)
return f"Analysis result for Task {task_id}:\n{result}"
# Universal tool instance & aliases
analyze_task_output = AnalyzeTaskOutputTool()
AnalyzeTaskOutput = AnalyzeTaskOutputTool
ToolRegistry.register(analyze_task_output)
def get_analyze_task_output_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports analyze_task_output as a LangChain BaseTool."""
return trace_langchain_tool(analyze_task_output.to_langchain_tool(ctx), ctx)
analyze_task_output_wrapper = ToolWrapper(
tool_fn_getter=get_analyze_task_output_tool,
on_success_fn=lambda output: output,
on_failure_fn=lambda err: f"Task output analysis failed: {err}",
)