{"repo":"NousResearch/hermes-agent","public_id":"16720","display_label":"Gateway auto-appending MEDIA tags from tool results","summary":"These items concern the gateway automatically appending MEDIA tags based on tool results. One is a bug report about tags being added from arbitrary tool output, and two are pull requests that propose restricting this to producer tools or adding false-positive guards for MEDIA: detection.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":36275,"merged_at":"2026-06-01T07:00:27+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-01T07:00:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":16720,"pr_number":36275,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-01T07:00:27+00:00"},{"issue_number":35695,"pr_number":36275,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-01T07:00:27+00:00"}],"members":[{"kind":"issue","number":16720,"title":"[Bug] Gateway auto-appends MEDIA tags from arbitrary tool results","url":null,"state":"closed"},{"kind":"pr","number":16721,"title":"fix(gateway): restrict auto-appended media to producer tools","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36275","number":36275},{"kind":"pr","value":"#34608","number":34608},{"kind":"pr","value":"#160","number":160}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":36275,"title":"fix(gateway): salvage MEDIA: false-positive guards (#16721 + #35699)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","scripts/release.py","tests/gateway/test_media_extraction.py","tests/gateway/test_platform_base.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -2579,6 +2579,48 @@ def filter_local_delivery_paths(file_paths) -> List[str]:\n                 logger.warning(\"Skipping unsafe local file path: %s\", _log_safe_path(raw))\n         return safe_paths\n \n+\n+    @staticmethod\n+    def _mask_protected_spans(content: str) -> str:\n+        \"\"\"Replace content inside fenced code blocks, inline code spans,\n+        and blockquotes with spaces to prevent MEDIA: false positives.\n+\n+        Preserves character count so regex match offsets stay valid.\n+        Skips masking backtick-quoted paths in MEDIA: tags (e.g.\n+        ``MEDIA:`/path/to/file.png` ``) to avoid breaking path extraction.\n+        \"\"\"\n+        chars = list(content)\n+        n = len(chars)\n+\n+        # Build list of (start, end) spans to mask\n+        spans: list = []\n+\n+        # Fenced code blocks: ```...```\n+        for m in re.finditer(r'```[^\\n]*\\n.*?```', content, re.DOTALL):\n+            spans.append((m.start(), m.end()))\n+\n+        # Inline code: `...` but NOT backtick-quoted paths in MEDIA: tags\n+        for m in re.finditer(r'`[^`\\n]+`', content):\n+            start = m.start()\n+            # Check if this is a backtick-quoted path after MEDIA:\n+            prefix = content[max(0, start - 20):start]\n+            if re.search(r'MEDIA:\\s*$', prefix):\n+                continue  # This is a MEDIA path quote, not inline code\n+            spans.append((start, m.end()))\n+\n+        # Blockquote lines: > at line start\n+        for m in re.finditer(r'^>.*$', content, re.MULTILINE):\n+            spans.append((m.start(), m.end()))\n+\n+        # Apply masking\n+        for start, end in spans:\n+            for i in range(start, end):\n+                if chars[i] != '\\n':\n+                    chars[i] = ' '\n+\n+        return ''.join(chars)\n+\n+\n     @staticmethod\n     def _mask_json_string_media(content: str) -> str:\n         \"\"\"Blank out ``MEDIA:<bare-path>`` occurrences that sit inside a JSON","@@ -2661,9 +2703,14 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:\n         # set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into\n         # MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.\n         media_pattern = MEDIA_TAG_CLEANUP_RE\n-        # Mask MEDIA: embedded inside serialized JSON string values so stale\n-        # paths from stored tool results are never re-delivered (#34375).\n-        scan_content = BasePlatformAdapter._mask_json_string_media(content)\n+        # Mask example/stored MEDIA: paths before scanning so they are never\n+        # delivered as real attachments:\n+        #  - code blocks / inline code / blockquotes hold prose examples (#35695)\n+        #  - serialized JSON string values hold stored tool-result text (#34375)\n+        # Both maskers are offset-preserving (chars -> spaces) so match offsets\n+        # stay valid; chaining them masks the union of both protected regions.\n+        scan_content = BasePlatformAdapter._mask_protected_spans(content)\n+        scan_content = BasePlatformAdapter._mask_json_string_media(scan_content)\n         for match in media_pattern.finditer(scan_content):\n             path = match.group(\"path\").strip()\n             if len(path) >= 2 and path[0] == path[-1] and path[0] in \"`\\\"'\":","@@ -2677,15 +2724,17 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:\n                     # and dropping every other attachment in the response.\n                     continue\n \n-        # Remove the delivered MEDIA tags from the user-visible text. Mask\n-        # ``cleaned`` (same length, so offsets line up with it), find the real\n-        # tag spans there, and delete those spans from the *unmasked* ``cleaned``.\n-        # This strips real tags while leaving JSON-embedded MEDIA: text intact —\n-        # it is stored data, not a directive, and must read back verbatim\n-        # (#34375). Masking ``cleaned`` (not ``content``) keeps offsets valid\n-        # after the [[audio_as_voice]] / [[as_document]] directives are removed.\n+        # Remove the delivered MEDIA tags from the user-visible text. Mask a\n+        # length-equal copy of ``cleaned`` (same union of protected regions) to\n+        # *locate* the real tag spans, then delete exactly those spans from the\n+        # *unmasked* ``cleaned``. Masking is only a locator — protected spans\n+        # (code blocks, quotes, JSON-embedded MEDIA: text) must survive verbatim\n+        # in the delivered text, not be blanked to whitespace. Masking\n+        # ``cleaned`` (not ``content``) keeps offsets valid after the\n+        # [[audio_as_voice]] / [[as_document]] directives are removed.\n         if media:\n-            masked_cleaned = BasePlatformAdapter._mask_json_string_media(cleaned)\n+            masked_cleaned = BasePlatformAdapter._mask_protected_spans(cleaned)\n+            masked_cleaned = BasePlatformAdapter._mask_json_string_media(masked_cleaned)\n             spans = [m.span() for m in media_pattern.finditer(masked_cleaned)]\n             if spans:\n                 chars = list(cleaned)"]},{"file":"gateway/run.py","hunks":["@@ -669,6 +669,88 @@ def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:\n     return None\n \n \n+# Tool results can contain literal MEDIA: examples in docs, logs, or other\n+# ordinary outputs. Only tools that intentionally create deliverable media\n+# artifacts should be eligible for automatic append when the model omits them\n+# from the final gateway reply.\n+_AUTO_APPEND_MEDIA_TOOL_NAMES = {\"text_to_speech\", \"text_to_speech_tool\"}\n+\n+\n+# Extension-anchored MEDIA: matcher for tool results. Mirrors the dispatch-site\n+# pattern so a bare ``MEDIA:`` token in prose (no deliverable extension) is never\n+# auto-appended. Kept local to the auto-append path; the producer-tool allowlist\n+# below is the primary guard, this is the secondary precision guard.\n+_TOOL_MEDIA_RE = re.compile(\n+    r'MEDIA:((?:[A-Za-z]:[/\\\\]|/|~\\/)\\S+\\.(?:png|jpe?g|gif|webp|'\n+    r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'\n+    r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'\n+    r'txt|csv|apk|ipa))',\n+    re.IGNORECASE,\n+)\n+\n+\n+def _collect_auto_append_media_tags(\n+    messages: List[Dict[str, Any]],\n+    history_offset: int = 0,\n+    history_media_paths: Optional[set] = None,\n+) -> tuple[List[str], bool]:\n+    \"\"\"Collect real media tags from current-turn producer-tool results only.\n+\n+    Two layered guards keep stale/example MEDIA: strings out of the reply:\n+\n+    1. Producer-tool allowlist: only tools that intentionally emit deliverable\n+       artifacts (TTS) are eligible. Documentation, logs, and search results can\n+       contain example strings such as MEDIA:/absolute/path/to/file, which must\n+       never be delivered as attachments. (Fixes the original report behind #16721.)\n+    2. Current-turn isolation: only messages produced this turn are scanned, so a\n+       tool result from an earlier turn (still present in the full message list)\n+       cannot leak onto a later text-only reply (#34608).\n+\n+    Mid-run context compression can rewrite/shrink the message list below the\n+    original history length. When that happens the slice boundary is no longer\n+    trustworthy, so fall back to scanning every message and rely on\n+    ``history_media_paths`` for dedup, preserving the compression-safe behaviour\n+    of #160. The producer-tool allowlist still applies on the fallback path.\n+    \"\"\"\n+    history_media_paths = history_media_paths or set()\n+    # Only trust the slice boundary when the message list still contains the\n+    # full history prefix. Otherwise scan everything (compression-safe fallback).\n+    if history_offset and len(messages) >= history_offset:\n+        new_messages = messages[history_offset:]\n+    else:\n+        new_messages = messages\n+\n+    tool_name_by_call_id: Dict[str, str] = {}\n+    for msg in new_messages:\n+        if msg.get(\"role\") != \"assistant\":\n+            continue\n+        for call in msg.get(\"tool_calls\") or []:\n+            call_id = call.get(\"id\") or call.get(\"call_id\")\n+            fn = call.get(\"function\") or {}\n+            name = str(fn.get(\"name\") or call.get(\"name\") or \"\")\n+            if call_id and name:\n+                tool_name_by_call_id[str(call_id)] = name\n+\n+    media_tags: List[str] = []\n+    has_voice_directive = False\n+    for msg in new_messages:\n+        if msg.get(\"role\") not in (\"tool\", \"function\"):\n+            continue\n+        call_id = str(msg.get(\"tool_call_id\") or msg.get(\"call_id\") or \"\")\n+        if tool_name_by_call_id.get(call_id) not in _AUTO_APPEND_MEDIA_TOOL_NAMES:\n+            continue\n+        content = str(msg.get(\"content\") or \"\")\n+        if \"MEDIA:\" not in content:\n+            continue\n+        for match in _TOOL_MEDIA_RE.finditer(content):\n+            path = match.group(1).strip().rstrip('\\\",}')\n+            if path and path not in history_media_paths:\n+                media_tags.append(f\"MEDIA:{path}\")\n+        if \"[[audio_as_voice]]\" in content:\n+            has_voice_directive = True\n+\n+    return media_tags, has_voice_directive\n+\n # ---------------------------------------------------------------------------\n # SSL certificate auto-detection for NixOS and other non-standard systems.\n # Must run BEFORE any HTTP library (discord, aiohttp, etc.) is imported.","@@ -17931,36 +18013,12 @@ def _approval_notify_sync(approval_data: dict) -> None:\n             # context compression shrinks the message list below the original\n             # history length, preserving the compression-safe behaviour of #160.\n             if \"MEDIA:\" not in final_response:\n-                media_tags = []\n-                has_voice_directive = False\n-                _all_msgs = result.get(\"messages\", [])\n-                _history_len = len(agent_history)\n-                # Only trust the slice boundary when the message list still\n-                # contains the full history prefix. Mid-run compression can\n-                # rewrite/shrink the list; in that case fall back to scanning\n-                # everything and rely on _history_media_paths for dedup.\n-                if _history_len and len(_all_msgs) >= _history_len:\n-                    _scan_msgs = _all_msgs[_history_len:]\n-                else:\n-                    _scan_msgs = _all_msgs\n-                for msg in _scan_msgs:\n-                    if msg.get(\"role\") in {\"tool\", \"function\"}:\n-                        content = msg.get(\"content\", \"\")\n-                        if \"MEDIA:\" in content:\n-                            _TOOL_MEDIA_RE = re.compile(\n-                                r'MEDIA:((?:[A-Za-z]:[/\\\\]|/|~\\/)\\S+\\.(?:png|jpe?g|gif|webp|'\n-                                r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'\n-                                r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'\n-                                r'txt|csv|apk|ipa))',\n-                                re.IGNORECASE\n-                            )\n-                            for match in _TOOL_MEDIA_RE.finditer(content):\n-                                path = match.group(1).strip().rstrip('\",}')\n-                                if path and path not in _history_media_paths:\n-                                    media_tags.append(f\"MEDIA:{path}\")\n-                            if \"[[audio_as_voice]]\" in content:\n-                                has_voice_directive = True\n-                \n+                media_tags, has_voice_directive = _collect_auto_append_media_tags(\n+                    result.get(\"messages\", []),\n+                    history_offset=len(agent_history),\n+                    history_media_paths=_history_media_paths,\n+                )\n+\n                 if media_tags:\n                     seen = set()\n                     unique_tags = []"]},{"file":"scripts/release.py","hunks":["@@ -1032,6 +1032,7 @@\n     \"[redacted:email]\": \"holynn-q\",\n     \"[redacted:email]\": \"jacdevos\",\n     \"[redacted:email]\": \"liuhao1024\",\n+    \"[redacted:email]\": \"VinciZhu\",\n     \"[redacted:email]\": \"rodrigoeqnit\",\n     \"178342791+sgtworkman@users.noreply.github.com\": \"sgtworkman\",\n     \"[redacted:email]\": \"keepcalmqqf\","]},{"file":"tests/gateway/test_media_extraction.py","hunks":["@@ -102,6 +102,63 @@ def extract_media_tags_broken(result_messages):\n \n class TestMediaExtraction:\n     \"\"\"Tests for MEDIA tag extraction from tool results.\"\"\"\n+\n+    def test_gateway_auto_append_ignores_media_examples_in_skill_docs(self):\n+        \"\"\"Skill/documentation examples must not be appended as real attachments.\"\"\"\n+        from gateway.run import _collect_auto_append_media_tags\n+\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"How should I format gateway media?\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"tool_calls\": [\n+                    {\"id\": \"call_skill\", \"function\": {\"name\": \"skill_view\"}}\n+                ],\n+            },\n+            {\n+                \"role\": \"tool\",\n+                \"tool_call_id\": \"call_skill\",\n+                \"content\": \"\"\"\n+Recommended pattern:\n+```text\n+MEDIA:/absolute/path/to/image.png\n+```\n+Second message:\n+```text\n+caption\n+```\n+\"\"\",\n+            },\n+            {\"role\": \"assistant\", \"content\": \"Use a standalone media message.\"},\n+        ]\n+\n+        tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)\n+        assert tags == []\n+        assert voice is False\n+\n+    def test_gateway_auto_append_keeps_real_tts_media_tag(self):\n+        \"\"\"TTS tool media tags are still auto-appended when the model omits them.\"\"\"\n+        from gateway.run import _collect_auto_append_media_tags\n+\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"Say this as audio\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"tool_calls\": [\n+                    {\"id\": \"call_tts\", \"function\": {\"name\": \"text_to_speech\"}}\n+                ],\n+            },\n+            {\n+                \"role\": \"tool\",\n+                \"tool_call_id\": \"call_tts\",\n+                \"content\": '{\"success\": true, \"media_tag\": \"[[audio_as_voice]]\\\\nMEDIA:/tmp/voice.ogg\"}',\n+            },\n+            {\"role\": \"assistant\", \"content\": \"Done.\"},\n+        ]\n+\n+        tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)\n+        assert tags == [\"MEDIA:/tmp/voice.ogg\"]\n+        assert voice is True\n     \n     def test_media_tags_not_extracted_from_history(self):\n         \"\"\"MEDIA tags from previous turns should NOT be extracted again.\"\"\""]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -400,6 +400,64 @@ def test_relative_path_still_ignored(self):\n         )\n         assert media == []\n \n+    # --- Code block / inline code / blockquote false-positive guards (#35695) ---\n+\n+    def test_media_in_fenced_code_block_ignored(self):\n+        \"\"\"MEDIA: inside ``` fenced code blocks must not be extracted.\"\"\"\n+        content = \"Here is an example:\\n```text\\nMEDIA:/path/to/example.png\\n```\\nDone.\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == []\n+        assert \"example\" in cleaned.lower()\n+\n+    def test_media_in_inline_code_ignored(self):\n+        \"\"\"MEDIA: inside backtick inline code must not be extracted.\"\"\"\n+        content = \"Use `MEDIA:/path/to/file.png` in your response.\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == []\n+        assert \"MEDIA:\" in cleaned  # preserved as text\n+\n+    def test_media_in_blockquote_ignored(self):\n+        \"\"\"MEDIA: inside a > blockquote must not be extracted.\"\"\"\n+        content = \"> To send an image, include MEDIA:/path/to/image.jpg\\nEnd.\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == []\n+        assert \"End.\" in cleaned\n+\n+    def test_media_outside_code_blocks_still_extracted(self):\n+        \"\"\"Real MEDIA: tags outside protected regions must still work.\"\"\"\n+        content = \"MEDIA:/real/file.png\\n```code\\nMEDIA:/fake/file.png\\n```\"\n+        media, _ = BasePlatformAdapter.extract_media(content)\n+        assert len(media) == 1\n+        assert media[0][0] == \"/real/file.png\"\n+\n+    def test_media_mixed_code_and_prose(self):\n+        \"\"\"Real MEDIA: in prose + example in code block: only prose extracted,\n+        and the code block survives verbatim in the delivered text.\"\"\"\n+        content = (\n+            \"Here is your file:\\n\"\n+            \"MEDIA:/output/report.pdf\\n\"\n+            \"Example usage:\\n\"\n+            \"```text\\nMEDIA:/example/path.pdf\\n```\\n\"\n+            \"Done.\"\n+        )\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert len(media) == 1\n+        assert media[0][0] == \"/output/report.pdf\"\n+        assert \"Done.\" in cleaned\n+        # The real tag is stripped from the delivered text...\n+        assert \"MEDIA:/output/report.pdf\" not in cleaned\n+        # ...but the fenced code block (incl. its example MEDIA: line) must\n+        # survive verbatim — masking is a locator, not a text rewrite.\n+        assert \"```text\\nMEDIA:/example/path.pdf\\n```\" in cleaned\n+\n+    def test_inline_code_survives_when_real_media_present(self):\n+        \"\"\"When a real MEDIA: tag is delivered, an inline-code example in the\n+        same reply must not be blanked to whitespace.\"\"\"\n+        content = \"See MEDIA:/r/a.png and `MEDIA:/ex/b.png` inline\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert [p for p, _ in media] == [\"/r/a.png\"]\n+        assert \"`MEDIA:/ex/b.png`\" in cleaned\n+\n \n class TestMediaInsideSerializedJson:\n     \"\"\"Regression coverage for #34375 — MEDIA: embedded in serialized JSON\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}