{"repo":"NousResearch/hermes-agent","public_id":"973","display_label":"GatewayConfig missing dict-like .get() method bug","summary":"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.","subsystem":"gateway","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1287},"resolution":{"state":"partially_resolved","by":"pr-1287"},"freshness":{"state":"stale","last_verified_at":"2026-03-14T11:13:56+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20444444444444443},"fix_edges":[{"issue_number":973,"pr_number":1287,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":973,"title":"[Bug]: GatewayConfig has no attribute 'get' when sending /start on Telegram","url":null,"state":"closed"},{"kind":"issue","number":1158,"title":"fix(gateway): GatewayConfig.get() AttributeError crashes all Telegram/Discord message handling","url":null,"state":"closed"},{"kind":"pr","number":975,"title":"fix(gateway): avoid /start crash when config is a GatewayConfig","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1287","number":1287}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1016,"title":"fix: GatewayConfig has no attribute 'get' when sending /start on Telegram (#973)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1287","number":1287}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":1070,"title":"Fix/gateway config get attribute error","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1287","number":1287},{"kind":"pr","value":"#973","number":973}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":1199,"title":"fix: use attribute access on GatewayConfig instead of dict .get()","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":1209,"title":"Fix: Add quick_commands field to GatewayConfig","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1287","number":1287}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":1287,"title":"fix(gateway): avoid slash-command crash with GatewayConfig","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/run.py","tests/gateway/test_config.py","tests/test_quick_commands.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -151,6 +151,9 @@ class GatewayConfig:\n     \n     # Reset trigger commands\n     reset_triggers: List[str] = field(default_factory=lambda: [\"/new\", \"/reset\"])\n+\n+    # User-defined quick commands (slash commands that bypass the agent loop)\n+    quick_commands: Dict[str, Any] = field(default_factory=dict)\n     \n     # Storage paths\n     sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / \"sessions\")","@@ -218,6 +221,7 @@ def to_dict(self) -> Dict[str, Any]:\n                 p.value: v.to_dict() for p, v in self.reset_by_platform.items()\n             },\n             \"reset_triggers\": self.reset_triggers,\n+            \"quick_commands\": self.quick_commands,\n             \"sessions_dir\": str(self.sessions_dir),\n             \"always_log_local\": self.always_log_local,\n         }","@@ -252,12 +256,17 @@ def from_dict(cls, data: Dict[str, Any]) -> \"GatewayConfig\":\n         if \"sessions_dir\" in data:\n             sessions_dir = Path(data[\"sessions_dir\"])\n         \n+        quick_commands = data.get(\"quick_commands\", {})\n+        if not isinstance(quick_commands, dict):\n+            quick_commands = {}\n+\n         return cls(\n             platforms=platforms,\n             default_reset_policy=default_policy,\n             reset_by_type=reset_by_type,\n             reset_by_platform=reset_by_platform,\n             reset_triggers=data.get(\"reset_triggers\", [\"/new\", \"/reset\"]),\n+            quick_commands=quick_commands,\n             sessions_dir=sessions_dir,\n             always_log_local=data.get(\"always_log_local\", True),\n         )","@@ -299,6 +308,16 @@ def load_gateway_config() -> GatewayConfig:\n             if sr and isinstance(sr, dict):\n                 config.default_reset_policy = SessionResetPolicy.from_dict(sr)\n \n+            # Bridge quick commands from config.yaml into gateway runtime config.\n+            # config.yaml is the user-facing config source, so when present it\n+            # should override gateway.json for this setting.\n+            qc = yaml_cfg.get(\"quick_commands\")\n+            if qc is not None:\n+                if isinstance(qc, dict):\n+                    config.quick_commands = qc\n+                else:\n+                    logger.warning(\"Ignoring invalid quick_commands in config.yaml (expected mapping, got %s)\", type(qc).__name__)\n+\n             # Bridge discord settings from config.yaml to env vars\n             # (env vars take precedence — only set if not already defined)\n             discord_cfg = yaml_cfg.get(\"discord\", {})"]},{"file":"gateway/run.py","hunks":["@@ -1013,7 +1013,12 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n         \n         # User-defined quick commands (bypass agent loop, no LLM call)\n         if command:\n-            quick_commands = self.config.get(\"quick_commands\", {})\n+            if isinstance(self.config, dict):\n+                quick_commands = self.config.get(\"quick_commands\", {}) or {}\n+            else:\n+                quick_commands = getattr(self.config, \"quick_commands\", {}) or {}\n+            if not isinstance(quick_commands, dict):\n+                quick_commands = {}\n             if command in quick_commands:\n                 qcmd = quick_commands[command]\n                 if qcmd.get(\"type\") == \"exec\":"]},{"file":"tests/gateway/test_config.py","hunks":["@@ -6,6 +6,7 @@\n     Platform,\n     PlatformConfig,\n     SessionResetPolicy,\n+    load_gateway_config,\n )\n \n ","@@ -89,15 +90,49 @@ def test_full_roundtrip(self):\n             platforms={\n                 Platform.TELEGRAM: PlatformConfig(\n                     enabled=True,\n-                    token=\"tok\",\n+                    token=\"[redacted:secret-assignment]\",\n                     home_channel=HomeChannel(Platform.TELEGRAM, \"123\", \"Home\"),\n                 ),\n             },\n             reset_triggers=[\"/new\"],\n+            quick_commands={\"limits\": {\"type\": \"exec\", \"command\": \"echo ok\"}},\n         )\n         d = config.to_dict()\n         restored = GatewayConfig.from_dict(d)\n \n         assert Platform.TELEGRAM in restored.platforms\n-        assert restored.platforms[Platform.TELEGRAM].token == \"tok\"\n+        assert restored.platforms[Platform.TELEGRAM].token == \"tok_123\"\n         assert restored.reset_triggers == [\"/new\"]\n+        assert restored.quick_commands == {\"limits\": {\"type\": \"exec\", \"command\": \"echo ok\"}}\n+\n+\n+class TestLoadGatewayConfig:\n+    def test_bridges_quick_commands_from_config_yaml(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        config_path = hermes_home / \"config.yaml\"\n+        config_path.write_text(\n+            \"quick_commands:\\n\"\n+            \"  limits:\\n\"\n+            \"    type: exec\\n\"\n+            \"    command: echo ok\\n\",\n+            encoding=\"utf-8\",\n+        )\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        assert config.quick_commands == {\"limits\": {\"type\": \"exec\", \"command\": \"echo ok\"}}\n+\n+    def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        config_path = hermes_home / \"config.yaml\"\n+        config_path.write_text(\"quick_commands: not-a-mapping\\n\", encoding=\"utf-8\")\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        assert config.quick_commands == {}"]},{"file":"tests/test_quick_commands.py","hunks":["@@ -146,3 +146,20 @@ async def test_timeout_returns_error(self):\n             result = await runner._handle_message(event)\n         assert result is not None\n         assert \"timed out\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_gateway_config_object_supports_quick_commands(self):\n+        from gateway.config import GatewayConfig\n+        from gateway.run import GatewayRunner\n+\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = GatewayConfig(\n+            quick_commands={\"limits\": {\"type\": \"exec\", \"command\": \"echo ok\"}}\n+        )\n+        runner._running_agents = {}\n+        runner._pending_messages = {}\n+        runner._is_user_authorized = MagicMock(return_value=True)\n+\n+        event = self._make_event(\"limits\")\n+        result = await runner._handle_message(event)\n+        assert result == \"ok\"\n"]}]},{"kind":"pr","number":1364,"title":"fix: implement .get() method in GatewayConfig for dict-like access (#973)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#973","number":973}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}