Files
hermes-agent/gateway/platforms/event.py
Victor Kyriazakos b08bb5afb8 fix(gateway): a bare silence marker on a turn not addressed to the bot stays silent
Since 5ea8fb2b78 (#111624, for #110952) the gateway rejects a bare silence
marker on any human turn and delivers "The model returned only a silence
marker for a message that needed a reply" instead. That protects a human
who asked this bot something and got nothing back. It also fires on every
human message the adapter admitted without the bot being addressed at all:
a free-response channel, a thread follow-up under
`thread_require_mention: false`, or a message @-mentioning another person
or bot with `ignore_other_user_mentions: false`. A bot whose SOUL declines
peer-addressed turns with a deliberate marker now posts that notice on
every such message. A fleet running several bots in shared Slack threads
reported it as spam on v2026.9.21. #37940 established that intentional
silence must not be re-inflated. Both contracts hold once the turn knows
whether a reply was expected.

`MessageEvent.reply_expected` (True, False, None) is set by the adapter
where the message is admitted. Slack (`slack_reply_expected`): a 1:1 DM,
an @mention of this bot or a command is True, anything else it admits is
False. Other adapters leave None, which keeps today's behaviour, so nothing
changes for them until they are ported. `response_filters.silence_allowed`
holds the one rule (machinery turn, or reply not expected) and both call
sites use it: the live turn in `run_turn._hmwa_shape_agent_response` and
the crash-recovery redelivery from #120377 (1136f135dd), which reads the
flag back from the persisted turn metadata. The suppressed case logs one
DEBUG line naming platform and chat.

Operator workaround until this lands: `platforms.slack.extra.
ignore_other_user_mentions: true` drops peer-addressed messages before a
turn exists.

(cherry picked from commit 094439776ab898cccde303a1c2c911c8ab5bfb75)
2026-09-26 07:21:49 +05:30

120 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Inbound message event types shared by every gateway platform adapter.
A leaf module: adapters, helpers and the runner import it, so it must not import from
gateway.platforms.*.
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
from gateway.session import SessionSource
class MessageType(Enum):
"""Types of incoming messages."""
TEXT = "text"
LOCATION = "location"
PHOTO = "photo"
VIDEO = "video"
AUDIO = "audio"
VOICE = "voice"
DOCUMENT = "document"
STICKER = "sticker"
COMMAND = "command" # /command style
class ProcessingOutcome(Enum):
"""Result classification for message-processing lifecycle hooks."""
SUCCESS = "success"
FAILURE = "failure"
CANCELLED = "cancelled"
@dataclass
class MessageEvent:
"""Incoming message from a platform — the normalized shape all adapters produce."""
text: str
message_type: MessageType = MessageType.TEXT
# Author, mirrored from ``source`` for per-message prompt builders; None for non-IM sources.
user_id: Optional[str] = None
user_name: Optional[str] = None
# None only in isolated unit tests; production always sets it. Typing it Optional
# exposes ~60 unguarded ``.source.<attr>`` reads, so that is a separate change.
source: SessionSource = None
raw_message: Any = None
message_id: Optional[str] = None
# Delivery-ledger identity for the final send, when it differs from ``message_id``. A queued
# (/queue) chain answers the LAST message of the chain, so its final send has to be ledgered
# under that message's id. Keyed on the opening event's id instead, two chained turns carrying
# the same text collide on one obligation id and the earlier turn's row is overwritten (a
# refused first reply then reads as delivered). Reply routing is unaffected: the reply anchor
# still comes from this event.
ledger_message_id: Optional[str] = None
# Reply anchor for the final send when the answer is to a DIFFERENT message than the one that
# opened the turn: a successful busy redirect turns the running turn onto the redirecting
# message, so its reply must quote that message (#115001). ``_reply_anchor_for_event``
# honours this over ``message_id``; None = derive from the event as usual.
reply_anchor_override: Optional[str] = None
# Platform update id (Telegram ``update_id``): ``/restart`` records it so the new gateway
# advances past it even if PTB's shutdown ACK times out.
platform_update_id: Optional[int] = None
# Media attachments: local file paths (for vision tool access)
media_urls: List[str] = field(default_factory=list)
media_types: List[str] = field(default_factory=list)
# Per-attachment text-inlining contract; None = legacy "text/* already inlined into ``text``".
media_text_inlined: List[Optional[bool]] = field(default_factory=list)
reply_to_message_id: Optional[str] = None
reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection)
reply_to_author_id: Optional[str] = None
reply_to_author_name: Optional[str] = None
reply_to_is_own_message: bool = False # True when the user replied to this bot/assistant's message
# Structured interactive-prompt reply (relay only): {prompt_id, option_id, label?,
# prompt_message_id?}; routed to the approval/slash-confirm/clarify resolvers BEFORE dispatch.
prompt_response: Optional[Dict[str, Any]] = None
# Auto-loaded skill(s) for topic/channel bindings; a single name or ordered list.
auto_skill: Optional[str | list[str]] = None
# Per-channel ephemeral system prompt; applied at API call time, never persisted to transcript.
channel_prompt: Optional[str] = None
# History-backfilled channel context (missed under require_mention); kept out of ``text`` so
# run.py's sender-prefix logic sees only the trigger message.
channel_context: Optional[str] = None
# Set for synthetic events (e.g. background-process notifications) that must bypass user authorization.
internal: bool = False
# Free-form per-event metadata (e.g. ``whatsapp_from_owner=True``); plugins must ``.get()``.
metadata: Dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
# May this event resolve gateway commands / control prompts? Proactive plugin events set False
# so untrusted payload text stays conversational. Kept last for positional compat.
allow_gateway_control: bool = True
# Whether this inbound turn was addressed to this bot. False means the adapter admitted a
# free-response or peer-addressed message, None means the adapter cannot determine it.
reply_expected: Optional[bool] = None
# Process-local admission receipt, never routing metadata or execution acknowledgement.
_gateway_accepted: bool = field(default=False, init=False, repr=False, compare=False)
# Run-owned final presentation snapshot; never deserialized from ingress metadata.
_notification_reply_muted: Optional[bool] = field(default=None, init=False, repr=False, compare=False)
def is_command(self) -> bool:
"""Check if this is a command message (e.g., /new, /reset)."""
return self.allow_gateway_control and (self.text or "").lstrip().startswith("/")
def get_command(self) -> Optional[str]:
"""Extract command name if this is a command message."""
if not self.is_command():
return None
raw = (self.text or "").lstrip().split(maxsplit=1)[0][1:].lower().split("@", 1)[0]
# Reject file paths: valid command names never contain /
return None if "/" in raw else raw
def get_command_args(self) -> str:
"""Get the arguments after a command."""
if not self.is_command():
return self.text
parts = (self.text or "").lstrip().split(maxsplit=1)
args = parts[1] if len(parts) > 1 else ""
# iOS auto-corrects -- to — (em dash) and - to – (en dash)
return args.replace("\u2014\u2014", "--").replace("\u2014", "--").replace("\u2013", "-")