feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -16,6 +16,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
|
||||
@@ -367,9 +367,9 @@ def run_command(
|
||||
def on_status(sess_info):
|
||||
st = sess_info.get("status")
|
||||
if st == "queued":
|
||||
console.print(f"[yellow]⏳ Task queued in scheduler, waiting for device...[/yellow]")
|
||||
console.print("[yellow]⏳ Task queued in scheduler, waiting for device...[/yellow]")
|
||||
elif st == "running":
|
||||
console.print(f"[green]▶ Task executing on mobile device...[/green]")
|
||||
console.print("[green]▶ Task executing on mobile device...[/green]")
|
||||
|
||||
try:
|
||||
final_res = wait_for_daemon_task(
|
||||
@@ -380,7 +380,7 @@ def run_command(
|
||||
)
|
||||
final_st = final_res.get("status")
|
||||
if final_st in ("completed", "success"):
|
||||
console.print(f"\n[bold green]✅ Task completed successfully![/bold green]")
|
||||
console.print("\n[bold green]✅ Task completed successfully![/bold green]")
|
||||
return
|
||||
else:
|
||||
err = final_res.get("error") or final_res.get("explanation") or ""
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from typing import Annotated
|
||||
import webbrowser
|
||||
|
||||
@@ -25,8 +26,8 @@ from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
import typer
|
||||
|
||||
from artemis.interfaces.cli.commands.ui import ui_command
|
||||
from artemis.runtime.daemon_client import spawn_daemon
|
||||
from artemis.interfaces.cli.commands.ui import ensure_showcase_built, ui_command
|
||||
from artemis.runtime.daemon_client import daemon_log_path, is_daemon_running, spawn_daemon
|
||||
from artemis.runtime.server_lifecycle import (
|
||||
find_server_pids,
|
||||
get_server_status,
|
||||
@@ -55,8 +56,15 @@ def _format_uptime(seconds: float | None) -> str:
|
||||
def restart_command(
|
||||
host: Annotated[
|
||||
str,
|
||||
typer.Option("--host", "-H", help="Bind host address for the UI server."),
|
||||
] = "0.0.0.0",
|
||||
typer.Option(
|
||||
"--host",
|
||||
"-H",
|
||||
help=(
|
||||
"Bind host address for the UI server. Defaults to loopback; for remote "
|
||||
"access prefer a Tailscale/SSH tunnel over a wide bind."
|
||||
),
|
||||
),
|
||||
] = "127.0.0.1",
|
||||
port: Annotated[
|
||||
int,
|
||||
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
|
||||
@@ -86,14 +94,23 @@ def restart_command(
|
||||
daemon: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--daemon",
|
||||
"-d",
|
||||
help="Run the new server as a detached background process instead of foreground.",
|
||||
"--daemon/--foreground",
|
||||
"-d/-F",
|
||||
help=(
|
||||
"Run the new server as a detached background daemon (default) or attached "
|
||||
"to the current terminal. The daemon is launched via 'python -m' so the "
|
||||
"console-script executable is never pinned by a long-lived server "
|
||||
"(which would block 'uv sync' reinstalls on Windows)."
|
||||
),
|
||||
),
|
||||
] = False,
|
||||
] = True,
|
||||
) -> None:
|
||||
"""Restart the Artemis Web UI server, terminating any existing instance on the port."""
|
||||
console.print()
|
||||
if reload and daemon:
|
||||
# uvicorn auto-reload requires an attached terminal session.
|
||||
console.print(" [dim]ℹ --reload requested: switching to foreground mode.[/dim]")
|
||||
daemon = False
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold cyan]🔄 Artemis Server Restart[/bold cyan]\n"
|
||||
@@ -111,7 +128,7 @@ def restart_command(
|
||||
else:
|
||||
console.print(f" [dim]ℹ No active Artemis server found on port {port}.[/dim]")
|
||||
|
||||
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
|
||||
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0)
|
||||
if stopped_pids:
|
||||
console.print(f" [green]✓ {msg}[/green]\n")
|
||||
elif not success:
|
||||
@@ -122,23 +139,54 @@ def restart_command(
|
||||
# 2. Launch fresh server instance
|
||||
local_url = f"http://localhost:{port}"
|
||||
if daemon:
|
||||
console.print(f" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]")
|
||||
ensure_showcase_built(console)
|
||||
console.print(" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]")
|
||||
proc = spawn_daemon(host=host, port=port)
|
||||
if proc:
|
||||
if not proc:
|
||||
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Wait until the daemon answers HTTP before declaring success (cold
|
||||
# start includes DB/trace initialization and can take tens of seconds).
|
||||
ready = False
|
||||
deadline = time.monotonic() + 60.0
|
||||
while time.monotonic() < deadline:
|
||||
if is_daemon_running(host="127.0.0.1", port=port, timeout=0.5):
|
||||
ready = True
|
||||
break
|
||||
if proc.poll() is not None:
|
||||
# Parent exited: either it crashed, or it handed the socket off
|
||||
# to a child worker. Give the worker a short grace period.
|
||||
grace = time.monotonic() + 5.0
|
||||
while time.monotonic() < grace:
|
||||
if is_daemon_running(host="127.0.0.1", port=port, timeout=0.5):
|
||||
ready = True
|
||||
break
|
||||
time.sleep(0.3)
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
log_path = daemon_log_path(port)
|
||||
if ready:
|
||||
console.print(
|
||||
f" [bold green]✓ Artemis server running in background (PID: {proc.pid})[/bold green]\n"
|
||||
f" 🌐 [cyan]{local_url}[/cyan]\n"
|
||||
f" 🛠️ [cyan]{local_url}/admin[/cyan]\n"
|
||||
f" 📄 [dim]Logs: {log_path}[/dim]\n"
|
||||
f" [dim]💡 Stop with [bold]artemis stop[/bold]; run in-terminal with [bold]artemis restart --foreground[/bold].[/dim]\n"
|
||||
)
|
||||
if open_browser:
|
||||
webbrowser.open(local_url)
|
||||
else:
|
||||
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
|
||||
console.print(
|
||||
f" [red]❌ Background daemon did not become ready on port {port}.[/red]\n"
|
||||
f" 📄 [dim]Check logs: {log_path}[/dim]\n"
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
return
|
||||
|
||||
# Default foreground mode
|
||||
console.print(f" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
|
||||
# Foreground mode (--foreground / --reload)
|
||||
console.print(" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
|
||||
ui_command(host=host, port=port, open_browser=open_browser, reload=reload)
|
||||
|
||||
|
||||
@@ -165,7 +213,7 @@ def stop_command(
|
||||
else:
|
||||
console.print(f" [dim]Scanning port {port}...[/dim]")
|
||||
|
||||
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
|
||||
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0)
|
||||
if stopped_pids:
|
||||
console.print(
|
||||
Panel(
|
||||
|
||||
@@ -29,6 +29,33 @@ from rich.panel import Panel
|
||||
import typer
|
||||
|
||||
|
||||
def running_via_console_script_shim() -> bool:
|
||||
"""Return True when this CLI was launched through a console-script ``.exe`` shim.
|
||||
|
||||
On Windows, ``uv``/pip install entry points as trampoline executables
|
||||
(``Scripts/artemis.exe``) that stay resident for the lifetime of the CLI
|
||||
process and hold a lock on their own file. A long-running foreground server
|
||||
started through the shim therefore blocks ``uv sync`` from reinstalling the
|
||||
package ("os error 32") until the server exits. Launching via
|
||||
``python -m artemis`` avoids the shim entirely.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
argv0 = Path(sys.argv[0] or "")
|
||||
return argv0.suffix.lower() == ".exe" and argv0.name.lower() != "python.exe"
|
||||
|
||||
|
||||
def warn_if_console_script_shim(console: Console) -> None:
|
||||
"""Print a hint when a foreground server run will pin the console-script exe."""
|
||||
if running_via_console_script_shim():
|
||||
console.print(
|
||||
" [dim]⚠ Running in foreground via the [bold]artemis.exe[/bold] launcher: package "
|
||||
"syncs (uv sync / uv run) cannot replace it while this server is running.\n"
|
||||
" Prefer [bold]artemis restart[/bold] (background daemon) or launch with "
|
||||
"[bold]uv run python -m artemis ui[/bold] to avoid this.[/dim]\n"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_npm_executable(platform_name: str | None = None) -> str | None:
|
||||
"""Return a directly executable npm path for the current platform."""
|
||||
platform_name = platform_name or sys.platform
|
||||
@@ -57,6 +84,25 @@ def _showcase_build_required(showcase_dir: Path) -> bool:
|
||||
return any(path.exists() and path.stat().st_mtime > build_time for path in input_paths)
|
||||
|
||||
|
||||
def ensure_showcase_built(console: Console) -> None:
|
||||
"""Rebuild the Angular Showcase UI when its sources are newer than the dist build."""
|
||||
from artemis.config.paths import ROOT_DIR
|
||||
|
||||
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
|
||||
if not _showcase_build_required(showcase_dir):
|
||||
return
|
||||
npm_executable = _resolve_npm_executable()
|
||||
if not npm_executable:
|
||||
return
|
||||
console.print(" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]")
|
||||
try:
|
||||
subprocess.run([npm_executable, "install", "--silent"], cwd=showcase_dir, check=True)
|
||||
subprocess.run([npm_executable, "run", "build"], cwd=showcase_dir, check=True)
|
||||
console.print(" [green]✓ Showcase UI built successfully.[/green]\n")
|
||||
except Exception as e:
|
||||
console.print(f" [red]⚠ Failed to auto-build Showcase UI: {e}[/red]\n")
|
||||
|
||||
|
||||
def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float = 15.0) -> None:
|
||||
"""Poll the UI server until it responds, then launch the user's default browser."""
|
||||
start = time.time()
|
||||
@@ -75,8 +121,15 @@ def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float
|
||||
def ui_command(
|
||||
host: Annotated[
|
||||
str,
|
||||
typer.Option("--host", "-h", help="Bind host address for the UI server."),
|
||||
] = "0.0.0.0",
|
||||
typer.Option(
|
||||
"--host",
|
||||
"-h",
|
||||
help=(
|
||||
"Bind host address for the UI server. Defaults to loopback; for remote "
|
||||
"access prefer a Tailscale/SSH tunnel over a wide bind."
|
||||
),
|
||||
),
|
||||
] = "127.0.0.1",
|
||||
port: Annotated[
|
||||
int,
|
||||
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
|
||||
@@ -116,7 +169,7 @@ def ui_command(
|
||||
|
||||
if restart:
|
||||
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
|
||||
stop_server(port=port, timeout=4.0)
|
||||
stop_server(port=port, timeout=12.0)
|
||||
elif is_port_in_use(port):
|
||||
pids = find_server_pids(port)
|
||||
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
|
||||
@@ -132,7 +185,7 @@ def ui_command(
|
||||
choice = "1"
|
||||
if choice == "2":
|
||||
console.print(f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]")
|
||||
stop_server(port=port, timeout=4.0)
|
||||
stop_server(port=port, timeout=12.0)
|
||||
elif choice == "3":
|
||||
return
|
||||
else:
|
||||
@@ -178,21 +231,8 @@ def ui_command(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
|
||||
if _showcase_build_required(showcase_dir):
|
||||
npm_executable = _resolve_npm_executable()
|
||||
if npm_executable:
|
||||
console.print(
|
||||
" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]"
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
[npm_executable, "install", "--silent"], cwd=showcase_dir, check=True
|
||||
)
|
||||
subprocess.run([npm_executable, "run", "build"], cwd=showcase_dir, check=True)
|
||||
console.print(" [green]✓ Showcase UI built successfully.[/green]\n")
|
||||
except Exception as e:
|
||||
console.print(f" [red]⚠ Failed to auto-build Showcase UI: {e}[/red]\n")
|
||||
ensure_showcase_built(console)
|
||||
warn_if_console_script_shim(console)
|
||||
|
||||
stop_event = threading.Event()
|
||||
if open_browser:
|
||||
|
||||
@@ -15,13 +15,17 @@
|
||||
"""Python SDK for ARTEMIS."""
|
||||
|
||||
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
|
||||
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
|
||||
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task
|
||||
from artemis_client import Capabilities, Device, TaskHandle, TaskResult
|
||||
|
||||
__all__ = [
|
||||
"ArtemisClient",
|
||||
"Capabilities",
|
||||
"ConcurrencyMode",
|
||||
"Device",
|
||||
"StreamEvent",
|
||||
"StreamEventType",
|
||||
"Task",
|
||||
"TaskHandle",
|
||||
"TaskResult",
|
||||
]
|
||||
@@ -5,440 +5,15 @@
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from artemis.engine.pipeline import Pipeline
|
||||
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
|
||||
from artemis.runtime import (
|
||||
ConcurrencyMode,
|
||||
DeviceBusyError,
|
||||
DeviceExecutionLock,
|
||||
DeviceStatus,
|
||||
device_pool,
|
||||
ensure_daemon_running,
|
||||
submit_task_to_daemon,
|
||||
wait_for_daemon_task,
|
||||
)
|
||||
from artemis.utils.logger import get_logger
|
||||
"""Compatibility import for the standalone remote Artemis client.
|
||||
|
||||
logger = get_logger(__name__)
|
||||
The implementation lives in the zero-dependency ``artemis-client`` package.
|
||||
Keeping this module lets existing full-runtime installations migrate without
|
||||
maintaining a second client execution path.
|
||||
"""
|
||||
|
||||
from artemis.runtime import ConcurrencyMode
|
||||
from artemis_client import ArtemisClient
|
||||
|
||||
class ArtemisClient:
|
||||
"""The official high-level developer entrypoint for interacting with ARTEMIS."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
default_profile: Literal["flash", "pro"] = "pro",
|
||||
concurrency_mode: Literal["global", "per_device"] | str = "per_device",
|
||||
max_concurrency: int | None = None,
|
||||
standalone: bool = False,
|
||||
):
|
||||
self.standalone = standalone or os.environ.get("ARTEMIS_STANDALONE", "").lower() in ("1", "true", "yes")
|
||||
if not self.standalone:
|
||||
try:
|
||||
ensure_daemon_running(timeout=1.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
target_dev = device_serial or device_id
|
||||
if target_dev is None or target_dev == "default-device":
|
||||
self._device_id = device_pool.select_device() or "default-device"
|
||||
else:
|
||||
self._device_id = target_dev
|
||||
|
||||
self.default_profile = default_profile
|
||||
self._concurrency_mode = str(concurrency_mode).strip().lower()
|
||||
self._max_concurrency = max_concurrency
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
return self._device_id
|
||||
|
||||
@device_id.setter
|
||||
def device_id(self, val: str) -> None:
|
||||
self._device_id = val
|
||||
|
||||
@property
|
||||
def device_serial(self) -> str:
|
||||
"""Alias for device_id."""
|
||||
return self._device_id
|
||||
|
||||
@device_serial.setter
|
||||
def device_serial(self, val: str) -> None:
|
||||
self._device_id = val
|
||||
|
||||
def set_device(self, device_serial: str) -> "ArtemisClient":
|
||||
"""Set target device serial and return client for chaining."""
|
||||
self._device_id = device_serial
|
||||
return self
|
||||
|
||||
@property
|
||||
def concurrency_mode(self) -> str:
|
||||
"""Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)."""
|
||||
return self._concurrency_mode
|
||||
|
||||
@concurrency_mode.setter
|
||||
def concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> None:
|
||||
self._concurrency_mode = str(mode).strip().lower()
|
||||
|
||||
@property
|
||||
def max_concurrency(self) -> int | None:
|
||||
return self._max_concurrency
|
||||
|
||||
@max_concurrency.setter
|
||||
def max_concurrency(self, val: int | None) -> None:
|
||||
self._max_concurrency = val
|
||||
|
||||
def set_concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> "ArtemisClient":
|
||||
"""Set concurrency mode ('global' or 'per_device') and return client for chaining."""
|
||||
self.concurrency_mode = mode
|
||||
return self
|
||||
|
||||
def list_devices(self) -> list[DeviceStatus]:
|
||||
"""List all connected Android devices with their lock state and availability."""
|
||||
return device_pool.list_devices()
|
||||
|
||||
async def list_devices_async(self) -> list[DeviceStatus]:
|
||||
"""Asynchronously list all connected Android devices with their lock state."""
|
||||
return await device_pool.list_devices_async()
|
||||
|
||||
def get_idle_devices(self) -> list[DeviceStatus]:
|
||||
"""Return connected devices currently ready and idle (not holding a task lock)."""
|
||||
return device_pool.get_idle_devices()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
goal: str,
|
||||
profile: Literal["flash", "pro"] | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
concurrency_mode: Literal["global", "per_device"] | str | None = None,
|
||||
max_concurrency: int | None = None,
|
||||
blocking: bool = True,
|
||||
lock_timeout: float | None = None,
|
||||
max_turns: int = 30,
|
||||
**kwargs: Any,
|
||||
) -> TaskResult:
|
||||
"""Executes a single autonomous task on a target device and returns the final TaskResult."""
|
||||
target_profile = profile or self.default_profile
|
||||
target_device = device_serial or device_id or self.device_id
|
||||
if target_device == "default-device":
|
||||
target_device = device_pool.select_device() or "default-device"
|
||||
|
||||
mode = concurrency_mode or self.concurrency_mode
|
||||
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
|
||||
|
||||
logger.info(
|
||||
f"ArtemisClient running task '{goal}' on device '{target_device}' "
|
||||
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
|
||||
)
|
||||
|
||||
task_session_id = str(uuid.uuid4())
|
||||
prev_ingress = os.environ.get("ARTEMIS_TASK_INGRESS")
|
||||
prev_sess_id = os.environ.get("ARTEMIS_SESSION_ID")
|
||||
|
||||
lock = DeviceExecutionLock(
|
||||
device_id=target_device,
|
||||
description=f"{goal[:120]}",
|
||||
concurrency_mode=mode,
|
||||
max_concurrency=concurrency_limit,
|
||||
session_id=task_session_id,
|
||||
ingress="sdk",
|
||||
)
|
||||
|
||||
try:
|
||||
os.environ["ARTEMIS_TASK_INGRESS"] = "sdk"
|
||||
os.environ["ARTEMIS_SESSION_ID"] = task_session_id
|
||||
|
||||
await asyncio.to_thread(
|
||||
lock.acquire,
|
||||
blocking=blocking,
|
||||
timeout=lock_timeout,
|
||||
)
|
||||
|
||||
# Check if targeting a live connected device
|
||||
devices = device_pool.list_devices()
|
||||
connected_serials = {d.serial for d in devices}
|
||||
is_live_device = target_device in connected_serials and os.environ.get("ARTEMIS_MOCK_DRIVER") != "1"
|
||||
|
||||
if is_live_device:
|
||||
# Route through unified Artemis Daemon scheduler unless standalone explicitly configured
|
||||
if not self.standalone and os.environ.get("ARTEMIS_STANDALONE") != "1":
|
||||
try:
|
||||
is_running, base_url = ensure_daemon_running(timeout=8.0, wait_ready=True)
|
||||
if is_running and base_url:
|
||||
dev_serial = target_device if target_device != "default-device" else None
|
||||
resp = submit_task_to_daemon(
|
||||
goal=goal,
|
||||
profile=target_profile,
|
||||
device_serial=dev_serial,
|
||||
expected_output=kwargs.get("expected_output"),
|
||||
enable_outputter=kwargs.get("enable_outputter"),
|
||||
locked_app_package=kwargs.get("locked_package") or kwargs.get("locked_app_package"),
|
||||
app_path=kwargs.get("app_path"),
|
||||
session_id=task_session_id,
|
||||
ingress="sdk",
|
||||
base_url=base_url,
|
||||
)
|
||||
if resp and resp.get("tasks"):
|
||||
final_res = wait_for_daemon_task(
|
||||
task_session_id,
|
||||
base_url=base_url,
|
||||
timeout=float(kwargs.get("timeout", 1800.0)),
|
||||
)
|
||||
final_st = final_res.get("status", "completed")
|
||||
err = final_res.get("error")
|
||||
return TaskResult(
|
||||
trace_id=task_session_id,
|
||||
status="completed" if final_st in ("completed", "success") else final_st,
|
||||
turns=final_res.get("turns", 1),
|
||||
error=err,
|
||||
device_id=target_device,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"SDK daemon routing notice: {exc}")
|
||||
|
||||
from artemis.sdk.agent import Agent
|
||||
from artemis.sdk.builders import Builders
|
||||
from artemis.context import DevicePlatform
|
||||
|
||||
cfg_builder = (
|
||||
Builders.AgentConfig
|
||||
.for_device(DevicePlatform.ANDROID, target_device)
|
||||
.with_concurrency_mode(mode)
|
||||
)
|
||||
if concurrency_limit is not None:
|
||||
cfg_builder.with_max_concurrency(concurrency_limit)
|
||||
|
||||
agent = Agent(config=cfg_builder.build())
|
||||
agent._session_id = task_session_id
|
||||
await agent.init()
|
||||
try:
|
||||
task = agent.new_task(goal=goal)
|
||||
if target_profile:
|
||||
task.using_profile(target_profile)
|
||||
if kwargs.get("locked_package"):
|
||||
task.with_locked_app_package(kwargs["locked_package"])
|
||||
|
||||
output = await agent.run_task(request=task.build())
|
||||
return TaskResult(
|
||||
trace_id=task_session_id,
|
||||
status="completed" if output is not None else "success",
|
||||
turns=getattr(agent, "_turns_completed", 1),
|
||||
output=output,
|
||||
device_id=target_device,
|
||||
)
|
||||
finally:
|
||||
await agent.clean()
|
||||
|
||||
state = await Pipeline.execute(
|
||||
goal=goal,
|
||||
profile=target_profile,
|
||||
device_id=target_device,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return TaskResult(
|
||||
trace_id=state.trace_id,
|
||||
status=state.status.value,
|
||||
turns=state.current_turn,
|
||||
error=state.error_message,
|
||||
device_id=target_device,
|
||||
)
|
||||
finally:
|
||||
if prev_sess_id is not None:
|
||||
os.environ["ARTEMIS_SESSION_ID"] = prev_sess_id
|
||||
else:
|
||||
os.environ.pop("ARTEMIS_SESSION_ID", None)
|
||||
if prev_ingress is not None:
|
||||
os.environ["ARTEMIS_TASK_INGRESS"] = prev_ingress
|
||||
else:
|
||||
os.environ.pop("ARTEMIS_TASK_INGRESS", None)
|
||||
if lock._acquired:
|
||||
await asyncio.to_thread(lock.release)
|
||||
|
||||
async def stream_run(
|
||||
self,
|
||||
goal: str,
|
||||
profile: Literal["flash", "pro"] | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
concurrency_mode: Literal["global", "per_device"] | str | None = None,
|
||||
max_concurrency: int | None = None,
|
||||
blocking: bool = True,
|
||||
lock_timeout: float | None = None,
|
||||
max_turns: int = 30,
|
||||
callbacks: list[Callable[[StreamEvent], Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""Executes an autonomous task while yielding real-time StreamEvents asynchronously."""
|
||||
target_profile = profile or self.default_profile
|
||||
target_device = device_serial or device_id or self.device_id
|
||||
if target_device == "default-device":
|
||||
target_device = device_pool.select_device() or "default-device"
|
||||
|
||||
mode = concurrency_mode or self.concurrency_mode
|
||||
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
|
||||
|
||||
logger.info(
|
||||
f"ArtemisClient streaming task '{goal}' on device '{target_device}' "
|
||||
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
|
||||
)
|
||||
|
||||
task_session_id = str(uuid.uuid4())
|
||||
prev_ingress = os.environ.get("ARTEMIS_TASK_INGRESS")
|
||||
prev_sess_id = os.environ.get("ARTEMIS_SESSION_ID")
|
||||
|
||||
lock = DeviceExecutionLock(
|
||||
device_id=target_device,
|
||||
description=f"ArtemisClient streaming task: {goal[:120]}",
|
||||
concurrency_mode=mode,
|
||||
max_concurrency=concurrency_limit,
|
||||
session_id=task_session_id,
|
||||
ingress="sdk",
|
||||
)
|
||||
|
||||
try:
|
||||
os.environ["ARTEMIS_TASK_INGRESS"] = "sdk"
|
||||
os.environ["ARTEMIS_SESSION_ID"] = task_session_id
|
||||
|
||||
await asyncio.to_thread(
|
||||
lock.acquire,
|
||||
blocking=blocking,
|
||||
timeout=lock_timeout,
|
||||
)
|
||||
# 1. Emit initial starting event
|
||||
start_event = StreamEvent(
|
||||
event_type=StreamEventType.STATUS,
|
||||
step_number=0,
|
||||
payload={
|
||||
"status": "starting",
|
||||
"goal": goal,
|
||||
"profile": target_profile,
|
||||
"device_id": target_device,
|
||||
"concurrency_mode": mode,
|
||||
},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(start_event)
|
||||
except Exception as e:
|
||||
logger.debug(f"Callback error: {e}")
|
||||
yield start_event
|
||||
|
||||
# 2. Execute task pipeline
|
||||
try:
|
||||
state = await Pipeline.execute(
|
||||
goal=goal,
|
||||
profile=target_profile,
|
||||
device_id=target_device,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# 3. Emit intermediate step events
|
||||
for step in state.steps:
|
||||
ev = StreamEvent(
|
||||
event_type=StreamEventType.STEP_END,
|
||||
step_number=step.step_number,
|
||||
payload={
|
||||
"thought": step.thought,
|
||||
"action": step.action_name,
|
||||
"params": step.action_params,
|
||||
"result": step.result,
|
||||
"duration": step.duration_seconds,
|
||||
},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(ev)
|
||||
except Exception as e:
|
||||
logger.debug(f"Callback error: {e}")
|
||||
yield ev
|
||||
|
||||
# 4. Emit completion event
|
||||
final_ev = StreamEvent(
|
||||
event_type=StreamEventType.STATUS,
|
||||
step_number=state.current_turn,
|
||||
payload={
|
||||
"status": state.status.value,
|
||||
"turns": state.current_turn,
|
||||
"error": state.error_message,
|
||||
"device_id": target_device,
|
||||
},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(final_ev)
|
||||
except Exception as e:
|
||||
logger.debug(f"Callback error: {e}")
|
||||
yield final_ev
|
||||
|
||||
except Exception as e:
|
||||
err_ev = StreamEvent(
|
||||
event_type=StreamEventType.ERROR,
|
||||
step_number=0,
|
||||
payload={"error": str(e), "device_id": target_device},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(err_ev)
|
||||
except Exception:
|
||||
pass
|
||||
yield err_ev
|
||||
finally:
|
||||
if prev_sess_id is not None:
|
||||
os.environ["ARTEMIS_SESSION_ID"] = prev_sess_id
|
||||
else:
|
||||
os.environ.pop("ARTEMIS_SESSION_ID", None)
|
||||
if prev_ingress is not None:
|
||||
os.environ["ARTEMIS_TASK_INGRESS"] = prev_ingress
|
||||
else:
|
||||
os.environ.pop("ARTEMIS_TASK_INGRESS", None)
|
||||
if lock._acquired:
|
||||
await asyncio.to_thread(lock.release)
|
||||
|
||||
async def run_task(
|
||||
self,
|
||||
task: Task,
|
||||
profile: Literal["flash", "pro"] | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
concurrency_mode: Literal["global", "per_device"] | str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> TaskResult:
|
||||
"""Executes a Task instance using ArtemisClient."""
|
||||
target_device = (
|
||||
device_serial
|
||||
or device_id
|
||||
or task.device_serial
|
||||
or task.device_id
|
||||
or self.device_id
|
||||
)
|
||||
target_profile = profile or task.profile or self.default_profile
|
||||
target_mode = concurrency_mode or task.concurrency_mode or self.concurrency_mode
|
||||
return await self.run(
|
||||
goal=task.goal,
|
||||
profile=target_profile,
|
||||
device_id=target_device,
|
||||
concurrency_mode=target_mode,
|
||||
max_turns=task.max_turns,
|
||||
locked_package=task.locked_package,
|
||||
**kwargs,
|
||||
)
|
||||
__all__ = ["ArtemisClient", "ConcurrencyMode"]
|
||||
Reference in new issue
Block a user