fix: bound task failures and release stalled workers
This commit is contained in:
1 parent
606ed94160
commit
1fdf7c0d1d
32 files changed
+1121
-93
No files matched your search
@@ -81,7 +81,7 @@ from artemis.graph.state import State
|
||||
from artemis.llm.router import ModelEndpoint
|
||||
from artemis.llm.structured import ParseFailure, parse_structured
|
||||
from artemis.mcp.action_executor import McpActionExecutor
|
||||
from artemis.mcp.observation import observe
|
||||
from artemis.mcp.observation import DeviceObservationError, require_observation
|
||||
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral
|
||||
from artemis.services.llm import (
|
||||
RobustChatModelWrapper,
|
||||
@@ -380,8 +380,16 @@ class FlashRunner:
|
||||
blocks.append(
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
|
||||
)
|
||||
if xml_list:
|
||||
blocks.append({"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n{xml_list}"})
|
||||
blocks.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"{PRO_UI_LIST_MARKER}\n"
|
||||
+ (
|
||||
xml_list
|
||||
or "当前没有可用的元素索引。仅根据当前截图定位坐标,禁止使用历史画面的元素编号。"
|
||||
),
|
||||
}
|
||||
)
|
||||
ephemeral: list[int] = []
|
||||
# (text, ephemeral) in tail order.
|
||||
per_turn: list[tuple[str, bool]] = [(text, True) for text in (notices or [])]
|
||||
@@ -604,19 +612,14 @@ class FlashRunner:
|
||||
post_img_bytes = exec_result.screenshot_bytes
|
||||
if post_img_bytes or name not in action_names:
|
||||
return post_img_bytes
|
||||
try:
|
||||
obs, screenshot_bytes = await observe(self.ctx, self.controller, settle_ms=0)
|
||||
except Exception as shot_err:
|
||||
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {shot_err}")
|
||||
return post_img_bytes
|
||||
if not obs.ok:
|
||||
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {obs.message}")
|
||||
return post_img_bytes
|
||||
if state is not None:
|
||||
state.indexed_points = []
|
||||
state.indexed_elements = []
|
||||
state.latest_screenshot = None
|
||||
obs, screenshot_bytes = await require_observation(self.ctx, self.controller, settle_ms=0)
|
||||
post_img_bytes = screenshot_bytes
|
||||
if not exec_result.ui_elements_text:
|
||||
exec_result.ui_elements_text = obs.elements_text
|
||||
if not exec_result.screenshot_path and obs.screenshot_path:
|
||||
exec_result.screenshot_path = obs.screenshot_path
|
||||
exec_result.ui_elements_text = obs.elements_text
|
||||
exec_result.screenshot_path = obs.screenshot_path
|
||||
if state is not None:
|
||||
if obs.hierarchy_ok:
|
||||
state.indexed_points = [el["center"] for el in obs.elements]
|
||||
@@ -831,7 +834,7 @@ class FlashRunner:
|
||||
turn.actions.append((name, exec_result.status, exec_result.text_summary))
|
||||
|
||||
# ⚡ Non-blocking dispatch of objective visual transition summarizer
|
||||
if self.summarizer and name in action_names:
|
||||
if self.summarizer and name in action_names and post_img_bytes:
|
||||
action_sequence += 1
|
||||
self.summarizer.dispatch(
|
||||
step_number=action_sequence,
|
||||
@@ -857,7 +860,7 @@ class FlashRunner:
|
||||
|
||||
if post_img_bytes:
|
||||
pre_screenshot_bytes = post_img_bytes
|
||||
if exec_result.ui_elements_text:
|
||||
if exec_result.ui_elements_text is not None:
|
||||
xml_list = exec_result.ui_elements_text
|
||||
|
||||
# Helper tools may return multimodal blocks (a step screenshot);
|
||||
@@ -876,6 +879,8 @@ class FlashRunner:
|
||||
)
|
||||
)
|
||||
|
||||
except DeviceObservationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing tool {name}: {e}")
|
||||
if name in action_names:
|
||||
@@ -1047,6 +1052,21 @@ class FlashRunner:
|
||||
|
||||
@trace(type="agent", name="FlashRunner")
|
||||
async def run(self, state: State) -> dict:
|
||||
try:
|
||||
return await self._run(state)
|
||||
except DeviceObservationError as exc:
|
||||
state.indexed_points = []
|
||||
state.indexed_elements = []
|
||||
state.latest_screenshot = None
|
||||
logger.error(f"Flash 因设备观测失败停止:{exc}")
|
||||
return {
|
||||
"status": "failed",
|
||||
"explanation": str(exc),
|
||||
"error": str(exc),
|
||||
"code": "device_observation_failed",
|
||||
}
|
||||
|
||||
async def _run(self, state: State) -> dict:
|
||||
limit = self.turn_limit
|
||||
logger.info(
|
||||
f"Starting Artemis Flash reactive loop for goal: {self.goal}"
|
||||
@@ -1073,6 +1093,7 @@ class FlashRunner:
|
||||
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
||||
fallback_llm = None
|
||||
using_tool_fallback = False
|
||||
action_failure_attempts = 0
|
||||
|
||||
while limit is None or turns < limit:
|
||||
control = getattr(self.ctx, "task_control", None)
|
||||
@@ -1084,6 +1105,7 @@ class FlashRunner:
|
||||
if resumed:
|
||||
using_tool_fallback = False
|
||||
no_tool_attempts = 0
|
||||
action_failure_attempts = 0
|
||||
self._llm = llm
|
||||
(
|
||||
shot_path,
|
||||
@@ -1157,6 +1179,7 @@ class FlashRunner:
|
||||
# assembled before the instruction arrived.
|
||||
using_tool_fallback = False
|
||||
no_tool_attempts = 0
|
||||
action_failure_attempts = 0
|
||||
self._llm = llm
|
||||
(
|
||||
shot_path,
|
||||
@@ -1241,6 +1264,7 @@ class FlashRunner:
|
||||
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
|
||||
using_tool_fallback = True
|
||||
no_tool_attempts = 0
|
||||
action_failure_attempts = 0
|
||||
logger.warning("Flash 首选模型未调用工具,当前步骤切换到兜底模型")
|
||||
pending_notices.append("首选模型连续未调用工具;本步骤改用兜底模型。")
|
||||
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
||||
@@ -1279,6 +1303,37 @@ class FlashRunner:
|
||||
using_tool_fallback = False
|
||||
self._llm = llm
|
||||
pending_notices.append("兜底模型已完成本步骤;后续步骤恢复首选模型。")
|
||||
for _, status, _ in turn.actions:
|
||||
action_failure_attempts = 0 if status == "success" else action_failure_attempts + 1
|
||||
failure_limit = (
|
||||
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||
if using_tool_fallback
|
||||
else settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS
|
||||
)
|
||||
if action_failure_attempts >= failure_limit:
|
||||
if using_tool_fallback:
|
||||
final_report = {
|
||||
"status": "failed",
|
||||
"explanation": f"兜底模型连续 {action_failure_attempts} 次设备动作失败,已停止任务。最后原因:{turn.result()}",
|
||||
}
|
||||
break
|
||||
# A fresh, valid observation is required before spending any
|
||||
# fallback model calls. Hardware recovery is not model retry.
|
||||
(
|
||||
shot_path,
|
||||
current_pre_screenshot_bytes,
|
||||
current_xml_list,
|
||||
) = await capture_screenshot_and_parse_ui(self.ctx, state, self.controller)
|
||||
state.latest_screenshot = shot_path
|
||||
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
|
||||
using_tool_fallback = True
|
||||
self._llm = fallback_llm
|
||||
action_failure_attempts = 0
|
||||
no_tool_attempts = 0
|
||||
pending_notices.append(
|
||||
f"首选模型连续 {settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS} 次设备动作失败。"
|
||||
f"最后原因:{turn.result()}。已重新观察;本步骤由兜底模型重新定位,勿重复旧动作。"
|
||||
)
|
||||
ledger.stage_turn(messages[turn_base:])
|
||||
previous_turn = turn
|
||||
if final_report_from_calls is not None:
|
||||
|
||||
@@ -136,9 +136,14 @@ async def capture_screenshot_and_parse_ui(
|
||||
Thin adapter over :func:`artemis.mcp.observation.observe` that adds the LangGraph
|
||||
``State`` write-back (indexed points/elements) the agents rely on.
|
||||
"""
|
||||
obs, screenshot_bytes = await observe(ctx, controller, settle_ms=0 if skip_settling else 400)
|
||||
if not obs.ok:
|
||||
return None, None, None
|
||||
from artemis.mcp.observation import require_observation
|
||||
|
||||
state.indexed_points = []
|
||||
state.indexed_elements = []
|
||||
state.latest_screenshot = None
|
||||
obs, screenshot_bytes = await require_observation(
|
||||
ctx, controller, settle_ms=0 if skip_settling else 400
|
||||
)
|
||||
|
||||
if obs.hierarchy_ok:
|
||||
state.indexed_points = [el["center"] for el in obs.elements]
|
||||
|
||||
@@ -122,6 +122,12 @@ class Settings(BaseSettings):
|
||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||
# Consecutive Flash responses without any tool call before failing the task.
|
||||
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
|
||||
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
|
||||
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
|
||||
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
|
||||
ARTEMIS_ACTION_FAILURE_ATTEMPTS: int = Field(default=3, ge=1, le=10)
|
||||
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
|
||||
|
||||
# Android ADB Connectivity
|
||||
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
||||
|
||||
@@ -19,6 +19,7 @@ import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from PIL import Image
|
||||
|
||||
from adbutils import AdbClient, AdbDevice
|
||||
from artemis.clients.ui_automator_client import (
|
||||
@@ -26,6 +27,7 @@ from artemis.clients.ui_automator_client import (
|
||||
_parse_hierarchy_xml_to_elements,
|
||||
)
|
||||
from artemis.config.agent import load_agent_config
|
||||
from artemis.config.settings import settings
|
||||
from artemis.config.paths import get_temp_dir
|
||||
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
||||
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
||||
@@ -91,6 +93,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
self._recording_process: asyncio.subprocess.Process | None = None
|
||||
self._recording_output_path: Path | None = None
|
||||
self._screenshot_config = load_agent_config().screenshot
|
||||
self._screen_capture_task: asyncio.Task[ScreenData] | None = None
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
@@ -122,6 +125,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
||||
|
||||
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
||||
"""Bound observation latency and share unfinished capture work across retries."""
|
||||
task = self._screen_capture_task
|
||||
if task is None or task.done():
|
||||
task = asyncio.create_task(self._get_screen_data(skip_settling))
|
||||
# A timed-out caller does not cancel a blocking SDK thread or start
|
||||
# another concurrent read on the same device. Retrieve late errors.
|
||||
task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
|
||||
self._screen_capture_task = task
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.shield(task), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
raise TimeoutError(
|
||||
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
|
||||
) from exc
|
||||
|
||||
async def _get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
||||
"""Captures screenshot and XML hierarchy concurrently."""
|
||||
if not skip_settling:
|
||||
await asyncio.sleep(0.3)
|
||||
@@ -131,11 +152,14 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
screenshot_base64: str = ""
|
||||
ui_hierarchy_xml: str | None = None
|
||||
ui_elements: list[dict[str, Any]] = []
|
||||
capture_errors: list[str] = []
|
||||
|
||||
# Check if ui_adb_client has direct screen data support
|
||||
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
|
||||
try:
|
||||
raw_ui_data = self._ui_adb_client.get_screen_data()
|
||||
raw_ui_data = await asyncio.to_thread(self._ui_adb_client.get_screen_data)
|
||||
if asyncio.iscoroutine(raw_ui_data):
|
||||
raw_ui_data = await raw_ui_data
|
||||
if (
|
||||
hasattr(raw_ui_data, "base64")
|
||||
and isinstance(raw_ui_data.base64, str)
|
||||
@@ -158,11 +182,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
elif isinstance(raw_elements, list):
|
||||
ui_elements = raw_elements
|
||||
except Exception as e:
|
||||
capture_errors.append(str(e))
|
||||
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
|
||||
|
||||
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
||||
try:
|
||||
pil_img = await asyncio.to_thread(self.device.screenshot)
|
||||
raw_image = await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
["screencap", "-p"],
|
||||
encoding=None,
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
pil_img = Image.open(BytesIO(raw_image))
|
||||
if hasattr(pil_img, "save"):
|
||||
buf = BytesIO()
|
||||
pil_img.save(buf, format="PNG")
|
||||
@@ -170,18 +201,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
self._width, self._height = pil_img.size
|
||||
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
||||
except Exception as e:
|
||||
capture_errors.append(str(e))
|
||||
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
|
||||
|
||||
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
||||
# Fallback for headless testing environments
|
||||
screenshot_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
screenshot_bytes = base64.b64decode(screenshot_base64)
|
||||
raise RuntimeError(
|
||||
"设备截图失败:ADB 与屏幕辅助服务均未返回有效截图;" + "; ".join(capture_errors)
|
||||
)
|
||||
|
||||
with Image.open(BytesIO(screenshot_bytes)) as image:
|
||||
if min(image.size) <= 1:
|
||||
raise RuntimeError("设备截图无效:收到占位图,禁止基于旧画面继续操作")
|
||||
image.verify()
|
||||
|
||||
# 2. Capture UI Hierarchy via UIAutomator
|
||||
if self._ui_adb_client and not ui_elements:
|
||||
try:
|
||||
if hasattr(self._ui_adb_client, "get_hierarchy"):
|
||||
res = self._ui_adb_client.get_hierarchy()
|
||||
res = await asyncio.to_thread(self._ui_adb_client.get_hierarchy)
|
||||
hierarchy = await res if asyncio.iscoroutine(res) else res
|
||||
if isinstance(hierarchy, str):
|
||||
ui_hierarchy_xml = hierarchy
|
||||
@@ -189,7 +226,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
else:
|
||||
ui_elements = hierarchy
|
||||
elif hasattr(self._ui_adb_client, "get_ui_elements"):
|
||||
res = self._ui_adb_client.get_ui_elements()
|
||||
res = await asyncio.to_thread(self._ui_adb_client.get_ui_elements)
|
||||
ui_elements = await res if asyncio.iscoroutine(res) else res
|
||||
except Exception as e:
|
||||
logger.debug(f"UI hierarchy extraction failed: {e}")
|
||||
@@ -208,16 +245,8 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
screen_width=w,
|
||||
screen_height=h,
|
||||
)
|
||||
clean_b64 = (
|
||||
screenshot_base64
|
||||
if isinstance(screenshot_base64, str) and screenshot_base64
|
||||
else "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
clean_bytes = (
|
||||
screenshot_bytes
|
||||
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
|
||||
else base64.b64decode(clean_b64)
|
||||
)
|
||||
clean_b64 = screenshot_base64
|
||||
clean_bytes = screenshot_bytes
|
||||
if self._screenshot_config.enabled:
|
||||
limited_bytes = limit_screenshot_long_edge(
|
||||
clean_bytes, self._screenshot_config.max_long_edge
|
||||
@@ -255,7 +284,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps)
|
||||
|
||||
logger.info(f"[ADB] {cmd}")
|
||||
await asyncio.to_thread(self.device.shell, cmd)
|
||||
await asyncio.to_thread(
|
||||
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Tap failed at ({x}, {y}): {e}")
|
||||
@@ -275,7 +306,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
try:
|
||||
cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
|
||||
logger.info(f"[ADB] {cmd}")
|
||||
await asyncio.to_thread(self.device.shell, cmd)
|
||||
await asyncio.to_thread(
|
||||
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}")
|
||||
@@ -324,7 +357,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
"input keyevent 67 && "
|
||||
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
|
||||
)
|
||||
await asyncio.to_thread(self.device.shell, clear_cmd)
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
clear_cmd,
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
else:
|
||||
# Append mode: move cursor to the very end of existing text
|
||||
await asyncio.to_thread(
|
||||
@@ -346,7 +383,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
set_clip_ok = True
|
||||
|
||||
if set_clip_ok:
|
||||
await asyncio.to_thread(self.device.shell, "input keyevent 279")
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
"input keyevent 279",
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
|
||||
@@ -359,7 +400,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
if "adbkeyboard" in str(default_ime).lower():
|
||||
b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8")
|
||||
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
|
||||
await asyncio.to_thread(self.device.shell, broadcast_cmd)
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
broadcast_cmd,
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
# ADBKeyboard probe/broadcast failed; fall through to native input.
|
||||
@@ -370,10 +415,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
for i, line in enumerate(lines):
|
||||
if i > 0:
|
||||
# Send Enter key between lines
|
||||
await asyncio.to_thread(self.device.shell, "input keyevent 66")
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
"input keyevent 66",
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
if line:
|
||||
escaped = _escape_for_adb_text(line)
|
||||
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
f"input text {escaped}",
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Input text failed for '{text}': {e}")
|
||||
@@ -386,7 +439,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
key_name = str(key).lower().replace("keycode.", "")
|
||||
keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key)
|
||||
|
||||
await asyncio.to_thread(self.device.shell, f"input keyevent {keycode_val}")
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
f"input keyevent {keycode_val}",
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Press key failed for '{key}': {e}")
|
||||
@@ -395,7 +452,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
async def launch_app(self, package_name: str) -> bool:
|
||||
try:
|
||||
cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1"
|
||||
await asyncio.to_thread(self.device.shell, cmd)
|
||||
await asyncio.to_thread(
|
||||
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Launch app failed for '{package_name}': {e}")
|
||||
@@ -403,7 +462,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
|
||||
async def stop_app(self, package_name: str) -> bool:
|
||||
try:
|
||||
await asyncio.to_thread(self.device.shell, f"am force-stop {package_name}")
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
f"am force-stop {package_name}",
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Stop app failed for '{package_name}': {e}")
|
||||
@@ -439,7 +502,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(self.device.shell, command),
|
||||
asyncio.to_thread(
|
||||
self.device.shell,
|
||||
command,
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -22,9 +22,12 @@ Validator -- which runs its own UI-change polling -- simply never calls it.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import time
|
||||
from PIL import Image
|
||||
|
||||
from artemis.config import get_temp_dir
|
||||
from artemis.config.settings import settings
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.mcp.action_types import ActionCode, ObserveResult
|
||||
@@ -34,7 +37,29 @@ from artemis.utils.visualization import format_minimal_list_with_elements
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["observe"]
|
||||
__all__ = ["observe", "require_observation", "DeviceObservationError"]
|
||||
|
||||
|
||||
class DeviceObservationError(RuntimeError):
|
||||
"""A live device could not be observed after bounded recovery."""
|
||||
|
||||
|
||||
async def require_observation(
|
||||
ctx: ArtemisContext, controller: UnifiedMobileController, settle_ms: int = 400
|
||||
) -> tuple[ObserveResult, bytes | None]:
|
||||
"""Recover observation without spending model calls on unavailable screens."""
|
||||
last = "设备未返回有效画面"
|
||||
for attempt in range(settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS):
|
||||
observation, image = await observe(ctx, controller, settle_ms=settle_ms)
|
||||
if observation.ok:
|
||||
return observation, image
|
||||
last = observation.message
|
||||
logger.warning(
|
||||
f"设备观测恢复 {attempt + 1}/{settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS}:{last}"
|
||||
)
|
||||
raise DeviceObservationError(
|
||||
f"设备观测失败(已尝试 {settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS} 次):{last}"
|
||||
)
|
||||
|
||||
|
||||
async def observe(
|
||||
@@ -60,9 +85,19 @@ async def observe(
|
||||
await asyncio.sleep(settle_ms / 1000.0)
|
||||
else:
|
||||
logger.info("Retrieving screen data directly (skip settling)...")
|
||||
device_data = await controller.get_screen_data()
|
||||
device_data = await asyncio.wait_for(
|
||||
controller.get_screen_data(), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
|
||||
)
|
||||
screenshot_bytes = base64.b64decode(device_data.base64)
|
||||
with Image.open(BytesIO(screenshot_bytes)) as image:
|
||||
if min(image.size) <= 1:
|
||||
raise ValueError("设备截图为占位图,当前画面不可用")
|
||||
image.verify()
|
||||
except Exception as e:
|
||||
if isinstance(e, TimeoutError):
|
||||
e = TimeoutError(
|
||||
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
|
||||
)
|
||||
logger.error(f"Failed to capture screen state: {e}")
|
||||
return (
|
||||
ObserveResult(
|
||||
|
||||
@@ -221,6 +221,7 @@ def submit_task_to_daemon(
|
||||
conversation_id: str | None = None,
|
||||
verification_level: str | None = None,
|
||||
explorer_mode: str | None = None,
|
||||
runtime_overrides: dict[str, Any] | None = None,
|
||||
base_url: str | None = None,
|
||||
timeout: float = 15.0,
|
||||
) -> dict[str, Any] | None:
|
||||
@@ -246,6 +247,7 @@ def submit_task_to_daemon(
|
||||
"session_id": session_id,
|
||||
"ingress": ingress,
|
||||
"conversation_id": conversation_id,
|
||||
"runtime_overrides": runtime_overrides,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Execution budgets shared by supervised web and MCP tasks."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
from artemis.config.settings import settings
|
||||
|
||||
|
||||
def task_timeout(item: dict, job: dict | None = None, batch: dict | None = None) -> int:
|
||||
options = item.get("runtime_overrides") or (job or {}).get("case", {}).get("task_config") or {}
|
||||
return int(
|
||||
options.get("timeout_seconds")
|
||||
or (batch or {}).get("timeout_seconds")
|
||||
or settings.ARTEMIS_TASK_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionBudget:
|
||||
limit: float
|
||||
elapsed: float = 0
|
||||
_last: float = field(default_factory=time.monotonic)
|
||||
_excluded: bool = False
|
||||
|
||||
def update(self, *, excluded: bool = False) -> float:
|
||||
now = time.monotonic()
|
||||
if not self._excluded:
|
||||
self.elapsed += max(0, now - self._last)
|
||||
self._last, self._excluded = now, excluded
|
||||
return max(0, self.limit - self.elapsed)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Persist useful failure evidence even when a worker must be terminated."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from artemis.config import TRACES_PATH
|
||||
|
||||
|
||||
def read_worker_outcome(session_id: str) -> dict:
|
||||
"""Read a worker's terminal signal without waiting for hung SDK threads."""
|
||||
try:
|
||||
data = json.loads(
|
||||
(TRACES_PATH / session_id / "run_outcome.json").read_text(encoding="utf-8")
|
||||
)
|
||||
if (
|
||||
isinstance(data, dict)
|
||||
and data.get("worker_finalized") is True
|
||||
and data.get("task_status")
|
||||
in {
|
||||
"failed",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
}
|
||||
):
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
pass # Atomic outcome is absent until the task finalizes.
|
||||
return {}
|
||||
|
||||
|
||||
def save_failure_result(session_id: str, error: str, *, code: str = "execution_failed") -> None:
|
||||
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
|
||||
raise ValueError("无效的任务编号")
|
||||
directory = TRACES_PATH / session_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
payload: dict = {}
|
||||
try:
|
||||
previous = json.loads((directory / "result.json").read_text(encoding="utf-8"))
|
||||
if isinstance(previous, dict):
|
||||
payload.update(previous)
|
||||
except (OSError, ValueError):
|
||||
pass # Missing/truncated results are expected after a killed worker.
|
||||
payload.update(
|
||||
{
|
||||
"status": "failed",
|
||||
"task_status": "failed",
|
||||
"explanation": error,
|
||||
"error": error,
|
||||
"code": code,
|
||||
"worker_finalized": True,
|
||||
"tests": payload.get("tests") or {},
|
||||
}
|
||||
)
|
||||
for name in ("result.json", "run_outcome.json"):
|
||||
target = directory / name
|
||||
temporary = target.with_name(f".{name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -32,6 +32,7 @@ import shutil
|
||||
from shutil import which
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from types import NoneType
|
||||
from typing import Any, TypeVar, overload
|
||||
import uuid
|
||||
@@ -573,6 +574,13 @@ class Agent:
|
||||
)
|
||||
try:
|
||||
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
from artemis.runtime.task_control import control_dir, _write_atomic
|
||||
|
||||
if sess_id and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
|
||||
_write_atomic(
|
||||
control_dir(str(sess_id)) / "task_control_state.json",
|
||||
{"status": "queued", "updated_at": time.time(), "pid": os.getpid()},
|
||||
)
|
||||
queue_cancel_event = threading.Event()
|
||||
acquire_task = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
@@ -605,6 +613,7 @@ class Agent:
|
||||
|
||||
task_control = TaskControl(str(sess_id), device_lock)
|
||||
context.task_control = task_control
|
||||
task_control._state("running")
|
||||
self._prepare_output_files(task=task)
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
if self._ui_adb_client is not None:
|
||||
@@ -712,6 +721,14 @@ class Agent:
|
||||
state=last_state_snapshot,
|
||||
error=err,
|
||||
)
|
||||
from artemis.runtime.task_result import save_failure_result
|
||||
|
||||
if sess_id:
|
||||
save_failure_result(
|
||||
str(sess_id),
|
||||
err,
|
||||
code=flash_result.get("code", "execution_failed"),
|
||||
)
|
||||
if context.data_engine:
|
||||
context.data_engine.end_session("failed")
|
||||
return output
|
||||
@@ -862,6 +879,10 @@ class Agent:
|
||||
state=last_state_snapshot,
|
||||
error=err,
|
||||
)
|
||||
from artemis.runtime.task_result import save_failure_result
|
||||
|
||||
if sess_id:
|
||||
save_failure_result(str(sess_id), err)
|
||||
if context.data_engine:
|
||||
context.data_engine.end_session("failed")
|
||||
|
||||
|
||||
Reference in new issue
Block a user