{"repo":"NousResearch/hermes-agent","public_id":"57122","display_label":"Telegram rich Markdown hint gating and config","summary":"These issues and PRs concern whether Telegram's rich Markdown formatting instructions and prompt hints are shown or applied when the rich_messages config option is disabled. Multiple approaches to gating or defaulting this behavior are represented.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64423},"resolution":{"state":"resolved","by":"pr-64423"},"freshness":{"state":"verified","last_verified_at":"2026-07-14T13:48:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":53824,"pr_number":64423,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":57122,"pr_number":64423,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":57122,"title":"Telegram platform hint pushes rich markdown constructs even when rich_messages is disabled (default)","url":null,"state":"closed"},{"kind":"pr","number":57132,"title":"fix(agent): gate Telegram rich-Markdown hint on rich_messages config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64423","number":64423},{"kind":"pr","value":"#53825","number":53825}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":57148,"title":"fix(telegram): gate rich markdown prompt hint","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64423","number":64423},{"kind":"pr","value":"#57132","number":57132}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":57210,"title":"fix(agent): remove rich Markdown instruction from Telegram platform hint","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64423","number":64423},{"kind":"pr","value":"#57122","number":57122},{"kind":"pr","value":"#57132","number":57132}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":64423,"title":"fix(telegram): respect rich_messages opt-in for pipe tables and prompt hint (#53825, #57132 salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/prompt_builder.py","agent/system_prompt.py","plugins/platforms/telegram/adapter.py","tests/agent/test_prompt_builder.py","tests/agent/test_system_prompt.py","tests/gateway/test_telegram_rich_messages.py"],"fix_hunks":[{"file":"agent/prompt_builder.py","hunks":["@@ -659,19 +659,7 @@ def format_steer_marker(steer_text: str) -> str:\n         \"Standard Markdown is automatically converted to Telegram formatting. \"\n         \"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, \"\n         \"`inline code`, ```code blocks```, [links](url), and ## headers. \"\n-        \"Telegram now supports rich Markdown, so lean into it: whenever it \"\n-        \"makes the answer clearer or easier to scan, actively reach for real \"\n-        \"Markdown tables (pipe `| col | col |` syntax), bullet and numbered \"\n-        \"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, \"\n-        \"collapsible details, footnotes/references, math/formulas (`$...$`, \"\n-        \"`$$...$$`), underline, subscript/superscript, marked (highlighted) \"\n-        \"text, and anchors. Default to structured formatting over dense \"\n-        \"paragraphs for any comparison, set of steps, key/value summary, or \"\n-        \"tabular data. Prefer real Markdown tables and task lists over \"\n-        \"hand-built bullet substitutes when presenting structured data; these \"\n-        \"degrade gracefully (tables become readable bullet groups) when rich \"\n-        \"rendering is unavailable, but advanced constructs like math and \"\n-        \"collapsible details may render as plain source text in that case. \"\n+        \"Prefer bullet lists and labeled key:value pairs for structured data. \"\n         \"You can send media files natively: to deliver a file to the user, \"\n         \"include MEDIA:/absolute/path/to/file in your response. Images \"\n         \"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice \"","@@ -865,6 +853,27 @@ def format_steer_marker(steer_text: str) -> str:\n     ),\n }\n \n+# Telegram rich-messages extension — only injected when the user has opted in\n+# to ``platforms.telegram.extra.rich_messages: true``.  The base\n+# PLATFORM_HINTS[\"telegram\"] covers MarkdownV2-compatible constructs; this\n+# extension adds the Bot API 10.1 rich-Markdown guidance (tables, task lists,\n+# collapsible details, math, etc.).\n+TELEGRAM_RICH_MESSAGES_HINT = (\n+    \"Telegram now supports rich Markdown, so lean into it: whenever it \"\n+    \"makes the answer clearer or easier to scan, actively reach for real \"\n+    \"Markdown tables (pipe `| col | col |` syntax), bullet and numbered \"\n+    \"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, \"\n+    \"collapsible details, footnotes/references, math/formulas (`$...$`, \"\n+    \"`$$...$$`), underline, subscript/superscript, marked (highlighted) \"\n+    \"text, and anchors. Default to structured formatting over dense \"\n+    \"paragraphs for any comparison, set of steps, key/value summary, or \"\n+    \"tabular data. Prefer real Markdown tables and task lists over \"\n+    \"hand-built bullet substitutes when presenting structured data; these \"\n+    \"degrade gracefully (tables become readable bullet groups) when rich \"\n+    \"rendering is unavailable, but advanced constructs like math and \"\n+    \"collapsible details may render as plain source text in that case. \"\n+)\n+\n # ---------------------------------------------------------------------------\n # Environment hints — execution-environment awareness for the agent.\n # Unlike PLATFORM_HINTS (which describe the messaging channel), these describe"]},{"file":"agent/system_prompt.py","hunks":["@@ -40,6 +40,7 @@\n     SKILLS_GUIDANCE,\n     STEER_CHANNEL_NOTE,\n     TASK_COMPLETION_GUIDANCE,\n+    TELEGRAM_RICH_MESSAGES_HINT,\n     TOOL_USE_ENFORCEMENT_GUIDANCE,\n     TOOL_USE_ENFORCEMENT_MODELS,\n     drain_truncation_warnings,","@@ -429,6 +430,20 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)\n         except Exception:\n             pass\n \n+    # For Telegram: append the rich-messages extension only when the user has\n+    # opted in to ``platforms.telegram.extra.rich_messages: true``.  The base\n+    # hint covers MarkdownV2-compatible constructs; the extension adds Bot API\n+    # 10.1 guidance (tables, task lists, math, collapsible details, etc.).\n+    if platform_key == \"telegram\" and _default_hint:\n+        try:\n+            from hermes_cli.config import load_config_readonly\n+            _cfg = load_config_readonly()\n+            _tg_extra = ((_cfg.get(\"platforms\") or {}).get(\"telegram\") or {}).get(\"extra\") or {}\n+            if _tg_extra.get(\"rich_messages\"):\n+                _default_hint = _default_hint.rstrip() + \" \" + TELEGRAM_RICH_MESSAGES_HINT\n+        except Exception:\n+            pass  # Config read failure — fall back to base hint only\n+\n     _effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)\n     if platform_key == \"tui\" and _effective_hint:\n         _effective_hint = _tui_embedded_pane_clarifier(_effective_hint)"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -1479,33 +1479,9 @@ def _needs_rich_rendering(self, content: str) -> bool:\n             return True\n         return False\n \n-    def _content_is_pipe_table_primary(self, content: str) -> bool:\n-        \"\"\"True when pipe tables are the only rich construct in *content*.\n-\n-        Tables are auto-routed to ``sendRichMessage`` even when the full\n-        ``rich_messages`` opt-in is off — MarkdownV2 has no table syntax and\n-        the legacy path rewrites them into bullet lists, which reads like a\n-        regression when users enable Telegram Topics and expect native tables.\n-        Task lists, ``<details>``, and block math still require the full opt-in.\n-        \"\"\"\n-        if not content or not any(\n-            _TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()\n-        ):\n-            return False\n-        if re.search(r\"(?m)^\\s*[-*]\\s+\\[[ xX]\\]\\s+\", content):\n-            return False\n-        if re.search(r\"(?m)^<details\\b|^</details>|^<summary\\b|^</summary>\", content):\n-            return False\n-        if \"$$\" in content:\n-            return False\n-        return True\n-\n-    def _rich_delivery_enabled(self, content: str) -> bool:\n-        \"\"\"Whether rich delivery is allowed for this payload.\"\"\"\n-        return bool(\n-            getattr(self, \"_rich_messages_enabled\", True)\n-            or self._content_is_pipe_table_primary(content)\n-        )\n+    def _rich_delivery_enabled(self) -> bool:\n+        \"\"\"Whether rich delivery is allowed (``rich_messages`` opt-in).\"\"\"\n+        return bool(getattr(self, \"_rich_messages_enabled\", True))\n \n     def _rich_eligible(self, content: str) -> bool:\n         \"\"\"Capability/content eligibility for rich, ignoring ``expect_edits``.","@@ -1517,7 +1493,7 @@ def _rich_eligible(self, content: str) -> bool:\n         FINAL edit should still upgrade to rich when the content warrants it.\n         \"\"\"\n         return bool(\n-            self._rich_delivery_enabled(content)\n+            self._rich_delivery_enabled()\n             and not getattr(self, \"_rich_send_disabled\", False)\n             and content\n             and content.strip()"]},{"file":"tests/agent/test_prompt_builder.py","hunks":["@@ -1092,15 +1092,22 @@ def test_telegram_hint_encourages_rich_markdown(self):\n         hint = PLATFORM_HINTS[\"telegram\"]\n         lowered = hint.lower()\n         assert \"Telegram has NO table syntax\" not in hint\n-        assert \"rich markdown\" in lowered\n-        assert \"table\" in lowered\n-        assert \"task list\" in lowered\n-        assert \"math\" in lowered\n+        # Base hint covers MarkdownV2-compatible constructs.\n+        assert \"MEDIA:\" in hint\n+        # Rich-messages extension (TELEGRAM_RICH_MESSAGES_HINT) covers the\n+        # Bot API 10.1 guidance; it is injected conditionally in\n+        # system_prompt.py when rich_messages: true.\n+        from agent.prompt_builder import TELEGRAM_RICH_MESSAGES_HINT\n+        rich_lowered = TELEGRAM_RICH_MESSAGES_HINT.lower()\n+        assert \"rich markdown\" in rich_lowered\n+        assert \"table\" in rich_lowered\n+        assert \"task list\" in rich_lowered\n+        assert \"math\" in rich_lowered\n         # Hint should proactively steer toward structured formatting, not just\n         # permit it: bullet + numbered lists for scannable, structured output.\n-        assert \"bullet\" in lowered\n-        assert \"numbered\" in lowered\n-        # Local media delivery guidance must remain intact.\n+        assert \"bullet\" in rich_lowered\n+        assert \"numbered\" in rich_lowered\n+        # Local media delivery guidance must remain intact in the base hint.\n         assert \"include MEDIA:\" in hint\n \n     def test_platform_hints_mattermost(self):"]},{"file":"tests/agent/test_system_prompt.py","hunks":["@@ -99,3 +99,46 @@ def test_absent_without_tools(self, monkeypatch, tmp_path):\n         monkeypatch.setenv(\"TERMINAL_CWD\", str(tmp_path))\n         agent = _make_agent(valid_tool_names=[], platform=\"cli\")\n         assert \"coding agent\" not in _stable_prompt(agent)\n+\n+\n+class TestTelegramRichMessagesHint:\n+    \"\"\"Verify that TELEGRAM_RICH_MESSAGES_HINT is conditionally included.\"\"\"\n+\n+    def test_base_hint_without_rich_messages(self, monkeypatch):\n+        \"\"\"When rich_messages is False (default), only the base hint is used.\"\"\"\n+        agent = _make_agent(platform=\"telegram\")\n+        # Mock config to return rich_messages: false (default)\n+        with patch(\"hermes_cli.config.load_config_readonly\") as mock_cfg:\n+            mock_cfg.return_value = {\n+                \"platforms\": {\"telegram\": {\"extra\": {\"rich_messages\": False}}}\n+            }\n+            stable = _stable_prompt(agent)\n+        # Base hint should be present\n+        assert \"Standard Markdown is automatically converted\" in stable\n+        # Rich-messages extension should NOT be present\n+        assert \"lean into it\" not in stable\n+        assert \"task lists\" not in stable\n+\n+    def test_rich_hint_with_rich_messages_enabled(self, monkeypatch):\n+        \"\"\"When rich_messages is True, the rich-messages extension is appended.\"\"\"\n+        agent = _make_agent(platform=\"telegram\")\n+        with patch(\"hermes_cli.config.load_config_readonly\") as mock_cfg:\n+            mock_cfg.return_value = {\n+                \"platforms\": {\"telegram\": {\"extra\": {\"rich_messages\": True}}}\n+            }\n+            stable = _stable_prompt(agent)\n+        # Base hint should be present\n+        assert \"Standard Markdown is automatically converted\" in stable\n+        # Rich-messages extension should be present\n+        assert \"lean into it\" in stable\n+        assert \"task lists\" in stable\n+        assert \"math/formulas\" in stable\n+\n+    def test_base_hint_without_config(self, monkeypatch):\n+        \"\"\"When config has no telegram section, only base hint is used.\"\"\"\n+        agent = _make_agent(platform=\"telegram\")\n+        with patch(\"hermes_cli.config.load_config_readonly\") as mock_cfg:\n+            mock_cfg.return_value = {}\n+            stable = _stable_prompt(agent)\n+        assert \"Standard Markdown is automatically converted\" in stable\n+        assert \"lean into it\" not in stable"]},{"file":"tests/gateway/test_telegram_rich_messages.py","hunks":["@@ -582,21 +582,20 @@ async def test_notification_opt_in_drops_disable_flag():\n \n \n @pytest.mark.asyncio\n-async def test_table_only_uses_rich_when_rich_messages_opt_out():\n-    \"\"\"Pipe tables auto-route to sendRichMessage even without the full opt-in.\"\"\"\n+async def test_table_only_uses_legacy_when_rich_messages_opt_out():\n+    \"\"\"Pipe tables respect the rich_messages: false config and stay on legacy.\"\"\"\n     adapter = _make_adapter(extra={\"rich_messages\": False})\n \n     result = await adapter.send(\"12345\", TABLE_ONLY_CONTENT)\n \n     assert result.success is True\n-    api_kwargs = _rich_api_kwargs(adapter)\n-    assert api_kwargs[\"rich_message\"][\"markdown\"] == TABLE_ONLY_CONTENT\n-    adapter._bot.send_message.assert_not_called()\n+    adapter._bot.do_api_request.assert_not_called()\n+    adapter._bot.send_message.assert_awaited()\n \n \n @pytest.mark.asyncio\n-async def test_table_only_uses_rich_with_default_config():\n-    \"\"\"Default config keeps task lists on legacy but upgrades bare tables.\"\"\"\n+async def test_table_only_uses_legacy_with_default_config():\n+    \"\"\"Default config (rich_messages unset → False) keeps tables on legacy path.\"\"\"\n     config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n     adapter = TelegramAdapter(config)\n     bot = MagicMock()","@@ -608,13 +607,13 @@ async def test_table_only_uses_rich_with_default_config():\n     result = await adapter.send(\"12345\", TABLE_ONLY_CONTENT)\n \n     assert result.success is True\n-    bot.do_api_request.assert_awaited_once()\n-    bot.send_message.assert_not_called()\n+    bot.do_api_request.assert_not_called()\n+    bot.send_message.assert_awaited()\n \n \n @pytest.mark.asyncio\n-async def test_dm_topic_resumed_send_uses_rich_for_table_without_reply_anchor():\n-    \"\"\"Resumed/synthetic DM-topic sends route tables via direct_messages_topic_id.\"\"\"\n+async def test_dm_topic_resumed_send_uses_legacy_for_table_when_opt_out():\n+    \"\"\"Resumed DM-topic sends respect rich_messages: false for tables.\"\"\"\n     adapter = _make_adapter(extra={\"rich_messages\": False})\n \n     result = await adapter.send(","@@ -628,14 +627,13 @@ async def test_dm_topic_resumed_send_uses_rich_for_table_without_reply_anchor():\n     )\n \n     assert result.success is True\n-    api_kwargs = _rich_api_kwargs(adapter)\n-    assert api_kwargs[\"direct_messages_topic_id\"] == 20189\n-    assert \"reply_parameters\" not in api_kwargs\n-    assert api_kwargs[\"rich_message\"][\"markdown\"] == TABLE_ONLY_CONTENT\n+    adapter._bot.do_api_request.assert_not_called()\n+    adapter._bot.send_message.assert_awaited()\n \n \n @pytest.mark.asyncio\n-async def test_finalize_edit_rich_includes_forum_topic_routing():\n+async def test_finalize_edit_legacy_includes_forum_topic_routing():\n+    \"\"\"With rich_messages: false, table edits use legacy path with topic routing.\"\"\"\n     adapter = _make_adapter(extra={\"rich_messages\": False})\n \n     result = await adapter.edit_message(","@@ -647,9 +645,8 @@ async def test_finalize_edit_rich_includes_forum_topic_routing():\n     )\n \n     assert result.success is True\n-    api_kwargs = _rich_edit_kwargs(adapter)\n-    assert api_kwargs[\"message_thread_id\"] == 5\n-    assert api_kwargs[\"rich_message\"][\"markdown\"] == TABLE_ONLY_CONTENT\n+    adapter._bot.do_api_request.assert_not_called()\n+    adapter._bot.edit_message_text.assert_awaited()\n \n \n @pytest.mark.asyncio\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}