Skip to content
Merged
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
27 changes: 27 additions & 0 deletions gateway/response_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,30 @@ def is_intentional_silence_agent_result(agent_result: dict | None, response: Any
if agent_result.get("failed"):
return False
return is_intentional_silence_response(response)


def is_partial_silence_marker(text: Any) -> bool:
"""Return True while ``text`` could still resolve to a silence marker.

The streaming path accumulates the reply delta-by-delta and must decide,
before the whole response is known, whether to show what it has so far.
A buffer whose canonical form is a non-empty *prefix* of a silence marker
(e.g. ``"NO"`` on the way to ``"NO_REPLY"``, or an exact marker that has
not yet been terminated by stream-end) is held back so a raw marker is
never edited onto the screen and then belatedly retracted.

Anything that has already diverged from every marker (ordinary prose) —
and anything longer than the marker cap — returns False so normal
streaming resumes immediately. This is the streaming counterpart to
:func:`is_intentional_silence_response`, sharing the same marker set and
canonicalization so the two never drift.
"""
if not isinstance(text, str):
return False
stripped = text.strip()
if not stripped or len(stripped) > 64:
return False
candidate = _canonical_silence_candidate(stripped)
if not candidate:
return False
return any(marker.startswith(candidate) for marker in LIVE_GATEWAY_SILENT_MARKERS)
81 changes: 81 additions & 0 deletions gateway/stream_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
DEFAULT_STREAMING_CURSOR as _DEFAULT_STREAMING_CURSOR,
)
from gateway.response_filters import (
is_intentional_silence_response as _is_intentional_silence_response,
is_partial_silence_marker as _is_partial_silence_marker,
)

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

Expand Down Expand Up @@ -542,6 +546,22 @@ async def run(self) -> None:
if got_done:
self._flush_think_buffer()

# Intentional-silence suppression. When the agent chose
# not to reply it emits a bare control marker (NO_REPLY /
# [SILENT] / …). The gateway's whole-response filter
# (gateway/run.py) suppresses this on the non-streaming
# path, but by the time it runs the stream consumer has
# already edited the raw marker onto the screen. Detect
# the exact-marker final buffer here and retract any
# preview instead of finalizing it, so the marker never
# reaches the chat. Substantive prose that merely mentions
# a marker is NOT suppressed (see is_intentional_silence_response).
if _is_intentional_silence_response(
self._clean_for_display(self._accumulated)
):
await self._suppress_silence_marker()
return

# Decide whether to flush an edit
now = time.monotonic()
elapsed = now - self._last_edit_time
Expand All @@ -562,6 +582,24 @@ async def run(self) -> None:
)

current_update_visible = False
# Hold back mid-stream edits while the buffer so far could
# still resolve to an intentional-silence marker. Without
# this, a partial marker (e.g. "NO_REPLY" streamed as
# "NO"→"NO_REPLY") would flash onto the screen on an interval
# tick before got_done can suppress it. Only defers display —
# got_done above always resolves the buffer (suppress if it's
# an exact marker, otherwise fall through and flush normally),
# so genuine prose that merely starts marker-like is never lost.
if (
should_edit
and not got_done
and not got_segment_break
and commentary_text is None
and _is_partial_silence_marker(
self._clean_for_display(self._accumulated)
)
):
should_edit = False
if should_edit and self._accumulated:
# Split overflow: if accumulated text exceeds the platform
# limit, split into properly sized chunks.
Expand Down Expand Up @@ -1359,6 +1397,49 @@ async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bo
self._final_response_sent = True
return True

