mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
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:
parent
6fc571afe9
commit
83bba799e2
2 changed files with 80 additions and 9 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue