Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions gateway/response_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

import re
from typing import Optional

LIVE_GATEWAY_SILENT_MARKERS = frozenset(
{
"[silent]",
"silent",
"no message",
"no reply",
"no response",
"no response generated",
"empty",
}
)


def _unwrap_live_gateway_response_text(text: str) -> str:
normalized = text
for _ in range(6):
updated = normalized.strip()
changed = False

for wrapper in ("**", "__", "~~", "`"):
if updated.startswith(wrapper) and updated.endswith(wrapper):
inner = updated[len(wrapper) : -len(wrapper)].strip()
if inner:
normalized = inner
changed = True
break
if changed:
continue

for left, right in (("(", ")"), ("[", "]"), ("{", "}"), ('"', '"'), ("'", "'")):
if updated.startswith(left) and updated.endswith(right):
inner = updated[len(left) : -len(right)].strip()
if inner:
normalized = inner
changed = True
break

if not changed:
normalized = updated
break

return normalized


def _canonicalize_live_gateway_response(text: str) -> str:
normalized = _unwrap_live_gateway_response_text(text)
return re.sub(r"[\s\-_]+", " ", normalized).strip(" .!?:;").casefold()


def normalize_live_gateway_response(
response: Optional[str], *, failed: bool = False
) -> str:
"""Suppress placeholder silence markers before live message delivery."""
if response is None:
return ""

text = str(response).strip()
if not text or failed:
return text

if _canonicalize_live_gateway_response(text) in LIVE_GATEWAY_SILENT_MARKERS:
return ""

return text
13 changes: 11 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
from datetime import datetime
from typing import Dict, Optional, Any, List

from gateway.response_filters import normalize_live_gateway_response

# ---------------------------------------------------------------------------
# SSL certificate auto-detection for NixOS and other non-standard systems.
# Must run BEFORE any HTTP library (discord, aiohttp, etc.) is imported.
Expand Down Expand Up @@ -3775,7 +3777,10 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
except Exception:
pass

response = agent_result.get("final_response") or ""
response = normalize_live_gateway_response(
agent_result.get("final_response"),
failed=bool(agent_result.get("failed")),
)
agent_messages = agent_result.get("messages", [])
_response_time = time.time() - _msg_start_time
_api_calls = agent_result.get("api_calls", 0)
Expand Down Expand Up @@ -8330,6 +8335,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
else:
_stream_consumer.on_commentary(text)
return
text = normalize_live_gateway_response(text)
if already_streamed or not _status_adapter or not str(text or "").strip():
return
try:
Expand Down Expand Up @@ -9139,7 +9145,10 @@ async def _notify_long_running():
)
)
)
first_response = result.get("final_response", "")
first_response = normalize_live_gateway_response(
result.get("final_response"),
failed=bool(result.get("failed")),
)
if first_response and not _already_streamed:
try:
await adapter.send(
Expand Down
29 changes: 25 additions & 4 deletions gateway/stream_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from dataclasses import dataclass
from typing import Any, Optional

from gateway.response_filters import normalize_live_gateway_response

logger = logging.getLogger("gateway.stream_consumer")

# Sentinel to signal the stream is complete
Expand Down Expand Up @@ -444,12 +446,31 @@ def _clean_for_display(text: str) -> str:
# Strip trailing whitespace/newlines but preserve leading content
return cleaned.rstrip()

def _prepare_for_delivery(self, text: str) -> str:
cleaned = self._clean_for_display(text)
if not cleaned:
return cleaned

cursor = self.cfg.cursor or ""
if cursor and cleaned.strip() == cursor.strip():
return cleaned

if cursor and cleaned.endswith(cursor):
body = cleaned[: -len(cursor)].rstrip()
if body and not normalize_live_gateway_response(body):
return ""

if not normalize_live_gateway_response(cleaned):
return ""

return cleaned

async def _send_new_chunk(self, text: str, reply_to_id: Optional[str]) -> Optional[str]:
"""Send a new message chunk, optionally threaded to a previous message.

Returns the message_id so callers can thread subsequent chunks.
"""
text = self._clean_for_display(text)
text = self._prepare_for_delivery(text)
if not text.strip():
return reply_to_id
try:
Expand Down Expand Up @@ -508,7 +529,7 @@ async def _send_fallback_final(self, text: str) -> None:

Retries each chunk once on flood-control failures with a short delay.
"""
final_text = self._clean_for_display(text)
final_text = self._prepare_for_delivery(text)
continuation = self._continuation_text(final_text)
self._fallback_final_send = False
if not continuation.strip():
Expand Down Expand Up @@ -600,7 +621,7 @@ async def _try_strip_cursor(self) -> None:

async def _send_commentary(self, text: str) -> bool:
"""Send a completed interim assistant commentary message."""
text = self._clean_for_display(text)
text = self._prepare_for_delivery(text)
if not text.strip():
return False
try:
Expand All @@ -626,7 +647,7 @@ async def _send_or_edit(self, text: str) -> bool:
# Strip MEDIA: directives so they don't appear as visible text.
# Media files are delivered as native attachments after the stream
# finishes (via _deliver_media_from_response in gateway/run.py).
text = self._clean_for_display(text)
text = self._prepare_for_delivery(text)
# A bare streaming cursor is not meaningful user-visible content and
# can render as a stray tofu/white-box message on some clients.
visible_without_cursor = text
Expand Down
93 changes: 93 additions & 0 deletions tests/gateway/test_live_silent_responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest

from gateway.config import Platform
from gateway.platforms.base import MessageEvent, MessageType
from gateway.response_filters import normalize_live_gateway_response
from gateway.run import GatewayRunner
from gateway.session import SessionSource


@pytest.mark.parametrize(
("raw_text", "expected"),
[
("(No message)", ""),
("[SILENT]", ""),
("`(No reply)`", ""),
("**(No response generated)**", ""),
("(empty)", ""),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(empty) is not an intentional-silence token on current main: gateway/run.py:11565-11575 converts it to a visible exhausted-retry/model-failure explanation. Please remove this expectation so a real failure is not silently dropped.

("[SILENT] means stay quiet", "[SILENT] means stay quiet"),
("No message received from Discord", "No message received from Discord"),
],
)
def test_normalize_live_gateway_response(raw_text, expected):
assert normalize_live_gateway_response(raw_text) == expected


def test_normalize_live_gateway_response_preserves_failed_output():
assert normalize_live_gateway_response("[SILENT]", failed=True) == "[SILENT]"


def _make_runner():
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = MagicMock()
runner.session_store = MagicMock()
runner.hooks = SimpleNamespace(emit=AsyncMock())
runner.adapters = {}
runner._show_reasoning = False
runner._session_db = None
runner._set_session_env = MagicMock(return_value=[])
runner._clear_session_env = MagicMock()
runner._should_send_voice_reply = MagicMock(return_value=False)
runner._deliver_media_from_response = AsyncMock()
return runner


@pytest.mark.asyncio
async def test_handle_message_with_agent_suppresses_placeholder(monkeypatch):
runner = _make_runner()

session_entry = SimpleNamespace(
session_id="sess-1",
session_key="key-1",
created_at=1,
updated_at=2,
was_auto_reset=False,
last_prompt_tokens=0,
)
history = [{"role": "assistant", "content": "Earlier reply"}]

runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.load_transcript.return_value = history
runner.session_store.has_any_sessions.return_value = True
runner.session_store.append_to_transcript = MagicMock()
runner.session_store.update_session = MagicMock()

runner._run_agent = AsyncMock(
return_value={
"final_response": "(No message)",
"messages": history,
"api_calls": 1,
"last_prompt_tokens": 0,
}
)

monkeypatch.setattr("gateway.run.build_session_context", lambda *_a, **_kw: {})
monkeypatch.setattr("gateway.run.build_session_context_prompt", lambda *_a, **_kw: "")

source = SessionSource(
platform=Platform.LOCAL,
chat_id="chat-1",
user_id="user-1",
user_name="tester",
)
event = MessageEvent(text="test", message_type=MessageType.TEXT, source=source)

result = await runner._handle_message_with_agent(event, source, "key-1")

assert result == ""
appended = [call.args[1] for call in runner.session_store.append_to_transcript.call_args_list]
assert any(entry["role"] == "user" for entry in appended)
assert not any(entry.get("content") == "(No message)" for entry in appended)
Loading