async def _suppress_silence_marker(self) -> None:
"""Retract any streamed preview when the final reply is a silence marker.

The agent chose not to respond and emitted a bare control marker. Any
preview message the consumer already put on screen (a partial marker
flushed on an interval tick, or a preamble before a tool boundary) must
be removed so the raw marker is never left visible. Deletion reuses the
same best-effort ``delete_message`` path as :meth:`_try_fresh_final`.

Crucially, the delivery flags (``_final_response_sent`` /
``_final_content_delivered``) are left **False**: nothing was delivered.
The gateway then does not mistake the marker for a delivered reply, and
its own whole-response filter turns the marker into "" so no fallback
send happens either. ``_already_sent`` is likewise cleared so the
gateway's ``already_sent`` short-circuits do not fire.
"""
stale_ids = set(self._preview_message_ids)
if self._message_id and self._message_id != "__no_edit__":
stale_ids.add(self._message_id)
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
for stale_id in stale_ids:
if not stale_id or stale_id == "__no_edit__":
continue
try:
await delete_fn(self.chat_id, stale_id)
except Exception as e:
logger.debug(
"Silence-marker preview cleanup failed (%s): %s",
stale_id, e,
)
self._preview_message_ids = set()
self._message_id = None
self._accumulated = ""
self._last_sent_text = ""
self._already_sent = False
self._final_response_sent = False
self._final_content_delivered = False
logger.info(
"Suppressed streamed intentional-silence marker (chat=%s)",
self.chat_id,
)
Comment on lines +1416 to +1441

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Task cancellation during silence-marker suppression leaks raw marker to platform (bug)

_suppress_silence_marker() in gateway/stream_consumer.py resets delivery state flags (_accumulated, _message_id, _already_sent, _final_response_sent, _final_content_delivered) AFTER the await delete_fn() deletion loop. If the consumer task is cancelled by asyncio during an await delete_fn() call, the CancelledError propagates to the outer except asyncio.CancelledError: handler in run(). At that point _accumulated still holds the marker text (e.g. 'NO_REPLY', '[SILENT]') and _message_id may still be set, so the handler's _send_or_edit call delivers the raw marker to the platform, then sets _final_response_sent = True — which prevents the gateway's downstream whole-response silence filter from running. The silence marker that should have been completely suppressed ends up permanently visible on the chat.

💡 Suggestion: Reset the delivery state flags BEFORE the deletion loop so that a mid-deletion CancelledError leaves the consumer in a clean state. The stale_ids set is built from a snapshot before the loop, so resetting flags early is safe — the deletion loop iterates over the snapshot, not the consumer's live fields.

Suggested change
stale_ids = set(self._preview_message_ids)
if self._message_id and self._message_id != "__no_edit__":
stale_ids.add(self._message_id)
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
for stale_id in stale_ids:
if not stale_id or stale_id == "__no_edit__":
continue
try:
await delete_fn(self.chat_id, stale_id)
except Exception as e:
logger.debug(
"Silence-marker preview cleanup failed (%s): %s",
stale_id, e,
)
self._preview_message_ids = set()
self._message_id = None
self._accumulated = ""
self._last_sent_text = ""
self._already_sent = False
self._final_response_sent = False
self._final_content_delivered = False
logger.info(
"Suppressed streamed intentional-silence marker (chat=%s)",
self.chat_id,
)
stale_ids = set(self._preview_message_ids)
if self._message_id and self._message_id != "__no_edit__":
stale_ids.add(self._message_id)
self._preview_message_ids = set()
self._message_id = None
self._accumulated = ""
self._last_sent_text = ""
self._already_sent = False
self._final_response_sent = False
self._final_content_delivered = False
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
for stale_id in stale_ids:
if not stale_id or stale_id == "__no_edit__":
continue
try:
await delete_fn(self.chat_id, stale_id)
except Exception as e:
logger.debug(
"Silence-marker preview cleanup failed (%s): %s",
stale_id, e,
)
logger.info(
"Suppressed streamed intentional-silence marker (chat=%s)",
self.chat_id,
)
📋 Prompt for AI Agents

