fix(gateway): make NO_REPLY a first-class silence token; don't re-inflate intentional silence (#13248)#37940
Conversation
There was a problem hiding this comment.
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 canonicalSILENT_REPLY_TOKEN. - Uses the normalizer to suppress silent/placeholder outputs in
gateway/run.pyandgateway/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.
| 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}" | ||
| ) |
| 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. |
| cursor = self.cfg.cursor or "" | ||
| if cursor and cleaned.strip() == cursor.strip(): | ||
| return cleaned |
ae93c2a to
99008f2
Compare
|
Rebased onto current Locally tested: |
99008f2 to
2d49e65
Compare
|
Rebased onto current Locally tested as part of the full patch-stack regression suite — green. |
2d49e65 to
6b9e6a9
Compare
|
Rebased onto current Locally tested: test_live_silent_responses, test_stream_consumer — |
|
Rebased onto current Locally tested: |
6b9e6a9 to
5d3549b
Compare
(cherry picked from commit a82048f)
…-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.
5d3549b to
ffa06c6
Compare
|
Merged via PR #46080 — the exact-token silence behavior was salvaged onto current main with your authorship preserved in git log ( Scope note: this intentionally keeps the narrow exact-token boundary and does not include the broader |
What this PR does
Makes
NO_REPLYa 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.pyboundary andnormalize_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:
The canonical token is suppressed only by accident.
NO_REPLYis 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.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""(whenapi_calls > 0) into: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— addSILENT_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 inLIVE_GATEWAY_SILENT_MARKERSso 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 afailed=Falseturn) from an empty-generation failure, and skip_normalize_empty_agent_response()for the former. A genuine empty failure still surfaces the user-facing notice.failed=Trueoutput passes through, assert prose merely mentioningNO_REPLYis still delivered, and add a handler-level regression for the intentional-silence-vs-empty-failure distinction.Verification
Broader local regression (gateway + cron silence paths): 424 passed.
Relationship to other PRs / issues