refactor(observability): move Relay runtime ownership into core

Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
Alex Fournier 2026-07-19 08:54:49 -04:00
parent 3bd338d2a9
commit 64faff6768
20 changed files with 1826 additions and 1234 deletions

View file

@ -7,6 +7,9 @@ graft locales
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
# below covers the wheel; this covers the sdist. See #34034 / #28149.
recursive-include plugins plugin.yaml plugin.yml
# The built-in shared-metrics package validates exported JSON against this
# closed schema. Include it when downstream packagers build from the sdist.
recursive-include hermes_cli/observability/schemas *.json
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
recursive-include gateway/assets *
global-exclude __pycache__

View file

@ -14,6 +14,10 @@ Behavior-changing request or execution wrappers are outside this observer
contract. Observer hooks should report what happened; they should not replace
provider requests, tool arguments, or execution callbacks.
Hermes also has a first-party NeMo Relay shared-metrics path. It uses these
lifecycle boundaries directly and does not require enabling an observability
plugin. See [Relay shared metrics](relay-shared-metrics.md).
## Contract
Plugins register observer callbacks from `register(ctx)`:

View file

@ -0,0 +1,72 @@
# NeMo Relay Shared Metrics
Hermes includes NeMo Relay as a normal runtime dependency on platforms for
which Relay publishes a native wheel. The shared-metrics integration is built
into Hermes and does not require `hermes plugins enable
observability/nemo_relay`. Hermes remains importable without Relay on other
native targets, but Relay-backed instrumentation is unavailable there.
Collection remains off unless Hermes policy enables it:
```yaml
telemetry:
shared_metrics:
enabled: true
```
The existing `observability/nemo_relay` plugin remains separate. Enable that
plugin only for its opt-in rich observability exporters, adaptive execution,
or dynamic Relay plugins.
Hermes core owns one Relay host and one isolated Relay session scope per Hermes
session. Core lifecycle producers use
`hermes_cli.observability.relay_runtime` to obtain the shared session handle or
run Relay scope, LLM, tool, and mark APIs in that session context. New product
marks do not require Hermes plugin registration. Shared-metrics marks must
still contain only fields approved by the versioned allowlist; the hard
dependency does not change the collection or privacy policy.
## Current Slice
The first vertical slice records one logical model-call counter:
```text
Hermes API hooks
-> Relay session and LLM lifecycle
-> Hermes shared-metrics subscriber
-> SQLite counter
-> immutable JSON delta package
```
Hermes sends an empty `LLMRequest` into this metrics lifecycle. The terminal
event contains only bounded model family, provider family, locality, call role,
and outcome values. Prompts, responses, exact model IDs, endpoints, errors,
session IDs, task IDs, and request IDs are not included in the metrics event or
package.
Local state is written under:
```text
$HERMES_HOME/telemetry/shared_metrics/metrics.sqlite3
$HERMES_HOME/telemetry/shared_metrics/outbox/*.json
```
The database keeps transactional aggregate and package-outbox state. Package
files are immutable delta documents that conform to a closed JSON schema and
are written with atomic replacement.
## Smoke Test
Run a real Hermes CLI turn against the deterministic local model server:
```bash
./.venv/bin/python scripts/smoke_nemo_relay_shared_metrics.py
```
The script uses the installed `nemo-relay` dependency by default. Pass
`--relay-python ../nemo-relay/python` only when testing a locally built Relay
binding.
The smoke verifies the model request reached the local server, one counter was
stored, one package was exported, and prompt, response, and exact-model
canaries are absent from the package.

View file

@ -0,0 +1,51 @@
"""First-party Hermes observability integrations."""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def prepare_lifecycle(hook_name: str, **kwargs: Any) -> None:
"""Prepare subscribers that must observe a session's start event."""
from . import relay_runtime, relay_shared_metrics
if hook_name in relay_runtime.SESSION_START_HOOKS:
try:
relay_shared_metrics.prepare_session_start()
except Exception:
logger.warning("Built-in observability preparation failed", exc_info=True)
def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
"""Dispatch a Hermes lifecycle event to built-in observability features."""
from . import relay_runtime, relay_shared_metrics
# Session-start plugin callbacks register optional per-session subscribers
# before this completion step opens the shared core scope. On teardown,
# metrics finish child LLM scopes before the neutral host closes the owner.
if hook_name not in relay_runtime.SESSION_CLOSE_HOOKS:
_safe_observe(relay_runtime.observe_lifecycle, hook_name, kwargs)
_safe_observe(relay_shared_metrics.observe_lifecycle, hook_name, kwargs)
if hook_name in relay_runtime.SESSION_CLOSE_HOOKS:
_safe_observe(relay_runtime.observe_lifecycle, hook_name, kwargs)
def handles_hook(hook_name: str) -> bool:
"""Return whether any built-in observability feature handles a hook."""
from . import relay_runtime, relay_shared_metrics
return relay_runtime.handles_hook(hook_name) or relay_shared_metrics.handles_hook(
hook_name
)
def _safe_observe(callback: Any, hook_name: str, kwargs: dict[str, Any]) -> None:
try:
callback(hook_name, **kwargs)
except Exception:
logger.warning(
"Built-in observability hook failed: %s", hook_name, exc_info=True
)

View file

