{"repo":"NousResearch/hermes-agent","public_id":"46627","display_label":"Media tag deduplication across context compression boundary","summary":"These issues and PRs concern duplicate MEDIA tags appearing when context is compressed, particularly for image generation and tool-result content. Work focuses on scoping and deduplicating media references across the compression boundary in the gateway.","subsystem":null,"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":49989,"merged_at":"2026-06-21T06:20:17+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-21T06:20:17+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":46627,"pr_number":49989,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-21T06:20:17+00:00"}],"members":[{"kind":"issue","number":46627,"title":"_collect_auto_append_media_tags duplicates MEDIA tags after context compression","url":null,"state":"closed"},{"kind":"pr","number":46653,"title":"fix(gateway): collect image_generate JSON paths in history media dedup set","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49989","number":49989}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49989,"title":"fix(gateway): dedup image_generate media across the compression boundary (#46627)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_media_extraction.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1115,6 +1115,55 @@ def _collect_auto_append_media_tags(\n \n     return media_tags, has_voice_directive\n \n+\n+def _collect_history_media_paths(agent_history: List[Dict[str, Any]]) -> set:\n+    \"\"\"Collect every media path already delivered in prior tool results.\n+\n+    Used to dedup auto-appended MEDIA tags so the same file is not re-sent on\n+    later turns. Must cover BOTH delivery shapes:\n+      * ``MEDIA:<path>`` text tags in tool results, and\n+      * ``image_generate`` JSON-payload paths (``host_image`` / ``image`` /\n+        ``agent_visible_image``), which carry no MEDIA: tag.\n+\n+    Missing the JSON-payload shape caused #46627: after a compression\n+    boundary the auto-append fallback rescans full history, re-discovers an\n+    earlier ``image_generate`` result whose path was never in the dedup set,\n+    and re-emits the MEDIA tag every turn.\n+    \"\"\"\n+    paths: set = set()\n+    tool_name_by_call_id: Dict[str, str] = {}\n+    for msg in agent_history:\n+        if msg.get(\"role\") == \"assistant\":\n+            for call in msg.get(\"tool_calls\") or []:\n+                cid = 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 cid and name:\n+                    tool_name_by_call_id[str(cid)] = name\n+    for msg in agent_history:\n+        if msg.get(\"role\") not in {\"tool\", \"function\"}:\n+            continue\n+        content = str(msg.get(\"content\", \"\") or \"\")\n+        if \"MEDIA:\" in content:\n+            for match in _TOOL_MEDIA_RE.finditer(content):\n+                p = match.group(1).strip().rstrip('\",}')\n+                if p:\n+                    paths.add(p)\n+            continue\n+        cid = str(msg.get(\"tool_call_id\") or msg.get(\"call_id\") or \"\")\n+        if tool_name_by_call_id.get(cid) == \"image_generate\":\n+            try:\n+                payload = json.loads(content)\n+            except Exception:\n+                payload = None\n+            if isinstance(payload, dict) and payload.get(\"success\"):\n+                for field in _JSON_MEDIA_TOOL_PATH_FIELDS:\n+                    jp = payload.get(field)\n+                    if isinstance(jp, str) and jp:\n+                        paths.add(jp)\n+                        break\n+    return paths\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.","@@ -15537,22 +15586,7 @@ def _clarify_callback_sync(question: str, choices) -> str:\n             # Collect MEDIA paths already in history so we can exclude them\n             # from the current turn's extraction. This is compression-safe:\n             # even if the message list shrinks, we know which paths are old.\n-            _history_media_paths: set = set()\n-            for _hm in agent_history:\n-                if _hm.get(\"role\") in {\"tool\", \"function\"}:\n-                    _hc = _hm.get(\"content\", \"\")\n-                    if \"MEDIA:\" in _hc:\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(_hc):\n-                            _p = _match.group(1).strip().rstrip('\",}')\n-                            if _p:\n-                                _history_media_paths.add(_p)\n+            _history_media_paths: set = _collect_history_media_paths(agent_history)\n             \n             # Register per-session gateway approval callback so dangerous\n             # command approval blocks the agent thread (mirrors CLI input())."]},{"file":"tests/gateway/test_media_extraction.py","hunks":["@@ -259,6 +259,69 @@ def test_gateway_auto_append_image_generate_dedupes_history(self):\n         )\n         assert tags == []\n \n+    def test_collect_history_media_paths_includes_image_generate_json(self):\n+        \"\"\"Regression for #46627: the history media-path collector must pick up\n+        image_generate JSON-payload paths (no MEDIA: tag), not just MEDIA:\n+        text tags. Otherwise, after a compression boundary the auto-append\n+        fallback rescans full history, finds the generated path absent from\n+        the dedup set, and re-emits the same MEDIA tag every turn.\n+        \"\"\"\n+        from gateway.run import _collect_history_media_paths\n+\n+        history = [\n+            {\"role\": \"user\", \"content\": \"make a cat\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"tool_calls\": [{\"id\": \"c\", \"function\": {\"name\": \"image_generate\"}}],\n+            },\n+            {\n+                \"role\": \"tool\",\n+                \"tool_call_id\": \"c\",\n+                \"content\": '{\"success\": true, \"image\": \"/tmp/gen/cat.png\"}',\n+            },\n+            # A separate MEDIA: text tag from another tool, to confirm both shapes.\n+            {\n+                \"role\": \"tool\",\n+                \"tool_call_id\": \"d\",\n+                \"content\": \"Saved MEDIA:/tmp/voice/note.ogg done\",\n+            },\n+        ]\n+        paths = _collect_history_media_paths(history)\n+        assert \"/tmp/gen/cat.png\" in paths  # JSON-payload path (the bug)\n+        assert \"/tmp/voice/note.ogg\" in paths  # MEDIA: text path (already worked)\n+\n+    def test_image_generate_not_reemitted_after_compression(self):\n+        \"\"\"End-to-end of the #46627 fix: collect history paths, then the\n+        compression-fallback rescan (history_offset stale) must dedup the\n+        generated image against them — no re-emission.\"\"\"\n+        from gateway.run import (\n+            _collect_auto_append_media_tags,\n+            _collect_history_media_paths,\n+        )\n+\n+        history = [\n+            {\n+                \"role\": \"assistant\",\n+                \"tool_calls\": [{\"id\": \"c\", \"function\": {\"name\": \"image_generate\"}}],\n+            },\n+            {\n+                \"role\": \"tool\",\n+                \"tool_call_id\": \"c\",\n+                \"content\": '{\"success\": true, \"image\": \"/tmp/gen/dog.png\"}',\n+            },\n+        ]\n+        history_paths = _collect_history_media_paths(history)\n+\n+        # Simulate the post-compression fallback: history_offset is stale\n+        # (larger than the shrunken message list), so the collector rescans\n+        # the full list. With the dedup set populated, the already-delivered\n+        # image must NOT be re-emitted.\n+        tags, _ = _collect_auto_append_media_tags(\n+            history, history_offset=9999, history_media_paths=history_paths\n+        )\n+        assert tags == [], f\"generated image re-emitted after compression: {tags}\"\n+\n+\n     def test_media_tags_not_extracted_from_history(self):\n         \"\"\"MEDIA tags from previous turns should NOT be extracted again.\"\"\"\n         # Simulate conversation history with a TTS call from a previous turn\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}