Revert "refactor: clean up legacy code, rename hopper to entity_extractor, and add third-party notices"

This reverts commit 54fcef9887.

Please do not submit unrelated docs and codes in the same commit. I am reverting this commit so that the related files can be submitted in better-organized future commits.
This commit is contained in:
yaoyao-open-source committed 2026-09-11 19:32:22 -07:00
1 parent 54fcef9887
commit 1d82d59ec8
83 files changed
+3677 -2013

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ GEMINI_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
OPENROUTER_API_KEY=
OPEN_ROUTER_API_KEY=
XAI_API_KEY=
# Google Cloud Vision OCR (Optional - for advanced OCR processing)
-27
View File
@@ -1,27 +0,0 @@
ARTEMIS Android Agent & Automation Platform
Copyright 2026 Google LLC
This product includes software developed by Google LLC.
================================================================================
Third-Party Software Attribution
================================================================================
Portions of this software are derived from and incorporate code originally
developed by the following open-source projects under the Apache License, Version 2.0:
1. mobile-use
Repository: https://github.com/minitap-ai/mobile-use
Copyright 2025-2026 Minitap, Inc.
Original authors & contributors:
Pierre-Louis Favreau, Jean-Pierre Lo, Nicolas Dehandschoewercker,
Clément Guiguet, Karun Agarwal, and the mobile-use open-source community.
License: Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
2. finalrun-agent
Repository: https://github.com/final-run/finalrun-agent
Copyright 2025 FinalRun Inc.
Original authors & contributors:
Ashish Yadav, Arnold Laishram, Srinidhi G S,
and the finalrun-agent open-source community.
License: Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
-4
View File
@@ -314,7 +314,3 @@ Contributions are warmly welcomed!
## License
This project is licensed under the [Apache License 2.0](LICENSE).
This project includes source code developed by [Minitap, Inc.](https://github.com/minitap-ai/mobile-use).
-11
View File
@@ -312,14 +312,3 @@ ARTEMIS 提供两种运行模式以适应不同的自动化需求:
## 开源许可证
本项目基于 [Apache License 2.0](LICENSE) 协议开源。
## 致谢
本项目包含由 [Minitap, Inc.](https://github.com/minitap-ai/mobile-use)(`mobile-use`)团队开发的部分源代码与工具组件,遵循 Apache License 2.0 开源许可。我们感谢其团队及开源社区在移动端自动化领域的贡献。
同时感谢 [Finalrun](https://github.com/final-run/finalrun-agent) 在智能滑动与自适应手势机制理念上的启发。
关于完整的第三方开源软件版权与归属声明,请参阅根目录的 [NOTICE](NOTICE) 文件。
@@ -1,30 +0,0 @@
# Artemis Entity Extractor & Package Resolver
You are an autonomous, high-precision structured data extractor in the ARTEMIS mobile testing runtime.
Your responsibility is to extract the exact requested identifier, package name, or configuration token from raw device dump outputs (such as `pm list packages`, `dumpsys`, logcat records, or accessibility tree dumps).
## Operating Directives
1. **Verbatim Extraction**:
- Return the extracted entity exactly as it appears in the source data.
- Do NOT modify capitalization, strip domain segments, translate, or normalize the matched identifier.
2. **Package Resolution Rules**:
- Match target application titles, codenames, or brand identifiers against standard Android package formats (`com.<vendor>.<app>`, `org.<project>.<module>`, etc.).
- Prioritize exact matches over partial or prefix matches.
3. **Strict Disambiguation & Null-Safety**:
- If the requested entity is absent from the input dataset, return `found: false` with `output: null`.
- If multiple candidates conflict and cannot be deterministically verified from the query, do NOT guess. Set `found: false` and `output: null`.
## Structured Deliverable Schema
You must produce your final answer conforming to the following JSON structure:
```json
{
"found": true,
"output": "<exact_matched_string_or_null>",
"reason": "<one_sentence_rationale_confirming_criteria>"
}
```
@@ -1,90 +0,0 @@
# 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.
"""Lightweight entity extraction and package resolver for ARTEMIS.
Dispatches bounded LLM extraction turns to identify application package identifiers
and configuration attributes from raw device listings without manual parsing heuristics.
"""
from __future__ import annotations
from pathlib import Path
from typing import cast
from jinja2 import Template
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class ExtractionResult(BaseModel):
"""Structured extraction outcome for entity resolution."""
found: bool = Field(description="True if the requested entity was definitively identified.")
output: str | None = Field(default=None, description="Exact extracted identifier or string value.")
reason: str = Field(description="Brief justification of the match criteria applied.")
@trace(type="agent", name="entity_extractor")
async def extract_entity(
ctx: ArtemisContext,
request: str,
data: str,
use_fallback: bool = True,
) -> ExtractionResult:
"""Extract a specific identifier or entity from raw batch device data."""
logger.info(f"Invoking Artemis Entity Extractor (fallback={use_fallback})")
template_path = Path(__file__).parent / "entity_extractor.md"
system_prompt = Template(template_path.read_text(encoding="utf-8")).render()
messages = [
SystemMessage(content=system_prompt),
HumanMessage(content=f"Target Query:\n{request}\n\nCandidate Raw Dataset:\n{data}"),
]
primary_llm = get_llm(ctx=ctx, name="entity_extractor", is_utils=True).with_structured_output(
ExtractionResult
)
try:
if use_fallback:
fallback_llm = get_llm(
ctx=ctx, name="entity_extractor", is_utils=True, use_fallback=True
).with_structured_output(ExtractionResult)
raw = await with_fallback(
main_call=lambda: invoke_llm_with_timeout_message(primary_llm.ainvoke(messages)),
fallback_call=lambda: invoke_llm_with_timeout_message(fallback_llm.ainvoke(messages)),
)
else:
raw = await invoke_llm_with_timeout_message(primary_llm.ainvoke(messages))
if isinstance(raw, ExtractionResult):
return raw
if isinstance(raw, dict):
return ExtractionResult.model_validate(raw)
return cast(ExtractionResult, raw)
except Exception as exc:
logger.error(f"Entity extraction turn failed: {exc}")
return ExtractionResult(
found=False,
output=None,
reason=f"LLM extraction error: {str(exc)[:400]}",
)
+15
View File
@@ -0,0 +1,15 @@
## Hopper
Extract relevant information from batch data. **Keep extracted data exactly as-is** - no reformatting.
## Output
- **found**: `true` if data was found, `false` otherwise
- **output**: Extracted information if found, `null` otherwise
- **reason**: Brief explanation of search logic
## Rules
1. **Search entire input** - may contain hundreds of entries
2. **For app package lookup**: Match app name (or variations) in package identifier
- Common patterns: lowercase app name, company+app, brand name, codenames
3. **Prefer direct matches** over partial matches
4. **Return `null`** if not found or if multiple ambiguous matches exist - don't guess
+93
View File
@@ -0,0 +1,93 @@
# 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 pathlib import Path
from jinja2 import Template
from langchain_core.messages import HumanMessage, SystemMessage
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.utils.logger import get_logger
from pydantic import BaseModel, Field
logger = get_logger(__name__)
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class HopperOutput(BaseModel):
model_config = {"ignored_types": (CyFunctionDetector,)}
found: bool = Field(description="True if the requested data was found, False otherwise.")
output: str | None = Field(description="The extracted data if found, null otherwise.")
reason: str = Field(
description="A short explanation of what you looked for"
+ " and how you decided what to extract."
)
@trace(type="agent", name="hopper")
async def hopper(
ctx: ArtemisContext,
request: str,
data: str,
use_fallback: bool = True,
) -> HopperOutput:
logger.info(f"Starting Hopper Agent (use_fallback={use_fallback})")
system_message = Template(
Path(__file__).parent.joinpath("hopper.md").read_text(encoding="utf-8")
).render()
messages = [
SystemMessage(content=system_message),
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"),
]
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
try:
if use_fallback:
llm_fallback = get_llm(
ctx=ctx, name="hopper", is_utils=True, use_fallback=True
).with_structured_output(HopperOutput)
response: HopperOutput = await with_fallback(
main_call=lambda: invoke_llm_with_timeout_message(llm.ainvoke(messages)),
fallback_call=lambda: invoke_llm_with_timeout_message(
llm_fallback.ainvoke(messages)
),
) # type: ignore
else:
response: HopperOutput = await invoke_llm_with_timeout_message(llm.ainvoke(messages))
return response
except Exception as e:
logger.error(f"Hopper LLM invocation failed: {e}")
return HopperOutput(
found=False,
output=None,
reason=f"Failed due to LLM error: {str(e)[:500]}",
)
@@ -21,7 +21,26 @@ from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class SpawnLogReaderArgs(BaseModel):
model_config = {"ignored_types": (CyFunctionDetector,)}
specific_query: str = Field(
...,
description=(
@@ -35,6 +54,7 @@ from langgraph.prebuilt import InjectedState
from artemis.core.tool_failure import ToolFailure, is_tool_failure
from artemis.context import ArtemisContext
from artemis.data_engine.trace import (
CURRENT_TRACE_ID,
TraceSpan,
trace,
)
-3
View File
@@ -11,9 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
import json
from pathlib import Path
+20 -1
View File
@@ -23,7 +23,7 @@ from pydantic import BaseModel, Field
from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import TraceSpan, trace
from artemis.data_engine.trace import CURRENT_TRACE_ID, TraceSpan, trace
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.services.llm import (
@@ -108,7 +108,26 @@ def build_planner_system_blocks(prompts_data: dict, mode: str, include_checks: b
return blocks
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class ValidationResult(BaseModel):
model_config = {"ignored_types": (CyFunctionDetector,)}
is_approved: bool = Field(
description=(
"True if the new plan still serves the initial goal and is coherent."
+327
View File
@@ -0,0 +1,327 @@
# 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.
"""ADB WebSocket tunnel implementation.
Bridges local TCP connections to a remote ADB server via WebSocket.
This replaces the need for the `lim` CLI for ADB tunneling.
The tunnel runs in a dedicated thread with its own event loop to ensure
it cannot be blocked by synchronous operations in the main thread.
Based on the Go SDK implementation which:
1. Creates a TCP listener
2. Accepts TCP connections from ADB
3. Dials the WebSocket
4. Forwards traffic bidirectionally
"""
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import socket
import threading
from artemis.utils.logger import get_logger
import websockets
from websockets.asyncio.client import ClientConnection
logger = get_logger(__name__)
BUFFER_SIZE = 32 * 1024 # 32KB, same as Go SDK
PING_INTERVAL = 30 # seconds
class AdbTunnel:
"""ADB tunnel that bridges local TCP connections to a remote WebSocket endpoint.
Runs in a dedicated thread with its own event loop to ensure the tunnel
cannot be blocked by synchronous operations in the caller's thread.
Usage:
tunnel = AdbTunnel(remote_url, token)
addr = await tunnel.start() # Returns "127.0.0.1:PORT"
# Use addr with ADB: adb connect 127.0.0.1:PORT
await tunnel.stop()
"""
def __init__(self, remote_url: str, token: str):
"""Initialize the ADB tunnel.
Args:
remote_url: WebSocket URL for the remote ADB endpoint
token: Bearer token for authentication
"""
self.remote_url = remote_url
self.token = token
self._local_port: int | None = None
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._started = threading.Event()
self._stop_event: asyncio.Event | None = None
@property
def local_addr(self) -> str:
"""Get the local address to connect ADB to."""
if self._local_port is None:
raise RuntimeError("Tunnel not started")
return f"127.0.0.1:{self._local_port}"
@property
def local_port(self) -> int:
"""Get the local port number."""
if self._local_port is None:
raise RuntimeError("Tunnel not started")
return self._local_port
async def start(self) -> str:
"""Start the ADB tunnel in a dedicated thread.
Returns:
The local address (host:port) to connect ADB to.
"""
if self._thread is not None and self._thread.is_alive():
return self.local_addr
# Create listener in main thread to get the port immediately
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
listener.listen(5)
listener.setblocking(False)
self._local_port = listener.getsockname()[1]
logger.info(f"ADB tunnel listening on {self.local_addr}")
# Start tunnel thread
self._thread = threading.Thread(
target=self._run_in_thread,
args=(listener,),
name=f"adb-tunnel-{self._local_port}",
daemon=True,
)
self._thread.start()
# Wait for thread to initialize its event loop
self._started.wait(timeout=5.0)
if not self._started.is_set():
await self.stop()
raise RuntimeError("Tunnel thread failed to start")
return self.local_addr
async def stop(self) -> None:
"""Stop the ADB tunnel and cleanup resources."""
if self._loop and self._stop_event:
# Signal the tunnel loop to stop
self._loop.call_soon_threadsafe(self._stop_event.set)
if self._thread and self._thread.is_alive():
await asyncio.to_thread(self._thread.join, 5.0)
if self._thread.is_alive():
logger.warning("ADB tunnel thread did not stop within 5s")
self._thread = None
self._loop = None
self._stop_event = None
logger.info("ADB tunnel stopped")
def _run_in_thread(self, listener: socket.socket) -> None:
"""Run the tunnel event loop in a dedicated thread."""
try:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
self._stop_event = asyncio.Event()
self._started.set()
self._loop.run_until_complete(self._run_tunnel(listener))
except Exception as e:
logger.error(f"Tunnel thread error: {e}")
finally:
listener.close()
if self._loop:
self._loop.close()
async def _run_tunnel(self, listener: socket.socket) -> None:
"""Run the tunnel - accept TCP connections and bridge to WebSocket."""
loop = asyncio.get_event_loop()
connection_tasks: list[asyncio.Task] = []
stop_event = self._stop_event
assert stop_event is not None
logger.debug("Tunnel: ready to accept connections")
while not stop_event.is_set():
try:
# Use wait_for to periodically check stop_event
try:
tcp_conn, addr = await asyncio.wait_for(
loop.sock_accept(listener),
timeout=1.0,
)
except TimeoutError:
# Check if we should stop
continue
logger.debug(f"Tunnel: new TCP connection from {addr}")
task = asyncio.create_task(
self._handle_connection(tcp_conn, loop),
name=f"conn-{addr[1]}",
)
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
connection_tasks.append(task)
connection_tasks = [t for t in connection_tasks if not t.done()]
except Exception as e:
if not stop_event.is_set():
logger.error(f"Tunnel accept error: {e}")
break
# Cleanup pending connections
for task in connection_tasks:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _handle_connection(
self,
tcp_conn: socket.socket,
loop: asyncio.AbstractEventLoop,
) -> None:
"""Handle a single TCP connection."""
tcp_conn.setblocking(False)
ws: ClientConnection | None = None
try:
logger.debug(f"Connecting to WebSocket: {self.remote_url[:80]}...")
ws = await websockets.connect(
self.remote_url,
additional_headers={"Authorization": f"Bearer {self.token}"},
ping_interval=PING_INTERVAL,
ping_timeout=10,
)
logger.debug("WebSocket connected to remote ADB")
await self._bridge(tcp_conn, ws, loop)
except Exception as e:
logger.error(f"Connection error: {e}")
finally:
if ws is not None:
await ws.close()
tcp_conn.close()
logger.debug("Connection closed")
async def _bridge(
self,
tcp_conn: socket.socket,
ws: ClientConnection,
loop: asyncio.AbstractEventLoop,
) -> None:
"""Bridge TCP and WebSocket bidirectionally."""
stop_event = self._stop_event
assert stop_event is not None
async def tcp_to_ws() -> None:
"""Forward data from TCP to WebSocket."""
msg_count = 0
try:
while not stop_event.is_set():
try:
data = await asyncio.wait_for(
loop.sock_recv(tcp_conn, BUFFER_SIZE),
timeout=1.0,
)
except TimeoutError:
continue
except (OSError, ConnectionError) as e:
logger.debug(f"tcp->ws: recv error after {msg_count} msgs: {e}")
break
if not data:
logger.debug(f"tcp->ws: closed by client after {msg_count} msgs")
break
msg_count += 1
logger.debug(f"tcp->ws: [{msg_count}] sending {len(data)} bytes")
await ws.send(data)
logger.debug(f"tcp->ws: loop ended after {msg_count} messages")
except asyncio.CancelledError:
logger.debug(f"tcp->ws: cancelled after {msg_count} messages")
raise
except Exception as e:
logger.error(f"tcp->ws: error after {msg_count} messages: {e}")
async def ws_to_tcp() -> None:
"""Forward data from WebSocket to TCP."""
msg_count = 0
try:
logger.debug("ws->tcp: starting message loop")
async for message in ws:
if stop_event.is_set():
break
if isinstance(message, bytes) and message:
msg_count += 1
logger.debug(f"ws->tcp: [{msg_count}] received {len(message)} bytes")
await loop.sock_sendall(tcp_conn, message)
elif isinstance(message, str):
logger.debug(f"ws->tcp: received string: {message[:100]}")
logger.debug(f"ws->tcp: loop ended after {msg_count} messages")
except asyncio.CancelledError:
logger.debug(f"ws->tcp: cancelled after {msg_count} messages")
raise
except Exception as e:
logger.error(f"ws->tcp: error after {msg_count} messages: {e}")
tcp_task = asyncio.create_task(tcp_to_ws())
ws_task = asyncio.create_task(ws_to_tcp())
done, pending = await asyncio.wait(
[tcp_task, ws_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
@asynccontextmanager
async def adb_tunnel(remote_url: str, token: str) -> AsyncGenerator[AdbTunnel, None]:
"""Context manager for creating and managing an ADB tunnel.
Args:
remote_url: WebSocket URL for the remote ADB endpoint
token: Bearer token for authentication
Yields:
AdbTunnel instance with the tunnel running
Example:
async with adb_tunnel(ws_url, token) as tunnel:
# Connect ADB to tunnel.local_addr
adb_client = AdbClient()
adb_client.connect(tunnel.local_addr)
"""
tunnel = AdbTunnel(remote_url, token)
try:
await tunnel.start()
yield tunnel
finally:
await tunnel.stop()
+109 -7
View File
@@ -11,13 +11,12 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""UIAutomator2 client for Android device screen data retrieval.
Provides direct device access for hierarchy and screenshot retrieval.
Provides an alternative to the Maestro-based screen API with direct device
access.
Handles Maestro blocker detection and removal before connecting.
"""
import base64
@@ -42,9 +41,32 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
MAESTRO_PACKAGE = "dev.mobile.maestro"
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class UIAutomatorScreenData(BaseModel):
"""Screen data response from UIAutomator2."""
model_config = {"ignored_types": (CyFunctionDetector,)}
base64: str
hierarchy_xml: str
elements: list[dict]
@@ -148,11 +170,88 @@ def _pil_to_base64(img: Image.Image, format: str = "JPEG", quality: int = 80) ->
return base64.b64encode(buffer.getvalue()).decode("utf-8")
def _is_package_installed(device_id: str, pkg: str) -> bool:
"""Check if a package is installed on the device."""
try:
result = subprocess.run(
adb_command(["-s", device_id, "shell", "pm", "list", "packages"]),
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
logger.warning(f"Failed to list packages: {result.stderr}")
return False
lines = result.stdout.splitlines()
target = f"package:{pkg}"
return target in lines
except subprocess.TimeoutExpired:
logger.warning("Timeout checking installed packages")
return False
except (OSError, subprocess.SubprocessError) as e:
logger.warning(f"Error checking installed packages: {e}")
return False
def _uninstall_package(device_id: str, pkg: str) -> bool:
"""Uninstall a package from the device for the current user."""
try:
result = subprocess.run(
adb_command(
[
"-s",
device_id,
"shell",
"pm",
"uninstall",
"--user",
"0",
pkg,
]
),
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
logger.info(f"Successfully uninstalled {pkg}")
return True
else:
logger.warning(f"Failed to uninstall {pkg}: {result.stderr}")
return False
except subprocess.TimeoutExpired:
logger.warning(f"Timeout uninstalling {pkg}")
return False
except Exception as e:
logger.warning(f"Error uninstalling {pkg}: {e}")
return False
def _ensure_maestro_not_installed(device_id: str) -> None:
"""Check if Maestro is installed and uninstall it.
Maestro conflicts with uiautomator2 - if Maestro's package is installed,
u2.connect() will fail. This function ensures Maestro is removed before
attempting to connect.
"""
if _is_package_installed(device_id, MAESTRO_PACKAGE):
logger.warning(
f"Maestro ({MAESTRO_PACKAGE}) detected - uninstalling to enable UIAutomator2..."
)
_uninstall_package(device_id, MAESTRO_PACKAGE)
class UIAutomatorClient:
"""UIAutomator2 client for Android screen data retrieval.
This client uses the uiautomator2 library for direct device communication,
providing high-performance hierarchy and screenshot retrieval.
This client uses uiautomator2 library for direct device communication,
providing faster hierarchy and screenshot retrieval compared to Maestro.
Important: Maestro must not be installed on the device as it conflicts
with uiautomator2. This client automatically handles Maestro removal.
"""
def __init__(self, device_id: str):
@@ -166,7 +265,7 @@ class UIAutomatorClient:
self._awake_strategy: str | None = None
def _ensure_connected(self) -> "Device":
"""Ensure connection to the device.
"""Ensure connection to the device, handling Maestro blocker.
Returns:
Connected uiautomator2 Device instance
@@ -180,6 +279,9 @@ class UIAutomatorClient:
logger.warning("UIAutomator2 connection lost, reconnecting...")
self._device = None
# Ensure Maestro is not blocking us
_ensure_maestro_not_installed(self._device_id)
# Enroll the device in the containing Artemis service lifetime before
# UIAutomator2 reads the screen. Client disconnects do not release it.
if self._awake_strategy is None:
+2 -2
View File
@@ -83,7 +83,6 @@ from artemis.config.constants import (
ENV_ARTEMIS_TRACES_DIR,
ENV_ARTEMIS_USE_FILE_API,
ENV_ARTEMIS_USE_USER_DIR,
ENV_OPENROUTER_API_KEY,
ENV_OPEN_ROUTER_API_KEY,
ENV_OPENAI_API_KEY,
ENV_OPENAI_BASE_URL,
@@ -101,6 +100,7 @@ from artemis.config.constants import (
LLMUtilsNodeWithFallback,
)
from artemis.config.llm import (
CyFunctionDetector,
LLM,
LLMConfig,
LLMConfigUtils,
@@ -196,6 +196,7 @@ __all__ = [
"get_ls_address_file",
"is_frozen_bundle",
# LLM Config & Models
"CyFunctionDetector",
"LLM",
"LLMWithFallback",
"LLMConfigUtils",
@@ -295,7 +296,6 @@ __all__ = [
"ENV_OPENAI_API_KEY",
"ENV_OPENAI_BASE_URL",
"ENV_ANTHROPIC_API_KEY",
"ENV_OPENROUTER_API_KEY",
"ENV_OPEN_ROUTER_API_KEY",
"ENV_XAI_API_KEY",
"ENV_ARTEMIS_EXPLORER_VERSION",
+1 -2
View File
@@ -62,7 +62,6 @@ ENV_API_KEY = "API_KEY"
ENV_OPENAI_API_KEY = "OPENAI_API_KEY"
ENV_OPENAI_BASE_URL = "OPENAI_BASE_URL"
ENV_ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY"
ENV_OPENROUTER_API_KEY = "OPENROUTER_API_KEY"
ENV_OPEN_ROUTER_API_KEY = "OPEN_ROUTER_API_KEY"
ENV_XAI_API_KEY = "XAI_API_KEY"
@@ -126,7 +125,7 @@ ExplorerVersion = Literal["flash", "pro", "ultra"]
LLMUtilsNode = Literal[
"outputter",
"entity_extractor",
"hopper",
"video_analyzer",
"object_detector",
]
+30 -7
View File
@@ -48,11 +48,30 @@ def validate_vertex_ai_credentials() -> None:
)
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
"""Utility type detector for Cython and C-extension functions in Pydantic."""
pass
class LLM(BaseModel):
"""Base model representing an LLM model provider and runtime parameters."""
model_config = {"ignored_types": (CyFunctionDetector,)}
provider: LLMProvider
model: str
temperature: float | None = None
@@ -76,8 +95,8 @@ class LLM(BaseModel):
if not (settings.ANTHROPIC_API_KEY or os.environ.get("ANTHROPIC_API_KEY")):
raise Exception(f"{name} requires ANTHROPIC_API_KEY in .env")
elif self.provider == "openrouter":
if not (settings.OPENROUTER_API_KEY or settings.OPEN_ROUTER_API_KEY):
raise Exception(f"{name} requires OPENROUTER_API_KEY in .env")
if not settings.OPEN_ROUTER_API_KEY:
raise Exception(f"{name} requires OPEN_ROUTER_API_KEY in .env")
elif self.provider == "xai":
if not settings.XAI_API_KEY:
raise Exception(f"{name} requires XAI_API_KEY in .env")
@@ -114,14 +133,18 @@ def lightweight_judge_default() -> "LLMWithFallback":
class LLMConfigUtils(BaseModel):
"""Configuration container for auxiliary utility agents/nodes."""
model_config = {"ignored_types": (CyFunctionDetector,)}
outputter: LLMWithFallback
entity_extractor: LLMWithFallback
hopper: LLMWithFallback
video_analyzer: LLMWithFallback | None = None
object_detector: LLMWithFallback | None = None
class LLMConfig(BaseModel):
"""Comprehensive LLM configuration mapping every node to primary/fallback models."""
model_config = {"ignored_types": (CyFunctionDetector,)}
planner: LLMWithFallback
utils: LLMConfigUtils
summarizer: LLMWithFallback
@@ -144,7 +167,7 @@ class LLMConfig(BaseModel):
"""Validate credentials across all configured agent nodes."""
self.planner.validate_provider("Planner")
self.utils.outputter.validate_provider("Outputter")
self.utils.entity_extractor.validate_provider("EntityExtractor")
self.utils.hopper.validate_provider("Hopper")
if self.utils.video_analyzer:
self.utils.video_analyzer.validate_provider("VideoAnalyzer")
if self.utils.object_detector:
@@ -173,7 +196,7 @@ class LLMConfig(BaseModel):
return f"""
📃 Planner: {self.planner}
🧩 Utils:
🔍 Entity Extractor: {self.utils.entity_extractor}
🔽 Hopper: {self.utils.hopper}
📝 Outputter: {self.utils.outputter}
🎬 Video Analyzer: {self.utils.video_analyzer or "Not configured"}
👁️ Object Detector: {self.utils.object_detector or "Not configured"}
@@ -196,7 +219,7 @@ class LLMConfig(BaseModel):
def get_utils(self, item: LLMUtilsNode) -> LLMWithFallback:
"""Retrieve model configuration for a specific utility node."""
value = getattr(self.utils, item, None)
value = getattr(self.utils, item)
if value is None:
raise ValueError(
f"Utils '{item}' is not configured. Please add it to your LLM "
@@ -241,7 +264,7 @@ def _expand_default_into_nodes(config_dict: dict) -> dict:
all_utils_nodes = [
"outputter",
"entity_extractor",
"hopper",
"video_analyzer",
"object_detector",
]
+3
View File
@@ -26,6 +26,7 @@ from artemis.config.constants import (
ENV_EVENTS_OUTPUT_PATH,
ENV_RESULTS_OUTPUT_PATH,
)
from artemis.config.llm import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -33,6 +34,8 @@ logger = get_logger(__name__)
class OutputConfig(BaseModel):
"""Configuration for structured output formatting and schemas."""
model_config = {"ignored_types": (CyFunctionDetector,)}
structured_output: Annotated[
type[BaseModel] | dict | None,
Field(
+5 -19
View File
@@ -18,7 +18,7 @@ import os
from pathlib import Path
from dotenv import load_dotenv
from pydantic import AliasChoices, Field, SecretStr, model_validator
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings
from artemis.config.constants import (
@@ -32,7 +32,6 @@ from artemis.config.constants import (
ENV_GEMINI_API_KEY,
ENV_GOOGLE_API_KEY,
ENV_OCR_API_KEY,
ENV_OPENROUTER_API_KEY,
ENV_OPEN_ROUTER_API_KEY,
ENV_OPENAI_API_KEY,
ENV_VISION_API_KEY,
@@ -100,10 +99,6 @@ class Settings(BaseSettings):
GCP_API_KEY: SecretStr | None = None
ANTHROPIC_API_KEY: SecretStr | None = None
XAI_API_KEY: SecretStr | None = None
OPENROUTER_API_KEY: SecretStr | None = Field(
default=None,
validation_alias=AliasChoices("OPENROUTER_API_KEY", "OPEN_ROUTER_API_KEY"),
)
OPEN_ROUTER_API_KEY: SecretStr | None = None
# Google Cloud Vision OCR Authentication
@@ -168,7 +163,6 @@ class Settings(BaseSettings):
"GCP_API_KEY",
"ANTHROPIC_API_KEY",
"XAI_API_KEY",
"OPENROUTER_API_KEY",
"OPEN_ROUTER_API_KEY",
"OCR_API_KEY",
"VISION_API_KEY",
@@ -178,12 +172,6 @@ class Settings(BaseSettings):
if val and is_placeholder_key(val):
setattr(self, attr, None)
# Synchronize OpenRouter keys
if not self.OPENROUTER_API_KEY and self.OPEN_ROUTER_API_KEY:
self.OPENROUTER_API_KEY = self.OPEN_ROUTER_API_KEY
elif not self.OPEN_ROUTER_API_KEY and self.OPENROUTER_API_KEY:
self.OPEN_ROUTER_API_KEY = self.OPENROUTER_API_KEY
if not self.GOOGLE_API_KEY:
if self.GEMINI_API_KEY:
self.GOOGLE_API_KEY = self.GEMINI_API_KEY
@@ -216,8 +204,8 @@ class Settings(BaseSettings):
key = self.OPENAI_API_KEY
elif provider_lower in ("anthropic", "claude"):
key = self.ANTHROPIC_API_KEY
elif provider_lower in ("openrouter", "open_router"):
key = self.OPENROUTER_API_KEY or self.OPEN_ROUTER_API_KEY
elif provider_lower == "openrouter":
key = self.OPEN_ROUTER_API_KEY
elif provider_lower in ("xai", "grok"):
key = self.XAI_API_KEY
@@ -255,11 +243,9 @@ class Settings(BaseSettings):
self.ANTHROPIC_API_KEY = secret
env_key_name = ENV_ANTHROPIC_API_KEY
os.environ[ENV_ANTHROPIC_API_KEY] = key
elif provider_lower in ("openrouter", "open_router"):
self.OPENROUTER_API_KEY = secret
elif provider_lower == "openrouter":
self.OPEN_ROUTER_API_KEY = secret
env_key_name = ENV_OPENROUTER_API_KEY
os.environ[ENV_OPENROUTER_API_KEY] = key
env_key_name = ENV_OPEN_ROUTER_API_KEY
os.environ[ENV_OPEN_ROUTER_API_KEY] = key
elif provider_lower == "xai":
self.XAI_API_KEY = secret
-3
View File
@@ -11,9 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Context variables for global state management.
+142 -70
View File
@@ -11,42 +11,45 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Abstract device controller contract and telemetry interfaces for ARTEMIS.
Defines the hardware interaction lifecycle including touch gestures, text entry,
process lifecycle operations, display capture, and multi-segment recording.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from artemis.controllers.types import Bounds, CoordinatesSelectorRequest, TapOutput
from artemis.utils.video import VideoRecordingResult
from pydantic import BaseModel, Field
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class ScreenDataResponse(BaseModel):
"""Aggregate snapshot containing live display frames and parsed node hierarchies."""
model_config = ConfigDict(extra="ignore")
base64: str = Field(default="", description="Base64 encoded JPEG/PNG frame capture.")
elements: list[dict[str, Any]] = Field(default_factory=list, description="Parsed hierarchy node elements.")
width: int = Field(default=1080, description="Active viewport width in physical pixels.")
height: int = Field(default=2400, description="Active viewport height in physical pixels.")
platform: str = Field(default="android", description="Target platform identifier.")
model_config = {"ignored_types": (CyFunctionDetector,)}
base64: str = Field(default="", description="Base64 encoded screenshot string")
elements: list[dict[str, Any]] = Field(
default_factory=list, description="Parsed UI hierarchy elements"
)
width: int = Field(default=1080, description="Device screen width in pixels")
height: int = Field(default=2400, description="Device screen height in pixels")
platform: str = Field(default="android", description="Platform identifier")
class MobileDeviceController(ABC):
"""Abstract driver interface establishing device actuation and telemetry operations."""
@abstractmethod
async def tap(
self,
@@ -56,8 +59,7 @@ class MobileDeviceController(ABC):
times: int = 1,
delay_ms: int = 100,
) -> TapOutput:
"""Dispatch single, repeated, or long-press tap events to the target coordinates."""
raise NotImplementedError
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def swipe(
@@ -66,102 +68,161 @@ class MobileDeviceController(ABC):
end: CoordinatesSelectorRequest,
duration: int = 400,
) -> str | None:
"""Execute directional drag or fling between two coordinate positions."""
raise NotImplementedError
"""Swipe from start to end coordinates.
Returns error message on failure, None on success.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def screenshot(self) -> str:
"""Capture current display frame as an encoded image payload."""
raise NotImplementedError
"""Take a screenshot and return raw image data."""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def input_text(self, text: str) -> bool:
"""Stream textual characters into the currently active input field."""
raise NotImplementedError
"""Input text at the currently focused field.
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def launch_app(self, package_name: str) -> bool:
"""Bring target Android application package into foreground."""
raise NotImplementedError
async def launch_app(self, package_or_bundle_id: str) -> bool:
"""Launch an application by package name (Android).
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def terminate_app(self, package_name: str | None) -> bool:
"""Force-stop application process matching the target package."""
raise NotImplementedError
async def terminate_app(self, package_or_bundle_id: str | None) -> bool:
"""Terminate an application.
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def open_url(self, url: str) -> bool:
"""Dispatch system VIEW intent for the specified URI."""
raise NotImplementedError
"""Open a URL.
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def press_back(self) -> bool:
"""Trigger Android BACK system navigation key event."""
raise NotImplementedError
"""Press the back button (Android).
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def press_home(self) -> bool:
"""Trigger Android HOME system navigation key event."""
raise NotImplementedError
"""Press the home button.
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def press_enter(self) -> bool:
"""Trigger ENTER / ACTION_DONE key event on virtual keyboard."""
raise NotImplementedError
"""Press the enter/return key.
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def press_key(self, keycode: str) -> bool:
"""Dispatch raw Android keycode event (e.g., 'KEYCODE_TAB')."""
raise NotImplementedError
"""Press a specific key event.
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def get_ui_hierarchy(self) -> list[dict[str, Any]]:
"""Retrieve flattened or nested accessibility tree node descriptors."""
raise NotImplementedError
async def get_ui_hierarchy(self) -> list[dict]:
"""Get the UI element hierarchy.
Returns a list of UI elements with their properties.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
def find_element(
self,
ui_hierarchy: list[dict[str, Any]],
ui_hierarchy: list[dict],
resource_id: str | None = None,
text: str | None = None,
index: int = 0,
) -> tuple[dict[str, Any] | None, Bounds | None, str | None]:
"""Locate target element within parsed hierarchy by ID or label matching."""
raise NotImplementedError
) -> tuple[dict | None, Bounds | None, str | None]:
"""Find a UI element in the hierarchy.
Returns:
Tuple of (element_dict, bounds, error_message)
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def cleanup(self) -> None:
"""Tear down companion sessions, tunnels, and streaming handles."""
raise NotImplementedError
"""Clean up resources (e.g., stop companion processes)."""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def erase_text(self, nb_chars: int | None = None) -> bool:
"""Dispatch backspace events to clear trailing characters in focused field."""
raise NotImplementedError
"""Erase the last nb_chars characters.
Returns True on success, False on failure.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def get_screen_data(self) -> ScreenDataResponse:
"""Capture unified screen frame, layout tree, and viewport dimensions."""
raise NotImplementedError
async def get_screen_data(self) -> "ScreenDataResponse":
"""Get screen data including screenshot (base64), UI hierarchy elements,
screen dimensions, and platform.
Returns:
ScreenDataResponse with base64 screenshot, elements, width, height,
platform
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
def get_compressed_b64_screenshot(self, image_base64: str, quality: int = 50) -> str:
"""Produce compressed image payload for bandwidth-constrained turns."""
raise NotImplementedError
"""Compress a base64 image.
Returns the compressed base64 image.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def start_video_recording(
self,
max_duration_seconds: int = 900,
) -> VideoRecordingResult:
"""Initialize session screen capture recording."""
raise NotImplementedError
"""Start screen recording on the device.
Args:
max_duration_seconds: Maximum recording duration in seconds.
Returns:
VideoRecordingResult with success status and message.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def stop_video_recording(self) -> VideoRecordingResult:
"""Terminate active screen capture and finalize video artifact."""
raise NotImplementedError
"""Stop screen recording and retrieve the video file.
Returns:
VideoRecordingResult with success status, message, and video_path if
successful.
"""
raise NotImplementedError("Subclasses must implement this method")
@abstractmethod
async def extract_segment_metadata(
@@ -169,5 +230,16 @@ class MobileDeviceController(ABC):
start_relative_time: float,
end_relative_time: float | None = None,
) -> VideoRecordingResult:
"""Slice video segment for the requested relative execution epoch."""
raise NotImplementedError
"""Get a video segment for a specific time range.
Args:
start_relative_time: Start time in seconds relative to session
start.
end_relative_time: End time in seconds relative to session start, or
None for latest.
Returns:
VideoRecordingResult with success status, message, and video_path if
successful.
"""
raise NotImplementedError("Subclasses must implement this method")
@@ -11,18 +11,15 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
import os
from shutil import which
import subprocess
import time
from typing import Any
from artemis.context import ArtemisContext, DevicePlatform
from artemis.utils.logger import ArtemisLogger, get_logger
from artemis.utils.shell_utils import run_shell_command_on_host
logger = get_logger(__name__)
@@ -60,17 +57,12 @@ def get_first_device(
if which("adb"):
try:
res = subprocess.run(
["adb", "devices"],
capture_output=True,
text=True,
check=True,
stdin=subprocess.DEVNULL,
)
for line in res.stdout.strip().split("\n"):
android_output = run_shell_command_on_host("adb devices")
lines = android_output.strip().split("\n")
for line in lines:
if "device" in line and not line.startswith("List of devices"):
return line.split()[0], DevicePlatform.ANDROID, None
except Exception as e:
except RuntimeError as e:
if logger:
logger.error(f"ADB command failed: {e}")
@@ -11,9 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
import asyncio
import os
+20
View File
@@ -511,6 +511,24 @@ def _get_active_subgoal_hashes(ctx: ArtemisContext) -> tuple[str, str | None]:
return "default", None
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
def check_plan_mutation_rejections(
content_before: str, content_after: str, state: State | None = None
) -> str | None:
@@ -596,6 +614,7 @@ def _reject_checker_note_write(key: str, tool_call_id: str | None) -> ToolMessag
class NoteArgs(BaseModel):
model_config = {"ignored_types": (CyFunctionDetector,)}
key: str = Field(..., description=SAVE_NOTE_ARG_KEY_DESC)
content: str = Field(..., description=SAVE_NOTE_ARG_CONTENT_DESC)
@@ -833,6 +852,7 @@ def wrap_note_tool(ctx: ArtemisContext, original_tool):
class UpdateNoteArgs(BaseModel):
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)
+2 -18
View File
@@ -26,6 +26,7 @@ from artemis.config import checker_overrides_for_level, initialize_llm_config, s
from artemis.utils.startup_progress import publish_startup_progress
from artemis import Agent, Builders
from artemis.sdk.types.task import AgentProfile
from artemis.utils.cli_helpers import display_device_status
from artemis.utils.logger import get_logger
from artemis.utils.video import check_ffmpeg_available
import signal
@@ -36,23 +37,6 @@ import typer
logger = get_logger(__name__)
def _display_device_status(console: Console, adb_client: AdbClient | None = None) -> None:
"""Checks for connected devices and displays the status."""
console.print("\n[bold]📱 Device Status[/bold]")
devices = None
if adb_client is not None:
try:
devices = adb_client.device_list()
except Exception:
devices = None
if devices:
console.print("✅ [bold green]Android device(s) connected:[/bold green]")
for device in devices:
console.print(f" - {device.serial}")
else:
console.print("❌ [bold red]No Android device found.[/bold red]")
async def execute_task(
goal: str,
device_serial: str | None = None,
@@ -467,7 +451,7 @@ def run_command(
# Optional cosmetic device-status display; run continues without it.
logger.debug(f"Could not create ADB client for device status display: {exc}")
_display_device_status(console, adb_client=adb_client)
display_device_status(console, adb_client=adb_client)
cancelled = False
original_sigterm = None
+2 -2
View File
@@ -93,8 +93,8 @@
// 📋 Summarizer: Aggregates step history and generates structured outcome
"summarizer": {},
// 🔍 Entity Extractor: Fast lightweight package name locator and entity extractor
"entity_extractor": {
// 🔽 Hopper: Fast lightweight package name locator
"hopper": {
"model": "gemini-3.5-flash-lite",
"fallback": {
"provider": "google",
+1 -3
View File
@@ -11,9 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
import asyncio
import inspect
@@ -140,6 +137,7 @@ class Agent:
_current_task: asyncio.Task | None = None
_task_lock: asyncio.Lock
_cloud_mobile_id: str | None = None
def __init__(
self,
+7 -3
View File
@@ -11,9 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Builder for AgentConfig objects using a fluent interface."""
@@ -72,6 +69,7 @@ class AgentConfigBuilder:
self._video_recording_tools_enabled: bool = detect_video_tools_enabled()
self._force_web_accessibility: bool = False
self._disable_checker: bool = False
self._cloud_mobile_id_or_ref: str | None = None
self._concurrency_mode: str = "per_device"
self._max_concurrency: int | None = None
@@ -154,6 +152,12 @@ class AgentConfigBuilder:
platform_or_device_id: DevicePlatform or unique identifier for the device
device_id: The unique identifier for the device (if platform was passed first)
"""
if self._cloud_mobile_id_or_ref is not None:
raise ValueError(
"Device ID cannot be set when a cloud mobile is already"
" configured.\n> for_device() and for_cloud_mobile() are"
" mutually exclusive"
)
if isinstance(platform_or_device_id, DevicePlatform):
self._device_platform = platform_or_device_id
self._device_id = device_id
+159 -62
View File
@@ -11,71 +11,123 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Fluent specification builders for ARTEMIS automation task requests.
Provides type-safe configuration chaining for task objectives, execution bounds,
device sandboxing constraints, and structured schema validations.
"""
from __future__ import annotations
"""Builder for TaskRequest objects using a fluent interface."""
from pathlib import Path
from typing import Generic, Self, TypeVar, cast
from typing import Generic, TypeVar, cast
from pydantic import BaseModel
try:
from typing import Self
except ImportError:
from typing import Self
from artemis.constants import RECURSION_LIMIT
from artemis.sdk.types.agent import AgentProfile
from artemis.sdk.types.task import TaskRequest, TaskRequestCommon
from pydantic import BaseModel
TIn = TypeVar("TIn", bound=BaseModel | None)
TOut = TypeVar("TOut", bound=BaseModel)
class TaskRequestCommonBuilder:
"""Configurator for common execution bounds and sandbox constraints."""
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
def __init__(self) -> None:
self._max_steps: int = RECURSION_LIMIT
self._record_trace: bool = True
self._trace_path: Path = Path("traces")
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class TaskRequestCommonBuilder(BaseModel):
"""Builder class providing a fluent interface for creating TaskRequestCommon objects."""
model_config = {"ignored_types": (CyFunctionDetector,)}
def __init__(self):
self._max_steps = RECURSION_LIMIT
self._record_trace = True
self._trace_path = Path("traces")
self._llm_output_path: Path | None = None
self._locked_app_package: str | None = None
self._app_path: Path | None = None
def with_max_steps(self, max_steps: int) -> Self:
"""Constrain the maximum interaction turns allowed."""
"""Set the maximum number of steps the task can take.
Args:
max_steps: Maximum number of steps
"""
self._max_steps = max_steps
return self
def with_trace_recording(self, enabled: bool = True, path: str | Path | None = None) -> Self:
"""Toggle telemetry snapshot capture and set output storage directory."""
def with_trace_recording(self, enabled: bool = True, path: str | None = None) -> Self:
"""Configure trace recording for the task.
Traces record screenshots and actions during execution.
Args:
enabled: Whether to enable trace recording
path: Directory path where traces should be saved
"""
self._record_trace = enabled
if enabled and path:
self._trace_path = Path(path)
return self
def with_llm_output_saving(self, path: str | Path) -> Self:
"""Designate destination path for persisting raw reasoning payloads."""
def with_llm_output_saving(self, path: str) -> Self:
"""Configure LLM output saving for the task.
Args:
path: Path where to save the LLM output message
"""
self._llm_output_path = Path(path)
return self
def with_locked_app_package(self, package_name: str) -> Self:
"""Bind test execution strictly to the target Android package namespace."""
self._locked_app_package = package_name.strip()
"""Set the app package to lock execution to.
This ensures the specified app is launched and in the foreground before
the agentic loop starts.
Args:
package_name: Package name (Android, e.g., 'com.whatsapp')
"""
self._locked_app_package = package_name
return self
def with_app_path(self, app_path: str | Path) -> Self:
"""Provide path to local APK for automatic installation prior to run."""
self._app_path = Path(app_path)
"""Set the path to an app to install before running the task.
For Android: Path to an APK file.
The app will be installed automatically before the task starts.
Args:
app_path: Path to the app file to install
"""
self._app_path = Path(app_path) if isinstance(app_path, str) else app_path
return self
def build(self) -> TaskRequestCommon:
"""Materialize configured constraints into a TaskRequestCommon contract."""
"""Build the TaskRequestCommon object.
Returns:
A configured TaskRequestCommon object
Raises:
ValueError: If required fields are missing
"""
return TaskRequestCommon(
max_steps=self._max_steps,
record_trace=self._record_trace,
@@ -87,66 +139,110 @@ class TaskRequestCommonBuilder:
class TaskRequestBuilder(TaskRequestCommonBuilder, Generic[TIn]):
"""Fluent configurator constructing immutable TaskRequest specifications."""
"""Builder class providing a fluent interface for creating TaskRequest objects.
def __init__(self, goal: str) -> None:
This builder allows for step-by-step construction of a TaskRequest with
clear methods that make the configuration process intuitive and type-safe.
Examples:
>>> builder = TaskRequestBuilder[None](goal="Open Gmail and check unread
emails")
>>> task_request = (
... builder
... .with_max_steps(30)
... .using_profile("LowReasoning")
... .with_output_description("A list of email subjects and senders")
... .build()
... )
"""
model_config = {"ignored_types": (CyFunctionDetector,)}
def __init__(self, goal: str):
"""Initialize an empty TaskRequestBuilder."""
super().__init__()
self._goal: str = goal
self._goal = goal
self._profile: str | AgentProfile | None = None
self._name: str | None = None
self._output_description: str | None = None
self._output_description = None
self._output_format: type[TIn] | None = None
@classmethod
def from_common(cls, goal: str, common: TaskRequestCommon) -> TaskRequestBuilder[None]:
"""Derive a new task builder initialized with existing environment constraints."""
builder = TaskRequestBuilder[None](goal=goal)
builder._max_steps = common.max_steps
builder._record_trace = common.record_trace
builder._trace_path = common.trace_path
builder._llm_output_path = common.llm_output_path
builder._locked_app_package = common.locked_app_package
builder._app_path = common.app_path
return builder
def from_common(cls, goal: str, common: TaskRequestCommon):
res = cls(goal=goal)
res._max_steps = common.max_steps
res._record_trace = common.record_trace
res._trace_path = common.trace_path
res._llm_output_path = common.llm_output_path
res._locked_app_package = common.locked_app_package
res._app_path = common.app_path
return res
def using_profile(self, profile: str | AgentProfile) -> Self:
"""Select execution profile (e.g., 'flash', 'pro', or custom AgentProfile)."""
def using_profile(self, profile: str | AgentProfile) -> "TaskRequestBuilder[TIn]":
"""Set the agent profile for executing the task.
Args:
profile: The agent profile to use
"""
self._profile = profile
return self
def with_name(self, name: str) -> Self:
"""Assign human-readable identifier to this test task."""
self._name = name.strip()
def with_name(self, name: str) -> "TaskRequestBuilder[TIn]":
"""Set the name of the task - useful when recording traces.
Otherwise, a random name will be generated.
Args:
name: Name of the task
"""
self._name = name
return self
def without_llm_output_saving(self) -> Self:
"""Omit persisting standalone model reasoning outputs."""
"""Disable LLM output saving for the task."""
self._llm_output_path = None
return self
def with_output_description(self, description: str) -> Self:
"""Supply natural language extraction instructions for unformatted deliverables."""
def with_output_description(self, description: str) -> "TaskRequestBuilder[TIn]":
"""Set the description of the expected output format.
This is especially useful for data extraction tasks.
Args:
description: Description of the expected output format
"""
self._output_description = description
return self
def with_output_format(self, output_format: type[TOut]) -> TaskRequestBuilder[TOut]:
"""Bind output schema to a structured Pydantic model class."""
self._output_format = cast(type[TIn], output_format)
def with_output_format(self, output_format: type[TOut]) -> "TaskRequestBuilder[TOut]":
"""Set the pydantic model for the expected output format.
Args:
output_format: Pydantic model instance defining the output format
"""
self._output_format = output_format # type: ignore
return cast(TaskRequestBuilder[TOut], self)
def build(self) -> TaskRequest[TIn]:
"""Validate criteria and construct final TaskRequest instance."""
if not self._goal or not self._goal.strip():
raise ValueError("Task goal must be a non-empty natural language objective.")
"""Build the TaskRequest object.
if self._output_format is not None and self._output_description is not None:
raise ValueError("Cannot specify both output_format and output_description simultaneously.")
Returns:
A configured TaskRequest object
profile_name = self._profile.name if isinstance(self._profile, AgentProfile) else self._profile
Raises:
ValueError: If required fields are missing
"""
if not self._goal:
raise ValueError("Task goal is required")
return TaskRequest(
goal=self._goal.strip(),
profile=profile_name,
if self._output_format and self._output_description:
raise ValueError("Output format and description are mutually exclusive")
task_request = TaskRequest(
goal=self._goal,
profile=self._profile.name
if isinstance(self._profile, AgentProfile)
else self._profile,
task_name=self._name,
output_description=self._output_description,
output_format=self._output_format,
@@ -157,3 +253,4 @@ class TaskRequestBuilder(TaskRequestCommonBuilder, Generic[TIn]):
locked_app_package=self._locked_app_package,
app_path=self._app_path,
)
return task_request
@@ -1,53 +0,0 @@
# 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.
"""Example: Accessibility Helper Diagnostics & Resilient Hierarchy Dumps.
This example shows how ARTEMIS uses its managed Accessibility Helper APK
to perform conflict-free UI tree inspection alongside external test frameworks
(e.g., Mobly, Appium) without locking the Android UiAutomation service.
"""
import asyncio
from artemis.controllers.platform_specific_commands_controller import get_first_device
from artemis.drivers.android.adb_driver import AndroidAdbDriver
async def main():
device_id, platform, _ = get_first_device()
if not device_id:
print("⚠️ No connected Android device found via ADB.")
return
print(f"📱 Connected device: {device_id} (Platform: {platform})")
# Initialize the Artemis Android ADB Driver
driver = AndroidAdbDriver(device_id=device_id)
# Capture screen data through Artemis managed hierarchy pipeline
screen_data = await driver.get_screen_data()
print(f"📐 Screen Dimensions: {screen_data.width}x{screen_data.height}")
print(f"🌲 Hierarchy Elements Captured: {len(screen_data.elements)}")
if screen_data.elements:
first = screen_data.elements[0]
print(f" Root Element Class: {first.get('class', 'N/A')}")
print(f" Package: {first.get('package', 'N/A')}")
await driver.disconnect()
print("✅ Diagnostics complete.")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,69 +0,0 @@
# 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.
"""Example: Automated Android System Settings Inspection.
This example demonstrates how to use the ARTEMIS SDK to launch the Android
Settings app, inspect device battery and storage configurations, and retrieve
verified diagnostics as a structured response.
"""
import asyncio
from pydantic import BaseModel, Field
from artemis.interfaces.sdk.client import ArtemisClient
from artemis.sdk.builders.task_request_builder import TaskRequestBuilder
class BatteryDiagnostics(BaseModel):
"""Structured report of Android battery diagnostics."""
battery_percentage: str | None = Field(
default=None, description="Current battery percentage level visible on screen"
)
battery_saver_status: str | None = Field(
default=None, description="Battery Saver state (e.g. On, Off)"
)
diagnostics_summary: str = Field(
description="Summary of the battery health and system status"
)
async def main():
# Initialize the Artemis SDK client
client = ArtemisClient()
# Build the task request targeting Android Settings
task_request = (
TaskRequestBuilder(goal="Open Android Settings, navigate to Battery, and extract the current battery level and status.")
.with_name("settings_battery_inspection")
.with_max_steps(15)
.with_locked_app_package("com.android.settings")
.with_output_format(BatteryDiagnostics)
.build()
)
print(f"🚀 Running Artemis Task: {task_request.task_name}...")
task = await client.run_task(task_request)
print(f"✅ Task Status: {task.status}")
if task.result and task.result.content:
report = task.result.get_as_model(BatteryDiagnostics)
print(f"📊 Battery Level: {report.battery_percentage}")
print(f"⚡ Battery Saver: {report.battery_saver_status}")
print(f"📝 Summary: {report.diagnostics_summary}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,87 @@
# 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 artemis.sdk import Agent
from pydantic import BaseModel, Field
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class MessageResult(BaseModel):
"""Structured result from messaging task."""
model_config = {"ignored_types": (CyFunctionDetector,)}
messages_sent: int = Field(..., description="Number of messages successfully sent")
contacts: list[str] = Field(..., description="List of contacts messaged")
success: bool = Field(..., description="Whether all messages were sent successfully")
async def main() -> None:
# Create agent with default configuration
agent = Agent()
try:
await agent.init()
# Use app lock to keep execution in WhatsApp
# This ensures the agent stays in the app and relaunches if needed
task = (
agent.new_task("Send 'Happy New Year!' message to Alice, Bob, and Charlie on WhatsApp")
.with_name("send_new_year_messages")
.with_locked_app_package("com.whatsapp") # Lock to WhatsApp
.with_output_format(MessageResult)
.with_max_steps(600) # Messaging tasks may need more steps
.build()
)
print("Sending messages with app lock enabled...")
print("The agent will stay in WhatsApp and relaunch if needed.\n")
result = await agent.run_task(request=task)
if result:
print("\n=== Messaging Complete ===")
print(f"Messages sent: {result.messages_sent}")
print(f"Contacts: {', '.join(result.contacts)}")
print(f"Success: {result.success}")
else:
print("Failed to send messages")
except Exception as e:
print(f"Error: {e}")
finally:
await agent.clean()
if __name__ == "__main__":
asyncio.run(main())
@@ -1,53 +0,0 @@
# 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.
"""Example: App Workflow Automation with Trace Recording.
This example demonstrates how to orchestrate multi-step mobile UI navigation,
capture step-by-step traces, and enforce deterministic completion criteria
using ARTEMIS.
"""
import asyncio
from artemis.interfaces.sdk.client import ArtemisClient
from artemis.sdk.builders.task_request_builder import TaskRequestBuilder
async def main():
client = ArtemisClient()
# Build a task with full trace recording
task_request = (
TaskRequestBuilder(
goal="Open Clock app, verify current alarm settings, and ensure the active alarms list is visible."
)
.with_name("clock_alarm_audit")
.with_max_steps(12)
.with_locked_app_package("com.google.android.deskclock")
.with_trace_recording(enabled=True, path="traces/clock_alarm_audit")
.with_output_description("Summary of configured alarms and their status")
.build()
)
print(f"🎬 Initiating Workflow Automation: {task_request.task_name}...")
task = await client.run_task(task_request)
print(f"🏁 Execution Finished: status={task.status}")
if task.result:
print(f"⏱️ Duration: {task.result.execution_time_seconds:.2f}s, Steps: {task.result.steps_taken}")
print(f"📄 Output: {task.result.content}")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,68 +0,0 @@
# 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.
"""Example: Multimodal UI Testing and Screen Element Grounding.
This example demonstrates using ARTEMIS multimodal perception (Explorer & OCR-XML
fusion) to locate visual elements on dynamic or non-standard UI canvases without
relying on static coordinates.
"""
import asyncio
from pydantic import BaseModel, Field
from artemis.interfaces.sdk.client import ArtemisClient
from artemis.sdk.builders.task_request_builder import TaskRequestBuilder
class VisualAuditReport(BaseModel):
"""Structured report verifying visual UI layout integrity."""
active_screen_title: str = Field(description="Title of the active screen or view")
interactive_buttons_count: int = Field(
description="Number of actionable buttons identified on the current view"
)
visual_anomalies_detected: bool = Field(
description="True if unexpected popups, overlapping text, or error banners exist"
)
observations: str = Field(description="Visual layout observations and audit details")
async def main():
client = ArtemisClient()
task_request = (
TaskRequestBuilder(
goal="Open Calculator, verify numeric keypad buttons layout, and confirm display screen is empty."
)
.with_name("calculator_visual_audit")
.with_max_steps(8)
.with_locked_app_package("com.google.android.calculator")
.with_output_format(VisualAuditReport)
.build()
)
print("🔍 Starting Multimodal Vision Audit...")
task = await client.run_task(task_request)
if task.result and task.result.content:
audit = task.result.get_as_model(VisualAuditReport)
print(f"📱 View: {audit.active_screen_title}")
print(f"🔘 Interactive Buttons: {audit.interactive_buttons_count}")
print(f"⚠️ Anomalies Detected: {audit.visual_anomalies_detected}")
print(f"📝 Observations: {audit.observations}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,111 @@
# 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.
"""Simple Photo Organizer - Basic SDK Usage Example
This example demonstrates a straightforward way to use the Artemis SDK
without builders or advanced configuration. It performs a real-world automation
task:
1. Opens the photo gallery
2. Finds photos from a specific date
3. Creates an album and moves those photos into it
Run:
- python artemis/sdk/examples/simple_photo_organizer.py
"""
import asyncio
from datetime import date, timedelta
from artemis.sdk import Agent
from pydantic import BaseModel, Field
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class PhotosResult(BaseModel):
"""Structured result from photo search."""
model_config = {"ignored_types": (CyFunctionDetector,)}
found_photos: int = Field(..., description="Number of photos found")
date_range: str = Field(..., description="Date range of photos found")
album_created: bool = Field(..., description="Whether an album was created")
album_name: str = Field(..., description="Name of the created album")
photos_moved: int = Field(0, description="Number of photos moved to the album")
async def main() -> None:
# Create a simple agent with default configuration
agent = Agent()
try:
# Initialize agent (finds a device, starts required servers)
await agent.init()
# Calculate yesterday's date for the example
yesterday = date.today() - timedelta(days=1)
formatted_date = yesterday.strftime("%B %d") # e.g. "August 22"
print(f"Looking for photos from {formatted_date}...")
# First task: search for photos and organize them, with typed output
result = await agent.run_task(
goal=(
"Open the Photos/Gallery app. Find photos taken on"
f" {formatted_date}. Create a new album named '{formatted_date}"
" Memories' and move those photos into it. Count how many"
" photos were moved."
),
output=PhotosResult,
name="organize_photos",
)
# Handle and display the result
if result:
print("\n=== Photo Organization Complete ===")
print(f"Found: {result.found_photos} photos from {result.date_range}")
if result.album_created:
print(f"Created album: '{result.album_name}'")
print(f"Moved {result.photos_moved} photos to the album")
else:
print("No album was created")
else:
print("Failed to organize photos")
except Exception as e:
print(f"Error: {e}")
finally:
# Always clean up resources
await agent.clean()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,235 @@
# 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.
"""Smart Notification Assistant - Intermediate SDK Usage Example
This example demonstrates more advanced SDK features including:
- TaskRequestBuilder pattern
- Multiple agent profiles for different reasoning tasks
- Tracing for debugging/visualization
- Structured output with Pydantic
- Exception handling
It performs a practical automation task:
1. Checks notification panel for unread notifications
2. Categorizes them by priority/app
3. Performs actions based on notification content
Run:
- python artemis/sdk/examples/smart_notification_assistant.py
"""
import asyncio
from datetime import datetime
try:
from enum import StrEnum
except ImportError:
from enum import Enum
class StrEnum(str, Enum):
pass
from artemis.config import LLM, LLMConfig, LLMConfigUtils, LLMWithFallback
from artemis.sdk import Agent
from artemis.sdk.builders import Builders
from artemis.sdk.types import AgentProfile
from artemis.sdk.types.exceptions import AgentError
from pydantic import BaseModel, Field
class NotificationPriority(StrEnum):
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class Notification(BaseModel):
"""Individual notification details."""
model_config = {"ignored_types": (CyFunctionDetector,)}
app_name: str = Field(..., description="Name of the app that sent the notification")
title: str = Field(..., description="Title/header of the notification")
message: str = Field(..., description="Message content of the notification")
priority: NotificationPriority = Field(
default=NotificationPriority.MEDIUM,
description="Priority level of notification",
)
class NotificationSummary(BaseModel):
"""Summary of all notifications."""
model_config = {"ignored_types": (CyFunctionDetector,)}
total_count: int = Field(..., description="Total number of notifications found")
high_priority_count: int = Field(0, description="Count of high priority notifications")
notifications: list[Notification] = Field(
default_factory=list, description="List of individual notifications"
)
def get_agent() -> Agent:
# Create two specialized profiles:
# 1. An analyzer profile for detailed inspection tasks
analyzer_profile = AgentProfile(
name="analyzer",
llm_config=LLMConfig(
planner=LLMWithFallback(
provider="openrouter",
model="meta-llama/llama-4-scout",
fallback=LLM(provider="openrouter", model="meta-llama/llama-4-maverick"),
),
utils=LLMConfigUtils(
outputter=LLMWithFallback(
provider="openai",
model="gpt-5-nano",
fallback=LLM(provider="openai", model="gpt-5-mini"),
),
hopper=LLMWithFallback(
provider="openai",
model="gpt-5-nano",
fallback=LLM(provider="openai", model="gpt-5-mini"),
),
),
),
# from_file="/tmp/analyzer.jsonc" # can be loaded from file
)
# 2. An action profile for handling easy & fast actions based on notifications
action_profile = AgentProfile(
name="note_taker",
llm_config=LLMConfig(
planner=LLMWithFallback(
provider="openai",
model="o3",
fallback=LLM(provider="openai", model="gpt-5"),
),
utils=LLMConfigUtils(
outputter=LLMWithFallback(
provider="openai",
model="gpt-5-nano",
fallback=LLM(provider="openai", model="gpt-5-mini"),
),
hopper=LLMWithFallback(
provider="openai",
model="gpt-5-nano",
fallback=LLM(provider="openai", model="gpt-5-mini"),
),
),
),
)
# Configure default task settings with tracing
task_defaults = Builders.TaskDefaults.with_max_steps(200).build()
# Configure the agent
config = (
Builders.AgentConfig.add_profiles(profiles=[analyzer_profile, action_profile])
.with_default_profile(profile=action_profile)
.with_default_task_config(config=task_defaults)
.build()
)
return Agent(config=config)
async def main():
# Set up traces directory with timestamp for uniqueness
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
traces_dir = f"/tmp/notification_traces/{timestamp}"
agent = get_agent()
try:
# Initialize agent (finds a device, starts required servers)
await agent.init()
print("Checking for notifications...")
# Task 1: Get and analyze notifications with analyzer profile
notification_task = (
agent.new_task(
goal=(
"Open the notification panel (swipe down from top). Scroll"
" through the first 3 unread notifications. For each"
" notification, identify the app name, title, and content."
" Tag messages from messaging apps or email as high"
" priority."
)
)
.with_output_format(NotificationSummary)
.using_profile("analyzer")
.with_name("notification_scan")
.with_max_steps(400)
.with_trace_recording(enabled=True, path=traces_dir)
.build()
)
# Execute the task with proper exception handling
try:
notifications = await agent.run_task(request=notification_task)
# Display the structured results
if notifications:
print("\n=== Notification Summary ===")
print(f"Total notifications: {notifications.total_count}")
print(f"High priority: {notifications.high_priority_count}")
# Task 2: Create a note to store the notification summary
response = await agent.run_task(
goal=(
"Open my Notes app and create a new note summarizing"
f" the following information:\n{notifications}"
),
name="email_action",
profile="note_taker",
)
print(f"Action result: {response}")
else:
print("Failed to retrieve notifications")
except AgentError as e:
print(f"Agent error occurred: {e}")
except Exception as e:
print(f"Unexpected error: {type(e).__name__}: {e}")
raise
finally:
# Clean up
await agent.clean()
print(f"\nTraces saved to: {traces_dir}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,110 @@
# 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 Transcription Example
This example demonstrates how to use the video recording tools to capture
and analyze video content from a mobile device screen.
The agent can:
1. Start a screen recording
2. Perform actions while recording
3. Stop the recording and analyze its content using Gemini models
Use case: Recording a video playing on the screen and transcribing its content.
"""
import asyncio
from artemis.config import LLM, LLMConfig, LLMConfigUtils, LLMWithFallback
from artemis.sdk.agent import Agent
from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder
from artemis.sdk.types.agent import AgentConfig
from artemis.sdk.types.task import AgentProfile, TaskRequest
def get_video_capable_llm_config() -> LLMConfig:
"""Returns an LLM config with video_analyzer configured.
The video_analyzer must use a video-capable Gemini model:
- gemini-3.8-flash (recommended - fast and capable)
- gemini-3.7-flash
- gemini-3-pro-preview
- gemini-2.5-flash
- gemini-2.5-pro
- gemini-2.0-flash
"""
return LLMConfig(
planner=LLMWithFallback(
provider="openai",
model="gpt-5-nano",
fallback=LLM(provider="openai", model="gpt-5-mini"),
),
utils=LLMConfigUtils(
outputter=LLMWithFallback(
provider="openai",
model="gpt-5-nano",
fallback=LLM(provider="openai", model="gpt-5-mini"),
),
hopper=LLMWithFallback(
provider="openai",
model="gpt-5-nano",
fallback=LLM(provider="openai", model="gpt-5-mini"),
),
video_analyzer=LLMWithFallback(
provider="google",
model="gemini-3.8-flash",
fallback=LLM(provider="google", model="gemini-3.7-flash"),
),
),
)
async def main():
config: AgentConfig = (
AgentConfigBuilder()
.add_profile(
AgentProfile(
name="VideoCapable",
llm_config=get_video_capable_llm_config(),
)
)
.with_video_recording_tools()
.build()
)
agent = Agent(config=config)
try:
await agent.init()
result = await agent.run_task(
request=TaskRequest(
goal="""
1. Open YouTube app
2. Search for "Python tutorial"
3. Start recording the screen
4. Play the first video
5. Wait for the first 30 seconds of the video to play
6. Stop recording and tell me what was said in the video
""",
profile="VideoCapable",
)
)
print(f"Task result: {result}")
finally:
await agent.clean()
if __name__ == "__main__":
asyncio.run(main())
+2 -2
View File
@@ -16,6 +16,7 @@
from artemis.sdk.types.agent import (
AgentConfig,
ApiBaseUrl,
DevicePlatform,
ServerConfig,
)
@@ -36,10 +37,10 @@ from artemis.sdk.types.task import (
TaskRequest,
TaskRequestCommon,
TaskResult,
TaskRunStatus,
)
__all__ = [
"ApiBaseUrl",
"AgentConfig",
"DevicePlatform",
"AgentProfile",
@@ -47,7 +48,6 @@ __all__ = [
"TaskRequest",
"TaskResult",
"TaskRequestCommon",
"TaskRunStatus",
"Task",
"AgentProfileNotFoundError",
"AgentTaskRequestError",
+56 -3
View File
@@ -11,11 +11,9 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
from typing import Literal
from urllib.parse import urlparse
from langchain_core.callbacks.base import Callbacks
from artemis.config import (
@@ -31,8 +29,63 @@ from artemis.utils.video import detect_video_tools_enabled
from pydantic import BaseModel, Field
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class ApiBaseUrl(BaseModel):
"""Defines an API base URL."""
model_config = {"ignored_types": (CyFunctionDetector,)}
scheme: Literal["http", "https"]
host: str
port: int | None = None
def __eq__(self, other):
if not isinstance(other, ApiBaseUrl):
return False
return self.to_url() == other.to_url()
def to_url(self):
return (
f"{self.scheme}://{self.host}:{self.port}"
if self.port is not None
else f"{self.scheme}://{self.host}"
)
@classmethod
def from_url(cls, url: str) -> "ApiBaseUrl":
parsed_url = urlparse(url)
if parsed_url.scheme not in ["http", "https"]:
raise ValueError(f"Invalid scheme: {parsed_url.scheme}")
if parsed_url.hostname is None:
raise ValueError("Invalid hostname")
return cls(
scheme=parsed_url.scheme, # type: ignore
host=parsed_url.hostname,
port=parsed_url.port,
)
class ServerConfig(BaseModel):
"""Configuration for the required servers."""
model_config = {"ignored_types": (CyFunctionDetector,)}
adb_host: str
adb_port: int
+38 -39
View File
@@ -12,95 +12,94 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Hierarchical exception taxonomy for the ARTEMIS client and automation runtime.
"""Exceptions for the ARTEMIS SDK.
Establishes categorized exception types covering host device discovery,
daemon lifecycle management, agent operational faults, and toolchain dependencies.
This module defines the exception hierarchy used throughout the Artemis SDK.
"""
from __future__ import annotations
from typing import Literal
_TOOLCHAIN_INSTALL_URLS: dict[str, str] = {
"adb": "https://developer.android.com/tools/adb",
}
class ArtemisError(Exception):
"""Root ancestor of all exceptions originating from ARTEMIS operations."""
"""Base exception class for all ARTEMIS SDK exceptions."""
def __init__(self, message: str = "An unexpected error occurred within the ARTEMIS runtime.") -> None:
def __init__(self, message="An error occurred in the Artemis SDK"):
self.message = message
super().__init__(self.message)
class DeviceError(ArtemisError):
"""Raised when hardware interaction or ADB communication fails."""
"""Exception raised for errors related to mobile devices."""
def __init__(self, message: str = "A mobile device error occurred.") -> None:
def __init__(self, message="A device-related error occurred"):
super().__init__(message)
class DeviceNotFoundError(DeviceError):
"""Raised when no authorized target Android device or emulator is accessible."""
"""Exception raised when no mobile device is found."""
def __init__(self, message: str = "No connected and authorized Android device found.") -> None:
def __init__(self, message="No mobile device found"):
super().__init__(message)
class ServerError(ArtemisError):
"""Raised when an internal ARTEMIS daemon or background service encounters an error."""
"""Exception raised for errors related to ARTEMIS servers."""
def __init__(self, message: str = "An ARTEMIS daemon service failure occurred.") -> None:
def __init__(self, message="A server-related error occurred"):
super().__init__(message)
class ServerStartupError(ServerError):
"""Raised when a background process (e.g. Scrcpy, MCP, or local server) fails to boot."""
"""Exception raised when ARTEMIS servers fail to start."""
def __init__(self, server_name: str | None = None, message: str | None = None) -> None:
resolved_msg = message or (f"Service '{server_name}' failed to start." if server_name else "Service startup failed.")
super().__init__(resolved_msg)
def __init__(self, server_name=None, message=None):
if server_name and not message:
message = f"Failed to start {server_name}"
elif not message:
message = "Failed to start Artemis servers"
super().__init__(message)
self.server_name = server_name
class AgentError(ArtemisError):
"""Raised when reasoning, task scheduling, or graph traversal fails."""
"""Exception raised for errors related to the ARTEMIS agent."""
def __init__(self, message: str = "An agent operational failure occurred.") -> None:
def __init__(self, message="An agent-related error occurred"):
super().__init__(message)
class AgentNotInitializedError(AgentError):
"""Raised when invoking automation actions before initialization."""
"""Exception raised when attempting operations on an uninitialized agent."""
def __init__(self, message: str = "Agent runtime has not been initialized.") -> None:
def __init__(self, message="Agent is not initialized. Call init() first"):
super().__init__(message)
class AgentTaskRequestError(AgentError):
"""Raised when task parameters violate execution bounds or constraints."""
"""Exception raised when a requested task is invalid."""
def __init__(self, message: str = "Invalid task request configuration.") -> None:
def __init__(self, message="An agent task-related error occurred"):
super().__init__(message)
class AgentProfileNotFoundError(AgentTaskRequestError):
"""Raised when requested execution profile is not registered in system catalog."""
"""Exception raised when an agent profile is not found."""
def __init__(self, profile_name: str) -> None:
super().__init__(f"Agent profile '{profile_name}' is not registered.")
self.profile_name = profile_name
def __init__(self, profile_name: str):
super().__init__(f"Agent profile {profile_name} not found")
EXECUTABLES = Literal["adb"]
class ExecutableNotFoundError(ArtemisError):
"""Raised when a mandatory host CLI toolchain executable is missing from PATH."""
"""Exception raised when a required executable is not found."""
def __init__(self, executable_name: Literal["adb"] | str) -> None:
guidance = _TOOLCHAIN_INSTALL_URLS.get(executable_name, "")
msg = f"Required tool '{executable_name}' was not detected in system PATH."
if guidance:
msg += f" Install instructions: {guidance}"
super().__init__(msg)
self.executable_name = executable_name
def __init__(self, executable_name: EXECUTABLES):
install_instructions: dict[EXECUTABLES, str] = {
"adb": "https://developer.android.com/tools/adb",
}
message = f"Required executable '{executable_name}' not found in PATH."
if executable_name in install_instructions:
message += f"\nTo install it, please visit: {install_instructions[executable_name]}"
super().__init__(message)
+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.
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
from pydantic.v1.utils import to_lower_camel
TaskRunStatus = Literal["pending", "running", "completed", "failed", "cancelled"]
IsoDatetime = Annotated[
datetime,
PlainSerializer(
func=lambda v: v.isoformat() if v else None,
return_type=str,
when_used="unless-none",
),
]
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class BaseApiModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_lower_camel,
populate_by_name=True,
str_strip_whitespace=True,
)
class LLMProfileResponse(BaseApiModel):
"""Response model for LLM profile."""
id: str = Field(..., description="Profile ID")
name: str = Field(..., description="Profile name")
description: str | None = Field(None, description="Profile description")
llms: dict[str, Any] = Field(..., description="LLM configuration")
created_at: str = Field(..., description="Creation timestamp (ISO format)")
updated_at: str = Field(..., description="Last update timestamp (ISO format)")
class TaskOptionsResponse(BaseApiModel):
"""Response model for task options."""
id: str = Field(..., description="Options ID")
enable_tracing: bool = Field(..., description="Whether tracing is enabled")
max_steps: int = Field(..., description="Maximum number of steps")
enable_video_tools: bool = Field(..., description="Whether video tools are enabled")
created_at: str = Field(..., description="Creation timestamp (ISO format)")
updated_at: str = Field(..., description="Last update timestamp (ISO format)")
class TaskResponse(BaseApiModel):
"""Response model for task."""
id: str = Field(..., description="Task ID")
name: str = Field(..., description="Task name")
description: str | None = Field(None, description="Task description")
input_prompt: str = Field(..., description="Input prompt")
output_description: str | None = Field(None, description="Output description")
options: TaskOptionsResponse = Field(..., description="Task options")
created_at: str = Field(..., description="Creation timestamp (ISO format)")
updated_at: str = Field(..., description="Last update timestamp (ISO format)")
class CreateTaskRunRequest(BaseApiModel):
"""Request model for creating a task run."""
task_id: str = Field(..., description="ID of the task to run")
llm_profile_id: str = Field(..., description="LLM profile ID to use")
virtual_mobile_id: str | None = Field(None, description="Virtual mobile ID to use")
locked_app_package: str | None = Field(None, description="App package to lock for the task run")
execution_origin: str | None = Field(None, description="Origin of the task execution")
enable_video_tools: bool = Field(
default=False,
description="Whether video tools are enabled for this task run",
)
class CreateOrphanTaskRunRequest(BaseApiModel):
"""Request model for creating an orphan task run (without a parent task)."""
input_prompt: str = Field(
...,
description="Input prompt for the task run",
)
output_description: str | None = Field(
None,
description="Optional output description",
)
llm_profile_id: str = Field(
...,
description="LLM profile ID to use",
)
virtual_mobile_id: str | None = Field(
None,
description="Optional virtual mobile ID on which it runs",
)
locked_app_package: str | None = Field(
None,
description="Optional app package to lock execution to",
)
execution_origin: str | None = Field(
None,
description="Origin of the task run execution",
)
max_steps: int | None = Field(
None,
description="Maximum number of steps for the task run",
)
enable_video_tools: bool = Field(
default=False,
description="Whether video tools are enabled for this task run",
)
class UpdateTaskRunStatusRequest(BaseApiModel):
"""Request model for updating task run status."""
status: TaskRunStatus = Field(..., description="New status of the task run")
message: str | None = Field(None, description="Message associated with the status")
output: str | None = Field(None, description="Output of the task run")
class TaskRunResponse(BaseApiModel):
"""Response model for a single task run."""
id: str = Field(..., description="Unique identifier for the task run")
task: TaskResponse | None = Field(
...,
description=("ID of the task this run is for or None if manually created"),
)
llm_profile: LLMProfileResponse = Field(..., description="LLM profile ID used for this run")
status: TaskRunStatus = Field(..., description="Current status of the task run")
input_prompt: str = Field(..., description="Input prompt for this task run")
output_description: str | None = Field(None, description="Description of expected output")
created_at: datetime = Field(..., description="When the task run was created")
started_at: datetime | None = Field(None, description="When the task run started")
finished_at: datetime | None = Field(None, description="When the task run finished")
locked_app_package: str | None = Field(
None, description="The app package to lock for the task run"
)
SubgoalState = Literal["pending", "started", "completed", "failed"]
class ArtemisSubgoal(BaseModel):
"""Upsert ArtemisSubgoal API model."""
model_config = {"ignored_types": (CyFunctionDetector,)}
name: str = Field(..., description="Name of the subgoal")
state: SubgoalState = Field(default="pending", description="Current state of the subgoal")
started_at: IsoDatetime | None = Field(default=None, description="When the subgoal started")
ended_at: IsoDatetime | None = Field(default=None, description="When the subgoal ended")
class UpsertTaskRunPlanRequest(BaseApiModel):
"""Upsert task run plan request model."""
started_at: IsoDatetime = Field(..., description="When the plan started")
subgoals: list[ArtemisSubgoal] = Field(..., description="Subgoals of the plan")
ended_at: IsoDatetime | None = Field(
default=None,
description="When the plan ended (replanned or completed)",
)
class TaskRunPlanResponse(UpsertTaskRunPlanRequest):
"""Response model for a task run plan."""
id: str = Field(..., description="Unique identifier for the task run plan")
task_run_id: str = Field(..., description="ID of the task run this plan is for")
class TrajectoryGifUploadResponse(BaseApiModel):
"""Response containing trajectory GIF signed upload URL."""
signed_url: str = Field(..., description="Signed upload URL for the trajectory GIF")
task_run_id: str = Field(..., description="Task run ID for which the GIF URL was generated")
file_path: str = Field(..., description="Path where the GIF will be stored")
+138 -86
View File
@@ -11,54 +11,67 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Task specifications, execution states, and outcome schemas for the ARTEMIS SDK.
Defines the declarative configuration contract for mobile automation goals,
agent operational profiles, telemetry preferences, and asynchronous result contracts.
"""
from __future__ import annotations
"""Task-related type definitions for the ARTEMIS SDK."""
from collections.abc import Callable, Coroutine
from datetime import datetime
from pathlib import Path
from typing import Any, Generic, Literal, TypeVar, overload
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, Generic, TypeVar, overload
from artemis.config import LLMConfig, get_default_llm_config
from artemis.constants import RECURSION_LIMIT
from artemis.context import DeviceContext
from artemis.sdk.types.platform import TaskRunStatus
from artemis.sdk.utils import load_llm_config_override
from pydantic import BaseModel, Field
TaskRunStatus = Literal["pending", "running", "completed", "failed", "cancelled"]
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class AgentProfile(BaseModel):
"""Runtime configuration profile governing LLM model selection and reasoning policies."""
"""Represents a ARTEMIS agent profile.
model_config = ConfigDict(arbitrary_types_allowed=True)
Attributes:
name: Name of the agent - used to reference the agent when running
tasks.
llm_config: LLM configuration for the agent.
"""
name: str = Field(description="Identifier for referencing this operational agent profile.")
model_config = {"ignored_types": (CyFunctionDetector,)}
name: str
llm_config: LLMConfig = Field(default_factory=get_default_llm_config)
@overload
def __init__(self, *, name: str, llm_config: LLMConfig): ...
@overload
def __init__(self, *, name: str, from_file: str | Path): ...
def __init__(self, *, name: str, from_file: str): ...
def __init__(
self,
*,
name: str,
llm_config: LLMConfig | None = None,
from_file: str | Path | None = None,
**kwargs: Any,
from_file: str | None = None,
**kwargs,
):
kwargs["name"] = name
if from_file:
@@ -66,11 +79,11 @@ class AgentProfile(BaseModel):
elif llm_config:
kwargs["llm_config"] = llm_config
else:
raise ValueError("Either llm_config or from_file must be specified.")
raise ValueError("Either llm_config or from_file must be provided")
super().__init__(**kwargs)
def __str__(self) -> str:
return f"AgentProfile({self.name}): {self.llm_config}"
def __str__(self):
return f"Profile {self.name}:\n{self.llm_config}"
T = TypeVar("T", bound=BaseModel)
@@ -78,73 +91,146 @@ TOutput = TypeVar("TOutput", bound=BaseModel | None)
class TaskRequestBase(BaseModel):
"""Baseline runtime limits and telemetry recording settings for automation runs."""
model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True)
"""Defines common parameters of a mobile automation task request."""
model_config = {"ignored_types": (CyFunctionDetector,)}
max_steps: int = RECURSION_LIMIT
record_trace: bool = True
trace_path: Path = Field(default_factory=lambda: Path("traces"))
trace_path: Path = Path("traces")
llm_output_path: Path | None = None
class TaskRequestCommon(TaskRequestBase):
"""Common automation constraints including application isolation and pre-install packages."""
"""Defines common parameters for any task request."""
model_config = {"ignored_types": (CyFunctionDetector,)}
max_steps: int = RECURSION_LIMIT
locked_app_package: str | None = None
app_path: Path | None = None
"""Path to an app to install before running the task.
For Android: Path to an APK file.
The app will be installed automatically before the task starts.
"""
class TaskRequest(TaskRequestCommon, Generic[TOutput]):
"""Declarative request specifying the high-level mobile testing or automation objective."""
"""Defines the format of a mobile automation task request.
goal: str = Field(description="Natural language instruction outlining target behavior or assertions.")
profile: str | None = Field(default=None, description="Execution profile (e.g. 'flash' or 'pro').")
task_name: str | None = Field(default=None, description="Optional label identifying this task execution.")
output_description: str | None = Field(default=None, description="Formatting prompt for expected deliverables.")
output_format: type[TOutput] | None = Field(default=None, description="Target Pydantic schema for structured output.")
Attributes:
goal: Natural language description of the goal to achieve
profile: Optional agent profile to use for executing the task
task_name: Optional name for the task
output_description: Optional natural language description of expected
output format
output_format: Optional pydantic model for the output format of the task
max_steps: Maximum number of steps the agent can take (default: 20)
record_trace: Whether to record a trace (screenshots, actions) of the
execution (default: False)
trace_path: Directory path to save trace data if recording is enabled
llm_output_path: Path to save LLM output data
"""
model_config = {"ignored_types": (CyFunctionDetector,)}
goal: str
profile: str | None = None
task_name: str | None = None
output_description: str | None = None
output_format: type[TOutput] | None = None
enable_remote_tracing: bool = False
class TaskResult(BaseModel):
"""Immutable outcome payload produced upon automation task completion."""
"""Result of a mobile automation task.
model_config = ConfigDict(arbitrary_types_allowed=True)
Attributes:
content: Raw result content (could be text or structured data)
error: Error message if the task failed
execution_time_seconds: How long the task took to execute
steps_taken: Number of steps the agent took to complete the task
"""
model_config = {"ignored_types": (CyFunctionDetector,)}
content: Any = None
error: str | None = None
execution_time_seconds: float = 0.0
steps_taken: int = 0
@property
def succeeded(self) -> bool:
"""True if the task terminated without error."""
return self.error is None
execution_time_seconds: float
steps_taken: int
def get_as_model(self, model_class: type[T]) -> T:
"""Parse structured content into the requested Pydantic model instance."""
"""Parse the content into a Pydantic model instance.
Args:
model_class: The Pydantic model class to parse the data into
Returns:
An instance of the specified model class
Raises:
ValueError: If content is None or not compatible with the model
"""
if self.content is None:
raise ValueError("No content available to parse into the requested schema.")
raise ValueError("No content available to parse into a model")
if isinstance(self.content, model_class):
return self.content
return model_class.model_validate(self.content)
class Task(BaseModel):
"""Stateful execution container tracking lifecycle progress, status transitions, and final results."""
"""A mobile automation task to be executed.
model_config = ConfigDict(arbitrary_types_allowed=True)
Attributes:
id: Unique identifier for the task
device: Information about the target device
status: Current status of the task execution
request: User task request
created_at: ISO timestamp when the task was created
ended_at: ISO timestamp when the task ended
"""
model_config = {"ignored_types": (CyFunctionDetector,)}
id: str
device: DeviceContext
status: TaskRunStatus = "pending"
status: TaskRunStatus
status_message: str | None = None
on_status_changed: Callable[[TaskRunStatus, str | None, Any | None], Coroutine] | None = None
request: TaskRequest[Any]
created_at: datetime = Field(default_factory=datetime.now)
request: TaskRequest
created_at: datetime
ended_at: datetime | None = None
result: TaskResult | None = None
async def finalize(
self,
content: Any | None = None,
state: dict | None = None,
error: str | None = None,
cancelled: bool = False,
):
new_status: TaskRunStatus = "completed" if error is None else "failed"
if new_status == "failed" and cancelled:
new_status = "cancelled"
message = "Task completed successfully"
if new_status == "failed":
message = "Task failed" + (f": {error}" if error else "")
elif new_status == "cancelled":
message = "Task cancelled" + (f": {error}" if error else "")
await self.set_status(status=new_status, message=message, output=content or error)
self.ended_at = datetime.now()
duration = self.ended_at - self.created_at
steps_taken = -1
if state is not None:
metadata = state.get("metadata", None)
if metadata:
steps_taken = metadata.get("step_count", -1)
self.result = TaskResult(
content=content,
error=error,
execution_time_seconds=duration.total_seconds(),
steps_taken=steps_taken,
)
def get_name(self) -> str:
return self.request.task_name or self.id
@@ -153,42 +239,8 @@ class Task(BaseModel):
status: TaskRunStatus,
message: str | None = None,
output: Any | None = None,
) -> None:
):
self.status = status
self.status_message = message
if self.on_status_changed is not None:
if self.on_status_changed:
await self.on_status_changed(status, message, output)
async def finalize(
self,
content: Any | None = None,
state: dict[str, Any] | None = None,
error: str | None = None,
cancelled: bool = False,
) -> None:
if cancelled:
resolved_status: TaskRunStatus = "cancelled"
default_msg = f"Task cancelled: {error}" if error else "Task cancelled."
elif error:
resolved_status = "failed"
default_msg = f"Task failed: {error}"
else:
resolved_status = "completed"
default_msg = "Task completed successfully."
await self.set_status(status=resolved_status, message=default_msg, output=content or error)
self.ended_at = datetime.now()
elapsed = (self.ended_at - self.created_at).total_seconds()
steps = -1
if state is not None:
metadata = state.get("metadata")
if isinstance(metadata, dict):
steps = metadata.get("step_count", -1)
self.result = TaskResult(
content=content,
error=error,
execution_time_seconds=max(0.0, elapsed),
steps_taken=steps,
)
+3
View File
@@ -46,6 +46,7 @@ from artemis.tools.tool_wrapper import (
invoke_tool_with_injection,
tool_result_messages,
)
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
@@ -60,6 +61,8 @@ 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.",
+3
View File
@@ -27,6 +27,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -35,6 +36,8 @@ 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=(
+3
View File
@@ -58,6 +58,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.element_hit_test import find_element_at_point
from artemis.utils.logger import get_logger
from artemis.utils.visualization import draw_dots
@@ -93,6 +94,8 @@ CANDIDATE_DOT_COLOR = "magenta"
class AskExplorerArgs(BaseModel):
"""Arguments of ``ask_explorer`` as seen by every calling agent."""
model_config = {"ignored_types": (CyFunctionDetector,)}
query: str = Field(..., description=ASK_EXPLORER_QUERY_DESCRIPTION)
context_feedback: str = Field("", description=ASK_EXPLORER_CONTEXT_FEEDBACK_DESCRIPTION)
+3
View File
@@ -25,6 +25,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -33,6 +34,8 @@ 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=(
+3
View File
@@ -27,6 +27,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -35,6 +36,8 @@ 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=(
+8 -8
View File
@@ -18,10 +18,7 @@ from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.agents.entity_extractor.entity_extractor import (
ExtractionResult,
extract_entity,
)
from artemis.agents.hopper.hopper import HopperOutput, hopper
from artemis.context import ArtemisContext
from artemis.controllers.platform_specific_commands_controller import (
list_packages_async,
@@ -31,6 +28,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory
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
@@ -39,6 +37,8 @@ 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.",
@@ -77,21 +77,21 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
package_cache[app_name] = app_name
return app_name
extractor_output: ExtractionResult = await extract_entity(
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 extractor_output.found or not extractor_output.output:
if not hopper_output.found or not hopper_output.output:
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None
package_name = extractor_output.output.strip()
package_name = hopper_output.output.strip()
if package_name not in package_set:
logger.warning(
f"Entity extractor returned package '{package_name}' for '{app_name}', "
f"Hopper returned package '{package_name}' for '{app_name}', "
"but it is NOT physically installed on the device!"
)
if isinstance(package_cache, dict):
+5
View File
@@ -25,6 +25,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -32,6 +33,10 @@ 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'"
+5
View File
@@ -35,6 +35,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -43,6 +44,8 @@ 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.")
@@ -50,6 +53,8 @@ class ObjectDetectionArgs(BaseModel):
# 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.")
+13
View File
@@ -25,6 +25,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool
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,
@@ -56,18 +57,24 @@ 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)
@@ -75,6 +82,8 @@ class UpdateNoteArgs(BaseModel):
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)")
@@ -82,6 +91,10 @@ class ReadNoteArgs(BaseModel):
class ListNotesArgs(BaseModel):
"""Arguments schema for listing notes."""
model_config = {"ignored_types": (CyFunctionDetector,)}
class SaveNoteTool(ArtemisTool):
"""Universal tool for saving notes to persistent memory."""
+5
View File
@@ -31,8 +31,13 @@ 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]
+38 -24
View File
@@ -12,47 +12,61 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Multi-strategy target element descriptors for ARTEMIS actions.
Encapsulates semantic identifiers, textual queries, and spatial bounding boxes
into a prioritized target resolution contract.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, model_validator
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):
"""Composite locator descriptor resolving UI nodes across dynamic and visual channels."""
"""A comprehensive locator for a UI element, supporting a fallback mechanism."""
model_config = ConfigDict(extra="ignore")
model_config = {"ignored_types": (CyFunctionDetector,)}
resource_id: str | None = Field(default=None, description="Android resource identifier token.")
resource_id: str | None = Field(None, description="The resource-id of the element.")
resource_id_index: int | None = Field(
default=None,
description="Zero-based ordinal index when multiple nodes match resource_id.",
None,
description=("The zero-based index if multiple elements share the same resource-id."),
)
text: str | None = Field(
default=None,
description="Exact or case-insensitive element label or content descriptor.",
None,
description=("The text content of the element (e.g., a label or placeholder)."),
)
text_index: int | None = Field(
default=None,
description="Zero-based ordinal index when multiple nodes match text query.",
None,
description=("The zero-based index if multiple elements share the same text."),
)
bounds: ElementBounds | None = Field(
default=None,
description="Spatial boundary metrics (x, y, width, height) of the element.",
None, description="The x, y, width, and height of the element."
)
@model_validator(mode="after")
def _normalize_indices(self) -> Target:
"""Coerce missing indices to zero if primary selector string is populated."""
if self.resource_id and self.resource_id.strip() and self.resource_id_index is None:
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 and self.text.strip() and self.text_index is None:
if (self.text is not None and self.text != "") and self.text_index is None:
self.text_index = 0
return self
+277 -207
View File
@@ -11,29 +11,25 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Spatial positioning, focus resolution, and interaction helpers for ARTEMIS tools.
Coordinates locator resolution across hierarchy trees, validates bounded screen areas,
and routes focus and cursor placement to target elements.
"""
from __future__ import annotations
from typing import Any, Literal
from typing import Literal
from pydantic import BaseModel, ConfigDict
from artemis.context import ArtemisContext
from artemis.controllers.types import CoordinatesSelectorRequest, PercentagesSelectorRequest
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,
@@ -43,54 +39,293 @@ from artemis.utils.ui_hierarchy import (
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):
"""Target selector driven by Android resource ID."""
"""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):
"""Target selector driven by exact or case-insensitive element label."""
"""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):
"""Target selector pointing to an absolute pixel coordinate."""
"""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):
"""Target selector pointing to normalized fractional [0.0, 1.0] coordinates."""
"""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):
"""Composite selector pairing resource ID with expected text."""
"""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}
@@ -106,30 +341,37 @@ SelectorRequest = (
def _extract_resource_id_and_text_from_selector(
selector: SelectorRequest,
) -> tuple[str | None, str | None]:
"""Unpack identifier and textual query from any supported SelectorRequest."""
"""Extract resource_id and text from a selector."""
resource_id = None
text = None
if isinstance(selector, IdSelectorRequest):
return selector.id, None
if isinstance(selector, TextSelectorRequest):
return None, selector.text
if isinstance(selector, IdWithTextSelectorRequest):
return selector.id, selector.text
return None, None
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,
) -> Any:
"""Dispatch an on-screen tap to an element or coordinate selector."""
):
"""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):
res = await controller.tap_at(
x=selector_request.coordinates.x,
y=selector_request.coordinates.y,
result = await controller.tap_at(
x=selector_request.coordinates.x, y=selector_request.coordinates.y
)
return res.error if res.error else None
return result.error if result.error else None
if isinstance(selector_request, SelectorRequestWithPercentages):
coords = selector_request.percentages.to_coords(
width=ctx.device.device_width,
@@ -137,183 +379,11 @@ async def tap(
)
return await controller.tap_at(coords.x, coords.y)
res_id, txt = _extract_resource_id_and_text_from_selector(selector_request)
# 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=res_id,
text=txt,
resource_id=resource_id,
text=text,
index=index if index is not None else 0,
)
def find_element_by_text(
ui_hierarchy: list[dict[str, Any]], text: str, index: int | None = None
) -> dict[str, Any] | None:
"""Search for elements matching label text across nested hierarchy tree nodes."""
target_text = (text or "").strip().lower()
if not target_text:
return None
matched_nodes: list[dict[str, Any]] = []
def _traverse(nodes: list[dict[str, Any]]) -> None:
for node in nodes:
if not isinstance(node, dict):
continue
attrs = node.get("attributes", node)
val = attrs.get("text")
if isinstance(val, str) and val.strip().lower() == target_text:
matched_nodes.append(attrs if "attributes" in node else node)
children = node.get("children")
if isinstance(children, list) and children:
_traverse(children)
_traverse(ui_hierarchy)
if not matched_nodes:
return None
target_idx = max(0, index or 0)
if target_idx < len(matched_nodes):
return matched_nodes[target_idx]
return None
async def tap_bottom_right_of_element(bounds: ElementBounds, ctx: ArtemisContext) -> None:
"""Position pointer near the trailing boundary of an input element."""
corner_point = bounds.get_relative_point(x_percent=0.99, y_percent=0.99)
await tap(
ctx=ctx,
selector_request=SelectorRequestWithCoordinates(
coordinates=CoordinatesSelectorRequest(x=corner_point.x, y=corner_point.y)
),
)
async def move_cursor_to_end_if_bounds(
ctx: ArtemisContext,
state: State,
target: Target,
elt: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Position insertion point at the end of an input element boundary."""
current_tree = state.latest_ui_hierarchy or []
if target.resource_id:
element_node = elt or find_element_by_resource_id(
ui_hierarchy=current_tree,
resource_id=target.resource_id,
index=target.resource_id_index,
)
if not element_node:
return None
box = get_bounds_for_element(element_node)
if not box:
return element_node
await tap_bottom_right_of_element(bounds=box, ctx=ctx)
return element_node
if target.bounds:
await tap_bottom_right_of_element(bounds=target.bounds, ctx=ctx)
return elt
if target.text:
matched = find_element_by_text(current_tree, target.text, index=target.text_index)
if matched:
box = get_bounds_for_element(matched)
if box:
await tap_bottom_right_of_element(bounds=box, ctx=ctx)
return matched
return None
return None
async def focus_element_if_needed(
ctx: ArtemisContext, target: Target
) -> Literal["resource_id", "coordinates", "text", "already_focused"] | None:
"""Ensure element possesses active input focus, falling back through available selectors."""
if not target.resource_id and not target.bounds and not target.text:
return "already_focused"
controller = UnifiedMobileController(ctx)
hierarchy = await controller.get_ui_elements()
element_by_id = None
if target.resource_id:
element_by_id = find_element_by_resource_id(
ui_hierarchy=hierarchy,
resource_id=target.resource_id,
index=target.resource_id_index,
is_rich_hierarchy=False,
)
if element_by_id and target.text:
observed_label = get_element_text(element_by_id)
if not observed_label or target.text.strip().lower() != observed_label.strip().lower():
logger.warning(
f"ID '{target.resource_id}' and text '{target.text}' do not align to the same node."
" Falling back to secondary selectors."
)
element_by_id = None
if element_by_id:
if not is_element_focused(element_by_id):
await tap(
ctx=ctx,
selector_request=IdSelectorRequest(id=target.resource_id), # type: ignore[arg-type]
index=target.resource_id_index,
)
hierarchy = await controller.get_ui_elements()
element_by_id = find_element_by_resource_id(
ui_hierarchy=hierarchy,
resource_id=target.resource_id, # type: ignore[arg-type]
index=target.resource_id_index,
is_rich_hierarchy=False,
)
if element_by_id and is_element_focused(element_by_id):
return "resource_id"
if target.bounds:
centroid = target.bounds.get_center()
await tap(
ctx=ctx,
selector_request=SelectorRequestWithCoordinates(
coordinates=CoordinatesSelectorRequest(x=centroid.x, y=centroid.y)
),
)
return "coordinates"
if target.text:
matched_text_node = find_element_by_text(hierarchy, target.text, index=target.text_index)
if matched_text_node:
text_box = get_bounds_for_element(matched_text_node)
if text_box:
centroid = text_box.get_center()
await tap(
ctx=ctx,
selector_request=SelectorRequestWithCoordinates(
coordinates=CoordinatesSelectorRequest(x=centroid.x, y=centroid.y)
),
)
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:
"""Verify target centroid resides within active viewport boundaries."""
if not target.bounds:
return None
c = target.bounds.get_center()
errors: list[str] = []
if not (0 <= c.x < screen_width):
errors.append(f"x={c.x} is outside screen width (0-{screen_width})")
if not (0 <= c.y < screen_height):
errors.append(f"y={c.y} is outside screen height (0-{screen_height})")
return "; ".join(errors) if errors else None
def has_valid_selectors(target: Target) -> bool:
"""Verify target defines at least one valid locator criterion."""
return bool(target.bounds is not None or target.resource_id or target.text)
+3
View File
@@ -34,6 +34,7 @@ from artemis.drivers.base import BaseDeviceDriver
from artemis.graph.state import State
from artemis.tools.base import ArtemisTool, ToolCategory
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -41,6 +42,8 @@ logger = get_logger(__name__)
class VideoAnalyzerArgs(BaseModel):
"""Arguments schema for video analyzer tool."""
model_config = {"ignored_types": (CyFunctionDetector,)}
time_description: str = Field(
...,
description=(
+3
View File
@@ -23,6 +23,7 @@ from artemis.data_engine.trace import trace_langchain_tool
from artemis.drivers.base import BaseDeviceDriver
from artemis.tools.base import ArtemisTool, ToolCategory
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.tools.types import CyFunctionDetector
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -30,6 +31,8 @@ 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."),
+3 -6
View File
@@ -11,9 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Utilities for handling app locking and initial app launch logic."""
@@ -219,7 +216,7 @@ async def _poll_for_app_ready(
Only fails if we get a different (non-null) package or timeout.
Args:
ctx: ArtemisContext
ctx: Mobile use context
app_package: Expected package name
max_poll_seconds: Maximum time to poll (default: 15s)
poll_interval: Time between polls (default: 1s)
@@ -275,7 +272,7 @@ async def launch_app_with_retries(
"""Launch an app with retry logic and smart polling.
Args:
ctx: ArtemisContext
ctx: Mobile use context
app_package: Package name (Android) to launch
max_retries: Maximum number of launch attempts (default: 3)
max_poll_seconds: Maximum time to wait for app to load per attempt
@@ -348,7 +345,7 @@ async def _handle_initial_app_launch(
3. Return status with success/error information
Args:
ctx: ArtemisContext
ctx: Mobile use context
locked_app_package: Package name (Android) to lock to
Returns:
+38
View File
@@ -0,0 +1,38 @@
# 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 sys
from adbutils import AdbClient
from rich.console import Console
def display_device_status(console: Console, adb_client: AdbClient | None = None):
"""Checks for connected devices and displays the status."""
console.print("\n[bold]📱 Device Status[/bold]")
devices = None
if adb_client is not None:
devices = adb_client.device_list()
if devices:
console.print("✅ [bold green]Android device(s) connected:[/bold green]")
for device in devices:
console.print(f" - {device.serial}")
else:
console.print("❌ [bold red]No Android device found.[/bold red]")
command = "emulator -avd <avd_name>"
if sys.platform not in ["win32", "darwin"]:
command = f"./{command}"
console.print(
f"You can start an emulator using a command like: [bold]'{command}'[/bold]"
)
+154
View File
@@ -0,0 +1,154 @@
# 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 sys
import inquirer
from rich.console import Console
from rich.prompt import Prompt
from rich.text import Text
def select_provider_and_model(
console: Console,
available_providers: list[str],
available_models: dict,
default_provider: str,
default_model: str,
provider: str | None = None,
model: str | None = None,
) -> tuple[str, str]:
"""Interactive selection of LLM provider and model with arrow-key dropdowns when available.
Args:
console: Rich console for output
available_providers: List of available provider names
available_models: Dict mapping providers to their available models
default_provider: Default provider to use
default_model: Default model to use
provider: Pre-selected provider (optional)
model: Pre-selected model (optional)
Returns:
Tuple of (selected_provider, selected_model)
"""
final_provider = provider
final_model = model
# Interactive provider selection
if not final_provider:
console.print("\n🤖 [bold cyan]LLM Provider Selection[/bold cyan]")
final_provider = _select_from_list(
console=console,
item_type="provider",
choices=available_providers,
default=default_provider,
message="Select LLM provider",
)
# Interactive model selection
if not final_model:
console.print(f"\n🎯 [bold green]Model Selection for {final_provider}[/bold green]")
available_model_list = (
available_models[final_provider]
if final_provider
else available_models[default_provider]
)
default_model_for_provider = (
default_model if default_model in available_model_list else available_model_list[0]
)
final_model = _select_from_list(
console=console,
item_type="model",
choices=available_model_list,
default=default_model_for_provider,
message=f"Select model for {final_provider}",
)
return final_provider, final_model
def _select_from_list(
console: Console,
item_type: str,
choices: list[str],
default: str,
message: str,
) -> str:
"""Select an item from a list using arrow keys when available, fallback to numbered selection.
Args:
console: Rich console for output
item_type: Type of item being selected (for error messages)
choices: List of choices to select from
default: Default choice
message: Message to display in dropdown
Returns:
Selected choice
"""
# Try arrow-key dropdown if TTY is available, fallback to numbered selection
if sys.stdin.isatty():
try:
questions = [
inquirer.List(
"selection",
message=f"{message} (use arrow keys)",
choices=choices,
default=default,
),
]
answers = inquirer.prompt(questions)
return answers["selection"] if answers else default
except Exception:
# Fallback to numbered selection
return _numbered_selection(console, item_type, choices, default)
else:
return _numbered_selection(console, item_type, choices, default)
def _numbered_selection(console: Console, item_type: str, choices: list[str], default: str) -> str:
"""Fallback numbered selection when arrow keys aren't available."""
choices_text = "\n".join([f" {i + 1}. {choice}" for i, choice in enumerate(choices)])
console.print(f"Available {item_type}s:\n{choices_text}")
default_idx = choices.index(default) + 1 if default in choices else 1
while True:
choice = Prompt.ask(
f"Select {item_type} (1-{len(choices)}) or press Enter for default",
default=str(default_idx),
)
try:
choice_idx = int(choice) - 1
if 0 <= choice_idx < len(choices):
return choices[choice_idx]
else:
console.print("[red]Invalid choice. Please try again.[/red]")
except ValueError:
console.print("[red]Please enter a number.[/red]")
def display_llm_config(console: Console, provider: str, model: str) -> None:
"""Display the selected LLM configuration with colors."""
config_text = Text()
config_text.append("🤖 LLM Configuration: ", style="bold white")
config_text.append("Provider: ", style="white")
config_text.append(f"{provider}", style="bold cyan")
config_text.append(" | Model: ", style="white")
config_text.append(f"{model}", style="bold green")
console.print(config_text)
+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.
from typing import TypeGuard
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
def is_ai_message(message: BaseMessage) -> TypeGuard[AIMessage]:
return isinstance(message, AIMessage)
def is_human_message(message: BaseMessage) -> TypeGuard[HumanMessage]:
return isinstance(message, HumanMessage)
def is_tool_message(message: BaseMessage) -> TypeGuard[ToolMessage]:
return isinstance(message, ToolMessage)
def is_tool_for_name(tool_message: ToolMessage, name: str) -> bool:
return tool_message.name == name
def get_screenshot_message_for_llm(screenshot_base64: str):
prefix = "" if screenshot_base64.startswith("data:image") else "data:image/jpeg;base64,"
return HumanMessage(
content=[
{
"type": "image_url",
"image_url": {"url": f"{prefix}{screenshot_base64}"},
}
]
)
+113 -74
View File
@@ -12,89 +12,128 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Lifecycle callback and execution telemetry decorators for ARTEMIS.
Provides function interception hooks for pre-execution logging, success verification,
and error capture across synchronous and asynchronous agent invocations.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from functools import wraps
import inspect
from typing import Any, Callable
from typing import Any, TypeVar, cast, overload
R = TypeVar("R")
def wrap_with_callbacks_sync(
fn: Callable[..., R],
*,
before: Callable[..., None] | None = None,
on_success: Callable[[R], None] | None = None,
on_failure: Callable[[Exception], None] | None = None,
suppress_exceptions: bool = False,
) -> Callable[..., R]:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> R:
if before:
before()
try:
result = fn(*args, **kwargs)
if on_success:
on_success(result)
return result
except Exception as e:
if on_failure:
on_failure(e)
if suppress_exceptions:
return None # type: ignore
raise
return wrapper
def wrap_with_callbacks_async(
fn: Callable[..., Awaitable[R]],
*,
before: Callable[..., None] | None = None,
on_success: Callable[[R], None] | None = None,
on_failure: Callable[[Exception], None] | None = None,
suppress_exceptions: bool = False,
) -> Callable[..., Awaitable[R]]:
@wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> R:
if before:
before()
try:
result = await fn(*args, **kwargs)
if on_success:
on_success(result)
return result
except Exception as e:
if on_failure:
on_failure(e)
if suppress_exceptions:
return None # type: ignore
raise
return wrapper
@overload
def wrap_with_callbacks(
fn: Callable[..., Awaitable[R]],
*,
before: Callable[[], None] | None = ...,
on_success: Callable[[R], None] | None = ...,
on_failure: Callable[[Exception], None] | None = ...,
suppress_exceptions: bool = ...,
) -> Callable[..., Awaitable[R]]: ...
@overload
def wrap_with_callbacks(
*,
before: Callable[..., None] | None = ...,
on_success: Callable[[Any], None] | None = ...,
on_failure: Callable[[Exception], None] | None = ...,
suppress_exceptions: bool = ...,
) -> Callable[[Callable[..., R]], Callable[..., R]]: ...
@overload
def wrap_with_callbacks(
fn: Callable[..., R],
*,
before: Callable[[], None] | None = ...,
on_success: Callable[[R], None] | None = ...,
on_failure: Callable[[Exception], None] | None = ...,
suppress_exceptions: bool = ...,
) -> Callable[..., R]: ...
def wrap_with_callbacks(
fn: Callable[..., Any] | None = None,
*,
before: Callable[..., None] | None = None,
before: Callable[[], None] | None = None,
on_success: Callable[[Any], None] | None = None,
on_failure: Callable[[Exception], None] | None = None,
suppress_exceptions: bool = False,
) -> Any:
"""Wrap a callable with pre-invocation, success, and error callback handlers.
def decorator(func: Callable[..., Any]) -> Any:
if inspect.iscoroutinefunction(func):
return wrap_with_callbacks_async(
cast(Callable[..., Awaitable[Any]], func),
before=before,
on_success=on_success,
on_failure=on_failure,
suppress_exceptions=suppress_exceptions,
)
else:
return wrap_with_callbacks_sync(
cast(Callable[..., Any], func),
before=before,
on_success=on_success,
on_failure=on_failure,
suppress_exceptions=suppress_exceptions,
)
Supports decorating both coroutines and regular synchronous functions.
"""
def _decorator(target_fn: Callable[..., Any]) -> Callable[..., Any]:
if inspect.iscoroutinefunction(target_fn):
@wraps(target_fn)
async def _async_wrapped(*args: Any, **kwargs: Any) -> Any:
if before is not None:
try:
before()
except Exception:
pass
try:
result = await target_fn(*args, **kwargs)
if on_success is not None:
try:
on_success(result)
except Exception:
pass
return result
except Exception as exc:
if on_failure is not None:
try:
on_failure(exc)
except Exception:
pass
if suppress_exceptions:
return None
raise
return _async_wrapped
@wraps(target_fn)
def _sync_wrapped(*args: Any, **kwargs: Any) -> Any:
if before is not None:
try:
before()
except Exception:
pass
try:
result = target_fn(*args, **kwargs)
if on_success is not None:
try:
on_success(result)
except Exception:
pass
return result
except Exception as exc:
if on_failure is not None:
try:
on_failure(exc)
except Exception:
pass
if suppress_exceptions:
return None
raise
return _sync_wrapped
if fn is not None:
return _decorator(fn)
return _decorator
if fn is None:
return decorator
else:
return decorator(fn)
+6 -33
View File
@@ -12,43 +12,16 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""File and configuration loading utilities for ARTEMIS."""
from __future__ import annotations
import json
from pathlib import Path
import re
from typing import IO, Any
from typing import IO
def strip_json_comments(text: str) -> str:
"""Removes single-line (//) and multi-line (/* ... */) comments from JSONC text.
Args:
text: Raw JSON with comments string.
Returns:
Clean JSON string with comments stripped.
"""
pattern = r"//.*?$|/\*.*?\*/"
return re.sub(pattern, "", text, flags=re.MULTILINE | re.DOTALL)
text = re.sub(r"//.*?$", "", text, flags=re.MULTILINE)
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
return text
def load_jsonc(source: str | Path | IO[str] | IO[bytes]) -> dict[str, Any]:
"""Loads and parses a JSONC (JSON with Comments) document.
Args:
source: File-like object, Path, or string path to parse.
Returns:
Parsed dictionary.
"""
if isinstance(source, (str, Path)):
raw_content = Path(source).read_text(encoding="utf-8")
else:
content = source.read()
raw_content = content.decode("utf-8") if isinstance(content, bytes) else content
cleaned = strip_json_comments(raw_content)
return json.loads(cleaned)
def load_jsonc(file: IO) -> dict:
return json.loads(strip_json_comments(file.read()))
-3
View File
@@ -11,9 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
from enum import Enum
import logging
+115 -117
View File
@@ -11,33 +11,23 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Media synthesis and trace asset management for ARTEMIS.
Handles chronological trace GIF generation, frame quantization, and step snapshot
compilation while preserving active session recording manifests.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
from typing import Any
from PIL import Image
from artemis.utils.logger import get_logger
from PIL import Image
logger = get_logger(__name__)
_PREFER_FFMPEG = os.environ.get("USE_FFMPEG_GIF", "").strip().lower() in ("1", "true", "yes")
USE_FFMPEG_GIF = os.environ.get("USE_FFMPEG_GIF", "").lower() in (
"1",
"true",
"yes",
)
def quantize_and_save_gif_from_paths(
@@ -46,68 +36,81 @@ def quantize_and_save_gif_from_paths(
colors: int = 128,
duration: int = 100,
) -> None:
"""Compile ordered image snapshots into a quantized animation asset."""
"""Create an optimized GIF from image file paths.
By default uses PIL (loads all frames into memory).
Set USE_FFMPEG_GIF=1 env var to use ffmpeg for memory-efficient streaming.
Args:
image_paths: List of paths to image files (must be sorted in desired
order)
output_path: Path where the GIF will be saved
colors: Number of colors to use in quantization (lower = smaller file)
duration: Duration of each frame in milliseconds
Raises:
ValueError: If image_paths list is empty
RuntimeError: If ffmpeg fails (when USE_FFMPEG_GIF is enabled)
"""
if not image_paths:
raise ValueError("Cannot assemble animation from an empty image sequence.")
raise ValueError("image_paths must not be empty")
if _PREFER_FFMPEG and shutil.which("ffmpeg"):
try:
_render_gif_with_ffmpeg(image_paths, output_path, colors=colors, frame_delay_ms=duration)
return
except Exception as exc:
logger.warning(f"FFmpeg GIF rendering encountered an error ({exc}); falling back to Pillow.")
_render_gif_with_pillow(image_paths, output_path, colors=colors, frame_delay_ms=duration)
if USE_FFMPEG_GIF:
_save_gif_ffmpeg(image_paths, output_path, colors, duration)
else:
_save_gif_pillow(image_paths, output_path, colors, duration)
def _render_gif_with_pillow(
paths: list[Path],
def _save_gif_pillow(
image_paths: list[Path],
output_path: Path,
colors: int,
frame_delay_ms: int,
duration: int,
) -> None:
"""Internal Pillow quantization loop."""
frames: list[Image.Image] = []
for p in paths:
try:
with Image.open(p) as img:
rgb_img = img.convert("RGB") if img.mode != "RGB" else img.copy()
frames.append(rgb_img.quantize(colors=colors, method=Image.Quantize.MEDIANCUT))
except Exception as exc:
logger.debug(f"Skipping damaged frame {p}: {exc}")
"""Create GIF using PIL (loads all frames into memory)."""
if not frames:
return
def frame_generator():
for path in image_paths:
with Image.open(path) as img:
if img.mode != "RGB":
img = img.convert("RGB")
yield img.quantize(colors=colors, method=2)
first_frame = frames[0]
frames = frame_generator()
first_frame = next(frames)
first_frame.save(
output_path,
save_all=True,
append_images=frames[1:],
append_images=frames,
loop=0,
optimize=True,
duration=frame_delay_ms,
duration=duration,
)
def _render_gif_with_ffmpeg(
paths: list[Path],
def _save_gif_ffmpeg(
image_paths: list[Path],
output_path: Path,
colors: int,
frame_delay_ms: int,
duration: int,
) -> None:
"""Streamlined two-pass palette-generated GIF rendering via FFmpeg."""
fps = max(1.0, 1000.0 / max(1, frame_delay_ms))
with tempfile.TemporaryDirectory() as tmpdir:
list_file = Path(tmpdir) / "frames.txt"
palette = Path(tmpdir) / "palette.png"
"""Create GIF using ffmpeg (memory-efficient streaming)."""
fps = 1000 / duration
lines = [f"file '{p.resolve()}'\nduration {frame_delay_ms / 1000.0}" for p in paths]
if paths:
lines.append(f"file '{paths[-1].resolve()}'")
list_file.write_text("\n".join(lines), encoding="utf-8")
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
for i, path in enumerate(image_paths):
f.write(f"file '{path.absolute()}'\n")
# Last file needs no duration (ffmpeg concat demuxer uses it as the final frame)
if i < len(image_paths) - 1:
f.write(f"duration {duration / 1000}\n")
# Repeat last file to ensure it's included (ffmpeg concat demuxer quirk)
f.write(f"file '{image_paths[-1].absolute()}'\n")
concat_file = Path(f.name)
subprocess.run(
palette_path = output_path.with_suffix(".palette.png")
try:
result = subprocess.run(
[
"ffmpeg",
"-y",
@@ -116,16 +119,18 @@ def _render_gif_with_ffmpeg(
"-safe",
"0",
"-i",
str(list_file),
str(concat_file),
"-vf",
f"palettegen=max_colors={min(colors, 256)}",
str(palette),
f"palettegen=max_colors={min(colors, 256)}:stats_mode=diff",
str(palette_path),
],
capture_output=True,
check=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg palette generation failed: {result.stderr}")
subprocess.run(
result = subprocess.run(
[
"ffmpeg",
"-y",
@@ -134,78 +139,71 @@ def _render_gif_with_ffmpeg(
"-safe",
"0",
"-i",
str(list_file),
str(concat_file),
"-i",
str(palette),
str(palette_path),
"-lavfi",
f"fps={fps},paletteuse",
f"fps={fps},paletteuse=dither=bayer:bayer_scale=5",
"-loop",
"0",
str(output_path),
],
capture_output=True,
check=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg GIF creation failed: {result.stderr}")
finally:
concat_file.unlink(missing_ok=True)
if palette_path.exists():
palette_path.unlink()
def create_gif_from_trace_folder(trace_folder_path: Path) -> None:
"""Scan and synthesize sequential JPEG frames in a trace directory to trace.gif."""
if not trace_folder_path.is_dir():
def create_gif_from_trace_folder(trace_folder_path: Path):
image_files: list[Path] = []
for file in trace_folder_path.iterdir():
if file.suffix == ".jpeg":
image_files.append(file)
image_files.sort(key=lambda f: int(f.stem))
logger.info(f"Found {len(image_files)} images to compile")
if not image_files:
return
frames = sorted(
[f for f in trace_folder_path.iterdir() if f.suffix.lower() in (".jpeg", ".jpg") and f.stem.isdigit()],
key=lambda x: int(x.stem),
)
if not frames:
return
target_gif = trace_folder_path / "trace.gif"
quantize_and_save_gif_from_paths(frames, target_gif)
logger.info(f"Assembled trace animation: {target_gif}")
gif_path = trace_folder_path / "trace.gif"
quantize_and_save_gif_from_paths(image_files, gif_path)
logger.info(f"GIF created at {gif_path}")
def remove_images_from_trace_folder(trace_folder_path: Path) -> None:
"""Purge temporary individual JPEG frame captures once compiled."""
if not trace_folder_path.is_dir():
return
for item in trace_folder_path.iterdir():
if item.suffix.lower() in (".jpeg", ".jpg") and item.stem.isdigit():
try:
item.unlink(missing_ok=True)
except OSError:
pass
def remove_images_from_trace_folder(trace_folder_path: Path):
for file in trace_folder_path.iterdir():
if file.suffix == ".jpeg":
file.unlink()
def create_steps_json_from_trace_folder(trace_folder_path: Path) -> None:
"""Collate discrete numeric step payload dumps into steps.json preserving metadata manifests."""
if not trace_folder_path.is_dir():
return
def create_steps_json_from_trace_folder(trace_folder_path: Path):
"""Compile legacy timestamp-named step files without consuming manifests."""
steps = []
for file in trace_folder_path.iterdir():
if file.suffix == ".json" and file.stem.isdigit():
with open(file, encoding="utf-8", errors="ignore") as f:
json_content = f.read()
steps.append({"timestamp": int(file.stem), "data": json_content})
numeric_step_files = sorted(
[f for f in trace_folder_path.iterdir() if f.suffix == ".json" and f.stem.isdigit()],
key=lambda x: int(x.stem),
)
steps.sort(key=lambda f: f["timestamp"])
records: list[dict[str, Any]] = []
for step_file in numeric_step_files:
try:
records.append({
"timestamp": int(step_file.stem),
"data": step_file.read_text(encoding="utf-8", errors="ignore"),
})
except Exception as exc:
logger.debug(f"Unable to read step file {step_file}: {exc}")
logger.info(f"Found {len(steps)} steps to compile")
output_file = trace_folder_path / "steps.json"
output_file.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
with open(trace_folder_path / "steps.json", "w", encoding="utf-8", errors="ignore") as f:
f.write(json.dumps(steps))
def remove_steps_json_from_trace_folder(trace_folder_path: Path) -> None:
"""Clean up standalone numeric step files, leaving steps.json and recording manifests untouched."""
if not trace_folder_path.is_dir():
return
for item in trace_folder_path.iterdir():
if item.suffix == ".json" and item.stem.isdigit():
try:
item.unlink(missing_ok=True)
except OSError:
pass
def remove_steps_json_from_trace_folder(trace_folder_path: Path):
"""Remove only legacy timestamp-named step files, preserving manifests."""
for file in trace_folder_path.iterdir():
if file.suffix == ".json" and file.stem.isdigit():
file.unlink()
+60
View File
@@ -0,0 +1,60 @@
# 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 time
from langchain_core.messages import BaseMessage
from artemis.context import ArtemisContext
from artemis.controllers.controller_factory import create_device_controller
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
async def record_interaction(ctx: ArtemisContext, response: BaseMessage):
if not ctx.execution_setup:
raise ValueError("No execution setup found")
if not ctx.execution_setup.traces_path or not ctx.execution_setup.trace_name:
raise ValueError("No traces path or trace name found")
logger.info("Recording interaction")
controller = create_device_controller(ctx)
screenshot_base64 = await controller.screenshot()
logger.info("Screenshot taken")
try:
controller = create_device_controller(ctx)
compressed_screenshot_base64 = controller.get_compressed_b64_screenshot(screenshot_base64)
except Exception as e:
logger.error(f"Error compressing screenshot: {e}")
return "Could not record this interaction"
timestamp = time.time()
folder = ctx.execution_setup.traces_path.joinpath(ctx.execution_setup.trace_name).resolve()
folder.mkdir(parents=True, exist_ok=True)
try:
with open(
folder.joinpath(f"{int(timestamp)}.jpeg").resolve(),
"wb",
) as f:
f.write(base64.b64decode(compressed_screenshot_base64))
with open(
folder.joinpath(f"{int(timestamp)}.json").resolve(),
"w",
encoding="utf-8",
) as f:
f.write(response.model_dump_json())
except Exception as e:
logger.error(f"Error recording interaction: {e}")
return "Screenshot recorded successfully"
+51
View File
@@ -0,0 +1,51 @@
# 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.utils.logger import get_logger
import requests
logger = get_logger(__name__)
def curl_from_request(req: requests.PreparedRequest) -> str:
"""Converts a requests.PreparedRequest object to a valid cURL command string."""
command = ["curl", f"-X {req.method}"]
for key, value in req.headers.items():
command.append(f'-H "{key}: {value}"')
if req.body:
body = req.body
if isinstance(body, bytes):
body = body.decode("utf-8")
# Escape single quotes in the body for shell safety
body = body.replace("'", "'\\''")
command.append(f"-d '{body}'")
command.append(f"'{req.url}'")
return " ".join(command)
def logging_hook(response, *args, **kwargs):
"""Hook to log the request as a cURL command."""
curl_command = curl_from_request(response.request)
logger.debug(f"\n--- cURL Command ---\n{curl_command}\n--------------------")
def get_session_with_curl_logging() -> requests.Session:
"""Returns a requests.Session with cURL logging enabled."""
session = requests.Session()
session.hooks["response"] = [logging_hook]
return session
+35
View File
@@ -0,0 +1,35 @@
# 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 subprocess
def run_shell_command_on_host(command: str) -> str:
"""Helper to run a shell command on the host and return the output."""
try:
result = subprocess.run(
command,
stdin=subprocess.DEVNULL,
shell=True,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
# Log the error and stderr for better debugging
error_message = (
f"Command '{command}' failed with exit code {e.returncode}.\nStderr: {e.stderr.strip()}"
)
raise RuntimeError(error_message) from e
+1 -1
View File
@@ -739,7 +739,7 @@ def _render_step_detailed(
INTERNAL_SYSTEM_TOOLS = {
"safety_net_validation",
"safety_net_pixel_validation",
"entity_extractor",
"hopper",
}
if interleaved:
@@ -12,14 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Entity extractor and package resolver agent for ARTEMIS."""
from datetime import datetime
from artemis.agents.entity_extractor.entity_extractor import (
ExtractionResult,
extract_entity,
)
__all__ = [
"ExtractionResult",
"extract_entity",
]
def convert_timestamp_to_str(ts: float) -> str:
dt = datetime.fromtimestamp(ts)
return dt.strftime("%Y-%m-%dT%H-%M-%S")
+132 -128
View File
@@ -12,151 +12,155 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""UI hierarchy traversal and element geometry primitives for ARTEMIS.
Provides structured spatial coordinates, bounding box metrics, and recursive
node querying across Android accessibility hierarchies and UIAutomator tree dumps.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from artemis.utils.logger import get_logger
from pydantic import BaseModel, Field
logger = get_logger(__name__)
def __find_element_by_resource_id_in_rich_hierarchy(
hierarchy: list[dict], resource_id: str
) -> dict | None:
"""Retrieves all the sibling elements for a given resource ID from a nested dictionary.
Args:
hierarchy (dict): The nested dictionary representing the UI hierarchy.
resource_id (str): The resource-id to find.
Returns:
list: A list of the sibling elements, or None if the resource_id is not
found.
"""
if not hierarchy:
return None
for child in hierarchy:
if child.get("attributes", {}).get("resource-id") == resource_id:
return child.get("attributes", {})
for child in hierarchy:
result = __find_element_by_resource_id_in_rich_hierarchy(
child.get("children", []), resource_id
)
if result is not None:
return result
return None
def text_input_is_empty(text: str | None, hint_text: str | None) -> bool:
return not text or text == hint_text
def find_element_by_resource_id(
ui_hierarchy: list[dict],
resource_id: str,
index: int | None = None,
is_rich_hierarchy: bool = False,
) -> dict | None:
"""Find a UI element by its resource-id in the UI hierarchy.
Args:
ui_hierarchy: List of UI element dictionaries
resource_id: The resource-id to search for (e.g.,
"com.google.android.settings.intelligence:id/open_search_view_edit_text")
Returns:
The complete UI element dictionary if found, None otherwise
"""
if is_rich_hierarchy:
return __find_element_by_resource_id_in_rich_hierarchy(ui_hierarchy, resource_id)
target_idx = index or 0
def search_recursive(elements: list[dict]) -> dict | None:
nonlocal target_idx
for element in elements:
if isinstance(element, dict):
if element.get("resourceId") == resource_id:
if target_idx == 0:
return element
target_idx -= 1
continue
children = element.get("children", [])
if children:
result = search_recursive(children)
if result:
return result
return None
return search_recursive(ui_hierarchy)
def is_element_focused(element: dict) -> bool:
return element.get("focused", None) == "true"
def get_element_text(element: dict, hint_text: bool = False) -> str | None:
if hint_text:
return element.get("hintText", None)
return element.get("text", None)
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class Point(BaseModel):
"""Discrete pixel coordinate on screen."""
model_config = ConfigDict(frozen=True)
model_config = {"ignored_types": (CyFunctionDetector,)}
x: int
y: int
class ElementBounds(BaseModel):
"""Axis-aligned rectangular boundary defining an on-screen element."""
model_config = ConfigDict(extra="ignore")
x: int = Field(description="Horizontal offset of the top-left corner in pixels.")
y: int = Field(description="Vertical offset of the top-left corner in pixels.")
width: int = Field(description="Horizontal extent of the bounding box.")
height: int = Field(description="Vertical extent of the bounding box.")
model_config = {"ignored_types": (CyFunctionDetector,)}
x: int = Field(description="The x coordinate of the top-left corner of the element.")
y: int = Field(description="The y coordinate of the top-left corner of the element.")
width: int = Field(description="The width of the element.")
height: int = Field(description="The height of the element.")
def get_center(self) -> Point:
"""Compute the centroid of the element boundary."""
return Point(x=self.x + (self.width // 2), y=self.y + (self.height // 2))
return Point(x=self.x + self.width // 2, y=self.y + self.height // 2)
def get_relative_point(self, x_percent: float, y_percent: float) -> Point:
"""Compute an internal pixel coordinate by fractional offsets [0.0, 1.0]."""
clamped_x = max(0.0, min(1.0, float(x_percent)))
clamped_y = max(0.0, min(1.0, float(y_percent)))
"""Returns the coordinates of the point at x_percent of the width and y_percent
of the height of the element.
Ex if x_percent = 0.95 and y_percent = 0.95,
the point is at the bottom right of the element:
<------>
| |
| x|
<------>
"""
return Point(
x=int(self.x + (self.width * clamped_x)),
y=int(self.y + (self.height * clamped_y)),
x=int(self.x + self.width * x_percent),
y=int(self.y + self.height * y_percent),
)
def get_bounds_for_element(element: dict[str, Any]) -> ElementBounds | None:
"""Extract and validate the bounding box from a node descriptor."""
raw_bounds = element.get("bounds")
if not isinstance(raw_bounds, dict):
return None
try:
return ElementBounds(
x=int(raw_bounds["x"]),
y=int(raw_bounds["y"]),
width=int(raw_bounds["width"]),
height=int(raw_bounds["height"]),
)
except (KeyError, TypeError, ValueError) as exc:
logger.error(f"Failed to validate element bounds ({raw_bounds}): {exc}")
return None
def text_input_is_empty(text: str | None, hint_text: str | None) -> bool:
"""Determine whether an editable field contains user-entered text."""
if not text:
return True
return text == hint_text
def is_element_focused(element: dict[str, Any]) -> bool:
"""Check if the given node currently holds system focus."""
focused_val = element.get("focused")
if isinstance(focused_val, bool):
return focused_val
return str(focused_val).strip().lower() == "true"
def get_element_text(element: dict[str, Any], hint_text: bool = False) -> str | None:
"""Retrieve textual label or placeholder from an element node."""
if hint_text:
return element.get("hintText")
return element.get("text")
def find_element_by_resource_id(
ui_hierarchy: list[dict[str, Any]],
resource_id: str,
index: int | None = None,
is_rich_hierarchy: bool = False,
) -> dict[str, Any] | None:
"""Locate an element in the hierarchy tree matching the specified resource ID.
Supports both flat attribute mappings and rich accessibility tree hierarchies.
"""
if not ui_hierarchy or not resource_id:
return None
if is_rich_hierarchy:
return _search_rich_tree(ui_hierarchy, resource_id)
target_index = max(0, index or 0)
current_match = 0
def _walk(nodes: list[dict[str, Any]]) -> dict[str, Any] | None:
nonlocal current_match
for node in nodes:
if not isinstance(node, dict):
continue
if node.get("resourceId") == resource_id:
if current_match == target_index:
return node
current_match += 1
children = node.get("children")
if isinstance(children, list) and children:
found = _walk(children)
if found is not None:
return found
return None
return _walk(ui_hierarchy)
def _search_rich_tree(
nodes: list[dict[str, Any]], resource_id: str
) -> dict[str, Any] | None:
"""Recursively traverse a rich XML attribute tree for resource ID match."""
for node in nodes:
if not isinstance(node, dict):
continue
attributes = node.get("attributes")
if isinstance(attributes, dict) and attributes.get("resource-id") == resource_id:
return attributes
children = node.get("children")
if isinstance(children, list) and children:
sub = _search_rich_tree(children, resource_id)
if sub is not None:
return sub
def get_bounds_for_element(element: dict) -> ElementBounds | None:
bounds = element.get("bounds", None)
if bounds:
try:
return ElementBounds(**bounds)
except Exception as e:
logger.error(f"Failed to validate bounds: {e}")
return None
return None
+18 -3
View File
@@ -11,9 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Video recording utilities for mobile devices.
@@ -126,6 +123,24 @@ async def await_scrcpy_first_frame(
return max(spawned_at, time.time())
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class RecordingSession(BaseModel):
"""Tracks an active video recording session."""
+2 -2
View File
@@ -93,8 +93,8 @@
// 📋 Summarizer: Aggregates step history and generates structured outcome
"summarizer": {},
// 🔍 Entity Extractor: Fast lightweight package name locator and entity extractor
"entity_extractor": {
// 🔽 Hopper: Fast lightweight package name locator
"hopper": {
"model": "gemini-3.5-flash-lite",
"fallback": {
"provider": "google",
@@ -14,6 +14,6 @@
"""This file is intentionally empty.
The entity_extractor agent does not expose any tools directly. It is invoked
The hopper agent does not expose any tools directly. It is invoked
programmatically by other tools (e.g., launch_app_tool) as an LLM utility.
"""
@@ -1,76 +0,0 @@
# 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.
"""Unit tests for Artemis entity extraction and package resolver."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.agents.entity_extractor.entity_extractor import (
ExtractionResult,
extract_entity,
)
def test_extraction_result_model():
"""Verify ExtractionResult attributes and validation."""
res = ExtractionResult(found=True, output="com.google.android.apps.nexuslauncher", reason="Exact match")
assert res.found is True
assert res.output == "com.google.android.apps.nexuslauncher"
assert res.reason == "Exact match"
@pytest.mark.asyncio
async def test_extract_entity_success():
"""Verify successful LLM entity extraction invocation."""
mock_ctx = MagicMock()
mock_llm = MagicMock()
expected_result = ExtractionResult(
found=True,
output="com.google.android.calculator",
reason="Found calculator package in list",
)
mock_llm.ainvoke = AsyncMock(return_value=expected_result)
with patch("artemis.agents.entity_extractor.entity_extractor.get_llm") as mock_get_llm:
mock_get_llm.return_value.with_structured_output.return_value = mock_llm
result = await extract_entity(
request="Calculator",
data="com.google.android.calculator\ncom.google.android.calendar",
ctx=mock_ctx,
)
assert result.found is True
assert result.output == "com.google.android.calculator"
assert result.reason == "Found calculator package in list"
@pytest.mark.asyncio
async def test_extract_entity_handles_exception():
"""Verify extraction returns a graceful failure result when LLM throws an exception."""
mock_ctx = MagicMock()
mock_llm = MagicMock()
mock_llm.ainvoke = AsyncMock(side_effect=RuntimeError("LLM connection timed out"))
with patch("artemis.agents.entity_extractor.entity_extractor.get_llm") as mock_get_llm:
mock_get_llm.return_value.with_structured_output.return_value = mock_llm
result = await extract_entity(
request="Settings",
data="com.android.settings",
ctx=mock_ctx,
)
assert result.found is False
assert result.output is None
assert "LLM extraction error" in result.reason
+19
View File
@@ -26,7 +26,26 @@ from artemis.utils.logger import get_logger # noqa: E402
logger = get_logger(__name__)
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class MockPydanticSchema(BaseModel):
model_config = {"ignored_types": (CyFunctionDetector,)}
color: str
price: float
currency_symbol: str
+2 -4
View File
@@ -9,8 +9,6 @@ from mcp_server.utils import device_utils
def test_ensure_emulator_uses_windows_creation_flags(monkeypatch) -> None:
popen = MagicMock()
monkeypatch.setattr(device_utils.sys, "platform", "win32")
monkeypatch.setattr(device_utils.subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False)
monkeypatch.setattr(device_utils.subprocess, "DETACHED_PROCESS", 0x00000008, raising=False)
monkeypatch.setattr(device_utils, "is_emulator_running", lambda _adb: False)
monkeypatch.setattr(device_utils.os.path, "exists", lambda _path: True)
monkeypatch.setattr(device_utils.subprocess, "Popen", popen)
@@ -22,7 +20,7 @@ def test_ensure_emulator_uses_windows_creation_flags(monkeypatch) -> None:
monkeypatch.setattr(device_utils.time, "sleep", lambda _seconds: None)
assert device_utils.ensure_emulator(
adb_path="adb.exe", emulator_path="emulator.exe", timeout_seconds=10
adb_path="adb.exe", emulator_path="emulator.exe", timeout_seconds=1
)
kwargs = popen.call_args.kwargs
@@ -45,7 +43,7 @@ def test_ensure_emulator_starts_new_session_on_posix(monkeypatch) -> None:
)
monkeypatch.setattr(device_utils.time, "sleep", lambda _seconds: None)
assert device_utils.ensure_emulator(adb_path="adb", emulator_path="emulator", timeout_seconds=10)
assert device_utils.ensure_emulator(adb_path="adb", emulator_path="emulator", timeout_seconds=1)
kwargs = popen.call_args.kwargs
assert kwargs["start_new_session"] is True
+12 -5
View File
@@ -24,8 +24,11 @@ import queue
import subprocess
import sys
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
from artemis.clients import ui_automator_client
from artemis.runtime.awake_lease import ScreenAwakeLease
from artemis.runtime.awake_service import _run_awake_adb_command
@@ -201,12 +204,16 @@ def test_device_utils_isolates_stdin():
def test_ui_automator_client_isolates_stdin():
"""Verify ui_automator_client screencap commands isolate stdin."""
client = ui_automator_client.UIAutomatorClient("dev-1")
"""Verify ui_automator_client helper commands isolate stdin."""
with patch("artemis.clients.ui_automator_client.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout=b"", stderr="")
with patch("artemis.clients.ui_automator_client.Image.open"):
client.get_screenshot()
mock_run.return_value = MagicMock(returncode=0, stdout="package:com.test\n", stderr="")
ui_automator_client._is_package_installed("dev-1", "com.test")
assert mock_run.called
assert mock_run.call_args.kwargs.get("stdin") == subprocess.DEVNULL
with patch("artemis.clients.ui_automator_client.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
ui_automator_client._uninstall_package("dev-1", "com.test")
assert mock_run.called
assert mock_run.call_args.kwargs.get("stdin") == subprocess.DEVNULL
-63
View File
@@ -1,63 +0,0 @@
# 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.
"""Unit tests validating Artemis SDK exception hierarchy and diagnostics."""
from artemis.sdk.types.exceptions import (
AgentError,
AgentNotInitializedError,
AgentProfileNotFoundError,
AgentTaskRequestError,
ArtemisError,
DeviceError,
DeviceNotFoundError,
ExecutableNotFoundError,
ServerError,
ServerStartupError,
)
def test_exception_hierarchy():
"""Verify all custom exceptions inherit properly from ArtemisError."""
assert issubclass(DeviceError, ArtemisError)
assert issubclass(DeviceNotFoundError, DeviceError)
assert issubclass(ServerError, ArtemisError)
assert issubclass(ServerStartupError, ServerError)
assert issubclass(AgentError, ArtemisError)
assert issubclass(AgentNotInitializedError, AgentError)
assert issubclass(AgentTaskRequestError, AgentError)
assert issubclass(AgentProfileNotFoundError, AgentTaskRequestError)
assert issubclass(ExecutableNotFoundError, ArtemisError)
def test_executable_not_found_error_adb_guidance():
"""Verify executable error formats download instructions for known tools."""
err = ExecutableNotFoundError("adb")
assert "adb" in str(err)
assert "developer.android.com" in str(err)
assert err.executable_name == "adb"
def test_server_startup_error_formatting():
"""Verify server startup error encapsulates service identifier."""
err = ServerStartupError(server_name="mcp_daemon")
assert "mcp_daemon" in str(err)
assert err.server_name == "mcp_daemon"
def test_agent_profile_not_found_error():
"""Verify profile error retains profile name."""
err = AgentProfileNotFoundError("nonexistent_profile")
assert "nonexistent_profile" in str(err)
assert err.profile_name == "nonexistent_profile"
-111
View File
@@ -1,111 +0,0 @@
# 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.
"""Unit tests for Artemis SDK TaskRequestBuilder."""
from pathlib import Path
from pydantic import BaseModel
import pytest
from artemis.sdk.builders.task_request_builder import TaskRequestBuilder, TaskRequestCommonBuilder
from artemis.sdk.types.task import TaskRequest
class DummyOutputSchema(BaseModel):
summary: str
item_count: int
def test_task_request_builder_fluent_chaining():
"""Verify basic fluent configuration and build."""
req = (
TaskRequestBuilder(goal="Open Settings and verify battery level")
.with_max_steps(25)
.with_trace_recording(True, path="custom_traces")
.with_locked_app_package("com.android.settings")
.with_app_path("build/app.apk")
.using_profile("flash")
.with_name("Battery Check")
.build()
)
assert isinstance(req, TaskRequest)
assert req.goal == "Open Settings and verify battery level"
assert req.max_steps == 25
assert req.record_trace is True
assert req.trace_path == Path("custom_traces")
assert req.locked_app_package == "com.android.settings"
assert req.app_path == Path("build/app.apk")
assert req.profile == "flash"
assert req.task_name == "Battery Check"
def test_task_request_builder_with_output_format():
"""Verify builder binds structured schema properly."""
req = (
TaskRequestBuilder(goal="Extract items")
.with_output_format(DummyOutputSchema)
.build()
)
assert req.output_format is DummyOutputSchema
assert req.output_description is None
def test_task_request_builder_with_output_description():
"""Verify builder accepts unformatted string deliverables."""
req = (
TaskRequestBuilder(goal="Extract items")
.with_output_description("Return a numbered list")
.build()
)
assert req.output_description == "Return a numbered list"
assert req.output_format is None
def test_task_request_builder_conflicting_outputs_raises():
"""Verify that specifying both output_format and output_description raises ValueError."""
builder = (
TaskRequestBuilder(goal="Extract items")
.with_output_format(DummyOutputSchema)
.with_output_description("Description text")
)
with pytest.raises(ValueError, match="Cannot specify both output_format and output_description"):
builder.build()
def test_task_request_builder_empty_goal_raises():
"""Verify empty or whitespace-only goal raises ValueError."""
with pytest.raises(ValueError, match="Task goal must be a non-empty"):
TaskRequestBuilder(goal=" ").build()
def test_task_request_builder_from_common():
"""Verify task request initialization derived from common base settings."""
common_settings = (
TaskRequestCommonBuilder()
.with_max_steps(42)
.with_trace_recording(False)
.with_locked_app_package("org.artemis.target")
.with_app_path(Path("/tmp/target.apk"))
.build()
)
derived = TaskRequestBuilder.from_common("Derived goal", common_settings).build()
assert derived.goal == "Derived goal"
assert derived.max_steps == 42
assert derived.record_trace is False
assert derived.locked_app_package == "org.artemis.target"
assert derived.app_path == Path("/tmp/target.apk")
+1 -1
View File
@@ -154,7 +154,7 @@ def test_llm_config_parsing_and_merging():
assert isinstance(llm_cfg, LLMConfig)
assert llm_cfg.planner.provider in ("google", "openai", "openrouter", "xai", "vertexai")
assert llm_cfg.get_agent("planner") is not None
assert llm_cfg.get_utils("entity_extractor") is not None
assert llm_cfg.get_utils("hopper") is not None
# Deep merge overrides
overrides = {
+7 -3
View File
@@ -11,14 +11,16 @@ from artemis.drivers.android.adb_driver import AndroidAdbDriver
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",
)
@patch("artemis.clients.ui_automator_client._ensure_maestro_not_installed")
def test_new_ui_connection_enrolls_device_in_shared_awake_strategy(
mock_ensure_awake, mock_connect
mock_remove_maestro, mock_ensure_awake, mock_connect
):
client = UIAutomatorClient("device-123")
client.connect()
client.connect()
mock_remove_maestro.assert_called_once_with("device-123")
mock_ensure_awake.assert_called_once_with("device-123")
mock_connect.assert_called_once_with("device-123")
@@ -28,8 +30,9 @@ def test_new_ui_connection_enrolls_device_in_shared_awake_strategy(
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",
)
@patch("artemis.clients.ui_automator_client._ensure_maestro_not_installed")
def test_failed_ui_connection_does_not_stop_process_awake_service(
mock_ensure_awake, _mock_connect
_mock_remove_maestro, mock_ensure_awake, _mock_connect
):
client = UIAutomatorClient("device-123")
@@ -45,8 +48,9 @@ def test_failed_ui_connection_does_not_stop_process_awake_service(
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",
)
@patch("artemis.clients.ui_automator_client._ensure_maestro_not_installed")
def test_client_disconnect_does_not_send_power_cleanup_commands(
mock_ensure_awake, _mock_connect
_mock_remove_maestro, mock_ensure_awake, _mock_connect
):
client = UIAutomatorClient("device-123")
client.connect()
+22 -23
View File
@@ -13,11 +13,10 @@
# limitations under the License.
from unittest.mock import MagicMock, patch
import pytest
from artemis.agents.entity_extractor.entity_extractor import ExtractionResult
from artemis.agents.hopper.hopper import HopperOutput
from artemis.context import ArtemisContext
from artemis.tools.mobile.launch_app import find_package
import pytest
@pytest.fixture
@@ -29,63 +28,63 @@ def mock_context():
@pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.extract_entity")
async def test_find_package_cache_hit(mock_extract_entity, mock_list_packages, mock_context):
@patch("artemis.tools.mobile.launch_app.hopper")
async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_context):
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
# First call: not in cache, calls extract_entity
mock_extract_entity.return_value = ExtractionResult(
# First call: not in cache, calls hopper
mock_hopper.return_value = HopperOutput(
found=True, output="com.example.app1", reason="Found it"
)
pkg1 = await find_package(mock_context, "App 1")
assert pkg1 == "com.example.app1"
assert mock_extract_entity.call_count == 1
assert mock_hopper.call_count == 1
assert mock_context.package_cache["App 1"] == "com.example.app1"
# Second call: cache hit, does not call extract_entity or list_packages again
mock_extract_entity.reset_mock()
# Second call: cache hit, does not call hopper or list_packages again
mock_hopper.reset_mock()
mock_list_packages.reset_mock()
pkg2 = await find_package(mock_context, "App 1")
assert pkg2 == "com.example.app1"
mock_extract_entity.assert_not_called()
mock_hopper.assert_not_called()
mock_list_packages.assert_not_called()
@pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.extract_entity")
async def test_find_package_not_found_caches_none(mock_extract_entity, mock_list_packages, mock_context):
@patch("artemis.tools.mobile.launch_app.hopper")
async def test_find_package_not_found_caches_none(mock_hopper, mock_list_packages, mock_context):
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
# Extractor doesn't find it
mock_extract_entity.return_value = ExtractionResult(found=False, output=None, reason="Not found")
# Hopper doesn't find it
mock_hopper.return_value = HopperOutput(found=False, output=None, reason="Not found")
pkg1 = await find_package(mock_context, "App 3")
assert pkg1 is None
assert mock_extract_entity.call_count == 1
assert mock_hopper.call_count == 1
assert mock_context.package_cache["App 3"] is None
# Second call: cache hit (None), does not call extract_entity again
mock_extract_entity.reset_mock()
# Second call: cache hit (None), does not call hopper again
mock_hopper.reset_mock()
pkg2 = await find_package(mock_context, "App 3")
assert pkg2 is None
mock_extract_entity.assert_not_called()
mock_hopper.assert_not_called()
@pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.extract_entity")
async def test_find_package_passes_use_fallback(mock_extract_entity, mock_list_packages, mock_context):
@patch("artemis.tools.mobile.launch_app.hopper")
async def test_find_package_passes_use_fallback(mock_hopper, mock_list_packages, mock_context):
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
mock_extract_entity.return_value = ExtractionResult(
mock_hopper.return_value = HopperOutput(
found=True, output="com.example.app1", reason="Found it"
)
# Pass use_fallback=False
await find_package(mock_context, "App 1", use_fallback=False)
mock_extract_entity.assert_called_once_with(
mock_hopper.assert_called_once_with(
ctx=mock_context,
request=("I'm looking for the package name of the following app: 'App 1'"),
data="com.example.app1\ncom.example.app2",
+357 -251
View File
@@ -12,340 +12,446 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for ARTEMIS tool utility functions and element interaction helpers."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import asyncio
import sys
from unittest.mock import Mock, patch
import pytest
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
from artemis.controllers.types import CoordinatesSelectorRequest
from artemis.tools.types import Target
from artemis.tools.utils import (
# Mock the problematic langgraph import at module level
_orig_chat_agent_executor = sys.modules.get("langgraph.prebuilt.chat_agent_executor")
_orig_state = sys.modules.get("artemis.graph.state")
sys.modules["langgraph.prebuilt.chat_agent_executor"] = Mock()
sys.modules["artemis.graph.state"] = Mock()
from artemis.context import DeviceContext, DevicePlatform, ArtemisContext # noqa: E402
from artemis.tools.types import Target # noqa: E402
from artemis.tools.utils import ( # noqa: E402
IdSelectorRequest,
IdWithTextSelectorRequest,
SelectorRequestWithCoordinates,
SelectorRequestWithPercentages,
TextSelectorRequest,
_extract_resource_id_and_text_from_selector,
find_element_by_text,
focus_element_if_needed,
has_valid_selectors,
move_cursor_to_end_if_bounds,
tap_bottom_right_of_element,
validate_coordinates_bounds,
)
from artemis.utils.ui_hierarchy import ElementBounds
from artemis.utils.ui_hierarchy import ElementBounds # noqa: E402
# Restore original modules to prevent contaminating other tests
if _orig_chat_agent_executor is not None:
sys.modules["langgraph.prebuilt.chat_agent_executor"] = _orig_chat_agent_executor
else:
del sys.modules["langgraph.prebuilt.chat_agent_executor"]
if _orig_state is not None:
sys.modules["artemis.graph.state"] = _orig_state
else:
del sys.modules["artemis.graph.state"]
@pytest.fixture
def dummy_context() -> MagicMock:
"""Fixture providing an ARTEMIS runtime context configured for Android."""
ctx = MagicMock(spec=ArtemisContext)
ctx.device = MagicMock(spec=DeviceContext)
def mock_context():
"""Create a mock ArtemisContext for testing."""
ctx = Mock(spec=ArtemisContext)
# Create device context with necessary attributes
ctx.device = Mock(spec=DeviceContext)
ctx.device.mobile_platform = DevicePlatform.ANDROID
ctx.device.device_id = "test-device-pixel8"
ctx.device.device_id = "test_device_123"
ctx.device.device_width = 1080
ctx.device.device_height = 2400
ctx.device.device_height = 2340
ctx.device.host_platform = "LINUX"
ctx.ui_adb_client = Mock()
# Mock the ADB client for Android
ctx.adb_client = Mock()
mock_device = Mock()
mock_device.shell = Mock(return_value="")
ctx.adb_client.device = Mock(return_value=mock_device)
# Mock the ADB client for Android
mock_response = Mock()
mock_response.json.return_value = {"elements": []}
ctx.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
ctx.ui_adb_client.get_hierarchy = Mock(
return_value=(
b'<hierarchy><node bounds="[0,0][1000,1000]"><node'
b' bounds="[0,0][100,100]"/></node></hierarchy>'
)
)
return ctx
@pytest.fixture
def mock_graph_state() -> MagicMock:
"""Fixture providing a mock LangGraph state container."""
state = MagicMock()
def mock_state():
"""Create a mock State for testing."""
state = Mock()
state.latest_ui_hierarchy = []
return state
@pytest.fixture
def sample_hierarchy_node() -> dict:
"""Fixture providing a standard flat hierarchy node."""
def sample_element():
"""Create a sample UI element for testing."""
return {
"resourceId": "com.google.android.apps:id/search_query",
"text": "Search Google",
"bounds": {"x": 120, "y": 250, "width": 400, "height": 80},
"resourceId": "com.example:id/text_input",
"text": "Sample text",
"bounds": {"x": 100, "y": 200, "width": 300, "height": 50},
"focused": "false",
}
@pytest.fixture
def sample_nested_node() -> dict:
"""Fixture providing a rich nested hierarchy node."""
def sample_rich_element():
"""Create a sample rich UI element for testing."""
return {
"attributes": {
"resource-id": "com.google.android.apps:id/search_query",
"text": "Search Google",
"bounds": {"x": 120, "y": 250, "width": 400, "height": 80},
"resource-id": "com.example:id/text_input",
"focused": "false",
"text": "Sample text",
"bounds": {"x": 100, "y": 200, "width": 300, "height": 50},
},
"children": [],
}
# ==============================================================================
# Cursor Placement & Boundary Alignment Tests
# ==============================================================================
class TestMoveCursorToEndIfBounds:
"""Test cases for move_cursor_to_end_if_bounds function."""
class TestCursorAlignment:
"""Validates precise cursor placement against interactive targets."""
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_resource_id")
async def test_cursor_routes_to_end_via_resource_id(
def test_move_cursor_with_resource_id(
self,
mock_find_elem: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
mock_graph_state: MagicMock,
sample_hierarchy_node: dict,
mock_find_element,
mock_tap,
mock_context,
mock_state,
sample_element,
):
"""Resource ID locator should calculate end-boundary tap (99% width/height offset)."""
mock_graph_state.latest_ui_hierarchy = [sample_hierarchy_node]
mock_find_elem.return_value = sample_hierarchy_node
"""Test moving cursor using resource_id (highest priority)."""
mock_state.latest_ui_hierarchy = [sample_element]
mock_find_element.return_value = sample_element
target = Target(resource_id="com.google.android.apps:id/search_query")
elem = await move_cursor_to_end_if_bounds(
ctx=dummy_context, state=mock_graph_state, target=target
target = Target(
resource_id="com.example:id/text_input",
resource_id_index=None,
text=None,
text_index=None,
bounds=None,
)
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_find_elem.assert_called_once_with(
ui_hierarchy=[sample_hierarchy_node],
resource_id="com.google.android.apps:id/search_query",
mock_find_element.assert_called_once_with(
ui_hierarchy=[sample_element],
resource_id="com.example:id/text_input",
index=0,
)
mock_tap_fn.assert_awaited_once()
kwargs = mock_tap_fn.await_args.kwargs
selector_req = kwargs["selector_request"]
assert isinstance(selector_req, SelectorRequestWithCoordinates)
assert selector_req.coordinates.x == 516
assert selector_req.coordinates.y == 329
assert elem == sample_hierarchy_node
mock_tap.assert_called_once()
call_args = mock_tap.call_args[1]
selector_request = call_args["selector_request"]
assert isinstance(selector_request, SelectorRequestWithCoordinates)
coords = selector_request.coordinates
assert coords.x == 397 # 100 + 300 * 0.99
assert coords.y == 249 # 200 + 50 * 0.99
assert result == sample_element
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_resource_id")
async def test_cursor_routes_using_direct_bounds(
self,
mock_find_elem: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
mock_graph_state: MagicMock,
def test_move_cursor_with_coordinates_only(
self, mock_find_element, mock_tap, mock_context, mock_state
):
"""Direct bounding box coordinates should bypass hierarchy inspection."""
bounds = ElementBounds(x=80, y=100, width=200, height=60)
target = Target(bounds=bounds)
result = await move_cursor_to_end_if_bounds(
ctx=dummy_context, state=mock_graph_state, target=target
"""Test moving cursor when only coordinates are provided."""
bounds = ElementBounds(x=50, y=150, width=200, height=40)
target = Target(
resource_id=None,
resource_id_index=None,
text=None,
text_index=None,
bounds=bounds,
)
mock_find_elem.assert_not_called()
mock_tap_fn.assert_awaited_once()
kwargs = mock_tap_fn.await_args.kwargs
selector_req = kwargs["selector_request"]
assert selector_req.coordinates.x == 278
assert selector_req.coordinates.y == 159
assert result is None
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.find_element_by_text")
async def test_cursor_routes_by_matched_text(
self,
mock_find_text: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
mock_graph_state: MagicMock,
sample_hierarchy_node: dict,
):
"""Visible text locator should successfully resolve bounds and trigger cursor placement."""
mock_graph_state.latest_ui_hierarchy = [sample_hierarchy_node]
mock_find_text.return_value = sample_hierarchy_node
target = Target(text="Search Google", text_index=0)
result = await move_cursor_to_end_if_bounds(
ctx=dummy_context, state=mock_graph_state, target=target
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_find_text.assert_called_once_with([sample_hierarchy_node], "Search Google", index=0)
mock_tap_fn.assert_awaited_once()
assert result == sample_hierarchy_node
mock_find_element.assert_not_called()
mock_tap.assert_called_once()
call_args = mock_tap.call_args[1]
selector_request = call_args["selector_request"]
coords = selector_request.coordinates
assert coords.x == 248 # 50 + 200 * 0.99
assert coords.y == 189 # 150 + 40 * 0.99
assert result is None # No element is returned when using coords directly
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_text")
async def test_cursor_skips_when_text_unmatched(
self,
mock_find_text: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
mock_graph_state: MagicMock,
def test_move_cursor_with_text_only_success(
self, mock_find_text, mock_tap, mock_context, mock_state, sample_element
):
"""Unresolved text target returns None without tapping."""
"""Test moving cursor when only text is provided and succeeds."""
mock_state.latest_ui_hierarchy = [sample_element]
mock_find_text.return_value = sample_element
target = Target(
resource_id=None,
resource_id_index=None,
text="Sample text",
text_index=0,
bounds=None,
)
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_find_text.assert_called_once_with([sample_element], "Sample text", index=0)
mock_tap.assert_called_once()
assert result == sample_element
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_text")
def test_move_cursor_with_text_only_element_not_found(
self, mock_find_text, mock_tap, mock_context, mock_state
):
"""Test when searching by text finds no element."""
mock_state.latest_ui_hierarchy = []
mock_find_text.return_value = None
target = Target(text="Unmatched Query")
result = await move_cursor_to_end_if_bounds(
ctx=dummy_context, state=mock_graph_state, target=target
target = Target(
resource_id=None,
resource_id_index=None,
text="Nonexistent text",
text_index=None,
bounds=None,
)
mock_tap_fn.assert_not_awaited()
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_tap.assert_not_called()
assert result is None
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_text")
def test_move_cursor_with_text_only_no_bounds(
self, mock_find_text, mock_tap, mock_context, mock_state
):
"""Test when element is found by text but has no bounds."""
element_no_bounds = {"text": "Text without bounds"}
mock_state.latest_ui_hierarchy = [element_no_bounds]
mock_find_text.return_value = element_no_bounds
target = Target(
resource_id=None,
resource_id_index=None,
text="Text without bounds",
text_index=None,
bounds=None,
)
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_tap.assert_not_called()
assert result is None # Should return None as no action was taken
@patch("artemis.tools.utils.find_element_by_resource_id")
def test_move_cursor_element_not_found_by_id(self, mock_find_element, mock_context, mock_state):
"""Test when element is not found by resource_id."""
mock_find_element.return_value = None
target = Target(
resource_id="com.example:id/nonexistent",
resource_id_index=None,
text=None,
text_index=None,
bounds=None,
)
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
assert result is None
# ==============================================================================
# Element Focus Resolution Tests
# ==============================================================================
class TestFocusElementIfNeeded:
"""Test cases for focus_element_if_needed function."""
class TestElementFocusing:
"""Validates conditional focusing behavior prior to keyboard inputs."""
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.UnifiedMobileController")
async def test_skips_tap_when_already_focused(
self,
mock_controller_cls: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
sample_hierarchy_node: dict,
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_resource_id")
def test_focus_element_already_focused(
self, mock_find_element, mock_tap, mock_context, sample_rich_element
):
"""Elements reporting focused state true should avoid redundant tap interactions."""
focused_elem = sample_hierarchy_node.copy()
focused_elem["focused"] = "true"
"""Test when element is already focused."""
focused_element = sample_rich_element.copy()
focused_element["attributes"]["focused"] = "true"
mock_ctrl = MagicMock()
mock_ctrl.get_ui_elements = AsyncMock(return_value=[focused_elem])
mock_controller_cls.return_value = mock_ctrl
mock_response = Mock()
mock_response.json.return_value = {"elements": [focused_element]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_element.return_value = focused_element["attributes"]
target = Target(resource_id="com.google.android.apps:id/search_query")
focus_strategy = await focus_element_if_needed(ctx=dummy_context, target=target)
target = Target(
resource_id="com.example:id/text_input",
resource_id_index=None,
text=None,
text_index=None,
bounds=None,
)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_tap_fn.assert_not_awaited()
assert focus_strategy == "resource_id"
mock_tap.assert_not_called()
assert result == "resource_id"
mock_context.ui_adb_client.get_hierarchy.assert_called_once()
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.UnifiedMobileController")
async def test_focus_executes_tap_for_unfocused_element(
self,
mock_controller_cls: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
sample_hierarchy_node: dict,
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_resource_id")
def test_focus_element_needs_focus_success(
self, mock_find_element, mock_tap, mock_context, sample_rich_element
):
"""Unfocused element triggers focus tap and confirms state change."""
unfocused = sample_hierarchy_node.copy()
unfocused["focused"] = "false"
focused = sample_hierarchy_node.copy()
focused["focused"] = "true"
"""Test when element needs focus and focusing succeeds."""
unfocused_element = sample_rich_element
focused_element = {
"attributes": {
"resource-id": "com.example:id/text_input",
"focused": "true",
},
"children": [],
}
mock_ctrl = MagicMock()
mock_ctrl.get_ui_elements = AsyncMock(side_effect=[[unfocused], [focused]])
mock_controller_cls.return_value = mock_ctrl
mock_find_element.side_effect = [
unfocused_element["attributes"],
focused_element["attributes"],
]
target = Target(resource_id="com.google.android.apps:id/search_query")
strategy = await focus_element_if_needed(ctx=dummy_context, target=target)
target = Target(
resource_id="com.example:id/text_input",
resource_id_index=None,
text=None,
text_index=None,
bounds=None,
)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_tap_fn.assert_awaited_once_with(
ctx=dummy_context,
selector_request=IdSelectorRequest(id="com.google.android.apps:id/search_query"),
mock_tap.assert_called_once_with(
ctx=mock_context,
selector_request=IdSelectorRequest(id="com.example:id/text_input"),
index=0,
)
assert strategy == "resource_id"
assert mock_context.ui_adb_client.get_hierarchy.call_count == 2
assert result == "resource_id"
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.UnifiedMobileController")
async def test_focus_falls_back_to_text_coordinates(
self,
mock_controller_cls: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
sample_hierarchy_node: dict,
):
"""When ID is unavailable, focusing falls back to text center tap coordinates."""
elem = sample_hierarchy_node.copy()
elem["bounds"] = {"x": 20, "y": 40, "width": 200, "height": 60}
mock_ctrl = MagicMock()
mock_ctrl.get_ui_elements = AsyncMock(return_value=[elem])
mock_controller_cls.return_value = mock_ctrl
target = Target(text="Search Google")
strategy = await focus_element_if_needed(ctx=dummy_context, target=target)
mock_tap_fn.assert_awaited_once()
req = mock_tap_fn.await_args.kwargs["selector_request"]
assert isinstance(req, SelectorRequestWithCoordinates)
assert req.coordinates.x == 120 # 20 + 200/2
assert req.coordinates.y == 70 # 40 + 60/2
assert strategy == "text"
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.logger")
@patch("artemis.tools.utils.UnifiedMobileController")
async def test_focus_aborts_when_no_locators_resolve(
@patch("artemis.tools.utils.find_element_by_resource_id")
def test_focus_id_and_text_mismatch_fallback_to_text(
self,
mock_controller_cls: MagicMock,
mock_logger: MagicMock,
dummy_context: MagicMock,
mock_find_id,
mock_logger,
mock_tap,
mock_context,
sample_rich_element,
):
"""When none of the declared locators match an active element, returns None and logs error."""
mock_ctrl = MagicMock()
mock_ctrl.get_ui_elements = AsyncMock(return_value=[])
mock_controller_cls.return_value = mock_ctrl
"""Test fallback when resource_id and text point to different elements."""
element_from_id = sample_rich_element["attributes"].copy()
element_from_id["text"] = "Different text"
target = Target(resource_id="unknown_id", text="unknown_text")
strategy = await focus_element_if_needed(ctx=dummy_context, target=target)
element_from_text = sample_rich_element.copy()
element_from_text["attributes"]["bounds"] = {
"x": 10,
"y": 20,
"width": 100,
"height": 30,
}
mock_logger.error.assert_called_once()
assert strategy is None
mock_response = Mock()
mock_response.json.return_value = {"elements": [element_from_text]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_id.return_value = element_from_id
with patch("artemis.tools.utils.find_element_by_text") as mock_find_text:
mock_find_text.return_value = element_from_text["attributes"]
# ==============================================================================
# Helper Verification & Selector Validation Tests
# ==============================================================================
target = Target(
resource_id="com.example:id/text_input",
resource_id_index=None,
text="Sample text",
text_index=None,
bounds=None,
)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_logger.warning.assert_called_once()
mock_tap.assert_called_once()
assert result == "text"
class TestSelectorValidation:
"""Verifies target completeness and boundary constraint checks."""
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_text")
def test_focus_fallback_to_text(
self, mock_find_text, mock_tap, mock_context, sample_rich_element
):
"""Test fallback to focusing using text."""
element_with_bounds = sample_rich_element.copy()
element_with_bounds["attributes"]["bounds"] = {
"x": 10,
"y": 20,
"width": 100,
"height": 30,
}
def test_has_valid_selectors_matrix(self):
"""Verifies selector detection across valid and invalid combinations."""
assert has_valid_selectors(Target(resource_id="elem_id"))
assert has_valid_selectors(Target(text="Label"))
assert has_valid_selectors(Target(bounds=ElementBounds(x=0, y=0, width=10, height=10)))
assert not has_valid_selectors(Target())
mock_response = Mock()
mock_response.json.return_value = {"elements": [element_with_bounds]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_text.return_value = element_with_bounds["attributes"]
def test_validate_coordinates_bounds(self):
"""Checks bounds containment against physical device display limits."""
valid_target = Target(bounds=ElementBounds(x=100, y=200, width=100, height=100))
assert validate_coordinates_bounds(valid_target, 1080, 2400) is None
out_width = Target(bounds=ElementBounds(x=1050, y=200, width=100, height=100))
assert validate_coordinates_bounds(out_width, 1080, 2400) is not None
out_height = Target(bounds=ElementBounds(x=100, y=2380, width=100, height=100))
assert validate_coordinates_bounds(out_height, 1080, 2400) is not None
def test_extract_selector_attributes(self):
"""Extracts resource ID and text from heterogeneous selector requests."""
req_id = IdSelectorRequest(id="view_item")
assert _extract_resource_id_and_text_from_selector(req_id) == ("view_item", None)
req_text = TextSelectorRequest(text="Confirm")
assert _extract_resource_id_and_text_from_selector(req_text) == (None, "Confirm")
req_both = IdWithTextSelectorRequest(id="submit_btn", text="Submit")
assert _extract_resource_id_and_text_from_selector(req_both) == ("submit_btn", "Submit")
req_coords = SelectorRequestWithCoordinates(
coordinates=CoordinatesSelectorRequest(x=50, y=50)
target = Target(
resource_id=None,
resource_id_index=None,
text="Sample text",
text_index=None,
bounds=None,
)
assert _extract_resource_id_and_text_from_selector(req_coords) == (None, None)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_find_text.assert_called_once()
mock_tap.assert_called_once()
call_args = mock_tap.call_args[1]
selector = call_args["selector_request"]
assert isinstance(selector, SelectorRequestWithCoordinates)
assert selector.coordinates.x == 60 # 10 + 100/2
assert selector.coordinates.y == 35 # 20 + 30/2
assert result == "text"
@patch("artemis.tools.utils.logger")
def test_focus_all_locators_fail(self, mock_logger, mock_context):
"""Test failure when no locator can find an element."""
mock_response = Mock()
mock_response.json.return_value = {"elements": []}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
with (
patch("artemis.tools.utils.find_element_by_resource_id") as mock_find_id,
patch("artemis.tools.utils.find_element_by_text") as mock_find_text,
):
mock_find_id.return_value = None
mock_find_text.return_value = None
target = Target(
resource_id="nonexistent",
resource_id_index=None,
text="nonexistent",
text_index=None,
bounds=None,
)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_logger.error.assert_called_once_with(
"Failed to focus element."
+ " No valid locator (resource_id, coordinates, or text) succeeded."
)
assert result is None
if __name__ == "__main__":
pytest.main([__file__])
+156 -106
View File
@@ -11,11 +11,6 @@
# 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.
#
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
"""Unit tests validating Artemis UI hierarchy parsing and geometry computation."""
from unittest.mock import patch
@@ -34,160 +29,215 @@ def test_text_input_is_empty():
assert text_input_is_empty(text=None, hint_text=None)
assert text_input_is_empty(text="", hint_text=None)
assert text_input_is_empty(text="", hint_text="")
assert text_input_is_empty(text="search", hint_text="search")
assert text_input_is_empty(text="text", hint_text="text")
assert not text_input_is_empty(text="search", hint_text=None)
assert not text_input_is_empty(text="search", hint_text="")
assert not text_input_is_empty(text="text", hint_text=None)
assert not text_input_is_empty(text="text", hint_text="")
def test_find_element_by_resource_id():
tree = [
ui_hierarchy = [
{
"resourceId": "com.google.android.settings:id/search_bar",
"text": "Search Settings",
"resourceId": "com.example:id/button1",
"text": "Button 1",
"children": [],
},
{
"resourceId": "com.google.android.settings:id/content_frame",
"resourceId": "com.example:id/container",
"children": [
{
"resourceId": "com.google.android.settings:id/network_item",
"text": "Network & Internet",
"resourceId": "com.example:id/nested_button",
"text": "Nested Button",
"children": [],
}
],
},
]
target = find_element_by_resource_id(tree, "com.google.android.settings:id/search_bar")
assert target is not None
assert target["resourceId"] == "com.google.android.settings:id/search_bar"
assert target["text"] == "Search Settings"
result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button1")
assert result is not None
assert result["resourceId"] == "com.example:id/button1"
assert result["text"] == "Button 1"
nested = find_element_by_resource_id(tree, "com.google.android.settings:id/network_item")
assert nested is not None
assert nested["resourceId"] == "com.google.android.settings:id/network_item"
result = find_element_by_resource_id(ui_hierarchy, "com.example:id/nested_button")
assert result is not None
assert result["resourceId"] == "com.example:id/nested_button"
assert result["text"] == "Nested Button"
assert find_element_by_resource_id(tree, "com.google.android.settings:id/missing") is None
assert find_element_by_resource_id([], "com.google.android.settings:id/search_bar") is None
result = find_element_by_resource_id(ui_hierarchy, "com.example:id/nonexistent")
assert result is None
result = find_element_by_resource_id([], "com.example:id/button1")
assert result is None
def test_find_element_by_resource_id_with_index():
duplicate_items = [
{"resourceId": "com.artemis.ui:id/row_item", "text": "Item Alpha", "children": []},
{"resourceId": "com.artemis.ui:id/row_item", "text": "Item Beta", "children": []},
ui_hierarchy = [
{
"resourceId": "com.artemis.ui:id/section_group",
"children": [
{"resourceId": "com.artemis.ui:id/row_item", "text": "Item Gamma", "children": []}
],
},
]
first = find_element_by_resource_id(duplicate_items, "com.artemis.ui:id/row_item", index=0)
assert first is not None
assert first["text"] == "Item Alpha"
second = find_element_by_resource_id(duplicate_items, "com.artemis.ui:id/row_item", index=1)
assert second is not None
assert second["text"] == "Item Beta"
third = find_element_by_resource_id(duplicate_items, "com.artemis.ui:id/row_item", index=2)
assert third is not None
assert third["text"] == "Item Gamma"
assert find_element_by_resource_id(duplicate_items, "com.artemis.ui:id/row_item", index=3) is None
def test_find_element_by_resource_id_rich_hierarchy():
rich_nodes = [
{
"attributes": {"resource-id": "com.android.systemui:id/clock"},
"resourceId": "com.example:id/button",
"text": "Button 1",
"children": [],
},
{
"attributes": {"resource-id": "com.android.systemui:id/status_bar"},
"resourceId": "com.example:id/button",
"text": "Button 2",
"children": [],
},
{
"resourceId": "com.example:id/container",
"children": [
{
"attributes": {"resource-id": "com.android.systemui:id/battery_meter"},
"resourceId": "com.example:id/button",
"text": "Button 3",
"children": [],
}
],
},
]
match = find_element_by_resource_id(
rich_nodes, "com.android.systemui:id/clock", is_rich_hierarchy=True
)
assert match is not None
assert match["resource-id"] == "com.android.systemui:id/clock"
result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button", index=0)
assert result is not None
assert result["text"] == "Button 1"
nested_match = find_element_by_resource_id(
rich_nodes, "com.android.systemui:id/battery_meter", is_rich_hierarchy=True
)
assert nested_match is not None
assert nested_match["resource-id"] == "com.android.systemui:id/battery_meter"
result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button", index=1)
assert result is not None
assert result["text"] == "Button 2"
assert find_element_by_resource_id(
rich_nodes, "com.android.systemui:id/unknown", is_rich_hierarchy=True
) is None
result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button", index=2)
assert result is not None
assert result["text"] == "Button 3"
result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button", index=3)
assert result is None
def test_find_element_by_resource_id_rich_hierarchy():
rich_hierarchy = [
{
"attributes": {"resource-id": "com.example:id/button1"},
"children": [],
},
{
"attributes": {"resource-id": "com.example:id/container"},
"children": [
{
"attributes": {"resource-id": "com.example:id/nested_button"},
"children": [],
}
],
},
]
result = find_element_by_resource_id(
rich_hierarchy, "com.example:id/button1", is_rich_hierarchy=True
)
assert result is not None
assert result["resource-id"] == "com.example:id/button1"
result = find_element_by_resource_id(
rich_hierarchy, "com.example:id/nested_button", is_rich_hierarchy=True
)
assert result is not None
assert result["resource-id"] == "com.example:id/nested_button"
result = find_element_by_resource_id(
rich_hierarchy, "com.example:id/nonexistent", is_rich_hierarchy=True
)
assert result is None
def test_is_element_focused():
assert is_element_focused({"focused": "true"})
assert is_element_focused({"focused": True})
assert not is_element_focused({"focused": "false"})
assert not is_element_focused({"focused": False})
assert not is_element_focused({"text": "sample"})
assert not is_element_focused({"focused": None})
focused_element = {"focused": "true"}
assert is_element_focused(focused_element)
non_focused_element = {"focused": "false"}
assert not is_element_focused(non_focused_element)
no_focused_element = {"text": "some text"}
assert not is_element_focused(no_focused_element)
none_focused_element = {"focused": None}
assert not is_element_focused(none_focused_element)
def test_get_element_text():
node = {"text": "Battery", "hintText": "Search settings"}
assert get_element_text(node) == "Battery"
assert get_element_text(node, hint_text=False) == "Battery"
assert get_element_text(node, hint_text=True) == "Search settings"
element = {"text": "Button Text", "hintText": "Hint Text"}
assert get_element_text(element) == "Button Text"
assert get_element_text(element, hint_text=False) == "Button Text"
assert get_element_text(element, hint_text=True) == "Hint Text"
node_no_text = {"hintText": "Placeholder"}
assert get_element_text(node_no_text) is None
assert get_element_text(node_no_text, hint_text=True) == "Placeholder"
element_no_text = {"hintText": "Hint Text"}
assert get_element_text(element_no_text) is None
assert get_element_text(element_no_text, hint_text=True) == "Hint Text"
element_no_hint = {"text": "Button Text"}
assert get_element_text(element_no_hint) == "Button Text"
assert get_element_text(element_no_hint, hint_text=True) is None
assert get_element_text({}) is None
empty_element = {}
assert get_element_text(empty_element) is None
assert get_element_text(empty_element, hint_text=True) is None
def test_get_bounds_for_element():
node_with_box = {"bounds": {"x": 24, "y": 48, "width": 400, "height": 80}}
box = get_bounds_for_element(node_with_box)
assert box is not None
assert isinstance(box, ElementBounds)
assert box.x == 24
assert box.y == 48
assert box.width == 400
assert box.height == 80
element_with_bounds = {"bounds": {"x": 10, "y": 20, "width": 100, "height": 50}}
bounds = get_bounds_for_element(element_with_bounds)
assert bounds is not None
assert isinstance(bounds, ElementBounds)
assert bounds.x == 10
assert bounds.y == 20
assert bounds.width == 100
assert bounds.height == 50
assert get_bounds_for_element({"text": "Click"}) is None
element_no_bounds = {"text": "Button"}
bounds = get_bounds_for_element(element_no_bounds)
assert bounds is None
# Suppress logger output for the invalid bounds test case
with patch("artemis.utils.ui_hierarchy.logger.error"):
bad_box = {"bounds": {"x": "corrupted", "y": 48, "width": 400, "height": 80}}
assert get_bounds_for_element(bad_box) is None
element_invalid_bounds = {
"bounds": {
"x": "invalid", # Should be int
"y": 20,
"width": 100,
"height": 50,
}
}
bounds = get_bounds_for_element(element_invalid_bounds)
assert bounds is None
def test_element_bounds_geometry():
box = ElementBounds(x=100, y=200, width=500, height=100)
def test_element_bounds():
bounds = ElementBounds(x=10, y=20, width=100, height=50)
center = box.get_center()
center = bounds.get_center()
assert isinstance(center, Point)
assert center.x == 350
assert center.y == 250
assert center.x == 60
assert center.y == 45
mid = box.get_relative_point(0.5, 0.5)
assert mid.x == 350
assert mid.y == 250
center_point = bounds.get_relative_point(0.5, 0.5)
assert isinstance(center_point, Point)
assert center_point.x == 60
assert center_point.y == 45
top_left = box.get_relative_point(0.0, 0.0)
assert top_left.x == 100
assert top_left.y == 200
top_left = bounds.get_relative_point(0.0, 0.0)
assert top_left.x == 10
assert top_left.y == 20
bottom_right = box.get_relative_point(1.0, 1.0)
assert bottom_right.x == 600
assert bottom_right.y == 300
bottom_right = bounds.get_relative_point(1.0, 1.0)
assert bottom_right.x == 110
assert bottom_right.y == 70
custom_point = bounds.get_relative_point(0.95, 0.95)
assert custom_point.x == 105
assert custom_point.y == 67
if __name__ == "__main__":
test_text_input_is_empty()
test_find_element_by_resource_id()
test_find_element_by_resource_id_with_index()
test_find_element_by_resource_id_rich_hierarchy()
test_is_element_focused()
test_get_element_text()
test_get_bounds_for_element()
test_element_bounds()
print("All tests passed")