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

+45
View File
@@ -0,0 +1,45 @@
# 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 device action tools."""
from artemis.tools.actions.device_actions import (
ClickArgs,
InputTextArgs,
LongPressArgs,
PressKeyArgs,
SwipeArgs,
WaitForDelayArgs,
click,
input_text,
long_press,
press_key,
swipe,
wait_for_delay,
)
__all__ = [
"click",
"long_press",
"input_text",
"swipe",
"press_key",
"wait_for_delay",
"ClickArgs",
"LongPressArgs",
"InputTextArgs",
"SwipeArgs",
"PressKeyArgs",
"WaitForDelayArgs",
]
+237
View File
@@ -0,0 +1,237 @@
# 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 device action tools implemented on top of BaseDeviceDriver."""
import asyncio
from typing import Any, Literal
from artemis.drivers.base import BaseDeviceDriver
from artemis.tools.base import artemis_tool
from artemis.utils.coordinates import parse_swipe_parameters
from artemis.utils.logger import get_logger
from pydantic import BaseModel, Field
logger = get_logger(__name__)
# Schemas
class ClickArgs(BaseModel):
target: list[int] = Field(
..., description="Click target normalized coordinates [x, y] in 0-1000 scale."
)
class LongPressArgs(BaseModel):
target: list[int] = Field(
..., description="Long press target normalized coordinates [x, y] in 0-1000 scale."
)
duration: int = Field(1000, description="Long press duration in milliseconds (default 1000).")
class InputTextArgs(BaseModel):
text: str = Field(..., description="The text content to input.")
target: list[int] = Field(
..., description="Input target field normalized coordinates [x, y] in 0-1000 scale."
)
clear_exist: bool = Field(
True, description="Whether to clear existing text in the input box before typing."
)
class SwipeArgs(BaseModel):
direction: Literal["up", "down", "left", "right"] | None = Field(
None,
description=(
"Smart directional swipe ('up', 'down', 'left', 'right') for general page"
" scrolling/browsing. Automatically applies safe vectors and a ~40% visual"
" overlap anchor: 'up' (drags bottom-to-top, scrolling down to reveal content below),"
" 'down' (drags top-to-bottom, scrolling up to reveal content above),"
" 'left' (drags right-to-left, scrolling right),"
" 'right' (drags left-to-right, scrolling left)."
),
)
start: list[int] | None = Field(
None,
description=(
"Start normalized coordinates [start_x, start_y] in 0-1000 scale for precise,"
" local interactions (e.g. adjusting sliders, SeekBars, fine range selection, or drag-and-drop)."
),
)
end: list[int] | None = Field(
None,
description=(
"End normalized coordinates [end_x, end_y] in 0-1000 scale for precise,"
" local interactions (e.g. adjusting sliders, SeekBars, fine range selection, or drag-and-drop)."
),
)
action: Literal["up", "down", "left", "right"] | list[int] | None = Field(
None,
description=(
"Backward-compatible swipe gesture: smart direction string ('up', 'down', 'left', 'right')"
" OR precise custom coordinates [start_x, start_y, end_x, end_y] in 0-1000 scale."
),
)
duration: int = Field(
400,
description=(
"Swipe/drag duration in milliseconds (default 400). For drag-and-drop, "
"list reordering, or sliding/adjusting sliders (e.g. volume, brightness, SeekBars), "
"set duration >= 1000 (e.g. 1500)."
),
)
class PressKeyArgs(BaseModel):
key: str = Field(
...,
description="Key name to press (e.g. 'home', 'back', 'enter', 'delete', 'power').",
)
class WaitForDelayArgs(BaseModel):
seconds: float = Field(
..., description="Duration in seconds to wait for animations, pages, or state updates."
)
# Universal Tool Definitions
@artemis_tool(
name="click",
description="[ACTION] Clicks on the specified screen coordinate [x, y] in normalized 0-1000 scale.",
args_schema=ClickArgs,
category="action",
)
async def click(target: list[int], driver: BaseDeviceDriver) -> str:
width, height = driver.screen_size
abs_x = int(target[0] * width / 1000.0)
abs_y = int(target[1] * height / 1000.0)
success = await driver.tap(abs_x, abs_y)
return (
f"Click executed at normalized {target} (pixels: {abs_x}, {abs_y})"
if success
else f"Click failed at {target}"
)
@artemis_tool(
name="long_press",
description="[ACTION] Long presses on the specified screen coordinate [x, y] in normalized 0-1000 scale.",
args_schema=LongPressArgs,
category="action",
)
async def long_press(
target: list[int], duration: int = 1000, driver: BaseDeviceDriver = None
) -> str:
width, height = driver.screen_size
abs_x = int(target[0] * width / 1000.0)
abs_y = int(target[1] * height / 1000.0)
success = await driver.long_press(abs_x, abs_y, duration_ms=duration)
return (
f"Long press executed at normalized {target} for {duration}ms"
if success
else f"Long press failed at {target}"
)
@artemis_tool(
name="input_text",
description="[ACTION] Clicks an input field target [x, y] and types text into it.",
args_schema=InputTextArgs,
category="action",
)
async def input_text(
text: str, target: list[int], clear_exist: bool = True, driver: BaseDeviceDriver = None
) -> str:
# First tap to focus
width, height = driver.screen_size
abs_x = int(target[0] * width / 1000.0)
abs_y = int(target[1] * height / 1000.0)
await driver.tap(abs_x, abs_y)
await asyncio.sleep(0.2)
# Then input text
success = await driver.input_text(text, clear_existing=clear_exist)
return (
f"Input text '{text}' into target {target}"
if success
else f"Failed to input text into {target}"
)
@artemis_tool(
name="swipe",
description=(
"[ACTION] Performs smart directional swipe for browsing, or precise coordinate drag/slider gestures."
),
args_schema=SwipeArgs,
category="action",
)
async def swipe(
action: Any = None,
direction: str | None = None,
start: list[int] | None = None,
end: list[int] | None = None,
duration: int = 400,
driver: BaseDeviceDriver = None,
**kwargs: Any,
) -> str:
params = dict(kwargs)
if action is not None:
params["action"] = action
if direction is not None:
params["direction"] = direction
if start is not None:
params["start"] = start
if end is not None:
params["end"] = end
params["duration"] = duration
kind, target, dur = parse_swipe_parameters(params, default_duration=duration)
if kind == "direction" and isinstance(target, str):
success = await driver.swipe_direction(target, duration_ms=dur)
return f"Swiped {target}" if success else f"Swipe {target} failed"
elif kind == "coords" and isinstance(target, list) and len(target) == 4:
width, height = driver.screen_size
start_x = int(target[0] * width / 1000.0)
start_y = int(target[1] * height / 1000.0)
end_x = int(target[2] * width / 1000.0)
end_y = int(target[3] * height / 1000.0)
success = await driver.swipe(start_x, start_y, end_x, end_y, duration_ms=dur)
return (
f"Swiped from {target[:2]} to {target[2:]}" if success else "Swipe coordinates failed"
)
return f"Invalid swipe action parameter: {action or direction or (start, end)}"
@artemis_tool(
name="press_key",
description="[ACTION] Presses a hardware or system key on the device ('home', 'back', 'enter', 'delete', 'power').",
args_schema=PressKeyArgs,
category="action",
)
async def press_key(key: str, driver: BaseDeviceDriver) -> str:
success = await driver.press_key(key)
return f"Key '{key}' pressed" if success else f"Failed to press key '{key}'"
@artemis_tool(
name="wait_for_delay",
description="[ACTION] Pauses execution for a specified number of seconds to allow UI loading or transition.",
args_schema=WaitForDelayArgs,
category="action",
)
async def wait_for_delay(seconds: float, driver: BaseDeviceDriver = None) -> str:
await asyncio.sleep(seconds)
return f"Waited for {seconds} seconds."
+274
View File
@@ -0,0 +1,274 @@
# 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 Tool Protocol and Registry for ARTEMIS.
Enables tools to be authored once using standard Pydantic schemas and async handlers,
then automatically exported to LangChain Tools (Pro Graph), Google GenAI FunctionDeclarations
(FlashRunner), and MCP Tools (FastMCP) with zero code duplication.
"""
import asyncio
from collections.abc import Callable
import concurrent.futures
import inspect
from typing import Annotated, Any, Literal
from google.genai import types as genai_types
from langchain_core.tools import BaseTool, StructuredTool
from langgraph.prebuilt import InjectedState
from pydantic import BaseModel
from artemis.drivers.base import BaseDeviceDriver
from artemis.drivers.factory import get_driver
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
ToolCategory = Literal["action", "perception", "system", "memory", "custom"]
class ArtemisTool:
"""Unified tool wrapper encapsulating schema, execution logic, and multi-protocol export."""
# pylint: disable=too-many-arguments,too-many-positional-arguments
def __init__(
self,
name: str,
description: str,
args_schema: type[BaseModel],
handler: Callable[..., Any] | None = None,
category: ToolCategory = "action",
):
self.name = name
self.description = description
self.args_schema = args_schema
self.handler = handler
self.category = category
async def execute(self, driver: BaseDeviceDriver, ctx: Any, **kwargs: Any) -> Any:
"""Executes the tool handler with injected driver and context."""
# Inspect handler signature to only pass supported arguments
if self.handler is not None:
sig = inspect.signature(self.handler)
call_kwargs = {}
for param_name in sig.parameters:
if param_name == "driver":
call_kwargs["driver"] = driver
elif param_name in ("ctx", "context"):
call_kwargs[param_name] = ctx
elif param_name in kwargs:
call_kwargs[param_name] = kwargs[param_name]
if inspect.iscoroutinefunction(self.handler):
return await self.handler(**call_kwargs)
return self.handler(**call_kwargs)
raise NotImplementedError("Subclasses must implement execute() or provide a handler.")
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""Allows ArtemisTool instances to be invoked directly as callables."""
driver = kwargs.pop("driver", None)
ctx = kwargs.pop("ctx", None) or kwargs.pop("context", None)
return await self.execute(driver=driver, ctx=ctx, *args, **kwargs)
def to_langchain_tool(self, ctx: Any, name: str | None = None) -> BaseTool:
"""Exports this tool to a LangChain StructuredTool."""
def _get_context_driver() -> BaseDeviceDriver | None:
if (
ctx is not None
and hasattr(ctx, "device")
and ctx.device
and getattr(ctx.device, "device_id", None)
):
try:
return get_driver(ctx)
except Exception as e: # pylint: disable=broad-exception-caught
logger.debug(f"Failed to get driver: {e}")
return None
return getattr(ctx, "_active_driver", None) if ctx is not None else None
def _func(state: Annotated[Any, InjectedState] = None, **kwargs):
driver = _get_context_driver()
if state is not None:
kwargs["state"] = state
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(
asyncio.run, self.execute(driver=driver, ctx=ctx, **kwargs)
).result()
return asyncio.run(self.execute(driver=driver, ctx=ctx, **kwargs))
async def _coroutine(state: Annotated[Any, InjectedState] = None, **kwargs):
driver = _get_context_driver()
if state is not None:
kwargs["state"] = state
return await self.execute(driver=driver, ctx=ctx, **kwargs)
return StructuredTool.from_function(
func=_func,
coroutine=_coroutine,
name=name or self.name,
description=self.description,
args_schema=self.args_schema,
)
def to_genai_declaration(self) -> genai_types.FunctionDeclaration:
"""Exports this tool to a Google GenAI FunctionDeclaration schema."""
properties = {}
required = []
if self.args_schema:
schema = self.args_schema.model_json_schema()
props = schema.get("properties", {})
required = schema.get("required", [])
for prop_name, prop_data in props.items():
p_type_str = prop_data.get("type")
items_data = prop_data.get("items")
if not p_type_str and "anyOf" in prop_data:
for sub_schema in prop_data["anyOf"]:
sub_type = sub_schema.get("type")
if sub_type and sub_type != "null":
p_type_str = sub_type
if "items" in sub_schema and not items_data:
items_data = sub_schema["items"]
break
if not p_type_str:
p_type_str = "string"
p_desc = prop_data.get("description", "")
genai_type = genai_types.Type.STRING
if p_type_str == "integer":
genai_type = genai_types.Type.INTEGER
elif p_type_str == "number":
genai_type = genai_types.Type.NUMBER
elif p_type_str == "boolean":
genai_type = genai_types.Type.BOOLEAN
elif p_type_str == "array":
genai_type = genai_types.Type.ARRAY
elif p_type_str == "object":
genai_type = genai_types.Type.OBJECT
# Handle array items if present
items_schema = None
if genai_type == genai_types.Type.ARRAY:
item_type_str = (items_data or {}).get("type", "string")
item_genai_type = (
genai_types.Type.INTEGER
if item_type_str == "integer"
else genai_types.Type.STRING
)
items_schema = genai_types.Schema(type=item_genai_type)
properties[prop_name] = genai_types.Schema(
type=genai_type,
description=p_desc,
items=items_schema,
)
return genai_types.FunctionDeclaration(
name=self.name,
description=self.description,
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties=properties,
required=required,
),
)
class ToolRegistry:
"""Global registry managing universal tools."""
_tools: dict[str, ArtemisTool] = {}
@classmethod
def register(cls, tool: ArtemisTool) -> ArtemisTool:
"""Registers a tool in the global registry."""
cls._tools[tool.name] = tool
return tool
@classmethod
def get(cls, name: str) -> ArtemisTool | None:
"""Retrieves a tool by name from the registry."""
normalized = name.split(":")[-1] if ":" in name else name
return cls._tools.get(normalized)
@classmethod
def list_tools(cls, category: ToolCategory | None = None) -> list[ArtemisTool]:
"""Lists all registered tools, optionally filtered by category."""
if category:
return [t for t in cls._tools.values() if t.category == category]
return list(cls._tools.values())
@classmethod
def get_langchain_tools(cls, ctx: Any, names: list[str] | None = None) -> list[BaseTool]:
"""Returns LangChain tool wrappers for the requested or all registered tools."""
tools = (
[cls._tools[n] for n in names if n in cls._tools]
if names
else list(cls._tools.values())
)
return [t.to_langchain_tool(ctx) for t in tools]
@classmethod
def get_genai_declarations(
cls, names: list[str] | None = None
) -> list[genai_types.FunctionDeclaration]:
"""Returns Google GenAI function declarations for registered tools."""
tools = (
[cls._tools[n] for n in names if n in cls._tools]
if names
else list(cls._tools.values())
)
return [t.to_genai_declaration() for t in tools]
@classmethod
async def execute(
cls, name: str, args: dict[str, Any], driver: BaseDeviceDriver, ctx: Any
) -> Any:
"""Executes a registered tool by name with provided arguments, driver, and context."""
tool = cls.get(name)
if not tool:
raise ValueError(f"Tool '{name}' not found in ToolRegistry.")
return await tool.execute(driver=driver, ctx=ctx, **args)
def artemis_tool(
name: str,
description: str,
args_schema: type[BaseModel],
category: ToolCategory = "action",
):
"""Decorator to define and register a universal ARTEMIS tool."""
def decorator(fn: Callable[..., Any]) -> ArtemisTool:
tool_obj = ArtemisTool(
name=name,
description=description,
args_schema=args_schema,
handler=fn,
category=category,
)
ToolRegistry.register(tool_obj)
return tool_obj
return decorator
+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}",
)
+5
View File
@@ -0,0 +1,5 @@
{
"planner_avatar": "You are the **Planner Avatar** (Meeting Host) in a committee resolving a task plan failure. Your role is to lead the meeting and advocate for a robust and correct high-level plan. You are aware of:\n- **History Analyzer**: The trajectory specialist who can analyze execution history and read notes. They have tools like `get_step_details` and `read_note`.\n- **Diagnoser**: The forensics specialist who can analyze logs and videos to find why things failed. They have tools like `video_analyzer` and `analyze_logs`.\nYour master (Master Planner) has given you a specific directive for this meeting, which is at the top of the blackboard.\n**Your Task**:\n1. In the first round, you must open the meeting, state the problem based on the master's directive, and call on the other experts (Diagnoser or History Analyzer) to provide their analysis.\n2. In subsequent rounds, evaluate their suggestions and push for a concrete, revised plan of subgoals.\n3. In the final round, summarize the discussion and output the final recommended plan revisions and actions.",
"history_analyzer_expert": "You are the **History Analyzer** (Trajectory Specialist) in a committee resolving a task failure. Your role is to analyze the execution history of the current session, understand what has been tried, what succeeded, and what failed, and check persistent notes. You are aware of:\n- **Planner Avatar**: The meeting host who needs a concrete plan.\n- **Diagnoser**: The forensics specialist who analyzes logs and videos.\nYou have tools to retrieve full-granularity step details and read notes. Wait for the Planner Avatar to call on you or respond to the current state of the blackboard. Be concise.",
"diagnoser_expert": "You are the **Diagnoser** (Forensics Specialist) in a committee resolving a UI automation failure. Your role is to analyze what went wrong in the past, look for error patterns in logs, and understand system state transitions. You are aware of:\n- **Planner Avatar**: The meeting host who needs a concrete plan.\n- **History Analyzer**: The trajectory specialist who analyzes history and notes.\nYou have tools to analyze video and logs. Wait for the Planner Avatar to call on you or respond to the current state of the blackboard. Be concise."
}
+427
View File
@@ -0,0 +1,427 @@
# pylint: disable=too-many-statements
# 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.
"""Committee tool for multi-agent deliberation and failure recovery."""
import asyncio
import base64
import json
from pathlib import Path
from typing import Any
from uuid import uuid4
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.agents.history_analyzer.history_analyzer import HistoryAnalyzer
from artemis.context import ArtemisContext
from artemis.data_engine.trace import (
CURRENT_TRACE_ID,
TraceSpan,
trace_langchain_tool,
)
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.log_tool import get_analyze_logs_tool
from artemis.tools.scratchpad import get_list_notes_tool, get_read_note_tool
from artemis.tools.tool_wrapper import (
ToolWrapper,
get_tool_result_content,
invoke_tool_with_injection,
)
from artemis.tools.types import CyFunctionDetector
from artemis.tools.video_tool import get_video_analyzer_tool
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path, get_notes_dir
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
get_recent_subgoal_hashes,
)
logger = get_logger(__name__)
class AskCommitteeArgs(BaseModel):
"""Arguments schema for invoking the committee tool."""
model_config = {"ignored_types": (CyFunctionDetector,)}
avatar_directive: str = Field(
...,
description="Directive to frame and steer the debate for your avatar.",
)
ASK_COMMITTEE_DOCSTRING = (
"[COMMITTEE] Call this tool to summon a committee of sub-agents to debate"
" and resolve complex failures.\n\n"
"Your avatar will host the meeting and drive it based on your directive.\n"
"The result will be a synthesized recommendation."
)
class AskCommitteeTool(ArtemisTool):
"""Universal tool for summoning a committee of sub-agents to debate and resolve failures."""
def __init__(self, category: ToolCategory = "custom"):
super().__init__(
name="ask_committee",
description=ASK_COMMITTEE_DOCSTRING,
args_schema=AskCommitteeArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
avatar_directive: str = "",
tool_call_id: str | None = None, # pylint: disable=unused-argument
state: State | None = None,
**kwargs: Any,
) -> str:
directive = avatar_directive or kwargs.get("AvatarDirective") or ""
return await _run_committee_logic(
ctx=ctx,
state=state,
avatar_directive=directive,
)
# Universal tool instance & aliases
ask_committee = AskCommitteeTool()
AskCommittee = AskCommitteeTool
AskCommitteeToolAlias = AskCommitteeTool
ToolRegistry.register(ask_committee)
def get_ask_committee_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports ask_committee as a LangChain BaseTool."""
return trace_langchain_tool(ask_committee.to_langchain_tool(ctx), ctx)
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
async def _execute_committee(
ctx: ArtemisContext,
state: State | None,
avatar_directive: str,
) -> str:
"""Executes committee debate loop and returns the final conclusion."""
with TraceSpan(name="committee", trace_type="agent", ctx=ctx):
logger.info(f"Committee invoked with directive: {avatar_directive}")
# 1. Access or create blackboard file
if ctx.data_engine:
notes_dir = get_notes_dir(ctx.data_engine.base_dir)
else:
notes_dir = Path(".") / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
# Load latest screenshot as base64 to provide multimodal vision to the committee
screenshot_b64 = None
screenshot_path = getattr(state, "latest_screenshot", None) if state is not None else None
if screenshot_path and Path(screenshot_path).exists():
try:
with open(screenshot_path, "rb") as f:
screenshot_b64 = base64.b64encode(f.read()).decode("utf-8")
logger.info(f"Committee successfully loaded screenshot from: {screenshot_path}")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to read screenshot in committee: {e}")
trace_id = CURRENT_TRACE_ID.get() or str(uuid4())
blackboard_path = notes_dir / f"blackboard_{trace_id}.md"
# Initialize blackboard with goal and directive
initial_goal = (
getattr(state, "initial_goal", "Not specified.")
if state is not None
else "Not specified."
)
blackboard_content = (
"# Committee Discussion Blackboard\n\n"
f"## User's Initial Goal\n{initial_goal}\n\n"
f"## Master's Directive\n{avatar_directive}\n\n"
)
blackboard_path.write_text(blackboard_content, encoding="utf-8")
# 2. Extract history
history_summary = "## Previous Context\n"
if ctx.data_engine:
steps = ctx.data_engine.get_agent_friendly_steps()
if steps:
active_subgoal_hash = "default"
if ctx.data_engine:
task_plan_path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
else:
task_plan_path = notes_dir / "task_plan.md"
task_plan = "No task plan yet."
keep_hashes = None
if task_plan_path.exists():
try:
task_plan = task_plan_path.read_text(encoding="utf-8")
active_subgoal_hash, _ = get_active_subgoal_hashes(task_plan)
keep_hashes = get_recent_subgoal_hashes(
steps,
active_subgoal_hash,
ctx.data_engine.base_dir,
)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to parse active subgoal in committee: {e}")
try:
plan_and_history = build_plan_and_history(
task_plan,
steps,
active_subgoal_hash,
keep_subgoal_hashes=keep_hashes,
last_n_detailed=3,
)
history_summary += plan_and_history
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to build plan and history in committee: {e}")
history_summary += f"Error building plan and history: {e}"
# Append immediate failure reason if present in state
replan_reason = (
getattr(state, "operator_replan_reason", None) if state is not None else None
)
if replan_reason:
history_summary += f"\n\n### Current Failure Reason\n{replan_reason}\n"
# Read failed plans history to ensure the committee avoids
# repeating failed strategies
failed_plans_history = ""
failed_history_path = notes_dir / "failed_plans_history.md"
if failed_history_path.exists():
try:
failed_plans_history = failed_history_path.read_text(encoding="utf-8")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to read failed plans history in committee: {e}")
if failed_plans_history:
history_summary += (
f"\n\n### Previous Failed Plan Attempts\n{failed_plans_history}\n"
)
with open(blackboard_path, "a", encoding="utf-8") as f:
f.write(history_summary + "\n\n")
# 3. Load prompts from JSON
prompts = {}
prompts_path = Path(__file__).parent / "committee_prompts.json"
if prompts_path.exists():
try:
prompts = json.loads(prompts_path.read_text(encoding="utf-8"))
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to load committee prompts: {e}")
planner_prompt = prompts.get("planner_avatar", "You are the Planner Avatar.")
history_analyzer_prompt = prompts.get(
"history_analyzer_expert", "You are the History Analyzer."
)
diagnoser_prompt = prompts.get("diagnoser_expert", "You are the Diagnoser Expert.")
# 4. Prepare Specialized Tools for Members
# History Analyzer Tools
analyzer_inst = HistoryAnalyzer(ctx)
history_steps = ctx.data_engine.get_agent_friendly_steps() if ctx.data_engine else []
# pylint: disable=protected-access
details_tool = analyzer_inst._get_step_details_tool(history_steps)
read_note_tool_hist = get_read_note_tool(ctx)
list_notes_tool_hist = get_list_notes_tool(ctx)
history_analyzer_tools = [
trace_langchain_tool(details_tool, ctx),
trace_langchain_tool(read_note_tool_hist, ctx),
trace_langchain_tool(list_notes_tool_hist, ctx),
]
# Diagnoser Tools
video_tool = get_video_analyzer_tool(ctx, role="diagnoser")
log_tool = get_analyze_logs_tool(ctx)
read_note_tool = get_read_note_tool(ctx)
list_notes_tool = get_list_notes_tool(ctx)
diagnoser_tools = [
trace_langchain_tool(video_tool, ctx),
trace_langchain_tool(log_tool, ctx),
trace_langchain_tool(read_note_tool, ctx),
trace_langchain_tool(list_notes_tool, ctx),
]
# 5. Instantiate LLMs
llm_pl = get_llm(ctx=ctx, name="planner_avatar")
llm_hist = get_llm(ctx=ctx, name="history_analyzer_expert")
llm_hist = llm_hist.bind_tools(tools=history_analyzer_tools)
llm_diag = get_llm(ctx=ctx, name="diagnoser_expert")
llm_diag = llm_diag.bind_tools(tools=diagnoser_tools)
# 6. Debate Loop (configured rounds)
exec_setup = getattr(ctx, "execution_setup", None)
rounds = getattr(exec_setup, "committee_debate_rounds", 2) if exec_setup else 2
async def run_agent_turn(llm, system_prompt, tools, agent_name):
current_blackboard = blackboard_path.read_text(encoding="utf-8")
human_content = [
{
"type": "text",
"text": f"Current Blackboard:\n{current_blackboard}",
}
]
if screenshot_b64:
human_content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{screenshot_b64}"},
}
)
messages = [
SystemMessage(content=system_prompt),
HumanMessage(content=human_content),
]
max_iterations = 3
for _ in range(max_iterations):
response = await invoke_llm_with_timeout_message(llm.ainvoke(messages))
if not response.tool_calls:
return response.content
messages.append(response)
async def run_tool(tc):
tool_name = tc["name"]
logger.info(f"{agent_name} requested tool: {tool_name}")
tool_to_run = next((t for t in tools if t.name == tool_name), None)
if tool_to_run:
try:
args = dict(tc["args"])
result = await invoke_tool_with_injection(
tool=tool_to_run,
args=args,
tool_call_id=tc["id"],
state=state,
)
return get_tool_result_content(result)
except Exception as e: # pylint: disable=broad-exception-caught
return f"Error: {e}"
else:
return f"Error: Tool {tool_name} not found"
active_tool_calls = [
tc for tc in response.tool_calls if tc["name"] != "google_search"
]
tool_outputs = await asyncio.gather(*(run_tool(tc) for tc in active_tool_calls))
for tc, result in zip(active_tool_calls, tool_outputs):
messages.append(
ToolMessage(
tool_call_id=tc["id"],
content=result,
status="success" if not result.startswith("Error") else "error",
)
)
return "Error: Reached max iterations in turn."
for r in range(1, rounds + 1):
logger.info(f"Committee Round {r}")
# 1. Planner Avatar speaks first (Host)
pl_response = await run_agent_turn(llm_pl, planner_prompt, [], "Planner Avatar")
with open(blackboard_path, "a", encoding="utf-8") as f:
f.write(f"### Planner Avatar (Round {r})\n{pl_response}\n\n")
# 2. Diagnoser speaks (Can use tools)
diag_response = await run_agent_turn(
llm_diag, diagnoser_prompt, diagnoser_tools, "Diagnoser"
)
with open(blackboard_path, "a", encoding="utf-8") as f:
f.write(f"### Diagnoser (Round {r})\n{diag_response}\n\n")
# 3. History Analyzer speaks (Can use tools)
hist_response = await run_agent_turn(
llm_hist,
history_analyzer_prompt,
history_analyzer_tools,
"History Analyzer",
)
with open(blackboard_path, "a", encoding="utf-8") as f:
f.write(f"### History Analyzer (Round {r})\n{hist_response}\n\n")
# Final Synthesis by Planner Avatar
current_blackboard = blackboard_path.read_text(encoding="utf-8")
final_human_content = [
{
"type": "text",
"text": f"Current Blackboard:\n{current_blackboard}",
}
]
if screenshot_b64:
final_human_content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{screenshot_b64}"},
}
)
msg_final = [
SystemMessage(
content=(
"You are the Planner Avatar. Summarize the discussion"
" and output the final recommended plan revisions and"
" action plan based on your master's directive."
)
),
HumanMessage(content=final_human_content),
]
resp_final = await invoke_llm_with_timeout_message(llm_pl.ainvoke(msg_final))
with open(blackboard_path, "a", encoding="utf-8") as f:
f.write(f"## Final Conclusion\n{resp_final.content}\n")
return resp_final.content
async def _run_committee_logic(
ctx: ArtemisContext | None,
state: State | None,
avatar_directive: str,
) -> str:
"""Executes multi-agent committee debate logic."""
if ctx is None:
return "Error: ArtemisContext is required for ask_committee."
try:
return await asyncio.wait_for(_execute_committee(ctx, state, avatar_directive), timeout=300)
except TimeoutError:
logger.error("Committee timed out after 300 seconds.")
return "Error: Committee timed out after 300 seconds."
ask_committee_wrapper = ToolWrapper(
tool_fn_getter=get_ask_committee_tool,
on_success_fn=lambda output: f"Committee concluded:\n{output}",
on_failure_fn=lambda error: f"Committee failed: {error}",
)
@@ -0,0 +1,88 @@
# 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 tool for submitting final diagnosis and actionable recovery advice."""
from typing import Any
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
# pylint: disable=too-few-public-methods
class SubmitAnswerArgs(BaseModel):
"""Arguments schema for submitting final diagnoser answer."""
analysis: str = Field(
...,
description=(
"A detailed and concise explanation of the root cause of the"
" failure or query. Answer in one full paragraph."
),
)
actionable_steps: list[str] = Field(
default_factory=list,
description=(
"A list of concrete recovery instructions or next steps. Each"
" step is a short sentence. If no actionable steps are"
" available, return an empty list."
),
)
class SubmitAnswerTool(ArtemisTool):
"""Universal tool for submitting final diagnosis and actionable recovery advice."""
def __init__(self):
super().__init__(
name="submit_answer",
description=(
"[Finalization] Submits the final diagnosis and actionable recovery advice. "
"Call this tool when you have pinpointed the root cause."
),
args_schema=SubmitAnswerArgs,
category="custom",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None, # pylint: disable=unused-argument
analysis: str | None = None, # pylint: disable=unused-argument
actionable_steps: list[str] | None = None, # pylint: disable=unused-argument
**kwargs: Any,
) -> str:
logger.info("Diagnoser submitted answer via tool.")
return "Answer submitted successfully."
# Universal tool instance & aliases
submit_answer = SubmitAnswerTool()
SubmitAnswer = SubmitAnswerTool
DiagnoserSubmitAnswerTool = SubmitAnswerTool
ToolRegistry.register(submit_answer)
def get_submit_answer_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports submit_answer as a LangChain BaseTool."""
return trace_langchain_tool(submit_answer.to_langchain_tool(ctx), ctx)
+120
View File
@@ -0,0 +1,120 @@
# 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 diagnostic agent tool for deep system logs and video inspection."""
from typing import Any
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.agents.diagnoser.diagnoser import Diagnoser
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
# pylint: disable=too-few-public-methods
class AskDiagnoserArgs(BaseModel):
"""Arguments schema for invoking the diagnostic subagent."""
model_config = {"ignored_types": (CyFunctionDetector,)}
query: str = Field(
...,
description=(
"The diagnostic query or what you want to investigate. Please"
" provide a specific, narrow time range if applicable."
),
)
async def _run_diagnoser_logic(
ctx: ArtemisContext | None,
state: State | None,
query: str,
) -> str:
logger.info(f"ask_diagnoser tool called with query: {query}")
if ctx is None:
return "Error: ArtemisContext is required for Diagnoser."
agent = Diagnoser(ctx)
try:
result = await agent.run(query, state)
return f"Diagnoser replied:\n{result}"
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to run diagnoser: {e}")
return f"Diagnoser failed: {e}"
class AskDiagnoserTool(ArtemisTool):
"""Universal tool for invoking the Diagnoser subagent to inspect logs and videos."""
def __init__(self):
super().__init__(
name="ask_diagnoser",
description=(
"[DIAGNOSTIC] Analyzes system logs (ADB) and video recordings to "
"pinpoint the root cause of any step failure, screen freeze, or "
"unexpected UI drift, and directly executes adb commands. Call this "
"tool proactively whenever an action fails, status stagnates, or you "
"are unsure why progress is blocked, to diagnose the issue before "
"committing to physical actions. Note: When querying video files, "
"specify a narrow timeframe (e.g., '[10s, 20s]') for faster analysis."
),
args_schema=AskDiagnoserArgs,
category="custom",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
query: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> str:
q = query or kwargs.get("Query") or ""
st = state if state is not None else kwargs.get("state")
return await _run_diagnoser_logic(ctx, st, q)
# Universal tool instance & aliases
ask_diagnoser = AskDiagnoserTool()
AskDiagnoser = AskDiagnoserTool
DiagnosticTool = AskDiagnoserTool
ToolRegistry.register(ask_diagnoser)
def get_ask_diagnostic_agent_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports ask_diagnoser as a LangChain BaseTool."""
return trace_langchain_tool(ask_diagnoser.to_langchain_tool(ctx), ctx)
def get_ask_diagnoser_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports ask_diagnoser as a LangChain BaseTool (standard naming alias)."""
return trace_langchain_tool(ask_diagnoser.to_langchain_tool(ctx), ctx)
ask_diagnoser_wrapper = ToolWrapper(
tool_fn_getter=get_ask_diagnostic_agent_tool,
on_success_fn=lambda output: f"Diagnoser replied:\n{output}",
on_failure_fn=lambda error: f"Diagnoser failed: {error}",
)
+404
View File
@@ -0,0 +1,404 @@
# 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.
import base64
import glob
import json
import os
from pathlib import Path
import re
from typing import Any, Literal
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field, create_model
from artemis.agents.explorer.constants import EXPLORE_DESCRIPTIONS
from artemis.agents.explorer.explorer import Explorer
from artemis.agents.object_detector.object_detector import _run_object_detection
from artemis.config import ExplorerVersion, resolve_explorer_version, settings
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace, trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
from artemis.utils.visualization import draw_dots
class AskExplorerArgs(BaseModel):
"""Arguments schema for invoking the explorer subagent."""
model_config = {"ignored_types": (CyFunctionDetector,)}
query: str = Field(
...,
description=("The target element or information to search for, including descriptions."),
)
context_feedback: str = Field(
"",
description=("Feedback from previous failed attempts or specific instructions"),
)
version: Literal["flash", "pro", "ultra"] | None = Field(
default=None,
description="Explicit explorer version to use (flash, pro, or ultra).",
)
logger = get_logger(__name__)
class AskExplorerTool(ArtemisTool):
"""Universal tool for visually parsing and locating UI elements."""
def __init__(
self,
version: ExplorerVersion | None = None,
agent_name: str = "operator",
description: str | None = None,
category: ToolCategory = "explorer",
):
self.version = version
self.agent_name = agent_name
resolved_v = version or "pro"
explore_info = EXPLORE_DESCRIPTIONS.get(resolved_v, EXPLORE_DESCRIPTIONS["pro"])
desc = description or explore_info["description"]
if "{max_iterations}" in desc:
max_iterations = 8 if resolved_v == "ultra" else 3
desc = desc.format(max_iterations=max_iterations)
super().__init__(
name="ask_explorer",
description=desc,
args_schema=AskExplorerArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
query: str = "",
context_feedback: str = "",
version: Literal["flash", "pro", "ultra"] | None = None,
tool_call_id: str | None = None, # pylint: disable=unused-argument
state: State | None = None,
**kwargs: Any,
) -> Any:
q = query or kwargs.get("Query") or ""
cf = context_feedback or kwargs.get("ContextFeedback") or ""
v = version or self.version
resolved_v = (
resolve_explorer_version(ctx, explicit_version=v, agent_or_profile_name=self.agent_name)
if ctx
else (v or "pro")
)
return await _run_explorer_logic(
ctx=ctx,
state=state,
query=q,
context_feedback=cf,
version=resolved_v,
)
# Universal tool instance & aliases
ask_explorer = AskExplorerTool()
AskExplorer = AskExplorerTool
AskExplorerToolAlias = AskExplorerTool
ToolRegistry.register(ask_explorer)
def get_ask_explorer_tool(
ctx: ArtemisContext,
version: ExplorerVersion | None = None,
agent_name: str = "operator",
) -> BaseTool:
"""Exports ask_explorer as a LangChain BaseTool with dynamic schema."""
resolved_version = resolve_explorer_version(
ctx, explicit_version=version, agent_or_profile_name=agent_name
)
explore_info = EXPLORE_DESCRIPTIONS.get(resolved_version, EXPLORE_DESCRIPTIONS["pro"])
description = explore_info["description"]
if "{max_iterations}" in description:
max_iterations = 8 if resolved_version == "ultra" else 3
description = description.format(max_iterations=max_iterations)
query_description = explore_info["query_description"]
# Dynamically build argument schema so Operator does not see
# context_feedback when flash is active
fields = {
"query": (str, Field(..., description=query_description)),
}
if resolved_version != "flash":
fields["context_feedback"] = (
str,
Field(
"",
description=("Feedback from previous failed attempts or specific instructions"),
),
)
# pylint: disable=invalid-name
DynamicAskExplorerArgs = create_model("AskExplorerArgs", **fields)
tool_instance = AskExplorerTool(
version=resolved_version,
agent_name=agent_name,
description=description,
)
tool_instance.args_schema = DynamicAskExplorerArgs
return trace_langchain_tool(tool_instance.to_langchain_tool(ctx), ctx)
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
async def _run_explorer_logic(
ctx: ArtemisContext,
state: State,
query: str,
context_feedback: str,
version: Literal["flash", "pro", "ultra"],
) -> Any:
if version == "flash":
logger.info(
"Explorer [version='flash'] intercepted -> Direct one-shot object"
" detection (context_feedback ignored)."
)
screenshot_path = state.latest_screenshot
if not screenshot_path:
return "Explorer (Flash) error: No latest_screenshot available in state."
queries = [q.strip() for q in query.split("|") if q.strip()]
if not queries:
queries = [query.strip()]
try:
prompt_path = Path(__file__).parent.parent.joinpath(
"agents", "object_detector", "object_detector.json"
)
with open(prompt_path, encoding="utf-8") as f:
cfg = json.load(f)
templates = [f"{t}\n\n{cfg.get('instructions', '')}" for t in cfg.get("templates", [])]
result_dict = await _run_object_detection(
ctx, screenshot_path, queries, templates, global_timeout=15.0
)
detected_items = result_dict.get("detected", [])
candidates = []
for item in detected_items:
if isinstance(item, dict) and "point" in item and len(item["point"]) == 2:
nx, ny = item["point"]
lbl = item.get("label", query)
candidates.append(
{
"label": lbl,
"coords": [int(nx), int(ny)],
"description": f"{lbl} (flash detection)",
}
)
if not candidates:
failed_queries = result_dict.get("failed", queries)
explorer_output = json.dumps(
{
"candidates": [],
"fallback_message": (
"Flash one-shot detection could not locate:"
f" {', '.join(failed_queries)}. Consider checking"
" visual visibility or layout."
),
}
)
else:
explorer_output = json.dumps({"candidates": candidates})
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Flash one-shot routing failed: {e}")
explorer_output = json.dumps(
{
"candidates": [],
"fallback_message": f"Flash detection error: {e}",
}
)
else:
@trace(type="agent", name="explorer", ctx=ctx)
async def run_explorer_agent(q: str, cf: str, screenshot_path: str, state: State) -> str:
agent = Explorer(ctx)
env_cache = os.getenv("ARTEMIS_EXPLORER_CACHING", "").lower()
if env_cache in ["true", "false"]:
enable_caching = env_cache == "true"
else:
agent_cfg = getattr(ctx, "agent_config", None)
if (
agent_cfg
and hasattr(agent_cfg, "explorer")
and hasattr(agent_cfg.explorer, "caching")
):
enable_caching = agent_cfg.explorer.caching
elif (
ctx and ctx.execution_setup and hasattr(ctx.execution_setup, "explorer_caching")
):
enable_caching = ctx.execution_setup.explorer_caching
else:
enable_caching = getattr(settings, "EXPLORER_CACHING", True)
return await agent.run(
q,
cf,
screenshot_path,
state,
enable_caching=enable_caching,
version=version,
)
screenshot_path = state.latest_screenshot
explorer_output = await run_explorer_agent(query, context_feedback, screenshot_path, state)
try:
data = json.loads(explorer_output)
candidates = data.get("candidates", [])
fallback_message = data.get("fallback_message", "")
if candidates:
# 1. Read Operator's active coordinates list
indexed_points = getattr(state, "indexed_points", None)
if indexed_points is None:
indexed_points = []
state.indexed_points = indexed_points
indexed_elements = getattr(state, "indexed_elements", None)
if indexed_elements is None:
indexed_elements = []
state.indexed_elements = indexed_elements
width = 1080
height = 2400
operator_raw_data = getattr(state, "operator_raw_data", {}) or {}
w_raw = operator_raw_data.get("width")
h_raw = operator_raw_data.get("height")
if isinstance(w_raw, int) and isinstance(h_raw, int):
width = w_raw
height = h_raw
else:
if ctx.device and getattr(ctx.device, "device_width", None):
width = ctx.device.device_width
if ctx.device and getattr(ctx.device, "device_height", None):
height = ctx.device.device_height
registered_info = []
for cand in candidates:
coords = cand.get("coords")
desc = cand.get("description", "Explorer candidate")
if coords and len(coords) == 2:
nx, ny = coords
pixel_x = int(max(0, min(width, nx * width / 1000)))
pixel_y = int(max(0, min(height, ny * height / 1000)))
indexed_points.append([pixel_x, pixel_y])
new_idx = len(indexed_points)
# Update indexed_elements in sync
indexed_elements.append(
{
"index": new_idx,
"center": [pixel_x, pixel_y],
"text": desc,
"bounds": None,
"class": "ExplorerCandidate",
"resource_id": None,
"is_ocr": False,
}
)
registered_info.append(f"- [{new_idx}] '{desc}' at coordinate {coords}")
response_msg = (
"Explorer successfully located the following candidate(s):\n"
+ "\n".join(registered_info)
)
response_msg += (
"\nYou can click/act on them directly by calling perform_action"
" with their respective index."
)
if fallback_message:
response_msg += f"\n\nAdditional Notes from Explorer: {fallback_message}"
# 2. Generate annotated image with ALL points
base_dir = (
Path(ctx.data_engine.base_dir)
if ctx.data_engine and getattr(ctx.data_engine, "base_dir", None)
else settings.TRACES_PATH
)
images_dir = base_dir / "images"
explorer_dir = images_dir / "explorer_tool"
explorer_dir.mkdir(parents=True, exist_ok=True)
image_name = "explorer_output"
existing_files = glob.glob(str(explorer_dir / f"{image_name}_*.jpg"))
max_seq = 0
for f in existing_files:
match = re.search(r"_(\d+)\.jpg$", f)
if match:
max_seq = max(max_seq, int(match.group(1)))
seq = max_seq + 1
output_path = explorer_dir / f"{image_name}_{seq}.jpg"
all_labels = [str(i) for i in range(1, len(indexed_points) + 1)]
try:
draw_dots(
screenshot_path,
indexed_points,
all_labels,
str(output_path),
)
with open(output_path, "rb") as im_f:
img_bytes = im_f.read()
img_b64 = base64.b64encode(img_bytes).decode("utf-8")
return [
{
"type": "text",
"text": f"Explorer replied:\n{response_msg}",
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"},
},
]
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to draw dots or read image in explorer_tool: {e}")
# Fallback to pure text if drawing fails
return f"Explorer replied:\n{response_msg}"
elif fallback_message:
return f"Explorer could not locate the element. Message: {fallback_message}"
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to parse explorer output: {e}")
return f"Explorer failed to provide a valid format. Raw output: {explorer_output}"
return f"Explorer replied:\n{explorer_output}"
ask_explorer_wrapper = ToolWrapper(
tool_fn_getter=get_ask_explorer_tool,
on_success_fn=lambda output: f"Explorer replied:\n{output}",
on_failure_fn=lambda error: f"Explorer failed: {error}",
)
+125
View File
@@ -0,0 +1,125 @@
# 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 Image Processor tool for executing image transformations."""
from typing import Any
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace, trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
# pylint: disable=too-few-public-methods
class AskVisionCoderArgs(BaseModel):
"""Arguments schema for invoking the Image Processor subagent."""
model_config = {"ignored_types": (CyFunctionDetector,)}
instruction: str = Field(
...,
description=(
"Step-by-step instructions on what image processing or"
" modifications should be performed."
),
)
target_image_id: str = Field(
...,
description=(
"The ID of the image in the Image Pool to start from (e.g. 'img_0', 'img_1')."
),
)
# Alias for naming consistency
AskImageProcessorArgs = AskVisionCoderArgs
class AskImageProcessorTool(ArtemisTool):
"""Universal tool for executing image transformations via ImageProcessor subagent."""
def __init__(self):
super().__init__(
name="ask_image_processor",
description=(
"Call this tool to ask the image processor agent to run"
" complex image transformations or modifications."
),
args_schema=AskVisionCoderArgs,
category="custom",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
instruction: str | None = None,
target_image_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
inst = instruction or kwargs.get("Instruction") or ""
tid = target_image_id or kwargs.get("TargetImageId") or kwargs.get("target_image_id") or ""
st = state if state is not None else kwargs.get("state")
return await _run_image_processor_logic(ctx, st, inst, tid)
# Universal tool instance & aliases
ask_image_processor = AskImageProcessorTool()
AskImageProcessor = AskImageProcessorTool
AskVisionCoderTool = AskImageProcessorTool
ImageProcessorTool = AskImageProcessorTool
ToolRegistry.register(ask_image_processor)
def get_ask_image_processor_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports ask_image_processor as a LangChain BaseTool."""
return trace_langchain_tool(ask_image_processor.to_langchain_tool(ctx), ctx)
async def _run_image_processor_logic(
ctx: ArtemisContext | None,
state: State | None,
instruction: str,
target_image_id: str, # pylint: disable=unused-argument
) -> Any:
@trace(type="agent", name="image_processor", ctx=ctx)
async def run_image_processor_agent(inst: str, img_path: str) -> dict:
from artemis.agents.image_processor.image_processor import ( # pylint: disable=import-outside-toplevel
ImageProcessor,
)
agent = ImageProcessor(ctx)
return await agent.run(inst, img_path)
screenshot_path = state.latest_screenshot if state else None
result = await run_image_processor_agent(instruction, screenshot_path)
return result
ask_image_processor_wrapper = ToolWrapper(
tool_fn_getter=get_ask_image_processor_tool,
on_success_fn=lambda output: f"Image Processor replied:\n{output}",
on_failure_fn=lambda error: f"Image Processor failed: {error}",
)
+52
View File
@@ -0,0 +1,52 @@
# 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 langchain_core.tools import BaseTool
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.tools.tool_wrapper import CompositeToolWrapper, ToolWrapper
def get_tools_from_wrappers(
ctx: "ArtemisContext",
wrappers: list[ToolWrapper],
) -> list[BaseTool]:
"""Instantiate and wrap LangChain tools from a list of ToolWrappers."""
tools: list[BaseTool] = []
for wrapper in wrappers:
if isinstance(wrapper, CompositeToolWrapper):
comp_tools = wrapper.composite_tools_fn_getter(ctx)
for t in comp_tools:
tools.append(trace_langchain_tool(t, ctx))
continue
t = wrapper.tool_fn_getter(ctx)
tools.append(trace_langchain_tool(t, ctx))
return tools
def get_tool_by_name(name: str, tools: list[BaseTool]) -> BaseTool | None:
"""Get a tool by name, stripping any prefixes like 'default_api:'."""
normalized_name = name
if ":" in normalized_name:
normalized_name = normalized_name.split(":")[-1]
for t in tools:
t_name = t.name
if ":" in t_name:
t_name = t_name.split(":")[-1]
if t_name == normalized_name:
return t
return None
+108
View File
@@ -0,0 +1,108 @@
# 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 Log Analyzer tool for inspecting and analyzing Android system logs."""
from typing import Any
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
# pylint: disable=too-few-public-methods
class AnalyzeLogsArgs(BaseModel):
"""Arguments schema for invoking the Log Analyzer subagent."""
model_config = {"ignored_types": (CyFunctionDetector,)}
specific_query: str = Field(
...,
description=(
"Instructions for the log analyzer, specifying what to search for or analyze."
),
)
class AnalyzeLogsTool(ArtemisTool):
"""Universal tool for analyzing Android logs using a dedicated hybrid log analyzer agent."""
def __init__(self):
super().__init__(
name="analyze_logs",
description=(
"[DIAGNOSTIC] Analyzes Android logs using a dedicated hybrid log analyzer agent. "
"Use this when you need to investigate failures, check for specific error "
"logs, or understand system events."
),
args_schema=AnalyzeLogsArgs,
category="custom",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
specific_query: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
query = (
specific_query
if specific_query is not None
else (
kwargs.get("specific_query")
or kwargs.get("SpecificQuery")
or kwargs.get("query")
or kwargs.get("Query")
or ""
)
)
st = state if state is not None else kwargs.get("state")
logger.info(f"analyze_logs tool called with query: '{query}'")
if ctx is None:
return "Error: ArtemisContext is required for LogAnalyzer."
analyst = LogAnalyzerNode(ctx)
result = await analyst.run(query, st)
return result
# Universal tool instance & aliases
analyze_logs = AnalyzeLogsTool()
AnalyzeLogs = AnalyzeLogsTool
LogTool = AnalyzeLogsTool
ToolRegistry.register(analyze_logs)
def get_analyze_logs_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports analyze_logs as a LangChain BaseTool."""
return trace_langchain_tool(analyze_logs.to_langchain_tool(ctx), ctx)
analyze_logs_wrapper = ToolWrapper(
tool_fn_getter=get_analyze_logs_tool,
on_success_fn=lambda output: f"Log Analyzer replied:\n{output}",
on_failure_fn=lambda error: f"Log Analyzer failed: {error}",
)
+66
View File
@@ -0,0 +1,66 @@
# 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.
"""Memory and Scratchpad Note Management tools."""
from typing import Any
from pydantic import BaseModel, Field
from artemis.tools.base import artemis_tool
_IN_MEMORY_NOTES: dict[str, str] = {}
class ReadNoteArgs(BaseModel):
"""Arguments schema for reading a note."""
title: str = Field(..., description="Note title to read.")
class SaveNoteArgs(BaseModel):
"""Arguments schema for saving a note."""
title: str = Field(..., description="Note title to create/save.")
content: str = Field(..., description="Content text of the note.")
@artemis_tool(
name="read_note",
description="[MEMORY] Reads content of an existing scratchpad note.",
args_schema=ReadNoteArgs,
category="memory",
)
async def read_note(
title: str,
ctx: Any = None, # pylint: disable=unused-argument
) -> str:
"""Reads content of an existing scratchpad note."""
return _IN_MEMORY_NOTES.get(title, f"Note '{title}' not found.")
@artemis_tool(
name="save_note",
description="[MEMORY] Saves content into the scratchpad notes system.",
args_schema=SaveNoteArgs,
category="memory",
)
async def save_note(
title: str,
content: str,
ctx: Any = None, # pylint: disable=unused-argument
) -> str:
"""Saves content into the scratchpad notes system."""
_IN_MEMORY_NOTES[title] = content
return f"Note '{title}' saved successfully."
+730
View File
@@ -0,0 +1,730 @@
# 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.
import asyncio
from typing import Any, Literal
from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool
from langgraph.types import Command
from pydantic import BaseModel, Field
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.coordinates import parse_swipe_parameters
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class ClickArgs(BaseModel):
"""Arguments schema for clicking on normalized coordinates."""
model_config = {"ignored_types": (CyFunctionDetector,)}
target: list[int] = Field(
...,
description=("Click target normalized coordinates [x, y] in 0-1000 scale."),
)
class LongPressArgs(BaseModel):
"""Arguments schema for long pressing on normalized coordinates."""
model_config = {"ignored_types": (CyFunctionDetector,)}
target: list[int] = Field(
...,
description=("Long press target normalized coordinates [x, y] in 0-1000 scale."),
)
duration: int = Field(1000, description="Long press duration in milliseconds (default 1000).")
class InputTextArgs(BaseModel):
"""Arguments schema for inputting text into normalized coordinates."""
model_config = {"ignored_types": (CyFunctionDetector,)}
text: str = Field(
...,
description="The text content to input. Supports multi-line strings with '\\n'.",
)
target: list[int] = Field(
...,
description=("Input target field normalized coordinates [x, y] in 0-1000 scale."),
)
clear_exist: bool = Field(
True,
description=(
"Whether to clear existing text before typing (True to replace all"
" text, False to append at the end of existing content)."
),
)
class SwipeArgs(BaseModel):
"""Arguments schema for performing swipe gestures."""
model_config = {"ignored_types": (CyFunctionDetector,)}
direction: Literal["up", "down", "left", "right"] | None = Field(
None,
description=(
"Smart directional swipe ('up', 'down', 'left', 'right') for general page"
" scrolling/browsing. Automatically applies safe vectors and a ~40% visual"
" overlap anchor: 'up' (drags bottom-to-top, scrolling down to reveal content below),"
" 'down' (drags top-to-bottom, scrolling up to reveal content above),"
" 'left' (drags right-to-left, scrolling right),"
" 'right' (drags left-to-right, scrolling left)."
),
)
start: list[int] | None = Field(
None,
description=(
"Start normalized coordinates [start_x, start_y] in 0-1000 scale for precise,"
" local interactions (e.g. adjusting sliders, SeekBars, fine range selection, or drag-and-drop)."
),
)
end: list[int] | None = Field(
None,
description=(
"End normalized coordinates [end_x, end_y] in 0-1000 scale for precise,"
" local interactions (e.g. adjusting sliders, SeekBars, fine range selection, or drag-and-drop)."
),
)
action: Literal["up", "down", "left", "right"] | list[int] | None = Field(
None,
description=(
"Backward-compatible swipe gesture: smart direction string ('up', 'down', 'left', 'right')"
" OR precise custom coordinates [start_x, start_y, end_x, end_y] in 0-1000 scale."
),
)
duration: int = Field(
400,
description=(
"Optional swipe/drag duration in milliseconds (default 400). For drag-and-drop,"
" list reordering, or sliding/adjusting sliders (e.g., volume, brightness, SeekBars),"
" set duration >= 1000 (e.g. 1500). If omitted for directional swipe, duration is computed automatically."
),
)
class PressKeyArgs(BaseModel):
"""Arguments schema for pressing system keys."""
model_config = {"ignored_types": (CyFunctionDetector,)}
key: Literal["ENTER", "BACK", "HOME", "APP_SWITCH"] = Field(
...,
description=("Standard Android system button name (ENTER, BACK, HOME, APP_SWITCH)."),
)
CLICK_DOCSTRING = "[ACTION] Click on the target normalized coordinates on the screen."
class ClickTool(ArtemisTool):
"""Universal tool for clicking on target normalized coordinates on the screen."""
def __init__(self, category: ToolCategory = "action"):
super().__init__(
name="click",
description=CLICK_DOCSTRING,
args_schema=ClickArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
target: list[int] | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
tgt = target if target is not None else (kwargs.get("target") or kwargs.get("Target") or [])
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
st = state if state is not None else kwargs.get("state")
width = 1080
height = 2400
if ctx and hasattr(ctx, "device") and ctx.device:
width = getattr(ctx.device, "device_width", 1080)
height = getattr(ctx.device, "device_height", 2400)
elif driver and hasattr(driver, "screen_size"):
width, height = driver.screen_size
try:
if not tgt or len(tgt) < 2:
raise ValueError(f"Invalid target coordinates: {tgt}")
nx, ny = tgt[0], tgt[1]
x = int(max(0, min(width - 1, nx * width / 1000)))
y = int(max(0, min(height - 1, ny * height / 1000)))
if driver is not None and hasattr(driver, "tap"):
success = await driver.tap(x, y)
outcome = (
f"Clicked at [{nx}, {ny}] (normalized) successfully."
if success
else f"Click failed at {tgt}"
)
elif ctx is not None:
controller = UnifiedMobileController(ctx)
result = await controller.tap_at(x, y)
success = result.error is None
outcome = (
f"Clicked at [{nx}, {ny}] (normalized) successfully."
if success
else f"Failed to click: {result.error}"
)
else:
success = False
outcome = "Error during click: No driver or context provided."
except Exception as e: # pylint: disable=broad-exception-caught
success = False
outcome = f"Error during click: {e}"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid or "",
content=outcome,
status="success" if success else "error",
)
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={VALIDATOR_MESSAGES_KEY: [tool_message]},
agent="validator",
)
)
return outcome
# Universal tool instance & aliases
click = ClickTool()
Click = ClickTool
ToolRegistry.register(click)
def get_click_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports click as a LangChain BaseTool."""
return trace_langchain_tool(click.to_langchain_tool(ctx), ctx)
LONG_PRESS_DOCSTRING = "[ACTION] Long press on the target normalized coordinates on the screen."
class LongPressTool(ArtemisTool):
"""Universal tool for long pressing on target normalized coordinates on the screen."""
def __init__(self, category: ToolCategory = "action"):
super().__init__(
name="long_press",
description=LONG_PRESS_DOCSTRING,
args_schema=LongPressArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
target: list[int] | None = None,
duration: int = 1000,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
tgt = target if target is not None else (kwargs.get("target") or kwargs.get("Target") or [])
dur = (
duration
if duration is not None
else (kwargs.get("duration") or kwargs.get("Duration") or 1000)
)
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
st = state if state is not None else kwargs.get("state")
width = 1080
height = 2400
if ctx and hasattr(ctx, "device") and ctx.device:
width = getattr(ctx.device, "device_width", 1080)
height = getattr(ctx.device, "device_height", 2400)
elif driver and hasattr(driver, "screen_size"):
width, height = driver.screen_size
try:
if not tgt or len(tgt) < 2:
raise ValueError(f"Invalid target coordinates: {tgt}")
nx, ny = tgt[0], tgt[1]
x = int(max(0, min(width - 1, nx * width / 1000)))
y = int(max(0, min(height - 1, ny * height / 1000)))
if driver is not None and hasattr(driver, "long_press"):
success = await driver.long_press(x, y, duration_ms=dur)
outcome = (
f"Long pressed at [{nx}, {ny}] (normalized) successfully."
if success
else f"Failed to long press: Long press failed at {tgt}"
)
elif ctx is not None:
controller = UnifiedMobileController(ctx)
result = await controller.tap_at(x, y, long_press=True, long_press_duration=dur)
success = result.error is None
outcome = (
f"Long pressed at [{nx}, {ny}] (normalized) successfully."
if success
else f"Failed to long press: {result.error}"
)
else:
success = False
outcome = "Error during long press: No driver or context provided."
except Exception as e: # pylint: disable=broad-exception-caught
success = False
outcome = f"Error during long press: {e}"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid or "",
content=outcome,
status="success" if success else "error",
)
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={VALIDATOR_MESSAGES_KEY: [tool_message]},
agent="validator",
)
)
return outcome
# Universal tool instance & aliases
long_press = LongPressTool()
LongPress = LongPressTool
ToolRegistry.register(long_press)
def get_long_press_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports long_press as a LangChain BaseTool."""
return trace_langchain_tool(long_press.to_langchain_tool(ctx), ctx)
INPUT_TEXT_DOCSTRING = "[ACTION] Type text into the target input field coordinates."
class InputTextTool(ArtemisTool):
"""Universal tool for typing text into target input field coordinates."""
def __init__(self, category: ToolCategory = "action"):
super().__init__(
name="input_text",
description=INPUT_TEXT_DOCSTRING,
args_schema=InputTextArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
text: str | None = None,
target: list[int] | None = None,
clear_exist: bool = True,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
txt = text if text is not None else (kwargs.get("text") or kwargs.get("Text") or "")
tgt = target if target is not None else (kwargs.get("target") or kwargs.get("Target") or [])
clr = (
clear_exist
if clear_exist is not None
else kwargs.get("clear_exist", kwargs.get("ClearExist", True))
)
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
st = state if state is not None else kwargs.get("state")
width = 1080
height = 2400
if ctx and hasattr(ctx, "device") and ctx.device:
width = getattr(ctx.device, "device_width", 1080)
height = getattr(ctx.device, "device_height", 2400)
elif driver and hasattr(driver, "screen_size"):
width, height = driver.screen_size
try:
if not tgt or len(tgt) < 2:
raise ValueError(f"Invalid target coordinates: {tgt}")
nx, ny = tgt[0], tgt[1]
x = int(max(0, min(width - 1, nx * width / 1000)))
y = int(max(0, min(height - 1, ny * height / 1000)))
if driver is not None and hasattr(driver, "input_text"):
await driver.tap(x, y)
await asyncio.sleep(0.2)
success = await driver.input_text(txt, clear_existing=clr)
outcome = (
f"Typed '{txt}' successfully."
if success
else f"Failed to input text into {tgt}"
)
elif ctx is not None:
controller = UnifiedMobileController(ctx)
tap_result = await controller.tap_at(x, y)
if tap_result.error:
raise RuntimeError(f"Failed to focus input field: {tap_result.error}")
if clr:
await controller.erase_text()
else:
await controller.press_key("123") # Move to end
success = await controller.type_text(txt, clear_existing=False)
outcome = f"Typed '{txt}' successfully." if success else "Failed to type text."
else:
success = False
outcome = "Error during input text: No driver or context provided."
except Exception as e: # pylint: disable=broad-exception-caught
success = False
outcome = f"Error during input text: {e}"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid or "",
content=outcome,
status="success" if success else "error",
)
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={VALIDATOR_MESSAGES_KEY: [tool_message]},
agent="validator",
)
)
return outcome
# Universal tool instance & aliases
input_text = InputTextTool()
InputText = InputTextTool
ToolRegistry.register(input_text)
def get_input_text_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports input_text as a LangChain BaseTool."""
return trace_langchain_tool(input_text.to_langchain_tool(ctx), ctx)
SWIPE_DOCSTRING = (
"[ACTION] Perform a swipe, drag, or slider-adjustment gesture on the"
" screen.\n\n"
"• Smart Directional Scrolling ('direction'): Best for general browsing"
" and page scrolling. Automatically computes safe swipe vectors (locking"
" axes to prevent edge/sidebar mis-clicks), retains a ~40% visual overlap"
" anchor for zero-omission traversal, and prevents inertial flings.\n"
"• Precise Coordinate Gestures ('start', 'end'): Best for local,"
" fine-grained interactions such as adjusting sliders/SeekBars (e.g.,"
" volume, brightness, progress bars) or drag-and-drop / list reordering."
" Always drag slightly PAST the target position to overcome touch slop"
" and reliably trigger the update. When setting a slider to Maximum (100%)"
" or Minimum (0%), swipe fully to the extreme boundary."
)
class SwipeTool(ArtemisTool):
"""Universal tool for performing swipe gestures on the screen."""
def __init__(self, category: ToolCategory = "action"):
super().__init__(
name="swipe",
description=SWIPE_DOCSTRING,
args_schema=SwipeArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-branches,too-many-locals,too-many-statements
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
action: Literal["up", "down", "left", "right"] | list[int] | str | None = None,
direction: Literal["up", "down", "left", "right"] | str | None = None,
start: list[int] | None = None,
end: list[int] | None = None,
duration: int = 400,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
params = dict(kwargs)
if action is not None:
params["action"] = action
if direction is not None:
params["direction"] = direction
if start is not None:
params["start"] = start
if end is not None:
params["end"] = end
if duration is not None:
params["duration"] = duration
kind, target, dur = parse_swipe_parameters(params, default_duration=duration or 400)
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
st = state if state is not None else kwargs.get("state")
width = 1080
height = 2400
if ctx and hasattr(ctx, "device") and ctx.device:
width = getattr(ctx.device, "device_width", 1080)
height = getattr(ctx.device, "device_height", 2400)
elif driver and hasattr(driver, "screen_size"):
width, height = driver.screen_size
try:
if kind is None or target is None:
raise ValueError(
f"Invalid swipe action parameter: {action or direction or (start, end) or kwargs}"
)
if (
driver is not None
and hasattr(driver, "swipe_direction")
and hasattr(driver, "swipe")
):
if kind == "direction" and isinstance(target, str):
success = await driver.swipe_direction(target, duration_ms=dur)
outcome = (
"Swipe completed successfully." if success else f"Swipe {target} failed"
)
elif kind == "coords" and isinstance(target, list) and len(target) == 4:
start_x = int(max(0, min(width - 1, target[0] * width / 1000)))
start_y = int(max(0, min(height - 1, target[1] * height / 1000)))
end_x = int(max(0, min(width - 1, target[2] * width / 1000)))
end_y = int(max(0, min(height - 1, target[3] * height / 1000)))
success = await driver.swipe(start_x, start_y, end_x, end_y, duration_ms=dur)
outcome = (
"Swipe completed successfully."
if success
else f"Swipe coordinates {target} failed"
)
else:
raise ValueError(f"Invalid swipe action parameter: {target}")
elif ctx is not None:
controller = UnifiedMobileController(ctx)
if kind == "direction" and isinstance(target, str):
if target == "up":
coords = [
width // 2,
int(height * 0.8),
width // 2,
int(height * 0.2),
]
elif target == "down":
coords = [
width // 2,
int(height * 0.2),
width // 2,
int(height * 0.8),
]
elif target == "left":
coords = [
int(width * 0.8),
height // 2,
int(width * 0.2),
height // 2,
]
elif target == "right":
coords = [
int(width * 0.2),
height // 2,
int(width * 0.8),
height // 2,
]
else:
raise ValueError(f"Invalid direction: {target}")
start_x, start_y, end_x, end_y = coords
elif kind == "coords" and isinstance(target, list) and len(target) == 4:
nx1, ny1, nx2, ny2 = target
start_x = int(max(0, min(width - 1, nx1 * width / 1000)))
start_y = int(max(0, min(height - 1, ny1 * height / 1000)))
end_x = int(max(0, min(width - 1, nx2 * width / 1000)))
end_y = int(max(0, min(height - 1, ny2 * height / 1000)))
else:
raise ValueError(f"Invalid swipe action parameter: {target}")
error = await controller.swipe_coords(start_x, start_y, end_x, end_y, dur)
success = error is None
outcome = "Swipe completed successfully." if success else f"Swipe failed: {error}"
else:
success = False
outcome = "Error during swipe: No driver or context provided."
except Exception as e: # pylint: disable=broad-exception-caught
success = False
outcome = f"Error during swipe: {e}"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid or "",
content=outcome,
status="success" if success else "error",
)
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={VALIDATOR_MESSAGES_KEY: [tool_message]},
agent="validator",
)
)
return outcome
# Universal tool instance & aliases
swipe = SwipeTool()
Swipe = SwipeTool
ToolRegistry.register(swipe)
def get_swipe_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports swipe as a LangChain BaseTool."""
return trace_langchain_tool(swipe.to_langchain_tool(ctx), ctx)
PRESS_KEY_DOCSTRING = "[ACTION] Press a physical or virtual system button."
class PressKeyTool(ArtemisTool):
"""Universal tool for pressing a physical or virtual system button."""
def __init__(self, category: ToolCategory = "action"):
super().__init__(
name="press_key",
description=PRESS_KEY_DOCSTRING,
args_schema=PressKeyArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
key: Literal["ENTER", "BACK", "HOME", "APP_SWITCH"] | str | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
k = key if key is not None else (kwargs.get("key") or kwargs.get("Key") or "")
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
st = state if state is not None else kwargs.get("state")
try:
if not k:
raise ValueError("Key parameter is required.")
if driver is not None and hasattr(driver, "press_key"):
success = await driver.press_key(k)
outcome = (
f"Pressed key '{k}' successfully." if success else f"Failed to press key '{k}'."
)
elif ctx is not None:
controller = UnifiedMobileController(ctx)
success = False
if k == "ENTER":
success = await controller.press_enter()
elif k == "BACK":
success = await controller.go_back()
elif k == "HOME":
success = await controller.go_home()
elif k == "APP_SWITCH":
success = await controller.press_key("KEYCODE_APP_SWITCH")
else:
success = await controller.press_key(k)
outcome = (
f"Pressed key '{k}' successfully." if success else f"Failed to press key '{k}'."
)
else:
success = False
outcome = "Error during press key: No driver or context provided."
except Exception as e: # pylint: disable=broad-exception-caught
success = False
outcome = f"Error during press key: {e}"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid or "",
content=outcome,
status="success" if success else "error",
)
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={VALIDATOR_MESSAGES_KEY: [tool_message]},
agent="validator",
)
)
return outcome
# Universal tool instance & aliases
press_key = PressKeyTool()
PressKey = PressKeyTool
ToolRegistry.register(press_key)
def get_press_key_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports press_key as a LangChain BaseTool."""
return trace_langchain_tool(press_key.to_langchain_tool(ctx), ctx)
# Wrappers to expose
click_wrapper = ToolWrapper(
tool_fn_getter=get_click_tool,
on_success_fn=lambda *args, **kwargs: "Success",
on_failure_fn=lambda *args, **kwargs: "Failure",
)
long_press_wrapper = ToolWrapper(
tool_fn_getter=get_long_press_tool,
on_success_fn=lambda *args, **kwargs: "Success",
on_failure_fn=lambda *args, **kwargs: "Failure",
)
input_text_wrapper = ToolWrapper(
tool_fn_getter=get_input_text_tool,
on_success_fn=lambda *args, **kwargs: "Success",
on_failure_fn=lambda *args, **kwargs: "Failure",
)
swipe_wrapper = ToolWrapper(
tool_fn_getter=get_swipe_tool,
on_success_fn=lambda *args, **kwargs: "Success",
on_failure_fn=lambda *args, **kwargs: "Failure",
)
press_key_wrapper = ToolWrapper(
tool_fn_getter=get_press_key_tool,
on_success_fn=lambda *args, **kwargs: "Success",
on_failure_fn=lambda *args, **kwargs: "Failure",
)
+215
View File
@@ -0,0 +1,215 @@
# 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 typing import Any
from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool
from langgraph.types import Command
from pydantic import BaseModel, Field
from artemis.agents.hopper.hopper import HopperOutput, hopper
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.controllers.platform_specific_commands_controller import (
list_packages_async,
)
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.app_launch_utils import launch_app_with_retries
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class LaunchAppArgs(BaseModel):
"""Arguments schema for launching an application."""
model_config = {"ignored_types": (CyFunctionDetector,)}
app_name: str = Field(
...,
description="The natural language name of the application to launch.",
)
LAUNCH_APP_DOCSTRING = (
"[ACTION] Finds and launches an application on the device using its natural language name."
)
async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None:
"""Finds the package name for a given application name.
Returns None if package not found or on error.
"""
package_cache = getattr(ctx, "package_cache", None)
if package_cache is None or not isinstance(package_cache, dict):
try:
ctx.package_cache = {}
package_cache = ctx.package_cache
except Exception: # pylint: disable=broad-exception-caught
package_cache = {}
if isinstance(package_cache, dict) and app_name in package_cache:
logger.info(f"Cache hit for app '{app_name}': {package_cache[app_name]}")
return package_cache[app_name]
try:
all_packages = await list_packages_async(ctx=ctx)
package_set = {p.strip() for p in all_packages.split("\n") if p.strip()}
# Fast path: If app_name is already directly an installed package name
if app_name in package_set:
if isinstance(package_cache, dict):
package_cache[app_name] = app_name
return app_name
hopper_output: HopperOutput = await hopper(
ctx=ctx,
request=(f"I'm looking for the package name of the following app: '{app_name}'"),
data=all_packages,
use_fallback=use_fallback,
)
if not hopper_output.found or not hopper_output.output:
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None
package_name = hopper_output.output.strip()
if package_name not in package_set:
logger.warning(
f"Hopper returned package '{package_name}' for '{app_name}', "
"but it is NOT physically installed on the device!"
)
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None
if isinstance(package_cache, dict):
package_cache[app_name] = package_name
return package_name
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to find package for '{app_name}': {e}")
return None
class LaunchAppTool(ArtemisTool):
"""Universal tool for finding and launching an application on the device."""
def __init__(self, category: ToolCategory = "action"):
super().__init__(
name="launch_app",
description=LAUNCH_APP_DOCSTRING,
args_schema=LaunchAppArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
app_name: str | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
app = (
app_name
if app_name is not None
else (kwargs.get("app_name") or kwargs.get("AppName") or "")
)
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
st = state if state is not None else kwargs.get("state")
success = False
error_msg = None
try:
if not app:
raise ValueError("app_name parameter is required.")
if ctx is not None:
package_name = await find_package(ctx=ctx, app_name=app)
if not package_name:
success = False
outcome = f"Failed to launch app '{app}': Package not found."
error_msg = "Package not found."
else:
success, error_msg = await launch_app_with_retries(
ctx=ctx, app_package=package_name
)
outcome = (
f"App '{app}' launched successfully."
if success
else f"Failed to launch app '{app}': {error_msg}"
)
elif driver is not None and hasattr(driver, "launch_app"):
success = await driver.launch_app(app)
outcome = (
f"App '{app}' launched successfully."
if success
else f"Failed to launch app '{app}'."
)
error_msg = None if success else "Launch failed."
else:
success = False
outcome = "Error during launch app: No driver or context provided."
error_msg = "No driver or context provided."
except Exception as e: # pylint: disable=broad-exception-caught
success = False
outcome = f"Error during launch app: {e}"
error_msg = str(e)
if st and callable(getattr(st, "asanitize_update", None)):
additional_kwargs = {} if success else ({"error": error_msg} if error_msg else {})
tool_message = ToolMessage(
tool_call_id=tcid or "",
content=outcome,
additional_kwargs=additional_kwargs,
status="success" if success else "error",
)
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={
VALIDATOR_MESSAGES_KEY: [tool_message],
},
agent="validator",
)
)
return outcome
# Universal tool instance & aliases
launch_app = LaunchAppTool()
LaunchApp = LaunchAppTool
ToolRegistry.register(launch_app)
def get_launch_app_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports launch_app as a LangChain BaseTool."""
return trace_langchain_tool(launch_app.to_langchain_tool(ctx), ctx)
launch_app_wrapper = ToolWrapper(
tool_fn_getter=get_launch_app_tool,
on_success_fn=lambda *args, **kwargs: "Success",
on_failure_fn=lambda *args, **kwargs: "Failure",
)
+138
View File
@@ -0,0 +1,138 @@
# 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 datetime import datetime, timedelta
import time
from artemis.context import ArtemisContext
from artemis.controllers.platform_specific_commands_controller import get_adb_device
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
def parse_log_time(time_str: str) -> datetime:
"""Parses a log timestamp string into a datetime object."""
now = datetime.now()
current_year = now.year
def _try_parse(year: int) -> datetime | None:
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(f"{year}-{time_str}", fmt)
except ValueError:
pass
return None
dt = _try_parse(current_year)
if dt is None:
raise ValueError(f"Unsupported time format: {time_str}")
# If parsed time is in the future (allowing 1 hour grace for clock drift),
# we assume it belongs to the previous year.
if dt > now + timedelta(hours=1):
prev_dt = _try_parse(current_year - 1)
if prev_dt is not None:
dt = prev_dt
return dt
def resolve_time(
time_input: str | float | None, session_start: float | None
) -> tuple[str | None, datetime | None]:
"""Resolves relative or absolute time input into formatted string and datetime."""
if time_input is None:
return None, None
if isinstance(time_input, (int, float)) or (
isinstance(time_input, str) and time_input.replace(".", "", 1).isdigit()
):
# It's relative seconds
if session_start is None:
raise ValueError("Cannot resolve relative time without session start time.")
rel_sec = float(time_input)
abs_ts = session_start + rel_sec
t_struct = time.localtime(abs_ts)
t_str = time.strftime("%m-%d %H:%M:%S", t_struct)
ms = int((abs_ts - int(abs_ts)) * 1000)
formatted_str = f"{t_str}.{ms:03d}"
dt = datetime.fromtimestamp(abs_ts)
return formatted_str, dt
# Assume it's absolute time string "MM-DD HH:MM:SS.ms"
try:
dt = parse_log_time(str(time_input))
except ValueError as e:
raise ValueError(f"Unsupported time format: {time_input}") from e
return str(time_input), dt
# pylint: disable=too-many-locals
def fetch_and_filter_logs(
ctx: ArtemisContext,
lines: int = 1000,
since_time: str | None = None,
until_time: str | None = None,
) -> str:
"""Fetches logs from ADB device and filters them by time if needed."""
device = get_adb_device(ctx)
session_start = ctx.data_engine.session_start_time if ctx.data_engine else None
since_str, since_dt = resolve_time(since_time, session_start)
_, until_dt = resolve_time(until_time, session_start)
if since_str:
try:
logger.info(f"Attempting to fetch logs since {since_str}...")
logs = device.shell(f'logcat -v threadtime -t "{since_str}"')
except Exception as e: # pylint: disable=broad-exception-caught
logger.warning(
f"Failed to fetch logs since time directly: {e}. Falling back"
" to reading lines and filtering."
)
logs = device.shell(f"logcat -v threadtime -t {lines}")
else:
logger.info(f"Reading last {lines} lines of logs...")
logs = device.shell(f"logcat -v threadtime -t {lines}")
if since_dt or until_dt:
filtered_lines = []
for line in logs.splitlines():
if len(line) >= 18:
log_time_str = line[:18]
try:
log_dt = parse_log_time(log_time_str)
if since_dt and log_dt < since_dt:
continue
if until_dt and log_dt > until_dt:
continue
filtered_lines.append(line)
except ValueError:
pass
# Cap to the requested line limit so timestamp fetches don't overflow context
if 0 < lines < len(filtered_lines):
filtered_lines = filtered_lines[-lines:]
logs = "\n".join(filtered_lines)
else:
# Ensure fallback capping even when no datetime parsing occurred
log_lines = logs.splitlines()
if 0 < lines < len(log_lines):
logs = "\n".join(log_lines[-lines:])
return logs
+210
View File
@@ -0,0 +1,210 @@
# 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.
import base64
import json
from pathlib import Path
from typing import Any
from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool
from langgraph.types import Command
from pydantic import BaseModel
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.utils.logger import get_logger
from artemis.utils.ocr_api import perform_ocr
class OcrArgs(BaseModel):
"""Arguments schema for OCR recognition."""
logger = get_logger(__name__)
OCR_DOCSTRING = (
"[EXPLORER] Performs OCR on the current screen and returns the detected"
" text along with their coordinates."
)
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
async def run_ocr_core(
ctx: ArtemisContext | None = None,
state: State | None = None,
driver: BaseDeviceDriver | None = None,
) -> str:
"""Universal OCR core function: reads screenshot from state or driver and returns results."""
screenshot_b64 = None
screenshot_path = getattr(state, "latest_screenshot", None) if state else None
if screenshot_path:
if not Path(screenshot_path).exists():
logger.error(f"Screenshot file does not exist: {screenshot_path}")
raise FileNotFoundError(f"Screenshot file does not exist: {screenshot_path}")
try:
with open(screenshot_path, "rb") as f:
screenshot_b64 = base64.b64encode(f.read()).decode("utf-8")
logger.info(f"OCR loaded screenshot from file: {screenshot_path}")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to read screenshot from {screenshot_path}: {e}")
raise e
elif driver is not None and hasattr(driver, "get_screen_data"):
try:
screen_data = await driver.get_screen_data()
if hasattr(screen_data, "screenshot_base64") and screen_data.screenshot_base64:
screenshot_b64 = screen_data.screenshot_base64
elif hasattr(screen_data, "screenshot_bytes") and screen_data.screenshot_bytes:
screenshot_b64 = base64.b64encode(screen_data.screenshot_bytes).decode("utf-8")
elif hasattr(screen_data, "base64") and screen_data.base64:
screenshot_b64 = screen_data.base64
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to capture screenshot from driver: {e}")
raise e
else:
logger.error("No screenshot path found in state.latest_screenshot and no driver available")
raise ValueError(
"No screenshot path found in state.latest_screenshot and no driver available"
)
if not screenshot_b64:
raise ValueError("Failed to obtain screenshot for OCR.")
width = 1080
height = 2400
try:
if ctx and hasattr(ctx, "device") and ctx.device:
w = getattr(ctx.device, "device_width", 1080)
h = getattr(ctx.device, "device_height", 2400)
if isinstance(w, int):
width = w
if isinstance(h, int):
height = h
elif driver and hasattr(driver, "screen_size") and driver.screen_size:
width, height = driver.screen_size
except Exception as e: # pylint: disable=broad-exception-caught
logger.warning(f"Failed to get device resolution for OCR normalization: {e}")
results = await perform_ocr(screenshot_b64)
if results:
normalized_results = []
for item in results:
text = item.get("text")
vertices = item.get("position") or []
# Calculate center point
x_coords = [v.get("x", 0) for v in vertices]
y_coords = [v.get("y", 0) for v in vertices]
if x_coords and y_coords:
min_x, max_x = min(x_coords), max(x_coords)
min_y, max_y = min(y_coords), max(y_coords)
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
# Normalize to 0-1000
norm_x = int(max(0, min(1000, center_x * 1000 / width)))
norm_y = int(max(0, min(1000, center_y * 1000 / height)))
normalized_results.append({"text": text, "coordinates": [norm_x, norm_y]})
else:
normalized_results.append({"text": text, "coordinates": None})
return json.dumps(normalized_results, ensure_ascii=False, indent=2)
return "No text detected on the screen."
class OcrRecognitionTool(ArtemisTool):
"""Universal tool for performing OCR on the current screen."""
def __init__(self, category: ToolCategory = "explorer"):
super().__init__(
name="ocr_recognition",
description=OCR_DOCSTRING,
args_schema=OcrArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
st = state if state is not None else kwargs.get("state")
has_failed = False
output = ""
error_message = None
try:
output = await run_ocr_core(ctx=ctx, state=st, driver=driver)
except Exception as e: # pylint: disable=broad-exception-caught
has_failed = True
error_message = f"Failed to perform OCR: {str(e)}"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid or "",
content=ocr_recognition_wrapper.on_failure_fn(error_message)
if has_failed
else ocr_recognition_wrapper.on_success_fn(output),
additional_kwargs={"error": error_message} if has_failed else {},
status="error" if has_failed else "success",
)
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={
VALIDATOR_MESSAGES_KEY: [tool_message],
},
agent="validator",
)
)
if has_failed:
return ocr_recognition_wrapper.on_failure_fn(error_message)
return ocr_recognition_wrapper.on_success_fn(output)
# Universal tool instance & aliases
ocr_recognition = OcrRecognitionTool()
OcrRecognition = OcrRecognitionTool
OCRRecognition = OcrRecognitionTool
OcrTool = OcrRecognitionTool
ToolRegistry.register(ocr_recognition)
def get_ocr_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports ocr_recognition as a LangChain BaseTool."""
return trace_langchain_tool(ocr_recognition.to_langchain_tool(ctx), ctx)
ocr_recognition_wrapper = ToolWrapper(
tool_fn_getter=get_ocr_tool,
on_success_fn=lambda output: f"OCR Recognition successful. Results:\n{output}",
on_failure_fn=lambda error: f"OCR Recognition failed: {error}",
)
+106
View File
@@ -0,0 +1,106 @@
# 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.
import inspect
from typing import Any
from langchain_core.tools import BaseTool
from pydantic import BaseModel
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class GetUiHierarchyArgs(BaseModel):
"""Arguments schema for getting UI hierarchy."""
model_config = {"ignored_types": (CyFunctionDetector,)}
GET_UI_HIERARCHY_DOCSTRING = (
"[DIAGNOSTIC] Retrieves the current screen UI hierarchy XML using a persistent"
" UI Automator client.\n\nUse this instead of running 'uiautomator dump'"
" command via adb shell, as it is much faster and more stable."
)
class GetUiHierarchyTool(ArtemisTool):
"""Universal tool for retrieving screen UI hierarchy XML."""
def __init__(self, category: ToolCategory = "diagnostic"):
super().__init__(
name="get_ui_hierarchy",
description=GET_UI_HIERARCHY_DOCSTRING,
args_schema=GetUiHierarchyArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None,
ctx: ArtemisContext | None = None,
tool_call_id: str | None = None, # pylint: disable=unused-argument
state: State | None = None, # pylint: disable=unused-argument
**kwargs: Any,
) -> str:
try:
logger.info("get_ui_hierarchy tool called.")
if ctx is not None and getattr(ctx, "ui_adb_client", None) is not None:
xml_hierarchy = ctx.ui_adb_client.get_hierarchy()
if inspect.iscoroutine(xml_hierarchy):
xml_hierarchy = await xml_hierarchy
return str(xml_hierarchy)
if driver is not None and hasattr(driver, "get_screen_data"):
screen_data = await driver.get_screen_data()
xml_hierarchy = getattr(screen_data, "ui_hierarchy_xml", None)
if xml_hierarchy:
return str(xml_hierarchy)
return "Error retrieving UI hierarchy: No UI hierarchy in screen data."
if driver is not None and hasattr(driver, "get_ui_hierarchy"):
xml_hierarchy = await driver.get_ui_hierarchy()
if inspect.iscoroutine(xml_hierarchy):
xml_hierarchy = await xml_hierarchy
return str(xml_hierarchy)
return "Error retrieving UI hierarchy: No UI Automator client or driver provided."
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to get UI hierarchy: {e}")
return f"Error retrieving UI hierarchy: {e}"
# Universal tool instance & aliases
get_ui_hierarchy = GetUiHierarchyTool()
GetUiHierarchy = GetUiHierarchyTool
GetUIHierarchy = GetUiHierarchyTool
ToolRegistry.register(get_ui_hierarchy)
def get_ui_hierarchy_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports get_ui_hierarchy as a LangChain BaseTool."""
return trace_langchain_tool(get_ui_hierarchy.to_langchain_tool(ctx), ctx)
ui_hierarchy_wrapper = ToolWrapper(
tool_fn_getter=get_ui_hierarchy_tool,
on_success_fn=lambda output: output,
on_failure_fn=lambda error: f"Failed to retrieve UI hierarchy: {error}",
)
+116
View File
@@ -0,0 +1,116 @@
# 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 typing import Any
from langchain_core.tools.base import BaseTool
from pydantic import BaseModel, Field
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.mobile.log_utils import fetch_and_filter_logs
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class ReadLogsArgs(BaseModel):
"""Arguments schema for read_logs tool."""
lines: int | None = Field(
default=200,
description=(
"Number of recent log lines to read. Default is 200. Use larger"
" numbers only if necessary."
),
)
since_time: str | None = Field(
default=None,
description=(
"Target timestamp in 'MM-DD HH:MM:SS.ms' format OR relative seconds"
" (e.g., '15.5') to read logs since. If provided, we will try to"
" fetch logs starting from this time."
),
)
until_time: str | None = Field(
default=None,
description=(
"Target timestamp in 'MM-DD HH:MM:SS.ms' format OR relative seconds"
" (e.g., '20.0') to read logs until. If provided, we will filter"
" logs up to this time."
),
)
READ_LOGS_DOCSTRING = (
"[DIAGNOSTIC] Fetches raw Android logs.\n\n"
"- Use when: You need to scan logs for a time window or see the sequence"
" of events.\n"
"- Input: lines (int, optional): Number of lines to read (default 200)."
" since_time (str, optional): Timestamp or relative seconds to read since."
" until_time (str, optional): Timestamp or relative seconds to read until.\n"
"- Output: Raw log text."
)
class ReadLogsTool(ArtemisTool):
"""Universal tool for fetching raw Android logs."""
def __init__(self, category: ToolCategory = "diagnostic"):
super().__init__(
name="read_logs",
description=READ_LOGS_DOCSTRING,
args_schema=ReadLogsArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
lines: int | None = 200,
since_time: str | None = None,
until_time: str | None = None,
tool_call_id: str | None = None, # pylint: disable=unused-argument
state: State | None = None, # pylint: disable=unused-argument
**kwargs: Any,
) -> str:
try:
num_lines = 200 if lines is None else lines
logs = fetch_and_filter_logs(
ctx=ctx,
lines=num_lines,
since_time=since_time,
until_time=until_time,
)
return logs
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to read logs: {e}")
return f"Failed to read logs: {e}"
# Universal tool instance & aliases
read_logs = ReadLogsTool()
ReadLogs = ReadLogsTool
ReadLogsToolAlias = ReadLogsTool
ToolRegistry.register(read_logs)
def get_read_logs_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports read_logs as a LangChain BaseTool."""
return trace_langchain_tool(read_logs.to_langchain_tool(ctx), ctx)
+237
View File
@@ -0,0 +1,237 @@
# 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.
import re
from typing import Any
from langchain_core.tools.base import BaseTool
from pydantic import BaseModel, Field
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.mobile.log_utils import fetch_and_filter_logs
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
def search_and_merge_logs(
logs: str,
keyword: str,
context_lines: int = 50,
is_regex: bool = False,
) -> str:
"""Searches for keyword/pattern in logs and merges overlapping context windows."""
log_lines = logs.splitlines()
if is_regex:
try:
pattern = re.compile(keyword, re.IGNORECASE)
except re.error as e:
return f"Error: Invalid regex: {e}"
else:
keyword_lower = keyword.lower()
# 1. Collect all raw match ranges
ranges = []
for i, line in enumerate(log_lines):
matched = False
if is_regex:
if pattern.search(line):
matched = True
else:
if keyword_lower in line.lower():
matched = True
if matched:
start = max(0, i - context_lines)
end = min(len(log_lines), i + context_lines + 1)
ranges.append((start, end, i))
if not ranges:
return f"No matches found for '{keyword}'."
# 2. Merge overlapping/adjacent ranges
ranges.sort(key=lambda x: x[0])
merged_ranges = []
curr_start, curr_end, curr_matches = (
ranges[0][0],
ranges[0][1],
[ranges[0][2]],
)
for start, end, match_idx in ranges[1:]:
if start <= curr_end: # Overlaps or adjacent
curr_end = max(curr_end, end)
curr_matches.append(match_idx)
else:
merged_ranges.append((curr_start, curr_end, curr_matches))
curr_start = start
curr_end = end
curr_matches = [match_idx]
merged_ranges.append((curr_start, curr_end, curr_matches))
# 3. Format output
matches = []
total_lines = 0
max_return_lines = 300
truncated = False
for start, end, match_indices in merged_ranges:
if total_lines >= max_return_lines:
truncated = True
break
match_str = ", ".join(map(str, match_indices))
matches.append(f"--- Match at line(s) {match_str} ---")
for idx in range(start, end):
line_content = log_lines[idx]
if idx in match_indices:
matches.append(f"{idx:4d}: [MATCH] {line_content}")
else:
matches.append(f"{idx:4d}: {line_content}")
total_lines += 1
if total_lines >= max_return_lines:
truncated = True
break
matches.append("-" * 20)
if truncated:
matches.append(
"\n... [SYSTEM WARNING: Output truncated to protect Context Window."
" Too many matches exceeded the 300 lines limit. You MUST use a"
" more specific keyword, narrow the time range, or use"
" context_lines=0.] ..."
)
return "\n".join(matches)
class SearchLogsArgs(BaseModel):
"""Arguments schema for search_logs tool."""
keyword: str = Field(
...,
description=("The keyword or regular expression to search for. Case-insensitive."),
)
is_regex: bool = Field(
default=False,
description=("Whether to treat keyword as a regular expression. Default is False."),
)
lines: int | None = Field(
default=10000,
description=("Number of recent log lines to search in. Default is 10000."),
)
since_time: str | None = Field(
default=None,
description=(
"Target timestamp in 'MM-DD HH:MM:SS.ms' format OR relative seconds"
" (e.g., '15.5') to search logs since. If provided, we will search"
" logs starting from this time."
),
)
until_time: str | None = Field(
default=None,
description=(
"Target timestamp in 'MM-DD HH:MM:SS.ms' format OR relative seconds"
" (e.g., '20.0') to search logs until. If provided, we will filter"
" logs up to this time."
),
)
context_lines: int | None = Field(
default=0,
description=(
"Number of lines to include before and after each match for"
" context. Default is 0. Only increase if you need more context"
" around a very specific match."
),
)
SEARCH_LOGS_DOCSTRING = (
"[DIAGNOSTIC] Searches logs for a keyword or regex pattern.\n\n"
"- Use when: Looking for specific errors, tags, or keywords without"
" reading all logs.\n"
"- Input: keyword (str): Search pattern. is_regex (bool, optional):"
" Treat keyword as regex. context_lines (int, optional): Lines around"
" match. since_time (str, optional): Timestamp or relative seconds to"
" search since. until_time (str, optional): Timestamp or relative seconds"
" to search until.\n"
"- Output: Matching lines with context."
)
class SearchLogsTool(ArtemisTool):
"""Universal tool for searching logs for keywords or regex patterns."""
def __init__(self, category: ToolCategory = "diagnostic"):
super().__init__(
name="search_logs",
description=SEARCH_LOGS_DOCSTRING,
args_schema=SearchLogsArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
keyword: str = "",
is_regex: bool = False,
lines: int | None = 10000,
since_time: str | None = None,
until_time: str | None = None,
context_lines: int | None = 0,
tool_call_id: str | None = None, # pylint: disable=unused-argument
state: State | None = None, # pylint: disable=unused-argument
**kwargs: Any,
) -> str:
try:
num_lines = 10000 if lines is None else lines
ctx_lines = 0 if context_lines is None else context_lines
logs = fetch_and_filter_logs(
ctx=ctx,
lines=num_lines,
since_time=since_time,
until_time=until_time,
)
return search_and_merge_logs(
logs=logs,
keyword=keyword,
context_lines=ctx_lines,
is_regex=is_regex,
)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to search logs: {e}")
return f"Error searching logs: {e}"
# Universal tool instance & aliases
search_logs = SearchLogsTool()
SearchLogs = SearchLogsTool
SearchLogsToolAlias = SearchLogsTool
ToolRegistry.register(search_logs)
def get_search_logs_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports search_logs as a LangChain BaseTool."""
return trace_langchain_tool(search_logs.to_langchain_tool(ctx), ctx)
+246
View File
@@ -0,0 +1,246 @@
# 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 Object Detection tool for visual element detection and localization."""
import json
from pathlib import Path
from typing import Any
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.agents.object_detector.object_detector import (
_create_error_command,
_create_success_command,
_run_object_detection,
)
from artemis.context import ArtemisContext
from artemis.data_engine.trace import (
TraceSpan,
trace_langchain_tool,
)
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
# pylint: disable=too-few-public-methods
class ObjectDetectionArgs(BaseModel):
"""Arguments schema for object detection with explicit screenshot path."""
model_config = {"ignored_types": (CyFunctionDetector,)}
image_path: str = Field(..., description="Absolute path to the screenshot file.")
queries: list[str] = Field(..., description="A list of single query strings to find.")
# pylint: disable=too-few-public-methods
class OperatorObjectDetectionArgs(BaseModel):
"""Arguments schema for operator object detection using latest screenshot from state."""
model_config = {"ignored_types": (CyFunctionDetector,)}
queries: list[str] = Field(..., description="A list of single query strings to find.")
def _load_prompt_templates() -> list[str]:
"""Loads prompt templates and instructions from object_detector.json config."""
prompt_path = Path(__file__).parent.parent.joinpath(
"agents", "object_detector", "object_detector.json"
)
try:
with open(prompt_path, encoding="utf-8") as f:
prompt_config = json.load(f)
base_templates = prompt_config.get("templates", [])
instructions = prompt_config.get("instructions", "")
except Exception as e: # pylint: disable=broad-exception-caught
logger.warning(f"Failed to load prompt config from JSON: {e}. Using fallbacks.")
base_templates = ["Point to the following objects in the provided image: {labels_str}."]
instructions = ""
return [f"{t}\n\n{instructions}" for t in base_templates]
# pylint: disable=too-many-arguments,too-many-positional-arguments,broad-exception-caught
async def _run_object_detection_logic(
ctx: ArtemisContext | None,
image_path: str | None,
queries: list[str] | None,
state: State | None = None,
tool_call_id: str | None = None,
wrapper: ToolWrapper | None = None,
) -> Any:
"""Core async execution logic for object detection."""
logger.info("Object detection tool called.")
active_wrapper = wrapper or object_detection_wrapper
templates = _load_prompt_templates()
target_image = image_path
if not target_image and state and hasattr(state, "latest_screenshot"):
target_image = state.latest_screenshot
if not target_image:
error_msg = "No image path provided and no screenshot available in state."
if state and callable(getattr(state, "asanitize_update", None)):
return await _create_error_command(ctx, state, tool_call_id, error_msg, active_wrapper)
return f"Object Detection failed: {error_msg}"
target_queries = queries or []
try:
with TraceSpan(name="run_object_detection", ctx=ctx) as span:
result = await _run_object_detection(ctx, target_image, target_queries, templates)
span.result = result
output = json.dumps(result) if not isinstance(result, str) else result
if state and callable(getattr(state, "asanitize_update", None)):
return await _create_success_command(ctx, state, tool_call_id, output, active_wrapper)
return output
except Exception as e:
logger.error(f"Object detection execution failed: {e}")
if state and callable(getattr(state, "asanitize_update", None)):
return await _create_error_command(ctx, state, tool_call_id, str(e), active_wrapper)
return f"Object Detection failed: {e}"
class ObjectDetectionTool(ArtemisTool):
"""Universal tool for detecting visual elements or icons in a mobile screen image."""
def __init__(
self,
name: str = "object_detection",
description: str | None = None,
args_schema: type[BaseModel] = ObjectDetectionArgs,
category: ToolCategory = "perception",
):
super().__init__(
name=name,
description=description
or (
"[EXPLORER] Detects specific visual elements or icons in a mobile screen image. "
"Use this tool when you need to find the coordinates of UI elements like "
"buttons, icons, or text fields."
),
args_schema=args_schema,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
image_path: str | None = None,
queries: list[str] | None = None,
state: State | None = None,
tool_call_id: str | None = None,
**kwargs: Any,
) -> Any:
img_path = (
image_path
if image_path is not None
else (kwargs.get("image_path") or kwargs.get("ImagePath"))
)
qs = (
queries
if queries is not None
else (kwargs.get("queries") or kwargs.get("Queries") or [])
)
st = state if state is not None else kwargs.get("state")
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
wrapper = kwargs.get("wrapper") or object_detection_wrapper
return await _run_object_detection_logic(
ctx=ctx,
image_path=img_path,
queries=qs,
state=st,
tool_call_id=tcid,
wrapper=wrapper,
)
class OperatorObjectDetectionTool(ObjectDetectionTool):
"""Universal tool for operator detecting visual elements using screenshot from state."""
def __init__(self):
super().__init__(
name="object_detection",
description=(
"[EXPLORER] Detects specific visual elements or icons in a mobile screen image. "
"Use this tool when you need to find the coordinates of UI elements like "
"buttons, icons, or text fields."
),
args_schema=OperatorObjectDetectionArgs,
category="perception",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
image_path: str | None = None,
queries: list[str] | None = None,
state: State | None = None,
tool_call_id: str | None = None,
**kwargs: Any,
) -> Any:
return await super().execute(
driver=driver,
ctx=ctx,
image_path=image_path,
queries=queries,
state=state,
tool_call_id=tool_call_id,
wrapper=operator_object_detection_wrapper,
**kwargs,
)
# Universal tool instances & aliases
object_detection = ObjectDetectionTool()
ObjectDetection = ObjectDetectionTool
ObjectDetectorTool = ObjectDetectionTool
operator_object_detection = OperatorObjectDetectionTool()
OperatorObjectDetection = OperatorObjectDetectionTool
OperatorObjectDetectorTool = OperatorObjectDetectionTool
ToolRegistry.register(object_detection)
def get_object_detector_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports object_detection as a LangChain BaseTool."""
return trace_langchain_tool(object_detection.to_langchain_tool(ctx), ctx)
def get_operator_object_detector_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports operator_object_detection as a LangChain BaseTool."""
return trace_langchain_tool(operator_object_detection.to_langchain_tool(ctx), ctx)
object_detection_wrapper = ToolWrapper(
tool_fn_getter=get_object_detector_tool,
on_success_fn=lambda output: f"Object Detection successful. Results:\n{output}",
on_failure_fn=lambda error: f"Object Detection failed: {error}",
)
operator_object_detection_wrapper = ToolWrapper(
tool_fn_getter=get_operator_object_detector_tool,
on_success_fn=lambda output: f"Object Detection successful. Results:\n{output}",
on_failure_fn=lambda error: f"Object Detection failed: {error}",
)
+43
View File
@@ -0,0 +1,43 @@
# 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.
"""Visual Object and Icon Detection perception tool."""
from pydantic import BaseModel, Field
from artemis.tools.base import artemis_tool
from artemis.drivers.base import BaseDeviceDriver
class ObjectDetectArgs(BaseModel):
"""Arguments schema for object detection."""
icon_description: str = Field(
..., description="Description of the icon, button, or graphic element."
)
@artemis_tool(
name="detect_object",
description=(
"[PERCEPTION] Detects visual icon or UI element on screen using multimodal localization."
),
args_schema=ObjectDetectArgs,
category="perception",
)
async def detect_object(
icon_description: str,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
) -> str:
"""Detects visual icon or UI element on screen using multimodal localization."""
return f"Object Detect: Found '{icon_description}' at normalized [500, 500]"
+46
View File
@@ -0,0 +1,46 @@
# 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.
"""Optical Character Recognition (OCR) perception tool."""
from pydantic import BaseModel, Field
from artemis.tools.base import artemis_tool
from artemis.drivers.base import BaseDeviceDriver
class OCRRecognitionArgs(BaseModel):
"""Arguments schema for OCR recognition."""
query_text: str | None = Field(
default=None,
description="Optional target text snippet to search on screen.",
)
@artemis_tool(
name="ocr_recognize",
description=(
"[PERCEPTION] Performs OCR on the active screen to extract visible text and coordinates."
),
args_schema=OCRRecognitionArgs,
category="perception",
)
async def ocr_recognize(
query_text: str | None = None,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
) -> str:
"""Performs OCR on the active screen to extract visible text and coordinates."""
if query_text:
return f"OCR: Text '{query_text}' located at center [500, 500]"
return "OCR: Extracted visible text items from screen."
+45
View File
@@ -0,0 +1,45 @@
# 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.
"""XML Layout Search perception tool."""
from pydantic import BaseModel, Field
from artemis.tools.base import artemis_tool
from artemis.drivers.base import BaseDeviceDriver
class XMLSearchArgs(BaseModel):
"""Arguments schema for XML layout search."""
query: str = Field(
...,
description="Text, resource ID, or class pattern to search in UI hierarchy.",
)
@artemis_tool(
name="xml_search",
description=(
"[PERCEPTION] Searches Android UI layout hierarchy for elements"
" matching text or resource ID."
),
args_schema=XMLSearchArgs,
category="perception",
)
async def xml_search(
query: str,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
) -> str:
"""Searches Android UI layout hierarchy for elements matching text or resource ID."""
return f"XML Search: Found element matching '{query}'"
+903
View File
@@ -0,0 +1,903 @@
# 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 typing import Any
from langchain_core.messages import ToolMessage
from langchain_core.tools.base import BaseTool
from langgraph.types import Command
from pydantic import BaseModel, Field
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
from artemis.utils.notes import (
APPEND_NOTE_ARG_CONTENT_DESC,
APPEND_NOTE_ARG_KEY_DESC,
APPEND_NOTE_DOCSTRING,
LIST_NOTES_DOCSTRING,
READ_NOTE_ARG_KEY_DESC,
READ_NOTE_DOCSTRING,
SAVE_NOTE_ARG_CONTENT_DESC,
SAVE_NOTE_ARG_KEY_DESC,
SAVE_NOTE_DOCSTRING,
UPDATE_NOTE_ARG_KEY_DESC,
UPDATE_NOTE_ARG_REPLACEMENT_DESC,
UPDATE_NOTE_ARG_TARGET_DESC,
UPDATE_NOTE_DOCSTRING,
append_note_content,
format_list_notes_failure,
format_list_notes_success,
format_read_note_failure,
format_read_note_success,
list_notes_info,
read_note_content,
save_note_content,
update_note_content,
)
logger = get_logger(__name__)
class SaveNoteArgs(BaseModel):
"""Arguments schema for saving notes."""
model_config = {"ignored_types": (CyFunctionDetector,)}
key: str = Field(..., description=SAVE_NOTE_ARG_KEY_DESC)
content: str = Field(..., description=SAVE_NOTE_ARG_CONTENT_DESC)
class AppendNoteArgs(BaseModel):
"""Arguments schema for appending notes."""
model_config = {"ignored_types": (CyFunctionDetector,)}
key: str = Field(..., description=APPEND_NOTE_ARG_KEY_DESC)
content: str = Field(..., description=APPEND_NOTE_ARG_CONTENT_DESC)
class UpdateNoteArgs(BaseModel):
"""Arguments schema for updating notes."""
model_config = {"ignored_types": (CyFunctionDetector,)}
key: str = Field(..., description=UPDATE_NOTE_ARG_KEY_DESC)
target: str = Field(..., description=UPDATE_NOTE_ARG_TARGET_DESC)
replacement: str = Field(..., description=UPDATE_NOTE_ARG_REPLACEMENT_DESC)
class ReadNoteArgs(BaseModel):
"""Arguments schema for reading notes."""
model_config = {"ignored_types": (CyFunctionDetector,)}
key: str = Field(..., description=READ_NOTE_ARG_KEY_DESC)
start_line: int | None = Field(None, description="Start line to read (1-indexed, inclusive)")
end_line: int | None = Field(None, description="End line to read (1-indexed, inclusive)")
class ListNotesArgs(BaseModel):
"""Arguments schema for listing notes."""
model_config = {"ignored_types": (CyFunctionDetector,)}
class SaveNoteTool(ArtemisTool):
"""Universal tool for saving notes to persistent memory."""
def __init__(self):
super().__init__(
name="save_note",
description=SAVE_NOTE_DOCSTRING,
args_schema=SaveNoteArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
key: str | None = None,
content: str | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
k = (
key
if key is not None
else (
kwargs.get("key")
or kwargs.get("Key")
or kwargs.get("title")
or kwargs.get("Title")
or ""
)
)
c = (
content
if content is not None
else (kwargs.get("content") or kwargs.get("Content") or "")
)
tcid = (tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")) or ""
st = state if state is not None else kwargs.get("state")
try:
logger.info(f"save_note called with key='{k}'")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
save_note_content(base_dir, k, c)
agent_outcome = f"Successfully saved note to {k}.md."
status = "success"
except Exception as e: # pylint: disable=broad-exception-caught
agent_outcome = f"Failed to save note {k}.md: {e}"
status = "error"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid,
content=agent_outcome,
status=status,
)
current_agent = "operator"
if hasattr(st, "current_agent") and st.current_agent:
current_agent = st.current_agent
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={
VALIDATOR_MESSAGES_KEY: [tool_message],
},
agent=current_agent,
),
)
return agent_outcome
# Universal tool instance & aliases
save_note = SaveNoteTool()
SaveNote = SaveNoteTool
ToolRegistry.register(save_note)
def get_save_note_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports save_note as a LangChain BaseTool."""
return trace_langchain_tool(save_note.to_langchain_tool(ctx), ctx)
class AppendNoteTool(ArtemisTool):
"""Universal tool for appending text content to notes in persistent memory."""
def __init__(self):
super().__init__(
name="append_note",
description=APPEND_NOTE_DOCSTRING,
args_schema=AppendNoteArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
key: str | None = None,
content: str | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
k = (
key
if key is not None
else (
kwargs.get("key")
or kwargs.get("Key")
or kwargs.get("title")
or kwargs.get("Title")
or ""
)
)
c = (
content
if content is not None
else (kwargs.get("content") or kwargs.get("Content") or "")
)
tcid = (tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")) or ""
st = state if state is not None else kwargs.get("state")
try:
logger.info(f"append_note called with key='{k}'")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
append_note_content(base_dir, k, c)
agent_outcome = f"Successfully appended note to {k}.md."
status = "success"
except Exception as e: # pylint: disable=broad-exception-caught
agent_outcome = f"Failed to append note {k}.md: {e}"
status = "error"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid,
content=agent_outcome,
status=status,
)
current_agent = "operator"
if hasattr(st, "current_agent") and st.current_agent:
current_agent = st.current_agent
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={
VALIDATOR_MESSAGES_KEY: [tool_message],
},
agent=current_agent,
),
)
return agent_outcome
# Universal tool instance & aliases
append_note = AppendNoteTool()
AppendNote = AppendNoteTool
ToolRegistry.register(append_note)
def get_append_note_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports append_note as a LangChain BaseTool."""
return trace_langchain_tool(append_note.to_langchain_tool(ctx), ctx)
class UpdateNoteTool(ArtemisTool):
"""Universal tool for updating notes by replacing a target string in persistent memory."""
def __init__(self):
super().__init__(
name="update_note",
description=UPDATE_NOTE_DOCSTRING,
args_schema=UpdateNoteArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
key: str | None = None,
target: str | None = None,
replacement: str | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
k = (
key
if key is not None
else (
kwargs.get("key")
or kwargs.get("Key")
or kwargs.get("title")
or kwargs.get("Title")
or ""
)
)
t = target if target is not None else (kwargs.get("target") or kwargs.get("Target") or "")
r = (
replacement
if replacement is not None
else (kwargs.get("replacement") or kwargs.get("Replacement") or "")
)
tcid = (tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")) or ""
st = state if state is not None else kwargs.get("state")
try:
logger.info(f"update_note called with key='{k}'")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
warning = update_note_content(base_dir, k, t, r)
if warning:
agent_outcome = f"Successfully updated note '{k}'.\nWARNING: {warning}"
else:
agent_outcome = f"Successfully updated note '{k}'."
status = "success"
except Exception as e: # pylint: disable=broad-exception-caught
agent_outcome = f"Failed to update note '{k}': {e}"
status = "error"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid,
content=agent_outcome,
status=status,
)
current_agent = "operator"
if hasattr(st, "current_agent") and st.current_agent:
current_agent = st.current_agent
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={
VALIDATOR_MESSAGES_KEY: [tool_message],
},
agent=current_agent,
),
)
return agent_outcome
# Universal tool instance & aliases
update_note = UpdateNoteTool()
UpdateNote = UpdateNoteTool
ToolRegistry.register(update_note)
def get_update_note_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports update_note as a LangChain BaseTool."""
return trace_langchain_tool(update_note.to_langchain_tool(ctx), ctx)
class ReadNoteTool(ArtemisTool):
"""Universal tool for reading previously saved notes from persistent memory."""
def __init__(self):
super().__init__(
name="read_note",
description=READ_NOTE_DOCSTRING,
args_schema=ReadNoteArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
key: str | None = None,
start_line: int | None = None,
end_line: int | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
k = (
key
if key is not None
else (
kwargs.get("key")
or kwargs.get("Key")
or kwargs.get("title")
or kwargs.get("Title")
or ""
)
)
sl = (
start_line
if start_line is not None
else (kwargs.get("start_line") or kwargs.get("StartLine"))
)
el = end_line if end_line is not None else (kwargs.get("end_line") or kwargs.get("EndLine"))
tcid = (tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")) or ""
st = state if state is not None else kwargs.get("state")
try:
logger.info(f"read_note called with key='{k}'")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
content = read_note_content(base_dir, k, sl, el)
agent_outcome = read_note_wrapper.on_success_fn(k, content, sl, el)
status = "success"
except Exception as e: # pylint: disable=broad-exception-caught
if "not found" in str(e).lower():
agent_outcome = read_note_wrapper.on_failure_fn(k)
else:
agent_outcome = f"Failed to read note '{k}': {e}"
status = "error"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid,
content=agent_outcome,
status=status,
)
current_agent = "operator"
if hasattr(st, "current_agent") and st.current_agent:
current_agent = st.current_agent
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={
VALIDATOR_MESSAGES_KEY: [tool_message],
},
agent=current_agent,
),
)
return agent_outcome
# Universal tool instance & aliases
read_note = ReadNoteTool()
ReadNote = ReadNoteTool
ToolRegistry.register(read_note)
def get_read_note_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports read_note as a LangChain BaseTool."""
return trace_langchain_tool(read_note.to_langchain_tool(ctx), ctx)
class ListNotesTool(ArtemisTool):
"""Universal tool for listing all stored note keys in persistent memory."""
def __init__(self):
super().__init__(
name="list_notes",
description=LIST_NOTES_DOCSTRING,
args_schema=ListNotesArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
tcid = (tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")) or ""
st = state if state is not None else kwargs.get("state")
try:
logger.info("list_notes called")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
notes_info = list_notes_info(base_dir)
agent_outcome = list_notes_wrapper.on_success_fn(notes_info)
status = "success"
except Exception as e: # pylint: disable=broad-exception-caught
agent_outcome = f"Failed to list notes: {e}"
status = "error"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid,
content=agent_outcome,
status=status,
)
current_agent = "operator"
if hasattr(st, "current_agent") and st.current_agent:
current_agent = st.current_agent
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={
VALIDATOR_MESSAGES_KEY: [tool_message],
},
agent=current_agent,
),
)
return agent_outcome
# Universal tool instance & aliases
list_notes = ListNotesTool()
ListNotes = ListNotesTool
ToolRegistry.register(list_notes)
def get_list_notes_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports list_notes as a LangChain BaseTool."""
return trace_langchain_tool(list_notes.to_langchain_tool(ctx), ctx)
class ReadNotePureTool(ArtemisTool):
"""Universal pure tool for reading previously saved notes from persistent memory."""
def __init__(self):
super().__init__(
name="read_note_pure",
description=READ_NOTE_DOCSTRING,
args_schema=ReadNoteArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
key: str | None = None,
start_line: int | None = None,
end_line: int | None = None,
**kwargs: Any,
) -> str:
k = (
key
if key is not None
else (
kwargs.get("key")
or kwargs.get("Key")
or kwargs.get("title")
or kwargs.get("Title")
or ""
)
)
sl = (
start_line
if start_line is not None
else (kwargs.get("start_line") or kwargs.get("StartLine"))
)
el = end_line if end_line is not None else (kwargs.get("end_line") or kwargs.get("EndLine"))
try:
logger.info(f"read_note_pure called with key='{k}'")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
content = read_note_content(base_dir, k, sl, el)
return format_read_note_success(k, content, sl, el)
except Exception as e: # pylint: disable=broad-exception-caught
return format_read_note_failure(k, str(e))
# Universal pure tool instance & aliases
read_note_pure = ReadNotePureTool()
ReadNotePure = ReadNotePureTool
ToolRegistry.register(read_note_pure)
def get_read_note_tool_pure(ctx: ArtemisContext) -> BaseTool:
"""Exports read_note_pure as a LangChain BaseTool named 'read_note'."""
return read_note_pure.to_langchain_tool(ctx, name="read_note")
class ListNotesPureTool(ArtemisTool):
"""Universal pure tool for listing all stored note keys in persistent memory."""
def __init__(self):
super().__init__(
name="list_notes_pure",
description=LIST_NOTES_DOCSTRING,
args_schema=ListNotesArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
**kwargs: Any,
) -> str:
try:
logger.info("list_notes_pure called")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
notes_info = list_notes_info(base_dir)
return format_list_notes_success(notes_info)
except Exception as e: # pylint: disable=broad-exception-caught
return format_list_notes_failure(str(e))
# Universal pure tool instance & aliases
list_notes_pure = ListNotesPureTool()
ListNotesPure = ListNotesPureTool
ToolRegistry.register(list_notes_pure)
def get_list_notes_tool_pure(ctx: ArtemisContext) -> BaseTool:
"""Exports list_notes_pure as a LangChain BaseTool named 'list_notes'."""
return list_notes_pure.to_langchain_tool(ctx, name="list_notes")
class SaveNotePureTool(ArtemisTool):
"""Universal pure tool for saving notes to persistent memory."""
def __init__(self):
super().__init__(
name="save_note_pure",
description=SAVE_NOTE_DOCSTRING,
args_schema=SaveNoteArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
key: str | None = None,
content: str | None = None,
**kwargs: Any,
) -> str:
k = (
key
if key is not None
else (
kwargs.get("key")
or kwargs.get("Key")
or kwargs.get("title")
or kwargs.get("Title")
or ""
)
)
c = (
content
if content is not None
else (kwargs.get("content") or kwargs.get("Content") or "")
)
try:
logger.info(f"save_note_pure called with key='{k}'")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
save_note_content(base_dir, k, c)
return f"Successfully saved note to {k}.md."
except Exception as e: # pylint: disable=broad-exception-caught
return f"Failed to save note {k}.md: {e}"
# Universal pure tool instance & aliases
save_note_pure = SaveNotePureTool()
SaveNotePure = SaveNotePureTool
ToolRegistry.register(save_note_pure)
def get_save_note_tool_pure(ctx: ArtemisContext) -> BaseTool:
"""Exports save_note_pure as a LangChain BaseTool named 'save_note'."""
return save_note_pure.to_langchain_tool(ctx, name="save_note")
class UpdateNotePureTool(ArtemisTool):
"""Universal pure tool for updating notes by replacing a target string in persistent memory."""
def __init__(self):
super().__init__(
name="update_note_pure",
description=UPDATE_NOTE_DOCSTRING,
args_schema=UpdateNoteArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
key: str | None = None,
target: str | None = None,
replacement: str | None = None,
**kwargs: Any,
) -> str:
k = (
key
if key is not None
else (
kwargs.get("key")
or kwargs.get("Key")
or kwargs.get("title")
or kwargs.get("Title")
or ""
)
)
t = target if target is not None else (kwargs.get("target") or kwargs.get("Target") or "")
r = (
replacement
if replacement is not None
else (kwargs.get("replacement") or kwargs.get("Replacement") or "")
)
try:
logger.info(f"update_note_pure called with key='{k}'")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
warning = update_note_content(base_dir, k, t, r)
if warning:
return f"Successfully updated note '{k}'.\nWARNING: {warning}"
return f"Successfully updated note '{k}'."
except Exception as e: # pylint: disable=broad-exception-caught
return f"Failed to update note '{k}': {e}"
# Universal pure tool instance & aliases
update_note_pure = UpdateNotePureTool()
UpdateNotePure = UpdateNotePureTool
ToolRegistry.register(update_note_pure)
def get_update_note_tool_pure(ctx: ArtemisContext) -> BaseTool:
"""Exports update_note_pure as a LangChain BaseTool named 'update_note'."""
return update_note_pure.to_langchain_tool(ctx, name="update_note")
class AppendNotePureTool(ArtemisTool):
"""Universal pure tool for appending text content to notes in persistent memory."""
def __init__(self):
super().__init__(
name="append_note_pure",
description=APPEND_NOTE_DOCSTRING,
args_schema=AppendNoteArgs,
category="memory",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
key: str | None = None,
content: str | None = None,
**kwargs: Any,
) -> str:
k = (
key
if key is not None
else (
kwargs.get("key")
or kwargs.get("Key")
or kwargs.get("title")
or kwargs.get("Title")
or ""
)
)
c = (
content
if content is not None
else (kwargs.get("content") or kwargs.get("Content") or "")
)
try:
logger.info(f"append_note_pure called with key='{k}'")
base_dir = (
ctx.data_engine.base_dir
if (
ctx
and getattr(ctx, "data_engine", None)
and getattr(ctx.data_engine, "base_dir", None)
)
else "."
)
append_note_content(base_dir, k, c)
return f"Successfully appended note to {k}.md."
except Exception as e: # pylint: disable=broad-exception-caught
return f"Failed to append note {k}.md: {e}"
# Universal pure tool instance & aliases
append_note_pure = AppendNotePureTool()
AppendNotePure = AppendNotePureTool
ToolRegistry.register(append_note_pure)
def get_append_note_tool_pure(ctx: ArtemisContext) -> BaseTool:
"""Exports append_note_pure as a LangChain BaseTool named 'append_note'."""
return append_note_pure.to_langchain_tool(ctx, name="append_note")
save_note_wrapper = ToolWrapper(
tool_fn_getter=get_save_note_tool,
on_success_fn=lambda key: f"Successfully saved note '{key}'.",
on_failure_fn=lambda key: f"Failed to save note '{key}'.",
)
read_note_wrapper = ToolWrapper(
tool_fn_getter=get_read_note_tool,
on_success_fn=format_read_note_success,
on_failure_fn=lambda key: format_read_note_failure(key, "not found"),
)
list_notes_wrapper = ToolWrapper(
tool_fn_getter=get_list_notes_tool,
on_success_fn=format_list_notes_success,
on_failure_fn=lambda: format_list_notes_failure("error"),
)
update_note_wrapper = ToolWrapper(
tool_fn_getter=get_update_note_tool,
on_success_fn=lambda key: f"Successfully updated note '{key}'.",
on_failure_fn=lambda key: f"Failed to update note '{key}'.",
)
append_note_wrapper = ToolWrapper(
tool_fn_getter=get_append_note_tool,
on_success_fn=lambda key: f"Successfully appended note '{key}'.",
on_failure_fn=lambda key: f"Failed to append note '{key}'.",
)
+31
View File
@@ -0,0 +1,31 @@
# 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 artemis.tools.command_tool import (
RunAdbCommandArgs,
RunAdbCommandTool,
RunAdbCommand,
run_adb_command,
)
# Backward-compatibility alias
AdbShellArgs = RunAdbCommandArgs
__all__ = [
"RunAdbCommandArgs",
"RunAdbCommandTool",
"RunAdbCommand",
"run_adb_command",
"AdbShellArgs",
]
+179
View File
@@ -0,0 +1,179 @@
# 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 collections.abc import Callable
import inspect
import time
from typing import Annotated, Any, get_args, get_origin
from unittest.mock import MagicMock, Mock
import uuid
from langchain_core.tools import BaseTool
from langchain_core.tools.base import InjectedToolCallId
from langgraph.prebuilt import InjectedState
from langgraph.types import Command
from pydantic import BaseModel
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.data_engine import engine as engine_mod
from artemis.data_engine.trace import CURRENT_TRACE_ID, smart_serialize
from artemis.graph.state import State
from artemis.tools.types import CyFunctionDetector
class ToolWrapper(BaseModel):
"""Wrapper holding a tool factory and lifecycle callbacks."""
model_config = {"ignored_types": (CyFunctionDetector,)}
tool_fn_getter: Callable[[ArtemisContext], BaseTool]
on_success_fn: Callable[..., str]
on_failure_fn: Callable[..., str]
class CompositeToolWrapper(ToolWrapper):
"""Wrapper holding a composite tool factory and lifecycle callbacks."""
composite_tools_fn_getter: Callable[[ArtemisContext], list[BaseTool]]
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
async def invoke_tool_with_injection(
tool: BaseTool,
args: dict,
tool_call_id: str,
state: State | None = None,
record_trace: bool = True,
) -> Any:
"""Invokes a LangChain tool, manually injecting arguments marked with
InjectedState or InjectedToolCallId if calling the underlying function
directly.
Automatically records type='tool' trace in DataEngine.
"""
final_args = dict(args)
if isinstance(tool, (Mock, MagicMock)):
has_real_coro = hasattr(tool, "coroutine") and not isinstance(
tool.coroutine, (Mock, MagicMock)
)
has_real_func = hasattr(tool, "func") and not isinstance(tool.func, (Mock, MagicMock))
if not has_real_coro and not has_real_func:
if (
state is not None
and hasattr(tool, "args")
and isinstance(tool.args, dict)
and "state" in tool.args
):
final_args["state"] = state
return await tool.ainvoke(final_args)
# For StructuredTool, the original function might be in tool.func or tool.coroutine
func = None
if hasattr(tool, "coroutine") and tool.coroutine:
func = tool.coroutine
elif hasattr(tool, "func") and tool.func:
func = tool.func
if func:
try:
sig = inspect.signature(func)
for param_name, param in sig.parameters.items():
annotation = param.annotation
# Handle Annotated types
if get_origin(annotation) is Annotated:
args_list = get_args(annotation)
if InjectedState in args_list:
final_args[param_name] = state
elif InjectedToolCallId in args_list:
final_args[param_name] = tool_call_id
except Exception: # pylint: disable=broad-exception-caught
# If inspection fails, fall back to just using provided args
pass
trace_id = uuid.uuid4()
parent_id = CURRENT_TRACE_ID.get()
token = CURRENT_TRACE_ID.set(trace_id)
start_time = time.time()
live_engine = getattr(engine_mod, "_CURRENT_DATA_ENGINE", None)
if record_trace and live_engine:
step_id = getattr(live_engine, "current_step_id", None)
live_engine.record_trace(
type="tool",
name=tool.name,
payload={"args": {k: smart_serialize(v) for k, v in args.items()}},
status="running",
parent_trace_id=parent_id,
step_id=step_id,
trace_id=trace_id,
)
execution_status = "running"
execution_result = None
execution_error = None
try:
# Call the function
if inspect.iscoroutinefunction(func):
result = await func(**final_args)
else:
result = func(**final_args)
execution_status = "success"
execution_result = result
return result
except Exception as e:
execution_status = "failed"
execution_error = e
raise e
finally:
duration = time.time() - start_time
if record_trace and live_engine:
step_id = getattr(live_engine, "current_step_id", None)
payload = {"args": {k: smart_serialize(v) for k, v in args.items()}}
if execution_status == "success":
payload["result"] = smart_serialize(get_tool_result_content(execution_result))
elif execution_status == "failed":
payload["error"] = str(execution_error)
else:
execution_status = "terminated"
payload["error"] = "Execution terminated unexpectedly."
live_engine.record_trace(
type="tool",
name=tool.name,
payload=payload,
status=execution_status,
duration=duration,
parent_trace_id=parent_id,
step_id=step_id,
trace_id=trace_id,
)
CURRENT_TRACE_ID.reset(token)
else:
# Fallback to ainvoke if no direct func available
return await tool.ainvoke(final_args)
def get_tool_result_content(result: Any) -> Any:
"""Extracts the content from a tool result, handling Command objects."""
if isinstance(result, Command):
updates = result.update
if VALIDATOR_MESSAGES_KEY in updates:
msgs = updates[VALIDATOR_MESSAGES_KEY]
if msgs and hasattr(msgs[0], "content"):
return msgs[0].content
return result
return result
+72
View File
@@ -0,0 +1,72 @@
# 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 pydantic import BaseModel, Field, model_validator
from artemis.utils.ui_hierarchy import ElementBounds
class _CyFunctionDetectorMeta(type):
"""Metaclass for detecting cython compiled functions."""
def __instancecheck__(cls, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
# pylint: disable=too-few-public-methods
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
"""Detector for cython compiled functions."""
class Target(BaseModel):
"""A comprehensive locator for a UI element, supporting a fallback mechanism."""
model_config = {"ignored_types": (CyFunctionDetector,)}
resource_id: str | None = Field(None, description="The resource-id of the element.")
resource_id_index: int | None = Field(
None,
description=("The zero-based index if multiple elements share the same resource-id."),
)
text: str | None = Field(
None,
description=("The text content of the element (e.g., a label or placeholder)."),
)
text_index: int | None = Field(
None,
description=("The zero-based index if multiple elements share the same text."),
)
bounds: ElementBounds | None = Field(
None, description="The x, y, width, and height of the element."
)
@model_validator(mode="after")
def _default_indices(self):
# Treat empty strings like "not provided"
if (
self.resource_id is not None and self.resource_id != ""
) and self.resource_id_index is None:
self.resource_id_index = 0
if (self.text is not None and self.text != "") and self.text_index is None:
self.text_index = 0
return self
+389
View File
@@ -0,0 +1,389 @@
# 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 __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict
from artemis.context import ArtemisContext
from artemis.controllers.types import (
CoordinatesSelectorRequest,
PercentagesSelectorRequest,
)
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.graph.state import State
from artemis.tools.types import Target
from artemis.utils.logger import get_logger
from artemis.utils.ui_hierarchy import (
ElementBounds,
Point,
find_element_by_resource_id,
get_bounds_for_element,
get_element_text,
is_element_focused,
)
logger = get_logger(__name__)
def find_element_by_text(
ui_hierarchy: list[dict], text: str, index: int | None = None
) -> dict | None:
"""Find a UI element by its text content (adapted to both flat and rich hierarchy)
This function performs a recursive, case-insensitive search.
Args:
ui_hierarchy: List of UI element dictionaries.
text: The text content to search for.
index: Optional index to select nth matching element.
Returns:
The complete UI element dictionary if found, None otherwise.
"""
matches: list[dict] = []
def search_recursive(elements: list[dict]) -> None:
for element in elements:
if isinstance(element, dict):
src = element.get("attributes", element)
element_text = src.get("text", "")
# Guard against non-string values
if not isinstance(element_text, str):
element_text = ""
text_lower = text.lower()
if element_text and text_lower == element_text.lower():
matches.append(element)
if children := element.get("children", []):
search_recursive(children)
search_recursive(ui_hierarchy)
if not matches:
return None
if index is None:
idx = 0
elif index < 0:
return None
else:
idx = index
if idx < len(matches):
return matches[idx]
return None
async def tap_bottom_right_of_element(bounds: ElementBounds, ctx: ArtemisContext):
"""Tap the bottom-right corner of the specified element bounds."""
bottom_right: Point = bounds.get_relative_point(x_percent=0.99, y_percent=0.99)
await tap(
ctx=ctx,
selector_request=SelectorRequestWithCoordinates(
coordinates=CoordinatesSelectorRequest(
x=bottom_right.x,
y=bottom_right.y,
),
),
)
# pylint: disable=too-many-return-statements
async def move_cursor_to_end_if_bounds(
ctx: ArtemisContext,
state: State,
target: Target,
elt: dict | None = None,
) -> dict | None:
"""Best-effort move of the text cursor near the end of the input by tapping the
bottom-right area of the focused element (if bounds are available).
"""
if target.resource_id:
if not elt:
elt = find_element_by_resource_id(
ui_hierarchy=state.latest_ui_hierarchy or [],
resource_id=target.resource_id,
index=target.resource_id_index,
)
if not elt:
return None
bounds = get_bounds_for_element(elt)
if not bounds:
return elt
logger.debug("Tapping near the end of the input to move the cursor")
await tap_bottom_right_of_element(bounds=bounds, ctx=ctx)
logger.debug(f"Tapped end of input {target.resource_id}")
return elt
if target.bounds:
await tap_bottom_right_of_element(target.bounds, ctx=ctx)
logger.debug("Tapped end of input by coordinates")
return elt
if target.text:
text_elt = find_element_by_text(
state.latest_ui_hierarchy or [],
target.text,
index=target.text_index,
)
if text_elt:
bounds = get_bounds_for_element(text_elt)
if bounds:
await tap_bottom_right_of_element(bounds=bounds, ctx=ctx)
logger.debug(f"Tapped end of input that had text'{target.text}'")
return text_elt
return None
return None
async def focus_element_if_needed(
ctx: ArtemisContext, target: Target
) -> Literal["resource_id", "coordinates", "text", "already_focused"] | None:
"""Ensures the element is focused, with a sanity check to prevent trusting misleading IDs.
If no target locator is provided (empty target), assumes the field is
already focused
and returns "already_focused" to allow typing without re-focusing.
"""
# If no locator is provided, assume already focused (e.g., keyboard is visible)
if not target.resource_id and not target.bounds and not target.text:
logger.debug("No target locator provided, assuming element is already focused")
return "already_focused"
controller = UnifiedMobileController(ctx)
rich_hierarchy = await controller.get_ui_elements()
elt_from_id = None
if target.resource_id:
elt_from_id = find_element_by_resource_id(
ui_hierarchy=rich_hierarchy,
resource_id=target.resource_id,
index=target.resource_id_index,
is_rich_hierarchy=False,
)
if elt_from_id and target.text:
text_from_id_elt = get_element_text(elt_from_id)
if not text_from_id_elt or target.text.lower() != text_from_id_elt.lower():
logger.warning(
f"ID '{target.resource_id}' and text '{target.text}' seem to be"
" on different elements. Ignoring the resource_id and falling"
" back to other locators."
)
elt_from_id = None
if elt_from_id:
if not is_element_focused(elt_from_id):
await tap(
ctx=ctx,
selector_request=IdSelectorRequest(id=target.resource_id), # type: ignore
index=target.resource_id_index,
)
logger.debug(f"Focused (tap) on resource_id={target.resource_id}")
rich_hierarchy = await controller.get_ui_elements()
elt_from_id = find_element_by_resource_id(
ui_hierarchy=rich_hierarchy,
resource_id=target.resource_id, # type: ignore
index=target.resource_id_index,
is_rich_hierarchy=False,
)
if elt_from_id and is_element_focused(elt_from_id):
logger.debug(f"Text input is focused: {target.resource_id}")
return "resource_id"
logger.warning(f"Failed to focus using resource_id='{target.resource_id}'. Fallback...")
if target.bounds:
relative_point = target.bounds.get_center()
await tap(
ctx=ctx,
selector_request=SelectorRequestWithCoordinates(
coordinates=CoordinatesSelectorRequest(x=relative_point.x, y=relative_point.y)
),
)
logger.debug(f"Tapped on coordinates ({relative_point.x}, {relative_point.y}) to focus.")
return "coordinates"
if target.text:
text_elt = find_element_by_text(rich_hierarchy, target.text, index=target.text_index)
if text_elt:
bounds = get_bounds_for_element(text_elt)
if bounds:
relative_point = bounds.get_center()
await tap(
ctx=ctx,
selector_request=SelectorRequestWithCoordinates(
coordinates=CoordinatesSelectorRequest(
x=relative_point.x, y=relative_point.y
)
),
)
logger.debug(f"Tapped on text element '{target.text}' to focus.")
return "text"
logger.error(
"Failed to focus element. No valid locator (resource_id, coordinates, or text) succeeded."
)
return None
def validate_coordinates_bounds(
target: Target, screen_width: int, screen_height: int
) -> str | None:
"""Validate that coordinates are within screen bounds.
Returns error message if invalid, None if valid.
"""
if not target.bounds:
return None
center = target.bounds.get_center()
errors = []
if center.x < 0 or center.x >= screen_width:
errors.append(f"x={center.x} is outside screen width (0-{screen_width})")
if center.y < 0 or center.y >= screen_height:
errors.append(f"y={center.y} is outside screen height (0-{screen_height})")
return "; ".join(errors) if errors else None
def has_valid_selectors(target: Target) -> bool:
"""Check if target has at least one valid selector."""
has_coordinates = target.bounds is not None
has_resource_id = target.resource_id is not None and target.resource_id != ""
has_text = target.text is not None and target.text != ""
return has_coordinates or has_resource_id or has_text
class IdSelectorRequest(BaseModel):
"""Selector request using resource id."""
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
id: str
def to_dict(self) -> dict[str, str | int]:
"""Convert selector to dictionary."""
return {"id": self.id}
class TextSelectorRequest(BaseModel):
"""Selector request using element text."""
model_config = ConfigDict(extra="forbid")
text: str
def to_dict(self) -> dict[str, str | int]:
"""Convert selector to dictionary."""
return {"text": self.text}
class SelectorRequestWithCoordinates(BaseModel):
"""Selector request using absolute coordinates."""
model_config = ConfigDict(extra="forbid")
coordinates: CoordinatesSelectorRequest
def to_dict(self) -> dict[str, str | int]:
"""Convert selector to dictionary."""
return {"point": self.coordinates.to_str()}
class SelectorRequestWithPercentages(BaseModel):
"""Selector request using percentage coordinates."""
model_config = ConfigDict(extra="forbid")
percentages: PercentagesSelectorRequest
def to_dict(self) -> dict[str, str | int]:
"""Convert selector to dictionary."""
return {"point": self.percentages.to_str()}
class IdWithTextSelectorRequest(BaseModel):
"""Selector request using both resource id and text."""
model_config = ConfigDict(extra="forbid")
id: str
text: str
def to_dict(self) -> dict[str, str | int]:
"""Convert selector to dictionary."""
return {"id": self.id, "text": self.text}
SelectorRequest = (
IdSelectorRequest
| SelectorRequestWithCoordinates
| SelectorRequestWithPercentages
| TextSelectorRequest
| IdWithTextSelectorRequest
)
def _extract_resource_id_and_text_from_selector(
selector: SelectorRequest,
) -> tuple[str | None, str | None]:
"""Extract resource_id and text from a selector."""
resource_id = None
text = None
if isinstance(selector, IdSelectorRequest):
resource_id = selector.id
elif isinstance(selector, TextSelectorRequest):
text = selector.text
elif isinstance(selector, IdWithTextSelectorRequest):
resource_id = selector.id
text = selector.text
return resource_id, text
async def tap(
ctx: ArtemisContext,
selector_request: SelectorRequest,
index: int | None = None,
):
"""Tap on a selector.
Index is optional and is used when you have multiple views matching the same
selector. ui_hierarchy is optional and used for ADB taps to find elements.
"""
controller = UnifiedMobileController(ctx)
if isinstance(selector_request, SelectorRequestWithCoordinates):
result = await controller.tap_at(
x=selector_request.coordinates.x, y=selector_request.coordinates.y
)
return result.error if result.error else None
if isinstance(selector_request, SelectorRequestWithPercentages):
coords = selector_request.percentages.to_coords(
width=ctx.device.device_width,
height=ctx.device.device_height,
)
return await controller.tap_at(coords.x, coords.y)
# For other selectors, we need the UI hierarchy
resource_id, text = _extract_resource_id_and_text_from_selector(selector_request)
return await controller.tap_element(
resource_id=resource_id,
text=text,
index=index if index is not None else 0,
)
+253
View File
@@ -0,0 +1,253 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Video Analyzer utility for analyzing video content using Gemini models.
This utility sends video files to video-capable Gemini models for analysis
and returns text descriptions based on the provided prompt.
It also exposes a tool for agents to perform structured video analysis.
"""
from typing import Any, Literal
from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool
from langgraph.types import Command
from pydantic import BaseModel, Field
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class VideoAnalyzerArgs(BaseModel):
"""Arguments schema for video analyzer tool."""
model_config = {"ignored_types": (CyFunctionDetector,)}
time_description: str = Field(
...,
description=(
"The specific time range to analyze, relative to the start of the"
" recording (e.g., 'from 5s to 12s', 'from 20s to the end')."
),
)
purpose: str = Field(
...,
description=(
"The specific query, intent, or question you want the video"
" analyzer to answer about the visual or audio content in this"
" segment."
),
)
OPERATOR_VIDEO_ANALYZER_DOCSTRING = (
"[VIDEO] Use this tool to analyze any specific time range of the video"
" recording. This tool delegates to a powerful video-analyzing subagent"
" with full access to the continuous screen recording of your entire"
" execution session.\n\n"
"- This tool overcomes your limitation of only receiving discrete"
" screenshots. Common use cases include (but are not limited to):\n"
" * Watching media files: Play the video on the device screen, use"
" `wait_for_delay` until the video finishes playing, and then call this"
" tool to analyze the recording.\n"
" * Bridging the gap: Inspecting what happened between your last action"
" and the current screenshot.\n"
" * Memory retrieval: Recalling fast-moving or transient information from"
" a previous step.\n"
" * Catching rapid changes: Investigating on-screen information that"
" changes too quickly for static screenshots to capture.\n"
" - Please specify a narrow time range when possible to ensure faster"
" processing.\n"
" - If only audio analysis is needed, state it clearly in your purpose.\n"
)
DIAGNOSER_VIDEO_ANALYZER_DOCSTRING = (
"[DIAGNOSTIC] Analyzes a video segment for failure diagnosis and"
" forensics.\n\n"
"Use this to verify if an action succeeded, check for error popups, or"
" investigate visual anomalies when a step fails.\n"
"Provide a narrow time range (e.g., 'from 10s to 15s') to ensure speed.\n"
)
class VideoAnalyzerTool(ArtemisTool):
"""Universal tool for analyzing video content using Gemini models."""
def __init__(
self,
role: Literal["operator", "diagnoser"] = "operator",
description: str | None = None,
category: ToolCategory = "perception",
):
chosen_description = description or (
OPERATOR_VIDEO_ANALYZER_DOCSTRING
if role == "operator"
else DIAGNOSER_VIDEO_ANALYZER_DOCSTRING
)
super().__init__(
name="video_analyzer",
description=chosen_description,
args_schema=VideoAnalyzerArgs,
category=category,
)
self.role = role
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
time_description: str | None = None,
purpose: str | None = None,
tool_call_id: str | None = None,
state: State | None = None,
**kwargs: Any,
) -> Any:
time_desc = (
time_description
if time_description is not None
else (kwargs.get("time_description") or kwargs.get("TimeDescription") or "")
)
purp = (
purpose
if purpose is not None
else (kwargs.get("purpose") or kwargs.get("Purpose") or "")
)
tcid = tool_call_id if tool_call_id is not None else kwargs.get("tool_call_id")
st = state if state is not None else kwargs.get("state")
try:
logger.info(f"video_analyzer called for range: {time_desc}")
if ctx is None:
raise ValueError("ArtemisContext is required for VideoAnalyzer.")
agent = VideoAnalyzer(ctx)
agent_outcome, status = await agent.run(time_desc, purp)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Error running video analyzer: {e}")
agent_outcome = f"Error running video analyzer: {e}"
status = "error"
if st and callable(getattr(st, "asanitize_update", None)):
tool_message = ToolMessage(
tool_call_id=tcid or "",
content=agent_outcome,
status=status,
)
return Command(
update=await st.asanitize_update(
ctx=ctx,
update={
VALIDATOR_MESSAGES_KEY: [tool_message],
},
agent="validator",
),
)
if status == "failed":
return f"Video analysis failed: {agent_outcome}"
return agent_outcome
# Universal tool instance & aliases
video_analyzer = VideoAnalyzerTool()
ToolRegistry.register(video_analyzer)
class VideoAnalyzerPureTool(ArtemisTool):
"""Universal pure tool for video analysis returning text results directly."""
def __init__(self):
super().__init__(
name="video_analyzer_pure",
description=OPERATOR_VIDEO_ANALYZER_DOCSTRING,
args_schema=VideoAnalyzerArgs,
category="perception",
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None,
time_description: str | None = None,
purpose: str | None = None,
**kwargs: Any,
) -> str:
time_desc = (
time_description
if time_description is not None
else (kwargs.get("time_description") or kwargs.get("TimeDescription") or "")
)
purp = (
purpose
if purpose is not None
else (kwargs.get("purpose") or kwargs.get("Purpose") or "")
)
try:
logger.info(f"video_analyzer (pure) called for range: {time_desc}")
if ctx is None:
raise ValueError("ArtemisContext is required for VideoAnalyzer.")
agent = VideoAnalyzer(ctx)
agent_outcome, status = await agent.run(time_desc, purp)
if status == "failed":
return f"Video analysis failed: {agent_outcome}"
return agent_outcome
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Error in pure video_analyzer tool: {e}")
return f"Error running video analyzer: {e}"
# Universal pure tool instance & aliases
video_analyzer_pure = VideoAnalyzerPureTool()
VideoAnalyzerPure = VideoAnalyzerPureTool
ToolRegistry.register(video_analyzer_pure)
def get_video_analyzer_tool(
ctx: ArtemisContext, role: Literal["operator", "diagnoser"] = "operator"
) -> BaseTool:
"""Creates a tool that allows agents to perform structured video analysis.
The tool acts as a Main Agent following the Dynamic Video Analyzer plan.
"""
tool_inst = VideoAnalyzerTool(role=role)
return trace_langchain_tool(tool_inst.to_langchain_tool(ctx), ctx)
def get_video_analyzer_tool_pure(ctx: ArtemisContext) -> BaseTool:
"""Creates a pure LangChain tool for video analysis that returns the text result directly.
Suitable for agents running outside of LangGraph (like the outputter).
"""
return trace_langchain_tool(
video_analyzer_pure.to_langchain_tool(ctx, name="video_analyzer"), ctx
)
video_analyzer_wrapper = ToolWrapper(
tool_fn_getter=get_video_analyzer_tool,
on_success_fn=lambda output: f"Video Analyzer replied:\n{output}",
on_failure_fn=lambda error: f"Video Analyzer failed: {error}",
)
+98
View File
@@ -0,0 +1,98 @@
# 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.
import asyncio
from typing import Any
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.tools.base import ArtemisTool, ToolCategory, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class WaitArgs(BaseModel):
"""Arguments schema for wait tool."""
model_config = {"ignored_types": (CyFunctionDetector,)}
seconds: int = Field(
...,
description=("The duration to wait/sleep in seconds. Minimum is 1, maximum is 60."),
)
WAIT_DOCSTRING = (
"[SHELL] Pauses execution for a specified duration in seconds.\n\n"
"Use this to wait for active background operations to progress."
)
class WaitTool(ArtemisTool):
"""Universal tool for pausing execution for a specified duration in seconds."""
def __init__(self, category: ToolCategory = "system"):
super().__init__(
name="wait",
description=WAIT_DOCSTRING,
args_schema=WaitArgs,
category=category,
)
# pylint: disable=too-many-arguments,too-many-positional-arguments
async def execute(
self,
driver: BaseDeviceDriver | None = None, # pylint: disable=unused-argument
ctx: ArtemisContext | None = None, # pylint: disable=unused-argument
seconds: int | None = None,
**kwargs: Any,
) -> str:
sec = (
seconds
if seconds is not None
else (kwargs.get("seconds") or kwargs.get("Seconds") or 1)
)
if sec < 1:
sec = 1
elif sec > 60:
sec = 60
logger.info(f"Diagnoser waiting for {sec} seconds...")
await asyncio.sleep(sec)
return f"Successfully waited for {sec} seconds."
# Universal tool instance & aliases
wait = WaitTool()
Wait = WaitTool
ToolRegistry.register(wait)
def get_wait_tool(ctx: ArtemisContext) -> BaseTool:
"""Exports wait as a LangChain BaseTool."""
return trace_langchain_tool(wait.to_langchain_tool(ctx), ctx)
wait_wrapper = ToolWrapper(
tool_fn_getter=get_wait_tool,
on_success_fn=lambda seconds: f"Waited {seconds}s",
on_failure_fn=lambda err: f"Wait failed: {err}",
)
export_wait_tool = get_wait_tool