feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

@@ -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
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Public API for the lightweight Artemis remote client."""
from artemis_client.client import ArtemisClient
from artemis_client.errors import (
ApiError,
ArtemisClientError,
AuthenticationError,
ConflictError,
NetworkError,
NotFoundError,
ProtocolError,
TaskRejectedError,
TaskTimeoutError,
)
from artemis_client.models import Capabilities, Device, TaskHandle, TaskResult
from artemis_client.transport import JsonTransport
__version__ = "0.1.0"
__all__ = [
"ApiError",
"ArtemisClient",
"ArtemisClientError",
"AuthenticationError",
"Capabilities",
"ConflictError",
"Device",
"JsonTransport",
"NetworkError",
"NotFoundError",
"ProtocolError",
"TaskHandle",
"TaskRejectedError",
"TaskResult",
"TaskTimeoutError",
"__version__",
]
@@ -0,0 +1,365 @@
# 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
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Asynchronous, remote-only Artemis client."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from collections.abc import Mapping
from typing import Any, Literal, Protocol
from artemis_client.errors import NotFoundError, ProtocolError, TaskRejectedError, TaskTimeoutError
from artemis_client.models import Capabilities, Device, TaskHandle, TaskResult
from artemis_client.transport import JsonTransport
class _Transport(Protocol):
def request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any: ...
class ArtemisClient:
"""Thin client for an Artemis daemon running on another host.
This class never imports ADB, an Artemis agent, an LLM provider, or a
device driver. All execution is delegated to ``base_url``.
"""
_LEGACY_FEATURES = frozenset(
{
"tasks.submit",
"tasks.get",
"tasks.stop",
"devices.list",
"system.readiness",
}
)
def __init__(
self,
base_url: str | None = None,
*,
token: str | None = None,
request_timeout: float = 30.0,
poll_interval: float = 1.0,
headers: Mapping[str, str] | None = None,
transport: _Transport | None = None,
device_id: str | None = None,
device_serial: str | None = None,
default_profile: Literal["flash", "pro"] = "flash",
concurrency_mode: str = "per_device",
max_concurrency: int | None = None,
standalone: bool = False,
) -> None:
if poll_interval <= 0:
raise ValueError("poll_interval must be greater than zero")
resolved_base_url = base_url or os.environ.get("ARTEMIS_BASE_URL")
if not resolved_base_url:
daemon_host = os.environ.get("ARTEMIS_DAEMON_HOST", "127.0.0.1")
daemon_port = os.environ.get("ARTEMIS_DAEMON_PORT", "8000")
resolved_base_url = f"http://{daemon_host}:{daemon_port}"
self.base_url = resolved_base_url.rstrip("/")
self.poll_interval = float(poll_interval)
self._device_serial = device_serial or device_id
self.default_profile = default_profile
self.concurrency_mode = str(concurrency_mode).strip().lower()
self.max_concurrency = max_concurrency
self.standalone = standalone
self._transport = transport or JsonTransport(
resolved_base_url,
token=token,
timeout=request_timeout,
headers=headers,
)
@property
def device_id(self) -> str | None:
"""Default remote device serial (legacy-compatible alias)."""
return self._device_serial
@device_id.setter
def device_id(self, value: str | None) -> None:
self._device_serial = value
@property
def device_serial(self) -> str | None:
return self._device_serial
@device_serial.setter
def device_serial(self, value: str | None) -> None:
self._device_serial = value
def set_device(self, device_serial: str) -> ArtemisClient:
"""Set the default remote device and return this client."""
self._device_serial = device_serial
return self
def set_concurrency_mode(self, mode: str) -> ArtemisClient:
"""Retain the legacy setting for callers migrating to server-side queues."""
self.concurrency_mode = str(mode).strip().lower()
return self
async def health(self) -> Mapping[str, Any]:
"""Perform a fast liveness check against the scheduler status API."""
payload = await self._request("GET", "/api/status")
return self._mapping(payload, endpoint="/api/status")
async def readiness(self) -> Mapping[str, Any]:
"""Run the host's full device and toolchain readiness diagnostics.
This endpoint can take substantially longer than :meth:`health`; set
a larger ``request_timeout`` when constructing the client if needed.
"""
payload = await self._request("GET", "/api/system/readiness")
return self._mapping(payload, endpoint="/api/system/readiness")
async def capabilities(self) -> Capabilities:
"""Discover server features, with a baseline for today's legacy API."""
try:
payload = await self._request("GET", "/api/v1/capabilities")
except NotFoundError:
return Capabilities(api_version="legacy", features=self._LEGACY_FEATURES)
return Capabilities.from_payload(self._mapping(payload, endpoint="/api/v1/capabilities"))
async def list_devices(self) -> tuple[Device, ...]:
"""List Android devices visible to the remote Artemis host."""
payload = await self._request("GET", "/api/devices")
if isinstance(payload, Mapping):
raw_devices = payload.get("devices", [])
else:
raw_devices = payload
if not isinstance(raw_devices, list):
raise ProtocolError("/api/devices response must contain a device list")
devices: list[Device] = []
for item in raw_devices:
if not isinstance(item, Mapping):
raise ProtocolError("/api/devices contained a non-object device entry")
devices.append(Device.from_payload(item))
return tuple(devices)
async def submit(
self,
goal: str,
*,
profile: Literal["flash", "pro"] | None = None,
device_serial: str | None = None,
expected_output: str | None = None,
enable_outputter: bool | None = None,
locked_app_package: str | None = None,
app_path: str | None = None,
conversation_id: str | None = None,
task_id: str | None = None,
options: Mapping[str, Any] | None = None,
) -> TaskHandle:
"""Submit one task and return immediately after scheduler admission.
``task_id`` is generated client-side and sent as the legacy
``session_id``. Reusing it makes submission retries idempotent.
Experimental, forward-compatible fields belong in ``options``.
"""
normalized_goal = goal.strip()
if not normalized_goal:
raise ValueError("goal must not be empty")
if task_id is None:
resolved_task_id = str(uuid.uuid4())
else:
try:
resolved_task_id = str(uuid.UUID(task_id))
except (AttributeError, TypeError, ValueError) as exc:
raise ValueError("task_id must be a valid UUID string") from exc
resolved_profile = profile or self.default_profile
resolved_device = device_serial or self.device_serial
payload: dict[str, Any] = {
"goal": normalized_goal,
"profile": resolved_profile,
"session_id": resolved_task_id,
"ingress": "python_sdk",
}
optional_values = {
"device_serial": resolved_device,
"expected_output": expected_output,
"enable_outputter": enable_outputter,
"locked_app_package": locked_app_package,
"app_path": app_path,
"conversation_id": conversation_id,
"options": dict(options) if options is not None else None,
}
payload.update({key: value for key, value in optional_values.items() if value is not None})
response = self._mapping(
await self._request("POST", "/api/run", json_body=payload),
endpoint="/api/run",
)
status = str(response.get("status") or "unknown").lower()
tasks = response.get("tasks")
if status == "rejected" or (isinstance(tasks, list) and not tasks):
detail = str(response.get("error") or "Artemis host rejected the task")
raise TaskRejectedError(detail)
if not isinstance(tasks, list) or not tasks or not isinstance(tasks[0], Mapping):
raise ProtocolError("/api/run response did not contain an admitted task")
task_payload = dict(tasks[0])
task_payload.setdefault("session_id", resolved_task_id)
task_payload.setdefault("status", status)
return TaskHandle.from_payload(task_payload)
async def get_task(self, task_id: str) -> TaskResult:
"""Get a task from session storage or the live scheduler queue."""
try:
payload = await self._request("GET", f"/api/sessions/{task_id}")
return TaskResult.from_payload(
self._mapping(payload, endpoint=f"/api/sessions/{task_id}"),
task_id=task_id,
)
except NotFoundError:
status_payload = self._mapping(
await self._request("GET", "/api/status"),
endpoint="/api/status",
)
live_task = self._find_live_task(status_payload, task_id)
if live_task is None:
return TaskResult(task_id=task_id, status="launching")
return TaskResult.from_payload(live_task, task_id=task_id)
async def wait_for_task(
self,
task_id: str,
*,
timeout: float = 1800.0,
poll_interval: float | None = None,
) -> TaskResult:
"""Wait until a task reaches a terminal state."""
if timeout <= 0:
raise ValueError("timeout must be greater than zero")
interval = self.poll_interval if poll_interval is None else float(poll_interval)
if interval <= 0:
raise ValueError("poll_interval must be greater than zero")
started = time.monotonic()
while True:
result = await self.get_task(task_id)
if result.done:
return result
elapsed = time.monotonic() - started
if elapsed >= timeout:
raise TaskTimeoutError(task_id, timeout)
await asyncio.sleep(min(interval, max(0.0, timeout - elapsed)))
async def run(
self,
goal: str,
*,
profile: Literal["flash", "pro"] | None = None,
device_serial: str | None = None,
expected_output: str | None = None,
enable_outputter: bool | None = None,
locked_app_package: str | None = None,
app_path: str | None = None,
conversation_id: str | None = None,
task_id: str | None = None,
options: Mapping[str, Any] | None = None,
timeout: float = 1800.0,
poll_interval: float | None = None,
) -> TaskResult:
"""Submit a task and wait for its terminal result."""
handle = await self.submit(
goal,
profile=profile,
device_serial=device_serial,
expected_output=expected_output,
enable_outputter=enable_outputter,
locked_app_package=locked_app_package,
app_path=app_path,
conversation_id=conversation_id,
task_id=task_id,
options=options,
)
return await self.wait_for_task(
handle.task_id,
timeout=timeout,
poll_interval=poll_interval,
)
async def run_task(self, task: Any, **overrides: Any) -> TaskResult:
"""Run a task-like object while delegating all execution remotely."""
goal = getattr(task, "goal", None)
if not isinstance(goal, str) or not goal.strip():
raise ValueError("task must expose a non-empty goal attribute")
values: dict[str, Any] = {
"profile": getattr(task, "profile", None),
"device_serial": getattr(task, "device_serial", None)
or getattr(task, "device_id", None),
"locked_app_package": getattr(task, "locked_package", None),
}
values.update(overrides)
return await self.run(goal, **values)
async def stop(self, task_id: str) -> bool:
"""Request cancellation of one remote task."""
payload = self._mapping(
await self._request(
"POST",
"/api/stop",
json_body={"session_id": task_id},
),
endpoint="/api/stop",
)
return str(payload.get("status") or "").lower() == "stopped"
async def _request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any:
return await asyncio.to_thread(
self._transport.request,
method,
path,
json_body=json_body,
)
@staticmethod
def _mapping(payload: Any, *, endpoint: str) -> Mapping[str, Any]:
if not isinstance(payload, Mapping):
raise ProtocolError(f"{endpoint} response must be a JSON object")
return payload
@staticmethod
def _find_live_task(
scheduler: Mapping[str, Any],
task_id: str,
) -> Mapping[str, Any] | None:
for collection_name in ("queue", "active_tasks"):
collection = scheduler.get(collection_name) or []
if not isinstance(collection, list):
continue
for item in collection:
if not isinstance(item, Mapping):
continue
item_id = item.get("task_id") or item.get("session_id")
if str(item_id) == task_id:
return item
active_id = scheduler.get("task_id") or scheduler.get("session_id")
if active_id is not None and str(active_id) == task_id:
synthesized = dict(scheduler)
synthesized.setdefault("session_id", task_id)
return synthesized
return None
@@ -0,0 +1,66 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Exceptions raised by the Artemis remote client."""
from __future__ import annotations
from typing import Any
class ArtemisClientError(Exception):
"""Base class for all errors raised by this package."""
class NetworkError(ArtemisClientError):
"""The Artemis host could not be reached or the connection failed."""
class ProtocolError(ArtemisClientError):
"""The server returned a response that does not satisfy the API contract."""
class ApiError(ArtemisClientError):
"""The Artemis host returned an unsuccessful HTTP response."""
def __init__(
self,
status_code: int,
detail: str,
*,
payload: Any = None,
) -> None:
self.status_code = status_code
self.detail = detail
self.payload = payload
super().__init__(f"Artemis API returned HTTP {status_code}: {detail}")
class AuthenticationError(ApiError):
"""Authentication or authorization failed."""
class NotFoundError(ApiError):
"""The requested Artemis resource does not exist."""
class ConflictError(ApiError):
"""The request conflicts with the current server or device state."""
class TaskRejectedError(ArtemisClientError):
"""The server accepted the request but rejected task admission."""
class TaskTimeoutError(ArtemisClientError):
"""Waiting for an Artemis task exceeded the caller's deadline."""
def __init__(self, task_id: str, timeout: float) -> None:
self.task_id = task_id
self.timeout = timeout
super().__init__(f"Task {task_id} did not finish within {timeout:g} seconds")
@@ -0,0 +1,212 @@
# 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
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Dependency-free data models for the Artemis remote API."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Mapping
TERMINAL_TASK_STATUSES = frozenset(
{"completed", "success", "failed", "cancelled", "canceled", "rejected"}
)
SUCCESS_TASK_STATUSES = frozenset({"completed", "success"})
def _string(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _integer(value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _device_from_payload(payload: Mapping[str, Any]) -> str | None:
direct = _string(payload.get("device_serial") or payload.get("device_id"))
if direct:
return direct
device_info = payload.get("device_info")
if isinstance(device_info, str):
try:
device_info = json.loads(device_info)
except (TypeError, ValueError, json.JSONDecodeError):
return None
if isinstance(device_info, Mapping):
return _string(device_info.get("device_serial") or device_info.get("device_id"))
return None
@dataclass(frozen=True, slots=True)
class TaskHandle:
"""A task accepted by the remote Artemis scheduler."""
task_id: str
status: str
device_serial: str | None = None
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
@property
def session_id(self) -> str:
"""Compatibility alias for servers that call a task a session."""
return self.task_id
@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> TaskHandle:
task_id = _string(payload.get("task_id") or payload.get("session_id") or payload.get("id"))
if not task_id:
from artemis_client.errors import ProtocolError
raise ProtocolError("Task admission response did not contain a task/session ID")
return cls(
task_id=task_id,
status=(_string(payload.get("status")) or "queued").lower(),
device_serial=_device_from_payload(payload),
raw=dict(payload),
)
@dataclass(frozen=True, slots=True)
class TaskResult:
"""Current or terminal state of a remote Artemis task."""
task_id: str
status: str
goal: str | None = None
profile: str | None = None
device_serial: str | None = None
output: Any = None
error: str | None = None
turns: int | None = None
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
@property
def session_id(self) -> str:
return self.task_id
@property
def trace_id(self) -> str:
return self.task_id
@property
def done(self) -> bool:
return self.status in TERMINAL_TASK_STATUSES
@property
def succeeded(self) -> bool:
return self.status in SUCCESS_TASK_STATUSES
@classmethod
def from_payload(
cls,
payload: Mapping[str, Any],
*,
task_id: str | None = None,
) -> TaskResult:
resolved_id = _string(
payload.get("task_id")
or payload.get("session_id")
or payload.get("trace_id")
or payload.get("id")
or task_id
)
if not resolved_id:
from artemis_client.errors import ProtocolError
raise ProtocolError("Task response did not contain a task/session ID")
output = payload.get("output")
if output is None:
output = payload.get("result")
if output is None:
output = payload.get("summary")
return cls(
task_id=resolved_id,
status=(_string(payload.get("status")) or "unknown").lower(),
goal=_string(payload.get("goal") or payload.get("initial_goal")),
profile=_string(payload.get("profile")),
device_serial=_device_from_payload(payload),
output=output,
error=_string(payload.get("error") or payload.get("error_message")),
turns=_integer(payload.get("turns") or payload.get("current_turn")),
raw=dict(payload),
)
@dataclass(frozen=True, slots=True)
class Device:
"""A device reported by the remote Artemis host."""
serial: str
state: str
model: str | None = None
product: str | None = None
busy: bool = False
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> Device:
serial = _string(
payload.get("serial") or payload.get("device_serial") or payload.get("device_id")
)
if not serial:
from artemis_client.errors import ProtocolError
raise ProtocolError("Device response did not contain a serial number")
state = (_string(payload.get("state") or payload.get("status")) or "unknown").lower()
busy_value = payload.get("busy")
if busy_value is None:
busy_value = payload.get("is_busy")
return cls(
serial=serial,
state=state,
model=_string(payload.get("model")),
product=_string(payload.get("product")),
busy=bool(busy_value) or state in {"busy", "running", "locked"},
raw=dict(payload),
)
@dataclass(frozen=True, slots=True)
class Capabilities:
"""Features advertised by an Artemis host."""
api_version: str
features: frozenset[str]
server_version: str | None = None
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
def supports(self, feature: str) -> bool:
return feature in self.features
@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> Capabilities:
raw_features = payload.get("features") or []
if isinstance(raw_features, Mapping):
features = frozenset(str(key) for key, enabled in raw_features.items() if enabled)
elif isinstance(raw_features, (list, tuple, set, frozenset)):
features = frozenset(str(item) for item in raw_features)
else:
features = frozenset()
return cls(
api_version=_string(payload.get("api_version")) or "unknown",
server_version=_string(payload.get("server_version") or payload.get("version")),
features=features,
raw=dict(payload),
)
@@ -0,0 +1 @@
@@ -0,0 +1,134 @@
# 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
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Small JSON-over-HTTP transport implemented with the Python standard library."""
from __future__ import annotations
import json
import ssl
import urllib.error
import urllib.request
from typing import Any, Mapping
from urllib.parse import urlsplit
from artemis_client.errors import (
ApiError,
AuthenticationError,
ConflictError,
NetworkError,
NotFoundError,
ProtocolError,
)
class JsonTransport:
"""Synchronous transport used internally by the async client.
Calls are moved to worker threads by :class:`ArtemisClient`, keeping the
public API asynchronous without adding an HTTP dependency.
"""
def __init__(
self,
base_url: str,
*,
token: str | None = None,
timeout: float = 30.0,
headers: Mapping[str, str] | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> None:
normalized = base_url.rstrip("/")
parsed = urlsplit(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("base_url must be an absolute http:// or https:// URL")
if timeout <= 0:
raise ValueError("timeout must be greater than zero")
self.base_url = normalized
self.timeout = float(timeout)
self.ssl_context = ssl_context
self.headers = {
"Accept": "application/json",
"User-Agent": "artemis-client/0.1",
**dict(headers or {}),
}
if token:
self.headers["Authorization"] = f"Bearer {token}"
def request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any:
if not path.startswith("/"):
path = f"/{path}"
url = f"{self.base_url}{path}"
body = None
headers = dict(self.headers)
if json_body is not None:
body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(
url,
data=body,
headers=headers,
method=method.upper(),
)
try:
with urllib.request.urlopen(
request,
timeout=self.timeout,
context=self.ssl_context,
) as response:
data = response.read()
if not data:
return None
try:
return json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ProtocolError(f"Artemis host returned invalid JSON from {path}") from exc
except urllib.error.HTTPError as exc:
payload = self._decode_error_payload(exc)
detail = self._error_detail(payload, exc.reason)
error_type = {
401: AuthenticationError,
403: AuthenticationError,
404: NotFoundError,
409: ConflictError,
}.get(exc.code, ApiError)
raise error_type(exc.code, detail, payload=payload) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
reason = getattr(exc, "reason", exc)
raise NetworkError(f"Could not reach Artemis host at {url}: {reason}") from exc
@staticmethod
def _decode_error_payload(error: urllib.error.HTTPError) -> Any:
try:
data = error.read()
except OSError:
return None
if not data:
return None
try:
return json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return data.decode("utf-8", errors="replace")
@staticmethod
def _error_detail(payload: Any, fallback: Any) -> str:
if isinstance(payload, Mapping):
detail = payload.get("detail") or payload.get("error") or payload.get("message")
if detail is not None:
return str(detail)
if isinstance(payload, str) and payload.strip():
return payload.strip()
return str(fallback or "request failed")