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

0open issues
0open PRs
6items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #61740 fix(gateway): malformed scalar config sections no longer crash gateway startup (#40834)

    merged to main

    files: gateway/config.py, tests/gateway/test_config.py

    Show 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
  • Closed#40834Gateway config deserializers crash on malformed scalar sections
  • 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.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.00 means 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 Lab

At a glance

Subsystem
gateway
Merged PRs
#61740
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
10 Jul 2026

For your agent

The same facts, typed.