diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 67696ae6ca..3dbbd397a0 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -147,6 +147,10 @@ class _RelayPluginConfigurationState(Enum): FAILED = auto() +class _RelayPluginConfigurationLoadError(RuntimeError): + """An explicitly selected Relay plugin configuration could not be loaded.""" + + @dataclass class RelaySession: """One isolated Relay scope stack owned by a Hermes session.""" @@ -1979,7 +1983,7 @@ def _load_nemo_relay() -> Any: def _configured_plugin_inputs( relay: Any, ) -> tuple[dict[str, Any], list[Any]] | None: - """Load environment-selected plugin inputs, or leave plugins disabled.""" + """Load selected plugin inputs, or return ``None`` when none were selected.""" configured = os.environ.get(RELAY_PLUGINS_CONFIG_ENV, "").strip() if not configured: legacy_vars = configured_legacy_relay_env_vars(os.environ) @@ -2010,14 +2014,11 @@ def _configured_plugin_inputs( plugin_config = dict(config) plugin_config.pop("plugins", None) return plugin_config, dynamic_plugins - except Exception: - logger.warning( - "Hermes Relay plugin configuration could not be loaded from %s; " - "continuing without Relay plugins", - config_path, - exc_info=True, - ) - return None + except Exception as exc: + raise _RelayPluginConfigurationLoadError( + "Hermes Relay plugin configuration could not be loaded from " + f"{config_path}; continuing without Relay plugins" + ) from exc def _flush_relay_subscribers(relay: Any) -> None: diff --git a/docs/observability/README.md b/docs/observability/README.md index f5ebea6177..6a9bca77f2 100644 --- a/docs/observability/README.md +++ b/docs/observability/README.md @@ -318,7 +318,7 @@ nested agent work or security lifecycle events. The bundled Langfuse plugin demonstrates direct hook-based observability for turns, provider requests, and tool calls. -The native NeMo Relay SDK integration maps Hermes session, turn, LLM, tool, -and mark lifecycles to Relay. Explicit Relay plugin configuration can add ATOF -or ATIF exporters and execution middleware; see +The native NeMo Relay SDK integration maps Hermes session, turn, LLM, and tool +lifecycles to Relay. Explicit Relay plugin configuration can add ATOF or ATIF +exporters and execution middleware; see [Relay shared metrics](relay-shared-metrics.md). diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 34f31990ce..146590dc99 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -62,6 +62,30 @@ from the selected file only. If the selected file cannot be loaded, Hermes reports the error and does not invoke Relay initialization or fall back to ambient discovery. +## Session-Span Segmentation for Continuous Sessions + +Relay exports a span when its scope closes. A continuous gateway session can +remain open for days, so its session span remains open even though each turn +span is exported normally. Optional segmentation rotates only the session +scope at a turn boundary: + +```yaml +gateway: + telemetry: + session_segments: + on_compaction: false # rotate after context compaction + max_turns: 0 # 0 = unlimited; N = turns per segment +``` + +| Key | Default | Behavior | +|---|---:|---| +| `on_compaction` | `false` | Rotate after compaction completes, at the next turn boundary. | +| `max_turns` | `0` | Rotate after every N completed turns; `0` disables the cap. | + +Both defaults preserve one session scope for the full session. Rotated spans +retain the same `session_id` and add `hermes.session.segment` plus +`hermes.session.segment_reason` (`compaction` or `max_turns`). + ## Process-Wide Plugin Policy and Profile Isolation Relay plugin configuration is a process-level deployment choice, not a Hermes diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 2c258ee8e8..aec327cd37 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1412,8 +1412,19 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: trusted and never prompted. """ from rich.console import Console + from hermes_cli.relay_plugin_cutover import ( + LEGACY_RELAY_PLUGIN_KEYS, + RELAY_PLUGINS_CONFIG_ENV, + ) console = Console() + if name in LEGACY_RELAY_PLUGIN_KEYS: + console.print( + f"[red]Plugin '{name}' was removed.[/red] Relay lifecycle is owned " + f"by Hermes core; configure {RELAY_PLUGINS_CONFIG_ENV} instead." + ) + sys.exit(1) + # Discover the plugin — check installed (user) AND bundled, including # nested category plugins — and normalize to its canonical registry key. resolved = _resolve_plugin_key_and_source(name) @@ -1422,6 +1433,13 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: sys.exit(1) key, source = resolved + if key in LEGACY_RELAY_PLUGIN_KEYS: + console.print( + f"[red]Plugin '{key}' was removed.[/red] Relay lifecycle is owned " + f"by Hermes core; configure {RELAY_PLUGINS_CONFIG_ENV} instead." + ) + sys.exit(1) + enabled = _get_enabled_set() disabled = _get_disabled_set() diff --git a/hermes_cli/relay_plugin_cutover.py b/hermes_cli/relay_plugin_cutover.py index 84aec0dae8..880fa867ff 100644 --- a/hermes_cli/relay_plugin_cutover.py +++ b/hermes_cli/relay_plugin_cutover.py @@ -26,6 +26,7 @@ LEGACY_RELAY_EXPORT_ENV_VARS = frozenset( "HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE", "HERMES_NEMO_RELAY_ATIF_AGENT_NAME", "HERMES_NEMO_RELAY_ATIF_AGENT_VERSION", + "HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S", "HERMES_NEMO_RELAY_ATIF_MODEL_NAME", "HERMES_NEMO_RELAY_ATIF_SUBAGENT_EXPORT_MODE", } diff --git a/tests/agent/test_relay_runtime_plugins.py b/tests/agent/test_relay_runtime_plugins.py index 91978f9f92..a906f94f29 100644 --- a/tests/agent/test_relay_runtime_plugins.py +++ b/tests/agent/test_relay_runtime_plugins.py @@ -188,6 +188,10 @@ def test_unset_config_disables_plugin_initialization(monkeypatch): try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.DISABLED + ) host.ensure_session({"session_id": "session"}) assert relay.events[0][0:2] == ("scope.push", relay_runtime.SESSION_SCOPE) assert not any(event[0].startswith("plugin.") for event in relay.events) @@ -213,6 +217,14 @@ def test_first_profile_plugin_decision_applies_to_later_profile( try: assert not host_a.managed_execution_enabled() assert not host_b.managed_execution_enabled() + assert ( + host_a._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.DISABLED + ) + assert ( + host_b._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.DISABLED + ) assert not any(event[0].startswith("plugin.") for event in relay.events) finally: host_a.shutdown() @@ -246,6 +258,10 @@ def test_foreign_active_plugin_configuration_is_left_unchanged( try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FOREIGN + ) assert relay.active_report is foreign_report assert relay.events == [] assert "already active outside Hermes native ownership" in caplog.text @@ -265,6 +281,10 @@ def test_unreadable_foreign_plugin_state_fails_safe( try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) assert relay.events == [] assert "refusing to replace it" in caplog.text finally: @@ -277,6 +297,7 @@ def test_legacy_exporter_env_without_plugins_toml_warns_and_stays_disabled( ): monkeypatch.delenv(relay_runtime.RELAY_PLUGINS_CONFIG_ENV, raising=False) monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1") + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S", "30") relay = _FakeRelay() with caplog.at_level("WARNING"): @@ -284,9 +305,14 @@ def test_legacy_exporter_env_without_plugins_toml_warns_and_stays_disabled( try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.DISABLED + ) assert relay.events == [] assert "no HERMES_NEMO_RELAY_PLUGINS_TOML was provided" in caplog.text assert "HERMES_NEMO_RELAY_ATOF_ENABLED" in caplog.text + assert "HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S" in caplog.text finally: host.shutdown() @@ -299,6 +325,10 @@ def test_initialization_failure_is_fail_open(explicit_static_config, caplog): try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) assert "Hermes Relay plugin initialization failed" in caplog.text finally: host.shutdown() @@ -308,18 +338,37 @@ def test_later_host_shares_initialization_failure(explicit_static_config): relay = _FakeRelay(initialize_error=RuntimeError("transient failure")) failed_host = relay_runtime.RelayRuntime(relay=relay, profile_key="failed") assert not failed_host.managed_execution_enabled() + assert ( + failed_host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) relay.initialize_error = None later_host = relay_runtime.RelayRuntime(relay=relay, profile_key="later") try: assert not later_host.managed_execution_enabled() + assert ( + later_host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) assert relay.events == [("plugin.initialize", {})] finally: failed_host.shutdown() later_host.shutdown() + retry_host = relay_runtime.RelayRuntime(relay=relay, profile_key="retry") + try: + assert retry_host.managed_execution_enabled() + assert ( + retry_host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.ACTIVE + ) + assert relay.events.count(("plugin.initialize", {})) == 2 + finally: + retry_host.shutdown() -def test_missing_explicit_config_does_not_fall_back_to_discovery( + +def test_missing_explicit_config_is_failed_for_all_current_hosts( tmp_path, monkeypatch, caplog, @@ -332,13 +381,22 @@ def test_missing_explicit_config_does_not_fall_back_to_discovery( relay = _FakeRelay() with caplog.at_level("WARNING"): - host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") + first_host = relay_runtime.RelayRuntime(relay=relay, profile_key="first") + missing_config.parent.mkdir() + missing_config.write_text("", encoding="utf-8") + later_host = relay_runtime.RelayRuntime(relay=relay, profile_key="later") try: - assert not host.managed_execution_enabled() + for host in (first_host, later_host): + assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) assert relay.events == [] assert "continuing without Relay plugins" in caplog.text finally: - host.shutdown() + first_host.shutdown() + later_host.shutdown() def test_malformed_explicit_config_does_not_fall_back_to_discovery( @@ -355,6 +413,10 @@ def test_malformed_explicit_config_does_not_fall_back_to_discovery( host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) assert relay.events == [] assert "continuing without Relay plugins" in caplog.text finally: @@ -380,6 +442,10 @@ def test_present_plugins_section_is_validated_even_when_falsey( host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) assert relay.events == [] assert "'plugins' must be a table" in caplog.text assert "continuing without Relay plugins" in caplog.text @@ -595,6 +661,10 @@ manifest = "relay-plugin.toml" host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) assert [event[0] for event in relay.events] == [ "plugin.load_dynamic_specs", "plugin.initialize_dynamic", @@ -868,6 +938,10 @@ manifest_ref = "relay-plugin.toml" host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") try: assert not host.managed_execution_enabled() + assert ( + host._plugin_configuration_state + is relay_runtime._RelayPluginConfigurationState.FAILED + ) assert relay.events == [] assert "Hermes [[dynamic_plugins]] records are unsupported" in caplog.text assert "use Relay [[plugins.dynamic]] records" in caplog.text diff --git a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py index ef89a9d7a3..38e1b57a7c 100644 --- a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py +++ b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py @@ -3,7 +3,7 @@ Companion to test_plugins_cmd_category_discovery.py. That file covers the *listing* side of nested category plugins (issue #41066). These tests cover the *mutation* side: `hermes plugins enable/disable` must resolve a bare name -OR a full path-derived key (e.g. `observability/nemo_relay`) to the canonical +OR a full path-derived key (e.g. `observability/trace_sink`) to the canonical registry key and write THAT — the same string PluginManager gates on — so a nested bundled plugin can actually be toggled. """ @@ -32,8 +32,8 @@ def _make_category_plugin(parent: Path, category: str, name: str, manifest: dict def nested_plugin_env(tmp_path): """A user-plugins dir containing one nested and one flat plugin, with the bundled dir pointed at an empty path. Returns the tmp_path.""" - _make_category_plugin(tmp_path, "observability", "nemo_relay", { - "name": "nemo_relay", "version": "1.0.0", "description": "relay obs" + _make_category_plugin(tmp_path, "observability", "trace_sink", { + "name": "trace_sink", "version": "1.0.0", "description": "trace sink" }) _make_plugin_dir(tmp_path, "disk-cleanup", { "name": "disk-cleanup", "version": "1.0.0" @@ -53,7 +53,7 @@ class TestResolvePluginKey: from hermes_cli.plugins_cmd import _resolve_plugin_key mock_user.return_value = nested_plugin_env mock_bundled.return_value = nested_plugin_env / "nonexistent" - assert _resolve_plugin_key("observability/nemo_relay") == "observability/nemo_relay" + assert _resolve_plugin_key("observability/trace_sink") == "observability/trace_sink" @patch("hermes_cli.plugins.get_bundled_plugins_dir") @@ -98,13 +98,13 @@ class TestEnableDisableNested: mock_user.return_value = nested_plugin_env mock_bundled.return_value = nested_plugin_env / "nonexistent" - cmd_enable("nemo_relay", allow_tool_override=False) # bare name + cmd_enable("trace_sink", allow_tool_override=False) # bare name saved = mock_save_en.call_args[0][0] # The canonical key — NOT the bare name — must be persisted, because # that is what PluginManager matches when deciding to load. - assert "observability/nemo_relay" in saved - assert "nemo_relay" not in saved or "observability/nemo_relay" in saved + assert "observability/trace_sink" in saved + assert "trace_sink" not in saved or "observability/trace_sink" in saved @patch("hermes_cli.plugins.get_bundled_plugins_dir") @@ -230,4 +230,3 @@ class TestCompositeMenuWritesCanonicalKey: saved_dis = mock_save_dis.call_args[0][0] assert "web/firecrawl" in saved_dis # canonical key persisted assert "web-firecrawl" not in saved_dis # never the bare name - diff --git a/tests/hermes_cli/test_relay_plugin_cutover.py b/tests/hermes_cli/test_relay_plugin_cutover.py index e53cf1700c..9bb6c7aee9 100644 --- a/tests/hermes_cli/test_relay_plugin_cutover.py +++ b/tests/hermes_cli/test_relay_plugin_cutover.py @@ -5,6 +5,7 @@ from __future__ import annotations import os from unittest.mock import patch +import pytest import yaml from hermes_cli.config import migrate_config @@ -62,12 +63,59 @@ def test_doctor_reports_legacy_exporter_env_without_new_config(monkeypatch): findings = dict( collect_relay_plugin_cutover_findings( {}, - {"HERMES_NEMO_RELAY_ATIF_ENABLED": "true"}, + { + "HERMES_NEMO_RELAY_ATIF_ENABLED": "true", + "HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S": "30", + }, ) ) assert "now ignored" in findings["HERMES_NEMO_RELAY_ATIF_ENABLED"] assert RELAY_PLUGINS_CONFIG_ENV in findings["HERMES_NEMO_RELAY_ATIF_ENABLED"] + assert "now ignored" in findings["HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S"] + assert ( + RELAY_PLUGINS_CONFIG_ENV + in findings["HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S"] + ) + + +@pytest.mark.parametrize("name", ["nemo_relay", "observability/nemo_relay"]) +def test_enable_rejects_removed_relay_plugin_without_discovery(name, capsys): + with ( + patch("hermes_cli.plugins_cmd._resolve_plugin_key_and_source") as resolve, + patch("hermes_cli.plugins_cmd._save_enabled_set") as save_enabled, + ): + from hermes_cli.plugins_cmd import cmd_enable + + with pytest.raises(SystemExit) as exc_info: + cmd_enable(name, allow_tool_override=False) + + assert exc_info.value.code == 1 + resolve.assert_not_called() + save_enabled.assert_not_called() + output = capsys.readouterr().out + assert name in output + assert RELAY_PLUGINS_CONFIG_ENV in output + + +def test_enable_rejects_alias_resolving_to_removed_relay_plugin(capsys): + with ( + patch( + "hermes_cli.plugins_cmd._resolve_plugin_key_and_source", + return_value=("observability/nemo_relay", "user"), + ), + patch("hermes_cli.plugins_cmd._save_enabled_set") as save_enabled, + ): + from hermes_cli.plugins_cmd import cmd_enable + + with pytest.raises(SystemExit) as exc_info: + cmd_enable("relay-copy", allow_tool_override=False) + + assert exc_info.value.code == 1 + save_enabled.assert_not_called() + output = capsys.readouterr().out + assert "observability/nemo_relay" in output + assert RELAY_PLUGINS_CONFIG_ENV in output def test_doctor_does_not_warn_for_legacy_env_after_new_config_is_selected( diff --git a/tests/plugins/test_nemo_relay_bounded_marks.py b/tests/plugins/test_nemo_relay_bounded_marks.py deleted file mode 100644 index b9b72a1ff2..0000000000 --- a/tests/plugins/test_nemo_relay_bounded_marks.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Plugin Relay marks must be bounded — never an unbounded native call. - -The nemo_relay plugin's ``_Runtime.run_in_session`` wrapper serves every -mark/event the plugin emits (turn start/end, approvals, subagent marks) and -runs synchronously on the agent's conversation thread. The host's -``run_in_session`` default (``timeout=None``) is an unbounded native call: a -wedged native Relay pipeline blocked the agent between API calls with zero -activity ticks until the cron 600s inactivity kill fired (observed live -2026-08-15 — two cron jobs and a gateway chat session died with -``last_activity=API call #N completed``). - -The wrapper now always passes ``timeout=relay_runtime._SCOPE_OP_TIMEOUT`` to -the host, and a breach flags ``scope_errored`` so ``close_session`` skips the -ATIF export for the wedged session. -""" - -from __future__ import annotations - -import importlib -import sys -from types import SimpleNamespace - -import pytest - -from agent import relay_runtime as core_relay_runtime - - -def _plugin_module(): - mod = sys.modules.get("plugins.observability.nemo_relay") - if mod is None: - mod = importlib.import_module("plugins.observability.nemo_relay") - return mod - - -def _make_runtime_and_state(host): - plugin = _plugin_module() - runtime = object.__new__(plugin._Runtime) - runtime.host = host - state = plugin._SessionState(session_id="s-bound") - state.relay_session = SimpleNamespace(session_id="s-bound") - return plugin, runtime, state - - -def test_plugin_marks_pass_bounded_timeout_to_host(): - """Every wrapper dispatch carries the core scope-op budget.""" - seen = {} - - class _Host: - def run_in_session(self, session, callback, *args, timeout=None, **kwargs): - seen["timeout"] = timeout - return callback(*args, **kwargs) - - _plugin, runtime, state = _make_runtime_and_state(_Host()) - result = runtime.run_in_session(state, lambda: "ok") - - assert result == "ok" - assert seen["timeout"] == core_relay_runtime._SCOPE_OP_TIMEOUT - assert state.scope_errored is False - - -def test_timeout_breach_flags_session_and_skips_atif_export(): - """A wedged native call costs one span and disables the session export.""" - - class _WedgedHost: - def run_in_session(self, session, callback, *args, timeout=None, **kwargs): - raise TimeoutError( - f"Relay scope operation exceeded {timeout}s (session=s-bound)" - ) - - plugin, runtime, state = _make_runtime_and_state(_WedgedHost()) - - with pytest.raises(TimeoutError): - runtime.run_in_session(state, lambda: "never") - - assert state.scope_errored is True - - # export_atif must now skip without touching the exporter. - class _ExplodingExporter: - def export_json(self): # pragma: no cover - must not be called - raise AssertionError("export ran for a scope-errored session") - - state.atif_exporter = _ExplodingExporter() - runtime.settings = plugin._Settings( - atif_enabled=True, atif_output_directory="/tmp/never-used" - ) - runtime.export_atif(state) # no raise, no export - - -def test_non_timeout_errors_still_flag_session(): - """The pre-existing generic error path keeps its scope_errored contract.""" - - class _BrokenHost: - def run_in_session(self, session, callback, *args, timeout=None, **kwargs): - raise RuntimeError("scope handle is not at the top of the stack") - - _plugin, runtime, state = _make_runtime_and_state(_BrokenHost()) - - with pytest.raises(RuntimeError): - runtime.run_in_session(state, lambda: "never") - - assert state.scope_errored is True diff --git a/tests/plugins/test_nemo_relay_mark_turn_parenting.py b/tests/plugins/test_nemo_relay_mark_turn_parenting.py deleted file mode 100644 index ef63d42e12..0000000000 --- a/tests/plugins/test_nemo_relay_mark_turn_parenting.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Marks must parent to the live turn scope, not the session scope. - -Scope events export when their OWNING scope closes. Turn scopes close every -turn; session scopes close only at session end. Parenting marks to the -session scope means a long-lived conversation (the normal enterprise case — -a Slack thread open all day) emits no approval or turn marks for hours, and -none at all if the process dies first. Audit dashboards then show an empty -approval table while approvals are demonstrably firing. - -Contract: when a live turn exists for the mark's session, the mark is -attached to the turn handle so it exports at turn end. When no live turn -exists (session-level events such as session.end, or marks emitted outside -a turn), the mark falls back to the session handle — the historical -behavior, which is correct for those cases. -""" - -from __future__ import annotations - -import sys -import types -from unittest.mock import MagicMock, patch - -import pytest - - -@pytest.fixture() -def runtime_and_state(): - """Build the plugin runtime with its relay + session state stubbed.""" - from plugins.observability import nemo_relay as plugin_mod - - runtime = plugin_mod._Runtime.__new__(plugin_mod._Runtime) - runtime.nemo_relay = MagicMock() - state = types.SimpleNamespace( - session_id="sess-long-lived", - handle="SESSION_HANDLE", - relay_session=object(), - ) - runtime.ensure_session = lambda kwargs: state - runtime.run_in_session = MagicMock() - return plugin_mod, runtime, state - - -def _mark_handle(runtime): - """Return the handle kwarg the mark was dispatched with.""" - assert runtime.run_in_session.called, "mark must dispatch" - return runtime.run_in_session.call_args.kwargs["handle"] - - -class TestMarkTurnParenting: - def test_mark_uses_live_turn_handle(self, runtime_and_state): - plugin_mod, runtime, state = runtime_and_state - live_turn = types.SimpleNamespace(handle="TURN_HANDLE") - - with patch.object( - plugin_mod.relay_runtime, "active_turn", return_value=live_turn - ): - runtime.mark("hermes.approval.response", {"choice": "once"}) - - assert _mark_handle(runtime) == "TURN_HANDLE", ( - "an approval decided mid-conversation must export at turn end, " - "not wait for the session to close" - ) - - def test_mark_falls_back_to_session_handle(self, runtime_and_state): - plugin_mod, runtime, state = runtime_and_state - - with patch.object( - plugin_mod.relay_runtime, "active_turn", return_value=None - ): - runtime.mark("hermes.session.end", {}) - - assert _mark_handle(runtime) == "SESSION_HANDLE", ( - "session-level marks with no live turn keep session parentage" - ) - - def test_mark_falls_back_when_turn_has_no_handle(self, runtime_and_state): - plugin_mod, runtime, state = runtime_and_state - handleless_turn = types.SimpleNamespace(handle=None) - - with patch.object( - plugin_mod.relay_runtime, "active_turn", return_value=handleless_turn - ): - runtime.mark("hermes.turn.start", {}) - - assert _mark_handle(runtime) == "SESSION_HANDLE", ( - "a turn whose scope push failed must not strand the mark" - ) - - def test_active_turn_queried_for_this_session(self, runtime_and_state): - """Turn lookup is session-scoped: never borrow another session's turn.""" - plugin_mod, runtime, state = runtime_and_state - - with patch.object( - plugin_mod.relay_runtime, "active_turn", return_value=None - ) as active_turn: - runtime.mark("hermes.approval.request", {}) - - active_turn.assert_called_once_with("sess-long-lived") diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 9e36f98012..da2bbb7a90 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -778,6 +778,12 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_CRON_MEDIA_SEND_TIMEOUT` | Timeout for each media attachment send during cron delivery via a live gateway adapter, in seconds (default: `300`). Raise it if large attachments (long TTS audio, big exports) time out during upload. Also configurable via `cron.media_send_timeout_seconds` in `config.yaml`. | | `HERMES_CRON_MAX_PARALLEL` | Max cron jobs run in parallel per tick (default: `4`). | +## NeMo Relay + +| Variable | Description | +|----------|-------------| +| `HERMES_NEMO_RELAY_PLUGINS_TOML` | Explicit path to the standard NeMo Relay `plugins.toml` loaded process-wide by Hermes core. When unset, Hermes does not initialize Relay middleware, dynamic plugins, or exporters. The removed `HERMES_NEMO_RELAY_ATOF_*` and `HERMES_NEMO_RELAY_ATIF_*` variables are ignored; configure those outputs in the selected file instead. See [NeMo Relay observability configuration](https://docs.nvidia.com/nemo/relay/configure-plugins/observability/about). | + ## Agent Behavior | Variable | Description | diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index bb8f36e73c..ac18d4c925 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -202,40 +202,32 @@ Hermes-prefixed and standard SDK env vars (`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECR **Disabling:** `hermes plugins disable observability/langfuse`. The plugin module is still discovered, but no module code runs until you re-enable. -### observability/nemo_relay +### NeMo Relay native integration (migration note) -Relays Hermes execution boundaries — sessions, turns, LLM calls, and tool invocations — to an [NVIDIA NeMo Relay](https://docs.nvidia.com/nemo/relay/about-nemo-relay/overview) endpoint. Hermes core owns the Relay session/turn/LLM/tool scopes; the plugin configures exporters (ATOF JSONL, ATIF trajectories, OpenTelemetry) and adds observer marks for approvals and delegated subagents. Full exporter setup lives in the plugin's `README.md` under `plugins/observability/nemo_relay/`. +NeMo Relay is no longer a bundled Hermes plugin. Do not run `hermes plugins enable observability/nemo_relay`; Hermes core now owns the Relay session, turn, LLM, and tool lifecycles. -**Enabling:** +To opt into Relay middleware or exporters, create a standard Relay `plugins.toml`, then set `HERMES_NEMO_RELAY_PLUGINS_TOML` to that file before starting Hermes. The policy is process-wide for every profile hosted by that Hermes process. See the [NeMo Relay observability configuration](https://docs.nvidia.com/nemo/relay/configure-plugins/observability/about) for ATOF, ATIF, and OpenTelemetry options. -```bash -hermes plugins enable observability/nemo_relay -``` +The old `HERMES_NEMO_RELAY_ATOF_*` and `HERMES_NEMO_RELAY_ATIF_*` settings no longer activate exporters. `hermes doctor` reports these stale settings when no replacement `plugins.toml` is selected. #### Session-span segmentation (continuous sessions) -Relay export is close-driven: a span exports when its scope pops. A continuous gateway session (the normal state for a Telegram/Slack agent) keeps its session scope open for days or weeks, so the session root span — and any marks attached to it — stays unexported until `/new` or idle-end, and a crash or redeploy loses the whole open segment. Turn spans are unaffected; they already export per-turn. - -Opt-in segmentation rotates the session scope at turn boundaries, in `config.yaml`: +Relay exports a span when its scope closes. A continuous gateway session can keep its session span open for days even though each turn span exports normally. Optional segmentation rotates only the session scope at a turn boundary: ```yaml gateway: telemetry: session_segments: - on_compaction: false # rotate the session scope when the session compacts - max_turns: 0 # 0 = unlimited; N = rotate after N turns per segment + on_compaction: false # rotate after context compaction + max_turns: 0 # 0 = unlimited; N = turns per segment ``` -| Key | Default | Behaviour | -|---|---|---| -| `on_compaction` | `false` | Close and reopen the session scope after a context compaction completes (at the next turn boundary, never mid-turn) | -| `max_turns` | `0` | Rotate after every N turns within a segment; `0` disables the cap | +| Key | Default | Behavior | +|---|---:|---| +| `on_compaction` | `false` | Rotate after compaction completes, at the next turn boundary. | +| `max_turns` | `0` | Rotate after every N completed turns; `0` disables the cap. | -Both defaults are off — with no config set, the scope lifecycle is identical to previous releases (one session scope for the life of the session). - -Rotated segments keep the same `session_id` attribute and add `hermes.session.segment` (0-based index) plus `hermes.session.segment_reason` (`compaction` or `max_turns`), so dashboards that group on `session_id` are unaffected. Rotation happens exclusively at turn boundaries and rides the same bounded scope-op executor as every other native Relay call — a wedged exporter costs one segment span, never the agent. - -**Disabling:** remove the `session_segments` block (or set both keys back to their defaults). +Both defaults preserve one session scope for the full session. Rotated spans retain the same `session_id` and add `hermes.session.segment` plus `hermes.session.segment_reason` (`compaction` or `max_turns`). ### google_meet