3fad83df31 (Aug 11) moved Relay exporter config to a plugins.toml
selected by HERMES_NEMO_RELAY_PLUGINS_TOML. A .env still carrying the legacy
exporter vars and no TOML logs ONE warning and initialises no exporters, so
users who followed the earlier docs lost every trace silently (the
maintainer's stopped Aug 20, noticed Sep 14; five multiplexed profiles on
the same box carry the same eight vars today).
- `hermes_cli/relay_plugin_migrate.py`: build the document from the
`nemo_relay.observability` dataclasses (`ComponentSpec(...).to_dict()`,
so the `type = "file"` sink discriminator is emitted), validate it by
activating it through `nemo_relay.plugin.initialize` + `clear_async`,
write `<home>/relay-plugins.toml` (tomli_w when installed, minimal emitter
otherwise), set HERMES_NEMO_RELAY_PLUGINS_TOML in that .env, and comment
the legacy lines out (never delete). Defaults mirror the removed plugin so
files land where they used to.
- `hermes update` runs it for the default home AND every live named profile
(each writes its own TOML) as a best-effort post-update step, with a loud
notice; `hermes migrate relay [--all-profiles] [--no-validate]` runs it on
demand.
- The runtime WARNING and the `hermes doctor` finding now say "NO traces
are being exported" and name the exact command and file path.
- Docs: environment-variables.md + built-in-plugins.md carry the migration
note and a complete plugins.toml example including `type = "file"`.
91 lines
4.1 KiB
Python
91 lines
4.1 KiB
Python
"""``hermes update`` / ``hermes migrate relay``: legacy ``HERMES_NEMO_RELAY_ATIF_*``/``ATOF_*`` vars
|
|
become a validated ``relay-plugins.toml`` per profile home, selected from ``.env``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.relay_plugin_migrate import (
|
|
RELAY_PLUGINS_TOML_NAME, migrate_all_profile_relay_envs, migrate_profile_relay_env)
|
|
from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV, configured_legacy_relay_env_vars
|
|
|
|
nemo_relay = pytest.importorskip("nemo_relay")
|
|
|
|
LEGACY_ENV = """OPENAI_API_KEY=sk-test
|
|
HERMES_NEMO_RELAY_ATOF_ENABLED=1
|
|
HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY={home}/telemetry/atof
|
|
HERMES_NEMO_RELAY_ATOF_FILENAME=hermes-atof.jsonl
|
|
HERMES_NEMO_RELAY_ATOF_MODE=append
|
|
HERMES_NEMO_RELAY_ATIF_ENABLED=1
|
|
HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY={home}/telemetry/atif
|
|
HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE=trajectory-{{session_id}}.json
|
|
HERMES_NEMO_RELAY_ATIF_SUBAGENT_EXPORT_MODE=all
|
|
"""
|
|
|
|
|
|
def _parse_env(path: Path) -> dict[str, str]:
|
|
out = {}
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
if line.strip() and not line.startswith("#") and "=" in line:
|
|
k, _, v = line.partition("=")
|
|
out[k.strip()] = v.strip().strip('"')
|
|
return out
|
|
|
|
|
|
@pytest.fixture
|
|
def profile_env(tmp_path, monkeypatch):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
return home
|
|
|
|
|
|
def test_legacy_env_becomes_validated_toml_selected_from_env(profile_env):
|
|
(profile_env / ".env").write_text(LEGACY_ENV.format(home=profile_env), encoding="utf-8")
|
|
|
|
result = migrate_profile_relay_env(profile_env)
|
|
|
|
assert result.migrated, (result.skipped_reason, result.validation_error)
|
|
assert result.diagnostics == []
|
|
toml_path = profile_env / RELAY_PLUGINS_TOML_NAME
|
|
document = tomllib.loads(toml_path.read_text(encoding="utf-8"))
|
|
sink = document["components"][0]["config"]["atof"]["sinks"][0]
|
|
assert sink["type"] == "file" and sink["filename"] == "hermes-atof.jsonl"
|
|
assert document["components"][0]["config"]["atif"]["filename_template"] == "trajectory-{session_id}.json"
|
|
# Relay itself accepts the file Hermes will load at runtime.
|
|
report = asyncio.run(nemo_relay.plugin.initialize(document))
|
|
asyncio.run(nemo_relay.plugin.clear_async())
|
|
assert report.get("diagnostics") == []
|
|
# .env now selects the file; the legacy lines survive as comments (not deleted), so the
|
|
# runtime warning + doctor finding go quiet and a second run is a no-op.
|
|
env = _parse_env(profile_env / ".env")
|
|
assert env[RELAY_PLUGINS_CONFIG_ENV] == str(toml_path)
|
|
assert env["OPENAI_API_KEY"] == "sk-test"
|
|
assert configured_legacy_relay_env_vars(env) == ()
|
|
assert "# migrated to relay-plugins.toml: HERMES_NEMO_RELAY_ATOF_ENABLED=1" in (profile_env / ".env").read_text(encoding="utf-8")
|
|
assert migrate_profile_relay_env(profile_env).skipped_reason == "no legacy exporter variables"
|
|
|
|
|
|
def test_update_migrates_every_profile_home_separately(profile_env):
|
|
"""Multiplex: each profile keeps its own TOML; a profile without legacy vars is untouched."""
|
|
(profile_env / ".env").write_text("OPENAI_API_KEY=x\n", encoding="utf-8")
|
|
work = profile_env / "profiles" / "work"
|
|
idle = profile_env / "profiles" / "idle"
|
|
for p in (work, idle):
|
|
p.mkdir(parents=True)
|
|
(work / ".env").write_text(LEGACY_ENV.format(home=work), encoding="utf-8")
|
|
(idle / ".env").write_text("SLACK_BOT_TOKEN=y\n", encoding="utf-8")
|
|
|
|
results = {r.home: r for r in migrate_all_profile_relay_envs(validate=False)}
|
|
|
|
assert results[work].migrated and results[work].toml_path == work / RELAY_PLUGINS_TOML_NAME
|
|
assert not results[idle].migrated and not (idle / RELAY_PLUGINS_TOML_NAME).exists()
|
|
assert not results[profile_env].migrated and not (profile_env / RELAY_PLUGINS_TOML_NAME).exists()
|
|
assert _parse_env(work / ".env")[RELAY_PLUGINS_CONFIG_ENV] == str(work / RELAY_PLUGINS_TOML_NAME)
|
|
assert (idle / ".env").read_text(encoding="utf-8") == "SLACK_BOT_TOKEN=y\n"
|