Handling scalar gateway config values without crashing
Cluster 40836 · gateway. Data as of
Nothing open on GitHub. Fix merged in #61740 on 10 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue 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
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members all concern a gateway configuration section being a scalar value rather than a structured block, with a reported crash in streaming-fallback lookup and startup. Several titles describe fixes that guard against or tolerate this malformed config, but the outcome of those changes is not established here.
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"
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1
Closed issues · 1
- Closed#40836Scalar gateway config block crashes streaming fallback
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.50: 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
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 10 Jul 2026