{"repo":"NousResearch/hermes-agent","public_id":"23045","display_label":"Telegram file handling errors and unsupported types","summary":"These issues and fixes address how the Telegram gateway handles unsupported or failing document uploads, including .stl/.3mf files, covering silent failures, empty turns, and routing errors to the correct recipient.","subsystem":"gateway","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53912},"resolution":{"state":"partially_resolved","by":"pr-53912"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T11:23:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18577777777777776},"fix_edges":[{"issue_number":23045,"pr_number":53912,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":35474,"pr_number":65510,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":37318,"pr_number":65510,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":42206,"pr_number":65510,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":47093,"pr_number":53912,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":53249,"pr_number":65510,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":23045,"title":"[Telegram gateway] Unsupported document types are sent to agent as fake user messages; cache failures silently lose the attachment","url":null,"state":"closed"},{"kind":"issue","number":53249,"title":"Telegram gateway rejects .stl / .3mf attachments — silently breaks 3D-printing agent workflows","url":null,"state":"closed"},{"kind":"pr","number":23063,"title":"fix: keep telegram document failures out of agent input","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"db16854f343c67548933ac2e0e1d4d586bdeaaa6","number":0},{"kind":"pr","value":"#53912","number":53912},{"kind":"pr","value":"#23045","number":23045},{"kind":"pr","value":"#53912","number":53912},{"kind":"pr","value":"#53912","number":53912}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23241,"title":"fix(telegram): route unsupported docs and cache errors to user, not agent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"db16854f343c67548933ac2e0e1d4d586bdeaaa6","number":0},{"kind":"pr","value":"#53912","number":53912},{"kind":"pr","value":"#23045","number":23045},{"kind":"pr","value":"#53912","number":53912},{"kind":"pr","value":"#53912","number":53912}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23349,"title":"fix(telegram): reply to user on unsupported docs and cache failures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23061","number":23061},{"kind":"pr","value":"#23045","number":23045},{"kind":"pr","value":"#23063","number":23063},{"kind":"pr","value":"#23241","number":23241}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":46680,"title":"fix: avoid empty turns when Telegram documents fail to download","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23063","number":23063},{"kind":"pr","value":"#23045","number":23045},{"kind":"pr","value":"#23063","number":23063},{"kind":"pr","value":"#23061","number":23061},{"kind":"pr","value":"#41087","number":41087}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53354,"title":"fix(gateway): accept .stl and .3mf document uploads","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4314d451ca961cb50c3430197a3a2c7a8575fd0e","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":53912,"title":"fix(telegram): surface failed media downloads to user and agent, not a silent empty turn","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_documents.py"],"fix_hunks":[{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -6101,6 +6101,47 @@ def _append_observed_note(existing: Optional[str], note: str) -> str:\n             return note\n         return f\"{existing}\\n\\n{note}\"\n \n+    async def _surface_media_cache_failure(\n+        self,\n+        msg: Message,\n+        event: MessageEvent,\n+        kind: str,\n+        exc: Exception,\n+        display_name: Optional[str] = None,\n+    ) -> None:\n+        \"\"\"Surface a failed media download/cache on BOTH ends instead of swallowing it.\n+\n+        When download_as_bytearray()/cache_*_from_bytes() raises (typically a\n+        transient httpx.ConnectError to Telegram's CDN), the attachment never\n+        made it into event.media_urls. Without this, the handler falls through\n+        and dispatches an empty turn: the user thinks the file was delivered,\n+        the agent sees nothing, and the only record is a buried log warning.\n+\n+        This (1) replies to the user in Telegram so they know to retry, and\n+        (2) appends an agent-visible notice to event.text via the existing\n+        observed-note channel so the agent knows an attachment was attempted\n+        and failed — never a silent empty turn. No new event fields (the\n+        structured-event refactor is out of scope per #23045).\n+        \"\"\"\n+        named = f\" ({display_name})\" if display_name else \"\"\n+        try:\n+            await msg.reply_text(\n+                f\"\\u26a0\\ufe0f Couldn't download your {kind}{named} \"\n+                f\"({exc.__class__.__name__}). Please try sending it again.\"\n+            )\n+        except Exception as reply_err:\n+            logger.warning(\n+                \"[Telegram] Failed to notify user about %s cache failure: %s\",\n+                kind,\n+                reply_err,\n+                exc_info=True,\n+            )\n+        agent_note = (\n+            f\"[The user attempted to send a {kind}{named} but it could not be \"\n+            f\"downloaded ({exc.__class__.__name__}); they have been asked to retry.]\"\n+        )\n+        event.text = self._append_observed_note(event.text, agent_note)\n+\n     def _observe_unmentioned_group_message(\n         self,\n         message: Message,","@@ -6562,6 +6603,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n \n             except Exception as e:\n                 logger.warning(\"[Telegram] Failed to cache photo: %s\", e, exc_info=True)\n+                await self._surface_media_cache_failure(msg, event, \"photo\", e)\n \n         # Download voice/audio messages to cache for STT transcription\n         if msg.voice:","@@ -6580,6 +6622,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n                 logger.info(\"[Telegram] Cached user voice at %s\", cached_path)\n             except Exception as e:\n                 logger.warning(\"[Telegram] Failed to cache voice: %s\", e, exc_info=True)\n+                await self._surface_media_cache_failure(msg, event, \"voice message\", e)\n         elif msg.audio:\n             try:\n                 allowed, note = self._telegram_media_size_allowed(msg.audio, \"audio file\")","@@ -6596,6 +6639,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n                 logger.info(\"[Telegram] Cached user audio at %s\", cached_path)\n             except Exception as e:\n                 logger.warning(\"[Telegram] Failed to cache audio: %s\", e, exc_info=True)\n+                await self._surface_media_cache_failure(msg, event, \"audio file\", e)\n \n         elif msg.video:\n             try:","@@ -6619,6 +6663,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n                 logger.info(\"[Telegram] Cached user video at %s\", cached_path)\n             except Exception as e:\n                 logger.warning(\"[Telegram] Failed to cache video: %s\", e, exc_info=True)\n+                await self._surface_media_cache_failure(msg, event, \"video file\", e)\n \n         # Download document files to cache for agent processing\n         elif msg.document:","@@ -6751,6 +6796,10 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n \n             except Exception as e:\n                 logger.warning(\"[Telegram] Failed to cache document: %s\", e, exc_info=True)\n+                await self._surface_media_cache_failure(\n+                    msg, event, \"attachment\", e,\n+                    display_name=getattr(doc, \"file_name\", None) or None,\n+                )\n \n         media_group_id = getattr(msg, \"media_group_id\", None)\n         if media_group_id:"]},{"file":"tests/gateway/test_telegram_documents.py","hunks":["@@ -106,6 +106,7 @@ def _make_message(document=None, caption=None, media_group_id=None, photo=None):\n     msg.from_user.id = 1\n     msg.from_user.full_name = \"Test User\"\n     msg.message_thread_id = None\n+    msg.reply_text = AsyncMock()\n     return msg\n \n ","@@ -397,7 +398,7 @@ async def test_text_injection_capped(self, adapter):\n \n     @pytest.mark.asyncio\n     async def test_download_exception_handled(self, adapter):\n-        \"\"\"If get_file() raises, the handler logs the error without crashing.\"\"\"\n+        \"\"\"If get_file() raises, the handler surfaces the failure without crashing.\"\"\"\n         doc = _make_document(file_name=\"crash.pdf\", file_size=100)\n         doc.get_file = AsyncMock(side_effect=RuntimeError(\"Telegram API down\"))\n         msg = _make_message(document=doc)","@@ -406,8 +407,73 @@ async def test_download_exception_handled(self, adapter):\n         # Should not raise\n         await adapter._handle_media_message(update, MagicMock())\n         # handle_message should still be called (the handler catches the exception)\n+        # so the agent gets a turn — but now that turn carries a notice instead\n+        # of being an empty/content-less event.\n         adapter.handle_message.assert_called_once()\n \n+    @pytest.mark.asyncio\n+    async def test_document_cache_failure_replies_and_signals_agent(self, adapter):\n+        \"\"\"A failed document download must surface on BOTH ends, not silently.\n+\n+        Regression for #23045 Bug 2: a CDN download/cache failure used to log a\n+        warning and fall through to an empty agent turn — user thinks the file\n+        arrived, agent sees nothing. Now the user gets a Telegram reply AND the\n+        agent's event.text carries an attempted-attachment notice.\n+        \"\"\"\n+        doc = _make_document(file_name=\"notes.md\", mime_type=\"text/markdown\", file_size=100)\n+        doc.get_file = AsyncMock(side_effect=RuntimeError(\"Telegram CDN down\"))\n+        msg = _make_message(document=doc)\n+        update = _make_update(msg)\n+\n+        await adapter._handle_media_message(update, MagicMock())\n+\n+        # 1. User is told the download failed, with the filename + exception type.\n+        msg.reply_text.assert_awaited_once()\n+        reply = msg.reply_text.await_args.args[0]\n+        assert \"Couldn't download\" in reply\n+        assert \"notes.md\" in reply\n+        assert \"RuntimeError\" in reply\n+\n+        # 2. The agent still gets a turn, but event.text now carries a notice so\n+        #    it knows an attachment was attempted and failed (not a silent empty turn).\n+        adapter.handle_message.assert_called_once()\n+        event = adapter.handle_message.call_args[0][0]\n+        assert event.media_urls == []  # nothing cached\n+        assert \"could not be downloaded\" in (event.text or \"\")\n+        assert \"notes.md\" in (event.text or \"\")\n+\n+    @pytest.mark.asyncio\n+    async def test_document_cache_failure_reply_error_is_nonfatal(self, adapter):\n+        \"\"\"If even the user-reply fails, the agent notice is still appended.\"\"\"\n+        doc = _make_document(file_name=\"x.bin\", mime_type=\"application/octet-stream\", file_size=100)\n+        doc.get_file = AsyncMock(side_effect=RuntimeError(\"CDN down\"))\n+        msg = _make_message(document=doc)\n+        msg.reply_text = AsyncMock(side_effect=RuntimeError(\"reply failed too\"))\n+        update = _make_update(msg)\n+\n+        # Must not raise despite reply_text blowing up\n+        await adapter._handle_media_message(update, MagicMock())\n+        adapter.handle_message.assert_called_once()\n+        event = adapter.handle_message.call_args[0][0]\n+        assert \"could not be downloaded\" in (event.text or \"\")\n+\n+    @pytest.mark.asyncio\n+    async def test_voice_cache_failure_replies_and_signals_agent(self, adapter):\n+        \"\"\"Same fail-closed contract applies to the voice site (#23045 Bug 2 class).\"\"\"\n+        msg = _make_message()\n+        msg.voice = MagicMock()\n+        msg.voice.file_size = 100\n+        msg.voice.get_file = AsyncMock(side_effect=RuntimeError(\"CDN down\"))\n+        update = _make_update(msg)\n+\n+        await adapter._handle_media_message(update, MagicMock())\n+\n+        msg.reply_text.assert_awaited_once()\n+        assert \"voice message\" in msg.reply_text.await_args.args[0]\n+        adapter.handle_message.assert_called_once()\n+        event = adapter.handle_message.call_args[0][0]\n+        assert \"could not be downloaded\" in (event.text or \"\")\n+\n \n class TestVideoDownloadBlock:\n     @pytest.mark.asyncio\n"]}]},{"kind":"pr","number":60069,"title":"fix(gateway): allow .py/.markdown/.stl/.3mf media & document attachments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65510","number":65510}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":65510,"title":"fix(gateway): deliver MEDIA: tags for every file type via validated egress","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","tests/gateway/test_platform_base.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1504,12 +1504,16 @@ def _log_safe_path(path: str) -> str:\n     re.IGNORECASE,\n )\n \n-# Extension-less absolute paths (e.g. Caddyfile, Dockerfile, Makefile) are\n-# intentionally excluded from MEDIA_TAG_CLEANUP_RE — they are validated and\n-# delivered via MEDIA_EXTENSIONLESS_TAG_RE so prompt-injection paths that do\n-# not exist on disk are left visible instead of silently dropped. Paths with\n-# an unknown but present extension (e.g. .weirdext) stay on the #34517\n-# bare-path fallback and are not handled here.\n+# Paths NOT covered by MEDIA_TAG_CLEANUP_RE's extension alternation — both\n+# extension-less files (Caddyfile, Dockerfile, Makefile) and files with an\n+# unknown extension (.py, .log, .weirdext, ...) — are validated and delivered\n+# via MEDIA_EXTENSIONLESS_TAG_RE. Every ``MEDIA:`` path is therefore\n+# deliverable regardless of file type (#36060): known extensions extract\n+# unconditionally via the anchored pattern above, everything else extracts\n+# only after ``validate_media_delivery_path`` accepts it (exists on disk, not\n+# under the credential/system denylist, strict-mode rules honored), so\n+# prompt-injection paths that do not validate are left visible instead of\n+# silently dropped.\n MEDIA_EXTENSIONLESS_TAG_RE = re.compile(\n     r'''[`\"']?MEDIA:\\s*'''\n     r'''(?P<path>`[^`\\n]+`|\"[^\"\\n]+\"|'[^'\\n]+'|'''","@@ -1527,8 +1531,16 @@ def _normalize_media_tag_path(raw: str) -> str:\n \n \n def _path_lacks_deliverable_extension(path: str) -> bool:\n-    \"\"\"True only when the basename has no extension (Caddyfile, Makefile, …).\"\"\"\n-    return not Path(path).suffix\n+    \"\"\"True when MEDIA_TAG_CLEANUP_RE's extension alternation does not cover\n+    ``path`` — either the basename has no extension at all (Caddyfile,\n+    Makefile, …) or the extension is not in MEDIA_DELIVERY_EXTS (.py, .log,\n+    .weirdext, …). Such paths route through the validated delivery pass\n+    (``validate_media_delivery_path``) instead of the unconditional one, so\n+    every file type is deliverable (#36060) while nonexistent / denylisted\n+    paths stay visible in the text.\n+    \"\"\"\n+    suffix = Path(path).suffix.lower()\n+    return not suffix or suffix not in MEDIA_DELIVERY_EXTS\n \n \n def _strip_media_tag_directives(text: str) -> str:"]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -631,12 +631,15 @@ def test_extract_media_and_local_files_share_one_extension_set(self):\n \n     def test_unknown_extension_not_black_holed_by_cleanup(self):\n         \"\"\"A MEDIA: tag with an unknown extension is NOT stripped from the\n-        body — it survives so extract_local_files can still see the bare path,\n-        rather than vanishing entirely (the core of issue #34517).\"\"\"\n+        body by the extension-anchored cleanup — and when the path does not\n+        validate (nonexistent file here), it is not delivered either, so the\n+        tag survives visibly instead of vanishing (the core of issue #34517).\n+        Validated unknown-extension paths DO deliver — see\n+        TestUniversalMediaEgress (#36060).\"\"\"\n         from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE\n         text = \"Saved to MEDIA:/tmp/data.weirdext done\"\n         media, _ = BasePlatformAdapter.extract_media(text)\n-        assert media == []  # unknown extension is not a deliverable MEDIA tag\n+        assert media == []  # nonexistent path fails validation, not delivered\n         stripped = MEDIA_TAG_CLEANUP_RE.sub(\"\", text)\n         assert \"/tmp/data.weirdext\" in stripped  # path preserved, not dropped\n ","@@ -712,6 +715,104 @@ def test_as_document_directive_stripped_without_media_tag(self):\n         )\n \n \n+class TestUniversalMediaEgress:\n+    \"\"\"#36060: every MEDIA: path is deliverable regardless of file type.\n+\n+    Known extensions extract unconditionally (MEDIA_TAG_CLEANUP_RE); unknown\n+    extensions and extension-less files extract via the validated pass —\n+    delivered when validate_media_delivery_path accepts them, left visible\n+    when it does not (nonexistent, denylisted).\n+    \"\"\"\n+\n+    def _patch_allow_root(self, monkeypatch, root):\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS\",\n+            (str(root),),\n+        )\n+        monkeypatch.delenv(\"HERMES_MEDIA_DELIVERY_STRICT\", raising=False)\n+\n+    @pytest.mark.parametrize(\"name\", [\n+        \"script.py\", \"server.log\", \"notes.weirdext\", \"app.ts\", \"run.sh\",\n+        \"config.toml\", \"styles.css\", \"contract.sol\",\n+    ])\n+    def test_unknown_extension_delivered_when_file_validates(\n+        self, tmp_path, monkeypatch, name,\n+    ):\n+        root = tmp_path / \"output\"\n+        root.mkdir()\n+        f = root / name\n+        f.write_text(\"content\", encoding=\"utf-8\")\n+        self._patch_allow_root(monkeypatch, root)\n+\n+        content = f\"Here you go:\\nMEDIA:{f}\\nDone.\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert len(media) == 1\n+        assert media[0][0] == str(f.resolve())\n+        assert \"MEDIA:\" not in cleaned\n+        assert \"Done.\" in cleaned\n+\n+    def test_unknown_extension_left_visible_when_not_on_disk(\n+        self, tmp_path, monkeypatch,\n+    ):\n+        root = tmp_path / \"output\"\n+        root.mkdir()\n+        self._patch_allow_root(monkeypatch, root)\n+\n+        content = \"MEDIA:/nonexistent/script.py\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == []\n+        assert \"MEDIA:/nonexistent/script.py\" in cleaned\n+\n+    def test_denylisted_paths_still_rejected_regardless_of_extension(\n+        self, tmp_path, monkeypatch,\n+    ):\n+        # A denylisted path must not deliver even though .py/.log/etc now\n+        # route through the validated pass. _media_delivery_denied_paths()\n+        # reads _MEDIA_DELIVERY_DENIED_PREFIXES at call time, so patching the\n+        # tuple exercises the real denylist logic.\n+        secret_dir = [redacted:secret-assignment] / \"secrets\"\n+        secret_dir.mkdir()\n+        f = secret_dir / \"creds.py\"\n+        f.write_text(\"TOKEN = 'x'\", encoding=\"utf-8\")\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES\",\n+            (str(secret_dir),),\n+        )\n+        monkeypatch.delenv(\"HERMES_MEDIA_DELIVERY_STRICT\", raising=False)\n+\n+        content = f\"MEDIA:{f}\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == []\n+        assert \"MEDIA:\" in cleaned  # rejected tag stays visible\n+\n+    def test_strip_for_display_strips_validated_unknown_extension(\n+        self, tmp_path, monkeypatch,\n+    ):\n+        root = tmp_path / \"output\"\n+        root.mkdir()\n+        f = root / \"server.log\"\n+        f.write_text(\"x\", encoding=\"utf-8\")\n+        self._patch_allow_root(monkeypatch, root)\n+\n+        text = f\"MEDIA:{f}\"\n+        stripped = BasePlatformAdapter.strip_media_directives_for_display(text)\n+        assert \"MEDIA:\" not in stripped\n+\n+    def test_strip_for_display_keeps_unvalidated_unknown_extension(self):\n+        text = \"MEDIA:/nonexistent/server.log\"\n+        stripped = BasePlatformAdapter.strip_media_directives_for_display(text)\n+        assert \"MEDIA:/nonexistent/server.log\" in stripped\n+\n+    def test_known_extension_still_unconditional(self):\n+        # Known extensions keep the pre-#36060 behavior: extracted (and the\n+        # tag stripped) even when the file does not exist — downstream\n+        # delivery surfaces the failure.\n+        content = \"MEDIA:/nonexistent/report.pdf\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == [(\"/nonexistent/report.pdf\", False)]\n+        assert \"MEDIA:\" not in cleaned\n+\n+\n class TestMediaDeliveryPathValidation:\n     def _patch_roots(self, monkeypatch, *roots):\n         monkeypatch.setattr(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}