NousResearch/hermes-agent

Gateway MEDIA: extraction attaching files from serialized tool results

Cluster 34375 · gateway. Data as of

Nothing open on GitHub. Fix merged in #36283 on 1 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #36283. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

The members concern the gateway extracting MEDIA: references from serialized tool or search-result text, which can attach stale files. Two members are pull requests that propose different approaches: skipping MEDIA: inside serialized JSON string values, and requiring a line-start boundary.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #36283 fix(gateway): skip MEDIA: inside serialized JSON string values (#34388)

    merged to main

    files: gateway/platforms/base.py, tests/gateway/test_platform_base.py

    Show the diff
    • gateway/platforms/base.py
      @@ -2579,6 +2579,46 @@ def filter_local_delivery_paths(file_paths) -> List[str]:
                       logger.warning("Skipping unsafe local file path: %s", _log_safe_path(raw))
               return safe_paths
       
      +    @staticmethod
      +    def _mask_json_string_media(content: str) -> str:
      +        """Blank out ``MEDIA:<bare-path>`` occurrences that sit inside a JSON
      +        string *value* so they are never delivered as real attachments.
      +
      +        Serialized tool results frequently embed a previous reply's text, e.g.::
      +
      +            {"result": "MEDIA:/Users/x/.hermes/media/generated/stale.png"}
      +
      +        Here the ``MEDIA:`` is part of stored text, not an outbound directive,
      +        but the bare-path branch of ``MEDIA_TAG_CLEANUP_RE`` would still match it
      +        and re-deliver a stale file. (Regression report #34375.)
      +
      +        The discriminator is precise so legitimate tags are untouched:
      +
      +        * Only spans opened by a JSON value-context quote (``:``, ``,``, ``{`` or
      +          ``[`` immediately before the ``"``) are considered.
      +        * Within such a span, only a ``MEDIA:`` followed by a **bare** path
      +          (``/``, ``~/`` or ``X:\\``) is masked. A ``MEDIA:"..."`` quoted-path
      +          tag — a real LLM output format the extractor supports — is not bare and
      +          is left alone.
      +        * Tags at line start, after prose whitespace, or indented are outside any
      +          JSON value span and are never affected.
      +
      +        Offsets are preserved (matched chars replaced with spaces, newlines kept)
      +        so downstream match positions stay valid.
      +        """
      +        if '"' not in content or "MEDIA:" not in content:
      +            return content
      +        chars = list(content)
      +        # JSON value-context string: a quote preceded by : , { or [ (optional ws),
      +        # capturing the (escape-aware) string body up to the closing quote.
      +        for m in re.finditer(r'(?<=[:,{\[])\s*"((?:[^"\\\n]|\\.)*)"', content):
      +            seg = m.group(1)
      +            if re.search(r'MEDIA:\s*(?:~/|/|[A-Za-z]:[/\\])', seg):
      +                for i in range(m.start(1), m.end(1)):
      +                    if chars[i] != '\n':
      +                        chars[i] = ' '
      +        return ''.join(chars)
      +
           @staticmethod
           def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
               """
      @@ -2621,7 +2661,10 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
               # set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
               # MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
               media_pattern = MEDIA_TAG_CLEANUP_RE
      -        for match in media_pattern.finditer(content):
      +        # Mask MEDIA: embedded inside serialized JSON string values so stale
      +        # paths from stored tool results are never re-delivered (#34375).
      +        scan_content = BasePlatformAdapter._mask_json_string_media(content)
      +        for match in media_pattern.finditer(scan_content):
                   path = match.group("path").strip()
                   if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
                       path = path[1:-1].strip()
      @@ -2634,10 +2677,22 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
                           # and dropping every other attachment in the response.
                           continue
       
      -        # Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
      +        # Remove the delivered MEDIA tags from the user-visible text. Mask
      +        # ``cleaned`` (same length, so offsets line up with it), find the real
      +        # tag spans there, and delete those spans from the *unmasked* ``cleaned``.
      +        # This strips real tags while leaving JSON-embedded MEDIA: text intact —
      +        # it is stored data, not a directive, and must read back verbatim
      +        # (#34375). Masking ``cleaned`` (not ``content``) keeps offsets valid
      +        # after the [[audio_as_voice]] / [[as_document]] directives are removed.
               if media:
      -            cleaned = media_pattern.sub('', cleaned)
      -            cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
      +            masked_cleaned = BasePlatformAdapter._mask_json_string_media(cleaned)
      +            spans = [m.span() for m in media_pattern.finditer(masked_cleaned)]
      +            if spans:
      +                chars = list(cleaned)
      +                for start, end in sorted(spans, reverse=True):
      +                    del chars[start:end]
      +                cleaned = "".join(chars)
      +                cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
               
               return media, cleaned
       
    • tests/gateway/test_platform_base.py
      @@ -401,6 +401,93 @@ def test_relative_path_still_ignored(self):
               assert media == []
       
       
      +class TestMediaInsideSerializedJson:
      +    """Regression coverage for #34375 — MEDIA: embedded in serialized JSON
      +    string values (e.g. a stored previous reply inside a tool result) must not
      +    be re-delivered as a real attachment, while legitimate MEDIA: tags in prose,
      +    at line start, indented, or as quoted-path tags keep working.
      +    """
      +
      +    def test_media_in_json_value_not_extracted(self):
      +        content = '{"result": "MEDIA:/tmp/stale.png"}'
      +        media, _ = BasePlatformAdapter.extract_media(content)
      +        assert media == [], f"JSON value MEDIA: leaked: {media}"
      +
      +    def test_media_in_pretty_json_value_not_extracted(self):
      +        content = '{\n  "tool_result": "MEDIA:/var/old.jpg"\n}'
      +        media, _ = BasePlatformAdapter.extract_media(content)
      +        assert media == [], f"pretty JSON MEDIA: leaked: {media}"
      +
      +    def test_media_in_json_array_not_extracted(self):
      +        content = '["MEDIA:/a/b.png", "other"]'
      +        media, _ = BasePlatformAdapter.extract_media(content)
      +        assert media == [], f"JSON array MEDIA: leaked: {media}"
      +
      +    def test_media_in_nested_json_value_not_extracted(self):
      +        content = '{"a":{"b":"see MEDIA:/x/y.pdf here"}}'
      +        media, _ = BasePlatformAdapter.extract_media(content)
      +        assert media == [], f"nested JSON MEDIA: leaked: {media}"
      +
      +    def test_media_in_embedded_serialized_reply_not_extracted(self):
      +        """A serialized tool result that embeds a prior reply's MEDIA: tag."""
      +        content = (
      +            '{"content":"previous reply MEDIA:/Users/ex/.hermes/media/'
      +            'generated/stale.png and more text"}'
      +        )
      +        media, _ = BasePlatformAdapter.extract_media(content)
      +        assert media == [], f"embedded serialized reply leaked: {media}"
      +
      +    # --- Legitimate tags must still extract (no regression vs line-start anchor) ---
      +
      +    def test_media_at_line_start_still_extracted(self):
      +        media, _ = BasePlatformAdapter.extract_media("MEDIA:/real/file.png")
      +        assert len(media) == 1 and media[0][0] == "/real/file.png"
      +
      +    def test_media_after_prose_same_line_still_extracted(self):
      +        media, _ = BasePlatformAdapter.extract_media(
      +            "Here is your file: MEDIA:/out/report.pdf"
      +        )
      +        assert len(media) == 1 and media[0][0] == "/out/report.pdf"
      +
      +    def test_media_indented_still_extracted(self):
      +        media, _ = BasePlatformAdapter.extract_media("  MEDIA:/tmp/x.png")
      +        assert len(media) == 1 and media[0][0] == "/tmp/x.png"
      +
      +    def test_quoted_path_media_still_extracted(self):
      +        """MEDIA:"..." quoted-path form (a real LLM output) is not JSON-masked."""
      +        media, _ = BasePlatformAdapter.extract_media(
      +            'MEDIA:"/path/with space/file.png"'
      +        )
      +        assert len(media) == 1 and media[0][0] == "/path/with space/file.png"
      +
      +    def test_tts_two_line_still_extracted(self):
      +        media, _ = BasePlatformAdapter.extract_media(
      +            "[[audio_as_voice]]\nMEDIA:/tmp/v.ogg"
      +        )
      +        assert len(media) == 1 and media[0][0] == "/tmp/v.ogg"
      +        assert media[0][1] is True  # voice flag
      +
      +    # --- cleaned-text invariants: real tags stripped, JSON data kept verbatim ---
      +
      +    def test_json_embedded_media_kept_verbatim_in_cleaned_text(self):
      +        """A real tag is delivered+stripped; a JSON-embedded MEDIA: stays as
      +        literal text (stored data must read back unchanged)."""
      +        content = 'MEDIA:/real/r.png\nlog: {"old":"MEDIA:/stale/s.png"}'
      +        media, cleaned = BasePlatformAdapter.extract_media(content)
      +        assert [p for p, _ in media] == ["/real/r.png"]
      +        # The JSON-embedded path must survive verbatim — not blanked to spaces.
      +        assert '{"old":"MEDIA:/stale/s.png"}' in cleaned
      +
      +    def test_cleaned_text_after_directive_not_truncated(self):
      +        """Stripping a tag preceded by a [[as_document]] directive must not
      +        shift offsets and chop the path or trailing text."""
      +        content = "See [[as_document]] MEDIA:/d/report.pdf now"
      +        media, cleaned = BasePlatformAdapter.extract_media(content)
      +        assert [p for p, _ in media] == ["/d/report.pdf"]
      +        assert "MEDIA:" not in cleaned          # real tag removed
      +        assert cleaned.endswith("now")          # trailing text intact (not chopped)
      +
      +
       class TestMediaExtensionAllowlistParity:
           """Regression coverage for issue #34517 — the MEDIA: extension black hole.
       

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #34388 fix(gateway): require line-start boundary for MEDIA: extraction to prevent stale attachments from serialized tool results · superseded by another PR → #36283, #34375
Closed issues · 1
  • Closed#34375Gateway MEDIA extraction can attach stale files from serialized tool/search-result text
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#36283
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
1 Jun 2026

For your agent

The same facts, typed.