fix(gateway): steering an addressed message into a running turn keeps the silence fallback

Busy steer (the busy-mode and priority paths, and /steer) pushes a new message
into the running turn, which then answers it without a turn of its own.
Redirect already folded the message's reply_expected into the turn; steer
did not, so an unaddressed opener steered by an @mention could still end
on a bare silence marker. All of them now go through _fold_into_running_turn.

Also: stale comments on MessageEvent and the queued-terminal silence
verdict, and a crash-recovery case for an unaddressed row.
This commit is contained in:
kshitijk4poor
2026-09-26 06:37:36 +05:30
committed by kshitij
parent b17e037e35
commit 9fc7f17906
6 changed files with 58 additions and 12 deletions

View File

@@ -86,10 +86,11 @@ class MessageEvent:
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.
# so untrusted payload text stays conversational. New fields append after it (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.
# Was this message addressed to this bot? False lets a bare silence marker stand (the adapter
# knows the message was meant for someone else); None means unknown and keeps the visible
# fallback, like True.
reply_expected: Optional[bool] = None
# Process-local admission receipt, never routing metadata or execution acknowledgement.

View File

@@ -593,7 +593,9 @@ class GatewayBusySessionMixin:
steered = self._try_agent_verb(
running_agent, "steer", steer_text, session_key, event=event
)
if not steered:
if steered:
self._fold_into_running_turn(running_agent, session_key, event)
else:
effective_mode = "queue"
elif (
effective_mode == "interrupt" and plain_text and agent_live
@@ -636,22 +638,32 @@ class GatewayBusySessionMixin:
"""
if not self._try_agent_verb(running_agent, "redirect", text, session_key, event=event):
return False
turn = self._session_state(session_key).turn
if turn.agent is not running_agent:
turn = self._fold_into_running_turn(running_agent, session_key, event)
if turn is None:
return True # a newer turn already owns the slot; never re-anchor it
anchor = self._reply_anchor_for_event(event)
inbound_id = str(event.message_id) if event.message_id else None
if turn.event is not None and turn.event is not event:
turn.event.reply_anchor_override = anchor
turn.event.ledger_message_id = inbound_id
turn.event.absorb_reply_expected(event)
if turn.ctx is not None:
turn.ctx.event_message_id = anchor
turn.ctx.inbound_message_id = inbound_id
if turn.event is not None:
turn.ctx.reply_expected = turn.event.reply_expected
return True
def _fold_into_running_turn(self, running_agent, session_key: str, event: MessageEvent):
"""The running turn now answers *event* too (steer, redirect): if *event* was addressed to
the bot, a bare silence marker must not end the turn. Returns the turn, or None when a newer
turn already owns the slot."""
turn = self._session_state(session_key).turn
if turn.agent is not running_agent:
return None
if turn.event is not None and turn.event is not event:
turn.event.absorb_reply_expected(event)
if turn.ctx is not None:
turn.ctx.reply_expected = turn.event.reply_expected
return turn
async def _interrupt_running_agent_for_busy_event(self, event: MessageEvent, adapter, running_agent) -> None:
"""Interrupt mode: abort in-flight tool calls; the agent loop exits at its next check point."""
from gateway.run import _build_media_placeholder
@@ -1061,6 +1073,7 @@ class GatewayBusySessionMixin:
return f"⚠️ Steer failed: {exc}"
if not accepted:
return "Steer rejected (empty payload)."
self._fold_into_running_turn(running_agent, quick_key, event)
preview = steer_text[:60] + ("..." if len(steer_text) > 60 else "")
target = "run and its active subagent(s)" if self._agent_has_active_subagents(running_agent) else "run"
return f"⏩ Steer queued into current {target} — arrives after the next tool call: '{preview}'"

View File

@@ -642,6 +642,7 @@ class GatewayInboundMixin:
except Exception as exc:
logger.warning("PRIORITY steer failed for session %s: %s", _quick_key, exc)
if steered:
self._fold_into_running_turn(running_agent, _quick_key, event)
logger.debug("PRIORITY steer for session %s", _quick_key)
return
logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key)

View File

@@ -1540,7 +1540,8 @@ class GatewayTurnMixin:
response = ""
_intentional_silence = self._is_intentional_silence(agent_result, response)
# A queued (/queue) chain's TERMINAL turn owns the silence verdict, not the event that
# opened the chain: an internal follow-up may go silent, a human one must not.
# opened the chain: an internal follow-up, or a message not addressed to the bot, may go
# silent; any other human one must not.
_silence_kind = agent_result.get("queued_terminal_display_kind", persist_user_display_kind)
_silence_reply_expected = agent_result.get("queued_terminal_reply_expected", reply_expected)
if _intentional_silence and not silence_allowed(_silence_kind, _silence_reply_expected):