fix(gateway): skip MEDIA: tags inside code blocks and blockquotes

extract_media() scanned the full response text without distinguishing
live delivery tags from example paths in fenced code blocks, inline code
spans, and blockquotes. This caused false positives where the agent's
explanation of MEDIA: syntax (or tool output containing example paths)
was stripped from user-visible text and the path was added to the media
delivery list.

Added _mask_protected_spans() helper that replaces protected regions
with equal-length whitespace before regex matching, preserving match
offsets. The helper skips backtick-quoted paths in MEDIA: tags to
maintain existing path extraction behavior.

Fixes #35695
This commit is contained in:
liuhao1024
2026-06-01 00:00:26 -07:00
committed by kshitij
parent 521d06975e
commit 3ccf4fdc6d
2 changed files with 104 additions and 11 deletions
+60 -11
View File
@@ -2579,6 +2579,48 @@ class BasePlatformAdapter(ABC):
logger.warning("Skipping unsafe local file path: %s", _log_safe_path(raw))
return safe_paths
@staticmethod
def _mask_protected_spans(content: str) -> str:
"""Replace content inside fenced code blocks, inline code spans,
and blockquotes with spaces to prevent MEDIA: false positives.
Preserves character count so regex match offsets stay valid.
Skips masking backtick-quoted paths in MEDIA: tags (e.g.
``MEDIA:`/path/to/file.png` ``) to avoid breaking path extraction.
"""
chars = list(content)
n = len(chars)
# Build list of (start, end) spans to mask
spans: list = []
# Fenced code blocks: ```...```
for m in re.finditer(r'```[^\n]*\n.*?```', content, re.DOTALL):
spans.append((m.start(), m.end()))
# Inline code: `...` but NOT backtick-quoted paths in MEDIA: tags
for m in re.finditer(r'`[^`\n]+`', content):
start = m.start()
# Check if this is a backtick-quoted path after MEDIA:
prefix = content[max(0, start - 20):start]
if re.search(r'MEDIA:\s*$', prefix):
continue # This is a MEDIA path quote, not inline code
spans.append((start, m.end()))
# Blockquote lines: > at line start
for m in re.finditer(r'^>.*$', content, re.MULTILINE):
spans.append((m.start(), m.end()))
# Apply masking
for start, end in spans:
for i in range(start, end):
if chars[i] != '\n':
chars[i] = ' '
return ''.join(chars)
@staticmethod
def _mask_json_string_media(content: str) -> str:
"""Blank out ``MEDIA:<bare-path>`` occurrences that sit inside a JSON
@@ -2661,9 +2703,14 @@ 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
# 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)
# Mask example/stored MEDIA: paths before scanning so they are never
# delivered as real attachments:
# - code blocks / inline code / blockquotes hold prose examples (#35695)
# - serialized JSON string values hold stored tool-result text (#34375)
# Both maskers are offset-preserving (chars -> spaces) so match offsets
# stay valid; chaining them masks the union of both protected regions.
scan_content = BasePlatformAdapter._mask_protected_spans(content)
scan_content = BasePlatformAdapter._mask_json_string_media(scan_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 "`\"'":
@@ -2677,15 +2724,17 @@ class BasePlatformAdapter(ABC):
# and dropping every other attachment in the response.
continue
# 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.
# Remove the delivered MEDIA tags from the user-visible text. Mask a
# length-equal copy of ``cleaned`` (same union of protected regions) to
# *locate* the real tag spans, then delete exactly those spans from the
# *unmasked* ``cleaned``. Masking is only a locator — protected spans
# (code blocks, quotes, JSON-embedded MEDIA: text) must survive verbatim
# in the delivered text, not be blanked to whitespace. Masking
# ``cleaned`` (not ``content``) keeps offsets valid after the
# [[audio_as_voice]] / [[as_document]] directives are removed.
if media:
masked_cleaned = BasePlatformAdapter._mask_json_string_media(cleaned)
masked_cleaned = BasePlatformAdapter._mask_protected_spans(cleaned)
masked_cleaned = BasePlatformAdapter._mask_json_string_media(masked_cleaned)
spans = [m.span() for m in media_pattern.finditer(masked_cleaned)]
if spans:
chars = list(cleaned)