fix(runtime): release 2.7.1 with consistent ADB and failure diagnostics
This commit is contained in:
1 parent
b2316ca249
commit
8bf59897bb
24 files changed
+792
-45
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user