mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
fix(gateway): honor nested gateway.* form for 9 more top-level keys
load_gateway_config() already accepted both the top-level key and the nested gateway.<key> form (written by `hermes config set gateway.<key> ...`) for multiplex_profiles, max_concurrent_sessions, streaming, and write_sessions_json — each fixed one at a time as users hit it (most recently #59320 for multiplex_profiles). Nine sibling top-level keys never got the same nested fallback: session_reset, quick_commands, stt, stt_echo_transcripts, group_sessions_per_user, thread_sessions_per_user, reset_triggers, always_log_local, and unauthorized_dm_behavior. `hermes config set gateway.<any-of-these> ...` builds exactly this nested shape (hermes_cli/config.py's _set_nested has no schema, so it accepts any dotted path), so a user following the same pattern that legitimately works for gateway.multiplex_profiles/gateway.streaming gets a silent no-op for these nine keys instead. Fix: read `gateway: {...}` into a single `gateway_section` variable once (consolidating three separate `yaml_cfg.get("gateway")` calls already in the function) and add the same top-level-wins/nested-fallback check for each of the nine keys, mirroring the existing write_sessions_json precedent exactly. Note: because every fallback here is guarded by `isinstance(gateway_section, dict)`, this also makes the streaming fallback tolerate a scalar `gateway:` block (e.g. `gateway: disabled`) without crashing — the same crash #40837 (open) targets specifically for streaming. This change doesn't set out to fix that PR's issue, but the consolidated guard covers it as a side effect; flagging it for the reviewer rather than leaving it to be found in review.
This commit is contained in:
parent
c8027d5e60
commit
e9bd3b6eeb
2 changed files with 204 additions and 14 deletions
|
|
@ -1178,13 +1178,25 @@ def load_gateway_config() -> GatewayConfig:
|
|||
from hermes_cli import managed_scope
|
||||
yaml_cfg = managed_scope.apply_managed_overlay(yaml_cfg)
|
||||
|
||||
# Shared nested-fallback source: settings meant to be top-level
|
||||
# keys are also accepted when a user nests them under `gateway:`
|
||||
# (e.g. via `hermes config set gateway.<key> ...`, which naturally
|
||||
# produces that shape). Every key below mirrors the precedent
|
||||
# already established for gateway.multiplex_profiles/streaming/
|
||||
# write_sessions_json: top-level wins, nested gateway.* falls back.
|
||||
gateway_section = yaml_cfg.get("gateway")
|
||||
|
||||
# Map config.yaml keys → GatewayConfig.from_dict() schema.
|
||||
# Each key overwrites whatever gateway.json may have set.
|
||||
sr = yaml_cfg.get("session_reset")
|
||||
if not (sr and isinstance(sr, dict)) and isinstance(gateway_section, dict):
|
||||
sr = gateway_section.get("session_reset")
|
||||
if sr and isinstance(sr, dict):
|
||||
gw_data["default_reset_policy"] = sr
|
||||
|
||||
qc = yaml_cfg.get("quick_commands")
|
||||
if qc is None and isinstance(gateway_section, dict):
|
||||
qc = gateway_section.get("quick_commands")
|
||||
if qc is not None:
|
||||
if isinstance(qc, dict):
|
||||
gw_data["quick_commands"] = qc
|
||||
|
|
@ -1196,18 +1208,26 @@ def load_gateway_config() -> GatewayConfig:
|
|||
)
|
||||
|
||||
stt_cfg = yaml_cfg.get("stt")
|
||||
if not isinstance(stt_cfg, dict) and isinstance(gateway_section, dict):
|
||||
stt_cfg = gateway_section.get("stt")
|
||||
if isinstance(stt_cfg, dict):
|
||||
gw_data["stt"] = stt_cfg
|
||||
if "stt_echo_transcripts" in yaml_cfg:
|
||||
gw_data["stt_echo_transcripts"] = yaml_cfg["stt_echo_transcripts"]
|
||||
elif isinstance(gateway_section, dict) and "stt_echo_transcripts" in gateway_section:
|
||||
gw_data["stt_echo_transcripts"] = gateway_section["stt_echo_transcripts"]
|
||||
|
||||
gateway_cfg = yaml_cfg.get("gateway")
|
||||
|
||||
if "group_sessions_per_user" in yaml_cfg:
|
||||
gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"]
|
||||
elif isinstance(gateway_section, dict) and "group_sessions_per_user" in gateway_section:
|
||||
gw_data["group_sessions_per_user"] = gateway_section["group_sessions_per_user"]
|
||||
|
||||
if "thread_sessions_per_user" in yaml_cfg:
|
||||
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]
|
||||
elif isinstance(gateway_section, dict) and "thread_sessions_per_user" in gateway_section:
|
||||
gw_data["thread_sessions_per_user"] = gateway_section["thread_sessions_per_user"]
|
||||
|
||||
# Multiplexing flag: accept both the top-level key and the nested
|
||||
# gateway.multiplex_profiles form (written by
|
||||
|
|
@ -1219,14 +1239,11 @@ def load_gateway_config() -> GatewayConfig:
|
|||
# ``profile_routes`` or the nested ``gateway.profile_routes`` form
|
||||
# (matching the multiplex_profiles parity above).
|
||||
_pr = yaml_cfg.get("profile_routes")
|
||||
if _pr is None:
|
||||
_gw_section = yaml_cfg.get("gateway")
|
||||
if isinstance(_gw_section, dict):
|
||||
_pr = _gw_section.get("profile_routes")
|
||||
if _pr is None and isinstance(gateway_section, dict):
|
||||
_pr = gateway_section.get("profile_routes")
|
||||
if isinstance(_pr, list):
|
||||
gw_data["profile_routes"] = _pr
|
||||
|
||||
gateway_section = yaml_cfg.get("gateway")
|
||||
if isinstance(gateway_section, dict):
|
||||
if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data:
|
||||
# gateway.multiplex_profiles written by `hermes config set gateway.multiplex_profiles true`
|
||||
|
|
@ -1242,41 +1259,49 @@ def load_gateway_config() -> GatewayConfig:
|
|||
gw_data["max_concurrent_sessions"] = yaml_cfg["max_concurrent_sessions"]
|
||||
|
||||
streaming_cfg = yaml_cfg.get("streaming")
|
||||
if not isinstance(streaming_cfg, dict):
|
||||
if not isinstance(streaming_cfg, dict) and isinstance(gateway_section, dict):
|
||||
# Fall back to nested gateway.streaming written by
|
||||
# ``hermes config set gateway.streaming.*``
|
||||
streaming_cfg = (
|
||||
gateway_cfg.get("streaming")
|
||||
if isinstance(gateway_cfg, dict)
|
||||
else None
|
||||
)
|
||||
streaming_cfg = gateway_section.get("streaming")
|
||||
if isinstance(streaming_cfg, dict):
|
||||
gw_data["streaming"] = streaming_cfg
|
||||
|
||||
if "reset_triggers" in yaml_cfg:
|
||||
gw_data["reset_triggers"] = yaml_cfg["reset_triggers"]
|
||||
elif isinstance(gateway_section, dict) and "reset_triggers" in gateway_section:
|
||||
gw_data["reset_triggers"] = gateway_section["reset_triggers"]
|
||||
|
||||
if "always_log_local" in yaml_cfg:
|
||||
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
|
||||
elif isinstance(gateway_section, dict) and "always_log_local" in gateway_section:
|
||||
gw_data["always_log_local"] = gateway_section["always_log_local"]
|
||||
|
||||
# write_sessions_json: top-level wins; nested gateway.* fallback
|
||||
# (matches the gateway.streaming precedence pattern).
|
||||
_gw_section = yaml_cfg.get("gateway")
|
||||
if "write_sessions_json" in yaml_cfg:
|
||||
gw_data["write_sessions_json"] = yaml_cfg["write_sessions_json"]
|
||||
elif isinstance(_gw_section, dict) and "write_sessions_json" in _gw_section:
|
||||
gw_data["write_sessions_json"] = _gw_section["write_sessions_json"]
|
||||
elif isinstance(gateway_section, dict) and "write_sessions_json" in gateway_section:
|
||||
gw_data["write_sessions_json"] = gateway_section["write_sessions_json"]
|
||||
|
||||
if "filter_silence_narration" in yaml_cfg:
|
||||
gw_data["filter_silence_narration"] = yaml_cfg[
|
||||
"filter_silence_narration"
|
||||
]
|
||||
elif isinstance(gateway_section, dict) and "filter_silence_narration" in gateway_section:
|
||||
gw_data["filter_silence_narration"] = gateway_section[
|
||||
"filter_silence_narration"
|
||||
]
|
||||
|
||||
if "unauthorized_dm_behavior" in yaml_cfg:
|
||||
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
|
||||
yaml_cfg.get("unauthorized_dm_behavior"),
|
||||
"pair",
|
||||
)
|
||||
elif isinstance(gateway_section, dict) and "unauthorized_dm_behavior" in gateway_section:
|
||||
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
|
||||
gateway_section.get("unauthorized_dm_behavior"),
|
||||
"pair",
|
||||
)
|
||||
|
||||
# Merge platform config into gw_data so runtime-only settings under
|
||||
# ``gateway.platforms`` are loaded the same way as top-level
|
||||
|
|
|
|||
|
|
@ -662,6 +662,171 @@ class TestLoadGatewayConfig:
|
|||
assert extra["websocket_heartbeat_ack_max_age_seconds"] == 75
|
||||
assert extra["websocket_max_latency_seconds"] == 30
|
||||
|
||||
def test_session_reset_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
"""``gateway.session_reset`` (nested form) must reach default_reset_policy,
|
||||
mirroring the gateway.multiplex_profiles precedent."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n session_reset:\n mode: idle\n idle_minutes: 30\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.default_reset_policy.mode == "idle"
|
||||
assert config.default_reset_policy.idle_minutes == 30
|
||||
|
||||
def test_quick_commands_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n quick_commands:\n limits:\n type: exec\n command: echo ok\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
|
||||
|
||||
def test_stt_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
"""Asserts False (not the True default) so the test fails if the
|
||||
nested gateway.stt value never reaches from_dict() and silently
|
||||
falls back to the class default instead."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n stt:\n enabled: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.stt_enabled is False
|
||||
|
||||
def test_stt_echo_transcripts_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n stt_echo_transcripts: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.stt_echo_transcripts is False
|
||||
|
||||
def test_group_sessions_per_user_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n group_sessions_per_user: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.group_sessions_per_user is False
|
||||
|
||||
def test_thread_sessions_per_user_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n thread_sessions_per_user: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.thread_sessions_per_user is True
|
||||
|
||||
def test_reset_triggers_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n reset_triggers:\n - /new\n - /clear\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.reset_triggers == ["/new", "/clear"]
|
||||
|
||||
def test_always_log_local_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n always_log_local: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.always_log_local is False
|
||||
|
||||
def test_filter_silence_narration_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n filter_silence_narration: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.filter_silence_narration is False
|
||||
|
||||
def test_unauthorized_dm_behavior_from_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n unauthorized_dm_behavior: ignore\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.unauthorized_dm_behavior == "ignore"
|
||||
|
||||
def test_top_level_still_wins_over_nested_gateway_section(self, tmp_path, monkeypatch):
|
||||
"""Top-level keys keep precedence over the nested gateway.* fallback
|
||||
for every key this fix touches (matches the existing
|
||||
gateway.streaming/write_sessions_json precedence contract)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"always_log_local: true\n"
|
||||
"gateway:\n"
|
||||
" always_log_local: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.always_log_local is True
|
||||
|
||||
def test_relay_platform_enabled_from_env_url(self, tmp_path, monkeypatch):
|
||||
"""GATEWAY_RELAY_URL must enable Platform.RELAY in config.platforms so
|
||||
start_gateway()'s connect loop actually dials the connector. Registering
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue