Rebase onto upstream (a4d95fd)
#12
@@ -23,6 +23,7 @@ from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.visibility import sign_content, has_forged_marker, strip_all_hidden_markers
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
@@ -420,8 +421,13 @@ class AgentLoop:
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
|
||||
if suppress_output:
|
||||
# Prefix content for session visibility
|
||||
final_content_for_session = f"[HIDDEN] {final_content}"
|
||||
# Check for and strip any forged markers from model output
|
||||
if has_forged_marker(final_content):
|
||||
logger.warning("Stripping forged visibility marker from model output")
|
||||
final_content = strip_all_hidden_markers(final_content)
|
||||
|
||||
# Sign content with our secret key
|
||||
final_content_for_session = sign_content(final_content)
|
||||
# Mark as suppressed for channel handler
|
||||
outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
|
||||
else:
|
||||
|
||||
@@ -85,3 +85,217 @@ def test_system_prompt_includes_visibility_docs(tmp_path):
|
||||
assert "[HIDDEN:" in prompt
|
||||
assert "cryptographically signed" in prompt.lower()
|
||||
assert "do not generate" in prompt.lower() or "don't generate" in prompt.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppress_mode_adds_signed_marker(tmp_path):
|
||||
"""Test that suppress mode adds cryptographically signed markers."""
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
# Setup
|
||||
bus = MessageBus()
|
||||
sessions = SessionManager(tmp_path)
|
||||
|
||||
# Mock provider
|
||||
mock_provider = Mock()
|
||||
mock_provider.default_model = "mock-model"
|
||||
mock_provider.thinking_budget = 0
|
||||
|
||||
# Mock successful response
|
||||
mock_response = LLMResponse(
|
||||
content="Test response",
|
||||
tool_calls=[],
|
||||
reasoning_content=None
|
||||
)
|
||||
mock_provider.chat = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Create agent loop
|
||||
loop = AgentLoop(
|
||||
provider=mock_provider,
|
||||
bus=bus,
|
||||
session_manager=sessions,
|
||||
workspace=tmp_path
|
||||
)
|
||||
|
||||
# Process message with suppress_output=True
|
||||
msg = InboundMessage(
|
||||
channel="test",
|
||||
sender_id="user",
|
||||
chat_id="123",
|
||||
content="Test message",
|
||||
metadata={"suppress_output": True}
|
||||
)
|
||||
|
||||
response = await loop._process_message(msg)
|
||||
|
||||
# Verify response has suppressed metadata
|
||||
assert response.metadata.get("suppressed") is True
|
||||
|
||||
# Verify session contains signed marker
|
||||
session = sessions.get_or_create("test:123")
|
||||
assistant_messages = [m for m in session.messages if m.get("role") == "assistant"]
|
||||
assert len(assistant_messages) > 0
|
||||
|
||||
last_msg = assistant_messages[-1]["content"]
|
||||
assert last_msg.startswith("[HIDDEN:")
|
||||
|
||||
# Verify signature is valid
|
||||
is_valid, clean = verify_signature(last_msg)
|
||||
assert is_valid is True
|
||||
assert clean == "Test response"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_marker_accumulation_with_real_provider(tmp_path):
|
||||
"""
|
||||
CRITICAL TEST: Verify markers don't accumulate when model sees them in context.
|
||||
|
||||
This test uses a semi-realistic provider that sees the context and could
|
||||
potentially copy markers, unlike pure mocks that don't see context at all.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.bus.events import InboundMessage
|
||||
import re
|
||||
|
||||
# Setup
|
||||
bus = MessageBus()
|
||||
sessions = SessionManager(tmp_path)
|
||||
|
||||
# Create a provider that SEES context and simulates potential copying behavior
|
||||
class ContextAwareProvider:
|
||||
"""Provider that sees context and could copy markers (simulating real LLM)."""
|
||||
default_model = "test-model"
|
||||
thinking_budget = 0
|
||||
|
||||
def __init__(self):
|
||||
self.call_count = 0
|
||||
self.last_context = None
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
"""Get the default model."""
|
||||
return self.default_model
|
||||
|
||||
async def chat(self, messages, **kwargs):
|
||||
self.call_count += 1
|
||||
self.last_context = messages
|
||||
|
||||
# Count markers in non-system messages (system prompt has 2 mentions in docs)
|
||||
marker_count = sum(
|
||||
msg.get("content", "").count("[HIDDEN:")
|
||||
for msg in messages
|
||||
if isinstance(msg.get("content"), str) and msg.get("role") != "system"
|
||||
)
|
||||
|
||||
# Simulate model behavior: on first call (msg 2), sees 1 marker from msg 1
|
||||
# The model should NOT copy it
|
||||
if self.call_count == 2:
|
||||
# Verify context has exactly 1 marker in assistant messages (from message 1)
|
||||
assert marker_count == 1, f"Expected 1 marker in context, found {marker_count}"
|
||||
|
||||
# Always return clean response (good model behavior)
|
||||
return LLMResponse(
|
||||
content=f"Response {self.call_count}",
|
||||
tool_calls=[],
|
||||
reasoning_content=None
|
||||
)
|
||||
|
||||
provider = ContextAwareProvider()
|
||||
|
||||
# Create agent loop
|
||||
loop = AgentLoop(
|
||||
provider=provider,
|
||||
bus=bus,
|
||||
session_manager=sessions,
|
||||
workspace=tmp_path
|
||||
)
|
||||
|
||||
# Use unique chat_id for this test to avoid pollution from previous runs
|
||||
import time
|
||||
test_chat_id = f"test_accumulation_{int(time.time()*1000)}"
|
||||
|
||||
# Message 1: suppress_output=True → should add signed marker
|
||||
msg1 = InboundMessage(
|
||||
channel="test",
|
||||
sender_id="user",
|
||||
chat_id=test_chat_id,
|
||||
content="Hidden message 1",
|
||||
metadata={"suppress_output": True}
|
||||
)
|
||||
|
||||
await loop._process_message(msg1)
|
||||
|
||||
# Verify message 1 has signed marker
|
||||
session = sessions.get_or_create(f"test:{test_chat_id}")
|
||||
assistant_msgs = [m for m in session.messages if m.get("role") == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
msg1_content = assistant_msgs[0]["content"]
|
||||
assert msg1_content.startswith("[HIDDEN:")
|
||||
is_valid, clean = verify_signature(msg1_content)
|
||||
assert is_valid is True
|
||||
assert clean == "Response 1"
|
||||
|
||||
# Count markers in session after message 1
|
||||
marker_count_1 = sum(m.get("content", "").count("[HIDDEN:") for m in session.messages if isinstance(m.get("content"), str))
|
||||
assert marker_count_1 == 1, f"Expected 1 marker after msg1, found {marker_count_1}"
|
||||
|
||||
# Message 2: Normal message (context includes message 1 with marker)
|
||||
msg2 = InboundMessage(
|
||||
channel="test",
|
||||
sender_id="user",
|
||||
chat_id=test_chat_id,
|
||||
content="Normal message 2",
|
||||
metadata={}
|
||||
)
|
||||
|
||||
await loop._process_message(msg2)
|
||||
|
||||
# Verify message 2 response does NOT start with [HIDDEN: (model didn't copy)
|
||||
session = sessions.get_or_create(f"test:{test_chat_id}")
|
||||
assistant_msgs = [m for m in session.messages if m.get("role") == "assistant"]
|
||||
assert len(assistant_msgs) == 2
|
||||
msg2_content = assistant_msgs[1]["content"]
|
||||
assert not msg2_content.startswith("[HIDDEN:"), f"Message 2 should not start with [HIDDEN:, got: {msg2_content}"
|
||||
|
||||
# Verify still only 1 marker in session (no accumulation)
|
||||
marker_count_2 = sum(m.get("content", "").count("[HIDDEN:") for m in session.messages if isinstance(m.get("content"), str))
|
||||
assert marker_count_2 == 1, f"Expected 1 marker after msg2, found {marker_count_2} (ACCUMULATION DETECTED)"
|
||||
|
||||
# Message 3: Another suppress_output=True → should add SECOND signed marker
|
||||
msg3 = InboundMessage(
|
||||
channel="test",
|
||||
sender_id="user",
|
||||
chat_id=test_chat_id,
|
||||
content="Hidden message 3",
|
||||
metadata={"suppress_output": True}
|
||||
)
|
||||
|
||||
await loop._process_message(msg3)
|
||||
|
||||
# Verify message 3 has signed marker
|
||||
session = sessions.get_or_create(f"test:{test_chat_id}")
|
||||
assistant_msgs = [m for m in session.messages if m.get("role") == "assistant"]
|
||||
assert len(assistant_msgs) == 3
|
||||
msg3_content = assistant_msgs[2]["content"]
|
||||
assert msg3_content.startswith("[HIDDEN:")
|
||||
is_valid, clean = verify_signature(msg3_content)
|
||||
assert is_valid is True
|
||||
assert clean == "Response 3"
|
||||
|
||||
# Verify exactly 2 markers in session (one from msg1, one from msg3)
|
||||
marker_count_3 = sum(m.get("content", "").count("[HIDDEN:") for m in session.messages if isinstance(m.get("content"), str))
|
||||
assert marker_count_3 == 2, f"Expected 2 markers after msg3, found {marker_count_3}"
|
||||
|
||||
# CRITICAL: Verify no double/triple markers like "[HIDDEN: [HIDDEN: [HIDDEN: message"
|
||||
for msg in session.messages:
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and "[HIDDEN:" in content:
|
||||
# Count occurrences of [HIDDEN: pattern in this single message
|
||||
hidden_count = content.count("[HIDDEN:")
|
||||
assert hidden_count == 1, f"Message has {hidden_count} [HIDDEN: markers (accumulation): {content[:100]}"
|
||||
|
||||
Reference in New Issue
Block a user