Skip to content

fix(gateway): suppress NO_REPLY/[SILENT] markers on the streaming path - #179

Merged
hashbender merged 1 commit into
mainfrom
mirror/pr-56099
Jul 1, 2026
Merged

fix(gateway): suppress NO_REPLY/[SILENT] markers on the streaming path#179
hashbender merged 1 commit into
mainfrom
mirror/pr-56099

Conversation

@hashbender

Copy link
Copy Markdown
Owner

Infographic

silence-stays-silent

Summary

Intentional-silence markers (NO_REPLY / [SILENT] / SILENT / NO REPLY) are now suppressed on the streaming delivery path, not just the non-streaming one. A user reported a literal NO_REPLY bubble leaking into Slack.

The gateway already suppressed whole-response silence markers via response_filters.is_intentional_silence_response + run.py's whole-response filter — but only on the non-streaming path. GatewayStreamConsumer edits the reply onto the screen delta-by-delta, before that filter runs, so by the time the filter fires the marker is already visible. This affects any streaming-capable adapter (Slack, Telegram, Discord, Matrix, …); Slack has editing on by default, which is where it was caught.

Both streaming call sites (run.py proxy SSE and local) funnel through on_delta, so the fix has a single correct home: the consumer.

Changes

  • gateway/response_filters.py: add is_partial_silence_marker(), the streaming counterpart to is_intentional_silence_response(). Shares the same LIVE_GATEWAY_SILENT_MARKERS set + canonicalization so the two never drift.
  • gateway/stream_consumer.py:
    • Mid-stream hold-back: defer edits while the accumulated buffer is still a prefix of a silence marker, so a partial marker never flashes on an interval tick.
    • Stream-end suppression: on got_done, if the final buffer is exactly a marker, retract any preview (best-effort delete_message, reusing the _try_fresh_final cleanup path) and leave delivery flags False so the gateway's own filter drops it and no fallback send fires.
  • tests/gateway/test_stream_consumer_silence.py: predicate truth table + end-to-end run() suppression (single-shot and token-by-token NO_REPLY, [SILENT] parity, preview retraction, no-delete-support best-effort, prose-passthrough).

No new config, no platform-specific code.

Validation

Before After
Streaming reply of exactly NO_REPLY literal marker delivered to chat suppressed, nothing sent
Partial marker on interval tick flashes on screen held back until resolved
Prose mentioning NO_REPLY delivered delivered (unchanged)

30 tests pass (scripts/run_tests.sh tests/gateway/test_stream_consumer_silence.py). Matches the narrow intentional-silence contract: suppress only when the whole response is an exact marker; never substring-match prose.

Salvaged from NousResearch#56042 by @benbarclay; authorship preserved via rebase-merge.


Mirror-of: NousResearch#56099
NousResearch#56099

@tenki-reviewer

tenki-reviewer Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Complete

Files Reviewed: 3
Findings: 1

By Severity:

  • 🟠 High: 1

The silence-marker suppression feature has two leak paths where raw control markers (e.g. 'NO_REPLY', '[SILENT]') can reach chat platforms: async task cancellation in the stream consumer leaves stale state, and the pending-follow-up delivery path bypasses the intentional-silence filter.

Files Reviewed (3 files)
gateway/response_filters.py
gateway/stream_consumer.py
tests/gateway/test_stream_consumer_silence.py

@hashbender
hashbender merged commit 814001f into main Jul 1, 2026
3 checks passed

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Risk: 🟠 High (68/100) — 1 high finding · 347 LOC across 3 files


PR #179 — Silenco-marker suppression review

This PR adds intentional silence marker suppression via _suppress_silence_marker() in gateway/stream_consumer.py and related filtering in gateway/response_filters.py. The feature correctly suppresses streamed silence markers during normal operation but exposes two leak paths that can deliver raw control markers to end-users on chat platforms.

finding-001 (high) — Cancellation leak in stream_consumer.py

The state-reset block in _suppress_silence_marker() runs after the delete_fn() deletion loop. If the consumer task is cancelled by asyncio during an await delete_fn() call, CancelledError propagates to the outer handler in run(), where _accumulated still holds the raw marker text and _message_id is set. The handler's _send_or_edit then delivers the raw marker permanently to the platform.

finding-002 (medium) — Pending-follow-up bypass in run.py

After silence suppression clears _final_response_sent, the pending-follow-up delivery path (when a user sends a queued message during agent processing) reads result['final_response'] and sends it without checking for intentional silence. The _stream_confirmed_final_delivery guard that previously prevented this relied on _final_response_sent being True — but suppression now sets it to False.

Files reviewed

  • gateway/response_filters.py — new is_intentional_silence_agent_result() and silence marker definitions
  • gateway/stream_consumer.py_suppress_silence_marker() and async cancellation handling
  • tests/gateway/test_stream_consumer_silence.py — test coverage for suppression

Comment on lines +1416 to +1441
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,
)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant