Skip to content

Add whatsapp.suppress_notifications: drop gateway notices on human-facing platforms - #32550

Open
marcelopaniza wants to merge 2 commits into
NousResearch:mainfrom
marcelopaniza:whatsapp-suppress-notifications
Open

Add whatsapp.suppress_notifications: drop gateway notices on human-facing platforms#32550
marcelopaniza wants to merge 2 commits into
NousResearch:mainfrom
marcelopaniza:whatsapp-suppress-notifications

Conversation

@marcelopaniza

@marcelopaniza marcelopaniza commented May 26, 2026

Copy link
Copy Markdown

Summary

Adds a per-platform flag, whatsapp.suppress_notifications (default false),
that delivers only genuine agent replies on WhatsApp and drops every
gateway-originated "notice" (status/lifecycle, "still working", shutdown,
session-reset, STT hints, runtime footer, "no home channel", kanban pings, cron
headers, etc.).

It's enforced at the single outbound chokepoint rather than per call site, so
new notice types are suppressed automatically.

One class of leak can't be caught at the adapter chokepoint: text that is
appended to the genuine reply rather than sent as a separate message. The
per-turn file-mutation verifier footer (⚠️ File-mutation verifier: N file(s) were NOT modified…) rides the real reply, so it carries the reply marker and
the chokepoint must let it through. That one is gated in the core instead — see
the run_agent.py change below. This is the general pattern for any
reply-embedded developer-facing text: gate it on <platform>.suppress_notifications
where it is produced, since the adapter guard only sees standalone sends.

Motivation

When Hermes drives a WhatsApp number that's also used to talk to human
contacts
, internal bubbles leak into those human chats and are confusing /
reputationally damaging. display.platforms.whatsapp.interim_assistant_messages: false only covered part of this — many notices call adapter.send() directly
and ignored it, so suppression became per-call-site whack-a-mole and regressed
whenever a new notice type was added.

Approach

Every genuine reply (background path, synchronous path, and slash-command
responses) is delivered through BasePlatformAdapter._send_with_retry. Gateway
notices call adapter.send() directly. We use that asymmetry:

  1. Reply marker — a task-local contextvars.ContextVar
    (_delivering_agent_reply) set True only while inside _send_with_retry
    (split into a wrapper + _send_with_retry_impl). Task-local ⇒ no cross-talk
    between concurrent sessions, and it's reset in finally so it can't bleed
    into later sends in the same task.
  2. Chokepoint guard — at the top of WhatsAppAdapter.send(), if the send is
    not a marked reply and whatsapp.suppress_notifications is set, drop it
    (return success, log at DEBUG).

This inverts the burden: replies are tagged in one well-tested place; everything
else is treated as a notice. A missed reply tag would be the only risk (it would
drop a real reply), which is why tagging lives at the single shared funnel.

Changes

  • gateway/platforms/base.pyimport contextvars; module-level
    _delivering_agent_reply ContextVar; split _send_with_retry into a
    marker-setting wrapper + _send_with_retry_impl (body unchanged).
  • gateway/platforms/whatsapp.py — guard at the top of send() (after the
    empty-content check).
  • gateway/config.pyPlatformConfig.suppress_notifications: bool = False
    (+ to_dict/from_dict).
  • run_agent.py — in AIAgent._file_mutation_verifier_enabled(), return
    False when <platform>.suppress_notifications is set, so the reply-embedded
    verifier footer is dropped on suppressed platforms while CLI/TUI keep it.

See hermes-suppress-notifications.patch (attached) — validated git apply --check clean against main @ 2517917. (A variant for the v0.14.0 release
tag is in hermes-suppress-notifications-v0.14.0.patch; the only difference is
the config.py from_dict context, which main rewrote to bridge
gateway_restart_notification through extra.)

Config

# Either layout works:
whatsapp:
  suppress_notifications: true        # top-level convenience layout (read from raw config)

# or
platforms:
  whatsapp:
    suppress_notifications: true      # typed PlatformConfig layout

The adapter reads the flag from the raw config dict
(hermes_cli.config.read_raw_config()), so it works for the top-level
whatsapp: layout that doesn't currently flow through
PlatformConfig.from_dict. The typed field is also populated for the
platforms: layout.

Backward compatibility

  • Default false ⇒ no behavior change for existing users.
  • When suppress_notifications is unset, the guard falls back to
    display.platforms.<platform>.interim_assistant_messages: false, so installs
    already relying on that keep their suppression. (Optional — drop if undesired.)

Extending to other platforms

The reply marker is platform-agnostic; enabling <platform>.suppress_notifications
elsewhere is just the same ~10-line guard at the top of that adapter's send().

