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:
@@ -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.
|
||||
|
||||
@@ -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}'"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user