Files
hermes-agent/tests/hermes_cli/test_relay_shared_metrics_runtime.py
Teknium c99299d746 feat(telemetry): memory and compression rows say why they failed or were skipped
Fleet telemetry shows built-in memory writes failing 34-47% and automatic
compression failing ~15%, and several merged fixes each target one cause
(#133038 for memory, #133054/#133040/#133051/#133032 for compression). Both
metrics only carried outcome, so the fleet could not tell which cause a fix
moved.

- hermes.memory.op.count and hermes.compression.count gain one closed-enum
  dimension, failure_class; `none` on success.
- Memory: every refusal/failure return in tools/memory_tool.py and
  tools/memory_tool_store.py names its class (no_match, ambiguous,
  over_budget, would_empty, drift, read_failed, retry_cap, scan_blocked,
  missing_content, missing_old_text, invalid_args, stale_entry, disabled,
  staged, gate_refused) through a context variable; the result the model
  sees is unchanged. Plugin providers report provider_error / exception.
- Compression: the attempt log's own failure_class, collapsed onto the
  compressor's closed names (exception:<Type> / rollback:<Type> keep only the
  prefix, anything else reads other, a classless failure unknown). Skipped
  rows carry the skip reason, and the skip set is now defined once in the
  contract.
- Package schema v4 / event schema v4; v3 field sets of both metrics stay
  valid so pending rows drain. Doc table rows and the consent copy (CLI
  setup and every Desktop locale's collectedActivity) name the new reason.
2026-10-05 22:07:41 -07:00

3056 lines
102 KiB
Python

"""Tests for the direct Hermes-to-Relay shared-metrics runtime."""
from __future__ import annotations
import contextvars
import json
import sqlite3
import threading
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Any
import pytest
from hermes_cli import lifecycle, plugins
from agent import relay_runtime
from hermes_cli.observability import 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 _ToolExecutionResult:
def __init__(self, result: Any, annotation: Any = None) -> None:
self.result = result
self.annotation = annotation
class _Relay:
def __init__(self) -> None:
self.events: list[tuple[Any, ...]] = []
self._callbacks: dict[str, Any] = {}
self._starts: dict[Any, dict[str, Any]] = {}
self._tool_starts: dict[Any, dict[str, Any]] = {}
self._scope_starts: dict[Any, dict[str, Any]] = {}
self._scope = contextvars.ContextVar("relay_scope", default=None)
self._scope_stack = contextvars.ContextVar("relay_scope_stack", default=None)
self._scope_serial = 0
self.ScopeType = SimpleNamespace(
Agent="agent", Function="function", Tool="tool"
)
self.LLMRequest = _Request
self.ToolExecutionResult = _ToolExecutionResult
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.tools = SimpleNamespace(call=self._tool_call, call_end=self._tool_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)
stack = self._scope_stack.get()
if stack is None:
stack = []
self._scope_stack.set(stack)
stack.append(handle)
self._scope.set(handle)
self.events.append(("scope.push", name, scope_type, kwargs))
if scope_type == self.ScopeType.Function:
self._scope_starts[handle] = kwargs
event = SimpleNamespace(
kind="scope",
category="function",
name=name,
scope_category="start",
category_profile=None,
metadata=kwargs.get("metadata"),
data=kwargs.get("input"),
)
for callback in list(self._callbacks.values()):
callback(event)
return handle
def _scope_pop(self, handle: Any, **kwargs: Any) -> None:
stack = self._scope_stack.get()
if not stack or stack[-1] != handle:
current = stack[-1] if stack else None
self.events.append(("scope.pop.rejected", handle, current))
raise RuntimeError("scope handle is not at the top of the stack")
stack.pop()
self._scope.set(stack[-1] if stack else None)
self.events.append(("scope.pop", handle, kwargs))
start = self._scope_starts.pop(handle, None)
if start is not None:
event = SimpleNamespace(
kind="scope",
category="function",
name=handle[1],
scope_category="end",
category_profile=None,
metadata={
**(start.get("metadata") or {}),
**(kwargs.get("metadata") or {}),
},
data=kwargs.get("output"),
)
for callback in list(self._callbacks.values()):
callback(event)
def _scope_event(self, name: str, **kwargs: Any) -> None:
self.events.append(("scope.event", name, kwargs))
event = SimpleNamespace(
kind="mark",
category=None,
name=name,
scope_category=None,
category_profile=None,
metadata=kwargs.get("metadata"),
data=kwargs.get("data"),
)
for callback in list(self._callbacks.values()):
callback(event)
def _get_scope_stack(self) -> Any:
stack = self._scope_stack.get()
current = stack[-1] if stack else None
self._scope.set(current)
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 _tool_call(
self,
name: str,
args: dict[str, Any],
**kwargs: Any,
) -> Any:
handle = ("tool", name, len(self._tool_starts))
self._tool_starts[handle] = kwargs
self.events.append(("tool.call", name, args, kwargs))
return handle
def _tool_call_end(
self,
handle: Any,
result: _ToolExecutionResult,
**kwargs: Any,
) -> None:
assert isinstance(result, _ToolExecutionResult)
payload = result.result
start = self._tool_starts.pop(handle)
self.events.append(("tool.call_end", handle, payload, kwargs))
event = SimpleNamespace(
kind="scope",
category="tool",
name=handle[1],
scope_category="end",
category_profile={},
metadata={
**start["metadata"],
**kwargs["metadata"],
"otel.status_code": "OK",
},
data=payload,
)
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",))
_EXPORT_WORKER = "hermes-shared-metrics-flush"
def _join_export_workers() -> None:
"""Task/session exports run on a background worker; wait for it before reading the store."""
while workers := [t for t in threading.enumerate() if t.name == _EXPORT_WORKER]:
for worker in workers:
worker.join(timeout=10)
assert not worker.is_alive()
@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.read_raw_config_readonly",
lambda: {"telemetry": {"shared_metrics": {"enabled": True}}},
)
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
_mgr = PluginManager()
# Pin as discovered: hook queries lazy-discover plugins (#64178), and
# this test's contract is a runtime with ZERO plugins loaded.
_mgr._discovered = True
monkeypatch.setattr(plugins, "_plugin_manager", _mgr)
yield fake
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
@pytest.fixture
def real_binding_runtime(tmp_path, monkeypatch):
relay = pytest.importorskip("nemo_relay")
if getattr(relay, "_native", None) is None:
pytest.skip("NeMo Relay native binding is unavailable on this platform")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
monkeypatch.setattr(
"hermes_cli.config.read_raw_config_readonly",
lambda: {"telemetry": {"shared_metrics": {"enabled": True}}},
)
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
_mgr = PluginManager()
_mgr._discovered = True # see direct_runtime fixture (#64178)
monkeypatch.setattr(plugins, "_plugin_manager", _mgr)
yield relay
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",
"turn_id": "turn-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 lifecycle.has_hook("pre_api_request")
lifecycle.invoke_hook("on_session_start", **base)
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook(
"pre_api_request",
**base,
request={"body": {"messages": ["sensitive-prompt"]}},
)
lifecycle.invoke_hook(
"pre_tool_call",
**base,
tool_call_id="sensitive-tool-call",
tool_name="terminal",
toolset="terminal",
args={"command": "sensitive-command"},
)
lifecycle.invoke_hook(
"post_approval_response",
turn_id=base["turn_id"],
tool_call_id="sensitive-tool-call",
choice="once",
command="sensitive-command",
description="sensitive-approval-description",
)
lifecycle.invoke_hook(
"post_tool_call",
**base,
tool_call_id="sensitive-tool-call",
tool_name="terminal",
toolset="terminal",
args={"command": "sensitive-command"},
result={"output": "sensitive-tool-result"},
status="ok",
duration_ms=275,
retry_count=0,
)
lifecycle.invoke_hook(
"api_request_error",
**base,
retryable=True,
error={"message": "sensitive-error"},
)
lifecycle.invoke_hook(
"pre_api_request",
**{
**base,
"provider": "anthropic",
"model": "claude-sonnet",
"base_url": "https://api.anthropic.com",
},
request={"body": {"messages": ["sensitive-prompt"]}},
)
lifecycle.invoke_hook(
"post_api_request",
**{
**base,
"provider": "anthropic",
"model": "claude-sonnet",
"base_url": "https://api.anthropic.com",
},
response={"content": "sensitive-response"},
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
# The daily package is cut by the first export that runs. Let the one the task
# close scheduled finish before the session closes, so the package holds exactly the
# task's metrics instead of racing the session-summary rows.
_join_export_workers()
lifecycle.finalize_session(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"]
tool_starts = [event for event in direct_runtime.events if event[0] == "tool.call"]
tool_ends = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
scope_starts = [
event for event in direct_runtime.events if event[0] == "scope.push"
]
assert len(scope_starts) == 2
assert scope_starts[0][2] == direct_runtime.ScopeType.Agent
assert scope_starts[1][1] == "hermes.task_run"
assert scope_starts[1][2] == direct_runtime.ScopeType.Function
assert scope_starts[1][3]["handle"][1] == relay_runtime.SESSION_SCOPE
assert scope_starts[1][3]["input"] == {
"entrypoint": "interactive",
"execution_surface": "cli",
"platform": "none",
}
assert len(starts) == 1
assert len(ends) == 1
assert len(tool_starts) == 1
assert len(tool_ends) == 1
assert tool_starts[0][1] == "hermes.tool_call"
assert tool_starts[0][2] == {}
assert tool_ends[0][2] == {
"approval_outcome": "approved",
"error_class": "none",
"latency_bucket": "250ms_to_500ms",
"outcome": "success",
"retry_count_bucket": "0",
"tool_category": "terminal",
"tool_name": "terminal",
}
assert starts[0][2] == {}
assert starts[0][3]["model_name"] == "unknown"
active_marks = [
event
for event in direct_runtime.events
if event[0] == "scope.event" and event[1] == "hermes.client.active"
]
assert len(active_marks) == 2
assert all(mark[2]["data"] == {} for mark in active_marks)
assert ends[0][2] == {
"call_role": "primary",
# The call recovered from an unclassified provider error before succeeding.
"error_class": "unknown",
"model": "claude-sonnet",
"outcome": "success",
"provider": "anthropic",
"ttft_bucket": "unknown",
}
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 "sensitive-command" not in serialized_events
assert "sensitive-tool-result" not in serialized_events
assert "sensitive-tool-call" not in serialized_events
assert "sensitive-approval-description" 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"))
metrics = {metric["name"]: metric for metric in package["metrics"]}
assert set(metrics) == {
"hermes.client.active",
"hermes.install.milestone",
"hermes.install.snapshot",
"hermes.model_route.count",
"hermes.task_cost.count",
"hermes.task_run.duration",
"hermes.task_run.finished",
"hermes.task_run.started",
"hermes.tool.usage.count",
"hermes.tool_approval.count",
"hermes.tool_call.count",
"hermes.tool_call.latency",
}
assert metrics["hermes.tool.usage.count"]["dimensions"] == {
"error_class": "none", "outcome": "success", "tool_name": "terminal",
}
assert metrics["hermes.install.snapshot"]["value"] == 1
assert metrics["hermes.client.active"] == {
"name": "hermes.client.active",
"type": "counter",
"dimensions": {},
"value": 1,
}
assert metrics["hermes.model_route.count"]["dimensions"] == {
"call_role": "primary",
# The call recovered from an unclassified provider error before succeeding.
"error_class": "unknown",
"model": "claude-sonnet",
"outcome": "success",
"provider": "anthropic",
"ttft_bucket": "unknown",
}
assert metrics["hermes.model_route.count"]["value"] == 1
assert metrics["hermes.tool_call.count"] == {
"name": "hermes.tool_call.count",
"type": "counter",
"dimensions": {
"approval_outcome": "approved",
"outcome": "success",
"tool_category": "terminal",
},
"value": 1,
}
assert metrics["hermes.tool_call.latency"]["dimensions"] == {
"latency_bucket": "250ms_to_500ms", "retry_count_bucket": "0", "tool_category": "terminal",
}
assert metrics["hermes.tool_approval.count"] == {
"name": "hermes.tool_approval.count",
"type": "counter",
"dimensions": {
"attribution": "tool_call",
"outcome": "approved",
},
"value": 1,
}
assert metrics["hermes.task_run.started"] == {
"name": "hermes.task_run.started",
"type": "counter",
"dimensions": {
"entrypoint": "interactive",
"execution_surface": "cli",
"platform": "none",
},
"value": 1,
}
assert metrics["hermes.task_run.finished"]["dimensions"] == {
"end_reason": "completed",
"entrypoint": "interactive",
"execution_surface": "cli",
"failure_class": "none",
"outcome": "success",
"platform": "none",
"termination": "none",
}
duration = metrics["hermes.task_run.duration"]["dimensions"]
assert duration["duration_bucket"] in {
"lt_1s",
"1s_to_5s",
"5s_to_30s",
"30s_to_2m",
"2m_to_10m",
"gte_10m",
}
assert {key: value for key, value in duration.items() if key != "duration_bucket"} == {
"execution_surface": "cli", "outcome": "success", "retry_count_bucket": "1",
}
# Per-task call counts ride on the task cost row now.
assert metrics["hermes.task_cost.count"]["dimensions"]["api_calls_bucket"] == "1"
assert metrics["hermes.task_cost.count"]["dimensions"]["tool_calls_bucket"] == "1"
def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
real_binding_runtime,
tmp_path,
monkeypatch,
):
assert real_binding_runtime._native is not None
prompt_canary = "real-relay-sensitive-prompt"
response_canary = "real-relay-sensitive-response"
model_canary = "gpt-real-relay-sensitive-model"
tool_canary = "real-relay-sensitive-tool-result"
def base(index: int) -> dict[str, Any]:
return {
"session_id": f"sensitive-session-{index}",
"task_id": f"sensitive-task-{index}",
"turn_id": f"sensitive-turn-{index}",
"api_request_id": f"sensitive-request-{index}",
"platform": "cli",
"provider": "custom",
"model": model_canary,
"base_url": "http://127.0.0.1:11434/v1",
}
success = base(1)
lifecycle.invoke_hook("on_session_start", **success)
lifecycle.invoke_hook("pre_llm_call", **success, messages=[prompt_canary])
lifecycle.invoke_hook("pre_api_request", **success, retry_count=0)
lifecycle.invoke_hook(
"api_request_error",
**success,
retry_count=0,
retryable=True,
error={"message": prompt_canary},
)
lifecycle.invoke_hook("pre_api_request", **success, retry_count=1)
lifecycle.invoke_hook(
"pre_tool_call",
**success,
tool_call_id="sensitive-tool-call",
tool_name="terminal",
args={"command": prompt_canary},
)
lifecycle.invoke_hook(
"post_approval_response",
turn_id=success["turn_id"],
tool_call_id="sensitive-tool-call",
choice="session",
command=prompt_canary,
description="sensitive-approval-description",
)
lifecycle.invoke_hook(
"post_tool_call",
**success,
tool_call_id="sensitive-tool-call",
tool_name="terminal",
args={"command": prompt_canary},
result={"output": tool_canary},
status="ok",
duration_ms=125,
retry_count=0,
)
lifecycle.invoke_hook(
"post_api_request",
**success,
retry_count=1,
response={"content": response_canary},
)
lifecycle.invoke_hook(
"on_session_end",
**success,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id=success["session_id"])
# The daily package forms after the first session; later sessions land in the next one.
_join_export_workers()
failed = base(2)
lifecycle.invoke_hook("on_session_start", **failed)
lifecycle.invoke_hook("pre_llm_call", **failed, messages=[prompt_canary])
lifecycle.invoke_hook("pre_api_request", **failed, retry_count=0)
lifecycle.invoke_hook(
"pre_tool_call",
**failed,
tool_call_id="sensitive-failed-tool-call",
tool_name="read_file",
args={"path": prompt_canary},
)
lifecycle.invoke_hook(
"post_tool_call",
**failed,
tool_call_id="sensitive-failed-tool-call",
tool_name="read_file",
args={"path": prompt_canary},
result={"error": tool_canary},
status="error",
duration_ms=750,
)
lifecycle.invoke_hook(
"api_request_error",
**failed,
retry_count=0,
retryable=False,
error={"message": response_canary},
)
lifecycle.invoke_hook(
"on_session_end",
**failed,
completed=False,
failed=True,
interrupted=False,
turn_exit_reason="system_aborted",
)
lifecycle.finalize_session(session_id=failed["session_id"])
cancelled = base(3)
lifecycle.invoke_hook("on_session_start", **cancelled)
lifecycle.invoke_hook("pre_llm_call", **cancelled, messages=[prompt_canary])
lifecycle.invoke_hook("pre_api_request", **cancelled, retry_count=0)
lifecycle.invoke_hook(
"pre_tool_call",
**cancelled,
tool_call_id="sensitive-cancelled-tool-call",
tool_name="browser_navigate",
args={"url": prompt_canary},
)
lifecycle.invoke_hook(
"post_tool_call",
**cancelled,
tool_call_id="sensitive-cancelled-tool-call",
tool_name="browser_navigate",
args={"url": prompt_canary},
result={"error": tool_canary},
status="cancelled",
duration_ms=31_000,
)
lifecycle.invoke_hook(
"on_session_end",
**cancelled,
completed=False,
failed=False,
interrupted=True,
turn_exit_reason="interrupted_by_user",
)
lifecycle.finalize_session(session_id=cancelled["session_id"])
from hermes_cli.observability.shared_metrics import SharedMetricsStore
_join_export_workers()
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox")
tomorrow = datetime.now(timezone.utc) + timedelta(days=1)
monkeypatch.setattr(
"hermes_cli.observability.shared_metrics._utc_now",
lambda: tomorrow,
)
assert len(store.create_and_export_package_if_due()) == 1
snapshot = store.counter_snapshot()
by_metric: dict[str, list[dict[str, Any]]] = {}
for counter in snapshot:
by_metric.setdefault(counter["metric_name"], []).append(counter)
assert by_metric["hermes.client.active"][0]["dimensions"] == {}
assert by_metric["hermes.client.active"][0]["value"] == 1
assert len(by_metric["hermes.task_run.started"]) == 1
assert by_metric["hermes.task_run.started"][0]["value"] == 3
# v3: one route row per terminal outcome; an unclassified failed call reports `unknown`.
route_by_outcome = {
counter["dimensions"]["outcome"]: counter
for counter in by_metric["hermes.model_route.count"]
}
assert set(route_by_outcome) == {"success", "failed", "cancelled"}
for outcome, error_class in (
# The success call recovered from an unclassified error; the failed one never did.
("success", "unknown"), ("failed", "unknown"), ("cancelled", "none")
):
assert route_by_outcome[outcome]["dimensions"] == {
"call_role": "primary",
"error_class": error_class,
# A custom endpoint's model id is user-named: it never leaves the machine.
"model": "custom",
"outcome": outcome,
"provider": "custom",
"ttft_bucket": route_by_outcome[outcome]["dimensions"]["ttft_bucket"],
}
assert route_by_outcome[outcome]["value"] == 1
assert {
(c["dimensions"]["tool_name"], c["dimensions"]["outcome"], c["dimensions"]["error_class"])
for c in by_metric["hermes.tool.usage.count"]
} == {
("terminal", "success", "none"),
("read_file", "failed", "unknown"),
("browser_navigate", "cancelled", "interrupted"),
}
assert {
counter["dimensions"]["outcome"]
for counter in by_metric["hermes.tool_call.count"]
} == {"success", "failed", "cancelled"}
tool_by_outcome = {
counter["dimensions"]["outcome"]: counter["dimensions"]
for counter in by_metric["hermes.tool_call.count"]
}
assert tool_by_outcome["success"] == {
"approval_outcome": "approved",
"outcome": "success",
"tool_category": "terminal",
}
assert tool_by_outcome["failed"] == {
"approval_outcome": "not_required",
"outcome": "failed",
"tool_category": "file",
}
assert tool_by_outcome["cancelled"] == {
"approval_outcome": "not_required",
"outcome": "cancelled",
"tool_category": "browser",
}
assert {
tuple(counter["dimensions"][f] for f in ("tool_category", "latency_bucket", "retry_count_bucket"))
for counter in by_metric["hermes.tool_call.latency"]
} == {
("terminal", "100ms_to_250ms", "0"),
("file", "500ms_to_1s", "unknown"),
("browser", "gte_30s", "unknown"),
}
assert len(by_metric["hermes.tool_approval.count"]) == 1
approval_counter = by_metric["hermes.tool_approval.count"][0]
assert approval_counter["dimensions"] == {
"attribution": "tool_call",
"outcome": "approved",
}
assert approval_counter["value"] == 1
assert approval_counter["packaged_value"] == 1
terminal_by_outcome = {
counter["dimensions"]["outcome"]: counter
for counter in by_metric["hermes.task_run.finished"]
}
assert set(terminal_by_outcome) == {"success", "failed", "cancelled"}
duration_by_outcome = {
counter["dimensions"]["outcome"]: counter["dimensions"]
for counter in by_metric["hermes.task_run.duration"]
}
assert set(duration_by_outcome) == {"success", "failed", "cancelled"}
assert duration_by_outcome["success"]["retry_count_bucket"] == "1"
assert terminal_by_outcome["failed"]["dimensions"]["end_reason"] == (
"system_aborted"
)
assert terminal_by_outcome["failed"]["dimensions"]["failure_class"] == "other"
assert terminal_by_outcome["success"]["dimensions"]["failure_class"] == "none"
assert terminal_by_outcome["cancelled"]["dimensions"]["termination"] == (
"user_cancelled"
)
assert all(counter["packaged_value"] == counter["value"] for counter in snapshot)
snapshot_values = {
(
counter["metric_name"],
tuple(sorted(counter["dimensions"].items())),
): counter["value"]
for counter in snapshot
}
package_values: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
packages = sorted((root / "outbox").glob("*.json"))
assert len(packages) == 2
package_payloads = [
json.loads(package.read_text(encoding="utf-8")) for package in packages
]
for package in package_payloads:
assert package["schema_version"] == "hermes.shared_metrics.v4"
for metric in package["metrics"]:
key = (metric["name"], tuple(sorted(metric["dimensions"].items())))
package_values[key] = package_values.get(key, 0) + metric["value"]
assert package_values == snapshot_values
serialized_analytics = json.dumps({
"snapshot": snapshot,
"packages": package_payloads,
})
for canary in (
model_canary,
prompt_canary,
response_canary,
tool_canary,
"sensitive-session",
"sensitive-task",
"sensitive-request",
"sensitive-tool-call",
"sensitive-failed-tool-call",
"sensitive-cancelled-tool-call",
"sensitive-approval-description",
):
assert canary not in serialized_analytics
def test_real_binding_correlates_plugin_approval_denial_to_tool_metric(
real_binding_runtime,
tmp_path,
monkeypatch,
):
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from tools import approval
import tools.approval_prompt as approval_prompt
import tools.approval_context as approval_context
assert real_binding_runtime._native is not None
base = {
"session_id": "sensitive-session",
"task_id": "sensitive-task",
"turn_id": "sensitive-turn",
"api_request_id": "sensitive-request",
"platform": "cli",
}
def plugin_hook(hook_name: str, **kwargs: Any) -> list[dict[str, str]]:
if hook_name == "pre_tool_call":
return [{"action": "approve", "message": "sensitive-rule"}]
return []
monkeypatch.setattr(plugins, "invoke_hook", plugin_hook)
monkeypatch.setattr(approval, "_YOLO_MODE_FROZEN", False)
monkeypatch.setattr(approval, "is_current_session_yolo_enabled", lambda: False)
monkeypatch.setattr(approval, "is_approved", lambda *args: False)
monkeypatch.setattr(approval, "get_current_session_key", lambda: "session-key")
monkeypatch.setattr(approval_context, "get_current_session_key", lambda: "session-key")
monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True)
monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False)
monkeypatch.setattr(approval_context, "_is_gateway_approval_context", lambda: False)
monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *args, **kwargs: "deny")
monkeypatch.setattr(approval_prompt, "prompt_dangerous_approval", lambda *args, **kwargs: "deny")
lifecycle.invoke_hook("on_session_start", **base)
lifecycle.invoke_hook("pre_llm_call", **base, messages=["sensitive-prompt"])
block_message = plugins.resolve_pre_tool_block(
"write_file",
{"path": "sensitive-path"},
task_id=base["task_id"],
session_id=base["session_id"],
turn_id=base["turn_id"],
api_request_id=base["api_request_id"],
tool_call_id="sensitive-tool-call",
)
assert block_message is not None
assert "User denied" in block_message
lifecycle.invoke_hook(
"post_tool_call",
**base,
tool_call_id="sensitive-tool-call",
tool_name="write_file",
args={"path": "sensitive-path"},
result={"error": block_message},
status="blocked",
duration_ms=12,
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=False,
failed=True,
interrupted=False,
turn_exit_reason="approval_denied",
)
lifecycle.finalize_session(session_id=base["session_id"])
_join_export_workers()
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox")
snapshot = store.counter_snapshot()
tool_metrics = [
counter
for counter in snapshot
if counter["metric_name"] == "hermes.tool_call.count"
]
assert len(tool_metrics) == 1
assert tool_metrics[0]["dimensions"] == {
"approval_outcome": "denied",
"outcome": "blocked",
"tool_category": "file",
}
assert [
counter["dimensions"] for counter in snapshot if counter["metric_name"] == "hermes.tool_call.latency"
] == [{"latency_bucket": "lt_100ms", "retry_count_bucket": "unknown", "tool_category": "file"}]
approval_metrics = [
counter
for counter in snapshot
if counter["metric_name"] == "hermes.tool_approval.count"
]
assert len(approval_metrics) == 1
assert approval_metrics[0]["dimensions"] == {
"attribution": "tool_call",
"outcome": "denied",
}
assert "sensitive" not in json.dumps(snapshot)
def test_real_binding_aggregates_tool_and_approval_timeouts(
real_binding_runtime,
tmp_path,
):
from hermes_cli.observability.shared_metrics import SharedMetricsStore
assert real_binding_runtime._native is not None
base = {
"session_id": "timeout-sensitive-session",
"task_id": "timeout-sensitive-task",
"turn_id": "timeout-sensitive-turn",
"platform": "cli",
}
lifecycle.invoke_hook("on_session_start", **base)
lifecycle.invoke_hook("pre_llm_call", **base, messages=["timeout-sensitive-prompt"])
lifecycle.invoke_hook(
"pre_tool_call",
**base,
tool_call_id="timeout-sensitive-tool-call",
tool_name="terminal",
args={"command": "timeout-sensitive-command"},
)
lifecycle.invoke_hook(
"post_approval_response",
**base,
tool_call_id="timeout-sensitive-tool-call",
choice="timeout",
command="timeout-sensitive-command",
)
lifecycle.invoke_hook(
"post_tool_call",
**base,
tool_call_id="timeout-sensitive-tool-call",
tool_name="terminal",
result={"error": "timeout-sensitive-result"},
status="timeout",
duration_ms=30_000,
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=False,
failed=True,
interrupted=False,
turn_exit_reason="provider_timeout",
)
lifecycle.finalize_session(session_id=base["session_id"])
_join_export_workers()
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
snapshot = SharedMetricsStore(
root / "metrics.sqlite3",
root / "outbox",
).counter_snapshot()
[tool_metric] = [
counter
for counter in snapshot
if counter["metric_name"] == "hermes.tool_call.count"
]
assert tool_metric["dimensions"] == {
"approval_outcome": "timed_out",
"outcome": "timed_out",
"tool_category": "terminal",
}
assert [
counter["dimensions"] for counter in snapshot if counter["metric_name"] == "hermes.tool_call.latency"
] == [{"latency_bucket": "gte_30s", "retry_count_bucket": "unknown", "tool_category": "terminal"}]
[approval_metric] = [
counter
for counter in snapshot
if counter["metric_name"] == "hermes.tool_approval.count"
]
assert approval_metric["dimensions"] == {
"attribution": "tool_call",
"outcome": "timed_out",
}
assert "timeout-sensitive" not in json.dumps(snapshot)
def test_execution_adapters_do_not_create_relay_host_without_a_consumer(
monkeypatch,
):
from agent import relay_llm, relay_tools
relay_runtime._reset_for_tests()
imports = []
def load_relay():
imports.append("nemo_relay")
raise AssertionError("disabled execution adapter created Relay host")
monkeypatch.setattr(relay_runtime, "_load_nemo_relay", load_relay)
request = {"model": "test-model", "messages": []}
response = object()
tool_args = {"command": "true"}
tool_result = object()
assert (
relay_llm.execute(
request,
lambda observed: response if observed is request else None,
session_id="llm-session",
name="test-provider",
model_name="test-model",
)
is response
)
result, observed_args = relay_tools.execute(
"terminal",
tool_args,
lambda observed: tool_result if observed is tool_args else None,
session_id="tool-session",
)
assert result is tool_result
assert observed_args is tool_args
assert relay_runtime.HOST_REGISTRY.for_profile(create=False) is None
assert imports == []
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
host = relay_runtime.HOST_REGISTRY.for_profile()
assert isinstance(host, relay_runtime.NoopRelayRuntime)
assert host.profile_key == relay_runtime.current_profile_key()
assert "nemo_relay" in host.reason
assert host.apply_tool_request_intercepts(
session_id="s1",
tool_name="terminal",
args={"command": "true"},
) == {"command": "true"}
assert "Hermes Relay runtime initialization failed" in caplog.text
relay_runtime._reset_for_tests()
def test_core_task_instrumentation_preserves_prompt_history_and_tool_schema(
direct_runtime,
monkeypatch,
):
from run_agent import AIAgent
agent = object.__new__(AIAgent)
agent.session_id = "cache-stable-session"
agent.platform = "cli"
agent._parent_session_id = None
agent._session_db = None
agent._cached_system_prompt = "byte-stable-system-prompt\nwith exact spacing"
agent.tools = [
{
"type": "function",
"function": {
"name": "probe",
"parameters": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
},
}
]
history = [{"role": "user", "content": "sensitive-history-canary"}]
prompt_before = agent._cached_system_prompt.encode("utf-8")
history_before = json.dumps(history, ensure_ascii=False, sort_keys=True)
tools_before = json.dumps(agent.tools, ensure_ascii=False, sort_keys=True)
def fake_run_conversation(
active_agent,
user_message,
system_message,
conversation_history,
task_id,
stream_callback,
persist_user_message,
**kwargs,
):
del (
user_message,
system_message,
task_id,
stream_callback,
persist_user_message,
kwargs,
)
assert active_agent is agent
assert conversation_history is history
return {"final_response": "ok", "completed": True}
monkeypatch.setattr(
"agent.conversation_loop.run_conversation",
fake_run_conversation,
)
for task_id in ("cache-task-1", "cache-task-2"):
result = AIAgent.run_conversation(
agent,
"hello",
conversation_history=history,
task_id=task_id,
)
assert result["final_response"] == "ok"
assert agent._cached_system_prompt.encode("utf-8") == prompt_before
assert json.dumps(history, ensure_ascii=False, sort_keys=True) == history_before
assert json.dumps(agent.tools, ensure_ascii=False, sort_keys=True) == tools_before
def test_skipped_turn_does_not_finish_another_sessions_matching_task(
direct_runtime,
monkeypatch,
):
"""A skipped turn must not use shared-metrics' task-id fallback on finish."""
from run_agent import AIAgent
owner_session = "instrumented-session"
shared_task_id = "caller-supplied-task-id"
relay_shared_metrics.start_task_run(
session_id=owner_session,
task_id=shared_task_id,
platform="cli",
)
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
assert (owner_session, shared_task_id) in runtime._task_sessions
agent = object.__new__(AIAgent)
agent.session_id = "skipped-session"
agent.platform = "cli"
agent._parent_session_id = None
agent._session_db = None
agent._cached_system_prompt = "stable"
agent.tools = []
skipped_turn = SimpleNamespace(relay_enabled=False)
monkeypatch.setattr(
relay_runtime.SESSION_COORDINATOR,
"begin_turn",
lambda *_args, **_kwargs: skipped_turn,
)
monkeypatch.setattr(
relay_runtime.SESSION_COORDINATOR,
"finish_logical_calls",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
relay_runtime.SESSION_COORDINATOR,
"end_turn",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
"agent.conversation_loop.run_conversation",
lambda *_args, **_kwargs: {"final_response": "ok", "completed": True},
)
result = AIAgent.run_conversation(
agent,
"hello",
conversation_history=[],
task_id=shared_task_id,
)
assert result["completed"] is True
assert (owner_session, shared_task_id) in runtime._task_sessions
assert not [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == relay_shared_metrics.TASK_SCOPE
]
relay_shared_metrics.finish_task_run(
session_id=owner_session,
task_id=shared_task_id,
platform="cli",
result={"completed": True},
)
@pytest.mark.parametrize(
("profile_enabled", "managed_enabled"),
((None, True), (False, True), (True, False)),
)
def test_managed_config_cannot_override_shared_metrics_consent(
tmp_path,
monkeypatch,
profile_enabled,
managed_enabled,
):
from hermes_cli import config, managed_scope
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
profile = tmp_path / "profile"
managed = tmp_path / "managed"
profile.mkdir()
managed.mkdir()
profile_config = "{}\n"
if profile_enabled is not None:
profile_config = (
"telemetry:\n"
" shared_metrics:\n"
f" enabled: {str(profile_enabled).lower()}\n"
)
(profile / "config.yaml").write_text(profile_config, encoding="utf-8")
(managed / "config.yaml").write_text(
"telemetry:\n"
" shared_metrics:\n"
f" enabled: {str(managed_enabled).lower()}\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
config._LOAD_CONFIG_CACHE.clear()
config._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
token = set_hermes_home_override(profile)
try:
assert (
config.load_config_readonly()["telemetry"]["shared_metrics"]["enabled"]
is managed_enabled
)
assert relay_shared_metrics.enabled() is (profile_enabled is True)
finally:
reset_hermes_home_override(token)
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
managed_scope.invalidate_managed_cache()
def test_disabling_shared_metrics_stops_collection_and_shutdown_export(
tmp_path, monkeypatch
):
from hermes_cli.observability.shared_metrics import SharedMetricsStore
fake = _Relay()
profile = tmp_path / "profile"
policy = {"enabled": True}
monkeypatch.setenv("HERMES_HOME", str(profile))
monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake)
monkeypatch.setattr(
"hermes_cli.config.read_raw_config_readonly",
lambda: {"telemetry": {"shared_metrics": dict(policy)}},
)
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
relay_shared_metrics.start_task_run(
session_id="session",
task_id="task",
platform="cli",
)
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
policy["enabled"] = False
assert not relay_shared_metrics.enabled()
counters_before_stale_event = runtime.subscriber.store.counter_snapshot()
runtime.subscriber(
SimpleNamespace(
kind="scope",
category="function",
category_profile=None,
name="hermes.task_run",
scope_category="start",
metadata={
"hermes.metrics.schema_version": "hermes.metrics.event.v1",
relay_runtime.RUNTIME_INSTANCE_KEY: runtime.host.runtime_id,
},
data={"entrypoint": "interactive", "execution_surface": "cli"},
)
)
assert runtime.subscriber.store.counter_snapshot() == counters_before_stale_event
assert (
runtime.start_task({
"session_id": "session",
"task_id": "stale-runtime-task",
"platform": "cli",
})
is None
)
relay_shared_metrics.finish_task_run(
session_id="session",
task_id="task",
platform="cli",
result={"completed": True},
)
relay_shared_metrics._reset_for_tests()
root = profile / "telemetry" / "shared_metrics"
store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox")
assert [row["metric_name"] for row in store.counter_snapshot()] == [
"hermes.client.active",
"hermes.install.milestone",
"hermes.install.snapshot",
"hermes.task_run.started"
]
assert list((root / "outbox").glob("*.json")) == []
relay_runtime._reset_for_tests()
def test_sync_session_runner_releases_lock_before_callback(direct_runtime):
runtime = relay_runtime.get_runtime()
assert runtime is not None
session = runtime.ensure_session({"session_id": "sync-session"})
assert session is not None
acquired = threading.Event()
contender = None
def probe() -> Any:
nonlocal contender
def acquire_session_lock() -> None:
with session.lock:
acquired.set()
contender = threading.Thread(target=acquire_session_lock)
contender.start()
assert acquired.wait(timeout=1)
return direct_runtime._scope.get()
result = runtime.run_in_session(session, probe)
assert contender is not None
contender.join(timeout=1)
assert result == session.handle
assert contender.is_alive() is False
def test_close_session_drains_orphaned_scopes_before_session_pop(direct_runtime):
"""Orphaned physical scopes must not permanently wedge session close (#81521)."""
runtime = relay_runtime.get_runtime()
assert runtime is not None
session = runtime.ensure_session({"session_id": "orphan-drain"})
assert session is not None
session_handle = session.handle
orphan = runtime.run_in_session(
session,
direct_runtime.scope.push,
"orphaned-physical-llm",
direct_runtime.ScopeType.Function,
handle=session_handle,
)
assert orphan is not None
# Without drain, popping the session while the orphan is on top fails
# with "scope handle is not at the top of the stack".
runtime.close_session({"session_id": "orphan-drain"})
assert runtime.get_session("orphan-drain") is None
rejected = [
event
for event in direct_runtime.events
if event[0] == "scope.pop.rejected" and event[1] == session_handle
]
# First attempt may reject; drain + retry must succeed so the session
# handle is eventually popped (not left rejected-only).
session_pops = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1] == session_handle
]
orphan_pops = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1] == orphan
]
assert orphan_pops, "orphaned physical scope was not drained"
assert session_pops, f"session scope never closed (rejected={rejected!r})"
def test_real_binding_drains_orphaned_scope_before_session_pop(
real_binding_runtime,
):
"""Orphan drain must work against the pinned native binding (#81521).
Regression guard for the #81601 review finding: the native binding's
``get_scope_stack()`` returns a ``ScopeStack`` object (not a list and
not a ``ScopeHandle``), and ``scope.pop`` rejects it with TypeError.
The drain path must use the version-correct top accessor
(``scope.get_handle()``) and compare handles by uuid, because native
``ScopeHandle`` instances do not compare equal by value.
"""
runtime = relay_runtime.get_runtime()
assert runtime is not None
session = runtime.ensure_session({"session_id": "native-orphan-drain"})
assert session is not None
orphan = runtime.run_in_session(
session,
real_binding_runtime.scope.push,
"orphaned-physical-llm",
real_binding_runtime.ScopeType.Function,
handle=session.handle,
)
assert orphan is not None
# Without the drain fix this fails: the direct session pop raises
# "scope handle is not at the top of the stack", and the pre-fix
# drain retried with a ScopeStack object that pop() rejects.
failure = runtime._close_scope_handle(
session,
session.handle,
output={},
allow_closing=True,
failure_label="session scope close failed",
)
assert failure is None, failure
def test_concurrent_turn_skips_relay_before_scope_stack_can_interleave(
direct_runtime,
):
coordinator = relay_runtime.SESSION_COORDINATOR
profile_key = relay_runtime.current_profile_key()
lease = coordinator.acquire_conversation(
profile_key=profile_key,
session_id="shared-session",
platform="cli",
)
first = coordinator.begin_turn(lease, turn_id="first", task_id="first-task")
second = coordinator.begin_turn(
lease,
turn_id="second",
task_id="second-task",
)
assert first.relay_enabled is True
assert first.handle is not None
assert second.relay_enabled is False
assert second.handle is None
assert relay_runtime.resolve_execution_context("shared-session") == (
None,
None,
None,
)
coordinator.end_turn(first, outcome="success")
coordinator.end_turn(second, outcome="success")
coordinator.release_conversation(lease)
coordinator.finalize_conversation(
profile_key=profile_key,
session_id="shared-session",
)
turn_closes = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1] == first.handle
]
assert len(turn_closes) == 1
assert not [
event
for event in direct_runtime.events
if event[0] == "scope.pop.rejected"
]
def test_concurrent_turn_skips_shared_metrics_scope_creation(direct_runtime):
coordinator = relay_runtime.SESSION_COORDINATOR
profile_key = relay_runtime.current_profile_key()
lease = coordinator.acquire_conversation(
profile_key=profile_key,
session_id="shared-session",
platform="cli",
)
first = coordinator.begin_turn(lease, turn_id="first", task_id="first-task")
second = coordinator.begin_turn(lease, turn_id="second", task_id="second-task")
relay_shared_metrics.observe_lifecycle(
"pre_llm_call",
session_id="shared-session",
task_id="second-task",
platform="cli",
)
relay_shared_metrics.observe_lifecycle(
"pre_api_request",
session_id="shared-session",
task_id="second-task",
api_request_id="second-request",
platform="cli",
)
assert second.relay_enabled is False
assert not [
event
for event in direct_runtime.events
if event[0] == "scope.push" and event[1] == relay_shared_metrics.TASK_SCOPE
]
coordinator.end_turn(first, outcome="success")
coordinator.end_turn(second, outcome="success")
coordinator.release_conversation(lease)
def test_skipped_turn_stays_gated_after_instrumented_turn_ends(direct_runtime):
coordinator = relay_runtime.SESSION_COORDINATOR
profile_key = relay_runtime.current_profile_key()
lease = coordinator.acquire_conversation(
profile_key=profile_key,
session_id="shared-session",
platform="cli",
)
first = coordinator.begin_turn(lease, turn_id="first", task_id="first-task")
second = coordinator.begin_turn(lease, turn_id="second", task_id="second-task")
inherited = contextvars.copy_context()
coordinator.end_turn(first, outcome="success")
assert relay_runtime.current_turn() is second
assert inherited.run(relay_runtime.current_turn) is second
assert not relay_runtime.relay_instrumentation_enabled()
assert not inherited.run(relay_runtime.relay_instrumentation_enabled)
assert relay_runtime.resolve_execution_context("shared-session") == (
None,
None,
None,
)
relay_shared_metrics.observe_lifecycle(
"pre_llm_call",
session_id="shared-session",
task_id="second-task",
platform="cli",
)
inherited.run(
relay_shared_metrics.observe_lifecycle,
"pre_api_request",
session_id="shared-session",
task_id="second-task",
api_request_id="second-request",
platform="cli",
)
assert not [
event
for event in direct_runtime.events
if event[0] == "scope.push"
and event[1]
in {relay_shared_metrics.TASK_SCOPE, relay_shared_metrics.MODEL_CALL_SCOPE}
]
coordinator.end_turn(second, outcome="success")
assert relay_runtime.current_turn() is None
assert inherited.run(relay_runtime.current_turn) is second
assert not inherited.run(relay_runtime.relay_instrumentation_enabled)
coordinator.release_conversation(lease)
@pytest.mark.parametrize(
"terminal",
["return", "exception", "cancelled", "timeout"],
)
def test_subagent_agent_boundary_closes_its_own_scope(
direct_runtime,
monkeypatch,
terminal,
):
from run_agent import AIAgent
coordinator = relay_runtime.SESSION_COORDINATOR
profile_key = relay_runtime.current_profile_key()
parent_lease = coordinator.acquire_conversation(
profile_key=profile_key,
session_id="parent",
platform="cli",
)
parent_turn = coordinator.begin_turn(
parent_lease,
turn_id="parent-turn",
task_id="parent-task",
)
child_agent = SimpleNamespace(
session_id="child",
platform="subagent",
_parent_session_id="parent",
_session_db=None,
_conversation_root_id=lambda: "parent",
)
if terminal == "return":
monkeypatch.setattr(
"agent.conversation_loop.run_conversation",
lambda *_args, **_kwargs: {
"final_response": "done",
"completed": True,
"interrupted": False,
},
)
AIAgent.run_conversation(child_agent, "private", task_id="child-task")
elif terminal == "exception":
def fail(*_args, **_kwargs):
raise RuntimeError("child failed")
monkeypatch.setattr("agent.conversation_loop.run_conversation", fail)
with pytest.raises(RuntimeError, match="child failed"):
AIAgent.run_conversation(child_agent, "private", task_id="child-task")
elif terminal == "cancelled":
def cancel(*_args, **_kwargs):
raise KeyboardInterrupt
monkeypatch.setattr("agent.conversation_loop.run_conversation", cancel)
with pytest.raises(KeyboardInterrupt):
AIAgent.run_conversation(child_agent, "private", task_id="child-task")
else:
def time_out(*_args, **_kwargs):
raise TimeoutError("child timed out")
monkeypatch.setattr("agent.conversation_loop.run_conversation", time_out)
with pytest.raises(TimeoutError, match="child timed out"):
AIAgent.run_conversation(child_agent, "private", task_id="child-task")
runtime = relay_runtime.get_runtime(create=False)
assert runtime is not None
assert runtime.get_session("child") is None
child_push = next(
event
for event in direct_runtime.events
if event[0] == "scope.push"
and event[1] == relay_runtime.SESSION_SCOPE
and event[3]["metadata"].get("nemo_relay_scope_role") == "subagent"
)
assert child_push[3]["handle"] == parent_turn.handle
child_closes = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == relay_runtime.SESSION_SCOPE
]
assert len(child_closes) == 1
assert relay_runtime.current_turn() is parent_turn
coordinator.end_turn(parent_turn, outcome="success")
coordinator.release_conversation(parent_lease)
coordinator.finalize_conversation(
profile_key=profile_key,
session_id="parent",
)
def test_terminal_model_error_retains_the_failed_route(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"api_request_id": "r1",
"provider": "anthropic",
"model": "claude-sonnet",
}
lifecycle.invoke_hook("pre_api_request", **base)
lifecycle.invoke_hook(
"api_request_error",
**base,
retryable=False,
reason="auth",
error={"message": "sensitive-error"},
)
assert not [event for event in direct_runtime.events if event[0] == "llm.call_end"]
runtime = relay_shared_metrics._get_runtime()
session = runtime._session(base)
assert session is not None
[model_call] = session.model_calls.values()
assert model_call.fields == {
"model": "claude-sonnet",
"provider": "anthropic",
}
lifecycle.finalize_session(session_id="s1")
[end] = [event for event in direct_runtime.events if event[0] == "llm.call_end"]
assert end[2] == {
"call_role": "primary",
"error_class": "auth",
"model": "claude-sonnet",
"outcome": "failed",
"provider": "anthropic",
"ttft_bucket": "unknown",
}
def test_nonretryable_provider_error_can_recover_within_one_logical_call(
direct_runtime,
):
base = {
"session_id": "s1",
"task_id": "t1",
"api_request_id": "r1",
"provider": "anthropic",
"model": "claude-sonnet",
}
lifecycle.invoke_hook("pre_api_request", **base, retry_count=0)
lifecycle.invoke_hook(
"api_request_error",
**base,
retry_count=0,
retryable=False,
reason="model_not_found",
)
fallback = {
**base,
"provider": "openai-api",
"model": "gpt-5",
}
lifecycle.invoke_hook("pre_api_request", **fallback, retry_count=0)
lifecycle.invoke_hook(
"post_api_request",
**fallback,
retry_count=0,
)
lifecycle.finalize_session(session_id="s1")
[end] = [event for event in direct_runtime.events if event[0] == "llm.call_end"]
[start] = [event for event in direct_runtime.events if event[0] == "llm.call"]
assert start[3]["model_name"] == "unknown"
# The recovered call keeps the error it recovered from.
assert end[2] == {
"call_role": "primary",
"error_class": "model_not_found",
"model": "gpt-5",
"outcome": "success",
"provider": "openai-api",
"ttft_bucket": "unknown",
}
def test_same_request_id_is_isolated_between_tasks(direct_runtime):
common = {
"session_id": "s1",
"api_request_id": "shared-request",
"platform": "cli",
"provider": "anthropic",
"model": "claude-sonnet",
}
for task_id in ("t1", "t2"):
lifecycle.invoke_hook("pre_llm_call", **common, task_id=task_id)
lifecycle.invoke_hook("pre_api_request", **common, task_id=task_id)
lifecycle.invoke_hook("post_api_request", **common)
assert not [event for event in direct_runtime.events if event[0] == "llm.call_end"]
for task_id in ("t2", "t1"):
lifecycle.invoke_hook("post_api_request", **common, task_id=task_id)
lifecycle.invoke_hook(
"on_session_end",
**common,
task_id=task_id,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="s1")
model_ends = [
event for event in direct_runtime.events if event[0] == "llm.call_end"
]
assert len(model_ends) == 2
task_ends = [
event[2]["output"]
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert len(task_ends) == 2
assert all(fields["model_call_count_bucket"] == "1" for fields in task_ends)
assert all(fields["retry_count_bucket"] == "0" for fields in task_ends)
def test_reused_tool_call_id_is_counted_for_each_provider_request(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"platform": "cli",
}
lifecycle.invoke_hook("pre_llm_call", **base)
for api_request_id in ("request-1", "request-2"):
call = {
**base,
"api_request_id": api_request_id,
"tool_call_id": "provider-reused-id",
"tool_name": "terminal",
}
lifecycle.invoke_hook("pre_tool_call", **call, args={"command": "private"})
lifecycle.invoke_hook(
"post_tool_call",
**call,
result={"output": "private"},
status="ok",
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="s1")
tool_ends = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert len(tool_ends) == 2
[task_end] = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert task_end[2]["output"]["tool_call_count_bucket"] == "2"
def test_partial_terminal_context_reuses_the_pending_tool_span(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"api_request_id": "request-1",
"platform": "cli",
"tool_call_id": "tool-1",
"tool_name": "terminal",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook("pre_tool_call", **base)
lifecycle.invoke_hook(
"post_tool_call",
**{key: value for key, value in base.items() if key != "api_request_id"},
result={"output": "private"},
status="ok",
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="s1")
[tool_end] = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert tool_end[2]["outcome"] == "success"
[task_end] = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert task_end[2]["output"]["tool_call_count_bucket"] == "1"
def test_partial_terminal_variants_do_not_double_count_a_completed_call(
direct_runtime,
):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"api_request_id": "request-1",
"platform": "cli",
"tool_call_id": "tool-1",
"tool_name": "terminal",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook("pre_tool_call", **base)
for omitted_field in ("api_request_id", "turn_id"):
lifecycle.invoke_hook(
"post_tool_call",
**{key: value for key, value in base.items() if key != omitted_field},
result={"output": "private"},
status="ok",
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="s1")
tool_ends = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert len(tool_ends) == 1
[task_end] = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert task_end[2]["output"]["tool_call_count_bucket"] == "1"
def test_ambiguous_partial_terminal_does_not_create_a_phantom_tool_span(
direct_runtime,
):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"platform": "cli",
"tool_call_id": "provider-reused-id",
"tool_name": "terminal",
}
lifecycle.invoke_hook("pre_llm_call", **base)
for api_request_id in ("request-1", "request-2"):
lifecycle.invoke_hook(
"pre_tool_call",
**base,
api_request_id=api_request_id,
)
lifecycle.invoke_hook(
"post_tool_call",
**base,
result={"output": "ambiguous-private-result"},
status="ok",
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=False,
failed=True,
interrupted=False,
turn_exit_reason="system_aborted",
)
lifecycle.finalize_session(session_id="s1")
tool_ends = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert len(tool_ends) == 2
assert all(event[2]["outcome"] == "failed" for event in tool_ends)
[task_end] = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert task_end[2]["output"]["tool_call_count_bucket"] == "2"
def test_reused_task_id_starts_a_new_run_for_each_turn(direct_runtime):
for turn_id in ("turn-1", "turn-2"):
base = {
"session_id": "reused-session",
"task_id": "reused-session",
"turn_id": turn_id,
"platform": "api",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook(
"post_tool_call",
**base,
api_request_id=f"request-{turn_id}",
tool_call_id=f"tool-{turn_id}",
tool_name="read_file",
result={"output": "private"},
status="ok",
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="reused-session")
task_starts = [
event
for event in direct_runtime.events
if event[0] == "scope.push" and event[1] == "hermes.task_run"
]
task_ends = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
tool_ends = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert len(task_starts) == 2
assert len(task_ends) == 2
assert len(tool_ends) == 2
assert all(
event[2]["output"]["tool_call_count_bucket"] == "1" for event in task_ends
)
def test_late_tool_result_does_not_attach_to_reused_task_id(direct_runtime):
first = {
"session_id": "reused-session",
"task_id": "reused-session",
"turn_id": "turn-1",
"platform": "api",
}
lifecycle.invoke_hook("pre_llm_call", **first)
lifecycle.invoke_hook(
"pre_tool_call",
**first,
api_request_id="request-1",
tool_call_id="tool-1",
tool_name="terminal",
)
lifecycle.invoke_hook(
"on_session_end",
**first,
completed=False,
failed=True,
interrupted=False,
turn_exit_reason="timed_out",
)
second = {**first, "turn_id": "turn-2"}
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
assert runtime.start_task({
"session_id": second["session_id"],
"task_id": second["task_id"],
"platform": second["platform"],
})
lifecycle.invoke_hook(
"post_tool_call",
**first,
api_request_id="request-1",
tool_call_id="tool-1",
tool_name="terminal",
result={"output": "late-private-result"},
status="ok",
)
lifecycle.invoke_hook("pre_llm_call", **second)
lifecycle.invoke_hook(
"post_tool_call",
**second,
api_request_id="request-2",
tool_call_id="tool-2",
tool_name="read_file",
result={"output": "current-private-result"},
status="ok",
)
lifecycle.invoke_hook(
"on_session_end",
**second,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="reused-session")
tool_ends = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert len(tool_ends) == 2
assert [event[2]["outcome"] for event in tool_ends] == [
"timed_out",
"success",
]
assert [event[2]["tool_category"] for event in tool_ends] == [
"terminal",
"file",
]
task_ends = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert [
event[2]["output"]["tool_call_count_bucket"] for event in task_ends
] == ["1", "1"]
def test_pending_tool_is_closed_and_counted_when_task_is_interrupted(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"platform": "cli",
}
lifecycle.invoke_hook("on_session_start", **base)
lifecycle.invoke_hook(
"pre_tool_call",
**base,
tool_call_id="tool-1",
tool_name="terminal",
args={"command": "must-not-pass"},
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=False,
failed=False,
interrupted=True,
turn_exit_reason="interrupted_by_user",
)
lifecycle.invoke_hook(
"post_tool_call",
**base,
tool_call_id="tool-1",
tool_name="terminal",
result={"output": "late-result-must-not-pass"},
status="ok",
)
lifecycle.finalize_session(session_id="s1")
[tool_end] = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert tool_end[2] == {
"approval_outcome": "not_required",
"error_class": "interrupted",
"latency_bucket": tool_end[2]["latency_bucket"],
"outcome": "cancelled",
"retry_count_bucket": "unknown",
"tool_category": "terminal",
"tool_name": "terminal",
}
[task_end] = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert task_end[2]["output"]["tool_call_count_bucket"] == "1"
task_starts = [
event
for event in direct_runtime.events
if event[0] == "scope.push" and event[1] == "hermes.task_run"
]
assert len(task_starts) == 1
def test_pending_tool_uses_the_outer_task_timeout_outcome(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"platform": "api",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook(
"pre_tool_call",
**base,
tool_call_id="tool-1",
tool_name="web_search",
)
relay_shared_metrics.finish_task_run(
session_id="s1",
task_id="t1",
platform="api",
error=TimeoutError("private timeout detail"),
)
lifecycle.finalize_session(session_id="s1")
[tool_end] = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert tool_end[2]["outcome"] == "timed_out"
[task_end] = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert task_end[2]["output"]["outcome"] == "timed_out"
assert task_end[2]["output"]["tool_call_count_bucket"] == "1"
assert "private timeout detail" not in repr(direct_runtime.events)
def test_late_model_start_does_not_create_an_orphan_after_task_completion(
direct_runtime,
):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"platform": "cli",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.invoke_hook(
"pre_api_request",
**base,
api_request_id="late-request",
provider="nvidia",
model="nvidia/nemotron-3-super-120b-a12b",
)
lifecycle.finalize_session(session_id="s1")
assert [event for event in direct_runtime.events if event[0] == "llm.call"] == []
assert [
event for event in direct_runtime.events if event[0] == "llm.call_end"
] == []
def test_approval_without_tool_context_is_counted_as_unattributed(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"platform": "cli",
}
lifecycle.invoke_hook("on_session_start", **base)
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook(
"post_approval_response",
turn_id="turn-1",
choice="deny",
command="must-not-pass",
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=False,
failed=True,
interrupted=False,
turn_exit_reason="approval_denied",
)
lifecycle.finalize_session(session_id="s1")
[approval] = [
event
for event in direct_runtime.events
if event[0] == "scope.event" and event[1] == "hermes.tool_approval"
]
assert approval[2]["data"] == {
"attribution": "unattributed",
"outcome": "denied",
}
def test_approval_with_unmatched_tool_id_is_counted_as_unattributed(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"platform": "cli",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook(
"post_approval_response",
**base,
tool_call_id="spoofed-tool-call",
choice="deny",
command="must-not-pass",
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=False,
failed=True,
interrupted=False,
turn_exit_reason="approval_denied",
)
lifecycle.finalize_session(session_id="s1")
[approval] = [
event
for event in direct_runtime.events
if event[0] == "scope.event" and event[1] == "hermes.tool_approval"
]
assert approval[2]["data"] == {
"attribution": "unattributed",
"outcome": "denied",
}
def test_tool_category_comes_from_runtime_registry_metadata(
direct_runtime,
monkeypatch,
):
import model_tools
monkeypatch.setattr(
model_tools,
"get_toolset_for_tool",
lambda name: "terminal" if name == "runtime_only_tool" else None,
)
base = {
"session_id": "s1",
"task_id": "t1",
"turn_id": "turn-1",
"platform": "cli",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook(
"post_tool_call",
**base,
tool_call_id="tool-1",
tool_name="runtime_only_tool",
result={"output": "private"},
status="ok",
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="s1")
[tool_end] = [
event for event in direct_runtime.events if event[0] == "tool.call_end"
]
assert tool_end[2]["tool_category"] == "terminal"
def test_task_retry_count_survives_provider_fallback_ordinal_reset(direct_runtime):
base = {
"session_id": "s1",
"task_id": "t1",
"api_request_id": "r1",
"platform": "cli",
"provider": "nvidia",
"model": "nvidia/nemotron-3-super-120b-a12b",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook("pre_api_request", **base, retry_count=0)
lifecycle.invoke_hook(
"api_request_error",
**base,
retry_count=0,
retryable=True,
)
lifecycle.invoke_hook("pre_api_request", **base, retry_count=1)
lifecycle.invoke_hook(
"api_request_error",
**base,
retry_count=1,
retryable=True,
)
lifecycle.invoke_hook(
"pre_api_request",
**{**base, "provider": "openai", "model": "gpt-5"},
retry_count=0,
)
lifecycle.invoke_hook(
"post_api_request",
**{**base, "provider": "openai", "model": "gpt-5"},
retry_count=0,
)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="s1")
[model_end] = [
event for event in direct_runtime.events if event[0] == "llm.call_end"
]
assert {k: model_end[2][k] for k in ("model", "outcome", "provider")} == {
"model": "gpt-5",
"outcome": "success",
"provider": "openai",
}
[task_end] = [
event
for event in direct_runtime.events
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
]
assert task_end[2]["output"]["retry_count_bucket"] == "2"
def test_failed_flush_keeps_daily_export_open_for_later_task(
direct_runtime, tmp_path, monkeypatch, caplog
):
current_time = datetime(2026, 7, 28, 9, tzinfo=timezone.utc)
monkeypatch.setattr(
"hermes_cli.observability.shared_metrics._utc_now",
lambda: current_time,
)
original_flush = direct_runtime.subscribers.flush
flush_attempts = 0
def fail_first_flush() -> None:
nonlocal flush_attempts
flush_attempts += 1
if flush_attempts == 1:
raise RuntimeError("simulated flush failure")
original_flush()
direct_runtime.subscribers.flush = fail_first_flush
def finish_desktop_task(task_id: str) -> None:
lifecycle.invoke_hook(
"pre_llm_call",
session_id="s1",
task_id=task_id,
platform="desktop",
)
lifecycle.invoke_hook(
"on_session_end",
session_id="s1",
task_id=task_id,
platform="desktop",
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
_join_export_workers()
finish_desktop_task("t1")
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
assert list((root / "outbox").glob("*.json")) == []
with sqlite3.connect(root / "metrics.sqlite3") as connection:
[package_count] = connection.execute(
"SELECT COUNT(*) FROM package_outbox"
).fetchone()
assert package_count == 0
finish_desktop_task("t2")
[package_path] = list((root / "outbox").glob("*.json"))
package = json.loads(package_path.read_text(encoding="utf-8"))
metrics = {metric["name"]: metric for metric in package["metrics"]}
assert metrics["hermes.task_run.started"]["value"] == 2
assert metrics["hermes.task_run.finished"]["value"] == 2
assert flush_attempts == 2
assert "Hermes shared-metrics task flush failed" in caplog.text
def _finish_desktop_task(session_id: str, task_id: str) -> None:
hook = dict(session_id=session_id, task_id=task_id, platform="desktop")
lifecycle.invoke_hook("pre_llm_call", **hook)
lifecycle.invoke_hook(
"on_session_end", **hook,
completed=True, failed=False, interrupted=False,
turn_exit_reason="text_response(stop)",
)
@pytest.fixture
def parked_flush(direct_runtime, monkeypatch):
"""A flush that blocks like the real barrier does while another session's tool runs."""
monkeypatch.setattr(
"hermes_cli.observability.shared_metrics._utc_now",
lambda: datetime(2026, 7, 28, 9, tzinfo=timezone.utc),
)
state = SimpleNamespace(attempts=0, entered=threading.Event(), release=threading.Event())
def parked() -> None:
state.attempts += 1
state.entered.set()
state.release.wait(3)
direct_runtime.subscribers.flush = parked
yield state
state.release.set()
_join_export_workers()
def test_task_ends_do_not_wait_on_the_process_wide_flush_barrier(parked_flush, tmp_path):
_finish_desktop_task("s1", "t1")
# The turn thread is back while the barrier is still parked, so nothing is exported yet.
assert parked_flush.entered.wait(3)
outbox = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" / "outbox"
assert list(outbox.glob("*.json")) == []
# Task ends behind the parked barrier coalesce into one more pass, not one each.
for task_id in ("t2", "t3", "t4"):
_finish_desktop_task("s1", task_id)
parked_flush.release.set()
_join_export_workers()
assert parked_flush.attempts == 2
assert len(list(outbox.glob("*.json"))) == 1
def test_background_flush_runs_under_the_finishing_turns_profile(
direct_runtime, tmp_path, monkeypatch
):
from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override
seen: list[Any] = []
monkeypatch.setattr(
relay_shared_metrics._Runtime,
"_export",
lambda self: seen.append(get_hermes_home()),
)
profile_home = tmp_path / "other-profile"
token = set_hermes_home_override(profile_home)
try:
_finish_desktop_task("s1", "t1")
_join_export_workers()
finally:
reset_hermes_home_override(token)
assert seen == [profile_home]
def test_skill_lifecycle_flows_through_relay_to_a_privacy_safe_package(
direct_runtime,
tmp_path,
):
common = {
"skill_name": "private-skill-name",
"provenance": "agent_created",
}
lifecycle.invoke_hook("on_skill_lifecycle", **common, action="created")
lifecycle.invoke_hook(
"on_skill_lifecycle",
**common,
action="loaded",
use_count=1,
reused=False,
reuse_after_patch=False,
)
lifecycle.invoke_hook("on_skill_lifecycle", **common, action="patched")
lifecycle.invoke_hook(
"on_skill_lifecycle",
**common,
action="loaded",
use_count=2,
reused=True,
reuse_after_patch=True,
)
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
runtime.shutdown()
marks = [event for event in direct_runtime.events if event[0] == "scope.event"]
assert [event[1] for event in marks] == [
"hermes.skill.lifecycle",
"hermes.skill.load",
"hermes.skill.lifecycle",
"hermes.skill.load",
]
assert "private-skill-name" not in json.dumps(marks)
outbox = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" / "outbox"
[package_path] = list(outbox.glob("*.json"))
package = json.loads(package_path.read_text(encoding="utf-8"))
skill_metrics = [
metric
for metric in package["metrics"]
if metric["name"].startswith("hermes.skill.")
]
assert {metric["name"] for metric in skill_metrics} == {
"hermes.skill.lifecycle.count",
"hermes.skill.load.count",
}
assert "private-skill-name" not in json.dumps(package)
def test_skill_lifecycle_with_only_task_id_uses_unique_task_scope(direct_runtime):
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
task = runtime.start_task({
"session_id": "session-1",
"task_id": "task-1",
"platform": "cli",
})
assert task is not None
lifecycle.invoke_hook(
"on_skill_lifecycle",
action="created",
skill_name="private-skill-name",
provenance="agent_created",
task_id="task-1",
)
[mark] = [
event
for event in direct_runtime.events
if event[0] == "scope.event" and event[1] == "hermes.skill.lifecycle"
]
assert mark[2]["handle"] == task.handle
def test_skill_task_only_correlation_does_not_guess_across_sessions(direct_runtime):
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
for session_id in ("session-1", "session-2"):
assert runtime.start_task({
"session_id": session_id,
"task_id": "shared-task",
"platform": "cli",
}) is not None
lifecycle.invoke_hook(
"on_skill_lifecycle",
action="created",
skill_name="private-skill-name",
provenance="agent_created",
task_id="shared-task",
)
[mark] = [
event
for event in direct_runtime.events
if event[0] == "scope.event" and event[1] == "hermes.skill.lifecycle"
]
assert "handle" not in mark[2]
def test_late_skill_lifecycle_is_not_reemitted_at_the_root(direct_runtime):
base = {
"session_id": "session-1",
"task_id": "task-1",
"turn_id": "turn-1",
"platform": "cli",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook(
"on_session_end",
**base,
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.invoke_hook(
"on_skill_lifecycle",
**base,
action="loaded",
skill_name="private-skill-name",
provenance="local",
use_count=2,
reused=True,
reuse_after_patch=False,
)
assert [
event
for event in direct_runtime.events
if event[0] == "scope.event" and event[1] == "hermes.skill.load"
] == []
def test_skill_lifecycle_does_not_fallback_across_an_explicit_session(
direct_runtime,
):
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
assert runtime.start_task({
"session_id": "session-1",
"task_id": "task-1",
"platform": "cli",
}) is not None
lifecycle.invoke_hook(
"on_skill_lifecycle",
action="created",
skill_name="private-skill-name",
provenance="local",
session_id="wrong-session",
task_id="task-1",
)
assert [
event
for event in direct_runtime.events
if event[0] == "scope.event" and event[1] == "hermes.skill.lifecycle"
] == []
def test_real_binding_concurrent_task_close_skips_pop_under_sibling_scope(
real_binding_runtime, caplog,
):
"""Two concurrent turns in one session open two task scopes on the same physical
stack; finishing the LOWER one first must not raise "scope handle is not at the top
of the stack" (#115471) nor pop the sibling's live scope. The orphan is reclaimed by
the session-close drain, so both scopes are gone after the session ends."""
event = {"session_id": "shared-session", "task_id": "task-A", "turn_id": "turn-A", "platform": "cli"}
lifecycle.invoke_hook("on_session_start", **event)
relay_shared_metrics.start_task_run(session_id="shared-session", task_id="task-A", platform="cli")
relay_shared_metrics.start_task_run(session_id="shared-session", task_id="task-B", platform="cli")
runtime = relay_shared_metrics._get_runtime()
assert runtime is not None
session = runtime._session(event)
task_b_handle = session.tasks["task-B"].handle
with caplog.at_level("WARNING"):
relay_shared_metrics.finish_task_run(
session_id="shared-session", task_id="task-A", platform="cli", result={"ok": True},
)
assert not [r for r in caplog.records if "task close failed" in r.getMessage()], caplog.text
# Task B's live scope is still the top of its context's stack: the sibling was not popped through.
top = runtime._run_in_task(session.tasks["task-B"], relay_runtime._current_top, runtime.relay)
assert relay_runtime._same_handle(top, task_b_handle)
relay_shared_metrics.finish_task_run(
session_id="shared-session", task_id="task-B", platform="cli", result={"ok": True},
)
lifecycle.invoke_hook("on_session_end", **event)
assert "task close failed" not in caplog.text
def _stored_counters(tmp_path) -> dict[str, list[dict[str, Any]]]:
from hermes_cli.observability.shared_metrics import SharedMetricsStore
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
by_metric: dict[str, list[dict[str, Any]]] = {}
for counter in SharedMetricsStore(root / "metrics.sqlite3", root / "outbox").counter_snapshot():
by_metric.setdefault(counter["metric_name"], []).append(counter["dimensions"])
return by_metric
def test_v3_failure_dimensions_never_export_third_party_identifiers(direct_runtime, tmp_path):
"""Plugin tool names, exception class names, plugin platforms and raw exit reasons collapse
to closed classes, while Hermes's own vocabularies pass through."""
base = {
"session_id": "s1", "task_id": "t1", "api_request_id": "r1", "platform": "telegram",
"provider": "anthropic", "model": "claude-sonnet",
}
lifecycle.invoke_hook("pre_llm_call", **base)
lifecycle.invoke_hook("pre_api_request", **base)
lifecycle.invoke_hook("api_request_error", **base, retryable=True, reason="rate_limit")
for call_id, tool, error_type in (
("c1", "acme_private_tool", "AcmePrivateError"), ("c2", "terminal", "tool_timeout"),
):
lifecycle.invoke_hook("pre_tool_call", **base, tool_call_id=call_id, tool_name=tool)
lifecycle.invoke_hook(
"post_tool_call", **base, tool_call_id=call_id, tool_name=tool, status="error",
error_type=error_type, duration_ms=10,
)
relay_shared_metrics.finish_task_run(
session_id="s1", task_id="t1", platform="telegram",
result={"failed": True, "failure_reason": "rate_limit",
"turn_exit_reason": "acme private exit reason"},
)
lifecycle.finalize_session(session_id="s1")
counters = _stored_counters(tmp_path)
assert "acme" not in json.dumps(counters).lower()
assert {(d["tool_name"], d["error_class"]) for d in counters["hermes.tool.usage.count"]} == {
("plugin", "exception"), ("terminal", "timeout"),
}
[route] = counters["hermes.model_route.count"]
assert (route["outcome"], route["error_class"]) == ("failed", "rate_limit")
[finished] = counters["hermes.task_run.finished"]
assert (finished["platform"], finished["failure_class"]) == ("telegram", "rate_limit")
def test_install_snapshot_is_daily_and_carries_only_bucketed_counts(
direct_runtime, tmp_path, monkeypatch,
):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config_readonly",
lambda: {
"telemetry": {"shared_metrics": {"enabled": True}},
"memory": {"provider": "acme-private-memory"},
"mcp_servers": {"acme-private-server": {"command": "x"}, "off": {"enabled": False}},
"plugins": {"enabled": ["acme-private-plugin", "b", "c"]},
},
)
for task_id in ("t1", "t2"):
event = {"session_id": task_id, "task_id": task_id, "platform": "cli"}
lifecycle.invoke_hook("pre_llm_call", **event)
relay_shared_metrics.finish_task_run(**event, result={"completed": True})
lifecycle.finalize_session(session_id=task_id)
# A new runtime has no in-process throttle; only the store's 24h latch can hold it back.
relay_shared_metrics._reset_for_tests()
event = {"session_id": "t3", "task_id": "t3", "platform": "cli"}
lifecycle.invoke_hook("pre_llm_call", **event)
lifecycle.finalize_session(session_id="t3")
[snapshot] = _stored_counters(tmp_path)["hermes.install.snapshot"]
assert snapshot == {
"cron_job_count_bucket": "0", "display_language": "en", "install_age_bucket": snapshot["install_age_bucket"],
"main_provider": "none", "mcp_server_count_bucket": "1", "memory_provider": "plugin",
"messaging_platform_count_bucket": "0", "plugin_count_bucket": "3_to_5",
"profile_count_bucket": snapshot["profile_count_bucket"], "skill_count_bucket": "0",
"terminal_backend": "local", "local_model_provider_used": "no",
**{k: snapshot[k] for k in ("behind_bucket", "gpu_class", "ram_bucket", "release_channel", "version_age_bucket")},
}
def _stored_values(tmp_path, metric: str) -> list[tuple[dict[str, Any], int]]:
from hermes_cli.observability.shared_metrics import SharedMetricsStore
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
return [
(counter["dimensions"], counter["value"])
for counter in SharedMetricsStore(root / "metrics.sqlite3", root / "outbox").counter_snapshot()
if counter["metric_name"] == metric
]
def test_sessions_summarize_on_close_and_milestones_latch_once_per_install(direct_runtime, tmp_path):
"""Each closed session yields one bucketed summary; delegated children do not; an install
milestone is recorded the first time only, however many sessions reach it."""
for session_id, turns in (("s1", 3), ("s2", 1)):
for turn in range(turns):
task = f"{session_id}-t{turn}"
lifecycle.invoke_hook("pre_llm_call", session_id=session_id, task_id=task, platform="cli")
relay_shared_metrics.finish_task_run(
session_id=session_id, task_id=task, platform="cli", result={"completed": True},
)
lifecycle.finalize_session(session_id=session_id)
sessions = _stored_values(tmp_path, "hermes.session.count")
assert sorted((d["turn_count_bucket"], d["last_outcome"], v) for d, v in sessions) == [
("1", "success", 1), ("3_to_5", "success", 1),
]
milestones = {d["milestone"]: v for d, v in _stored_values(tmp_path, "hermes.install.milestone")}
assert milestones["first_task_started"] == 1
assert milestones["first_task_success"] == 1
def test_user_named_providers_and_models_never_reach_counters(direct_runtime, tmp_path):
"""A custom endpoint's name (``custom:<key>``), its model id and loopback-server models read
``custom``; shipped providers and their public model ids stay readable."""
from hermes_cli.observability import shared_metrics_events as events
from hermes_cli.observability.shared_metrics import SharedMetricsStore
events.record_setup_completed(surface="cli", provider="custom:acme-secret-llm")
events.record_model_switch(from_provider="custom:acme-secret-llm", to_provider="lmstudio", surface="cli")
events.record_fallback(from_provider="acme-unknown", to_provider="openrouter", reason="rate_limit")
runtime = relay_shared_metrics._get_runtime(retry_failed=True)
for provider, model in (("custom:acme-secret-llm", "acme-internal"), ("lmstudio", "bob-finetune"),
("openrouter", "c:/users/bob/model.gguf"), ("openrouter", "anthropic/claude-sonnet")):
runtime.record_auxiliary_tokens({
"usage": {"input_tokens": 5}, "aux_task": "compression", "provider": provider, "model": model,
})
runtime.relay.subscribers.flush()
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
stored = json.dumps(SharedMetricsStore(root / "metrics.sqlite3", root / "outbox").counter_snapshot())
assert not any(leak in stored for leak in ("acme", "bob", "users"))
tokens = {(d["provider"], d["model"]) for d, _ in _stored_values(tmp_path, "hermes.model_tokens.sum")}
assert tokens == {("custom", "custom"), ("lmstudio", "custom"), ("openrouter", "custom"),
("openrouter", "anthropic/claude-sonnet")}
def test_milestone_install_age_is_the_subscriber_profile_not_the_relay_thread(tmp_path, monkeypatch):
"""Under multiplex the Relay thread carries no profile binding: a milestone reached in
profile B must carry B's install age, not the launch profile's."""
import sqlite3 as _sqlite3
import time
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_subscriber import SharedMetricsSubscriber
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
launch, other = tmp_path / "A", tmp_path / "B"
for home in (launch, other):
home.mkdir()
with _sqlite3.connect(other / "state.db") as con:
con.execute("CREATE TABLE sessions (id TEXT, started_at REAL)")
con.execute("INSERT INTO sessions VALUES ('s1', ?)", (time.time() - 120 * 86400,))
monkeypatch.setenv("HERMES_HOME", str(launch))
token = set_hermes_home_override(str(other))
try:
subscriber = SharedMetricsSubscriber(SharedMetricsStore(), "1.0")
finally:
reset_hermes_home_override(token)
subscriber._record_milestones("hermes.setup.completed", {"provider": "nous", "surface": "cli"})
ages = [c["dimensions"]["install_age_bucket"] for c in subscriber.store.counter_snapshot()
if c["metric_name"] == "hermes.install.milestone"]
assert ages == ["gte_90d"]
def test_token_usage_is_summed_per_model_and_auxiliary_task(direct_runtime, tmp_path):
base = {"session_id": "s1", "task_id": "t1", "provider": "anthropic", "model": "claude-sonnet"}
for request_id in ("r1", "r2"):
lifecycle.invoke_hook("pre_api_request", **base, api_request_id=request_id)
lifecycle.invoke_hook(
"post_api_request", **base, api_request_id=request_id,
usage={"input_tokens": 100, "output_tokens": 10, "cache_read_tokens": 40},
)
for aux_task in ("compression", "acme-private-task"):
lifecycle.invoke_hook(
"post_auxiliary_call", aux_task=aux_task, provider="openrouter", model="small/model",
usage={"input_tokens": 7, "output_tokens": 3},
)
relay_shared_metrics.finish_task_run(
session_id="s1", task_id="t1", platform="cli", result={"completed": True},
)
lifecycle.finalize_session(session_id="s1")
sums = {
(d["call_role"], d["aux_task"], d["token_type"]): v
for d, v in _stored_values(tmp_path, "hermes.model_tokens.sum")
}
assert sums == {
("primary", "none", "input"): 200, ("primary", "none", "output"): 20,
("primary", "none", "cache_read"): 80,
("auxiliary", "compression", "input"): 7, ("auxiliary", "compression", "output"): 3,
("auxiliary", "other", "input"): 7, ("auxiliary", "other", "output"): 3,
}
def test_recovered_rows_report_saved_only_once_the_store_holds_them(real_binding_runtime, monkeypatch):
from hermes_cli.observability.shared_metrics import SharedMetricsStore
row = ("hermes.process.exit", {"crash_class": "none", "exit_kind": "killed", "process_kind": "gateway"})
real_store_write = SharedMetricsStore.record_counter
def locked(*args, **kwargs):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(SharedMetricsStore, "record_counter", locked)
assert relay_shared_metrics.record_process_marks_saved([row]) == 0
monkeypatch.setattr(SharedMetricsStore, "record_counter", real_store_write)
assert relay_shared_metrics.record_process_marks_saved([row]) == 1
saved = [(r["metric_name"], r["dimensions"], r["value"]) for r in SharedMetricsStore().counter_snapshot()]
assert saved == [("hermes.process.exit", row[1], 1)]
def _cli_turn(session_id: str, parent_session_id: str = "") -> None:
hook = dict(session_id=session_id, task_id=f"{session_id}-t", platform="cli")
lifecycle.invoke_hook("pre_llm_call", **hook, parent_session_id=parent_session_id)
relay_shared_metrics.finish_task_run(**hook, result={"completed": True})
def test_finite_cli_runs_report_their_own_entrypoint(direct_runtime, tmp_path, monkeypatch):
"""`hermes -z` / `chat -q` / `-Q` runs (HERMES_SINGLE_QUERY_SESSION) report `one_shot`, a
dispatcher-spawned one (kanban worker, A2A forward) `background`; the REPL stays `interactive`,
and a delegated child or a non-CLI surface keeps its own entrypoint inside a finite run."""
monkeypatch.delenv("HERMES_SINGLE_QUERY_SESSION", raising=False)
monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False)
_cli_turn("repl")
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
_cli_turn("oneshot")
_cli_turn("child", parent_session_id="oneshot")
_finish_desktop_task("desktop", "desktop-t")
monkeypatch.setenv("HERMES_SESSION_SOURCE", "kanban")
_cli_turn("kanban")
for session_id in ("repl", "oneshot", "child", "desktop", "kanban"):
lifecycle.finalize_session(session_id=session_id)
_join_export_workers()
started = sorted((d["entrypoint"], d["execution_surface"], v)
for d, v in _stored_values(tmp_path, "hermes.task_run.started"))
assert started == [
("background", "cli", 1), ("delegated", "cli", 1), ("interactive", "cli", 1),
("interactive", "desktop", 1), ("one_shot", "cli", 1),
]
finished = {d["entrypoint"] for d, _ in _stored_values(tmp_path, "hermes.task_run.finished")}
assert finished == {"background", "delegated", "interactive", "one_shot"}
# Delegated children are not sessions of their own.
sessions = sorted((d["entrypoint"], d["execution_surface"], d["turn_count_bucket"])
for d, _ in _stored_values(tmp_path, "hermes.session.count"))
assert sessions == [
("background", "cli", "1"), ("interactive", "cli", "1"), ("interactive", "desktop", "1"),
("one_shot", "cli", "1"),
]
def test_oneshot_hard_exit_cleanup_records_the_session_summary(direct_runtime, tmp_path, monkeypatch):
"""`hermes -z` leaves through os._exit, past the atexit hook that closes the metrics session; its
pre-exit cleanup must write the run's session.count itself."""
from hermes_cli import main as hermes_main
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
monkeypatch.setattr(hermes_main, "_oneshot_cleanup_done", False)
_cli_turn("z-run")
hermes_main._cleanup_oneshot_runtime()
_join_export_workers()
sessions = _stored_values(tmp_path, "hermes.session.count")
assert [(d["entrypoint"], d["turn_count_bucket"], v) for d, v in sessions] == [("one_shot", "1", 1)]