In gateway/stream_consumer.py method _suppress_silence_marker(), move the state-reset block (lines 1431-1437: setting _preview_message_ids, _message_id, _accumulated, _last_sent_text, _already_sent, _final_response_sent, _final_content_delivered) to BEFORE the for stale_id in stale_ids: deletion loop. Insert the flag reset block immediately after the stale_ids.add(self._message_id) line (line 1418), before the delete_fn = getattr(...) line (line 1419). The deletion loop remains best-effort with try/except, but the state flags are already clean if the task is cancelled mid-deletion. This ensures the outer CancelledError handler does not see stale accumulated text or message_id and therefore does not send the raw marker to the platform.


async def _send_or_edit(
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
) -> bool:
Expand Down
239 changes: 239 additions & 0 deletions tests/gateway/test_stream_consumer_silence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Streaming intentional-silence suppression.

When the agent chooses not to reply it emits a bare control marker
(``NO_REPLY`` / ``[SILENT]`` / …). The gateway's whole-response filter
(``gateway/response_filters.is_intentional_silence_agent_result``) suppresses
this on the non-streaming delivery path, but the *streaming* path
(``GatewayStreamConsumer``) previously had no silence awareness: it edited the
raw marker onto the screen delta-by-delta and finalized it *before* the
whole-response filter could run. On any streaming-capable adapter (Slack,
Telegram, Discord, …) users saw a literal ``NO_REPLY`` bubble.

These tests pin the two halves of the fix:

* ``is_partial_silence_marker`` — the mid-stream hold-back predicate.
* ``GatewayStreamConsumer`` — an exact-marker final buffer is suppressed and
any already-shown preview is retracted, while substantive prose that merely
mentions a marker is delivered normally.
"""

from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest

from gateway.response_filters import (
is_intentional_silence_response,
is_partial_silence_marker,
)
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig


# --------------------------------------------------------------------------
# is_partial_silence_marker — mid-stream hold-back predicate
# --------------------------------------------------------------------------

# Buffers that could still resolve to a marker → held back while streaming.
PARTIAL_POSITIVE = [
"N",
"NO",
"NO_",
"NO_REP",
"NO_REPLY", # exact marker, not yet terminated by stream-end
"NO REPLY",
"no reply", # canonicalized (case/space-insensitive)
" no_reply ", # surrounding whitespace stripped
"[",
"[SIL",
"[SILENT]",
"SILENT",
"sil",
]

# Buffers that have already diverged from every marker → stream normally.
PARTIAL_NEGATIVE = [
"",
" ",
"No reply needed — here is the plan", # diverged past the marker
"NO_REPLYING", # superset, not a prefix
"Nope",
"Hello there",
"The NO_REPLY token means silence", # marker mentioned mid-prose
"x" * 65, # over the 64-char cap
"silence is golden", # 'SILENCE...' is not a marker prefix
]


@pytest.mark.parametrize("text", PARTIAL_POSITIVE)
def test_partial_silence_marker_positive(text):
assert is_partial_silence_marker(text) is True


@pytest.mark.parametrize("text", PARTIAL_NEGATIVE)
def test_partial_silence_marker_negative(text):
assert is_partial_silence_marker(text) is False


def test_partial_silence_marker_none_safe():
assert is_partial_silence_marker(None) is False


def test_partial_predicate_agrees_with_exact_on_full_markers():
"""Every exact silence marker is also a (trivial) partial of itself."""
from gateway.response_filters import LIVE_GATEWAY_SILENT_MARKERS

for marker in LIVE_GATEWAY_SILENT_MARKERS:
assert is_partial_silence_marker(marker) is True
assert is_intentional_silence_response(marker) is True


# --------------------------------------------------------------------------
# GatewayStreamConsumer — end-to-end suppression through run()
# --------------------------------------------------------------------------

def _make_adapter(*, supports_delete: bool = True) -> MagicMock:
"""Minimal MagicMock adapter wired for send/edit/delete."""
adapter = MagicMock()
adapter.REQUIRES_EDIT_FINALIZE = False
adapter.MAX_MESSAGE_LENGTH = 4096
adapter.send = AsyncMock(return_value=SimpleNamespace(
success=True, message_id="preview_1",
))
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
success=True, message_id="preview_1",
))
if supports_delete:
adapter.delete_message = AsyncMock(return_value=True)
else:
del adapter.delete_message # type: ignore[attr-defined]
return adapter


def _sent_and_edited(adapter):
texts = []
for call in adapter.send.call_args_list:
texts.append(call.kwargs.get("content", ""))
if getattr(adapter, "edit_message", None) is not None:
for call in adapter.edit_message.call_args_list:
texts.append(call.kwargs.get("content", ""))
return texts


class TestStreamedSilenceSuppression:
@pytest.mark.asyncio
async def test_no_reply_only_stream_is_fully_suppressed(self):
"""A stream whose entire content is NO_REPLY sends nothing visible."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter, "chat_1",
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1),
)
consumer.on_delta("NO_REPLY")
consumer.finish()
await consumer.run()

