Skip to content

Gateway bots cannot stay intentionally silent — the agent has no first-class way to choose not to reply (essential for group/ambient channels) #2132

Description

@MervinPraison

Summary

PraisonAI's bot gateway already gates inbound messages well (group_policy: respond_all | mention_only | command_only), but once a message passes that gate the agent's reply is always delivered. There is no first-class, cross-platform way for the agent itself to look at a message and decide "this does not need a response — stay silent".

This is the missing half of world-class group-chat behaviour. Today an operator is forced into a binary:

  • respond_all — the bot replies to every message in the group (spammy, quickly muted/removed), or
  • mention_only — the bot can only react when explicitly @-mentioned, so it can never proactively help (e.g. answer a question nobody thought to direct at it).

A genuinely useful "ambient" assistant reads everything but speaks selectively — it jumps in when it can add value and otherwise says nothing. That requires the agent to be able to return an intentional-silence signal that the gateway honours by suppressing delivery, uniformly across every channel. PraisonAI has no such signal.

Current behaviour

Inbound gating exists and is good, but it is static and platform-side — it decides whether to run the agent, not whether the agent's answer is worth sending:

# src/praisonai/praisonai/bots/_config_schema.py:149
group_policy: str = "mention_only"  # respond_all | mention_only | command_only

# src/praisonai/praisonai/bots/discord.py:270-274
should_respond = False
if isinstance(message.channel, discord.DMChannel):
    should_respond = True
elif self._client.user.mentioned_in(message) or not self.config.mention_required:
    should_respond = True

Once the agent runs, its reply flows unconditionally to the platform. Every adapter shares the same send path via fire_message_sending (Telegram/Slack/Discord/WhatsApp/email/agentmail all call it):

# src/praisonai/praisonai/bots/telegram.py:403-406
send_result = self.fire_message_sending(
    str(update.message.chat_id), str(response),
    reply_to=str(update.message.message_id),
)
if send_result["cancel"]:
    ...  # only cancels if an external MESSAGE_SENDING hook returns cancel
# src/praisonai/praisonai/bots/_protocol_mixin.py:158-202
def fire_message_sending(self, channel_id, content, reply_to=None) -> Dict[str, Any]:
    """Hooks can modify content or cancel the send."""
    result = {"content": content, "cancel": False}
    ...
    if runner.is_blocked(hook_results):
        result["cancel"] = True
    ...

So cancellation is possible only if the operator writes and wires a custom MESSAGE_SENDING lifecycle hook that inspects content and returns a block decision. That is:

  • not built-in / not safe-by-default,
  • generic content cancellation by an external hook — not the agent self-determining silence as part of its own reasoning,
  • bespoke per deployment, with no canonical, prompt-discoverable contract telling the model how to signal "no reply".

Confirmed absent:

  • grep -rniE "intentional_silence|NO_REPLY|\[SILENT\]" src/praisonai-agents/praisonaiagents src/praisonai/praisonai/bots → no matches (no canonical silence token, no predicate, no system-prompt seed).
  • No bot module suppresses delivery based on an agent-emitted control token; a blank/empty reply is also not treated as deliberate silence (it falls on the empty/error path, not a silence path).

Desired behaviour

A first-class, opt-in intentional silence capability:

  1. A single canonical, documented control token (e.g. NO_REPLY) the agent may emit as its entire response to mean "deliberately send nothing".
  2. The gateway recognises the token in the shared delivery path so every adapter suppresses delivery identically (no placeholder left behind, no error surfaced, run still recorded in session/mirror so the agent keeps context of what it chose to skip).
  3. Strict matching: a substantive reply that merely mentions NO_REPLY is delivered normally; only an exact-token reply is treated as silence. A blank reply is not silence (that stays on the empty/error path).
  4. When enabled for a channel, the agent's system prompt is automatically seeded with the contract ("If a group message needs no response from you, reply with exactly NO_REPLY"), so the behaviour is discoverable without the user hand-writing prompt text.

This unlocks group_policy: respond_all as a genuinely usable mode: the agent sees every message but only speaks when useful.

