When every DRM card's vendor file or every Windows display adapter's MatchingDeviceId was unreadable, classify_gpu_vendors returned 'none' and availability refused with unsupported_gpu, contradicting the documented 'unreadable GPU passes' rule. Windows now records an unreadable numbered adapter as an empty id so the classifier sees it. Also drops the type-narrowing assert in availability() and makes _action's gpu_label required.
199 lines
8.3 KiB
Python
199 lines
8.3 KiB
Python
"""Application-backed MCP liveness parsing and user-facing status descriptions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Literal
|
|
|
|
from hermes_platform import declaration
|
|
from hermes_platform.host import facts
|
|
from hermes_platform.resolver.app import AppDef, AppResolver
|
|
from hermes_platform.resolver.availability import Availability, availability
|
|
from hermes_platform.resolver.base import Effort
|
|
from hermes_platform.resolver.core import CheckState
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LivenessKind = Literal["static", "server_json", "interactive_session"]
|
|
LivenessState = Literal[
|
|
"app_not_running",
|
|
"hermes_not_connected",
|
|
"endpoint_unavailable",
|
|
"no_interactive_session",
|
|
"version_too_old",
|
|
"missing_app",
|
|
"unsupported_gpu",
|
|
]
|
|
Retry = Literal["after_user_action", "never_here"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Liveness:
|
|
kind: LivenessKind
|
|
path: str = ""
|
|
url_field: str = "http"
|
|
token_field: str = "token"
|
|
pid_field: str = "pid"
|
|
|
|
def app_definition(self, definition: AppDef) -> AppDef:
|
|
if self.kind != "server_json":
|
|
return definition
|
|
return replace(
|
|
definition,
|
|
liveness_kind="server_json",
|
|
liveness_path=self.path,
|
|
liveness_pid_key=self.pid_field,
|
|
liveness_url_key=self.url_field,
|
|
liveness_token_key=self.token_field,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Status:
|
|
state: LivenessState
|
|
availability: Availability
|
|
liveness: Liveness
|
|
user_action: str
|
|
retry: Retry
|
|
|
|
|
|
def parse_liveness(raw: Any) -> Liveness:
|
|
"""Parse one portable-plugin liveness declaration."""
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("liveness must be an object")
|
|
kind = raw.get("kind")
|
|
if kind == "static":
|
|
if set(raw) != {"kind"}:
|
|
raise ValueError("static liveness only accepts 'kind'")
|
|
return Liveness("static")
|
|
if kind == "interactive_session":
|
|
if set(raw) != {"kind"}:
|
|
raise ValueError("interactive_session liveness only accepts 'kind'")
|
|
return Liveness("interactive_session")
|
|
if kind != "server_json":
|
|
raise ValueError(f"unknown liveness kind: {kind!r}")
|
|
if set(raw) - {"kind", "path", "fields"}:
|
|
raise ValueError("server_json liveness has unknown fields")
|
|
path = raw.get("path")
|
|
fields = {"url": "http", "token": "token", "pid": "pid", **(raw.get("fields") or {})}
|
|
if not isinstance(path, str) or not path.strip():
|
|
raise ValueError("server_json liveness requires a non-empty path")
|
|
if set(fields) != {"url", "token", "pid"}:
|
|
raise ValueError("server_json liveness fields may only override url, token, and pid")
|
|
if any(not isinstance(value, str) or not value.strip() for value in fields.values()):
|
|
raise ValueError("server_json liveness field names must be non-empty strings")
|
|
return Liveness(
|
|
"server_json",
|
|
path=path.strip(),
|
|
url_field=fields["url"].strip(),
|
|
token_field=fields["token"].strip(),
|
|
pid_field=fields["pid"].strip(),
|
|
)
|
|
|
|
|
|
def liveness_for(server_name: str) -> Liveness:
|
|
"""Return a server's registered liveness declaration, defaulting to static."""
|
|
try:
|
|
from hermes_cli.agent_plugins import liveness_for as registered_liveness
|
|
except ImportError:
|
|
return Liveness("static")
|
|
raw = registered_liveness(server_name)
|
|
if raw is None:
|
|
return Liveness("static")
|
|
try:
|
|
return parse_liveness(raw)
|
|
except ValueError as exc:
|
|
logger.warning("MCP server '%s' has an invalid liveness declaration (%s); treating it as static", server_name, exc)
|
|
return Liveness("static")
|
|
|
|
|
|
def _action(state: LivenessState, app_name: str, gpu_label: str) -> tuple[str, Retry]:
|
|
actions: dict[LivenessState, tuple[str, Retry]] = {
|
|
"app_not_running": (f"Start {app_name}, then try again.", "after_user_action"),
|
|
"hermes_not_connected": (f"Reconnect {app_name} in Hermes, then try again.", "after_user_action"),
|
|
"endpoint_unavailable": (f"Open {app_name} and enable its local connection, then try again.", "after_user_action"),
|
|
"no_interactive_session": (f"Open an interactive desktop session and start {app_name}, then try again.", "never_here"),
|
|
"version_too_old": (f"Update {app_name}, then try again.", "after_user_action"),
|
|
"missing_app": (f"Install {app_name}, then try again.", "after_user_action"),
|
|
"unsupported_gpu": (f"Use {app_name} on a machine with {gpu_label}.", "never_here"),
|
|
}
|
|
return actions[state]
|
|
|
|
|
|
def status(server_name: str) -> Status | None:
|
|
"""Return the current unavailable state for a registered declaration."""
|
|
decl = declaration.lookup(server_name)
|
|
if decl is None:
|
|
return None
|
|
available = availability(decl)
|
|
live = liveness_for(server_name)
|
|
if available.state in {"missing_app", "unsupported_os"}:
|
|
state: LivenessState = "missing_app"
|
|
elif available.state == "unsupported_gpu":
|
|
state = "unsupported_gpu"
|
|
elif available.state == "version_too_old":
|
|
state = "version_too_old"
|
|
elif live.kind == "interactive_session" and not facts.interactive_session():
|
|
state = "no_interactive_session"
|
|
elif live.kind == "server_json":
|
|
definition = decl.app_for(facts.os_family())
|
|
if definition is None:
|
|
state = "missing_app"
|
|
else:
|
|
probe = AppResolver(live.app_definition(definition)).probe(
|
|
AppResolver(live.app_definition(definition)).locate(), effort=Effort.LOCAL
|
|
)
|
|
if probe.running.value is not True:
|
|
state = "app_not_running"
|
|
elif probe.endpoint.state is not CheckState.PRESENT:
|
|
state = "endpoint_unavailable"
|
|
else:
|
|
# The app runs and its endpoint answers: the only thing missing is Hermes' own
|
|
# MCP connection to it. Telling the user to "start" an app that IS running is
|
|
# the wrong instruction (#119975).
|
|
state = "hermes_not_connected"
|
|
else:
|
|
# static / unknown liveness kinds cannot observe the app, so they cannot conclude it
|
|
# is not running; the honest answer is that Hermes is not connected (#119975).
|
|
state = "hermes_not_connected"
|
|
action, retry = _action(state, decl.name, declaration.gpu_label(decl.required_gpu))
|
|
return Status(state, available, live, action, retry)
|
|
|
|
|
|
def describe(decl: declaration.Declaration, available: Availability, liveness_state: LivenessState,
|
|
display_name: str | None = None) -> str:
|
|
"""Compose one unavailable-state sentence with one user action.
|
|
|
|
``display_name`` overrides the declaration's slug when the caller knows the plugin's
|
|
catalog/manifest title — the Plugins tab does, and a raw slug reads like an error code."""
|
|
app_name = display_name or decl.name
|
|
gpu = declaration.gpu_label(decl.required_gpu)
|
|
action, _retry = _action(liveness_state, app_name, gpu)
|
|
if liveness_state == "missing_app":
|
|
reason = f"{app_name} is not installed."
|
|
elif liveness_state == "unsupported_gpu":
|
|
reason = f"{app_name} needs {gpu}; none was found on this machine."
|
|
elif liveness_state == "version_too_old":
|
|
found = f" version {available.version}" if available.version else ""
|
|
minimum = f"; version {available.min_version} or newer is required" if available.min_version else ""
|
|
reason = f"{app_name}{found} is too old{minimum}."
|
|
elif liveness_state == "no_interactive_session":
|
|
reason = f"{app_name} needs an interactive desktop session."
|
|
elif liveness_state == "endpoint_unavailable":
|
|
reason = f"{app_name}'s local endpoint is unavailable."
|
|
elif liveness_state == "hermes_not_connected":
|
|
reason = f"{app_name}'s MCP connection is missing."
|
|
else:
|
|
reason = f"{app_name} is not running."
|
|
return f"{reason} {action}"
|
|
|
|
|
|
def unavailable_details(server_name: str) -> tuple[declaration.Declaration, Status, str] | None:
|
|
"""Return declaration, structured state, and its composed sentence."""
|
|
decl = declaration.lookup(server_name)
|
|
current = status(server_name)
|
|
if decl is None or current is None:
|
|
return None
|
|
return decl, current, describe(decl, current.availability, current.state)
|