mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 15:31:38 +08:00
Compare commits
1 Commits
fix/plugin
...
fix/anthro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0cc6a14d7 |
@@ -1022,6 +1022,18 @@ def normalize_anthropic_response(
|
||||
}
|
||||
finish_reason = stop_reason_map.get(response.stop_reason, "stop")
|
||||
|
||||
# Handle Anthropic 'sensitive' stop_reason (content policy filtering).
|
||||
# When the model's response is blocked, content blocks may be empty and
|
||||
# stop_reason is 'sensitive'. Surface a clear user-facing message instead
|
||||
# of crashing or silently returning None content.
|
||||
if getattr(response, "stop_reason", None) == "sensitive":
|
||||
finish_reason = "content_filter"
|
||||
if not text_parts:
|
||||
text_parts = [
|
||||
"I'm sorry, but my response was filtered by Anthropic's content policy. "
|
||||
"Please try rephrasing your request."
|
||||
]
|
||||
|
||||
return (
|
||||
SimpleNamespace(
|
||||
content="\n".join(text_parts) if text_parts else None,
|
||||
|
||||
@@ -301,6 +301,7 @@ async function startSocket() {
|
||||
senderName: msg.pushName || senderNumber,
|
||||
chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber),
|
||||
isGroup,
|
||||
fromMe: !!msg.key.fromMe,
|
||||
body,
|
||||
hasMedia,
|
||||
mediaType,
|
||||
|
||||
123
tests/agent/test_anthropic_sensitive.py
Normal file
123
tests/agent/test_anthropic_sensitive.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Tests for Anthropic 'sensitive' stop_reason handling.
|
||||
|
||||
When Anthropic returns stop_reason='sensitive' (content policy filtering),
|
||||
the agent should:
|
||||
1. Not treat it as an invalid/empty response that triggers retries
|
||||
2. Map it to finish_reason='content_filter'
|
||||
3. Inject a user-facing message when content blocks are empty
|
||||
4. Continue the conversation gracefully
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestNormalizeAnthropicSensitive:
|
||||
"""Tests for normalize_anthropic_response with stop_reason='sensitive'."""
|
||||
|
||||
def test_sensitive_empty_content_injects_message(self):
|
||||
"""When stop_reason='sensitive' and content is empty, inject a user-facing message."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
content=[],
|
||||
stop_reason="sensitive",
|
||||
)
|
||||
|
||||
assistant_msg, finish_reason = normalize_anthropic_response(response)
|
||||
|
||||
assert finish_reason == "content_filter"
|
||||
assert assistant_msg.content is not None
|
||||
assert "filtered" in assistant_msg.content.lower()
|
||||
assert "content policy" in assistant_msg.content.lower()
|
||||
assert assistant_msg.tool_calls is None
|
||||
|
||||
def test_sensitive_with_partial_text_preserves_content(self):
|
||||
"""When stop_reason='sensitive' but some text was returned, preserve it."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(type="text", text="Here is a partial answer..."),
|
||||
],
|
||||
stop_reason="sensitive",
|
||||
)
|
||||
|
||||
assistant_msg, finish_reason = normalize_anthropic_response(response)
|
||||
|
||||
assert finish_reason == "content_filter"
|
||||
# The partial text should be preserved, not replaced
|
||||
assert assistant_msg.content == "Here is a partial answer..."
|
||||
|
||||
def test_normal_end_turn_unchanged(self):
|
||||
"""Normal end_turn responses are unaffected by the sensitive handling."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(type="text", text="Hello, world!"),
|
||||
],
|
||||
stop_reason="end_turn",
|
||||
)
|
||||
|
||||
assistant_msg, finish_reason = normalize_anthropic_response(response)
|
||||
|
||||
assert finish_reason == "stop"
|
||||
assert assistant_msg.content == "Hello, world!"
|
||||
|
||||
def test_sensitive_with_thinking_blocks(self):
|
||||
"""Sensitive response with thinking blocks still works."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(type="thinking", thinking="Let me think about this..."),
|
||||
],
|
||||
stop_reason="sensitive",
|
||||
)
|
||||
|
||||
assistant_msg, finish_reason = normalize_anthropic_response(response)
|
||||
|
||||
assert finish_reason == "content_filter"
|
||||
# Thinking was present but no text — should inject the filter message
|
||||
assert assistant_msg.content is not None
|
||||
assert "filtered" in assistant_msg.content.lower()
|
||||
# Reasoning should be preserved
|
||||
assert assistant_msg.reasoning is not None
|
||||
assert "think about this" in assistant_msg.reasoning
|
||||
|
||||
def test_sensitive_maps_to_content_filter_in_stop_reason_map(self):
|
||||
"""The stop_reason_map in normalize_anthropic_response includes 'sensitive'."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
|
||||
# Even with content present, finish_reason should be content_filter
|
||||
response = SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="partial")],
|
||||
stop_reason="sensitive",
|
||||
)
|
||||
|
||||
_, finish_reason = normalize_anthropic_response(response)
|
||||
assert finish_reason == "content_filter"
|
||||
|
||||
def test_tool_use_stop_reason_unchanged(self):
|
||||
"""tool_use stop_reason is not affected."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(
|
||||
type="tool_use",
|
||||
id="tool_123",
|
||||
name="bash",
|
||||
input={"command": "ls"},
|
||||
),
|
||||
],
|
||||
stop_reason="tool_use",
|
||||
)
|
||||
|
||||
assistant_msg, finish_reason = normalize_anthropic_response(response)
|
||||
|
||||
assert finish_reason == "tool_calls"
|
||||
assert assistant_msg.tool_calls is not None
|
||||
assert len(assistant_msg.tool_calls) == 1
|
||||
179
tests/gateway/test_whatsapp_echo_loop.py
Normal file
179
tests/gateway/test_whatsapp_echo_loop.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Tests for WhatsApp echo loop prevention.
|
||||
|
||||
The WhatsApp adapter must filter out messages sent by the bot itself
|
||||
(fromMe=True) to prevent infinite reply loops. The JS bridge already
|
||||
does this filtering, but the Python adapter adds a safety-net check.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _AsyncCM:
|
||||
"""Minimal async context manager returning a fixed value."""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
"""Create a WhatsAppAdapter with test attributes (bypass __init__)."""
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
|
||||
adapter = WhatsAppAdapter.__new__(WhatsAppAdapter)
|
||||
adapter.platform = Platform.WHATSAPP
|
||||
adapter.config = MagicMock()
|
||||
adapter._bridge_port = 19877
|
||||
adapter._bridge_script = "/tmp/test-bridge.js"
|
||||
adapter._session_path = Path("/tmp/test-wa-session")
|
||||
adapter._bridge_log_fh = None
|
||||
adapter._bridge_log = None
|
||||
adapter._bridge_process = None
|
||||
adapter._reply_prefix = None
|
||||
adapter._running = True
|
||||
adapter._message_handler = None
|
||||
adapter._fatal_error_code = None
|
||||
adapter._fatal_error_message = None
|
||||
adapter._fatal_error_retryable = True
|
||||
adapter._fatal_error_handler = None
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._background_tasks = set()
|
||||
adapter._auto_tts_disabled_chats = set()
|
||||
adapter._message_queue = asyncio.Queue()
|
||||
return adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Echo loop prevention tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEchoLoopPrevention:
|
||||
"""Verify that fromMe messages are filtered out in _poll_messages."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fromMe_messages_are_skipped(self):
|
||||
"""Messages with fromMe=True should not be passed to handle_message.
|
||||
|
||||
Simulates the filtering logic from _poll_messages: iterate over
|
||||
bridge response messages, skip any with fromMe=True, and only
|
||||
build events for the rest.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
|
||||
# Build a mock bridge response with both fromMe and regular messages
|
||||
bridge_messages = [
|
||||
{
|
||||
"messageId": "msg-1",
|
||||
"chatId": "123@s.whatsapp.net",
|
||||
"senderId": "123@s.whatsapp.net",
|
||||
"senderName": "User",
|
||||
"chatName": "User",
|
||||
"isGroup": False,
|
||||
"fromMe": True, # This is our own message — should be skipped
|
||||
"body": "Bot reply that should be ignored",
|
||||
"hasMedia": False,
|
||||
"mediaType": "",
|
||||
"mediaUrls": [],
|
||||
"timestamp": 1234567890,
|
||||
},
|
||||
{
|
||||
"messageId": "msg-2",
|
||||
"chatId": "456@s.whatsapp.net",
|
||||
"senderId": "456@s.whatsapp.net",
|
||||
"senderName": "Real User",
|
||||
"chatName": "Real User",
|
||||
"isGroup": False,
|
||||
"fromMe": False, # This is from someone else
|
||||
"body": "Hello bot!",
|
||||
"hasMedia": False,
|
||||
"mediaType": "",
|
||||
"mediaUrls": [],
|
||||
"timestamp": 1234567891,
|
||||
},
|
||||
]
|
||||
|
||||
# Replicate the filtering logic from _poll_messages
|
||||
handled_events = []
|
||||
for msg_data in bridge_messages:
|
||||
if msg_data.get("fromMe", False):
|
||||
continue
|
||||
event = await adapter._build_message_event(msg_data)
|
||||
if event:
|
||||
handled_events.append(event)
|
||||
|
||||
# Only the non-fromMe message should have been processed
|
||||
assert len(handled_events) == 1
|
||||
assert handled_events[0].text == "Hello bot!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fromMe_absent_defaults_to_false(self):
|
||||
"""Messages without fromMe field should NOT be filtered out."""
|
||||
adapter = _make_adapter()
|
||||
|
||||
# Message without fromMe field (backward compat with older bridge)
|
||||
msg_data = {
|
||||
"messageId": "msg-3",
|
||||
"chatId": "789@s.whatsapp.net",
|
||||
"senderId": "789@s.whatsapp.net",
|
||||
"senderName": "Old Bridge User",
|
||||
"chatName": "Old Bridge User",
|
||||
"isGroup": False,
|
||||
# No fromMe field
|
||||
"body": "Message from older bridge version",
|
||||
"hasMedia": False,
|
||||
"mediaType": "",
|
||||
"mediaUrls": [],
|
||||
"timestamp": 1234567892,
|
||||
}
|
||||
|
||||
# fromMe defaults to False, so this message should pass the filter
|
||||
assert msg_data.get("fromMe", False) is False
|
||||
|
||||
# Build the event to verify it works
|
||||
event = await adapter._build_message_event(msg_data)
|
||||
assert event is not None
|
||||
assert event.text == "Message from older bridge version"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fromMe_false_passes_through(self):
|
||||
"""Messages with fromMe=False are processed normally."""
|
||||
adapter = _make_adapter()
|
||||
|
||||
msg_data = {
|
||||
"messageId": "msg-4",
|
||||
"chatId": "321@s.whatsapp.net",
|
||||
"senderId": "321@s.whatsapp.net",
|
||||
"senderName": "Normal User",
|
||||
"chatName": "Normal User",
|
||||
"isGroup": False,
|
||||
"fromMe": False,
|
||||
"body": "Normal incoming message",
|
||||
"hasMedia": False,
|
||||
"mediaType": "",
|
||||
"mediaUrls": [],
|
||||
"timestamp": 1234567893,
|
||||
}
|
||||
|
||||
# Should not be filtered
|
||||
assert msg_data.get("fromMe", False) is False
|
||||
|
||||
event = await adapter._build_message_event(msg_data)
|
||||
assert event is not None
|
||||
assert event.text == "Normal incoming message"
|
||||
Reference in New Issue
Block a user