Compare commits

...

1 Commits

Author SHA1 Message Date
Teknium
d0cc6a14d7 fix: Anthropic 'sensitive' stop reason handling + WhatsApp echo loop prevention
Two fixes inspired by OpenClaw v2026.3.28:

1. Anthropic content policy (sensitive stop_reason):
   When Anthropic blocks a response with stop_reason='sensitive', the
   adapter now maps it to finish_reason='content_filter' and injects a
   user-facing message instead of crashing on empty content.

2. WhatsApp echo loop:
   The WhatsApp bridge now sends fromMe flag on message events, and the
   Python adapter filters out self-sent messages. Prevents an infinite
   loop where the bot processes its own outbound replies as new inbound.

9 new tests (6 anthropic + 3 whatsapp).
2026-03-30 01:16:38 -07:00
4 changed files with 315 additions and 0 deletions

View File

@@ -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,

View File

@@ -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,

View 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

View 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"