Implement Apex Agent MVP scaffold

This commit is contained in:
2026-07-06 13:22:15 +08:00
commit 02ef4d23f2
49 changed files with 2767 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""MCP and REST API surfaces for Apex Agent."""
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from core.errors import (
DeviceNotFoundError,
DeviceOfflineError,
ElementNotFoundError,
DriverError,
)
def semantic_error(exc: Exception) -> str:
if isinstance(exc, DeviceOfflineError):
return "device offline"
if isinstance(exc, DeviceNotFoundError):
return "device not found"
if isinstance(exc, ElementNotFoundError):
return "element not found"
if isinstance(exc, DriverError):
return "driver error"
return "operation failed"
def call_with_semantic_errors(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except Exception as exc:
return {"ok": False, "error": semantic_error(exc)}
+162
View File
@@ -0,0 +1,162 @@
import base64
from collections.abc import Callable
from typing import Any
from api.errors import call_with_semantic_errors
from core.device_manager import DEFAULT_MANAGER, DeviceManager
from tools.describe_screen import describe_screen
from tools.find_icon import find_icon_on_screen
from tools.find_text import find_text_on_screen
from tools.input_text import input_text
from tools.launch_app import launch_app
from tools.screenshot import take_screenshot
from tools.swipe import swipe
from tools.tap import tap
from tools.ui_tree import get_ui_tree
def tool_handlers(
*,
manager: DeviceManager | None = None,
) -> dict[str, Callable[..., Any]]:
device_manager = manager or DEFAULT_MANAGER
def _screenshot(device_id: str | None = None) -> dict[str, Any]:
image = take_screenshot(device_id, manager=device_manager)
return {
"ok": True,
"image_base64": base64.b64encode(image).decode("ascii"),
"mime_type": "image/png",
}
return {
"take_screenshot": lambda device_id=None: call_with_semantic_errors(
_screenshot,
device_id,
),
"tap": lambda x, y, device_id=None: call_with_semantic_errors(
tap,
x,
y,
device_id=device_id,
manager=device_manager,
),
"swipe": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: call_with_semantic_errors(
swipe,
start_x,
start_y,
end_x,
end_y,
duration_ms=duration_ms,
device_id=device_id,
manager=device_manager,
),
"input_text": lambda text, device_id=None: call_with_semantic_errors(
input_text,
text,
device_id=device_id,
manager=device_manager,
),
"launch_app": lambda app_id, device_id=None: call_with_semantic_errors(
launch_app,
app_id,
device_id=device_id,
manager=device_manager,
),
"find_text": lambda query, device_id=None: call_with_semantic_errors(
find_text_on_screen,
query,
device_id=device_id,
manager=device_manager,
),
"find_icon": lambda name, device_id=None: call_with_semantic_errors(
find_icon_on_screen,
name,
device_id=device_id,
manager=device_manager,
),
"get_ui_tree": lambda device_id=None: call_with_semantic_errors(
get_ui_tree,
device_id,
manager=device_manager,
),
"describe_screen": lambda device_id=None: call_with_semantic_errors(
lambda: describe_screen(device_id, manager=device_manager).to_dict()
),
"list_devices": lambda: [
device.to_dict() for device in device_manager.list_devices()
],
"device_status": lambda device_id: call_with_semantic_errors(
lambda: {"device_id": device_id, "status": device_manager.status(device_id)}
),
}
def create_mcp_server(*, manager: DeviceManager | None = None) -> Any:
try:
from mcp.server.fastmcp import FastMCP
except ImportError as exc:
raise RuntimeError("mcp SDK is not installed") from exc
handlers = tool_handlers(manager=manager)
server = FastMCP("apex-agent")
@server.tool(name="take_screenshot")
def _take_screenshot(device_id: str | None = None) -> dict[str, Any]:
return handlers["take_screenshot"](device_id=device_id)
@server.tool(name="tap")
def _tap(x: float, y: float, device_id: str | None = None) -> dict[str, Any]:
return handlers["tap"](x=x, y=y, device_id=device_id)
@server.tool(name="swipe")
def _swipe(
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
device_id: str | None = None,
) -> dict[str, Any]:
return handlers["swipe"](
start_x=start_x,
start_y=start_y,
end_x=end_x,
end_y=end_y,
duration_ms=duration_ms,
device_id=device_id,
)
@server.tool(name="input_text")
def _input_text(text: str, device_id: str | None = None) -> dict[str, Any]:
return handlers["input_text"](text=text, device_id=device_id)
@server.tool(name="launch_app")
def _launch_app(app_id: str, device_id: str | None = None) -> dict[str, Any]:
return handlers["launch_app"](app_id=app_id, device_id=device_id)
@server.tool(name="find_text")
def _find_text(query: str, device_id: str | None = None) -> dict[str, Any]:
return handlers["find_text"](query=query, device_id=device_id)
@server.tool(name="find_icon")
def _find_icon(name: str, device_id: str | None = None) -> dict[str, Any]:
return handlers["find_icon"](name=name, device_id=device_id)
@server.tool(name="get_ui_tree")
def _get_ui_tree(device_id: str | None = None) -> Any:
return handlers["get_ui_tree"](device_id=device_id)
@server.tool(name="describe_screen")
def _describe_screen(device_id: str | None = None) -> dict[str, Any]:
return handlers["describe_screen"](device_id=device_id)
@server.tool(name="list_devices")
def _list_devices() -> list[dict[str, Any]]:
return handlers["list_devices"]()
@server.tool(name="device_status")
def _device_status(device_id: str) -> dict[str, Any]:
return handlers["device_status"](device_id=device_id)
return server
+109
View File
@@ -0,0 +1,109 @@
from typing import Any
from api.errors import semantic_error
from core.device_manager import DEFAULT_MANAGER, DeviceManager
from core.models import Task
from runtime.executor import Executor, default_tool_registry
from runtime.task import TaskRunner
from storage.task_metadata import TaskMetadataStore
from tools.launch_app import launch_app
from tools.screenshot import take_screenshot
from tools.tap import tap
def create_app(
*,
manager: DeviceManager | None = None,
task_runner: TaskRunner | None = None,
metadata_store: TaskMetadataStore | None = None,
) -> Any:
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel
device_manager = manager or DEFAULT_MANAGER
store = metadata_store or TaskMetadataStore()
runner = task_runner or TaskRunner(
metadata_store=store,
executor=Executor(tools=default_tool_registry(manager=device_manager)),
)
app = FastAPI(title="Apex Agent API")
class TapRequest(BaseModel):
x: float
y: float
class ScreenshotResponse(BaseModel):
image_base64: str
mime_type: str = "image/png"
class LaunchRequest(BaseModel):
app_id: str
class AgentTaskRequest(BaseModel):
goal: str
device_id: str
@app.get("/devices")
def devices() -> list[dict[str, Any]]:
return [device.to_dict() for device in device_manager.list_devices()]
@app.post("/devices/{device_id}/tap")
def tap_device(device_id: str, request: TapRequest) -> dict[str, Any]:
return _raise_semantic(
lambda: tap(
request.x,
request.y,
device_id=device_id,
manager=device_manager,
)
)
@app.post("/devices/{device_id}/screenshot")
def screenshot_device(device_id: str) -> dict[str, str]:
import base64
image = _raise_semantic(
lambda: take_screenshot(device_id, manager=device_manager)
)
response = ScreenshotResponse(
image_base64=base64.b64encode(image).decode("ascii")
)
return response.model_dump()
@app.post("/devices/{device_id}/launch")
def launch_device(device_id: str, request: LaunchRequest) -> dict[str, Any]:
return _raise_semantic(
lambda: launch_app(
request.app_id,
device_id=device_id,
manager=device_manager,
)
)
@app.post("/agent/task")
def start_task(
request: AgentTaskRequest,
background_tasks: BackgroundTasks,
) -> dict[str, str]:
task = Task(goal=request.goal, device_id=request.device_id)
store.create_task(task)
background_tasks.add_task(runner.run, task)
return {"task_id": task.id, "status": task.status}
@app.get("/task/{task_id}")
def get_task(task_id: str) -> dict[str, Any]:
task = store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
return app
def _raise_semantic(func: Any) -> Any:
from fastapi import HTTPException
try:
return func()
except Exception as exc:
raise HTTPException(status_code=400, detail=semantic_error(exc)) from exc
+2
View File
@@ -0,0 +1,2 @@
"""Core device and domain abstractions for Apex Agent."""
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
from threading import RLock
from time import sleep
from core.driver import Driver
from core.errors import (
DeviceBusyError,
DeviceNotFoundError,
DeviceOfflineError,
DriverError,
)
from core.models import Device, DeviceStatus
DriverFactory = Callable[[], Driver]
class DeviceManager:
def __init__(self) -> None:
self._devices: dict[str, Device] = {}
self._factories: dict[str, DriverFactory] = {}
self._drivers: dict[str, Driver] = {}
self._lock = RLock()
def register_device(
self,
device_id: str,
driver_factory: DriverFactory,
*,
name: str | None = None,
driver_type: str = "wda",
connection_info: dict[str, object] | None = None,
status: DeviceStatus = "idle",
) -> Device:
with self._lock:
device = Device(
id=device_id,
name=name,
status=status,
driver_type=driver_type,
connection_info=dict(connection_info or {}),
)
self._devices[device_id] = device
self._factories[device_id] = driver_factory
self._drivers.pop(device_id, None)
return replace(device)
def unregister_device(self, device_id: str) -> None:
with self._lock:
driver = self._drivers.pop(device_id, None)
if driver:
driver.disconnect()
self._devices.pop(device_id, None)
self._factories.pop(device_id, None)
def list_devices(self) -> list[Device]:
with self._lock:
return [replace(device) for device in self._devices.values()]
def status(self, device_id: str) -> DeviceStatus:
with self._lock:
return self._device(device_id).status
def connect(
self,
device_id: str,
*,
max_retries: int = 2,
retry_backoff_seconds: float = 0.25,
) -> Driver:
if max_retries < 1:
raise ValueError("max_retries must be at least 1")
last_error: Exception | None = None
for attempt in range(1, max_retries + 1):
with self._lock:
device = self._device(device_id)
if device.status == "busy" and device_id in self._drivers:
return self._drivers[device_id]
if device.status == "busy":
raise DeviceBusyError(f"device {device_id} is busy")
driver = self._factories[device_id]()
try:
driver.connect()
except Exception as exc:
last_error = exc
with self._lock:
status: DeviceStatus = (
"offline" if attempt == max_retries else "error"
)
self._set_status(device_id, status)
if attempt < max_retries:
sleep(retry_backoff_seconds)
continue
with self._lock:
self._drivers[device_id] = driver
self._set_status(device_id, "busy")
return driver
raise DeviceOfflineError(
f"device {device_id} is offline"
) from last_error
def disconnect(self, device_id: str) -> None:
with self._lock:
self._device(device_id)
driver = self._drivers.pop(device_id, None)
if driver:
driver.disconnect()
with self._lock:
self._set_status(device_id, "idle")
def mark_error(self, device_id: str, *, offline: bool = False) -> None:
with self._lock:
self._device(device_id)
self._drivers.pop(device_id, None)
self._set_status(device_id, "offline" if offline else "error")
def active_driver(self, device_id: str | None = None) -> Driver:
with self._lock:
if device_id is None:
busy_ids = [
known_id
for known_id, device in self._devices.items()
if device.status == "busy" and known_id in self._drivers
]
if len(busy_ids) != 1:
raise DriverError(
"exactly one connected device is required when no device_id is given"
)
device_id = busy_ids[0]
self._device(device_id)
driver = self._drivers.get(device_id)
if not driver:
raise DeviceOfflineError(f"device {device_id} is not connected")
return driver
def _device(self, device_id: str) -> Device:
try:
return self._devices[device_id]
except KeyError as exc:
raise DeviceNotFoundError(f"unknown device {device_id}") from exc
def _set_status(self, device_id: str, status: DeviceStatus) -> None:
device = self._devices[device_id]
self._devices[device_id] = replace(device, status=status)
DEFAULT_MANAGER = DeviceManager()
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class Driver(ABC):
"""Driver-independent device capability interface.
Implementations may hold a live connection handle, but no task or business
state belongs here.
"""
@abstractmethod
def connect(self) -> None:
"""Open the device connection."""
@abstractmethod
def disconnect(self) -> None:
"""Close the device connection."""
@abstractmethod
def screenshot(self) -> bytes:
"""Return the current screen as image bytes."""
@abstractmethod
def tap(self, x: float, y: float) -> None:
"""Tap the screen at the given coordinates."""
@abstractmethod
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
"""Swipe between two screen coordinates."""
@abstractmethod
def input(self, text: str) -> None:
"""Input text into the current focused field."""
@abstractmethod
def launch(self, app_id: str) -> None:
"""Launch an app by bundle id or driver-supported app identifier."""
@abstractmethod
def terminate(self, app_id: str) -> None:
"""Terminate an app by bundle id or driver-supported app identifier."""
@abstractmethod
def tree(self) -> Any:
"""Return the raw UI tree from the device driver."""
@abstractmethod
def home(self) -> None:
"""Press the device home button."""
@abstractmethod
def lock(self) -> None:
"""Lock the device."""
@abstractmethod
def unlock(self) -> None:
"""Unlock the device."""
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
class ApexAgentError(Exception):
"""Base error for semantic errors surfaced above the driver layer."""
class DriverError(ApexAgentError):
"""A driver operation failed."""
class DeviceNotFoundError(ApexAgentError):
"""A requested device id is unknown."""
class DeviceOfflineError(ApexAgentError):
"""A device is unavailable or lost its connection."""
class DeviceBusyError(ApexAgentError):
"""A device is already in use."""
class ElementNotFoundError(ApexAgentError):
"""A requested screen element was not found."""
class TaskFailedError(ApexAgentError):
"""A task failed before reaching its goal."""
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, Literal
from uuid import uuid4
DeviceStatus = Literal["idle", "busy", "offline", "error"]
TaskStatus = Literal["created", "running", "completed", "failed", "cancelled"]
StepStatus = Literal["pending", "running", "completed", "failed"]
def utc_now() -> datetime:
return datetime.now(UTC)
@dataclass(frozen=True)
class Bounds:
x: float
y: float
width: float
height: float
@property
def right(self) -> float:
return self.x + self.width
@property
def bottom(self) -> float:
return self.y + self.height
@property
def center(self) -> tuple[float, float]:
return (self.x + self.width / 2, self.y + self.height / 2)
def to_dict(self) -> dict[str, float]:
return {
"x": self.x,
"y": self.y,
"width": self.width,
"height": self.height,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Bounds":
return cls(
x=float(data["x"]),
y=float(data["y"]),
width=float(data["width"]),
height=float(data["height"]),
)
@dataclass
class Device:
id: str
status: DeviceStatus = "idle"
name: str | None = None
driver_type: str = "wda"
connection_info: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"status": self.status,
"driver_type": self.driver_type,
"connection_info": dict(self.connection_info),
}
@dataclass
class SceneElement:
id: str
type: str
bounds: Bounds
text: str | None = None
confidence: float | None = None
source: str | None = None
@property
def center(self) -> tuple[float, float]:
return self.bounds.center
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {
"id": self.id,
"type": self.type,
"text": self.text,
"bounds": self.bounds.to_dict(),
"confidence": self.confidence,
}
if self.source:
data["source"] = self.source
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SceneElement":
return cls(
id=str(data["id"]),
type=str(data.get("type") or "unknown"),
text=data.get("text"),
bounds=Bounds.from_dict(data["bounds"]),
confidence=data.get("confidence"),
source=data.get("source"),
)
@dataclass
class Scene:
width: int
height: int
elements: list[SceneElement] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"screen": {"width": self.width, "height": self.height},
"elements": [element.to_dict() for element in self.elements],
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Scene":
screen = data.get("screen") or {}
return cls(
width=int(screen.get("width") or data.get("width") or 0),
height=int(screen.get("height") or data.get("height") or 0),
elements=[
SceneElement.from_dict(element)
for element in data.get("elements", [])
],
)
@dataclass
class Task:
goal: str
device_id: str
id: str = field(default_factory=lambda: uuid4().hex)
status: TaskStatus = "created"
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
completed_at: datetime | None = None
failure_reason: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"goal": self.goal,
"device_id": self.device_id,
"status": self.status,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"completed_at": self.completed_at.isoformat()
if self.completed_at
else None,
"failure_reason": self.failure_reason,
}
@dataclass
class Step:
description: str
action: str
args: dict[str, Any] = field(default_factory=dict)
id: str = field(default_factory=lambda: uuid4().hex)
status: StepStatus = "pending"
result: dict[str, Any] | None = None
error: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"description": self.description,
"action": self.action,
"args": dict(self.args),
"status": self.status,
"result": self.result,
"error": self.error,
}
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from core.driver import Driver
from core.errors import DeviceOfflineError, DriverError
@dataclass(frozen=True)
class WDADriverConfig:
server_url: str = "http://127.0.0.1:4723"
platform_name: str = "iOS"
automation_name: str = "XCUITest"
device_name: str | None = None
udid: str | None = None
wda_local_port: int | None = None
no_reset: bool = True
extra_capabilities: dict[str, Any] = field(default_factory=dict)
class WDADriver(Driver):
def __init__(self, config: WDADriverConfig | None = None) -> None:
self.config = config or WDADriverConfig()
self._client: Any | None = None
def connect(self) -> None:
try:
from appium import webdriver
from appium.options.ios import XCUITestOptions
from appium.webdriver.client_config import AppiumClientConfig
except ImportError as exc:
raise DriverError("Appium Python client is not installed") from exc
capabilities: dict[str, Any] = {
"platformName": self.config.platform_name,
"automationName": self.config.automation_name,
"noReset": self.config.no_reset,
**self.config.extra_capabilities,
}
if self.config.device_name:
capabilities["deviceName"] = self.config.device_name
if self.config.udid:
capabilities["udid"] = self.config.udid
if self.config.wda_local_port:
capabilities["wdaLocalPort"] = self.config.wda_local_port
options = XCUITestOptions().load_capabilities(capabilities)
client_config = AppiumClientConfig(remote_server_addr=self.config.server_url)
try:
self._client = webdriver.Remote(
options=options,
client_config=client_config,
)
except Exception as exc:
self._client = None
raise DeviceOfflineError("device offline") from exc
def disconnect(self) -> None:
client = self._require_client()
try:
client.quit()
finally:
self._client = None
def screenshot(self) -> bytes:
client = self._require_client()
try:
return client.get_screenshot_as_png()
except Exception as exc:
raise DriverError("screenshot failed") from exc
def tap(self, x: float, y: float) -> None:
client = self._require_client()
try:
client.execute_script("mobile: tap", {"x": x, "y": y})
except Exception as exc:
raise DriverError("tap failed") from exc
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
client = self._require_client()
try:
client.execute_script(
"mobile: dragFromToForDuration",
{
"fromX": start_x,
"fromY": start_y,
"toX": end_x,
"toY": end_y,
"duration": duration_ms / 1000,
},
)
except Exception as exc:
raise DriverError("swipe failed") from exc
def input(self, text: str) -> None:
client = self._require_client()
try:
client.switch_to.active_element.send_keys(text)
except Exception as exc:
raise DriverError("text input failed") from exc
def launch(self, app_id: str) -> None:
client = self._require_client()
try:
client.activate_app(app_id)
except Exception as exc:
raise DriverError("app launch failed") from exc
def terminate(self, app_id: str) -> None:
client = self._require_client()
try:
client.terminate_app(app_id)
except Exception as exc:
raise DriverError("app terminate failed") from exc
def tree(self) -> str:
client = self._require_client()
try:
return client.page_source
except Exception as exc:
raise DriverError("ui tree retrieval failed") from exc
def home(self) -> None:
client = self._require_client()
try:
client.execute_script("mobile: pressButton", {"name": "home"})
except Exception as exc:
raise DriverError("home failed") from exc
def lock(self) -> None:
client = self._require_client()
try:
client.lock()
except Exception as exc:
raise DriverError("lock failed") from exc
def unlock(self) -> None:
client = self._require_client()
try:
client.unlock()
except Exception as exc:
raise DriverError("unlock failed") from exc
def _require_client(self) -> Any:
if self._client is None:
raise DeviceOfflineError("device offline")
return self._client
+58
View File
@@ -0,0 +1,58 @@
## 1. Project scaffolding
- [x] 1.1 Create the package layout: `core/`, `tools/`, `vision/`, `runtime/`, `api/`, `storage/`, `tests/` (each with `__init__.py`)
- [x] 1.2 Add MVP dependencies to `pyproject.toml` (Appium Python client, an OCR engine e.g. PaddleOCR, FastAPI, an MCP server SDK, SQLite driver, test framework)
- [x] 1.3 Add `core/models.py` with shared data classes: `Device`, `Scene`, `SceneElement`, `Task`, `Step`
- [x] 1.4 Set up a basic test runner config and a smoke test that imports every new package
## 2. Device management (capability: device-management)
- [x] 2.1 Define the abstract `Driver` interface in `core/driver.py` (`connect`, `disconnect`, `screenshot`, `tap`, `swipe`, `input`, `launch`, `terminate`, `tree`, `home`, `lock`, `unlock`) with no stored business state
- [x] 2.2 Implement `core/device_manager.py`: device discovery/registration, status tracking (`idle`/`busy`/`offline`/`error`), `list_devices()`, `connect()`, `disconnect()`, `status()`
- [x] 2.3 Implement `core/wda_driver.py` (`WDADriver`) using the Appium Python client against WebDriverAgent, covering all `Driver` methods
- [x] 2.4 Add connection-loss handling: mark device `offline`/`error` and bound retries on `connect()` instead of hanging
- [x] 2.5 Write unit tests for `DeviceManager` state transitions using a fake/mock `Driver`
- [x] 2.6 Write an integration test (skippable without hardware) that connects to a real/simulated device via `WDADriver` and takes a screenshot
## 3. Perception pipeline (capability: scene-perception)
- [x] 3.1 Implement `vision/ocr.py`: run OCR over a screenshot and return text boxes with bounds/confidence
- [x] 3.2 Implement `vision/ui_parser.py`: parse the raw UI tree returned by `Driver.tree()` into a normalized element list (type, text, bounds)
- [x] 3.3 Implement `vision/scene_builder.py`: fuse OCR output + parsed UI tree into a single `Scene`, deduping overlapping elements by bounding-box IoU (prefer UI-tree bounds/type on overlap)
- [x] 3.4 Implement `vision/icon_detector.py` as a minimal stub (template match or "not found") satisfying the `find_icon` contract
- [x] 3.5 Implement `tools/screenshot.py`, `tools/tap.py`, `tools/swipe.py`, `tools/input_text.py`, `tools/launch_app.py`, `tools/ui_tree.py` as thin wrappers over the active device's `Driver`
- [x] 3.6 Implement `tools/describe_screen.py` (returns the fused `Scene`) and `find_text`/`find_icon` helpers that search a `Scene` and return coordinates or "not found"
- [x] 3.7 Write unit tests for `scene_builder` dedup logic using fixture screenshots/tree/OCR data
- [x] 3.8 Write unit tests for `find_text`/`find_icon` against fixture `Scene` objects (found and not-found cases)
## 4. Agent runtime (capability: agent-runtime)
- [x] 4.1 Implement `runtime/context.py`: per-task in-memory context holding Scene history and executed step results
- [x] 4.2 Implement `runtime/planner.py`: given a goal + current `Scene` (+ context), produce an ordered list of intended next steps
- [x] 4.3 Implement `runtime/executor.py`: turn a planned step into `tools/` calls, with retry/backoff and wait-for-element handling, bounded by configurable max-retries and max-steps
- [x] 4.4 Implement `runtime/task.py`: drive the Observe→Think→Act→Observe loop for a task from start to completion/failure, wiring Planner + Executor + Context together
- [x] 4.5 Add max-step/max-retry ceiling handling that fails the task with a clear reason instead of looping indefinitely
- [x] 4.6 Write unit tests for `Executor` retry/backoff behavior using a fake tool that fails N times then succeeds
- [x] 4.7 Write an end-to-end test of the loop against a mocked `Driver`/`Scene` sequence simulating "open app → find search → tap → type"
## 5. Task memory / timeline (capability: task-memory)
- [x] 5.1 Implement `storage/artifact_store.py`: write screenshots and per-step JSON (Scene, prompt, tool call, result) to `tasks/history/<task_id>/NNN.{png,json}`
- [x] 5.2 Implement `storage/timeline.py`: append-and-read API for a task's ordered step records, backed by `artifact_store`
- [x] 5.3 Add SQLite-backed task metadata storage (task id, device id, status, start/end timestamps) with create/update/query functions
- [x] 5.4 Wire `runtime/task.py` (from section 4) to write a timeline record after every Executor step and update task metadata on completion/failure
- [x] 5.5 Write unit tests verifying timeline records are written in order and are readable after a simulated process restart (re-opening the same task directory)
## 6. MCP tool server & REST API (capability: mcp-tool-server)
- [x] 6.1 Implement `api/mcp.py`: register `take_screenshot`, `tap`, `swipe`, `input_text`, `launch_app`, `find_text`, `find_icon`, `get_ui_tree`, `describe_screen`, `list_devices`, `device_status` as MCP tools calling directly into `tools/`
- [x] 6.2 Add a semantic error-translation layer so driver/framework errors (WDA connection errors, element-not-found) surface as clear MCP tool errors (e.g. "device offline", "element not found")
- [x] 6.3 Implement `api/rest.py` (FastAPI) with `GET /devices`, `POST /devices/{id}/tap`, `POST /devices/{id}/screenshot`, `POST /devices/{id}/launch`, `POST /agent/task`, `GET /task/{id}`, calling the same `tools/`/`runtime/` functions as the MCP tools
- [x] 6.4 Write a test that starts a task via `POST /agent/task` and polls `GET /task/{id}` until it completes (using a mocked device)
- [x] 6.5 Write a test that calls each MCP tool against a mocked device and asserts no Appium/WDA-specific type or string leaks into the response
## 7. End-to-end validation
- [ ] 7.1 Run the full Observe→Think→Act→Observe loop against a real (or simulator) iPhone for a simple goal (e.g. "open an app and search for a term"), verifying tap/input/screenshot all work through the real `WDADriver`
- [ ] 7.2 Confirm the resulting task's timeline directory contains a complete, ordered set of per-step screenshots and JSON records
- [ ] 7.3 Confirm an MCP client (or a manual MCP tool call harness) can drive the same real-device flow end-to-end through `api/mcp.py`
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "agentic-modile-control"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"Appium-Python-Client>=5.1.1",
"fastapi>=0.115.0",
"mcp>=1.27,<2",
"paddleocr>=3.0.0",
"uvicorn[standard]>=0.30.0",
]
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[dependency-groups]
dev = [
"httpx>=0.27.0",
"pytest>=8.3.0",
]
[tool.setuptools.packages.find]
include = ["api*", "core*", "runtime*", "storage*", "tools*", "vision*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
markers = [
"integration: tests requiring external hardware or services",
]
+2
View File
@@ -0,0 +1,2 @@
"""Agent runtime loop, planner, executor, and in-run context."""
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from core.models import Scene
if TYPE_CHECKING:
from runtime.executor import StepResult
@dataclass
class TaskContext:
task_id: str
goal: str
scenes: list[Scene] = field(default_factory=list)
step_results: list["StepResult"] = field(default_factory=list)
def add_scene(self, scene: Scene) -> None:
self.scenes.append(scene)
def add_step_result(self, result: "StepResult") -> None:
self.step_results.append(result)
@property
def latest_scene(self) -> Scene | None:
if not self.scenes:
return None
return self.scenes[-1]
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from time import sleep
from typing import Any
from core.device_manager import DeviceManager
from core.errors import ElementNotFoundError
from runtime.context import TaskContext
from runtime.planner import PlannedStep
ToolCallable = Callable[..., Any]
@dataclass(frozen=True)
class StepResult:
step: PlannedStep
success: bool
attempts: int
result: Any = None
error: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"step": {
"action": self.step.action,
"description": self.step.description,
"args": dict(self.step.args),
"expected_text": self.step.expected_text,
},
"success": self.success,
"attempts": self.attempts,
"result": self.result,
"error": self.error,
}
@dataclass
class ExecutorConfig:
max_retries: int = 3
backoff_seconds: float = 0.25
class Executor:
def __init__(
self,
*,
tools: dict[str, ToolCallable] | None = None,
config: ExecutorConfig | None = None,
) -> None:
self.tools = tools or default_tool_registry()
self.config = config or ExecutorConfig()
def execute(
self,
step: PlannedStep,
*,
context: TaskContext | None = None,
) -> StepResult:
if self.config.max_retries < 1:
raise ValueError("max_retries must be at least 1")
last_error: Exception | None = None
for attempt in range(1, self.config.max_retries + 1):
try:
result = self._execute_once(step, context=context)
return StepResult(
step=step,
success=True,
attempts=attempt,
result=result,
)
except Exception as exc:
last_error = exc
if attempt < self.config.max_retries:
sleep(self.config.backoff_seconds)
return StepResult(
step=step,
success=False,
attempts=self.config.max_retries,
error=str(last_error) if last_error else "step failed",
)
def _execute_once(
self,
step: PlannedStep,
*,
context: TaskContext | None,
) -> Any:
if step.action == "wait_for_text":
query = step.args["query"]
scene = context.latest_scene if context else None
if scene is None:
raise ElementNotFoundError("element not found")
result = self.tools["find_text"](scene=scene, query=query)
if not result.get("found"):
raise ElementNotFoundError("element not found")
return result
tool = self.tools.get(step.action)
if tool is None:
raise KeyError(f"unknown tool action {step.action}")
return tool(**step.args)
def default_tool_registry(
*,
manager: DeviceManager | None = None,
) -> dict[str, ToolCallable]:
from tools.describe_screen import describe_screen
from tools.find_icon import find_icon, find_icon_on_screen
from tools.find_text import find_text, find_text_on_screen
from tools.input_text import input_text
from tools.launch_app import launch_app, terminate_app
from tools.screenshot import take_screenshot
from tools.swipe import swipe
from tools.tap import tap
from tools.ui_tree import get_ui_tree
return {
"take_screenshot": _bind_manager(take_screenshot, manager),
"screenshot": _bind_manager(take_screenshot, manager),
"tap": _bind_manager(tap, manager),
"swipe": _bind_manager(swipe, manager),
"input_text": _bind_manager(input_text, manager),
"launch_app": _bind_manager(launch_app, manager),
"terminate_app": _bind_manager(terminate_app, manager),
"get_ui_tree": _bind_manager(get_ui_tree, manager),
"ui_tree": _bind_manager(get_ui_tree, manager),
"describe_screen": _bind_manager(describe_screen, manager),
"find_text": find_text,
"find_text_on_screen": _bind_manager(find_text_on_screen, manager),
"find_icon": find_icon,
"find_icon_on_screen": _bind_manager(find_icon_on_screen, manager),
}
def _bind_manager(func: ToolCallable, manager: DeviceManager | None) -> ToolCallable:
if manager is None:
return func
def wrapper(*args: Any, **kwargs: Any) -> Any:
kwargs.setdefault("manager", manager)
return func(*args, **kwargs)
return wrapper
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from core.models import Scene
from runtime.context import TaskContext
@dataclass(frozen=True)
class PlannedStep:
action: str
description: str
args: dict[str, Any] = field(default_factory=dict)
expected_text: str | None = None
class Planner:
def plan(
self,
*,
goal: str,
scene: Scene,
context: TaskContext,
) -> list[PlannedStep]:
if context.step_results:
return []
return [
PlannedStep(
action="describe_screen",
description=f"Observe current screen for goal: {goal}",
args={},
)
]
def goal_reached(self, *, goal: str, scene: Scene, context: TaskContext) -> bool:
return bool(context.step_results) and all(
result.success for result in context.step_results
)
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, replace
from core.models import Scene, Task, utc_now
from runtime.context import TaskContext
from runtime.executor import Executor
from runtime.planner import PlannedStep, Planner
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tools.describe_screen import describe_screen
from tools.screenshot import take_screenshot
@dataclass
class TaskRunnerConfig:
max_steps: int = 20
Observer = Callable[[str], Scene]
ScreenshotProvider = Callable[[str], bytes]
class TaskRunner:
def __init__(
self,
*,
planner: Planner | None = None,
executor: Executor | None = None,
timeline: Timeline | None = None,
metadata_store: TaskMetadataStore | None = None,
config: TaskRunnerConfig | None = None,
observer: Observer | None = None,
screenshot_provider: ScreenshotProvider | None = None,
) -> None:
self.planner = planner or Planner()
self.executor = executor or Executor()
self.timeline = timeline
self.metadata_store = metadata_store
self.config = config or TaskRunnerConfig()
self.observer = observer or (lambda device_id: describe_screen(device_id))
self.screenshot_provider = screenshot_provider or (
lambda device_id: take_screenshot(device_id)
)
def run(self, task: Task) -> Task:
context = TaskContext(task_id=task.id, goal=task.goal)
self._update_task(task, status="running")
for _ in range(self.config.max_steps):
scene = self.observer(task.device_id)
context.add_scene(scene)
steps = self.planner.plan(goal=task.goal, scene=scene, context=context)
if not steps or self.planner.goal_reached(
goal=task.goal,
scene=scene,
context=context,
):
self._update_task(task, status="completed", completed=True)
return task
for step in steps:
result = self.executor.execute(
self._step_for_device(step, task.device_id),
context=context,
)
context.add_step_result(result)
self._append_timeline(task, scene, step, result)
if not result.success:
self._update_task(
task,
status="failed",
completed=True,
failure_reason=result.error or "step failed",
)
return task
self._update_task(
task,
status="failed",
completed=True,
failure_reason=f"max steps exceeded: {self.config.max_steps}",
)
return task
def _append_timeline(
self,
task: Task,
scene: Scene,
step: PlannedStep,
result: object,
) -> None:
if not self.timeline:
return
try:
screenshot = self.screenshot_provider(task.device_id)
except Exception:
screenshot = None
self.timeline.append(
task_id=task.id,
scene=scene.to_dict(),
prompt=task.goal,
tool_call={
"action": step.action,
"description": step.description,
"args": step.args,
},
result=result.to_dict() if hasattr(result, "to_dict") else {"result": result},
screenshot=screenshot,
)
def _step_for_device(self, step: PlannedStep, device_id: str) -> PlannedStep:
device_scoped_actions = {
"take_screenshot",
"screenshot",
"tap",
"swipe",
"input_text",
"launch_app",
"terminate_app",
"get_ui_tree",
"ui_tree",
"describe_screen",
"find_text_on_screen",
"find_icon_on_screen",
}
if step.action not in device_scoped_actions or "device_id" in step.args:
return step
return replace(step, args={**step.args, "device_id": device_id})
def _update_task(
self,
task: Task,
*,
status: str,
completed: bool = False,
failure_reason: str | None = None,
) -> None:
task.status = status # type: ignore[assignment]
task.updated_at = utc_now()
if completed:
task.completed_at = utc_now()
task.failure_reason = failure_reason
if self.metadata_store:
self.metadata_store.update_task(
task.id,
status=task.status,
completed=completed,
failure_reason=failure_reason,
)
+2
View File
@@ -0,0 +1,2 @@
"""Local artifact and task metadata storage."""
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import json
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any
class ArtifactStore:
def __init__(self, root: str | Path = "tasks/history") -> None:
self.root = Path(root)
def task_dir(self, task_id: str) -> Path:
return self.root / task_id
def write_step(
self,
*,
task_id: str,
index: int,
screenshot: bytes | None,
record: dict[str, Any],
) -> dict[str, str | None]:
task_dir = self.task_dir(task_id)
task_dir.mkdir(parents=True, exist_ok=True)
stem = f"{index:03d}"
screenshot_path: Path | None = None
if screenshot is not None:
screenshot_path = task_dir / f"{stem}.png"
screenshot_path.write_bytes(screenshot)
json_path = task_dir / f"{stem}.json"
payload = {
**record,
"screenshot_path": str(screenshot_path) if screenshot_path else None,
}
json_path.write_text(
json.dumps(_jsonable(payload), ensure_ascii=False, indent=2),
encoding="utf-8",
)
return {
"json_path": str(json_path),
"screenshot_path": str(screenshot_path) if screenshot_path else None,
}
def read_steps(self, task_id: str) -> list[dict[str, Any]]:
task_dir = self.task_dir(task_id)
if not task_dir.exists():
return []
steps = []
for path in sorted(task_dir.glob("*.json")):
steps.append(json.loads(path.read_text(encoding="utf-8")))
return steps
def _jsonable(value: Any) -> Any:
if hasattr(value, "to_dict"):
return value.to_dict()
if is_dataclass(value):
return asdict(value)
if isinstance(value, dict):
return {key: _jsonable(inner) for key, inner in value.items()}
if isinstance(value, list):
return [_jsonable(inner) for inner in value]
if isinstance(value, tuple):
return [_jsonable(inner) for inner in value]
if isinstance(value, (datetime, date)):
return value.isoformat()
return value
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Any
from core.models import Task, TaskStatus, utc_now
class TaskMetadataStore:
def __init__(self, db_path: str | Path = "tasks/tasks.sqlite3") -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
def create_task(self, task: Task) -> None:
with self._connect() as connection:
connection.execute(
"""
insert into tasks (
id, goal, device_id, status, created_at, updated_at,
completed_at, failure_reason
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task.id,
task.goal,
task.device_id,
task.status,
task.created_at.isoformat(),
task.updated_at.isoformat(),
task.completed_at.isoformat() if task.completed_at else None,
task.failure_reason,
),
)
def update_task(
self,
task_id: str,
*,
status: TaskStatus | None = None,
failure_reason: str | None = None,
completed: bool = False,
) -> None:
updates: dict[str, Any] = {"updated_at": utc_now().isoformat()}
if status:
updates["status"] = status
if failure_reason is not None:
updates["failure_reason"] = failure_reason
if completed:
updates["completed_at"] = utc_now().isoformat()
assignments = ", ".join(f"{key} = ?" for key in updates)
values = [*updates.values(), task_id]
with self._connect() as connection:
connection.execute(
f"update tasks set {assignments} where id = ?",
values,
)
def get_task(self, task_id: str) -> dict[str, Any] | None:
with self._connect() as connection:
row = connection.execute(
"select * from tasks where id = ?",
(task_id,),
).fetchone()
return dict(row) if row else None
def list_tasks(self) -> list[dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute(
"select * from tasks order by created_at desc"
).fetchall()
return [dict(row) for row in rows]
def _ensure_schema(self) -> None:
with self._connect() as connection:
connection.execute(
"""
create table if not exists tasks (
id text primary key,
goal text not null,
device_id text not null,
status text not null,
created_at text not null,
updated_at text not null,
completed_at text,
failure_reason text
)
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
return connection
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from storage.artifact_store import ArtifactStore
@dataclass(frozen=True)
class TimelineRecord:
index: int
scene: dict[str, Any]
prompt: str
tool_call: dict[str, Any]
result: dict[str, Any]
timestamp: str
screenshot_path: str | None = None
class Timeline:
def __init__(self, artifact_store: ArtifactStore | None = None) -> None:
self.artifact_store = artifact_store or ArtifactStore()
def append(
self,
*,
task_id: str,
scene: Any,
prompt: str,
tool_call: dict[str, Any],
result: dict[str, Any],
screenshot: bytes | None = None,
) -> TimelineRecord:
index = len(self.read(task_id)) + 1
record = {
"index": index,
"scene": scene,
"prompt": prompt,
"tool_call": tool_call,
"result": result,
"timestamp": datetime.now().astimezone().isoformat(),
}
paths = self.artifact_store.write_step(
task_id=task_id,
index=index,
screenshot=screenshot,
record=record,
)
return TimelineRecord(
index=index,
scene=record["scene"],
prompt=prompt,
tool_call=tool_call,
result=result,
timestamp=record["timestamp"],
screenshot_path=paths["screenshot_path"],
)
def read(self, task_id: str) -> list[dict[str, Any]]:
return self.artifact_store.read_steps(task_id)
+2
View File
@@ -0,0 +1,2 @@
"""Test package for Apex Agent."""
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from typing import Any
from core.driver import Driver
PNG_10X20 = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\r"
b"IHDR"
b"\x00\x00\x00\x0a"
b"\x00\x00\x00\x14"
)
TREE_XML = """
<AppiumAUT x="0" y="0" width="10" height="20">
<XCUIElementTypeButton name="Search" label="Search" x="1" y="2" width="4" height="4" />
<XCUIElementTypeImage name="Settings" label="Settings" x="6" y="2" width="3" height="3" />
</AppiumAUT>
"""
class FakeDriver(Driver):
def __init__(
self,
*,
fail_connect: bool = False,
tree: Any = TREE_XML,
screenshot: bytes = PNG_10X20,
) -> None:
self.fail_connect = fail_connect
self.connected = False
self.calls: list[tuple[str, tuple[Any, ...]]] = []
self._tree = tree
self._screenshot = screenshot
def connect(self) -> None:
self.calls.append(("connect", ()))
if self.fail_connect:
raise RuntimeError("connection refused")
self.connected = True
def disconnect(self) -> None:
self.calls.append(("disconnect", ()))
self.connected = False
def screenshot(self) -> bytes:
self.calls.append(("screenshot", ()))
return self._screenshot
def tap(self, x: float, y: float) -> None:
self.calls.append(("tap", (x, y)))
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
self.calls.append(("swipe", (start_x, start_y, end_x, end_y, duration_ms)))
def input(self, text: str) -> None:
self.calls.append(("input", (text,)))
def launch(self, app_id: str) -> None:
self.calls.append(("launch", (app_id,)))
def terminate(self, app_id: str) -> None:
self.calls.append(("terminate", (app_id,)))
def tree(self) -> Any:
self.calls.append(("tree", ()))
return self._tree
def home(self) -> None:
self.calls.append(("home", ()))
def lock(self) -> None:
self.calls.append(("lock", ()))
def unlock(self) -> None:
self.calls.append(("unlock", ()))
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import pytest
from core.device_manager import DeviceManager
from core.errors import DeviceOfflineError
from tests.fakes import FakeDriver
def test_device_manager_connect_disconnect_transitions() -> None:
driver = FakeDriver()
manager = DeviceManager()
manager.register_device("iphone-1", lambda: driver, connection_info={"wda_port": 8100})
assert manager.list_devices()[0].status == "idle"
assert manager.connect("iphone-1", max_retries=1) is driver
assert manager.status("iphone-1") == "busy"
manager.disconnect("iphone-1")
assert manager.status("iphone-1") == "idle"
assert driver.calls[0] == ("connect", ())
assert driver.calls[-1] == ("disconnect", ())
def test_device_manager_marks_unreachable_device_offline() -> None:
manager = DeviceManager()
manager.register_device("iphone-1", lambda: FakeDriver(fail_connect=True))
with pytest.raises(DeviceOfflineError):
manager.connect("iphone-1", max_retries=2, retry_backoff_seconds=0)
assert manager.status("iphone-1") == "offline"
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from runtime.executor import Executor, ExecutorConfig
from runtime.planner import PlannedStep
def test_executor_retries_until_transient_tool_succeeds() -> None:
calls = {"count": 0}
def flaky_tool() -> dict[str, bool]:
calls["count"] += 1
if calls["count"] < 3:
raise RuntimeError("transient")
return {"ok": True}
executor = Executor(
tools={"flaky": flaky_tool},
config=ExecutorConfig(max_retries=3, backoff_seconds=0),
)
result = executor.execute(PlannedStep(action="flaky", description="retry"))
assert result.success is True
assert result.attempts == 3
assert result.result == {"ok": True}
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from core.models import Bounds, Scene, SceneElement
from tools.find_icon import find_icon
from tools.find_text import find_text
def test_find_text_found_and_not_found() -> None:
scene = Scene(
width=100,
height=100,
elements=[
SceneElement(
id="button",
type="button",
text="Search",
bounds=Bounds(10, 20, 40, 20),
)
],
)
assert find_text(scene, "sea")["found"] is True
assert find_text(scene, "missing") == {
"found": False,
"query": "missing",
"reason": "not found",
}
def test_find_icon_found_and_not_found() -> None:
scene = Scene(
width=100,
height=100,
elements=[
SceneElement(
id="settings-icon",
type="image",
text="Settings",
bounds=Bounds(50, 50, 20, 20),
)
],
)
assert find_icon(scene, "settings")["found"] is True
assert find_icon(scene, "profile")["reason"] == "not found"
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from api.mcp import tool_handlers
from core.device_manager import DeviceManager
from tests.fakes import FakeDriver
def test_mcp_handlers_are_semantic_and_hide_driver_terms() -> None:
manager = DeviceManager()
manager.register_device("iphone-1", lambda: FakeDriver())
manager.connect("iphone-1", max_retries=1)
handlers = tool_handlers(manager=manager)
responses = [
handlers["take_screenshot"](device_id="iphone-1"),
handlers["tap"](x=1, y=2, device_id="iphone-1"),
handlers["swipe"](
start_x=1,
start_y=2,
end_x=3,
end_y=4,
device_id="iphone-1",
),
handlers["input_text"](text="hello", device_id="iphone-1"),
handlers["launch_app"](app_id="com.example.app", device_id="iphone-1"),
handlers["find_text"](query="Search", device_id="iphone-1"),
handlers["find_icon"](name="Settings", device_id="iphone-1"),
handlers["get_ui_tree"](device_id="iphone-1"),
handlers["describe_screen"](device_id="iphone-1"),
handlers["list_devices"](),
handlers["device_status"](device_id="iphone-1"),
]
serialized = repr(responses)
assert "WDA" not in serialized
assert "Appium" not in serialized
assert "XCUI" not in serialized
assert all(response is not None for response in responses)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import pytest
from core.device_manager import DeviceManager
from core.models import Task
from storage.task_metadata import TaskMetadataStore
from tests.fakes import FakeDriver
def test_rest_start_task_and_poll_until_complete(tmp_path) -> None:
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from api.rest import create_app
driver = FakeDriver()
manager = DeviceManager()
manager.register_device("iphone-1", lambda: driver)
manager.connect("iphone-1", max_retries=1)
store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
class CompletingRunner:
def run(self, task: Task) -> None:
store.update_task(task.id, status="completed", completed=True)
app = create_app(
manager=manager,
metadata_store=store,
task_runner=CompletingRunner(),
)
client = TestClient(app)
response = client.post(
"/agent/task",
json={"goal": "search", "device_id": "iphone-1"},
)
assert response.status_code == 200
task_id = response.json()["task_id"]
status_response = client.get(f"/task/{task_id}")
assert status_response.status_code == 200
assert status_response.json()["status"] == "completed"
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from core.models import Bounds, SceneElement
from vision.scene_builder import bbox_iou, build_scene
def test_scene_builder_merges_overlapping_ocr_into_ui_element() -> None:
ui_button = SceneElement(
id="ui-button",
type="button",
text=None,
bounds=Bounds(10, 10, 100, 40),
confidence=1.0,
source="ui",
)
ocr_label = SceneElement(
id="ocr-label",
type="text",
text="Search",
bounds=Bounds(12, 12, 96, 36),
confidence=0.92,
source="ocr",
)
ocr_only = SceneElement(
id="ocr-only",
type="text",
text="Footer",
bounds=Bounds(0, 100, 40, 20),
confidence=0.8,
source="ocr",
)
scene = build_scene(
screen_width=120,
screen_height=140,
ui_elements=[ui_button],
ocr_elements=[ocr_label, ocr_only],
)
assert len(scene.elements) == 2
assert scene.elements[0].type == "button"
assert scene.elements[0].text == "Search"
assert scene.elements[1].text == "Footer"
assert bbox_iou(ui_button.bounds, ocr_label.bounds) > 0.5
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import importlib
def test_imports_new_packages() -> None:
for package in ("core", "tools", "vision", "runtime", "api", "storage"):
importlib.import_module(package)
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from core.models import Bounds, Scene, SceneElement, Task
from runtime.executor import Executor, ExecutorConfig
from runtime.planner import PlannedStep, Planner
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.artifact_store import ArtifactStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20
class ScriptedPlanner(Planner):
def __init__(self, steps: list[PlannedStep]) -> None:
self.steps = steps
def plan(self, *, goal, scene, context):
if len(context.step_results) >= len(self.steps):
return []
return [self.steps[len(context.step_results)]]
def goal_reached(self, *, goal, scene, context):
return len(context.step_results) >= len(self.steps) and all(
result.success for result in context.step_results
)
def test_task_runner_executes_loop_and_writes_timeline(tmp_path) -> None:
scene = Scene(
width=10,
height=20,
elements=[
SceneElement(
id="search",
type="input",
text="Search",
bounds=Bounds(1, 2, 4, 4),
)
],
)
planner = ScriptedPlanner(
[
PlannedStep(action="tap", description="tap search", args={"x": 3, "y": 4}),
PlannedStep(
action="input_text",
description="type query",
args={"text": "Mac mini"},
),
]
)
executor = Executor(
tools={
"tap": lambda **kwargs: {"ok": True, **kwargs},
"input_text": lambda **kwargs: {"ok": True, **kwargs},
},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
metadata = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(goal="open app and search", device_id="iphone-1")
metadata.create_task(task)
runner = TaskRunner(
planner=planner,
executor=executor,
metadata_store=metadata,
timeline=timeline,
config=TaskRunnerConfig(max_steps=5),
observer=lambda device_id: scene,
screenshot_provider=lambda device_id: PNG_10X20,
)
result = runner.run(task)
assert result.status == "completed"
assert len(timeline.read(task.id)) == 2
assert metadata.get_task(task.id)["status"] == "completed"
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from storage.artifact_store import ArtifactStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20
def test_timeline_records_survive_reopening_store(tmp_path) -> None:
store = ArtifactStore(tmp_path / "history")
timeline = Timeline(store)
timeline.append(
task_id="task-1",
scene={"screen": {"width": 1, "height": 1}, "elements": []},
prompt="goal",
tool_call={"action": "tap"},
result={"ok": True},
screenshot=PNG_10X20,
)
timeline.append(
task_id="task-1",
scene={"screen": {"width": 1, "height": 1}, "elements": []},
prompt="goal",
tool_call={"action": "input_text"},
result={"ok": True},
screenshot=PNG_10X20,
)
reopened = Timeline(ArtifactStore(tmp_path / "history"))
records = reopened.read("task-1")
assert [record["index"] for record in records] == [1, 2]
assert records[0]["screenshot_path"].endswith("001.png")
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import os
import pytest
from core.wda_driver import WDADriver, WDADriverConfig
@pytest.mark.integration
def test_wda_driver_screenshot_against_real_device() -> None:
server_url = os.getenv("APEX_WDA_SERVER_URL")
if not server_url:
pytest.skip("set APEX_WDA_SERVER_URL to run WDA hardware integration")
driver = WDADriver(
WDADriverConfig(
server_url=server_url,
udid=os.getenv("APEX_WDA_UDID") or None,
device_name=os.getenv("APEX_WDA_DEVICE_NAME") or None,
)
)
driver.connect()
try:
assert driver.screenshot()
finally:
driver.disconnect()
+2
View File
@@ -0,0 +1,2 @@
"""Semantic capability wrappers over the active device driver."""
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from core.device_manager import DEFAULT_MANAGER, DeviceManager
from core.driver import Driver
def get_driver(
device_id: str | None = None,
*,
manager: DeviceManager | None = None,
) -> Driver:
return (manager or DEFAULT_MANAGER).active_driver(device_id)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from core.device_manager import DeviceManager
from core.models import Scene
from tools._device import get_driver
from vision.ocr import PaddleOCREngine, run_ocr
from vision.scene_builder import build_scene, infer_png_size
from vision.ui_parser import parse_ui_tree
def describe_screen(
device_id: str | None = None,
*,
manager: DeviceManager | None = None,
ocr_engine: PaddleOCREngine | None = None,
) -> Scene:
driver = get_driver(device_id, manager=manager)
screenshot = driver.screenshot()
raw_tree = driver.tree()
width, height = infer_png_size(screenshot)
return build_scene(
screen_width=width,
screen_height=height,
ui_elements=parse_ui_tree(raw_tree),
ocr_elements=run_ocr(screenshot, engine=ocr_engine),
)
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from core.device_manager import DeviceManager
from core.models import Scene
from tools.describe_screen import describe_screen
from vision.icon_detector import find_icon as find_icon_in_scene
def find_icon(scene: Scene, name: str) -> dict[str, object]:
return find_icon_in_scene(scene, name).to_dict()
def find_icon_on_screen(
name: str,
*,
device_id: str | None = None,
manager: DeviceManager | None = None,
) -> dict[str, object]:
return find_icon(describe_screen(device_id, manager=manager), name)
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from core.device_manager import DeviceManager
from core.models import Scene, SceneElement
from tools.describe_screen import describe_screen
def find_text(
scene: Scene,
query: str,
*,
exact: bool = False,
case_sensitive: bool = False,
) -> dict[str, object]:
match = _find_element(scene, query, exact=exact, case_sensitive=case_sensitive)
if not match:
return {"found": False, "query": query, "reason": "not found"}
x, y = match.center
return {
"found": True,
"query": query,
"x": x,
"y": y,
"element": match.to_dict(),
}
def find_text_on_screen(
query: str,
*,
device_id: str | None = None,
manager: DeviceManager | None = None,
exact: bool = False,
case_sensitive: bool = False,
) -> dict[str, object]:
return find_text(
describe_screen(device_id, manager=manager),
query,
exact=exact,
case_sensitive=case_sensitive,
)
def _find_element(
scene: Scene,
query: str,
*,
exact: bool,
case_sensitive: bool,
) -> SceneElement | None:
needle = query if case_sensitive else query.casefold()
for element in scene.elements:
if not element.text:
continue
haystack = element.text if case_sensitive else element.text.casefold()
matched = haystack == needle if exact else needle in haystack
if matched:
return element
return None
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from core.device_manager import DeviceManager
from tools._device import get_driver
def input_text(
text: str,
*,
device_id: str | None = None,
manager: DeviceManager | None = None,
) -> dict[str, object]:
get_driver(device_id, manager=manager).input(text)
return {"ok": True, "action": "input_text", "text": text}
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from core.device_manager import DeviceManager
from tools._device import get_driver
def launch_app(
app_id: str,
*,
device_id: str | None = None,
manager: DeviceManager | None = None,
) -> dict[str, object]:
get_driver(device_id, manager=manager).launch(app_id)
return {"ok": True, "action": "launch_app", "app_id": app_id}
def terminate_app(
app_id: str,
*,
device_id: str | None = None,
manager: DeviceManager | None = None,
) -> dict[str, object]:
get_driver(device_id, manager=manager).terminate(app_id)
return {"ok": True, "action": "terminate_app", "app_id": app_id}
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from core.device_manager import DeviceManager
from tools._device import get_driver
def take_screenshot(
device_id: str | None = None,
*,
manager: DeviceManager | None = None,
) -> bytes:
return get_driver(device_id, manager=manager).screenshot()
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from core.device_manager import DeviceManager
from tools._device import get_driver
def swipe(
start_x: float,
start_y: float,
end_x: float,
end_y: float,
*,
duration_ms: int = 500,
device_id: str | None = None,
manager: DeviceManager | None = None,
) -> dict[str, object]:
get_driver(device_id, manager=manager).swipe(
start_x,
start_y,
end_x,
end_y,
duration_ms,
)
return {
"ok": True,
"action": "swipe",
"start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y},
"duration_ms": duration_ms,
}
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from core.device_manager import DeviceManager
from tools._device import get_driver
def tap(
x: float,
y: float,
*,
device_id: str | None = None,
manager: DeviceManager | None = None,
) -> dict[str, object]:
get_driver(device_id, manager=manager).tap(x, y)
return {"ok": True, "action": "tap", "x": x, "y": y}
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Any
from core.device_manager import DeviceManager
from tools._device import get_driver
from vision.ui_parser import parse_ui_tree
def get_raw_ui_tree(
device_id: str | None = None,
*,
manager: DeviceManager | None = None,
) -> Any:
return get_driver(device_id, manager=manager).tree()
def get_ui_tree(
device_id: str | None = None,
*,
manager: DeviceManager | None = None,
) -> list[dict[str, object]]:
return [
element.to_dict()
for element in parse_ui_tree(get_raw_ui_tree(device_id, manager=manager))
]
+2
View File
@@ -0,0 +1,2 @@
"""Screen perception helpers for OCR, UI tree parsing, and Scene fusion."""
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from dataclasses import dataclass
from core.models import Scene
@dataclass(frozen=True)
class IconSearchResult:
found: bool
name: str
x: float | None = None
y: float | None = None
reason: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"found": self.found,
"name": self.name,
"x": self.x,
"y": self.y,
"reason": self.reason,
}
def find_icon(scene: Scene, name: str) -> IconSearchResult:
query = name.casefold()
for element in scene.elements:
if element.type not in {"image", "icon", "button"}:
continue
label = (element.text or element.id).casefold()
if query in label:
x, y = element.center
return IconSearchResult(found=True, name=name, x=x, y=y)
return IconSearchResult(found=False, name=name, reason="not found")
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
import os
import tempfile
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from core.models import Bounds, SceneElement
@dataclass(frozen=True)
class OCRBox:
text: str
bounds: Bounds
confidence: float | None = None
def to_scene_element(self, element_id: str) -> SceneElement:
return SceneElement(
id=element_id,
type="text",
text=self.text,
bounds=self.bounds,
confidence=self.confidence,
source="ocr",
)
class PaddleOCREngine:
def __init__(self, **kwargs: Any) -> None:
self.kwargs = kwargs
self._engine: Any | None = None
def extract(self, image: bytes | str | Path) -> list[OCRBox]:
engine = self._load()
image_input, temp_path = _image_input(image)
try:
if hasattr(engine, "predict"):
raw = engine.predict(input=image_input)
else:
raw = engine.ocr(image_input, cls=True)
return parse_paddle_result(raw)
finally:
if temp_path:
temp_path.unlink(missing_ok=True)
def _load(self) -> Any:
if self._engine is None:
from paddleocr import PaddleOCR
self._engine = PaddleOCR(**self.kwargs)
return self._engine
def run_ocr(
image: bytes | str | Path,
*,
engine: PaddleOCREngine | None = None,
strict: bool = False,
) -> list[SceneElement]:
try:
boxes = (engine or PaddleOCREngine()).extract(image)
except ImportError:
if strict:
raise
return []
return [box.to_scene_element(f"ocr-{index:03d}") for index, box in enumerate(boxes)]
def parse_paddle_result(raw: Any) -> list[OCRBox]:
boxes: list[OCRBox] = []
for item in _flatten_pages(raw):
parsed = _parse_line(item)
if parsed:
boxes.append(parsed)
return boxes
def _image_input(image: bytes | str | Path) -> tuple[str, Path | None]:
if isinstance(image, bytes):
handle = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
try:
handle.write(image)
finally:
handle.close()
return handle.name, Path(handle.name)
return os.fspath(image), None
def _flatten_pages(raw: Any) -> Iterable[Any]:
if raw is None:
return []
if isinstance(raw, dict):
return _dict_lines(raw)
if isinstance(raw, list):
flattened: list[Any] = []
for page in raw:
if isinstance(page, dict):
flattened.extend(_dict_lines(page))
elif _looks_like_ocr_line(page):
flattened.append(page)
elif isinstance(page, list):
flattened.extend(page)
return flattened
json_attr = getattr(raw, "json", None)
if callable(json_attr):
return _flatten_pages(json_attr)
return []
def _dict_lines(data: dict[str, Any]) -> list[Any]:
payload = data.get("res") if isinstance(data.get("res"), dict) else data
texts = payload.get("rec_texts") or payload.get("texts") or []
scores = payload.get("rec_scores") or payload.get("scores") or []
boxes = (
payload.get("rec_boxes")
or payload.get("rec_polys")
or payload.get("dt_polys")
or []
)
return [
{
"text": text,
"confidence": scores[index] if index < len(scores) else None,
"points": boxes[index] if index < len(boxes) else None,
}
for index, text in enumerate(texts)
]
def _looks_like_ocr_line(value: Any) -> bool:
return isinstance(value, (list, tuple)) and len(value) >= 2
def _parse_line(line: Any) -> OCRBox | None:
if isinstance(line, dict):
text = line.get("text")
points = line.get("points") or line.get("box") or line.get("bounds")
confidence = line.get("confidence")
if not text or not points:
return None
return OCRBox(str(text), _bounds_from_points(points), _float_or_none(confidence))
if not _looks_like_ocr_line(line):
return None
points = line[0]
text_payload = line[1]
if isinstance(text_payload, (list, tuple)) and text_payload:
text = text_payload[0]
confidence = text_payload[1] if len(text_payload) > 1 else None
else:
text = text_payload
confidence = None
if not text:
return None
return OCRBox(str(text), _bounds_from_points(points), _float_or_none(confidence))
def _bounds_from_points(points: Any) -> Bounds:
if isinstance(points, dict):
return Bounds.from_dict(points)
if (
isinstance(points, (list, tuple))
and len(points) == 4
and all(isinstance(value, (int, float)) for value in points)
):
x1, y1, x2, y2 = [float(value) for value in points]
return Bounds(x1, y1, x2 - x1, y2 - y1)
xs: list[float] = []
ys: list[float] = []
for point in points:
if isinstance(point, dict):
xs.append(float(point["x"]))
ys.append(float(point["y"]))
else:
xs.append(float(point[0]))
ys.append(float(point[1]))
return Bounds(min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))
def _float_or_none(value: Any) -> float | None:
if value is None:
return None
return float(value)
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from struct import unpack
from core.models import Bounds, Scene, SceneElement
def build_scene(
*,
screen_width: int,
screen_height: int,
ui_elements: list[SceneElement] | None = None,
ocr_elements: list[SceneElement] | None = None,
iou_threshold: float = 0.5,
) -> Scene:
ui_elements = ui_elements or []
ocr_elements = ocr_elements or []
merged: list[SceneElement] = []
used_ocr: set[int] = set()
for ui_index, ui_element in enumerate(ui_elements):
best_index: int | None = None
best_iou = 0.0
for ocr_index, ocr_element in enumerate(ocr_elements):
if ocr_index in used_ocr:
continue
score = bbox_iou(ui_element.bounds, ocr_element.bounds)
if score > best_iou:
best_iou = score
best_index = ocr_index
element = _with_id(ui_element, f"ui-{ui_index:03d}")
if best_index is not None and best_iou >= iou_threshold:
used_ocr.add(best_index)
ocr_element = ocr_elements[best_index]
element = replace(
element,
text=element.text or ocr_element.text,
confidence=_best_confidence(element.confidence, ocr_element.confidence),
)
merged.append(element)
for ocr_index, ocr_element in enumerate(ocr_elements):
if ocr_index in used_ocr:
continue
merged.append(_with_id(ocr_element, f"ocr-{ocr_index:03d}"))
return Scene(width=screen_width, height=screen_height, elements=merged)
def bbox_iou(first: Bounds, second: Bounds) -> float:
x_left = max(first.x, second.x)
y_top = max(first.y, second.y)
x_right = min(first.right, second.right)
y_bottom = min(first.bottom, second.bottom)
if x_right <= x_left or y_bottom <= y_top:
return 0.0
intersection = (x_right - x_left) * (y_bottom - y_top)
first_area = first.width * first.height
second_area = second.width * second.height
union = first_area + second_area - intersection
if union <= 0:
return 0.0
return intersection / union
def infer_png_size(image: bytes | str | Path | None) -> tuple[int, int]:
if image is None:
return (0, 0)
data = Path(image).read_bytes() if not isinstance(image, bytes) else image
if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n":
width, height = unpack(">II", data[16:24])
return (int(width), int(height))
return (0, 0)
def _with_id(element: SceneElement, fallback_id: str) -> SceneElement:
if element.id:
return element
return replace(element, id=fallback_id)
def _best_confidence(first: float | None, second: float | None) -> float | None:
values = [value for value in (first, second) if value is not None]
if not values:
return None
return max(values)
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from typing import Any
from core.models import Bounds, SceneElement
_ANDROID_BOUNDS = re.compile(r"\[(?P<x1>-?\d+),(?P<y1>-?\d+)\]\[(?P<x2>-?\d+),(?P<y2>-?\d+)\]")
def parse_ui_tree(raw_tree: Any) -> list[SceneElement]:
if raw_tree is None:
return []
if isinstance(raw_tree, str):
return _parse_xml(raw_tree)
if isinstance(raw_tree, dict):
elements: list[SceneElement] = []
_parse_dict_node(raw_tree, elements)
return _with_stable_ids(elements, "ui")
if isinstance(raw_tree, list):
elements = []
for node in raw_tree:
if isinstance(node, dict):
_parse_dict_node(node, elements)
return _with_stable_ids(elements, "ui")
return []
def _parse_xml(raw_tree: str) -> list[SceneElement]:
if not raw_tree.strip():
return []
root = ET.fromstring(raw_tree)
elements = []
for node in root.iter():
bounds = _bounds_from_attrs(node.attrib)
if bounds is None or bounds.width <= 0 or bounds.height <= 0:
continue
elements.append(
SceneElement(
id="",
type=_normalize_type(_local_name(node.tag), node.attrib),
text=_text_from_attrs(node.attrib),
bounds=bounds,
confidence=1.0,
source="ui",
)
)
return _with_stable_ids(elements, "ui")
def _parse_dict_node(node: dict[str, Any], elements: list[SceneElement]) -> None:
attrs = dict(node)
children = attrs.pop("children", None) or attrs.pop("nodes", None) or []
bounds = _bounds_from_attrs(attrs)
if bounds and bounds.width > 0 and bounds.height > 0:
elements.append(
SceneElement(
id="",
type=_normalize_type(
str(attrs.get("type") or attrs.get("class") or "unknown"),
attrs,
),
text=_text_from_attrs(attrs),
bounds=bounds,
confidence=1.0,
source="ui",
)
)
for child in children:
if isinstance(child, dict):
_parse_dict_node(child, elements)
def _with_stable_ids(elements: list[SceneElement], prefix: str) -> list[SceneElement]:
return [
SceneElement(
id=element.id or f"{prefix}-{index:03d}",
type=element.type,
text=element.text,
bounds=element.bounds,
confidence=element.confidence,
source=element.source,
)
for index, element in enumerate(elements)
]
def _bounds_from_attrs(attrs: dict[str, Any]) -> Bounds | None:
if all(key in attrs for key in ("x", "y", "width", "height")):
return Bounds(
float(attrs["x"]),
float(attrs["y"]),
float(attrs["width"]),
float(attrs["height"]),
)
if all(key in attrs for key in ("left", "top", "right", "bottom")):
left = float(attrs["left"])
top = float(attrs["top"])
right = float(attrs["right"])
bottom = float(attrs["bottom"])
return Bounds(left, top, right - left, bottom - top)
raw_bounds = attrs.get("bounds") or attrs.get("rect")
if isinstance(raw_bounds, dict):
return _bounds_from_attrs(raw_bounds)
if isinstance(raw_bounds, str):
match = _ANDROID_BOUNDS.fullmatch(raw_bounds.strip())
if match:
x1 = float(match.group("x1"))
y1 = float(match.group("y1"))
x2 = float(match.group("x2"))
y2 = float(match.group("y2"))
return Bounds(x1, y1, x2 - x1, y2 - y1)
return None
def _text_from_attrs(attrs: dict[str, Any]) -> str | None:
for key in ("label", "name", "text", "value", "placeholder"):
value = attrs.get(key)
if value not in (None, ""):
return str(value)
return None
def _normalize_type(raw_type: str, attrs: dict[str, Any]) -> str:
candidate = (
attrs.get("role")
or attrs.get("type")
or attrs.get("class")
or attrs.get("className")
or raw_type
)
normalized = str(candidate).split(".")[-1].replace("XCUIElementType", "").lower()
if "button" in normalized:
return "button"
if "textfield" in normalized or "textarea" in normalized or "input" in normalized:
return "input"
if "statictext" in normalized or normalized in {"text", "label"}:
return "text"
if "image" in normalized or "icon" in normalized:
return "image"
if "cell" in normalized or "row" in normalized:
return "cell"
if "window" in normalized:
return "window"
return normalized or "unknown"
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]