fix(runtime): release 2.7.1 with consistent ADB and failure diagnostics

This commit is contained in:
chenmingxuan committed 2026-11-05 11:40:49 +08:00
1 parent b2316ca249
commit 8bf59897bb
24 files changed
+792 -45

No files matched your search

+22 -15
View File
@@ -23,11 +23,15 @@ from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.utils.logger import get_logger
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
logger = get_logger(__name__)
class HopperError(RuntimeError):
"""Extraction failed; this says nothing about whether the requested data exists."""
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
@@ -55,6 +59,12 @@ class HopperOutput(BaseModel):
+ " and how you decided what to extract."
)
@model_validator(mode="after")
def validate_found_output(self):
if self.found and (not self.output or not self.output.strip()):
raise ValueError("A successful extraction must contain a nonempty output")
return self
@trace(type="agent", name="hopper")
async def hopper(
@@ -72,25 +82,22 @@ async def hopper(
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"),
]
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
async def extract(model) -> HopperOutput:
response = await invoke_llm_with_timeout_message(model.ainvoke(messages))
return HopperOutput.model_validate(response)
try:
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
if use_fallback:
llm_fallback = get_llm(
ctx=ctx, name="hopper", is_utils=True, use_fallback=True
).with_structured_output(HopperOutput)
response: HopperOutput = await with_fallback(
main_call=lambda: invoke_llm_with_timeout_message(llm.ainvoke(messages)),
fallback_call=lambda: invoke_llm_with_timeout_message(
llm_fallback.ainvoke(messages)
),
) # type: ignore
return await with_fallback(
main_call=lambda: extract(llm),
fallback_call=lambda: extract(llm_fallback),
)
else:
response: HopperOutput = await invoke_llm_with_timeout_message(llm.ainvoke(messages))
return response
return await extract(llm)
except Exception as e:
logger.error(f"Hopper LLM invocation failed: {e}")
return HopperOutput(
found=False,
output=None,
reason=f"Failed due to LLM error: {str(e)[:500]}",
)
raise HopperError("模型未能完成数据提取,请查看模型请求诊断。") from e
+7 -1
View File
@@ -36,6 +36,8 @@ from artemis.controllers.types import (
SwipeStartEndPercentagesRequest,
TapOutput,
)
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
from artemis.toolchain import find_scrcpy
from artemis.utils.logger import get_logger
from artemis.utils.video import (
ANDROID_RECORDING_SEGMENT_SECONDS,
@@ -82,9 +84,13 @@ class UnifiedMobileController:
@staticmethod
async def _spawn_scrcpy(command: list[str]) -> asyncio.subprocess.Process:
adb = AdbSession(current_adb_endpoint())
if not adb.adb_path:
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
kwargs: dict[str, Any] = {
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
"env": adb.environment(),
}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
@@ -606,7 +612,7 @@ class UnifiedMobileController:
config = recording_config(self.ctx)
bitrate = config.h265_kbps if codec == "h265" else config.h264_kbps
return build_scrcpy_record_command(
"scrcpy",
find_scrcpy(),
device_id,
path,
f"{bitrate}K",
+5
View File
@@ -39,6 +39,7 @@ from artemis.runtime.observation_budget import (
current_observation,
)
from artemis.toolchain import find_ffmpeg, find_scrcpy
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
from artemis.utils.video import build_scrcpy_record_command
from artemis.utils.ui_filter import filter_ui_hierarchy
from artemis.utils.logger import get_logger
@@ -608,6 +609,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._recording_output_path = out_dir / "recording.mp4"
logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...")
try:
adb = AdbSession(current_adb_endpoint())
if not adb.adb_path:
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
scrcpy_bin = find_scrcpy()
cmd = build_scrcpy_record_command(
scrcpy_bin,
@@ -617,6 +621,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
)
self._scrcpy_process = await asyncio.create_subprocess_exec(
*cmd,
env=adb.environment(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
+2
View File
@@ -167,6 +167,8 @@ class AdbSession:
def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]:
environment = dict(base if base is not None else os.environ)
self.endpoint.apply_to_environment(environment)
if self.adb_path:
environment["ADB"] = self.adb_path
return environment
def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
+207
View File
@@ -0,0 +1,207 @@
"""Small, task-owned runtime snapshots; no polling or readiness decisions."""
import asyncio
import ctypes
from ctypes import wintypes
from functools import lru_cache
import hashlib
import json
import logging
import os
from pathlib import Path
import platform
import subprocess
import sys
import time
import psutil
from artemis._version import get_version
from artemis.config.paths import ROOT_DIR
from artemis.runtime.adb_endpoint import current_adb_endpoint
from artemis.toolchain import toolchain
logger = logging.getLogger(__name__)
FILENAME = "runtime_diagnostics.json"
def _command(arguments: list[str]) -> str | None:
try:
result = subprocess.run(
arguments,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=3,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
check=False,
)
return result.stdout.strip()[:4096] if result.returncode == 0 else None
except (OSError, subprocess.SubprocessError):
return None
def _tool_identity(name: str) -> dict:
path = toolchain.resolve(name)
result = {"path": path, "version": None, "sha256": None}
if path:
result["version"] = _command([path, "version" if name == "adb" else "--version"])
try:
with Path(path).open("rb") as stream:
result["sha256"] = hashlib.file_digest(stream, "sha256").hexdigest()
except OSError:
logger.debug("Could not hash %s", name)
return result
@lru_cache(maxsize=2)
def runtime_identity(android: bool) -> dict:
"""Read identity once per worker, before later source updates can change it."""
git = ["git", "-C", str(ROOT_DIR)]
checkout = (ROOT_DIR / ".git").exists()
commit = _command([*git, "rev-parse", "--verify", "HEAD"]) if checkout else None
changes = (
_command([*git, "status", "--porcelain", "--untracked-files=no"]) if checkout else None
)
return {
"artemis_version": get_version(),
"source_commit": commit,
"source_modified": bool(changes) if changes is not None else None,
"installation_archive_sha256": None,
"python_version": platform.python_version(),
"platform": platform.system(),
"tools": {name: _tool_identity(name) for name in ("adb", "scrcpy")} if android else {},
}
class _PerformanceInformation(ctypes.Structure):
# https://learn.microsoft.com/windows/win32/api/psapi/ns-psapi-performance_information
_fields_ = (
[("cb", wintypes.DWORD)]
+ [
(name, ctypes.c_size_t)
for name in (
"CommitTotal",
"CommitLimit",
"CommitPeak",
"PhysicalTotal",
"PhysicalAvailable",
"SystemCache",
"KernelTotal",
"KernelPaged",
"KernelNonpaged",
"PageSize",
)
]
+ [(name, wintypes.DWORD) for name in ("HandleCount", "ProcessCount", "ThreadCount")]
)
def _windows_commit() -> dict | None:
if sys.platform != "win32":
return None
values = _PerformanceInformation()
values.cb = ctypes.sizeof(values)
query = ctypes.WinDLL("psapi", use_last_error=True).GetPerformanceInfo
query.argtypes = [ctypes.POINTER(_PerformanceInformation), wintypes.DWORD]
query.restype = wintypes.BOOL
if not query(ctypes.byref(values), values.cb):
raise ctypes.WinError(ctypes.get_last_error())
committed = values.CommitTotal * values.PageSize
limit = values.CommitLimit * values.PageSize
return {
"committed_bytes": committed,
"limit_bytes": limit,
"available_bytes": max(0, limit - committed),
"note": "系统提交额度,不能当作页面文件大小。",
}
def _memory_snapshot() -> dict:
result = {"physical": None, "system_commit": None, "worker": None}
try:
memory = psutil.virtual_memory()
result["physical"] = {"total_bytes": memory.total, "available_bytes": memory.available}
result["system_commit"] = _windows_commit()
except (OSError, psutil.Error):
logger.debug("Host memory snapshot unavailable")
try:
memory = psutil.Process().memory_info()
result["worker"] = {
"pid": os.getpid(),
"rss_bytes": memory.rss,
"private_bytes": getattr(memory, "private", None),
}
except (OSError, psutil.Error):
logger.debug("Worker memory snapshot unavailable")
return result
def _adb_server() -> dict:
endpoint = current_adb_endpoint()
result = {"endpoint": endpoint.to_dict(), "local_listener": None}
if endpoint.host.lower() not in {"localhost", "127.0.0.1", "::1"}:
return result # A remote server's process cannot be observed on this host.
try:
for connection in psutil.net_connections(kind="tcp"):
if (
connection.status == psutil.CONN_LISTEN
and connection.laddr.port == endpoint.port
and connection.laddr.ip
in {
endpoint.host,
"0.0.0.0",
"::",
*(("127.0.0.1", "::1") if endpoint.host.lower() == "localhost" else ()),
}
and connection.pid
):
process = psutil.Process(connection.pid)
result["local_listener"] = {
"pid": process.pid,
"name": process.name(),
"path": process.exe(),
}
break
except (OSError, psutil.Error):
logger.debug("ADB listener identity unavailable")
return result
def write_runtime_diagnostics(directory: Path, phase: str, *, android: bool = True) -> None:
"""Keep a start snapshot and the latest failure snapshot without changing task results."""
if phase not in {"start", "failure"}:
raise ValueError("Unknown runtime snapshot phase")
path = directory / FILENAME
try:
payload = {"schema_version": 1}
if path.is_file() and path.stat().st_size <= 256 * 1024:
try:
saved = json.loads(path.read_text(encoding="utf-8"))
if isinstance(saved, dict):
payload = saved
except (OSError, ValueError):
logger.debug("Previous runtime snapshot unreadable")
if phase == "start" and phase in payload:
return
payload[phase] = {
"captured_at": time.time(),
"runtime": runtime_identity(android),
"memory": _memory_snapshot(),
"adb_server": _adb_server() if android else None,
}
directory.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
except (OSError, psutil.Error, ValueError, TypeError):
logger.warning("Could not save runtime snapshot (%s)", phase, exc_info=True)
async def capture_runtime_diagnostics(directory: Path, phase: str, *, android: bool = True) -> None:
"""Keep optional evidence failures outside the task's execution result."""
try:
await asyncio.to_thread(write_runtime_diagnostics, directory, phase, android=android)
except (OSError, RuntimeError):
logger.warning("Could not start runtime snapshot (%s)", phase, exc_info=True)
+15
View File
@@ -654,6 +654,13 @@ class Agent:
# publish a new active session while the current task is still
# finishing.
self._prepare_tracing(task=task, context=context)
from artemis.runtime.diagnostics import capture_runtime_diagnostics
await capture_runtime_diagnostics(
context.data_engine.base_dir,
"start",
android=request.execution_target != "computer",
)
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
from artemis.runtime.task_control import TaskControl
@@ -1333,6 +1340,14 @@ class Agent:
async def _finalize_tracing(self, task: Task, context: ArtemisContext):
if context.data_engine:
if task.status == "failed":
from artemis.runtime.diagnostics import capture_runtime_diagnostics
await capture_runtime_diagnostics(
context.data_engine.base_dir,
"failure",
android=context.execution_target != "computer",
)
session_status = "completed"
if task.status == "failed":
session_status = "failed"
+9 -4
View File
@@ -748,10 +748,15 @@ class RobustChatModelWrapper:
if observation is not None and (usage := usage_from_message(response)) is not None:
observation.data["usage"] = usage
ensure_response_complete(response)
if self.structured_mode == "parsed" and isinstance(response, dict) and "raw" in response:
if response.get("parsing_error") is not None:
raise response["parsing_error"]
return response["parsed"]
if self.structured_mode == "parsed":
if isinstance(response, dict) and "raw" in response:
if response.get("parsing_error") is not None:
raise ResponseIntegrityError("invalid_structured_output") from response[
"parsing_error"
]
response = response.get("parsed")
if response is None:
raise ResponseIntegrityError("missing_structured_output")
return response
async def ainvoke(self, *args, **kwargs):
+5 -7
View File
@@ -18,7 +18,7 @@ from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from artemis.agents.hopper.hopper import HopperOutput, hopper
from artemis.agents.hopper.hopper import HopperError, HopperOutput, hopper
from artemis.context import ArtemisContext
from artemis.controllers.platform_specific_commands_controller import (
list_packages_async,
@@ -53,7 +53,7 @@ LAUNCH_APP_DOCSTRING = (
async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None:
"""Finds the package name for a given application name.
Returns None if package not found or on error.
Returns None if no installed package matches. Model failures raise HopperError.
"""
package_cache = getattr(ctx, "package_cache", None)
if package_cache is None or not isinstance(package_cache, dict):
@@ -63,7 +63,7 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
except Exception: # pylint: disable=broad-exception-caught
package_cache = {}
if isinstance(package_cache, dict) and app_name in package_cache:
if isinstance(package_cache, dict) and package_cache.get(app_name):
logger.info(f"Cache hit for app '{app_name}': {package_cache[app_name]}")
return package_cache[app_name]
@@ -84,8 +84,6 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
use_fallback=use_fallback,
)
if not hopper_output.found or not hopper_output.output:
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None
package_name = hopper_output.output.strip()
@@ -94,13 +92,13 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
f"Hopper returned package '{package_name}' for '{app_name}', "
"but it is NOT physically installed on the device!"
)
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None
if isinstance(package_cache, dict):
package_cache[app_name] = package_name
return package_name
except HopperError:
raise
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to find package for '{app_name}': {e}")
return None