@ -0,0 +1,372 @@
"""Process-wide NeMo Relay runtime owned by Hermes core."""
from __future__ import annotations
import atexit
import contextvars
import importlib
import logging
import threading
from dataclasses import dataclass, field
from typing import Any, Callable
logger = logging.getLogger(__name__)
SESSION_SCOPE = "hermes.session"
RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version"
RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1"
SESSION_START_HOOKS = frozenset({"on_session_start"})
SESSION_CLOSE_HOOKS = frozenset({"on_session_finalize", "on_session_reset"})
SUBAGENT_START_HOOKS = frozenset({"subagent_start"})
SUBAGENT_STOP_HOOKS = frozenset({"subagent_stop"})
HANDLED_HOOKS = (
SESSION_START_HOOKS
| SESSION_CLOSE_HOOKS
| SUBAGENT_START_HOOKS
| SUBAGENT_STOP_HOOKS
)
_RUNTIME_FAILED = object()
_RUNTIME: RelayRuntime | object | None = None
_RUNTIME_LOCK = threading.RLock()
@dataclass
class RelaySession:
"""One isolated Relay scope stack owned by a Hermes session."""
session_id: str
parent_session_id: str = ""
lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
closing: bool = False
handle: Any = None
context: contextvars.Context | None = None
class RelayRuntime:
"""Own Relay session scopes independently of any exporter or plugin."""
def __init__(self, relay: Any = None) -> None:
self.relay = relay or _load_nemo_relay()
self._sessions_lock = threading.RLock()
self._sessions: dict[str, RelaySession] = {}
self._subagent_parents: dict[str, str] = {}
self._shutdown_registered = True
atexit.register(self.shutdown)
def ensure_session(
self,
event: dict[str, Any],
*,
data: Any = None,
metadata: dict[str, Any] | None = None,
) -> RelaySession | None:
"""Return the existing session scope or create it once."""
session_id = _session_id(event)
if not session_id:
return None
with self._sessions_lock:
session = self._sessions.get(session_id)
if session is None:
parent_session_id = self._subagent_parents.get(session_id, "")
session = RelaySession(
session_id=session_id,
parent_session_id=parent_session_id,
)
self._sessions[session_id] = session
with session.lock:
if session.closing:
return None
if session.handle is None:
parent_handle = None
scope_metadata = {
**(metadata or {}),
RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION,
}
if session.parent_session_id:
parent = self.ensure_session({
"session_id": session.parent_session_id
})
if parent is not None:
parent_handle = parent.handle
scope_metadata["nemo_relay_scope_role"] = "subagent"
context = contextvars.Context()
try:
session.handle = context.run(
self.relay.scope.push,
SESSION_SCOPE,
self.relay.ScopeType.Agent,
handle=parent_handle,
data=data,
input={},
metadata=scope_metadata,
)
except Exception:
session.context = None
raise
session.context = context
return session
def register_subagent(self, event: dict[str, Any]) -> None:
"""Record the parent used when a delegated Hermes session starts."""
parent_session_id = str(event.get("parent_session_id") or "")
child_session_id = str(event.get("child_session_id") or "")
if (
not parent_session_id
or not child_session_id
or parent_session_id == child_session_id
):
return
self.ensure_session({"session_id": parent_session_id})
with self._sessions_lock:
self._subagent_parents[child_session_id] = parent_session_id
def unregister_subagent(self, event: dict[str, Any]) -> None:
"""Forget a delegated-session relationship after its terminal hook."""
child_session_id = str(event.get("child_session_id") or "")
if not child_session_id:
return
with self._sessions_lock:
self._subagent_parents.pop(child_session_id, None)
def get_session(self, session_id: str) -> RelaySession | None:
"""Return an active Hermes Relay session without creating one."""
with self._sessions_lock:
session = self._sessions.get(str(session_id or ""))
if session is None:
return None
with session.lock:
return None if session.closing else session
def get_session_handle(self, session_id: str) -> Any:
"""Return the Relay parent handle for a Hermes session, if active."""
session = self.get_session(session_id)
return None if session is None else session.handle
def run_in_session(
self,
session: RelaySession,
callback: Callable[..., Any],
*args: Any,
allow_closing: bool = False,
**kwargs: Any,
) -> Any:
"""Run a Relay operation against a session's isolated scope stack."""
with session.lock:
if session.closing and not allow_closing:
raise RuntimeError("Hermes Relay session is closing")
if session.context is None or session.handle is None:
raise RuntimeError("Hermes Relay session context is unavailable")
def invoke() -> Any:
self.relay.get_scope_stack()
return callback(*args, **kwargs)
# A copy permits a helper called by an existing Relay callback to
# re-enter the same logical session without re-entering Context.
return session.context.copy().run(invoke)
def emit_mark(
self,
name: str,
event: dict[str, Any],
*,
data: Any = None,
metadata: Any = None,
) -> bool:
"""Emit a mark parented to the Hermes session identified by ``event``."""
session = self.ensure_session(event)
if session is None:
return False
self.run_in_session(
session,
self.relay.scope.event,
name,
handle=session.handle,
data=data,
metadata=metadata,
)
return True
def close_session(self, event: dict[str, Any]) -> None:
"""Close one session scope and remove it from the core registry."""
session_id = _session_id(event)
with self._sessions_lock:
session = self._sessions.get(session_id)
if session is None:
return
failures: list[str] = []
with session.lock:
if session.closing:
return
session.closing = True
if session.handle is not None:
try:
self.run_in_session(
session,
self.relay.scope.pop,
session.handle,
output={},
metadata={RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION},
allow_closing=True,
)
except Exception as exc:
failures.append(f"session scope close failed: {exc}")
try:
self.relay.subscribers.flush()
except Exception as exc:
failures.append(f"subscriber flush failed: {exc}")
with self._sessions_lock:
if self._sessions.get(session_id) is session:
self._sessions.pop(session_id, None)
self._subagent_parents.pop(session_id, None)
if failures:
logger.warning(
"Hermes Relay session %s closed with errors: %s",
session_id,
"; ".join(failures),
)
def shutdown(self) -> None:
"""Close all core-owned Relay session scopes."""
with self._sessions_lock:
session_ids = list(self._sessions)
for session_id in session_ids:
self._safe(self.close_session, {"session_id": session_id})
if self._shutdown_registered:
try:
atexit.unregister(self.shutdown)
except Exception:
pass
self._shutdown_registered = False
@staticmethod
def _safe(callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
return callback(*args, **kwargs)
except Exception:
logger.warning("Hermes Relay runtime operation failed", exc_info=True)
return None
def handles_hook(hook_name: str) -> bool:
"""Return whether the core Relay host consumes this lifecycle hook."""
return hook_name in HANDLED_HOOKS
def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
"""Apply session lifecycle events to the core Relay host."""
if not handles_hook(hook_name):
return
# Session hooks do not activate Relay by themselves. A direct core
# producer or an enabled built-in consumer creates the host lazily, after
# which these hooks keep its session lifetime correct.
runtime = get_runtime(create=False)
if runtime is None:
return
try:
if hook_name in SESSION_START_HOOKS:
runtime.ensure_session(kwargs)
elif hook_name in SESSION_CLOSE_HOOKS:
runtime.close_session(kwargs)
elif hook_name in SUBAGENT_START_HOOKS:
runtime.register_subagent(kwargs)
else:
runtime.unregister_subagent(kwargs)
except Exception:
logger.warning("Hermes Relay lifecycle failed: %s", hook_name, exc_info=True)
def emit_mark(
name: str,
*,
session_id: str,
data: Any = None,
metadata: Any = None,
) -> bool:
"""Emit a fail-open Relay mark under a Hermes session."""
runtime = get_runtime()
if runtime is None:
return False
try:
return runtime.emit_mark(
name,
{"session_id": session_id},
data=data,
metadata=metadata,
)
except Exception:
logger.warning("Hermes Relay mark failed: %s", name, exc_info=True)
return False
def ensure_session(*, session_id: str, **context: Any) -> RelaySession | None:
"""Create or return the shared Relay session used by Hermes core."""
runtime = get_runtime()
if runtime is None:
return None
try:
return runtime.ensure_session({"session_id": session_id, **context})
except Exception:
logger.warning("Hermes Relay session initialization failed", exc_info=True)
return None
def run_in_session(
session_id: str,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""Run a scope, LLM, or tool API against a shared Hermes session."""
runtime = get_runtime()
if runtime is None:
raise RuntimeError("Hermes Relay runtime is unavailable")
session = runtime.get_session(session_id)
if session is None:
session = runtime.ensure_session({"session_id": session_id})
if session is None:
raise RuntimeError("Hermes Relay session is unavailable")
return runtime.run_in_session(session, callback, *args, **kwargs)
def get_session_handle(session_id: str) -> Any:
"""Return the shared Relay handle for direct core instrumentation."""
runtime = get_runtime(create=False)
return None if runtime is None else runtime.get_session_handle(session_id)
def get_runtime(*, create: bool = True) -> RelayRuntime | None:
"""Return the process-wide Hermes Relay host."""
global _RUNTIME
with _RUNTIME_LOCK:
if isinstance(_RUNTIME, RelayRuntime):
return _RUNTIME
if _RUNTIME is _RUNTIME_FAILED or not create:
return None
try:
_RUNTIME = RelayRuntime()
except Exception:
logger.warning("Hermes Relay runtime initialization failed", exc_info=True)
_RUNTIME = _RUNTIME_FAILED
return None
return _RUNTIME
def _load_nemo_relay() -> Any:
"""Load the binding only when a producer or consumer needs Relay."""
return importlib.import_module("nemo_relay")
def _session_id(event: dict[str, Any]) -> str:
return str(event.get("session_id") or "")
def _reset_for_tests() -> None:
"""Reset process-global core Relay state for isolated tests."""
global _RUNTIME
with _RUNTIME_LOCK:
if isinstance(_RUNTIME, RelayRuntime):
_RUNTIME.shutdown()
_RUNTIME = None

View file

@ -0,0 +1,342 @@
"""Direct NeMo Relay integration for Hermes shared client metrics."""
from __future__ import annotations
import atexit
import logging
import threading
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Any, Callable
from hermes_cli import __version__
from . import relay_runtime
from .shared_metrics import SharedMetricsStore
from .shared_metrics_contract import (
MODEL_CALL_SCOPE,
SCHEMA_KEY,
SCHEMA_VERSION,
SUBSCRIBER_NAME,
model_call_fields,
model_call_outcome,
)
from .shared_metrics_subscriber import SharedMetricsSubscriber
logger = logging.getLogger(__name__)
HANDLED_HOOKS = frozenset({
"on_session_start",
"on_session_end",
"on_session_finalize",
"on_session_reset",
"pre_api_request",
"post_api_request",
"api_request_error",
})
_RUNTIME_FAILED = object()
_RUNTIME: _Runtime | object | None = None
_RUNTIME_LOCK = threading.RLock()
@dataclass
class _ModelCall:
handle: Any
task_id: str
fields: dict[str, str]
@dataclass
class _MetricsSession:
session_id: str
relay_session: relay_runtime.RelaySession
lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
closing: bool = False
model_calls: dict[str, _ModelCall] = field(default_factory=dict)
class _Runtime:
"""Own shared-metrics state layered on the Hermes core Relay host."""
def __init__(self, host: relay_runtime.RelayRuntime | None = None) -> None:
resolved_host = host or relay_runtime.get_runtime()
if resolved_host is None:
raise RuntimeError("Hermes core Relay runtime is unavailable")
self.host: relay_runtime.RelayRuntime = resolved_host
self.relay = self.host.relay
self._sessions_lock = threading.RLock()
self._sessions: dict[str, _MetricsSession] = {}
self.subscriber = SharedMetricsSubscriber(SharedMetricsStore(), __version__)
self.relay.subscribers.register(SUBSCRIBER_NAME, self.subscriber)
self._registered = True
atexit.register(self.shutdown)
def ensure_session(self, event: dict[str, Any]) -> _MetricsSession | None:
session_id = str(event.get("session_id") or "")
if not session_id:
return None
relay_session = self.host.ensure_session(event)
if relay_session is None:
return None
with self._sessions_lock:
session = self._sessions.get(session_id)
if session is None:
session = _MetricsSession(
session_id=session_id,
relay_session=relay_session,
)
self._sessions[session_id] = session
with session.lock:
if session.closing:
return None
return session
def _run_in_session(
self,
session: _MetricsSession,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
return self.host.run_in_session(
session.relay_session,
callback,
*args,
**kwargs,
)
def start_model_call(self, event: dict[str, Any]) -> None:
session = self.ensure_session(event)
if session is None:
return
request_id = str(event.get("api_request_id") or "")
if not request_id:
return
fields = model_call_fields(event)
model_family = fields["model_family"]
with session.lock:
if session.closing:
return
existing = session.model_calls.get(request_id)
if existing is not None:
existing.fields = fields
return
handle = self._run_in_session(
session,
self.relay.llm.call,
MODEL_CALL_SCOPE,
self.relay.LLMRequest({}, {}),
handle=session.relay_session.handle,
metadata={SCHEMA_KEY: SCHEMA_VERSION},
model_name=model_family,
)
session.model_calls[request_id] = _ModelCall(
handle=handle,
task_id=str(event.get("task_id") or ""),
fields=fields,
)
def end_model_call(self, event: dict[str, Any], outcome: str | None = None) -> None:
session = self._session(event)
if session is None:
return
request_id = str(event.get("api_request_id") or "")
with session.lock:
if session.closing:
return
model_call = session.model_calls.get(request_id)
if model_call is None:
return
fields = model_call_fields(event)
model_call.fields = fields
self._finish_model_call(
session,
request_id,
outcome or model_call_outcome(event),
)
def end_pending_model_calls(self, event: dict[str, Any]) -> None:
session = self._session(event)
if session is None:
return
with session.lock:
if session.closing:
return
self._end_pending_model_calls(session, event)
def close_session(self, event: dict[str, Any]) -> None:
session = self._session(event)
if session is None:
return
failures: list[str] = []
with session.lock:
if session.closing:
return
session.closing = True
self._end_pending_model_calls(session, event)
try:
self.relay.subscribers.flush()
except Exception as exc:
failures.append(f"subscriber flush failed: {exc}")
self._export()
with self._sessions_lock:
if self._sessions.get(session.session_id) is session:
self._sessions.pop(session.session_id, None)
if failures:
logger.warning(
"Hermes shared-metrics session %s closed with errors: %s",
session.session_id,
"; ".join(failures),
)
def shutdown(self) -> None:
with self._sessions_lock:
session_ids = list(self._sessions)
for session_id in session_ids:
self._safe(self.close_session, {"session_id": session_id})
if not self._registered:
return
self._safe(self.relay.subscribers.flush)
self._export()
self._safe(self.relay.subscribers.deregister, SUBSCRIBER_NAME)
self._registered = False
try:
atexit.unregister(self.shutdown)
except Exception:
pass
def _session(self, event: dict[str, Any]) -> _MetricsSession | None:
session_id = str(event.get("session_id") or "")
with self._sessions_lock:
return self._sessions.get(session_id)
def _finish_model_call(
self,
session: _MetricsSession,
request_id: str,
outcome: str,
) -> None:
model_call = session.model_calls.pop(request_id, None)
if model_call is None:
return
try:
self._run_in_session(
session,
self.relay.llm.call_end,
model_call.handle,
{**model_call.fields, "outcome": outcome},
metadata={SCHEMA_KEY: SCHEMA_VERSION},
)
except Exception:
logger.warning(
"Hermes shared-metrics model call close failed", exc_info=True
)
def _end_pending_model_calls(
self,
session: _MetricsSession,
event: dict[str, Any],
) -> None:
task_id = str(event.get("task_id") or "")
request_ids = [
request_id
for request_id, model_call in session.model_calls.items()
if not task_id or model_call.task_id == task_id
]
outcome = "cancelled" if event.get("interrupted") else "failed"
for request_id in request_ids:
self._finish_model_call(session, request_id, outcome)
def _export(self) -> None:
self._safe(self.subscriber.store.create_and_export_package)
@staticmethod
def _safe(callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
return callback(*args, **kwargs)
except Exception:
logger.warning("Hermes shared metrics operation failed", exc_info=True)
return None
@lru_cache(maxsize=1)
def enabled() -> bool:
"""Return the process-lifetime Hermes shared-metrics policy."""
try:
from hermes_cli.config import load_config_readonly
config = load_config_readonly() or {}
except Exception:
logger.debug("Unable to read Hermes shared-metrics policy", exc_info=True)
return False
if not isinstance(config, dict):
return False
telemetry = config.get("telemetry")
if not isinstance(telemetry, dict):
return False
shared_metrics = telemetry.get("shared_metrics")
return isinstance(shared_metrics, dict) and shared_metrics.get("enabled") is True
def handles_hook(hook_name: str) -> bool:
return hook_name in HANDLED_HOOKS and enabled()
def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
"""Project one Hermes lifecycle event into the core Relay integration."""
if not handles_hook(hook_name):
return
runtime = _get_runtime()
if runtime is None:
return
try:
if hook_name == "on_session_start":
runtime.ensure_session(kwargs)
elif hook_name == "pre_api_request":
runtime.start_model_call(kwargs)
elif hook_name == "post_api_request":
runtime.end_model_call(kwargs, "success")
elif hook_name == "api_request_error":
if kwargs.get("retryable") is False:
runtime.end_model_call(kwargs, "failed")
elif hook_name == "on_session_end":
runtime.end_pending_model_calls(kwargs)
elif hook_name in {"on_session_finalize", "on_session_reset"}:
runtime.close_session(kwargs)
except Exception:
logger.warning(
"Hermes shared metrics hook failed: %s", hook_name, exc_info=True
)
def prepare_session_start() -> None:
"""Register the subscriber before any producer opens the session scope."""
if enabled():
_get_runtime()
def _get_runtime() -> _Runtime | None:
global _RUNTIME
with _RUNTIME_LOCK:
if isinstance(_RUNTIME, _Runtime):
return _RUNTIME
if _RUNTIME is _RUNTIME_FAILED:
return None
try:
_RUNTIME = _Runtime()
except Exception:
logger.warning("Hermes shared metrics initialization failed", exc_info=True)
_RUNTIME = _RUNTIME_FAILED
return None
return _RUNTIME
def _reset_for_tests() -> None:
"""Reset process-global state for isolated tests."""
global _RUNTIME
with _RUNTIME_LOCK:
if isinstance(_RUNTIME, _Runtime):
_RUNTIME.shutdown()
_RUNTIME = None
enabled.cache_clear()

View file

@ -8,12 +8,11 @@ from typing import Any
SCHEMA_KEY = "hermes.metrics.schema_version"
SCHEMA_VERSION = "hermes.metrics.event.v1"
SESSION_SCOPE = "hermes.session"
MODEL_CALL_SCOPE = "hermes.model_call"
SUBSCRIBER_NAME = "hermes.nemo_relay.shared_metrics"
PRIMARY_MODEL_CALL_ROLE = "primary"
EXECUTION_SURFACES = frozenset({
EXECUTION_SURFACES: frozenset[str] = frozenset({
"api",
"batch",
"cli",
@ -25,14 +24,20 @@ EXECUTION_SURFACES = frozenset({
"other",
"unknown",
})
PROVIDER_FAMILIES = frozenset({"aggregator", "custom", "direct", "local", "unknown"})
MODEL_LOCALITIES = frozenset({"local", "remote", "unknown"})
MODEL_OUTCOMES = frozenset({"cancelled", "failed", "success"})
PROVIDER_FAMILIES: frozenset[str] = frozenset({
"aggregator",
"custom",
"direct",
"local",
"unknown",
})
MODEL_LOCALITIES: frozenset[str] = frozenset({"local", "remote", "unknown"})
MODEL_OUTCOMES: frozenset[str] = frozenset({"cancelled", "failed", "success"})
# Shared metrics use an explicit family allowlist rather than raw model IDs or
# dynamically sourced catalog values. The latter would make the exported schema
# drift independently of this contract.
MODEL_FAMILIES = frozenset({
MODEL_FAMILIES: frozenset[str] = frozenset({
"claude",
"deepseek",
"gemini",
@ -60,7 +65,11 @@ _MODEL_FAMILY_PATTERN = re.compile(
r"(?:^|[/_.:-])("
+ "|".join(
re.escape(family)
for family in sorted(MODEL_FAMILIES - {"unknown"}, key=len, reverse=True)
for family in sorted(
MODEL_FAMILIES - {"unknown"},
key=lambda value: len(value),
reverse=True,
)
)
+ r")(?=$|[/_.:-]|\d)"
)
@ -109,6 +118,7 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None:
expected_fields = {
"call_role",
"locality",
"model_family",
"outcome",
"provider_family",
}
@ -117,6 +127,7 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None:
if (
data.get("call_role") != PRIMARY_MODEL_CALL_ROLE
or data.get("locality") not in MODEL_LOCALITIES
or data.get("model_family") not in MODEL_FAMILIES
or data.get("outcome") not in MODEL_OUTCOMES
or data.get("provider_family") not in PROVIDER_FAMILIES
):
@ -124,7 +135,7 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None:
return {
"call_role": PRIMARY_MODEL_CALL_ROLE,
"locality": data["locality"],
"model_family": event_model_family,
"model_family": data["model_family"],
"outcome": data["outcome"],
"provider_family": data["provider_family"],
}
@ -240,7 +251,7 @@ def model_family(kwargs: dict[str, Any]) -> str:
declared_family = str(kwargs.get("model_family") or "").strip().lower()
if declared_family in MODEL_FAMILIES - {"unknown"}:
return declared_family
model = str(kwargs.get("model") or "").lower()
model = str(kwargs.get("response_model") or kwargs.get("model") or "").lower()
match = _MODEL_FAMILY_PATTERN.search(model)
return match.group(1) if match is not None else "unknown"

View file

@ -2047,10 +2047,32 @@ def discover_plugins(force: bool = False) -> None:
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
"""Invoke a lifecycle hook on all loaded plugins.
"""Invoke a lifecycle hook on built-in observers and loaded plugins.
Returns a list of non-``None`` return values from plugin callbacks.
"""
if hook_name == "on_session_start":
try:
from hermes_cli.observability import prepare_lifecycle
prepare_lifecycle(hook_name, **kwargs)
except Exception:
logger.warning("Built-in observability preparation failed", exc_info=True)
results = get_plugin_manager().invoke_hook(hook_name, **kwargs)
try:
from hermes_cli.observability import observe_lifecycle
observe_lifecycle(hook_name, **kwargs)
except Exception:
logger.warning("Built-in observability hook failed", exc_info=True)
return results
try:
from hermes_cli.observability import observe_lifecycle
observe_lifecycle(hook_name, **kwargs)
except Exception:
logger.warning("Built-in observability hook failed", exc_info=True)
return get_plugin_manager().invoke_hook(hook_name, **kwargs)
@ -2072,7 +2094,14 @@ def has_middleware(kind: str) -> bool:
def has_hook(hook_name: str) -> bool:
"""Return True when a hook has registered callbacks."""
"""Return True when a built-in observer or plugin handles a hook."""
try:
from hermes_cli.observability import handles_hook
if handles_hook(hook_name):
return True
except Exception:
logger.warning("Unable to inspect built-in shared-metrics hooks", exc_info=True)
return get_plugin_manager().has_hook(hook_name)

View file

@ -73,89 +73,24 @@ checkout that contains this plugin. A globally installed older CLI will not see
new bundled plugins from your working tree.
```bash
uv sync --extra nemo-relay
uv sync
uv run hermes plugins enable observability/nemo_relay
uv run hermes chat --query 'Reply exactly ok' --provider custom --model qwen3.6:35b
```
To ship the updated CLI into another environment, build and install a fresh
wheel from this checkout, then install the official NeMo Relay runtime extra:
wheel from this checkout. On platforms for which Relay publishes a native
wheel, Hermes installs its supported NeMo Relay runtime as a normal dependency:
```bash
uv build --wheel
python -m pip install --force-reinstall dist/hermes_agent-*.whl
python -m pip install "nemo-relay>=0.5.0,<0.6.0"
hermes plugins enable observability/nemo_relay
```
The plugin fails open when `nemo-relay` is not installed. Install a supported
NeMo Relay 0.5.x distribution:
```bash
pip install "nemo-relay>=0.5.0,<0.6.0"
```
## Shared Metrics Proof Mode
The Phase 1 telemetry proof adds a separately gated metrics-only mode. Enable
the bundled plugin and the mode in the same `config.yaml`:
```yaml
plugins:
enabled:
- observability/nemo_relay
entries:
observability/nemo_relay:
shared_metrics:
enabled: true
```
The proof reads this setting when the plugin runtime initializes. Restart a
long-running Hermes agent or gateway after changing it. Live revocation and
local-state reset controls are follow-on requirements before production
rollout.
This first vertical slice maps Hermes's existing API-request hooks to one Relay
LLM lifecycle per logical model call and aggregates terminal primary calls into
`hermes.model_call.count`. Retries sharing an API request ID remain one logical
call. The counter uses bounded provider family, model family, locality, and
outcome dimensions; raw model IDs and request or response content are never
included in the metrics event.
The subscriber stores cumulative counters and a random opaque install ID in
`$HERMES_HOME/telemetry/shared_metrics/metrics.sqlite3`. After Relay's flush
barrier, Hermes commits un-packaged deltas to an immutable outbox record and
atomically writes a `hermes.shared_metrics.v1` JSON package under
`$HERMES_HOME/telemetry/shared_metrics/outbox/`. Repeating export without new
model calls reuses pending package IDs and does not package a count twice. The
proof mode does not perform network I/O. Task, tool, approval, and skill metrics
remain follow-on slices. The closed package contract ships with the plugin at
`schemas/hermes.shared_metrics.v1.schema.json`.
When shared metrics are enabled without a separate rich-observability
configuration, the plugin does not emit its existing content-bearing turn,
model, tool, approval, or subagent events. ATOF, ATIF, adaptive components, and
dynamic plugins remain separate explicit configuration choices.
### End-to-End Smoke Test
The repository includes a deterministic smoke runner that exercises the real
Hermes CLI and native NeMo Relay binding without external model credentials. It
starts a loopback OpenAI-compatible model server, creates an isolated
`HERMES_HOME`, runs one `hermes chat` turn, and validates the resulting SQLite
counter and schema-conformant JSON package:
```bash
./.venv/bin/python scripts/smoke_nemo_relay_shared_metrics.py \
--relay-python ../nemo-relay/python
```
The NeMo Relay Python binding must already be built. By default, the runner
expects the Hermes checkout as its working directory and looks for a sibling
`nemo-relay` checkout. Use `--hermes-repo`, `--relay-python`, or `--output-dir`
to override those locations. The generated profile, captured CLI output,
SQLite database, and immutable package are retained in the artifact directory
printed after a successful run.
The plugin remains opt-in even though the runtime dependency is installed by
default. Enabling this plugin controls rich observability and adaptive
behavior; it does not control Hermes shared client metrics.
## Export Configuration
@ -315,13 +250,10 @@ For the full generic Hermes middleware contract, see
## Canonical Local Examples
The observe-only examples in this section use a supported NeMo Relay 0.5.x
distribution and a local Ollama model served through the
OpenAI-compatible API.
The observe-only examples in this section use the NeMo Relay runtime installed
with Hermes and a local Ollama model served through the OpenAI-compatible API.
```bash
pip install "nemo-relay>=0.5.0,<0.6.0"
export HERMES_HOME=/tmp/hermes-nemo-relay-docs/hermes-home
mkdir -p "$HERMES_HOME"

File diff suppressed because it is too large Load diff

View file

@ -138,6 +138,10 @@ dependencies = [
# Hence the ``sys_platform == 'win32'`` marker: the dep (and its portalocker
# / pywin32 tree) ships only where it's actually used.
"concurrent-log-handler==0.9.29; sys_platform == 'win32'",
# First-party lifecycle and shared-metrics runtime. Relay 0.5 publishes
# native wheels for these targets only; keep unsupported Hermes targets
# installable without moving Relay back behind a user-selected extra.
"nemo-relay==0.5.0; (sys_platform == 'darwin' and platform_machine == 'arm64') or (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'linux' and platform_machine == 'aarch64') or (sys_platform == 'win32' and platform_machine == 'AMD64') or (sys_platform == 'win32' and platform_machine == 'ARM64')",
]
[project.optional-dependencies]
@ -205,7 +209,6 @@ vision = []
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
nemo-relay = ["nemo-relay>=0.5.0,<0.6.0"]
homeassistant = ["aiohttp==3.14.1"]
sms = ["aiohttp==3.14.1"]
teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.1"] # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525
@ -336,7 +339,7 @@ locales = ["locales/*.yaml"]
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1", "observability/schemas/*.json"]
gateway = ["assets/**/*"]
plugins = [
"*/dashboard/manifest.json",
@ -351,7 +354,6 @@ plugins = [
"**/plugin.yaml",
"**/plugin.yml",
"**/README.md",
"**/schemas/*.json",
]
[tool.setuptools.packages.find]

View file

@ -152,7 +152,7 @@ def _arguments() -> argparse.Namespace:
"--relay-python",
type=Path,
default=None,
help="NeMo Relay checkout's python directory",
help="Optional NeMo Relay checkout's python directory",
)
parser.add_argument(
"--output-dir",
@ -174,13 +174,9 @@ def _write_config(home: Path, port: int) -> None:
api_key: no-key-required
security:
tirith_enabled: false
plugins:
enabled:
- observability/nemo_relay
entries:
observability/nemo_relay:
shared_metrics:
enabled: true
telemetry:
shared_metrics:
enabled: true
""",
encoding="utf-8",
)
@ -270,15 +266,13 @@ def _validate_package(outbox: Path, schema_path: Path) -> tuple[Path, dict[str,
def main() -> int:
args = _arguments()
hermes_repo = args.hermes_repo.resolve()
relay_python = (
args.relay_python.resolve()
if args.relay_python
else (hermes_repo.parent / "nemo-relay" / "python").resolve()
)
relay_python = args.relay_python.resolve() if args.relay_python else None
hermes = hermes_repo / ".venv" / "bin" / "hermes"
if not hermes.is_file():
raise SystemExit(f"Hermes executable not found: {hermes}")
if not any((relay_python / "nemo_relay").glob("_native.*")):
if relay_python is not None and not any(
(relay_python / "nemo_relay").glob("_native.*")
):
raise SystemExit(
"Built NeMo Relay Python binding not found under "
f"{relay_python}; run the Relay Python build first"
@ -305,10 +299,11 @@ def main() -> int:
_write_config(home, server.server_port)
env = os.environ.copy()
env["HERMES_HOME"] = str(home)
env["PYTHONPATH"] = os.pathsep.join([
str(relay_python),
env.get("PYTHONPATH", ""),
]).rstrip(os.pathsep)
if relay_python is not None:
env["PYTHONPATH"] = os.pathsep.join([
str(relay_python),
env.get("PYTHONPATH", ""),
]).rstrip(os.pathsep)
result = subprocess.run(
[
str(hermes),
@ -359,9 +354,8 @@ def main() -> int:
package_path, package = _validate_package(
telemetry / "outbox",
hermes_repo
/ "plugins"
/ "hermes_cli"
/ "observability"
/ "nemo_relay"
/ "schemas"
/ "hermes.shared_metrics.v1.schema.json",
)

View file

@ -15,8 +15,8 @@ from types import SimpleNamespace
from typing import Any
import pytest
from plugins.observability.nemo_relay.shared_metrics import SharedMetricsStore
from plugins.observability.nemo_relay.shared_metrics_contract import (
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_contract import (
MODEL_FAMILIES,
MODEL_LOCALITIES,
MODEL_OUTCOMES,
@ -33,9 +33,8 @@ from plugins.observability.nemo_relay.shared_metrics_contract import (
SCHEMA_PATH = (
Path(__file__).resolve().parents[2]
/ "plugins"
/ "hermes_cli"
/ "observability"
/ "nemo_relay"
/ "schemas"
/ "hermes.shared_metrics.v1.schema.json"
)
@ -211,6 +210,12 @@ def test_model_family_accepts_only_allowlisted_declared_metadata():
assert model_family({"model": "private", "model_family": "private"}) == "unknown"
def test_model_family_prefers_the_provider_reported_terminal_model():
assert (
model_family({"model": "gpt-5", "response_model": "claude-sonnet"}) == "claude"
)
@pytest.mark.parametrize(
("platform", "expected"),
[
@ -245,6 +250,7 @@ def test_subscriber_contract_rejects_unknown_fields_and_dimension_values():
data={
"call_role": "primary",
"locality": "remote",
"model_family": "gpt",
"outcome": "success",
"provider_family": "direct",
},

View file

@ -0,0 +1,463 @@
"""Tests for the direct Hermes-to-Relay shared-metrics runtime."""
from __future__ import annotations
import contextvars
import json
import threading
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from hermes_cli import plugins
from hermes_cli.observability import relay_runtime, relay_shared_metrics
from hermes_cli.plugins import PluginManager
class _Request:
def __init__(self, headers: dict[str, Any], content: dict[str, Any]) -> None:
self.headers = headers
self.content = content
class _Relay:
def __init__(self) -> None:
self.events: list[tuple[Any, ...]] = []
self._callbacks: dict[str, Any] = {}
self._starts: dict[Any, dict[str, Any]] = {}
self._scope = contextvars.ContextVar("relay_scope", default=None)
self._scope_serial = 0
self.ScopeType = SimpleNamespace(Agent="agent")
self.LLMRequest = _Request
self.scope = SimpleNamespace(
push=self._scope_push,
pop=self._scope_pop,
event=self._scope_event,
)
self.llm = SimpleNamespace(call=self._llm_call, call_end=self._llm_call_end)
self.subscribers = SimpleNamespace(
register=self._register,
deregister=self._deregister,
flush=self._flush,
)
self.get_scope_stack = self._get_scope_stack
def _scope_push(self, name: str, scope_type: Any, **kwargs: Any) -> Any:
self._scope_serial += 1
handle = ("scope", name, self._scope_serial)
self._scope.set(handle)
self.events.append(("scope.push", name, scope_type, kwargs))
return handle
def _scope_pop(self, handle: Any, **kwargs: Any) -> None:
self.events.append(("scope.pop", handle, kwargs))
def _scope_event(self, name: str, **kwargs: Any) -> None:
self.events.append(("scope.event", name, kwargs))
def _get_scope_stack(self) -> Any:
current = self._scope.get()
self.events.append(("scope.sync", current))
return current
def _llm_call(
self,
name: str,
request: _Request,
**kwargs: Any,
) -> Any:
handle = ("llm", name, len(self._starts))
self._starts[handle] = kwargs
self.events.append(("llm.call", name, request.content, kwargs))
return handle
def _llm_call_end(
self,
handle: Any,
response: dict[str, Any],
**kwargs: Any,
) -> None:
start = self._starts.pop(handle)
self.events.append(("llm.call_end", handle, response, kwargs))
event = SimpleNamespace(
kind="scope",
category="llm",
name=handle[1],
scope_category="end",
category_profile={"model_name": start["model_name"]},
metadata={
**start["metadata"],
**kwargs["metadata"],
"otel.status_code": "OK",
},
data=response,
)
for callback in list(self._callbacks.values()):
callback(event)
def _register(self, name: str, callback: Any) -> None:
self._callbacks[name] = callback
self.events.append(("subscribers.register", name))
def _deregister(self, name: str) -> None:
self._callbacks.pop(name, None)
self.events.append(("subscribers.deregister", name))
def _flush(self) -> None:
self.events.append(("subscribers.flush",))
@pytest.fixture
def direct_runtime(tmp_path, monkeypatch):
fake = _Relay()
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake)
monkeypatch.setattr(
"hermes_cli.config.load_config_readonly",
lambda: {"telemetry": {"shared_metrics": {"enabled": True}}},
)
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
monkeypatch.setattr(plugins, "_plugin_manager", PluginManager())
yield fake
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_path):
base = {
"session_id": "sensitive-session",
"task_id": "task-1",
"api_request_id": "request-1",
"platform": "cli",
"provider": "custom",
"model": "gpt-sensitive-model-id",
"base_url": "http://127.0.0.1:11434/v1",
}
assert plugins.has_hook("pre_api_request")
plugins.invoke_hook("on_session_start", **base)
plugins.invoke_hook(
"pre_api_request",
**base,
request={"body": {"messages": ["sensitive-prompt"]}},
)
plugins.invoke_hook(
"api_request_error",
**base,
retryable=True,
error={"message": "sensitive-error"},
)
plugins.invoke_hook(
"pre_api_request",
**{
**base,
"provider": "anthropic",
"model": "claude-sonnet",
"base_url": "https://api.anthropic.com",
},
request={"body": {"messages": ["sensitive-prompt"]}},
)
plugins.invoke_hook(
"post_api_request",
**{
**base,
"provider": "anthropic",
"model": "claude-sonnet",
"base_url": "https://api.anthropic.com",
},
response={"content": "sensitive-response"},
)
plugins.invoke_hook("on_session_finalize", session_id=base["session_id"])
starts = [event for event in direct_runtime.events if event[0] == "llm.call"]
ends = [event for event in direct_runtime.events if event[0] == "llm.call_end"]
session_starts = [
event for event in direct_runtime.events if event[0] == "scope.push"
]
assert len(session_starts) == 1
assert len(starts) == 1
assert len(ends) == 1
assert starts[0][2] == {}
assert starts[0][3]["model_name"] == "gpt"
assert ends[0][2] == {
"call_role": "primary",
"locality": "remote",
"model_family": "claude",
"outcome": "success",
"provider_family": "direct",
}
serialized_events = json.dumps(direct_runtime.events)
assert "sensitive-prompt" not in serialized_events
assert "sensitive-response" not in serialized_events
assert "sensitive-error" not in serialized_events
assert "gpt-sensitive-model-id" not in serialized_events
assert plugins.get_plugin_manager().list_plugins() == []
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
packages = list((root / "outbox").glob("*.json"))
assert len(packages) == 1
package = json.loads(packages[0].read_text(encoding="utf-8"))
assert package["metrics"][0]["name"] == "hermes.model_call.count"
assert package["metrics"][0]["dimensions"]["model_family"] == "claude"
assert package["metrics"][0]["value"] == 1
def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch):
fake = _Relay()
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake)
monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {})
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
monkeypatch.setattr(plugins, "_plugin_manager", PluginManager())
assert not plugins.has_hook("pre_api_request")
plugins.invoke_hook("on_session_start", session_id="s1", platform="cli")
plugins.invoke_hook("on_session_finalize", session_id="s1")
assert fake.events == []
assert not (tmp_path / "hermes-home" / "telemetry").exists()
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
def test_core_runtime_is_fail_open_without_a_published_binding(monkeypatch, caplog):
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
def missing_relay(name: str):
assert name == "nemo_relay"
raise ModuleNotFoundError(name)
monkeypatch.setattr(relay_runtime.importlib, "import_module", missing_relay)
assert relay_runtime.get_runtime() is None
assert not relay_runtime.emit_mark("hermes.probe", session_id="s1")
assert "Hermes Relay runtime initialization failed" in caplog.text
relay_runtime._reset_for_tests()
def test_core_mark_uses_the_shared_session_handle_without_a_plugin(direct_runtime):
plugins.invoke_hook("on_session_start", session_id="s1", platform="cli")
handle = relay_runtime.get_session_handle("s1")
assert handle is not None
assert relay_runtime.emit_mark(
"hermes.skill.created",
session_id="s1",
data={"provenance": "agent_created"},
metadata={"data_schema": "hermes.skill.lifecycle.v1"},
)
[mark] = [event for event in direct_runtime.events if event[0] == "scope.event"]
assert mark[1] == "hermes.skill.created"
assert mark[2]["handle"] == handle
assert plugins.get_plugin_manager().list_plugins() == []
def test_core_mark_lazily_starts_relay_without_metrics_or_a_plugin(
tmp_path,
monkeypatch,
):
fake = _Relay()
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake)
monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {})
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
monkeypatch.setattr(plugins, "_plugin_manager", PluginManager())
assert relay_runtime.emit_mark(
"hermes.skill.created",
session_id="s1",
data={"provenance": "agent_created"},
)
plugins.invoke_hook("on_session_finalize", session_id="s1")
assert [event[0] for event in fake.events] == [
"scope.push",
"scope.sync",
"scope.event",
"scope.sync",
"scope.pop",
"subscribers.flush",
]
assert not any(event[0] == "subscribers.register" for event in fake.events)
assert not (tmp_path / "hermes-home" / "telemetry").exists()
relay_runtime._reset_for_tests()
def test_core_runtime_creates_one_session_under_concurrent_access(direct_runtime):
runtime = relay_runtime.get_runtime()
assert runtime is not None
ready = threading.Barrier(8)
sessions: list[Any] = []
def ensure() -> None:
ready.wait(timeout=5)
sessions.append(runtime.ensure_session({"session_id": "shared"}))
threads = [threading.Thread(target=ensure) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert all(not thread.is_alive() for thread in threads)
assert len({id(session) for session in sessions}) == 1
assert (
len([event for event in direct_runtime.events if event[0] == "scope.push"]) == 1
)
def test_core_runtime_parents_subagent_session_without_exposing_ids(
direct_runtime,
):
plugins.invoke_hook("on_session_start", session_id="parent", platform="cli")
parent_handle = relay_runtime.get_session_handle("parent")
plugins.invoke_hook(
"subagent_start",
parent_session_id="parent",
child_session_id="sensitive-child",
child_subagent_id="sensitive-subagent",
)
plugins.invoke_hook(
"on_session_start",
session_id="sensitive-child",
platform="cli",
)
runtime = relay_runtime.get_runtime()
assert runtime is not None
child = runtime.get_session("sensitive-child")
assert child is not None
assert child.parent_session_id == "parent"
pushes = [event for event in direct_runtime.events if event[0] == "scope.push"]
assert len(pushes) == 2
child_kwargs = pushes[1][3]
assert child_kwargs["handle"] == parent_handle
assert child_kwargs["metadata"] == {
relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION,
"nemo_relay_scope_role": "subagent",
}
assert "sensitive-child" not in json.dumps(pushes)
assert "sensitive-subagent" not in json.dumps(pushes)
def test_core_runtime_ignores_self_parenting_subagent_event(direct_runtime):
runtime = relay_runtime.get_runtime()
assert runtime is not None
runtime.register_subagent(
{"parent_session_id": "same", "child_session_id": "same"}
)
session = runtime.ensure_session({"session_id": "same"})
assert session is not None
assert session.parent_session_id == ""
def test_terminal_model_error_is_counted_as_failed(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"api_request_id": "r1",
"provider": "anthropic",
"model": "claude-sonnet",
}
plugins.invoke_hook("pre_api_request", **base)
plugins.invoke_hook("api_request_error", **base, retryable=False)
plugins.invoke_hook("on_session_finalize", session_id="s1")
[end] = [event for event in direct_runtime.events if event[0] == "llm.call_end"]
assert end[2]["outcome"] == "failed"
def test_persistence_failure_does_not_escape_the_hook(
direct_runtime,
monkeypatch,
caplog,
):
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
def fail_record(*_args: Any, **_kwargs: Any) -> None:
raise OSError("store unavailable")
monkeypatch.setattr(runtime.subscriber.store, "record_model_call", fail_record)
plugins.invoke_hook(
"pre_api_request",
session_id="s1",
task_id="t1",
api_request_id="r1",
provider="openai",
model="gpt-5",
)
plugins.invoke_hook(
"post_api_request",
session_id="s1",
task_id="t1",
api_request_id="r1",
provider="openai",
model="gpt-5",
)
assert "Unable to persist the Hermes model-call metric" in caplog.text
def test_close_does_not_reopen_a_session_after_scope_start_failure(
direct_runtime,
monkeypatch,
):
runtime = relay_runtime.get_runtime()
assert runtime is not None
original_push = direct_runtime.scope.push
push_attempts = 0
def fail_first_push(*args: Any, **kwargs: Any) -> Any:
nonlocal push_attempts
push_attempts += 1
if push_attempts == 1:
raise RuntimeError("simulated scope failure")
return original_push(*args, **kwargs)
direct_runtime.scope.push = fail_first_push
with pytest.raises(RuntimeError, match="simulated scope failure"):
runtime.ensure_session({"session_id": "s1"})
close_started = threading.Event()
allow_close = threading.Event()
original_flush = direct_runtime.subscribers.flush
def block_flush():
session = runtime._sessions["s1"]
assert session.closing is True
close_started.set()
assert allow_close.wait(timeout=5)
original_flush()
direct_runtime.subscribers.flush = block_flush
close_thread = threading.Thread(
target=runtime.close_session,
args=({"session_id": "s1"},),
)
close_thread.start()
assert close_started.wait(timeout=5)
ensure_thread = threading.Thread(
target=runtime.ensure_session,
args=({"session_id": "s1"},),
)
ensure_thread.start()
allow_close.set()
close_thread.join(timeout=5)
ensure_thread.join(timeout=5)
assert not close_thread.is_alive()
assert not ensure_thread.is_alive()
assert push_attempts == 1
assert "s1" not in runtime._sessions

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@
from pathlib import Path
import tomllib
from packaging.requirements import Requirement
def _load_optional_dependencies():
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
@ -11,6 +13,12 @@ def _load_optional_dependencies():
return project["optional-dependencies"]
def _load_project():
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
with pyproject_path.open("rb") as handle:
return tomllib.load(handle)["project"]
def _load_package_data():
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
with pyproject_path.open("rb") as handle:
@ -222,14 +230,40 @@ def test_feishu_extra_includes_qrcode_for_qr_login():
assert any(dep.startswith("qrcode") for dep in feishu_extra)
def test_nemo_relay_extra_uses_supported_official_distribution_range():
optional_dependencies = _load_optional_dependencies()
def test_nemo_relay_is_a_pinned_core_dependency():
metadata = _load_project()
assert optional_dependencies["nemo-relay"] == ["nemo-relay>=0.5,<1.0"]
assert not any(
spec == "hermes-agent[nemo-relay]"
for spec in optional_dependencies["all"]
relay_dependencies = [
dependency
for dependency in metadata["dependencies"]
if dependency.startswith("nemo-relay==")
]
assert len(relay_dependencies) == 1
requirement = Requirement(relay_dependencies[0])
assert str(requirement.specifier) == "==0.5.0"
assert requirement.marker is not None
assert requirement.marker.evaluate(
{"sys_platform": "darwin", "platform_machine": "arm64"}
)
assert requirement.marker.evaluate(
{"sys_platform": "linux", "platform_machine": "x86_64"}
)
assert requirement.marker.evaluate(
{"sys_platform": "linux", "platform_machine": "aarch64"}
)
assert requirement.marker.evaluate(
{"sys_platform": "win32", "platform_machine": "AMD64"}
)
assert requirement.marker.evaluate(
{"sys_platform": "win32", "platform_machine": "ARM64"}
)
assert not requirement.marker.evaluate(
{"sys_platform": "android", "platform_machine": "aarch64"}
)
assert not requirement.marker.evaluate(
{"sys_platform": "darwin", "platform_machine": "x86_64"}
)
assert "nemo-relay" not in metadata["optional-dependencies"]
def test_dashboard_plugin_manifests_and_assets_are_packaged():
@ -244,6 +278,12 @@ def test_dashboard_plugin_manifests_and_assets_are_packaged():
assert "*/dashboard/dist/**/*" in plugin_data
def test_shared_metrics_schema_is_packaged():
package_data = _load_package_data()
assert "observability/schemas/*.json" in package_data["hermes_cli"]
def test_nested_bundled_plugin_metadata_is_packaged():
"""Nested opt-in plugins need manifests and READMEs in wheel installs."""
package_data = _load_package_data()

8
uv.lock generated
View file

@ -1528,6 +1528,7 @@ dependencies = [
{ name = "httpx", extra = ["socks"] },
{ name = "jinja2" },
{ name = "markdown" },
{ name = "nemo-relay", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'ARM64' and sys_platform == 'win32')" },
{ name = "openai" },
{ name = "packaging" },
{ name = "pathspec" },
@ -1662,9 +1663,6 @@ mistral = [
modal = [
{ name = "modal" },
]
nemo-relay = [
{ name = "nemo-relay" },
]
parallel-web = [
{ name = "parallel-web" },
]
@ -1804,7 +1802,7 @@ requires-dist = [
{ name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" },
{ name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" },
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
{ name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = ">=0.5.0,<0.6.0" },
{ name = "nemo-relay", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'ARM64' and sys_platform == 'win32')", specifier = "==0.5.0" },
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
{ name = "openai", specifier = "==2.24.0" },
{ name = "packaging", specifier = "==26.0" },
@ -1853,7 +1851,7 @@ requires-dist = [
{ name = "websockets", specifier = "==15.0.1" },
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
[[package]]
name = "hf-xet"