Files
hermes-agent/gateway/turn_context.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

101 lines
4.8 KiB
Python

"""Per-turn context shared between ``GatewayRunner._run_agent_inner`` and ``TurnRunner``.
Each ex-closure local is a field, written once by ``_run_agent_inner`` while wiring the turn.
``message`` (ex-``nonlocal``) is the only rebindable field; other mutable state uses
single-element lists so mutation stays visible to the outer body.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, List, Optional
@dataclass
class TurnContext:
# read-only turn identity / wiring
source: Any = None
reply_expected: Optional[bool] = None
# Scheduled heartbeats are proactive work, not replies to the source message that
# registered the watch. Their routine delivery surfaces stay quiet.
scheduled_heartbeat: bool = False
_run_still_current: Callable[[], bool] = None # type: ignore[assignment]
_live_status_adapter: Any = None
_live_status_mode: str = "off"
_thinking_enabled: bool = False
progress_mode: str = "off"
progress_grouping: str = "grouped"
tool_progress_enabled: bool = False
progress_queue: Any = None
log_queue: Any = None
# mutable single-element containers (shared with the outer body)
last_progress_msg: list = field(default_factory=lambda: [None])
last_tool: list = field(default_factory=lambda: [None])
last_was_terminal_block: list = field(default_factory=lambda: [False])
repeat_count: list = field(default_factory=lambda: [0])
long_tool_hint_fired: list = field(default_factory=lambda: [False])
agent_holder: list = field(default_factory=lambda: [None])
_LONG_TOOL_THRESHOLD_S: float = 30.0
_cleanup_progress: bool = False
_cleanup_msg_ids: List[str] = field(default_factory=list)
_progress_metadata: Optional[dict] = None
_progress_reply_to: Optional[Any] = None
message: Optional[str] = None # the only rebindable field
# turn parameters / config snapshots (read-only in run_sync)
history: Any = None
context_prompt: Optional[str] = None
channel_prompt: Optional[str] = None
session_id: Optional[str] = None
session_key: Optional[str] = None
run_generation: Optional[int] = None
process_task_id: str = ""
process_baseline: frozenset[str] = field(default_factory=frozenset)
_interrupt_depth: int = 0
event_message_id: Optional[str] = None
# Raw inbound platform id (not the event_message_id reply anchor); stamped on the user turn.
inbound_message_id: Optional[str] = None
moa_config: Optional[dict] = None
persist_user_message: Optional[Any] = None
persist_user_timestamp: Optional[float] = None
# display_kind of the persisted user row for a self-injected turn; DB-only, never sent.
# "internal_notification" for async-delegation/background notifications (#82888).
persist_user_display_kind: Optional[str] = None
persist_user_display_metadata: Optional[dict] = None
user_config: Any = None
mute_notification_reply: bool = False
enabled_toolsets: Any = None
disabled_toolsets: Any = None
log_mode_enabled: bool = False
interim_assistant_messages_enabled: bool = False
needs_progress_queue: bool = False
AIAgent: Any = None
resolve_display_setting: Any = None
result_holder: list = field(default_factory=lambda: [None])
tools_holder: list = field(default_factory=lambda: [None])
stream_consumer_holder: list = field(default_factory=lambda: [None])
streaming_tts_consumer_holder: list = field(default_factory=lambda: [None])
# voice-ack wiring
_voice_ack_fired: list = field(default_factory=lambda: [False])
_voice_ack_guild: list = field(default_factory=lambda: [None])
_voice_ack_loop: Any = None
# hook / status bridge wiring
_loop_for_step: Any = None
_hooks_ref: Any = None
_status_adapter: Any = None
_status_chat_id: Any = None
_status_thread_metadata: Optional[dict] = None
# bound TurnRunner callbacks read via ctx
progress_callback: Optional[Callable] = None
voice_ack_callback: Optional[Callable] = None
_step_callback_sync: Optional[Callable] = None
_event_callback_sync: Optional[Callable] = None
_status_callback_sync: Optional[Callable] = None
# Slack-native task cards (opt-in); ID-bearing callbacks correlate start/complete by call ID
# --- Slack-native task-card progress (opt-in; #29483) ------------------ True when the Slack adapter's
# ``native_task_cards_enabled()`` opt-in is set for this turn's platform. The ID-bearing lifecycle
# callbacks are published by TurnRunner (like voice_ack_callback above) so tool starts and completions
# correlate by real tool-call ID instead of tool name.
_native_slack_task_cards: bool = False
native_tool_start_callback: Optional[Callable] = None
native_tool_complete_callback: Optional[Callable] = None