# No marker text ever reached the platform.
for text in _sent_and_edited(adapter):
assert "NO_REPLY" not in text, f"marker leaked: {text!r}"

# Delivery flags stay False so the gateway does not treat the marker
# as a delivered reply (its whole-response filter then drops it too).
assert consumer.final_response_sent is False
assert consumer.final_content_delivered is False
assert consumer.already_sent is False

@pytest.mark.asyncio
async def test_partial_marker_preview_is_retracted(self):
"""A marker flushed mid-stream as a preview is deleted on completion."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter, "chat_1",
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1),
)
# Force a mid-stream preview: pretend "NO_REPLY" was already put on
# screen (the pre-fix behaviour) before got_done runs.
consumer._message_id = "preview_1"
consumer._preview_message_ids = {"preview_1"}
consumer._already_sent = True

consumer.on_delta("NO_REPLY")
consumer.finish()
await consumer.run()

# The stale preview was best-effort deleted.
adapter.delete_message.assert_awaited_once_with("chat_1", "preview_1")
assert consumer.final_content_delivered is False
assert consumer.already_sent is False

@pytest.mark.asyncio
async def test_suppression_without_delete_support_is_best_effort(self):
"""Adapter lacking delete_message still suppresses (leaves no new send)."""
adapter = _make_adapter(supports_delete=False)
consumer = GatewayStreamConsumer(
adapter, "chat_1",
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1),
)
consumer.on_delta("NO_REPLY")
consumer.finish()
await consumer.run()

for text in _sent_and_edited(adapter):
assert "NO_REPLY" not in text
assert consumer.final_content_delivered is False

@pytest.mark.asyncio
async def test_bracket_silent_marker_suppressed(self):
"""The [SILENT] marker is suppressed just like NO_REPLY."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter, "chat_1",
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1),
)
consumer.on_delta("[SILENT]")
consumer.finish()
await consumer.run()

for text in _sent_and_edited(adapter):
assert "[SILENT]" not in text
assert consumer.final_content_delivered is False

@pytest.mark.asyncio
async def test_prose_mentioning_marker_is_delivered(self):
"""Substantive prose that merely mentions NO_REPLY is NOT suppressed."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter, "chat_1",
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5),
)
body = "The NO_REPLY token tells the gateway to stay silent."
consumer.on_delta(body)
consumer.finish()
await consumer.run()

delivered = "".join(_sent_and_edited(adapter))
assert "NO_REPLY" in delivered
assert consumer.final_content_delivered is True

@pytest.mark.asyncio
async def test_marker_prefix_then_prose_is_delivered(self):
"""A reply that starts marker-like but continues is delivered whole.

"NO REPLY needed …" passes through the mid-stream hold-back while the
buffer is still a marker prefix, then flushes normally once it diverges.
The final text is NOT an exact marker, so got_done does not suppress it.
"""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter, "chat_1",
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1),
)
consumer.on_delta("NO REPLY")
consumer.on_delta(" needed — the build is already green.")
consumer.finish()
await consumer.run()

delivered = "".join(_sent_and_edited(adapter))
assert "the build is already green" in delivered
assert consumer.final_content_delivered is True
Loading