Skip to content

fix(gateway): make NO_REPLY a first-class silence token; don't re-inflate intentional silence (#13248)#37940

Closed
aldoeliacim wants to merge 2 commits into
NousResearch:mainfrom
aldoeliacim:pr/no-reply-silence-13248
Closed

fix(gateway): make NO_REPLY a first-class silence token; don't re-inflate intentional silence (#13248)#37940
aldoeliacim wants to merge 2 commits into
NousResearch:mainfrom
aldoeliacim:pr/no-reply-silence-13248

Conversation

@aldoeliacim

Copy link
Copy Markdown
Contributor

What this PR does

Makes NO_REPLY a first-class, harness-emitted silence token and stops the gateway from re-inflating an intentional silent turn into a "no response was generated" notice. Root-cause fix for the empty-vs-silent ambiguity behind #13248.

This PR is stacked on top of #9956 — it keeps that PR's gateway/response_filters.py boundary and normalize_live_gateway_response() exactly as-is, and only adds the missing pieces. The first commit here is #9956's commit (author preserved); please review only the second commit.

Why

#9956 centralizes delivery suppression of silent placeholders, which is great. But two gaps remain:

  1. The canonical token is suppressed only by accident. NO_REPLY is suppressed today purely because _canonicalize_live_gateway_response() happens to fold it to "no reply", which is in the marker set. There is no explicit contract; a future tweak to the canonicalizer could silently start leaking the literal token into live chats.

  2. Intentional silence is re-inflated downstream (the Empty-response retry loop on claude-opus-4-7 in Slack group threads (non-@mention discussion messages) #13248 root). Even after normalize_live_gateway_response() collapses an intentional silent turn to "", the live path then calls _normalize_empty_agent_response(), which rewrites "" (when api_calls > 0) into:

    ⚠️ Processing completed but no response was generated. This may be a transient error — try sending your message again.

    So a deliberate "stay silent" decision becomes chat noise — exactly the failure mode reported in Empty-response retry loop on claude-opus-4-7 in Slack group threads (non-@mention discussion messages) #13248 (model reasons "don't reply in this group thread", and the gateway surfaces a spurious failure notice instead of staying quiet).

Changes

  • gateway/response_filters.py — add SILENT_REPLY_TOKEN = "NO_REPLY" (a first-class control token, cf. the equivalent silence token in comparable agent runtimes) as the single documented source of truth, and list its folded form explicitly in LIVE_GATEWAY_SILENT_MARKERS so the suppression contract no longer depends on canonicalizer internals.
  • gateway/run.py — in _handle_message_with_agent, distinguish intentional silence (a recognized silence marker on a failed=False turn) from an empty-generation failure, and skip _normalize_empty_agent_response() for the former. A genuine empty failure still surfaces the user-facing notice.
  • Tests — lock the canonical-token suppression contract independently of the canonicalizer (token alone, whitespace/case/wrapper variants), assert failed=True output passes through, assert prose merely mentioning NO_REPLY is still delivered, and add a handler-level regression for the intentional-silence-vs-empty-failure distinction.

Verification

./venv/bin/python -m pytest tests/gateway/test_live_silent_responses.py tests/gateway/test_stream_consumer.py -q -o 'addopts='
# 114 passed

Broader local regression (gateway + cron silence paths): 424 passed.

Relationship to other PRs / issues

Copilot AI left a comment

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.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a shared “silent response” normalization/filter so canonical silence tokens and common placeholder markers are suppressed across both streaming (GatewayStreamConsumer) and non-streaming live gateway delivery paths.

Changes:

  • Introduces gateway.response_filters.normalize_live_gateway_response() and a canonical SILENT_REPLY_TOKEN.
  • Uses the normalizer to suppress silent/placeholder outputs in gateway/run.py and gateway/stream_consumer.py.
  • Adds/updates tests to cover suppression behavior and reduce timing flakiness in streaming tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/gateway/test_stream_consumer.py Adds helper + tests ensuring silent placeholders are not streamed/sent; replaces fixed sleeps with a wait helper.
tests/gateway/test_live_silent_responses.py New tests for normalizer behavior + runner integration to ensure intentional silence is suppressed but real failures surface.
gateway/stream_consumer.py Adds _prepare_for_delivery() and suppresses silent markers before sending/editing/streaming commentary.
gateway/run.py Normalizes final responses and interim commentary; avoids converting intentional silence into “no response generated” notices.
gateway/response_filters.py New module defining silent markers and normalization logic for live gateway delivery.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +12 to +19
async def _wait_for_mock_call_count(mock, count: int, *, timeout: float = 0.5) -> None:
deadline = asyncio.get_running_loop().time() + timeout
while mock.call_count < count and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0.01)

