feat: add plugin runtime and cross-device task orchestration

This commit is contained in:
chenmingxuan committed 2026-10-14 20:21:44 +08:00
1 parent 3ee4a5f7a8
commit 6663233ab8
82 files changed
+4147 -124

No files matched your search

+91 -31
View File
@@ -202,7 +202,22 @@ class Agent:
device_serial: str | None = None,
retry_count: int = 5,
retry_wait_seconds: int = 5,
execution_target: str = "android",
):
if execution_target not in {"android", "computer", "mixed"}:
raise ValueError("任务目标必须是 android、computer 或 mixed")
self._execution_target = execution_target
if execution_target == "computer":
self._device_context = DeviceContext(
device_id="computer:local",
mobile_platform=DevicePlatform.COMPUTER,
device_width=0,
device_height=0,
)
self._adb_client = None
self._ui_adb_client = None
self._initialized = True
return True
target_dev = device_serial or device_id
if target_dev:
self._config = self._config.model_copy(
@@ -366,9 +381,13 @@ class Agent:
- .with_trace_recording() to enable trace recording
- .with_name() to set a custom task name
"""
return TaskRequestBuilder[None].from_common(
goal=goal,
common=self._config.task_request_defaults,
return (
TaskRequestBuilder[None]
.from_common(
goal=goal,
common=self._config.task_request_defaults,
)
.for_target(getattr(self, "_execution_target", "android"))
)
@overload
@@ -480,6 +499,13 @@ class Agent:
if not self._initialized:
raise AgentNotInitializedError()
if request.execution_target != getattr(self, "_execution_target", "android"):
raise AgentTaskRequestError("任务目标与 Agent 初始化目标不一致")
if request.execution_target == "computer" and (
request.app_path or request.locked_app_package
):
raise AgentTaskRequestError("纯电脑任务不能使用 Android 安装包或应用锁定")
if request.profile:
agent_profile = self._config.agent_profiles.get(request.profile)
if agent_profile is None:
@@ -522,7 +548,9 @@ class Agent:
ui_adb_client=self._ui_adb_client,
llm_config=agent_profile.llm_config,
agent_config=self._config,
execution_target=request.execution_target,
)
from artemis.plugins.runtime import PluginRuntime
output_config = None
if request.output_description or request.output_format:
@@ -551,28 +579,41 @@ class Agent:
or getattr(task, "id", None)
or getattr(getattr(task, "request", None), "task_name", None)
)
active_owner = DeviceExecutionLock.get_active_owner(self._device_context.device_id)
already_held = (
active_owner is not None
and active_owner.pid == os.getpid()
and (
(sess_id and str(active_owner.session_id) == str(sess_id))
or active_owner.token == self._device_context.device_id
)
)
device_lock = (
None
if already_held
else DeviceExecutionLock(
self._device_context.device_id,
description=f"{request.goal[:120]}",
concurrency_mode=effective_mode,
max_concurrency=effective_max,
session_id=str(sess_id) if sess_id else None,
ingress=os.getenv("ARTEMIS_TASK_INGRESS") or "agent",
)
)
device_lock = None
try:
# Construct the runtime only inside the task's cleanup boundary. A
# cancelled queue wait or setup failure must not leave version pins.
context.plugins = PluginRuntime(request.plugins, ctx=context)
active_owner = DeviceExecutionLock.get_active_owner(self._device_context.device_id)
already_held = (
active_owner is not None
and active_owner.pid == os.getpid()
and (
(sess_id and str(active_owner.session_id) == str(sess_id))
or active_owner.token == self._device_context.device_id
)
)
if not already_held or context.plugins.resources:
device_lock = DeviceExecutionLock(
self._device_context.device_id,
description=f"{request.goal[:120]}",
concurrency_mode=effective_mode,
max_concurrency=effective_max,
session_id=str(sess_id) if sess_id else None,
ingress=os.getenv("ARTEMIS_TASK_INGRESS") or "agent",
resource_ids=context.plugins.resources,
)
context.plugins.ensure_resources_ready()
if context.plugins.packages:
from artemis.plugins.models import PluginSelection
context.plugins.store.hold(
str(sess_id),
[
PluginSelection(id=m.id, version=m.version, profile=p.name)
for m, p, _ in context.plugins.packages.values()
],
)
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
from artemis.runtime.task_control import control_dir, _write_atomic
@@ -615,16 +656,23 @@ class Agent:
context.task_control = task_control
task_control._state("running")
self._prepare_output_files(task=task)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if (
os.environ.get("ARTEMIS_CLOUD_MODE") != "1"
and request.execution_target != "computer"
):
if self._ui_adb_client is not None:
await self._connect_screen_client(context, str(sess_id))
await self._ensure_device_unlocked()
publish_startup_progress(
"environment", "Preparing the device environment", session_id=str(sess_id)
)
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
if request.execution_target != "computer":
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
elif context.execution_setup:
context.execution_setup.video_recording_tools_enabled = False
context.execution_setup.record_video_enabled = False
async with context:
recording_started = False
recording_parts = 1
@@ -637,6 +685,7 @@ class Agent:
async def pause_capture():
nonlocal recording_started
await context.plugins.aclose(release=False)
if recording_started:
try:
await controller.stop_video_recording()
@@ -648,6 +697,8 @@ class Agent:
async def resume_capture():
nonlocal recording_started, recording_parts
if request.execution_target == "computer":
return
if not (
self._config.video_recording_tools_enabled
or self._config.record_video_enabled
@@ -662,7 +713,7 @@ class Agent:
context.on_task_pause = pause_capture
context.on_task_resume = resume_capture
if (
if request.execution_target != "computer" and (
self._config.video_recording_tools_enabled
or self._config.record_video_enabled
):
@@ -897,6 +948,15 @@ class Agent:
raise
finally:
# Close plugins before releasing the whole task's device/resource lease.
plugins_stopped = False
try:
if context.plugins:
await context.plugins.aclose()
context.plugins.store.release(str(sess_id))
plugins_stopped = True
except Exception:
logger.error("插件未确认停止,执行资源将保留至工作进程退出。")
try:
# Background ADB processes (logcat, screenrecord, ...) started
# by the Operator must not outlive the automation task.
@@ -932,9 +992,9 @@ class Agent:
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
finally:
if task_control is not None:
if task_control is not None and plugins_stopped:
await asyncio.to_thread(task_control.release)
elif device_lock is not None:
elif device_lock is not None and plugins_stopped:
await asyncio.to_thread(device_lock.release)
async with self._task_lock: