diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 78fb93c5348a..0aeb7d0e9b58 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -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)) diff --git a/gateway/run.py b/gateway/run.py index 07b272331b51..9e91d9083f2b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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:`` 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:`` text tags in tool results, + * ``MEDIA:`` 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