assert mock.call_count >= count, (
f"Timed out waiting for {mock!r} to reach {count} calls; got {mock.call_count}"
)
Comment thread gateway/run.py
Comment on lines +754 to 758
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.
Comment on lines +693 to +695
cursor = self.cfg.cursor or ""
if cursor and cleaned.strip() == cursor.strip():
return cleaned
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists labels Jun 3, 2026
@aldoeliacim aldoeliacim force-pushed the pr/no-reply-silence-13248 branch from ae93c2a to 99008f2 Compare June 4, 2026 20:10
@aldoeliacim

Copy link
Copy Markdown
Contributor Author

Rebased onto current origin/main (force-push). Same two-commit scope as before — brings the branch up to date so it merges cleanly after upstream moved forward.

Locally tested: tests/gateway/test_delivery_silence_filter.py, tests/gateway/test_notice_delivery.py, tests/gateway/test_post_delivery_callback_chaining.py — 50 passed, 0 failed.

@aldoeliacim aldoeliacim force-pushed the pr/no-reply-silence-13248 branch from 99008f2 to 2d49e65 Compare June 6, 2026 19:06
@aldoeliacim

Copy link
Copy Markdown
Contributor Author

Rebased onto current origin/main (force-push). Was 203 commits behind — just brings the branch up to date so it merges cleanly. No behavioral changes vs the prior tip; conflicts (where present) resolved by layering on upstream's changes.

Locally tested as part of the full patch-stack regression suite — green.

@aldoeliacim aldoeliacim force-pushed the pr/no-reply-silence-13248 branch from 2d49e65 to 6b9e6a9 Compare June 7, 2026 17:22
@aldoeliacim

Copy link
Copy Markdown
Contributor Author

Rebased onto current origin/main (force-push). Same scope as before —
just brings the branch up to date so it merges cleanly. No behavioral
changes vs the prior tip (stacked on #9956 as before; my contribution
remains the single NO_REPLY commit on top).

Locally tested: test_live_silent_responses, test_stream_consumer —
114 passed, 0 failed.

@aldoeliacim

Copy link
Copy Markdown
Contributor Author

Rebased onto current origin/main (force-push). Still stacked on #9956
(kept verbatim as commit 1, author preserved); please review only commit 2.
No behavioral changes vs the prior tip — just brings the branch up to date
so it merges cleanly.

Locally tested: tests/gateway/test_live_silent_responses.py +
tests/gateway/test_stream_consumer.py — 114 passed, 0 failed.

@aldoeliacim aldoeliacim force-pushed the pr/no-reply-silence-13248 branch from 6b9e6a9 to 5d3549b Compare June 8, 2026 14:42
luoxiao6645 and others added 2 commits June 11, 2026 10:18
…-inflate intentional silence (NousResearch#13248)

Builds on NousResearch#9956's response_filters boundary:
- Document NO_REPLY as the canonical harness-emitted SILENT_REPLY_TOKEN and
  list it explicitly in LIVE_GATEWAY_SILENT_MARKERS so the control-token
  contract no longer depends on the canonicalizer incidentally folding
  NO_REPLY -> 'no reply'.
- In _handle_message_with_agent, distinguish INTENTIONAL silence (a recognized
  silence marker on a non-failed turn) from an empty-generation FAILURE, and
  skip _normalize_empty_agent_response for the former so a deliberate NO_REPLY
  turn stays silent instead of being rewritten into 'no response was generated'
  chat noise. Empty failures still surface the user notice.
- Tests: lock the canonical-token suppression contract independent of the
  canonicalizer, the failed-output passthrough, prose-mention passthrough, and
  the intentional-silence-vs-empty-failure distinction at the handler level.
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #46080 — the exact-token silence behavior was salvaged onto current main with your authorship preserved in git log (293c04fef). Thanks!

Scope note: this intentionally keeps the narrow exact-token boundary and does not include the broader silence_allowed retry-loop/adapter plumbing.

#46080

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

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Empty-response retry loop on claude-opus-4-7 in Slack group threads (non-@mention discussion messages)

4 participants