fix(gateway): deduplicate repeated media tags against prior assistant/tool output

The model sometimes echoes a previous MEDIA:path tag or bare file path in a later response. Previously both streaming delivery and non-streaming delivery would re-send those files, causing duplicate documents/images on later turns.

Changes:

- _collect_history_media_paths now also scans assistant messages for MEDIA: tags, not just tool results.

- _deliver_media_from_response accepts pre-computed history_media_paths and filters extracted media/local files.

- BasePlatformAdapter gains _history_media_paths_for_session for non-streaming dedup.
This commit is contained in:
dhruvkej9 2026-06-27 11:04:29 +00:00 committed by Teknium
parent 6fc571afe9
commit 83bba799e2
2 changed files with 80 additions and 9 deletions

View file

@ -3112,6 +3112,44 @@ class BasePlatformAdapter(ABC):
"""
self._session_store = session_store
def _history_media_paths_for_session(self, session_key: str) -> Optional[set]:
"""Return media paths already delivered in prior turns of this session.
Loads the persisted transcript, drops the most recent assistant entry
(which belongs to the current response), and scans the remaining history
for MEDIA: tags and image_generate JSON payloads. Used to prevent the
model from re-delivering the same file when it echoes an old MEDIA tag.
"""
store = getattr(self, "_session_store", None)
if not store:
return None
try:
# The transcript store is keyed by session_id, not the gateway
# session_key — map through the routing index first. Falling back
# to the raw key covers stores that accept either.
session_id = None
peek = getattr(store, "peek_session_id", None)
if callable(peek):
session_id = peek(session_key)
transcript = store.load_transcript(session_id or session_key)
except Exception:
return None
if not transcript:
return None
# Exclude the current turn's assistant message, which has already been
# persisted by the time we reach delivery but must not be treated as
# "history" for dedup purposes.
history = list(transcript)
for msg in reversed(history):
if msg.get("role") == "assistant":
history.remove(msg)
break
if not history:
return None
# Avoid circular import: gateway.run already imports this module.
from gateway.run import _collect_history_media_paths
return _collect_history_media_paths(history)
@abstractmethod
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""
@ -5270,6 +5308,18 @@ class BasePlatformAdapter(ABC):
media_files, response = self.extract_media(response)
media_files = self.filter_media_delivery_paths(media_files)
# Deduplicate against media already delivered in prior turns.
# The model may echo a previous MEDIA: tag or bare file path in
# a later response; without this guard the same file is sent
# repeatedly.
_history_media_paths = self._history_media_paths_for_session(session_key)
if _history_media_paths:
media_files = [
(path, is_voice)
for path, is_voice in media_files
if path not in _history_media_paths
]
# Extract image URLs and send them as native platform attachments
images, text_content = self.extract_images(response)
# Strip any remaining internal directives from message body (fixes #1561).
@ -5288,6 +5338,8 @@ class BasePlatformAdapter(ABC):
# instead of becoming native uploads.
local_files, text_content = self.extract_local_files(text_content)
local_files = self.filter_local_delivery_paths(local_files)
if _history_media_paths:
local_files = [p for p in local_files if p not in _history_media_paths]
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))

View file

@ -1436,18 +1436,17 @@ def _collect_auto_append_media_tags(
def _collect_history_media_paths(agent_history: List[Dict[str, Any]]) -> set:
"""Collect every media path already delivered in prior tool results.
"""Collect every media path already delivered in prior assistant/tool output.
Used to dedup auto-appended MEDIA tags so the same file is not re-sent on
later turns. Must cover BOTH delivery shapes:
* ``MEDIA:<path>`` text tags in tool results, and
Used to dedup auto-appended and model-emitted MEDIA tags so the same file
is not re-sent on later turns. Covers three delivery shapes:
* ``MEDIA:<path>`` text tags in tool results,
* ``MEDIA:<path>`` text tags in assistant messages (model-generated tags),
* ``image_generate`` JSON-payload paths (``host_image`` / ``image`` /
``agent_visible_image``), which carry no MEDIA: tag.
Missing the JSON-payload shape caused #46627: after a compression
boundary the auto-append fallback rescans full history, re-discovers an
earlier ``image_generate`` result whose path was never in the dedup set,
and re-emits the MEDIA tag every turn.
Missing the JSON-payload shape caused #46627; missing the assistant-message
shape caused repeated delivery when the model echoed a previous MEDIA tag.
"""
paths: set = set()
tool_name_by_call_id: Dict[str, str] = {}
@ -1460,7 +1459,16 @@ def _collect_history_media_paths(agent_history: List[Dict[str, Any]]) -> set:
if cid and name:
tool_name_by_call_id[str(cid)] = name
for msg in agent_history:
if msg.get("role") not in {"tool", "function"}:
role = msg.get("role")
if role == "assistant":
content = str(msg.get("content", "") or "")
if "MEDIA:" in content:
for match in _TOOL_MEDIA_RE.finditer(content):
p = match.group(1).strip().rstrip('",}')
if p:
paths.add(p)
continue
if role not in {"tool", "function"}:
continue
content = str(msg.get("content", "") or "")
if "MEDIA:" in content:
@ -14548,6 +14556,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if _media_adapter:
await self._deliver_media_from_response(
response, event, _media_adapter,
history_media_paths=_history_media_paths,
)
# Streaming already delivered the body text, but the footer was
# intentionally held back (see the `not already_sent` gate above).
@ -15616,6 +15625,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
response: str,
event: MessageEvent,
adapter,
history_media_paths: Optional[set] = None,
) -> None:
"""Extract explicit MEDIA: tags from a response and deliver them.
@ -15647,6 +15657,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
media_files, cleaned = adapter.extract_media(response)
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
# Deduplicate against media already delivered in prior turns —
# the model may echo a previous turn's MEDIA: tag in a later
# response; without this guard the same file is re-sent.
if history_media_paths:
media_files = [
(path, is_voice)
for path, is_voice in media_files
if path not in history_media_paths
]
# Strip image URLs from the cleaned text for parity with the
# non-streaming chain, but do NOT run extract_local_files here:
# post-stream delivery is explicit-only (#20834). Bare local paths