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

+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:
+20
View File
@@ -0,0 +1,20 @@
# 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
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."""