feat: add multi-device concurrency, server lifecycle CLI, and unified execution stream

- Implement DevicePool and DeviceExecutionLock concurrency modes (per-device FIFO vs global serialization)
- Extend ArtemisClient SDK and CLI with explicit device targeting and concurrency strategy configuration
- Add server lifecycle management (artemis restart, stop, status) with cross-platform PID discovery
- Update MCP server tools to support multi-device dispatch, trace tagging, and per-device execution
- Enhance Showcase UI with step-by-step visual replay, playback speed controls, and device serial badges
- Add comprehensive unit test coverage and edge-case verification scripts
This commit is contained in:
somew1nd committed 2026-08-26 15:53:14 -07:00
1 parent a36a66ce6d
commit c2c90a6378
84 files changed
+5612 -645

No files matched your search

+55 -1
View File
@@ -39,6 +39,8 @@ logger = get_logger(__name__)
async def execute_task(
goal: str,
device_serial: str | None = None,
session_id: str | None = None,
locked_app_package: str | None = None,
test_name: str | None = None,
traces_output_path_str: str | None = None,
@@ -76,6 +78,14 @@ async def execute_task(
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
"""
publish_startup_progress("configuration", "Loading the run configuration")
effective_sid = (
session_id
or os.getenv("ARTEMIS_SESSION_ID")
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
)
if effective_sid:
os.environ["ARTEMIS_SESSION_ID"] = str(effective_sid)
llm_config = initialize_llm_config()
agent_profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile=agent_profile)
@@ -115,13 +125,25 @@ async def execute_task(
if settings.ADB_HOST:
config.with_adb_server(host=settings.ADB_HOST, port=settings.ADB_PORT)
target_serial = device_serial or settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL")
if not target_serial:
try:
from artemis.runtime import device_pool
target_serial = device_pool.select_device()
except Exception:
target_serial = None
if target_serial:
from artemis.context import DevicePlatform
config.for_device(DevicePlatform.ANDROID, target_serial)
if graph_config_callbacks:
config.with_graph_config_callbacks(graph_config_callbacks)
agent: Agent | None = None
try:
publish_startup_progress("device_check", "Checking the Android device")
agent = Agent(config=config.build())
agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init(
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
@@ -273,6 +295,28 @@ def run_command(
help="Explorer version mode when running under Pro profile (Operator/Validator).",
),
] = None,
device_serial: Annotated[
str | None,
typer.Option(
"--device-serial",
"-s",
help="Target specific Android device by serial number (e.g. emulator-5554).",
),
] = None,
session_id: Annotated[
str | None,
typer.Option(
"--session-id",
help="Canonical session UUID for trace and stream telemetry.",
),
] = None,
standalone: Annotated[
bool,
typer.Option(
"--standalone",
help="Run in standalone embedded mode without auto-spawning the Artemis Daemon.",
),
] = False,
) -> None:
"""Run an autonomous UI automation task on the connected Android device."""
if with_video_recording_tools:
@@ -280,6 +324,14 @@ def run_command(
console = Console()
# Daemon auto-spawn: By default, ensure Daemon is running in background unless standalone mode requested
if not standalone and os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
from artemis.runtime import ensure_daemon_running
ensure_daemon_running(timeout=1.5)
except Exception as exc:
logger.debug(f"Daemon probe/auto-spawn skipped: {exc}")
adb_client = None
try:
if which("adb"):
@@ -306,6 +358,8 @@ def run_command(
asyncio.run(
execute_task(
goal=goal,
device_serial=device_serial,
session_id=session_id,
locked_app_package=locked_app_package,
test_name=test_name,
traces_output_path_str=traces_path,
@@ -0,0 +1,255 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""CLI commands for Artemis server lifecycle management: restart, stop, and status."""
from __future__ import annotations
import datetime
from typing import Annotated
import webbrowser
from rich.console import Console
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.runtime.server_lifecycle import (
find_server_pids,
get_server_status,
stop_server,
)
console = Console()
def _format_uptime(seconds: float | None) -> str:
"""Format seconds into human-friendly duration (e.g. 1h 23m 45s)."""
if seconds is None:
return "Unknown"
sec = int(seconds)
hours, remainder = divmod(sec, 3600)
minutes, remainder_sec = divmod(remainder, 60)
parts = []
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0 or hours > 0:
parts.append(f"{minutes}m")
parts.append(f"{remainder_sec}s")
return " ".join(parts)
def restart_command(
host: Annotated[
str,
typer.Option("--host", "-H", help="Bind host address for the UI server."),
] = "0.0.0.0",
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
] = 8000,
open_browser: Annotated[
bool,
typer.Option(
"--open/--no-open",
help="Automatically open the Showcase UI in default web browser.",
),
] = True,
force: Annotated[
bool,
typer.Option(
"--force",
"-f",
help="Force kill previous server immediately without waiting for graceful exit.",
),
] = False,
reload: Annotated[
bool,
typer.Option(
"--reload/--no-reload",
help="Enable uvicorn live auto-reload (for development).",
),
] = False,
daemon: Annotated[
bool,
typer.Option(
"--daemon",
"-d",
help="Run the new server as a detached background process instead of foreground.",
),
] = False,
) -> None:
"""Restart the Artemis Web UI server, terminating any existing instance on the port."""
console.print()
console.print(
Panel(
f"[bold cyan]🔄 Artemis Server Restart[/bold cyan]\n"
f"[dim]Inspecting port {port} and recycling any running Artemis instances...[/dim]",
border_style="cyan",
expand=False,
)
)
# 1. Stop existing server
existing_pids = find_server_pids(port)
if existing_pids:
pid_str = ", ".join(map(str, existing_pids))
console.print(f" [yellow]🛑 Terminating running Artemis instance (PID: {pid_str})...[/yellow]")
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)
if stopped_pids:
console.print(f" [green]✓ {msg}[/green]\n")
elif not success:
console.print(f" [red]⚠ {msg}[/red]\n")
else:
console.print(f" [green]✓ Port {port} is clear and ready.[/green]\n")
# 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]")
proc = spawn_daemon(host=host, port=port)
if proc:
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"
)
if open_browser:
webbrowser.open(local_url)
else:
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
raise typer.Exit(code=1)
return
# Default foreground mode
console.print(f" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
ui_command(host=host, port=port, open_browser=open_browser, reload=reload)
def stop_command(
port: Annotated[
int,
typer.Option("--port", "-p", help="Port of the Artemis server to stop."),
] = 8000,
force: Annotated[
bool,
typer.Option(
"--force",
"-f",
help="Force kill immediately without waiting for graceful shutdown.",
),
] = False,
) -> None:
"""Stop any running Artemis Web UI / server instance on the given port."""
console.print()
existing_pids = find_server_pids(port)
if existing_pids:
pid_str = ", ".join(map(str, existing_pids))
console.print(f" [yellow]🛑 Stopping Artemis server on port {port} (PID: {pid_str})...[/yellow]")
else:
console.print(f" [dim]Scanning port {port}...[/dim]")
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
if stopped_pids:
console.print(
Panel(
f"[bold green]✓ Artemis server stopped successfully.[/bold green]\n\n"
f"[dim]{msg}[/dim]",
title="🛑 Server Stopped",
border_style="green",
expand=False,
)
)
elif not success:
console.print(
Panel(
f"[bold red]⚠ Failed to fully stop server on port {port}[/bold red]\n\n{msg}",
title="⚠ Warning",
border_style="red",
expand=False,
)
)
raise typer.Exit(code=1)
else:
console.print(
Panel(
f"[dim]No active Artemis server found on port {port}. Port is free.[/dim]",
title="ℹ Status",
border_style="dim",
expand=False,
)
)
def status_command(
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to query for Artemis server status."),
] = 8000,
) -> None:
"""Check whether the Artemis server is currently running and display runtime metadata."""
console.print()
status = get_server_status(port=port)
if status["running"]:
pids_str = ", ".join(map(str, status["pids"])) if status["pids"] else str(status["active_pid"] or "Unknown")
uptime_str = _format_uptime(status["uptime_seconds"])
metadata = status.get("metadata") or {}
started_at_str = "Unknown"
if metadata.get("started_at"):
try:
dt = datetime.datetime.fromtimestamp(metadata["started_at"])
started_at_str = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_row("[bold cyan]Status[/bold cyan]", "[bold green]● ONLINE / RUNNING[/bold green]")
table.add_row("[bold cyan]PID(s)[/bold cyan]", f"[yellow]{pids_str}[/yellow]")
table.add_row("[bold cyan]Port[/bold cyan]", f"[white]{port}[/white]")
table.add_row("[bold cyan]Uptime[/bold cyan]", f"[white]{uptime_str}[/white]")
table.add_row("[bold cyan]Started At[/bold cyan]", f"[dim]{started_at_str}[/dim]")
table.add_row("[bold cyan]Showcase UI[/bold cyan]", f"[cyan]{status['url']}[/cyan]")
table.add_row("[bold cyan]Admin Debug[/bold cyan]", f"[magenta]{status['admin_url']}[/magenta]")
if metadata.get("cwd"):
table.add_row("[bold cyan]Workspace[/bold cyan]", f"[dim]{metadata['cwd']}[/dim]")
console.print(
Panel(
table,
title="🚀 Artemis Server Status",
border_style="green",
expand=False,
)
)
console.print(" [dim]💡 To restart: [bold]artemis restart[/bold] | To stop: [bold]artemis stop[/bold][/dim]\n")
else:
console.print(
Panel(
f"[dim]○ OFFLINE / STOPPED[/dim]\n\n"
f"Port [cyan]{port}[/cyan] is currently free.\n"
f"[dim]No active Artemis server process detected.[/dim]",
title="ℹ Artemis Server Status",
border_style="dim",
expand=False,
)
)
console.print(" [dim]💡 To start: [bold]artemis ui[/bold] or [bold]./start.sh[/bold][/dim]\n")
+48
View File
@@ -95,11 +95,59 @@ def ui_command(
help="Enable uvicorn live auto-reload (for development).",
),
] = False,
restart: Annotated[
bool,
typer.Option(
"--restart",
"-r",
help="Restart server by terminating any existing instance on the port first.",
),
] = False,
) -> None:
"""Launch the unified Artemis Showcase UI & Admin Console in one click."""
console = Console()
local_url = f"http://localhost:{port}"
from artemis.runtime.server_lifecycle import (
find_server_pids,
is_port_in_use,
stop_server,
)
if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
stop_server(port=port, timeout=4.0)
elif is_port_in_use(port):
pids = find_server_pids(port)
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
if sys.stdin.isatty():
console.print(f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]")
console.print(" [dim]Choose an action:[/dim]")
console.print(" [bold]1[/bold] Open browser (default)")
console.print(" [bold]2[/bold] Restart server (stop existing and start here)")
console.print(" [bold]3[/bold] Cancel")
try:
choice = input(" Select [1-3, default 1]: ").strip()
except (KeyboardInterrupt, EOFError):
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)
elif choice == "3":
return
else:
if open_browser:
webbrowser.open(local_url)
return
else:
console.print(
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n"
)
if open_browser:
webbrowser.open(local_url)
return
console.print()
msg = (
f"[bold cyan]✨ Artemis Autonomous Mobile Agent UI[/bold cyan]\n\n"
+8
View File
@@ -23,6 +23,11 @@ from artemis.interfaces.cli.commands.init import init_command
from artemis.interfaces.cli.commands.mcp import mcp_command
from artemis.interfaces.cli.commands.run import run_command
from artemis.interfaces.cli.commands.server import server_app
from artemis.interfaces.cli.commands.server_lifecycle import (
restart_command,
status_command,
stop_command,
)
from artemis.interfaces.cli.commands.trace import trace_app
from artemis.interfaces.cli.commands.ui import ui_command
from artemis.utils.logger import get_logger
@@ -44,6 +49,9 @@ app = typer.Typer(
app.command(name="ui", help="Launch the unified Showcase UI & Admin Console in your browser.")(
ui_command
)
app.command(name="restart", help="Restart running Artemis Web UI & server.")(restart_command)
app.command(name="stop", help="Stop running Artemis Web UI & server.")(stop_command)
app.command(name="status", help="Display Artemis Web UI & server status.")(status_command)
app.command(name="run", help="Execute an autonomous task on a mobile device.")(run_command)
app.command(name="init", help="Interactive quickstart wizard to configure API keys & device.")(
init_command
+5 -2
View File
@@ -14,11 +14,14 @@
"""Python SDK for ARTEMIS."""
from artemis.interfaces.sdk.client import ArtemisClient
from artemis.interfaces.sdk.task import Task, TaskResult
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
__all__ = [
"ArtemisClient",
"ConcurrencyMode",
"StreamEvent",
"StreamEventType",
"Task",
"TaskResult",
]
+307 -81
View File
@@ -12,10 +12,21 @@
# 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, TaskResult
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
from artemis.runtime import (
ConcurrencyMode,
DeviceBusyError,
DeviceExecutionLock,
DeviceStatus,
device_pool,
ensure_daemon_running,
)
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -25,123 +36,338 @@ class ArtemisClient:
"""The official high-level developer entrypoint for interacting with ARTEMIS."""
def __init__(
self, device_id: str = "default-device", default_profile: Literal["flash", "pro"] = "pro"
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.device_id = device_id
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 and returns the final TaskResult."""
"""Executes a single autonomous task on a target device and returns the final TaskResult."""
target_profile = profile or self.default_profile
logger.info(f"ArtemisClient running task '{goal}' with profile '{target_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"
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=self.device_id,
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}')..."
)
return TaskResult(
trace_id=state.trace_id,
status=state.status.value,
turns=state.current_turn,
error=state.error_message,
task_session_id = str(uuid.uuid4())
lock = DeviceExecutionLock(
device_id=target_device,
description=f"{goal[:120]}",
concurrency_mode=mode,
max_concurrency=concurrency_limit,
session_id=task_session_id,
ingress="sdk",
)
await asyncio.to_thread(
lock.acquire,
blocking=blocking,
timeout=lock_timeout,
)
try:
# 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:
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:
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
logger.info(f"ArtemisClient streaming task '{goal}' with profile '{target_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"
# 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": self.device_id,
},
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}')..."
)
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
lock = DeviceExecutionLock(
device_id=target_device,
description=f"ArtemisClient streaming task: {goal[:120]}",
concurrency_mode=mode,
max_concurrency=concurrency_limit,
)
await asyncio.to_thread(
lock.acquire,
blocking=blocking,
timeout=lock_timeout,
)
try:
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=self.device_id,
)
# 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(
# 1. Emit initial starting event
start_event = StreamEvent(
event_type=StreamEventType.STATUS,
step_number=state.current_turn,
step_number=0,
payload={
"status": state.status.value,
"turns": state.current_turn,
"error": state.error_message,
"status": "starting",
"goal": goal,
"profile": target_profile,
"device_id": target_device,
"concurrency_mode": mode,
},
)
if callbacks:
for cb in callbacks:
try:
cb(final_ev)
cb(start_event)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield final_ev
yield start_event
except Exception as e:
err_ev = StreamEvent(
event_type=StreamEventType.ERROR,
step_number=0,
payload={"error": str(e)},
)
if callbacks:
for cb in callbacks:
try:
cb(err_ev)
except Exception:
pass
yield err_ev
# 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:
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,
)
+14
View File
@@ -44,9 +44,22 @@ class Task(BaseModel):
goal: str = Field(..., description="High-level goal description")
profile: Literal["flash", "pro"] = Field(default="pro", description="Execution profile")
device_id: str = Field(default="default-device", description="Target device serial")
device_serial: str | None = Field(
default=None, description="Target device serial (alias for device_id)"
)
concurrency_mode: Literal["global", "per_device"] | None = Field(
default=None,
description="Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)",
)
locked_package: str | None = Field(default=None, description="Package lock restriction")
max_turns: int = Field(default=30, description="Turn cutoff limit")
def model_post_init(self, __context: Any) -> None:
if self.device_serial and self.device_id == "default-device":
self.device_id = self.device_serial
elif self.device_id != "default-device" and not self.device_serial:
self.device_serial = self.device_id
class TaskResult(BaseModel):
"""Final structured outcome of a task execution."""
@@ -56,3 +69,4 @@ class TaskResult(BaseModel):
turns: int
output: Any = None
error: str | None = None
device_id: str | None = None