GatewayConfig missing dict-like .get() method bug

NousResearch/hermes-agent · cluster 973 · gateway

Data as of

Nothing open on GitHub. Fix merged in #1287. Check it covers your case.

The short answer

0open issues
0open PRs
9items grouped
Known?
Yes. 9 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #1287. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #1287. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple reports and fix attempts addressing an AttributeError caused by calling .get() on a GatewayConfig dataclass as if it were a dict, which crashed Telegram and Discord message handling. Proposed solutions include adding a .get() method, using getattr, or switching to attribute access.

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.

  • #1287 fix(gateway): avoid slash-command crash with GatewayConfig

    merged to main

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

    Show the diff
    • gateway/config.py
      @@ -151,6 +151,9 @@ class GatewayConfig:
           
           # Reset trigger commands
           reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
      +
      +    # User-defined quick commands (slash commands that bypass the agent loop)
      +    quick_commands: Dict[str, Any] = field(default_factory=dict)
           
           # Storage paths
           sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
      @@ -218,6 +221,7 @@ def to_dict(self) -> Dict[str, Any]:
                       p.value: v.to_dict() for p, v in self.reset_by_platform.items()
                   },
                   "reset_triggers": self.reset_triggers,
      +            "quick_commands": self.quick_commands,
                   "sessions_dir": str(self.sessions_dir),
                   "always_log_local": self.always_log_local,
               }
      @@ -252,12 +256,17 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
               if "sessions_dir" in data:
                   sessions_dir = Path(data["sessions_dir"])
               
      +        quick_commands = data.get("quick_commands", {})
      +        if not isinstance(quick_commands, dict):
      +            quick_commands = {}
      +
               return cls(
                   platforms=platforms,
                   default_reset_policy=default_policy,
                   reset_by_type=reset_by_type,
                   reset_by_platform=reset_by_platform,
                   reset_triggers=data.get("reset_triggers", ["/new", "/reset"]),
      +            quick_commands=quick_commands,
                   sessions_dir=sessions_dir,
                   always_log_local=data.get("always_log_local", True),
               )
      @@ -299,6 +308,16 @@ def load_gateway_config() -> GatewayConfig:
                   if sr and isinstance(sr, dict):
                       config.default_reset_policy = SessionResetPolicy.from_dict(sr)
       
      +            # Bridge quick commands from config.yaml into gateway runtime config.
      +            # config.yaml is the user-facing config source, so when present it
      +            # should override gateway.json for this setting.
      +            qc = yaml_cfg.get("quick_commands")
      +            if qc is not None:
      +                if isinstance(qc, dict):
      +                    config.quick_commands = qc
      +                else:
      +                    logger.warning("Ignoring invalid quick_commands in config.yaml (expected mapping, got %s)", type(qc).__name__)
      +
                   # Bridge discord settings from config.yaml to env vars
                   # (env vars take precedence — only set if not already defined)
                   discord_cfg = yaml_cfg.get("discord", {})
    • gateway/run.py
      @@ -1013,7 +1013,12 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
               
               # User-defined quick commands (bypass agent loop, no LLM call)
               if command:
      -            quick_commands = self.config.get("quick_commands", {})
      +            if isinstance(self.config, dict):
      +                quick_commands = self.config.get("quick_commands", {}) or {}
      +            else:
      +                quick_commands = getattr(self.config, "quick_commands", {}) or {}
      +            if not isinstance(quick_commands, dict):
      +                quick_commands = {}
                   if command in quick_commands:
                       qcmd = quick_commands[command]
                       if qcmd.get("type") == "exec":
    • tests/gateway/test_config.py
      @@ -6,6 +6,7 @@
           Platform,
           PlatformConfig,
           SessionResetPolicy,
      +    load_gateway_config,
       )
       
       
      @@ -89,15 +90,49 @@ def test_full_roundtrip(self):
                   platforms={
                       Platform.TELEGRAM: PlatformConfig(
                           enabled=True,
      -                    token="tok",
      +                    token="[redacted:secret-assignment]",
                           home_channel=HomeChannel(Platform.TELEGRAM, "123", "Home"),
                       ),
                   },
                   reset_triggers=["/new"],
      +            quick_commands={"limits": {"type": "exec", "command": "echo ok"}},
               )
               d = config.to_dict()
               restored = GatewayConfig.from_dict(d)
       
               assert Platform.TELEGRAM in restored.platforms
      -        assert restored.platforms[Platform.TELEGRAM].token == "tok"
      +        assert restored.platforms[Platform.TELEGRAM].token == "tok_123"
               assert restored.reset_triggers == ["/new"]
      +        assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
      +
      +
      +class TestLoadGatewayConfig:
      +    def test_bridges_quick_commands_from_config_yaml(self, tmp_path, monkeypatch):
      +        hermes_home = tmp_path / ".hermes"
      +        hermes_home.mkdir()
      +        config_path = hermes_home / "config.yaml"
      +        config_path.write_text(
      +            "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_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):
      +        hermes_home = tmp_path / ".hermes"
      +        hermes_home.mkdir()
      +        config_path = hermes_home / "config.yaml"
      +        config_path.write_text("quick_commands: not-a-mapping\n", encoding="utf-8")
      +
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +
      +        config = load_gateway_config()
      +
      +        assert config.quick_commands == {}
    • tests/test_quick_commands.py
      @@ -146,3 +146,20 @@ async def test_timeout_returns_error(self):
                   result = await runner._handle_message(event)
               assert result is not None
               assert "timed out" in result.lower()
      +
      +    @pytest.mark.asyncio
      +    async def test_gateway_config_object_supports_quick_commands(self):
      +        from gateway.config import GatewayConfig
      +        from gateway.run import GatewayRunner
      +
      +        runner = GatewayRunner.__new__(GatewayRunner)
      +        runner.config = GatewayConfig(
      +            quick_commands={"limits": {"type": "exec", "command": "echo ok"}}
      +        )
      +        runner._running_agents = {}
      +        runner._pending_messages = {}
      +        runner._is_user_authorized = MagicMock(return_value=True)
      +
      +        event = self._make_event("limits")
      +        result = await runner._handle_message(event)
      +        assert result == "ok"

6 PRs closed without merging

From the close comments: done elsewhere 4 · closed without comment 1 · merged via another change 1

  • #975 fix(gateway): avoid /start crash when config is a GatewayConfig · merged via another change → #1287
  • #1016 fix: GatewayConfig has no attribute 'get' when sending /start on Telegram (#973) · done elsewhere → #1287
  • #1070 Fix/gateway config get attribute error · done elsewhere → #1287, #973
  • #1199 fix: use attribute access on GatewayConfig instead of dict .get() · closed without comment
  • #1209 Fix: Add quick_commands field to GatewayConfig · done elsewhere → #1287
Show 1 more
  • #1364 fix: implement .get() method in GatewayConfig for dict-like access (#973) · done elsewhere → #973
Closed issues · 2
  • Closed#973[Bug]: GatewayConfig has no attribute 'get' when sending /start on Telegram
  • Closed#1158fix(gateway): GatewayConfig.get() AttributeError crashes all Telegram/Discord message handling
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.20: 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
#1287
Closed issues
2
Attempts
6 PRs closed without merging
Fix link checked
14 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.