{"repo":"NousResearch/hermes-agent","public_id":"34608","display_label":"Scope MEDIA tag scanning to current turn only","summary":"The members concern MEDIA tags from earlier tool output in session history appearing in later text-only responses. One is a bug report and two are gateway changes titled to scope the tool-result MEDIA scan to the current turn; their status is not stated in the titles.","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":34833,"merged_at":"2026-05-29T20:13:35+00:00"},"resolution":{"state":"resolved","by":"pr-34833"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T20:13:35+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":34608,"pr_number":34833,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T20:13:35+00:00"}],"members":[{"kind":"issue","number":34608,"title":"[Bug]: MEDIA tag from stale tool output in session history leaks into later text-only responses","url":null,"state":"closed"},{"kind":"pr","number":34613,"title":"fix(gateway): scope tool-result MEDIA scan to current turn","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"08c0b2241","number":0},{"kind":"pr","value":"#34833","number":34833},{"kind":"pr","value":"#160","number":160}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34833,"title":"fix(gateway): scope tool-result MEDIA scan to current turn (#34608)","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":["@@ -17478,13 +17478,33 @@ def _approval_notify_sync(approval_data: dict) -> None:\n             # append any that aren't already present in the final response, so the\n             # adapter's extract_media() can find and deliver the files exactly once.\n             #\n-            # Uses path-based deduplication against _history_media_paths (collected\n-            # before run_conversation) instead of index slicing. This is safe even\n-            # when context compression shrinks the message list. (Fixes #160)\n+            # Scope the scan to THIS turn's tool results only. ``agent_history``\n+            # was passed into run_conversation as ``conversation_history``, so the\n+            # agent's returned ``messages`` list is ``agent_history`` followed by\n+            # the messages produced this turn. Slicing at ``len(agent_history)``\n+            # isolates the current turn precisely, so a stale MEDIA: path emitted\n+            # by a tool several turns earlier (still present in the full message\n+            # list) can never leak onto a later text-only reply. (Fixes #34608)\n+            #\n+            # Path-based deduplication against _history_media_paths (collected\n+            # before run_conversation) is retained as a secondary guard. It is\n+            # also the sole guard on the fallback branch taken when mid-run\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-                for msg in result.get(\"messages\", []):\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:"]},{"file":"tests/gateway/test_media_extraction.py","hunks":["@@ -5,6 +5,10 @@\n messages in the CURRENT turn, not from the full conversation history.\n This prevents voice messages from accumulating and being sent multiple\n times per reply. (Regression test for #160)\n+\n+Also covers #34608: a stale MEDIA: path emitted by an execute_code /\n+make_image tool several turns earlier must not leak onto a later\n+text-only reply, even when the path-based dedup set fails to capture it.\n \"\"\"\n \n import pytest","@@ -43,6 +47,37 @@ def extract_media_tags_fixed(result_messages, history_len):\n     return media_tags, has_voice_directive\n \n \n+def extract_media_tags_production(result_messages, history_len, history_media_paths):\n+    \"\"\"Mirror of the production scan in gateway/run.py after the #34608 fix.\n+\n+    Primary guard: scope the scan to the current turn via ``history_len``\n+    slicing (matching how ``agent_history`` is passed as\n+    ``conversation_history`` into ``run_conversation``). Secondary guard:\n+    path-based dedup against ``history_media_paths`` (the #160 compression-safe\n+    fallback, also used when compression shrinks the list below history_len).\n+    \"\"\"\n+    media_tags = []\n+    has_voice_directive = False\n+\n+    if len(result_messages) >= history_len and history_len:\n+        scan_msgs = result_messages[history_len:]\n+    else:\n+        scan_msgs = result_messages\n+\n+    for msg in scan_msgs:\n+        if msg.get(\"role\") == \"tool\" or msg.get(\"role\") == \"function\":\n+            content = msg.get(\"content\", \"\")\n+            if \"MEDIA:\" in content:\n+                for match in re.finditer(r'MEDIA:(\\S+)', 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 def extract_media_tags_broken(result_messages):\n     \"\"\"\n     The BROKEN behavior: extract MEDIA tags from ALL messages including history.","@@ -180,5 +215,104 @@ def test_deduplication_within_current_turn(self):\n         assert len(unique) == 2  # After dedup: same.ogg and different.ogg\n \n \n+class TestStaleToolMediaLeak:\n+    \"\"\"Regression tests for #34608.\n+\n+    A MEDIA: path emitted by an execute_code / make_image tool several turns\n+    earlier remains in the full conversation message list. A later text-only\n+    reply (zero MEDIA directives) must NOT attach that stale image.\n+\n+    The production code previously relied solely on path-based dedup against\n+    paths reconstructed from the replayable transcript. When that\n+    reconstruction does not byte-match the in-memory tool content (timestamp\n+    stripping, observed-context withholding, compression rewrites), the stale\n+    path is absent from the dedup set and leaks. Turn-scoped slicing closes\n+    this class of bug deterministically.\n+    \"\"\"\n+\n+    def test_stale_execute_code_media_not_attached_to_text_only_reply(self):\n+        \"\"\"The exact #34608 scenario: make_image cover from an earlier turn.\"\"\"\n+        # Prior turn generated an image via execute_code stdout.\n+        history = [\n+            {\"role\": \"user\", \"content\": \"Make a cover image\"},\n+            {\"role\": \"assistant\", \"content\": None,\n+             \"tool_calls\": [{\"id\": \"1\", \"function\": {\"name\": \"execute_code\"}}]},\n+            {\"role\": \"tool\", \"tool_call_id\": \"1\",\n+             \"content\": \"Generating cover...\\nMEDIA:/tmp/seosmi_cover.png\\nDone.\"},\n+            {\"role\": \"assistant\", \"content\": \"Here is your cover.\"},\n+        ]\n+        # Current turn: plain text status update, zero MEDIA directives.\n+        new_messages = [\n+            {\"role\": \"user\", \"content\": \"What skill version am I on?\"},\n+            {\"role\": \"assistant\", \"content\": \"You're on v0.15.1.\"},\n+        ]\n+        all_messages = history + new_messages\n+        history_len = len(history)\n+\n+        # Simulate the dedup set FAILING to capture the stale path (the real\n+        # #34608 condition: replayable-history reconstruction diverged from\n+        # the in-memory tool content, so the path is not in the set).\n+        history_media_paths = set()\n+\n+        tags, voice = extract_media_tags_production(\n+            all_messages, history_len, history_media_paths\n+        )\n+        assert tags == [], (\n+            \"Stale tool MEDIA from a prior turn must not leak onto a \"\n+            f\"later text-only reply, got {tags}\"\n+        )\n+        assert voice is False\n+\n+        # The pre-fix production behaviour (scan everything, dedup only) would\n+        # have leaked the stale path when the dedup set missed it.\n+        broken_tags, _ = extract_media_tags_broken(all_messages)\n+        assert any(\"seosmi_cover.png\" in t for t in broken_tags), (\n+            \"Sanity: the unscoped scan does surface the stale path\"\n+        )\n+\n+    def test_current_turn_media_still_attached_when_dedup_set_empty(self):\n+        \"\"\"Turn-scoping must not suppress genuinely new media.\"\"\"\n+        history = [\n+            {\"role\": \"user\", \"content\": \"hi\"},\n+            {\"role\": \"assistant\", \"content\": \"hello\"},\n+        ]\n+        new_messages = [\n+            {\"role\": \"user\", \"content\": \"Make me a cover image\"},\n+            {\"role\": \"assistant\", \"content\": None,\n+             \"tool_calls\": [{\"id\": \"9\", \"function\": {\"name\": \"execute_code\"}}]},\n+            {\"role\": \"tool\", \"tool_call_id\": \"9\",\n+             \"content\": \"MEDIA:/tmp/fresh_cover.png\"},\n+            {\"role\": \"assistant\", \"content\": \"Here it is.\"},\n+        ]\n+        all_messages = history + new_messages\n+        tags, _ = extract_media_tags_production(\n+            all_messages, len(history), set()\n+        )\n+        assert len(tags) == 1 and \"fresh_cover.png\" in tags[0]\n+\n+    def test_compression_shrink_falls_back_to_path_dedup(self):\n+        \"\"\"When the list is shorter than history_len (mid-run compression),\n+        fall back to scanning everything with path-based dedup so the #160\n+        compression-safe guarantee is preserved.\"\"\"\n+        # Post-compression list is shorter than the original history length.\n+        compressed_messages = [\n+            {\"role\": \"user\", \"content\": \"summary so far...\"},\n+            {\"role\": \"tool\", \"tool_call_id\": \"7\",\n+             \"content\": \"MEDIA:/tmp/old_from_history.png\"},\n+            {\"role\": \"assistant\", \"content\": \"ok\"},\n+        ]\n+        original_history_len = 12  # larger than the compressed list\n+        # The old path IS captured in the dedup set here (history scan ran\n+        # before compression), so it must still be excluded.\n+        history_media_paths = {\"/tmp/old_from_history.png\"}\n+        tags, _ = extract_media_tags_production(\n+            compressed_messages, original_history_len, history_media_paths\n+        )\n+        assert tags == [], (\n+            \"On the compression fallback path, path-dedup must still exclude \"\n+            f\"known-old media, got {tags}\"\n+        )\n+\n+\n if __name__ == \"__main__\":\n     pytest.main([__file__, \"-v\"])\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}