feat: initial release of ARTEMIS Android Agent & Automation Platform
ARTEMIS is an autonomous multimodal Android agent and mobile UI automation platform engineered for physical devices and emulators. It combines state-of-the-art multimodal Vision-Language Models (VLMs) with hierarchical closed-loop planning, resilient self-healing, and deep Android subsystem integration. Key Features & Architectural Highlights: - Dual-Engine Orchestration: * Pro Engine: Multi-agent closed-loop (Planner -> Operator -> Validator/Failure Analyzer -> Summarizer) powered by LangGraph. * Flash Engine: Ultra-fast reactive loop for deterministic, low-latency UI execution (<35 steps). - Multimodal Perception & Grounding: * Hybrid visual screenshot grounding, real-time OCR extraction, and XML accessibility hierarchy parsing. * Dynamic-First, Coordinate-Fallback locating strategy for maximum resilience against UI drift. - Autonomous Self-Healing & Diagnostics: * Failure analyzer and safety net for popup dismissal, crash recovery, and auto-retry. * Diagnostic sub-agents for log analysis, video recording inspection, and object detection. - Unified CLI & Python SDK: * Rich CLI with commands: init, doctor, run, trace, batch, bench, server, and mcp. * Fluent Python SDK with Pydantic structured output models and package locking. - Multi-Provider LLM Architecture: * Support for Google Gemini, OpenAI, Anthropic Claude, Vertex AI, OpenRouter, xAI Grok, and local endpoints (Ollama/vLLM). * Centralized configuration in config/artemis.jsonc. - Native MCP & Observability: * Model Context Protocol (MCP) server endpoints for AI IDEs. * Full trace recording with before/after screenshots, action overlays, and execution replays. Change-Id: I430b93ee2b76b54cc5a3c6eeccd15d2cf29830a1
This commit is contained in:
commit
206a9ce921
229 files changed
+49653
No files matched your search
@@ -0,0 +1,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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user