fix(gateway): skip MEDIA: inside serialized JSON string values

Serialized tool results frequently embed a prior reply's text, e.g.
{"result": "MEDIA:/path/stale.png"}. The bare-path branch of
MEDIA_TAG_CLEANUP_RE matched these and re-delivered stale files (#34375).

Adds BasePlatformAdapter._mask_json_string_media, which blanks (offset-
preserving) only MEDIA:<bare-path> tokens that sit inside a JSON value-
context string (opened by : , { or [). Legitimate tags at line start,
after prose, indented, MEDIA:"quoted" form, and two-line TTS output are
all left untouched.

Reworked from the approach in #34388 (a line-start regex anchor), which
no longer applied to current main and regressed same-line/indented tags.

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
This commit is contained in:
liuhao1024
2026-05-31 23:51:42 -07:00
committed by kshitij
co-authored by kshitijk4poor
parent b3aaf2676b
commit e8827ef704
2 changed files with 114 additions and 2 deletions
+47 -2
View File
@@ -2579,6 +2579,46 @@ class BasePlatformAdapter(ABC):
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 @@ class BasePlatformAdapter(ABC):
# 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()
@@ -2636,7 +2679,9 @@ class BasePlatformAdapter(ABC):
# Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
if media:
cleaned = media_pattern.sub('', cleaned)
# Mask JSON-embedded tags before sub so they stay intact in the
# user-visible text (they are stored data, not directives).
cleaned = media_pattern.sub('', BasePlatformAdapter._mask_json_string_media(cleaned))
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
return media, cleaned