{"repo":"NousResearch/hermes-agent","public_id":"25676","display_label":"Gateway config nested keys silently ignored bug fixes","summary":"Multiple bugs where gateway configuration values set under nested YAML keys (gateway.streaming, gateway.api_server, gateway.multiplex_profiles) were silently ignored during initialization. Issues and fix attempts cover the docs/code mismatch and parsing gaps across these nested config sections.","subsystem":"gateway","counts":{"members":13,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":25923},"resolution":{"state":"partially_resolved","by":"pr-70168"},"freshness":{"state":"verified","last_verified_at":"2026-07-23T18:54:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13743589743589743},"fix_edges":[{"issue_number":25676,"pr_number":25923,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25710,"pr_number":25923,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":66630,"pr_number":70168,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":25676,"title":"gateway: streaming config silently ignored when set via \"hermes config set gateway.streaming.*\" — docs/code mismatch","url":null,"state":"closed"},{"kind":"issue","number":52562,"title":"gateway.multiplex_profiles config key not read by load_gateway_config","url":null,"state":"closed"},{"kind":"issue","number":66630,"title":"[Bug]: gateway.api_server config.yaml section is ignored during gateway initialization","url":null,"state":"closed"},{"kind":"pr","number":25694,"title":"fix(gateway): load streaming config from nested gateway.streaming key","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b4b8509fe","number":0},{"kind":"pr","value":"#25923","number":25923}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":25824,"title":"fix(gateway): accept both top-level and nested streaming config (#25676)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b4b8509fe81acf36bc1d32b8f586dc5e09e46e72","number":0},{"kind":"pr","value":"#25676","number":25676}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":25923,"title":"fix(telegram): streaming finalize — drop preview-deletion race, finalize MarkdownV2, accept nested config (salvage of #13542, #25724, #25694)","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/platforms/telegram.py","gateway/run.py","gateway/stream_consumer.py","tests/gateway/test_duplicate_reply_suppression.py","tests/test_gateway_streaming_nested_config.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -735,6 +735,10 @@ def load_gateway_config() -> GatewayConfig:\n                 gw_data[\"thread_sessions_per_user\"] = yaml_cfg[\"thread_sessions_per_user\"]\n \n             streaming_cfg = yaml_cfg.get(\"streaming\")\n+            if not isinstance(streaming_cfg, dict):\n+                # Fall back to nested gateway.streaming written by\n+                # ``hermes config set gateway.streaming.*``\n+                streaming_cfg = yaml_cfg.get(\"gateway\", {}).get(\"streaming\")\n             if isinstance(streaming_cfg, dict):\n                 gw_data[\"streaming\"] = streaming_cfg\n "]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -332,6 +332,13 @@ class TelegramAdapter(BasePlatformAdapter):\n     MEDIA_GROUP_WAIT_SECONDS = 0.8\n     _GENERAL_TOPIC_THREAD_ID = \"1\"\n \n+    # Telegram's edit_message applies MarkdownV2 formatting only on the\n+    # finalize=True path.  Without this flag, stream_consumer._send_or_edit\n+    # short-circuits when the raw text is unchanged between the last streamed\n+    # edit and the final edit, skipping the plain-text → MarkdownV2 conversion.\n+    # Fixes #25710.\n+    REQUIRES_EDIT_FINALIZE: bool = True\n+\n     # Adaptive text-batch ingress: short messages need a tighter delay so the\n     # first token reaches the agent fast.  Numbers tuned for \"feels instant\":\n     # ≤320 codepoints (one short paragraph) settles in ~180ms; ≤1024"]},{"file":"gateway/run.py","hunks":["@@ -16131,6 +16131,7 @@ async def _notify_long_running():\n                     _already_streamed = bool(\n                         (_sc and getattr(_sc, \"final_response_sent\", False))\n                         or _previewed\n+                        or (_sc and getattr(_sc, \"final_content_delivered\", False))\n                     )\n                     first_response = result.get(\"final_response\", \"\")\n                     if first_response and not _already_streamed:","@@ -16292,12 +16293,16 @@ async def _notify_long_running():\n             # response_previewed means the interim_assistant_callback already\n             # sent the final text via the adapter (non-streaming path).\n             _previewed = bool(response.get(\"response_previewed\"))\n-            if not _is_empty_sentinel and (_streamed or _previewed):\n+            _content_delivered = bool(\n+                _sc and getattr(_sc, \"final_content_delivered\", False)\n+            )\n+            if not _is_empty_sentinel and (_streamed or _previewed or _content_delivered):\n                 logger.info(\n-                    \"Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s).\",\n+                    \"Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s content_delivered=%s).\",\n                     session_key or \"?\",\n                     _streamed,\n                     _previewed,\n+                    _content_delivered,\n                 )\n                 response[\"already_sent\"] = True\n "]},{"file":"gateway/stream_consumer.py","hunks":["@@ -150,6 +150,10 @@ def __init__(\n         self._flood_strikes = 0         # Consecutive flood-control edit failures\n         self._current_edit_interval = self.cfg.edit_interval  # Adaptive backoff\n         self._final_response_sent = False\n+        # Set when the final response content was sent to the user via\n+        # streaming, even if the final edit (cursor removal etc.)\n+        # subsequently failed.\n+        self._final_content_delivered = False\n         # Cache adapter lifecycle capability: only platforms that need an\n         # explicit finalize call (e.g. DingTalk AI Cards) force us to make\n         # a redundant final edit.  Everyone else keeps the fast path.","@@ -187,6 +191,12 @@ def final_response_sent(self) -> bool:\n         \"\"\"True when the stream consumer delivered the final assistant reply.\"\"\"\n         return self._final_response_sent\n \n+    @property\n+    def final_content_delivered(self) -> bool:\n+        \"\"\"True when the final response content reached the user, even if\n+        the subsequent cosmetic edit (cursor removal) failed.\"\"\"\n+        return self._final_content_delivered\n+\n     def on_segment_break(self) -> None:\n         \"\"\"Finalize the current stream segment and start a fresh message.\"\"\"\n         self._queue.put(_NEW_SEGMENT)","@@ -455,6 +465,8 @@ async def run(self) -> None:\n                             # tool-progress edits or fallback-mode promotion (#10748)\n                             # — that doesn't mean the final answer reached the user.\n                             self._final_response_sent = chunks_delivered\n+                            if chunks_delivered:\n+                                self._final_content_delivered = True\n                             return\n                         if got_segment_break:\n                             self._message_id = None","@@ -505,6 +517,11 @@ async def run(self) -> None:\n                     self._last_edit_time = time.monotonic()\n \n                 if got_done:\n+                    # Record that the final content reached the user even\n+                    # if the cosmetic final edit below fails.\n+                    if current_update_visible and self._accumulated:\n+                        self._final_content_delivered = True\n+\n                     # Final edit without cursor. If progressive editing failed\n                     # mid-stream, send a single continuation/fallback message\n                     # here instead of letting the base gateway path send the"]},{"file":"tests/gateway/test_duplicate_reply_suppression.py","hunks":["@@ -467,3 +467,59 @@ def test_old_behavior_would_have_promoted_partial(self):\n             final_response_sent = True\n \n         assert final_response_sent is True  # the bug: partial promoted to final\n+\n+\n+class TestFinalContentDeliveredSuppression:\n+    \"\"\"When stream consumer delivered the final content but the cosmetic\n+    final edit (cursor removal) failed, the gateway must suppress the\n+    fallback send to prevent duplicate messages.\n+\n+    Covers the scenario not handled by final_response_sent alone:\n+    content reached the user via _send_or_edit, but the subsequent edit\n+    that clears a typing cursor or streaming marker failed, leaving\n+    final_response_sent=False even though the user already saw the text.\n+    \"\"\"\n+\n+    def test_content_delivered_but_final_edit_failed_suppresses(self):\n+        \"\"\"final_content_delivered=True + final_response_sent=False\n+        must suppress (content already visible to user).\"\"\"\n+        sc = SimpleNamespace(\n+            already_sent=True,\n+            final_response_sent=False,\n+            final_content_delivered=True,\n+        )\n+        response = {\"final_response\": \"Hello!\", \"response_previewed\": False}\n+\n+        _streamed = bool(getattr(sc, \"final_response_sent\", False))\n+        _previewed = bool(response.get(\"response_previewed\"))\n+        _content_delivered = bool(getattr(sc, \"final_content_delivered\", False))\n+        _is_empty_sentinel = (\n+            not response.get(\"final_response\")\n+            or response.get(\"final_response\") == \"(empty)\"\n+        )\n+        if not _is_empty_sentinel and (_streamed or _previewed or _content_delivered):\n+            response[\"already_sent\"] = True\n+\n+        assert response.get(\"already_sent\") is True\n+\n+    def test_intermediate_text_only_does_not_suppress(self):\n+        \"\"\"already_sent=True from intermediate text + final_content_delivered=False\n+        must NOT suppress (user still needs the real final answer).\"\"\"\n+        sc = SimpleNamespace(\n+            already_sent=True,\n+            final_response_sent=False,\n+            final_content_delivered=False,\n+        )\n+        response = {\"final_response\": \"Real answer\", \"response_previewed\": False}\n+\n+        _streamed = bool(getattr(sc, \"final_response_sent\", False))\n+        _previewed = bool(response.get(\"response_previewed\"))\n+        _content_delivered = bool(getattr(sc, \"final_content_delivered\", False))\n+        _is_empty_sentinel = (\n+            not response.get(\"final_response\")\n+            or response.get(\"final_response\") == \"(empty)\"\n+        )\n+        if not _is_empty_sentinel and (_streamed or _previewed or _content_delivered):\n+            response[\"already_sent\"] = True\n+\n+        assert \"already_sent\" not in response"]},{"file":"tests/test_gateway_streaming_nested_config.py","hunks":["@@ -0,0 +1,46 @@\n+\"\"\"Regression test for #25676 — nested gateway.streaming config must be loaded.\"\"\"\n+from pathlib import Path\n+from unittest.mock import patch, MagicMock\n+import json\n+\n+import pytest\n+import yaml\n+\n+\n+def _load_with_yaml_dict(yaml_dict: dict):\n+    \"\"\"Patch filesystem so load_gateway_config() sees *yaml_dict* as config.yaml.\"\"\"\n+    from gateway.config import load_gateway_config\n+\n+    fake_home = Path(\"/tmp/fake_hermes_home_25676\")\n+\n+    def fake_exists(self):\n+        return str(self).endswith(\"config.yaml\")\n+\n+    with patch(\"gateway.config.get_hermes_home\", return_value=fake_home), \\\n+         patch.object(Path, \"exists\", fake_exists), \\\n+         patch(\"builtins.open\", create=True) as mock_file:\n+        mock_file.return_value.__enter__ = lambda s: s\n+        mock_file.return_value.__exit__ = MagicMock(return_value=False)\n+        with patch(\"yaml.safe_load\", return_value=yaml_dict):\n+            return load_gateway_config()\n+\n+\n+class TestStreamingConfigNested:\n+    def test_top_level_streaming(self):\n+        cfg = _load_with_yaml_dict({\"streaming\": {\"enabled\": True, \"transport\": \"draft\"}})\n+        assert cfg.streaming.enabled is True\n+        assert cfg.streaming.transport == \"draft\"\n+\n+    def test_nested_gateway_streaming(self):\n+        \"\"\"Regression for #25676.\"\"\"\n+        cfg = _load_with_yaml_dict({\"gateway\": {\"streaming\": {\"enabled\": True, \"transport\": \"draft\"}}})\n+        assert cfg.streaming.enabled is True\n+        assert cfg.streaming.transport == \"draft\"\n+\n+    def test_top_level_takes_precedence(self):\n+        cfg = _load_with_yaml_dict({\n+            \"streaming\": {\"enabled\": True, \"transport\": \"edit\"},\n+            \"gateway\": {\"streaming\": {\"enabled\": False, \"transport\": \"draft\"}},\n+        })\n+        assert cfg.streaming.enabled is True\n+        assert cfg.streaming.transport == \"edit\"\n"]}]},{"kind":"pr","number":52596,"title":"fix(gateway): read multiplex_profiles from nested gateway config section","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51372","number":51372}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":52668,"title":"fix(gateway): honor nested gateway.multiplex_profiles from config.yaml","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51372","number":51372},{"kind":"pr","value":"#51372","number":51372},{"kind":"pr","value":"#51372","number":51372}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":52684,"title":"fix(gateway): read multiplex_profiles from nested gateway: section in load_gateway_config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51372","number":51372},{"kind":"pr","value":"#51372","number":51372}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53633,"title":"fix(gateway): read nested multiplex profiles config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51372","number":51372},{"kind":"pr","value":"#52562","number":52562},{"kind":"pr","value":"#52596","number":52596},{"kind":"pr","value":"#52684","number":52684},{"kind":"pr","value":"#51372","number":51372},{"kind":"pr","value":"#51372","number":51372},{"kind":"pr","value":"#50094","number":50094}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":66676,"title":"fix(gateway): read gateway.api_server YAML config section during init (#66630)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#66630","number":66630},{"kind":"pr","value":"#70168","number":70168},{"kind":"pr","value":"#66633","number":66633},{"kind":"pr","value":"#66633","number":66633}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":67332,"title":"fix(gateway): honor gateway.api_server config.yaml section during initialization","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#66630","number":66630},{"kind":"pr","value":"#70168","number":70168},{"kind":"pr","value":"#66633","number":66633}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":70168,"title":"fix(gateway): parse gateway.api_server YAML config section (#66633 salvage, fixes #66630)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","tests/gateway/test_config.py","website/docs/user-guide/features/api-server.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/api-server.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}