fix(gateway): a turn that answers an addressed message keeps the silence fallback

reply_expected was read from the event that opened the turn, so an addressed
message the same turn ended up answering could still end on a bare silence
marker and vanish:

- a queued chain paired the terminal turn's display kind with the opener's
  flag; the recursive run now receives the pending event's flag, persists
  it, and returns it as queued_terminal_reply_expected beside
  queued_terminal_display_kind for the outer shaping to read;
- pending-message merges (merge_pending_message_event, text batching, busy
  debounce) and an active-turn redirect folded the new message in but kept
  the old flag; MessageEvent.absorb_reply_expected now folds it: an
  addressed message wins, then an unknown one.

Also: reply_expected is persisted only when the adapter set it, so rows on
other platforms carry no null key; the turn_params pop and getattr reads
go (turn_params already flows into TurnContext); silence_allowed is a
module import; crash recovery keeps the diagnostic-mute check on machinery
turns and applies silence_allowed only to the silence verdict; the DEBUG
line no longer fires for machinery turns.
This commit is contained in:
kshitijk4poor
2026-09-26 06:27:11 +05:30
committed by kshitij
parent 6c566fcd6d
commit 2ed91ca39d
9 changed files with 74 additions and 26 deletions

View File

@@ -1765,6 +1765,7 @@ def merge_pending_message_event(pending_messages: Dict[str, MessageEvent], sessi
existing.media_text_inlined.extend(incoming_inline_flags)
if event.text:
existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)
existing.absorb_reply_expected(event)
if existing_is_photo or incoming_is_photo:
existing.message_type = MessageType.PHOTO
elif existing_type == MessageType.TEXT and event.message_type != MessageType.TEXT:
@@ -1779,6 +1780,7 @@ def merge_pending_message_event(pending_messages: Dict[str, MessageEvent], sessi
if merge_text and both_text:
if event.text:
existing.text = _append_text(existing.text, event.text)
existing.absorb_reply_expected(event)
return
pending_messages[session_key] = event
@@ -2515,6 +2517,7 @@ class BasePlatformAdapter(ABC):
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
existing.absorb_reply_expected(event)
existing._last_chunk_len = len(event.text or "") # type: ignore[attr-defined]
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
@@ -3784,6 +3787,7 @@ class BasePlatformAdapter(ABC):
else:
if event.text:
state.event.text = _append_text(state.event.text, event.text)
state.event.absorb_reply_expected(event)
latest_message_id = getattr(event, "message_id", None)
latest_anchor = latest_message_id or getattr(event, "reply_to_message_id", None)
if latest_message_id is not None:

View File

@@ -97,6 +97,11 @@ class MessageEvent:
# 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 absorb_reply_expected(self, other: "MessageEvent") -> None:
"""One turn now answers *other* too: an addressed message wins, then an unknown one."""
if self.reply_expected is not True and other.reply_expected is not False:
self.reply_expected = other.reply_expected
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("/")

View File

@@ -128,6 +128,12 @@ def silence_allowed(display_kind: Any, reply_expected: Optional[bool] = None) ->
return is_machinery_display_kind(display_kind) or reply_expected is False
def reply_expected_metadata(reply_expected: Optional[bool]) -> dict:
"""The persisted user row's ``reply_expected`` key, only when the adapter knew; crash recovery
reads it back to judge a silence marker as the live turn did."""
return {} if reply_expected is None else {"reply_expected": reply_expected}
def is_partial_silence_marker(text: Any) -> bool:
"""True while streamed ``text`` could still resolve to a silence marker.

View File

@@ -644,9 +644,12 @@ class GatewayBusySessionMixin:
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
async def _interrupt_running_agent_for_busy_event(self, event: MessageEvent, adapter, running_agent) -> None:

View File

@@ -760,10 +760,13 @@ class GatewayStartupMixin:
def _crash_left_reply(self, history: list, started: float, origin) -> Optional[str]:
"""What a crash-left turn owes, judged as live delivery would have: ``None`` when it never
persisted a final reply after *started*; ``""`` when nothing would have been presented (a
silence marker on a machinery turn, a muted diagnostic wake); else the text to send, with a
human turn's bare silence marker replaced by the same notice the live path sends."""
silence marker on a machinery turn or on a turn the adapter reported as not addressed to the
bot, a muted diagnostic wake); else the text to send, with any other bare silence marker
replaced by the same notice the live path sends."""
from gateway.platforms.base import _strip_media_directives
from gateway.response_filters import is_intentional_silence_response, silence_allowed
from gateway.response_filters import (
is_intentional_silence_response, is_machinery_display_kind, silence_allowed,
)
from gateway.run import _sanitize_gateway_final_response
from gateway.run_turn import _UNEXPECTED_SILENCE_REPLY
from gateway.warning_notifications import diagnostic_turn_muted
@@ -774,11 +777,7 @@ class GatewayStartupMixin:
or (coerce_epoch(last.get("timestamp")) or 0) < started):
return None
prompt = next((m for m in reversed(visible) if m.get("role") == "user"), {})
machinery = silence_allowed(
prompt.get("display_kind"),
(prompt.get("display_metadata") or {}).get("reply_expected"),
)
if machinery:
if is_machinery_display_kind(prompt.get("display_kind")):
try: # the owning profile's display policy, as the adapter reads it at delivery
scope = self._media_delivery_scope_for_source(origin)
except Exception:
@@ -788,7 +787,9 @@ class GatewayStartupMixin:
if diagnostic_turn_muted(prompt.get("display_metadata"), origin.platform):
return ""
if is_intentional_silence_response(last["content"]):
return "" if machinery else _UNEXPECTED_SILENCE_REPLY
silent_ok = silence_allowed(
prompt.get("display_kind"), (prompt.get("display_metadata") or {}).get("reply_expected"))
return "" if silent_ok else _UNEXPECTED_SILENCE_REPLY
return _strip_media_directives(_sanitize_gateway_final_response(origin.platform, last["content"])).strip() or None
@staticmethod

View File

@@ -25,7 +25,9 @@ from gateway.config import Platform
from gateway.media_repair import repair_explicit_computer_use_media_paths
from gateway.platforms.base import BasePlatformAdapter, ProcessingOutcome
from gateway.platforms.event import MessageEvent
from gateway.response_filters import display_kind_for_event, is_machinery_display_kind
from gateway.response_filters import (
display_kind_for_event, is_machinery_display_kind, reply_expected_metadata, silence_allowed,
)
from gateway.warning_notifications import diagnostic_metadata, diagnostic_turn_muted, diagnostic_wake_muted
from gateway.session import (
SessionSource, _session_key_namespace, build_channel_continuity_note,
@@ -1527,7 +1529,6 @@ class GatewayTurnMixin:
logging, resume-pending clear, empty-response normalization, and identity-guarded
post-compression session_id propagation. Returns
``(response, _intentional_silence, agent_messages)``."""
from gateway.response_filters import silence_allowed
from gateway.run import (
_is_gateway_hidden_reasoning_incomplete_turn, _normalize_empty_agent_response,
_sanitize_gateway_final_response, _should_clear_resume_pending_after_turn,
@@ -1541,14 +1542,15 @@ class GatewayTurnMixin:
# 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.
_silence_kind = agent_result.get("queued_terminal_display_kind", persist_user_display_kind)
if _intentional_silence and not silence_allowed(_silence_kind, reply_expected):
_silence_reply_expected = agent_result.get("queued_terminal_reply_expected", reply_expected)
if _intentional_silence and not silence_allowed(_silence_kind, _silence_reply_expected):
logger.warning(
"silence marker rejected on a user turn: platform=%s chat=%s",
_platform_name, source.chat_id or "unknown",
)
_intentional_silence = False
response = _UNEXPECTED_SILENCE_REPLY
elif _intentional_silence and reply_expected is False:
elif _intentional_silence and not is_machinery_display_kind(_silence_kind):
logger.debug(
"silence marker suppressed on an unaddressed turn: platform=%s chat=%s",
_platform_name, source.chat_id or "unknown",
@@ -2209,11 +2211,10 @@ class GatewayTurnMixin:
persist_user_message=prepared.persist_user_message,
persist_user_timestamp=prepared.persist_user_timestamp,
persist_user_display_kind=prepared.persist_user_display_kind,
reply_expected=getattr(event, "reply_expected", None),
reply_expected=event.reply_expected,
persist_user_display_metadata={
"gateway_input_owner": prepared.persistence_owner,
"reply_expected": getattr(event, "reply_expected", None),
**diagnostic_metadata(event)},
**reply_expected_metadata(event.reply_expected), **diagnostic_metadata(event)},
message_type=event.message_type,
scheduled_heartbeat=bool(getattr(event, "_heartbeat_session_id", None)),
)
@@ -2242,7 +2243,7 @@ class GatewayTurnMixin:
agent_result, source, history, session_entry, session_key,
_quick_key, run_generation, _run_start_session_id, _platform_name, _msg_start_time,
persist_user_display_kind=prepared.persist_user_display_kind,
reply_expected=getattr(event, "reply_expected", None),
reply_expected=event.reply_expected,
)
response = self._hmwa_prepend_reasoning(agent_result, response, source, _intentional_silence)
_footer_line = self._hmwa_runtime_footer_line(agent_result, source, _turn_seconds)
@@ -3104,7 +3105,7 @@ class GatewayTurnMixin:
# The one-slot progress/holder containers shared with the callbacks are TurnContext defaults.
turn_ctx = TurnContext(
source=source, reply_expected=turn_params.pop("reply_expected", None), message=message, AIAgent=AIAgent, session_key=session_key,
source=source, message=message, AIAgent=AIAgent, session_key=session_key,
run_generation=run_generation, _cleanup_progress=_cleanup_progress,
_run_still_current=self._run_still_current_fn(session_key, run_generation),
progress_queue=queue.Queue() if disp.needs_progress_queue else None,
@@ -3737,8 +3738,7 @@ class GatewayTurnMixin:
)
# Same silence predicate as the normal path, else this branch leaks the literal marker.
if self._is_intentional_silence(_delivery_result, first_response):
from gateway.response_filters import silence_allowed
if silence_allowed(turn_ctx.persist_user_display_kind, getattr(turn_ctx, "reply_expected", None)):
if silence_allowed(turn_ctx.persist_user_display_kind, turn_ctx.reply_expected):
logger.info(
"Queued follow-up for session %s: suppressing intentional silence marker before continuing.",
session_key or "?",
@@ -3841,6 +3841,7 @@ class GatewayTurnMixin:
# Queued Discord turns carry the same routing note as first turns; persist the authored text.
next_persist_message = None
next_display_kind = display_kind_for_event(pending_event)
next_reply_expected = pending_event.reply_expected if pending_event is not None else None
# See #60671.
if pending_event is not None:
next_source = getattr(pending_event, "source", None) or source
@@ -3917,7 +3918,9 @@ class GatewayTurnMixin:
channel_prompt=next_channel_prompt, message_type=next_message_type,
persist_user_message=next_persist_message,
persist_user_display_kind=next_display_kind,
persist_user_display_metadata=diagnostic_metadata(pending_event) or None,
reply_expected=next_reply_expected,
persist_user_display_metadata={
**reply_expected_metadata(next_reply_expected), **diagnostic_metadata(pending_event)} or None,
)
except asyncio.CancelledError:
await _run_followup_processing_hook(
@@ -3941,6 +3944,7 @@ class GatewayTurnMixin:
**merged,
"queued_terminal_inbound_id": next_inbound_id,
"queued_terminal_display_kind": next_display_kind,
"queued_terminal_reply_expected": next_reply_expected,
"queued_terminal_notification_category": (
(pending_event.metadata or {}).get("notification_category", "result")
if pending_event is not None and pending_event.internal else "result"),

View File

@@ -76,6 +76,7 @@ async def test_queued_followup_persists_authored_text():
)
pending_event = SimpleNamespace(
source=source, message_id="6002", channel_prompt=None, message_type=None, internal=False, metadata={},
reply_expected=None,
)
await GatewayRunner._run_agent_queued_followup(

View File

@@ -195,6 +195,7 @@ async def test_queued_human_turn_also_gets_the_visible_fallback():
stream_consumer_holder=[None],
mute_notification_reply=False,
persist_user_display_kind=None,
reply_expected=None,
source=_source(),
_status_thread_metadata=None,
event_message_id=None,
@@ -227,20 +228,25 @@ async def test_queued_terminal_turn_owns_the_silence_verdict(monkeypatch, tmp_pa
run_generation=1, _interrupt_depth=0, history=[], _status_thread_metadata=None,
context_prompt=None, result_holder=[None])
pending_event = SimpleNamespace(source=_source(), message_id="43", channel_prompt=None,
message_type=None, internal=True, metadata={})
message_type=None, internal=True, metadata={}, reply_expected=True)
merged = await gateway_run.GatewayRunner._run_agent_queued_followup(
runner, turn_ctx, adapter=None, pending="hi again", pending_event=pending_event,
response="resp", result={"interrupted": True, "messages": []}, stream_task=None)
assert runner._run_agent.await_args.kwargs["persist_user_display_kind"] == "internal_notification"
followup = runner._run_agent.await_args.kwargs
assert followup["persist_user_display_kind"] == "internal_notification"
assert followup["reply_expected"] is True
assert followup["persist_user_display_metadata"]["reply_expected"] is True
assert merged["queued_terminal_display_kind"] == "internal_notification"
assert merged["queued_terminal_reply_expected"] is True
def _result(terminal_kind):
def _result(terminal_kind, terminal_reply_expected=None):
return {
"final_response": "[SILENT]", "tools": [], "history_offset": 0, "last_prompt_tokens": 0,
"api_calls": 1, "failed": False, "queued_terminal_inbound_id": "43",
"queued_terminal_display_kind": terminal_kind,
"queued_terminal_reply_expected": terminal_reply_expected,
"messages": [{"role": "user", "content": "x"}, {"role": "assistant", "content": "[SILENT]"}],
}
@@ -255,6 +261,24 @@ async def test_queued_terminal_turn_owns_the_silence_verdict(monkeypatch, tmp_pa
response = await runner._handle_message_with_agent(
_event(internal=True), _source(), "agent:main:telegram:group:-1001:12345", 1)
assert response and not is_intentional_silence_response(response)
# Unaddressed opener, addressed terminal turn: visible fallback.
runner = _runner(monkeypatch, tmp_path)
runner._run_agent = AsyncMock(return_value=_result(None, True))
response = await runner._handle_message_with_agent(
_event(reply_expected=False), _source(), "agent:main:telegram:group:-1001:12345", 1)
assert response and not is_intentional_silence_response(response)
@pytest.mark.parametrize("opener, absorbed, merged", [
(False, True, True), (False, None, None), (True, False, True), (False, False, False),
])
def test_one_turn_answering_several_messages_is_addressed_if_any_was(opener, absorbed, merged):
"""A merged pending message answers both texts, so an addressed one keeps the fallback."""
from gateway.platforms.base import merge_pending_message_event
pending = {"k": _event(reply_expected=opener)}
merge_pending_message_event(pending, "k", _event(reply_expected=absorbed), merge_text=True)
assert pending["k"].reply_expected is merged
@pytest.mark.asyncio

View File

@@ -211,7 +211,7 @@ def _chain_runner_and_ctx(followup_return):
context_prompt=None, result_holder=[None])
pending_event = SimpleNamespace(
source=topic, message_id="6002", channel_prompt=None, message_type=None,
internal=False, metadata={})
internal=False, metadata={}, reply_expected=None)
return GatewayRunner, runner, turn_ctx, pending_event
@@ -239,7 +239,7 @@ async def test_a_chained_queued_turn_carries_its_own_inbound_id():
context_prompt=None, result_holder=[None])
pending_event = SimpleNamespace(
source=topic, message_id="6002", channel_prompt=None, message_type=None,
internal=False, metadata={})
internal=False, metadata={}, reply_expected=None)
await GatewayRunner._run_agent_queued_followup(
runner, turn_ctx, adapter=None, pending="hi again", pending_event=pending_event,