Gateway crashes on malformed scalar config sections
NousResearch/hermes-agent · cluster 40834 · gateway
Data as of
Nothing open on GitHub. Fix merged in #61740. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #61740. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #61740. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes address crashes or errors that occur when the gateway encounters scalar (non-mapping) config blocks or sections. Multiple fixes were proposed to guard against or ignore such malformed configuration inputs.
Open issues0
Open PRs0
Fixes on record
-
#61740 fix(gateway): malformed scalar config sections no longer crash gateway startup (#40834)
files:
gateway/config.py,tests/gateway/test_config.pyShow the diff
-
gateway/config.py@@ -130,6 +130,11 @@ def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]: return parsed +def _coerce_dict(value: Any) -> Dict[str, Any]: + """Return *value* when it is a mapping, otherwise an empty dict.""" + return value if isinstance(value, dict) else {} + + def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: """Normalize unauthorized DM behavior to a supported value.""" if isinstance(value, str): @@ -383,6 +388,7 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + data = _coerce_dict(data) # Handle both missing keys and explicit null values (YAML null → None) mode = data.get("mode") at_hour = data.get("at_hour") @@ -492,24 +498,26 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + data = _coerce_dict(data) home_channel = None - if "home_channel" in data: + if isinstance(data.get("home_channel"), dict): home_channel = HomeChannel.from_dict(data["home_channel"]) # gateway_restart_notification may be bridged into extra via the # shared-key loop in load_gateway_config(); check both top-level # and extra so YAML ``discord: gateway_restart_notification: false`` # works without needing a separate platforms: block. + extra = _coerce_dict(data.get("extra", {})) _grn = data.get("gateway_restart_notification") if _grn is None: - _grn = data.get("extra", {}).get("gateway_restart_notification") + _grn = extra.get("gateway_restart_notification") # typing_indicator mirrors gateway_restart_notification: it may arrive # top-level or bridged into extra by the shared-key loop in # load_gateway_config(), so check both. _typing = data.get("typing_indicator") if _typing is None: - _typing = data.get("extra", {}).get("typing_indicator") + _typing = extra.get("typing_indicator") channel_overrides: Dict[str, ChannelOverride] = {} raw_overrides = data.get("channel_overrides") or {} @@ -527,7 +535,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), channel_overrides=channel_overrides, - extra=data.get("extra", {}), + extra=extra, ) @@ -586,7 +594,7 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig": - if not data: + if not isinstance(data, dict) or not data: return cls() return cls( enabled=_coerce_bool(data.get("enabled"), False), @@ -823,8 +831,12 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": + data = _coerce_dict(data) platforms = {} - for platform_name, platform_data in data.get("platforms", {}).items(): + platforms_data = _coerce_dict(data.get("platforms", {})) + for platform_name, platform_data in platforms_data.items(): + if not isinstance(platform_data, dict): + continue try: platform = Platform(platform_name) platforms[platform] = PlatformConfig.from_dict(platform_data) @@ -832,11 +844,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": pass # Skip unknown platforms reset_by_type = {} - for type_name, policy_data in data.get("reset_by_type", {}).items(): + for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items(): reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) reset_by_platform = {} - for platform_name, policy_data in data.get("reset_by_platform", {}).items(): + for platform_name, policy_data in _coerce_dict(data.get("reset_by_platform", {})).items(): try: platform = Platform(platform_name) reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) @@ -1027,6 +1039,8 @@ def load_gateway_config() -> GatewayConfig: if "stt_echo_transcripts" in yaml_cfg: gw_data["stt_echo_transcripts"] = yaml_cfg["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"] @@ -1054,7 +1068,11 @@ def load_gateway_config() -> GatewayConfig: if not isinstance(streaming_cfg, dict): # Fall back to nested gateway.streaming written by # ``hermes config set gateway.streaming.*`` - streaming_cfg = yaml_cfg.get("gateway", {}).get("streaming") + streaming_cfg = ( + gateway_cfg.get("streaming") + if isinstance(gateway_cfg, dict) + else None + ) if isinstance(streaming_cfg, dict): gw_data["streaming"] = streaming_cfg @@ -1087,7 +1105,6 @@ def load_gateway_config() -> GatewayConfig: # ``gateway.platforms`` are loaded the same way as top-level # ``platforms``. Merge nested first so top-level config keeps # precedence, matching the existing gateway.streaming fallback. - gateway_cfg = yaml_cfg.get("gateway") gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None platforms_data = gw_data.setdefault("platforms", {}) if not isinstance(platforms_data, dict): -
tests/gateway/test_config.py@@ -142,6 +142,21 @@ def test_to_dict_omits_none(self): assert d["system_prompt"] == "Hi" +class TestPlatformConfigMalformedSections: + def test_from_dict_ignores_malformed_nested_sections(self): + restored = PlatformConfig.from_dict( + { + "enabled": True, + "home_channel": "telegram:123", + "extra": "oops", + } + ) + + assert restored.enabled is True + assert restored.home_channel is None + assert restored.extra == {} + + class TestGetConnectedPlatforms: def test_returns_enabled_with_token(self): config = GatewayConfig( @@ -238,6 +253,12 @@ def test_from_dict_coerces_quoted_false_notify(self): restored = SessionResetPolicy.from_dict({"notify": "false"}) assert restored.notify is False + def test_from_dict_malformed_section_falls_back_to_defaults(self): + restored = SessionResetPolicy.from_dict("oops") + assert restored.mode == SessionResetPolicy().mode + assert restored.at_hour == 4 + assert restored.idle_minutes == 1440 + class TestStreamingConfig: def test_defaults_to_auto_transport(self): @@ -263,6 +284,11 @@ def test_from_dict_malformed_numeric_values_fall_back_to_defaults(self): assert restored.buffer_threshold == 24 assert restored.fresh_final_after_seconds == 0.0 + def test_from_dict_malformed_section_falls_back_to_defaults(self): + restored = StreamingConfig.from_dict("enabled") + assert restored.enabled is False + assert restored.transport == "auto" + class TestGatewayConfigRoundtrip: def test_full_roundtrip(self): @@ -365,6 +391,27 @@ def test_from_dict_coerces_quoted_false_always_log_local(self): restored = GatewayConfig.from_dict({"always_log_local": "false"}) assert restored.always_log_local is False + def test_from_dict_ignores_malformed_nested_sections(self): + restored = GatewayConfig.from_dict( + { + "platforms": { + "telegram": "enabled", + "discord": {"enabled": True, "token": "tok"}, + }, + "default_reset_policy": "daily", + "reset_by_type": ["oops"], + "reset_by_platform": "oops", + "streaming": "enabled", + } + ) + + assert Platform.TELEGRAM not in restored.platforms + assert restored.platforms[Platform.DISCORD].enabled is True + assert restored.default_reset_policy.mode == SessionResetPolicy().mode + assert restored.reset_by_type == {} + assert restored.reset_by_platform == {} + assert restored.streaming.transport == "auto" + def test_get_notice_delivery_defaults_to_public(self): config = GatewayConfig( platforms={Platform.SLACK: PlatformConfig(enabled=True, token="***")} @@ -561,6 +608,18 @@ def test_top_level_max_concurrent_sessions_overrides_nested_config_yaml(self, tm assert config.max_concurrent_sessions == 2 + def test_scalar_gateway_section_does_not_crash_streaming_fallback(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text("gateway: disabled\n", encoding="utf-8") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.streaming.transport == "auto" + def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch): """discord.thread_require_mention in config.yaml should reach the runtime env var.""" hermes_home = tmp_path / ".hermes"
-
3 PRs closed without merging
From the close comments: merged via another change 2 · done elsewhere 1
-
#40835 fix(gateway): ignore malformed config sections · merged via another change →
50c66b2,a7f65e3,#61740,#40837 -
#40837 fix(gateway): tolerate scalar gateway config block · merged via another change →
a7f65e3,#61740,#40835 -
#40914 fix: guard streaming-fallback lookup against scalar gateway value · done elsewhere →
a7f65e3,#61740
Closed issues · 2
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.36: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #61740
- Closed issues
- 2
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 10 Jul 2026