# NousResearch/hermes-agent · Gateway crashes on malformed scalar config sections

## Open now

- 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.

## TL;DR

- 6 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #61740. Verify it addresses your case.
- **Recommended:** read PR #61740 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-61740`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.36`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #40834: Gateway config deserializers crash on malformed scalar sections
- #40836: Scalar gateway config block crashes streaming fallback

## Prior & competing PRs

- **#61740: fix(gateway): malformed scalar config sections no longer crash gateway startup (#40834)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/config.py`, `tests/gateway/test_config.py`
  - fix hunks:
  - `gateway/config.py`

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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 {}
    ```

    ```diff
    @@ -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,
             )
     
     
    ```

    ```diff
    @@ -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),
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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"]
     
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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="***")}
    ```

    ```diff
    @@ -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"
    ```
- **#40835: fix(gateway): ignore malformed config sections**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `50c66b2f8`, `a7f65e3bc`, `#61740`, `#40837`
- **#40837: fix(gateway): tolerate scalar gateway config block**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `a7f65e3bc`, `#61740`, `#40835`
- **#40914: fix: guard streaming-fallback lookup against scalar gateway value**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a7f65e3bcd937cd095ba599ab5927af2093a0d95`, `#61740`

## Fix status

- Merged to main in PR #61740 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-10`.
- Subsystem: `gateway`
