mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
A portable plugin with no app declaration for this OS maps to missing_app, so describe() told the user to install an app that cannot fix the OS restriction. It now says the app is not supported on this operating system.
214 lines
8.9 KiB
Python
214 lines
8.9 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]
|
|
|
|
|
|
_AVAILABILITY_PROBLEMS: dict[str, LivenessState] = {
|
|
"missing_app": "missing_app",
|
|
"unsupported_os": "missing_app",
|
|
"unsupported_gpu": "unsupported_gpu",
|
|
"version_too_old": "version_too_old",
|
|
}
|
|
|
|
|
|
def availability_problem(available: Availability) -> LivenessState | None:
|
|
"""The unavailable state an install-time availability verdict maps to; None when it is not a problem."""
|
|
return _AVAILABILITY_PROBLEMS.get(available.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)
|
|
problem = availability_problem(available)
|
|
if problem is not None:
|
|
state: LivenessState = problem
|
|
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" and available.state == "unsupported_os":
|
|
reason = f"{app_name} is not supported on this operating system."
|
|
action = "Use a supported operating system."
|
|
elif liveness_state == "missing_app":
|
|
reason = f"{app_name} is not installed."
|
|
if available.min_version:
|
|
action = f"Install {app_name} {available.min_version} or newer, then try again."
|
|
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)
|