Layer placement

  • Primary layer: wrapper (src/praisonai/praisonai/bots/) — recognise the silence token in the shared fire_message_sending / delivery path and suppress send, so all adapters honour it with one change; add the per-channel toggle and prompt seed.
  • Why not core: core stays protocol/contract-only; the actual delivery suppression is bot transport behaviour. (Core gains only a tiny dependency-free helper — see secondary touch.)
  • Why not tools: silence is not an agent-callable capability invoked mid-task; it is a property of the final reply on the delivery lifecycle.
  • Why not plugins: this is intrinsic, safe-by-default gateway behaviour every group deployment needs, not an optional cross-cutting policy. (A guardrail plugin could approximate it via a MESSAGE_SENDING hook, but that is exactly today's bespoke, non-default situation this issue removes.)
  • Secondary touch (core): add a pure, zero-dependency helper + constant in praisonaiagents (e.g. praisonaiagents.bots.silence: SILENT_REPLY_TOKEN and is_intentional_silence_response(text) -> bool) so the contract lives once in core and the wrapper + any consumer reuse it.
  • 3-way surface (CLI + YAML + Python): yes — gateway.yaml channels.<name>.allow_silence: true (+ optional custom token), CLI praisonai bot telegram --allow-silence, Python Bot("telegram", agent=agent, allow_silence=True).

Proposed approach

  • Extension point: core helper/constant (contract) + wrapper recognition in the shared send path + per-channel config flag + automatic system-prompt seed.
  • Minimal API sketch:
# core: praisonaiagents/bots/silence.py  (pure, no heavy imports)
SILENT_REPLY_TOKEN = "NO_REPLY"
_MARKERS = frozenset({"NO_REPLY", "[SILENT]", "SILENT"})

def is_intentional_silence_response(text: str | None) -> bool:
    """True only when the reply is *exactly* a silence marker (not prose mentioning it)."""
    if not text:
        return False                      # blank != deliberate silence
    return text.strip().strip("[]").upper() in _MARKERS
# wrapper: src/praisonai/praisonai/bots/_protocol_mixin.py  (shared by all adapters)
def fire_message_sending(self, channel_id, content, reply_to=None):
    if getattr(self, "_allow_silence", False) and is_intentional_silence_response(content):
        return {"content": "", "cancel": True, "silent": True}   # suppress uniformly
    ...  # existing hook logic unchanged

Resolution sketch

# Before (today): only two options, neither is "smart selective"
channels:
  telegram:
    token: ${TELEGRAM_BOT_TOKEN}
    group_policy: respond_all     # replies to EVERY message (spam)
    # or mention_only             # can NEVER help proactively
# After (proposed): ambient assistant — reads all, speaks when useful
channels:
  telegram:
    token: ${TELEGRAM_BOT_TOKEN}
    group_policy: respond_all
    allow_silence: true           # agent may emit NO_REPLY to stay quiet
# Agent reasoning, unchanged API — the gateway honours the choice:
#   user (group): "lol same"            -> agent replies "NO_REPLY"  -> nothing sent
#   user (group): "how do I reset it?"  -> agent answers the question -> delivered

Severity

High — selective participation is the defining behaviour of a group/ambient chat assistant. Without it, respond_all is unusable in real groups and mention_only cannot proactively help; the only workaround is bespoke per-deployment hook code. The fix is small (one shared delivery-path change + a dependency-free core helper + a config flag) and the value to gateway ease-of-use and "feels world-class in a group" is large.

Validation

  • src/praisonai/praisonai/bots/_config_schema.py:149,202-208group_policy gates inbound only (respond_all/mention_only/command_only); no output-side silence concept.
  • src/praisonai/praisonai/bots/discord.py:270-274 (and equivalents) — should_respond is static inbound gating, not agent-decided.
  • src/praisonai/praisonai/bots/_protocol_mixin.py:158-202 — the shared fire_message_sending can only cancel via an external MESSAGE_SENDING hook returning a block decision; there is no built-in agent-emitted silence token.
  • src/praisonai/praisonai/bots/telegram.py:403-415 — confirms the reply is delivered unless an external hook cancels; a blank reply is treated as empty/error, not deliberate silence.
  • grep -rniE "intentional_silence|NO_REPLY|\[SILENT\]" src/praisonai-agents src/praisonai/praisonai/bots → no matches: no canonical token, predicate, or prompt seed exists in core or wrapper.
  • All seven adapters (telegram/slack/discord/whatsapp/email/agentmail + mixin) route through fire_message_sending, so a single shared change covers every channel.

Metadata

Metadata

Assignees

No one assigned

    Labels

    botsclaudeAuto-trigger Claude analysisenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions