mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
348 lines
14 KiB
Python
348 lines
14 KiB
Python
"""
|
|
Tests for MEDIA tag extraction from tool results.
|
|
|
|
Verifies that MEDIA tags (e.g., from TTS tool) are only extracted from
|
|
messages in the CURRENT turn, not from the full conversation history.
|
|
This prevents voice messages from accumulating and being sent multiple
|
|
times per reply. (Regression test for #160)
|
|
|
|
Also covers #34608: a stale MEDIA: path emitted by an execute_code /
|
|
make_image tool several turns earlier must not leak onto a later
|
|
text-only reply, even when the path-based dedup set fails to capture it.
|
|
"""
|
|
|
|
import re
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
def extract_media_tags_fixed(result_messages, history_len):
|
|
"""
|
|
Extract MEDIA tags from tool results, but ONLY from new messages
|
|
(those added after history_len). This is the fixed behavior.
|
|
|
|
Args:
|
|
result_messages: Full list of messages including history + new
|
|
history_len: Length of history before this turn
|
|
|
|
Returns:
|
|
Tuple of (media_tags list, has_voice_directive bool)
|
|
"""
|
|
media_tags = []
|
|
has_voice_directive = False
|
|
|
|
# Only process new messages from this turn
|
|
new_messages = result_messages[history_len:] if len(result_messages) > history_len else []
|
|
|
|
for msg in new_messages:
|
|
if msg.get("role") == "tool" or msg.get("role") == "function":
|
|
content = msg.get("content", "")
|
|
if "MEDIA:" in content:
|
|
for match in re.finditer(r'MEDIA:(\S+)', content):
|
|
path = match.group(1).strip().rstrip('",}')
|
|
if path:
|
|
media_tags.append(f"MEDIA:{path}")
|
|
if "[[audio_as_voice]]" in content:
|
|
has_voice_directive = True
|
|
|
|
return media_tags, has_voice_directive
|
|
|
|
|
|
def extract_media_tags_production(result_messages, history_len, history_media_paths):
|
|
"""Mirror of the production scan in gateway/run.py after the #34608 fix.
|
|
|
|
Primary guard: scope the scan to the current turn via ``history_len``
|
|
slicing (matching how ``agent_history`` is passed as
|
|
``conversation_history`` into ``run_conversation``). Secondary guard:
|
|
path-based dedup against ``history_media_paths`` (the #160 compression-safe
|
|
fallback, also used when compression shrinks the list below history_len).
|
|
"""
|
|
media_tags = []
|
|
has_voice_directive = False
|
|
|
|
if len(result_messages) >= history_len and history_len:
|
|
scan_msgs = result_messages[history_len:]
|
|
else:
|
|
scan_msgs = result_messages
|
|
|
|
for msg in scan_msgs:
|
|
if msg.get("role") == "tool" or msg.get("role") == "function":
|
|
content = msg.get("content", "")
|
|
if "MEDIA:" in content:
|
|
for match in re.finditer(r'MEDIA:(\S+)', content):
|
|
path = match.group(1).strip().rstrip('",}')
|
|
if path and path not in history_media_paths:
|
|
media_tags.append(f"MEDIA:{path}")
|
|
if "[[audio_as_voice]]" in content:
|
|
has_voice_directive = True
|
|
|
|
return media_tags, has_voice_directive
|
|
|
|
|
|
def extract_media_tags_broken(result_messages):
|
|
"""
|
|
The BROKEN behavior: extract MEDIA tags from ALL messages including history.
|
|
This causes TTS voice messages to accumulate and be re-sent on every reply.
|
|
"""
|
|
media_tags = []
|
|
has_voice_directive = False
|
|
|
|
for msg in result_messages:
|
|
if msg.get("role") == "tool" or msg.get("role") == "function":
|
|
content = msg.get("content", "")
|
|
if "MEDIA:" in content:
|
|
for match in re.finditer(r'MEDIA:(\S+)', content):
|
|
path = match.group(1).strip().rstrip('",}')
|
|
if path:
|
|
media_tags.append(f"MEDIA:{path}")
|
|
if "[[audio_as_voice]]" in content:
|
|
has_voice_directive = True
|
|
|
|
return media_tags, has_voice_directive
|
|
|
|
|
|
class TestMediaExtraction:
|
|
"""Tests for MEDIA tag extraction from tool results."""
|
|
|
|
def test_gateway_auto_append_ignores_media_examples_in_skill_docs(self):
|
|
"""Skill/documentation examples must not be appended as real attachments."""
|
|
from gateway.run import _collect_auto_append_media_tags
|
|
|
|
messages = [
|
|
{"role": "user", "content": "How should I format gateway media?"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{"id": "call_skill", "function": {"name": "skill_view"}}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_skill",
|
|
"content": """
|
|
Recommended pattern:
|
|
```text
|
|
MEDIA:/absolute/path/to/image.png
|
|
```
|
|
Second message:
|
|
```text
|
|
caption
|
|
```
|
|
""",
|
|
},
|
|
{"role": "assistant", "content": "Use a standalone media message."},
|
|
]
|
|
|
|
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
|
|
assert tags == []
|
|
assert voice is False
|
|
|
|
|
|
def test_collect_history_media_paths_includes_image_generate_json(self):
|
|
"""Regression for #46627: the history media-path collector must pick up
|
|
image_generate JSON-payload paths (no MEDIA: tag), not just MEDIA:
|
|
text tags. Otherwise, after a compression boundary the auto-append
|
|
fallback rescans full history, finds the generated path absent from
|
|
the dedup set, and re-emits the same MEDIA tag every turn.
|
|
"""
|
|
from gateway.run import _collect_history_media_paths
|
|
|
|
history = [
|
|
{"role": "user", "content": "make a cat"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c", "function": {"name": "image_generate"}}],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "c",
|
|
"content": '{"success": true, "image": "/tmp/gen/cat.png"}',
|
|
},
|
|
# A separate MEDIA: text tag from another tool, to confirm both shapes.
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "d",
|
|
"content": "Saved MEDIA:/tmp/voice/note.ogg done",
|
|
},
|
|
]
|
|
paths = _collect_history_media_paths(history)
|
|
assert "/tmp/gen/cat.png" in paths # JSON-payload path (the bug)
|
|
assert "/tmp/voice/note.ogg" in paths # MEDIA: text path (already worked)
|
|
|
|
def test_non_streaming_dedup_excludes_current_turn_tool_output(self):
|
|
from gateway.platforms.base import BasePlatformAdapter
|
|
|
|
old_path = "/tmp/gen/old.png"
|
|
current_path = "/tmp/gen/current.png"
|
|
transcript = [
|
|
{"role": "user", "content": "make the old image"},
|
|
{"role": "assistant", "content": f"MEDIA:{old_path}"},
|
|
{"role": "user", "content": "make a new image"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "current", "function": {"name": "image_generate"}}],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "current",
|
|
"content": f'{{"success": true, "image": "{current_path}"}}',
|
|
},
|
|
{"role": "assistant", "content": f"MEDIA:{current_path}"},
|
|
]
|
|
adapter = MagicMock()
|
|
adapter._session_store.peek_session_id.return_value = "session-id"
|
|
adapter._session_store.load_transcript.return_value = transcript
|
|
|
|
paths = BasePlatformAdapter._history_media_paths_for_session(
|
|
adapter, "session-key"
|
|
)
|
|
assert paths == {old_path}
|
|
|
|
def test_image_generate_not_reemitted_after_compression(self):
|
|
"""End-to-end of the #46627 fix: collect history paths, then the
|
|
compression-fallback rescan (history_offset stale) must dedup the
|
|
generated image against them — no re-emission."""
|
|
from gateway.run import (
|
|
_collect_auto_append_media_tags,
|
|
_collect_history_media_paths,
|
|
)
|
|
|
|
history = [
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "c", "function": {"name": "image_generate"}}],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "c",
|
|
"content": '{"success": true, "image": "/tmp/gen/dog.png"}',
|
|
},
|
|
]
|
|
history_paths = _collect_history_media_paths(history)
|
|
|
|
# Simulate the post-compression fallback: history_offset is stale
|
|
# (larger than the shrunken message list), so the collector rescans
|
|
# the full list. With the dedup set populated, the already-delivered
|
|
# image must NOT be re-emitted.
|
|
tags, _ = _collect_auto_append_media_tags(
|
|
history, history_offset=9999, history_media_paths=history_paths
|
|
)
|
|
assert tags == [], f"generated image re-emitted after compression: {tags}"
|
|
|
|
|
|
def test_media_tags_not_extracted_from_history(self):
|
|
"""MEDIA tags from previous turns should NOT be extracted again."""
|
|
# Simulate conversation history with a TTS call from a previous turn
|
|
history = [
|
|
{"role": "user", "content": "Say hello as audio"},
|
|
{"role": "assistant", "content": None, "tool_calls": [{"id": "1", "function": {"name": "text_to_speech"}}]},
|
|
{"role": "tool", "tool_call_id": "1", "content": '{"success": true, "media_tag": "[[audio_as_voice]]\\nMEDIA:/path/to/audio1.ogg"}'},
|
|
{"role": "assistant", "content": "I've said hello for you!"},
|
|
]
|
|
|
|
# New turn: user asks a simple question
|
|
new_messages = [
|
|
{"role": "user", "content": "What time is it?"},
|
|
{"role": "assistant", "content": "It's 3:30 AM."},
|
|
]
|
|
|
|
all_messages = history + new_messages
|
|
history_len = len(history)
|
|
|
|
# Fixed behavior: should extract NO media tags (none in new messages)
|
|
tags, voice_directive = extract_media_tags_fixed(all_messages, history_len)
|
|
assert tags == [], "Fixed extraction should not find tags in history"
|
|
assert voice_directive is False
|
|
|
|
# Broken behavior: would incorrectly extract the old media tag
|
|
broken_tags, broken_voice = extract_media_tags_broken(all_messages)
|
|
assert len(broken_tags) == 1, "Broken extraction finds tags in history"
|
|
assert "audio1.ogg" in broken_tags[0]
|
|
|
|
|
|
|
|
|
|
|
|
class TestStaleToolMediaLeak:
|
|
"""Regression tests for #34608.
|
|
|
|
A MEDIA: path emitted by an execute_code / make_image tool several turns
|
|
earlier remains in the full conversation message list. A later text-only
|
|
reply (zero MEDIA directives) must NOT attach that stale image.
|
|
|
|
The production code previously relied solely on path-based dedup against
|
|
paths reconstructed from the replayable transcript. When that
|
|
reconstruction does not byte-match the in-memory tool content (timestamp
|
|
stripping, observed-context withholding, compression rewrites), the stale
|
|
path is absent from the dedup set and leaks. Turn-scoped slicing closes
|
|
this class of bug deterministically.
|
|
"""
|
|
|
|
def test_stale_execute_code_media_not_attached_to_text_only_reply(self):
|
|
"""The exact #34608 scenario: make_image cover from an earlier turn."""
|
|
# Prior turn generated an image via execute_code stdout.
|
|
history = [
|
|
{"role": "user", "content": "Make a cover image"},
|
|
{"role": "assistant", "content": None,
|
|
"tool_calls": [{"id": "1", "function": {"name": "execute_code"}}]},
|
|
{"role": "tool", "tool_call_id": "1",
|
|
"content": "Generating cover...\nMEDIA:/tmp/seosmi_cover.png\nDone."},
|
|
{"role": "assistant", "content": "Here is your cover."},
|
|
]
|
|
# Current turn: plain text status update, zero MEDIA directives.
|
|
new_messages = [
|
|
{"role": "user", "content": "What skill version am I on?"},
|
|
{"role": "assistant", "content": "You're on v0.15.1."},
|
|
]
|
|
all_messages = history + new_messages
|
|
history_len = len(history)
|
|
|
|
# Simulate the dedup set FAILING to capture the stale path (the real
|
|
# #34608 condition: replayable-history reconstruction diverged from
|
|
# the in-memory tool content, so the path is not in the set).
|
|
history_media_paths = set()
|
|
|
|
tags, voice = extract_media_tags_production(
|
|
all_messages, history_len, history_media_paths
|
|
)
|
|
assert tags == [], (
|
|
"Stale tool MEDIA from a prior turn must not leak onto a "
|
|
f"later text-only reply, got {tags}"
|
|
)
|
|
assert voice is False
|
|
|
|
# The pre-fix production behaviour (scan everything, dedup only) would
|
|
# have leaked the stale path when the dedup set missed it.
|
|
broken_tags, _ = extract_media_tags_broken(all_messages)
|
|
assert any("seosmi_cover.png" in t for t in broken_tags), (
|
|
"Sanity: the unscoped scan does surface the stale path"
|
|
)
|
|
|
|
|
|
def test_compression_shrink_falls_back_to_path_dedup(self):
|
|
"""When the list is shorter than history_len (mid-run compression),
|
|
fall back to scanning everything with path-based dedup so the #160
|
|
compression-safe guarantee is preserved."""
|
|
# Post-compression list is shorter than the original history length.
|
|
compressed_messages = [
|
|
{"role": "user", "content": "summary so far..."},
|
|
{"role": "tool", "tool_call_id": "7",
|
|
"content": "MEDIA:/tmp/old_from_history.png"},
|
|
{"role": "assistant", "content": "ok"},
|
|
]
|
|
original_history_len = 12 # larger than the compressed list
|
|
# The old path IS captured in the dedup set here (history scan ran
|
|
# before compression), so it must still be excluded.
|
|
history_media_paths = {"/tmp/old_from_history.png"}
|
|
tags, _ = extract_media_tags_production(
|
|
compressed_messages, original_history_len, history_media_paths
|
|
)
|
|
assert tags == [], (
|
|
"On the compression fallback path, path-dedup must still exclude "
|
|
f"known-old media, got {tags}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|