Testing

  • Isolated: a fake adapter confirms _send_with_retry ⇒ marker True (sent),
    direct send() ⇒ marker False (suppressed when flag on), and the marker
    resets after _send_with_retry returns.
  • Config: PlatformConfig.from_dict({'suppress_notifications': True}) round-trips
    through to_dict().
  • Runtime: gateway restarts cleanly; WhatsApp connects; genuine replies still
    delivered.
  • Footer gate: with whatsapp.suppress_notifications: true, the file-mutation
    verifier footer is absent from WhatsApp replies; with the flag off (or on
    CLI/TUI) the footer still appends as before.

Notes / open questions for maintainers

  • Naming: suppress_notifications vs notices_enabled (inverted) — happy to
    rename.
  • Whether to keep the interim_assistant_messages backcompat fallback.
  • Could generalize the guard into a shared base helper if you'd prefer a
    send()-wrapper-in-base refactor over per-adapter guards.

Update — reply-embedded interrupt notices (2nd commit)

The adapter chokepoint only catches separate send() notices. Four
interrupt / API-error strings in run_conversation() are assigned to
final_response, so they ride the genuine agent-reply path and bypass the
chokepoint — they leaked into WhatsApp as e.g.
Operation interrupted: waiting for model response (3.9s elapsed).

Fix (same pattern as the file-mutation footer gate): add
AIAgent._suppress_platform_notifications() and gate all four sites on it,
emitting "" / None instead of the notice when the platform suppresses.
Every site already sets interrupted=True, so the emptied reply is silent:
_normalize_empty_agent_response does not resurrect empty responses on
interrupted turns. CLI/TUI and other platforms keep the notices.

When Hermes drives a WhatsApp number shared with human contacts,
gateway-originated notices leak into those chats. Add a per-platform flag,
whatsapp.suppress_notifications (default false), that delivers only genuine
agent replies and drops every gateway notice.

Enforced at the single outbound chokepoint instead of per call site, so new
notice types are suppressed automatically:

- gateway/platforms/base.py: task-local _delivering_agent_reply ContextVar;
  split _send_with_retry into a marker-setting wrapper + _send_with_retry_impl
  (body unchanged).
- gateway/platforms/whatsapp.py: drop non-reply sends in send() when
  whatsapp.suppress_notifications is set (raw-config read; typed-field and
  interim_assistant_messages fallbacks).
- gateway/config.py: PlatformConfig.suppress_notifications (+ to_dict/from_dict).

The file-mutation verifier footer is appended to the reply itself, so it
bypasses the adapter chokepoint; gate it in the core instead:

- run_agent.py: AIAgent._file_mutation_verifier_enabled() returns False when
  <platform>.suppress_notifications is set (CLI/TUI keep it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery platform/whatsapp WhatsApp Business adapter P2 Medium — degraded but workaround exists labels May 26, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related: #24365 (suppress_system_messages for WhatsApp/Discord — broader but unimplemented), #30574 (suppress status banners on customer-facing platforms), #24853 (diagnostic_status gate). This PR takes a different approach using contextvars reply markers at the adapter chokepoint.

…tifications platforms

The four interrupt / API-error status strings are assigned to
final_response, so they ride the genuine agent-reply path and bypass the
adapter notice chokepoint (which only catches separate send() notices).
On platforms with suppress_notifications=true (e.g. WhatsApp shared with
a human contact) these internal notices leak into the chat.

Add AIAgent._suppress_platform_notifications() and gate all four sites in
run_conversation() on it: emit "" / None instead of the notice when the
platform suppresses. Each site already sets interrupted=True, so the
emptied reply is silent -- _normalize_empty_agent_response does not
resurrect empty responses on interrupted turns. CLI/TUI and every other
platform keep the notices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@teknium1 teknium1 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.

Thanks for tackling a real gateway-noise problem. The current implementation needs a different classification boundary before it can provide the stated guarantee.

Problems

  • _send_with_retry is not reply-only: current main uses it for the draining status at gateway/run.py:5301-5316 and the busy/queue acknowledgement at gateway/run.py:5600-5652. Marking that helper as a genuine reply would allow those notices through.
  • Current background-agent output is sent directly at gateway/run.py:13462-13467; the proposed unmarked-send() guard would suppress that genuine response.
  • The WhatsApp adapter moved to plugins/platforms/whatsapp/adapter.py in 5600105478ffde29d7566b45421b100eaa29c4ef, so the patch target no longer exists on main.

Suggested changes

  • Use explicit delivery intent at outbound call sites rather than inferring reply-ness from _send_with_retry, then test status notices and background responses separately.
  • Port the WhatsApp integration to the bundled plugin and add regression tests for the intended preserved and suppressed paths.

Automated hermes-sweeper review.

Comment thread gateway/platforms/base.py
try:
return await self._send_with_retry_impl(
chat_id=chat_id,
content=content,

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.

_send_with_retry is not reply-only: current main calls it for the draining status (gateway/run.py:5301-5316) and busy/queue acknowledgement (gateway/run.py:5600-5652). Setting this marker for every invocation lets those gateway notices bypass the proposed suppression guard.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
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 platform/whatsapp WhatsApp Business adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants