{"repo":"NousResearch/hermes-agent","public_id":"41086","display_label":"Telegram media download timeout and failure handling","summary":"These issues and fixes address cases where Telegram photo and voice file downloads silently fail or time out, leaving agents and users with empty or missing turns. The work covers surfacing errors, retrying transient failures, and failing closed rather than silently dropping media.","subsystem":"gateway","counts":{"members":6,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53912,"merged_at":"2026-06-28T02:12:57+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-28T02:12:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":23045,"pr_number":53912,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-28T02:12:57+00:00"},{"issue_number":47093,"pr_number":53912,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-28T02:12:57+00:00"}],"members":[{"kind":"issue","number":41086,"title":"[Telegram gateway] Voice cache timeout dispatches empty user turn","url":null,"state":"closed"},{"kind":"issue","number":47093,"title":"[Bug]: Telegram photos can be dropped permanently when get_file() times out before caching","url":null,"state":"open"},{"kind":"pr","number":41087,"title":"fix: fail closed on Telegram voice cache timeouts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5600105478ffde29d7566b45421b100eaa29c4ef","number":0},{"kind":"commit","value":"db16854f343c67548933ac2e0e1d4d586bdeaaa6","number":0}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":47103,"title":"fix(telegram): notify agent when photo download times out","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"db16854f343c67548933ac2e0e1d4d586bdeaaa6","number":0},{"kind":"pr","value":"#53912","number":53912}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":47160,"title":"fix(telegram): retry transient photo downloads","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"db16854f343c67548933ac2e0e1d4d586bdeaaa6","number":0}],"category":"uncategorized","implication":"needs_llm"},"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"},"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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}