From abe539acdd303cdff13bddc4bd8a739dfa03bf58 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Mon, 28 Sep 2026 07:39:25 +0000 Subject: [PATCH 1/2] fix(gateway): stop synthetic goal/heartbeat turns from stomping channel_pin Goal continuation, heartbeat due-prompts and /goal resume events are built non-internal (they must still pass authorization) with channel_prompt=None. _pinned_channel_inputs() only reused the pin for internal events, so these synthetic turns recorded (None, parent_chat_id) as the new channel_pin, dropping the channel prompt and channel_overrides for that turn and flipping them back on the next human turn (#125763/#124179-style A->B->A: shorter ephemeral prompt, changed _agent_config_signature, cached-agent rebuild, prefix-cache miss). Add MessageEvent.inherit_channel_pin, independent of `internal` (which also gates authorization bypass and touch_activity), and set it on the three producers that carry no channel inputs of their own. _pinned_channel_inputs now reuses the pin for internal OR pin-inheriting events. Fixes #126109 --- gateway/platforms/event.py | 5 ++ gateway/run_agent_cache.py | 13 ++--- gateway/run_goals.py | 5 +- gateway/run_turn.py | 9 ++-- gateway/slash_commands_goals.py | 1 + .../gateway/test_internal_event_pin_wiring.py | 50 +++++++++++++++++++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/event.py b/gateway/platforms/event.py index 4e9faa4097668..5b3c76c744b66 100644 --- a/gateway/platforms/event.py +++ b/gateway/platforms/event.py @@ -82,6 +82,11 @@ class MessageEvent: channel_context: Optional[str] = None # Set for synthetic events (e.g. background-process notifications) that must bypass user authorization. internal: bool = False + # Set for synthetic turns that carry no channel inputs of their own (goal continuation, + # heartbeat due-prompts, /goal resume) so the turn reuses the session's pinned channel + # prompt/parent instead of overwriting it with this event's own None (#126109). Independent + # of ``internal``: these events still go through user authorization. + inherit_channel_pin: 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) diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index e186721223cc9..32a20b1bbc81d 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -642,18 +642,19 @@ def _pinned_session_context_prompt( return text def _pinned_channel_inputs( - self, session_key: Optional[str], channel_prompt: Optional[str], source: SessionSource, *, internal: bool, + self, session_key: Optional[str], channel_prompt: Optional[str], source: SessionSource, *, reuse_pin: bool, ): """``(channel_prompt, source)`` for this turn's agent run. The ephemeral system prompt also appends ``channel_prompt`` and the ``channel_overrides`` - prompt (looked up by chat/thread/``parent_chat_id``). Internal events carry - ``channel_prompt=None`` and a source without ``parent_chat_id``, so they dropped both and - toggled the system prompt like the context pin did. Human turns record their inputs; - internal turns reuse them.""" + prompt (looked up by chat/thread/``parent_chat_id``). Events with no channel inputs of + their own (internal events, and synthetic goal/heartbeat/resume turns — see + ``MessageEvent.inherit_channel_pin``) carry ``channel_prompt=None`` and a source without + ``parent_chat_id``, so they dropped both and toggled the system prompt like the context pin + did. Human turns record their inputs; turns that ask to reuse the pin get them back.""" if not session_key: return channel_prompt, source - if not internal: + if not reuse_pin: self._session_state(session_key).conversation.channel_pin = (channel_prompt, source.parent_chat_id) return channel_prompt, source state = self._peek_session_state(session_key) diff --git a/gateway/run_goals.py b/gateway/run_goals.py index 4bb20245512c3..979f6a3fdc19c 100644 --- a/gateway/run_goals.py +++ b/gateway/run_goals.py @@ -107,7 +107,10 @@ def _synthetic_prompt_event(source: Any, text: str, *, internal: bool = False) - would quote that stale message (Telegram DM topics route anchorless via the topic id). """ source = dataclasses.replace(source, message_id=None) if getattr(source, "message_id", None) else source - return MessageEvent(text=text, message_type=MessageType.TEXT, source=source, internal=internal) + return MessageEvent( + text=text, message_type=MessageType.TEXT, source=source, internal=internal, + inherit_channel_pin=True, + ) def _register_heartbeat_watch(self, quick_key: str, source: Any, session_id: str) -> None: """Track the canonical route and start the restart-recoverable poller.""" diff --git a/gateway/run_turn.py b/gateway/run_turn.py index b0efe8d0a685d..b2ea236be99a9 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -2195,9 +2195,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Admission/typing is not execution. All routing, authorization and # turn preparation gates have passed when the agent runner is entered. event._heartbeat_execution_started = True - # Internal events reuse the last human turn's channel inputs (see _pinned_channel_inputs). + # Internal and pin-inheriting synthetic events reuse the last human turn's channel + # inputs (see _pinned_channel_inputs). _turn_channel_prompt, _turn_source = self._pinned_channel_inputs( - session_key, event.channel_prompt, source, internal=event.internal, + session_key, event.channel_prompt, source, + reuse_pin=event.internal or getattr(event, "inherit_channel_pin", False), ) agent_result = await self._run_agent( message=message_text, context_prompt=prepared.context_prompt, history=history, source=_turn_source, @@ -3867,7 +3869,8 @@ async def _run_agent_queued_followup( next_message_id = self._reply_anchor_for_event(pending_event) next_inbound_id = str(pending_event.message_id) if getattr(pending_event, "message_id", None) else None next_channel_prompt, next_source = self._pinned_channel_inputs( - next_session_key, pending_event.channel_prompt, next_source, internal=pending_event.internal, + next_session_key, pending_event.channel_prompt, next_source, + reuse_pin=pending_event.internal or getattr(pending_event, "inherit_channel_pin", False), ) next_message_type = getattr(pending_event, "message_type", None) diff --git a/gateway/slash_commands_goals.py b/gateway/slash_commands_goals.py index 422beb0d4fc3a..809a819f2f46c 100644 --- a/gateway/slash_commands_goals.py +++ b/gateway/slash_commands_goals.py @@ -90,6 +90,7 @@ def _enqueue_goal_turn( source=event.source, message_id=event.message_id if kickoff else None, channel_prompt=event.channel_prompt if kickoff else None, + inherit_channel_pin=not kickoff, ) self._enqueue_fifo(quick_key, turn, adapter) except Exception as exc: diff --git a/tests/gateway/test_internal_event_pin_wiring.py b/tests/gateway/test_internal_event_pin_wiring.py index fc4f966955a99..7234938a10fa0 100644 --- a/tests/gateway/test_internal_event_pin_wiring.py +++ b/tests/gateway/test_internal_event_pin_wiring.py @@ -11,6 +11,7 @@ from __future__ import annotations +import dataclasses from datetime import datetime from unittest.mock import AsyncMock, MagicMock @@ -207,3 +208,52 @@ async def test_internal_event_keeps_channel_prompt_and_parent_override(monkeypat eph = [_effective_ephemeral(runner, kw) for kw in calls] assert "Channel hint." in eph[0] and "Parent persona." in eph[0] assert eph[0] == eph[1] == eph[2], "internal event toggled the channel ephemeral components" + + +@pytest.mark.asyncio +async def test_synthetic_non_internal_event_reuses_channel_pin(monkeypatch): + """Goal continuation / heartbeat due-prompt / ``/goal`` resume events are non-internal (they + must still pass authorization, unlike the kanban-wake ``internal=True`` case covered above) + but carry no channel inputs of their own — ``_synthetic_prompt_event`` always sets + ``channel_prompt=None``. Regression for #126109: without ``inherit_channel_pin``, that + ``None`` overwrote the session's ``channel_pin``, and the human turn after it lost its + channel prompt / parent-override too (A->B->A on ``channel_prompt``, independent of the + session-context pin covered by ``internal`` above).""" + config = GatewayConfig() + config.platforms[Platform.DISCORD] = PlatformConfig( + enabled=True, + channel_overrides={PARENT_ID: ChannelOverride(system_prompt="Parent persona.")}, + ) + runner = _make_runner(monkeypatch, config) + calls: list[dict] = [] + _capture(runner, calls) + + human = _human_thread_source() + # _synthetic_prompt_event() reuses the triggering source verbatim, stripping only + # message_id — chat_name/user_name/parent_chat_id survive, unlike the rebuilt-from-origin + # kanban wake source used elsewhere in this file. + synthetic_source = dataclasses.replace(human, message_id=None) + + for text, source, channel_prompt, inherit_channel_pin in ( + ("hi", human, "Channel hint.", False), + ("[goal] continuation", synthetic_source, None, True), + ("hi again", human, "Channel hint.", False), + ): + event = MessageEvent( + text=text, source=source, message_id=source.message_id, internal=False, + channel_prompt=channel_prompt, inherit_channel_pin=inherit_channel_pin, + ) + await runner._handle_message_with_agent(event, source, KEY, 1) + + assert len(calls) == 3 + # The synthetic middle turn carries its own channel_prompt=None but must resolve to the + # pinned "Channel hint." — and so must the human turn after it. + resolved_channel_prompts = [kw["channel_prompt"] for kw in calls] + assert resolved_channel_prompts == ["Channel hint."] * 3, ( + f"channel pin flipped across the turn sequence: {resolved_channel_prompts}" + ) + # The parent-chat channel override ("Parent persona.") depends on parent_chat_id surviving + # onto the synthetic turn's resolved source too. + eph = [_effective_ephemeral(runner, kw) for kw in calls] + for text in eph: + assert "Channel hint." in text and "Parent persona." in text From 03407009f65d563c1b14a6247ea43efa849f9c9b Mon Sep 17 00:00:00 2001 From: chelsealong Date: Mon, 28 Sep 2026 10:02:27 +0000 Subject: [PATCH 2/2] fix(gateway): reuse session-context pin on inherit_channel_pin turns too Feed the same internal or inherit_channel_pin decision into _pinned_session_context_prompt as _pinned_channel_inputs, so a synthetic goal/heartbeat/resume turn (which strips message_id, a _ephemeral_change_key input) doesn't flip the session-context block and back on the next human turn. Also moves inherit_channel_pin to append after reply_expected for MessageEvent positional compat. --- gateway/platforms/event.py | 11 ++++++----- gateway/run_agent_cache.py | 5 ++++- gateway/run_turn.py | 3 ++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/gateway/platforms/event.py b/gateway/platforms/event.py index 5b3c76c744b66..efbdd08aa162a 100644 --- a/gateway/platforms/event.py +++ b/gateway/platforms/event.py @@ -82,11 +82,6 @@ class MessageEvent: channel_context: Optional[str] = None # Set for synthetic events (e.g. background-process notifications) that must bypass user authorization. internal: bool = False - # Set for synthetic turns that carry no channel inputs of their own (goal continuation, - # heartbeat due-prompts, /goal resume) so the turn reuses the session's pinned channel - # prompt/parent instead of overwriting it with this event's own None (#126109). Independent - # of ``internal``: these events still go through user authorization. - inherit_channel_pin: 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) @@ -97,6 +92,12 @@ class MessageEvent: # knows the message was meant for someone else); None means unknown and keeps the visible # fallback, like True. reply_expected: Optional[bool] = None + # Set for synthetic turns that carry no channel inputs of their own (goal continuation, + # heartbeat due-prompts, /goal resume) so the turn reuses the session's pinned channel + # prompt/parent instead of overwriting it with this event's own None (#126109). Independent + # of ``internal``: these events still go through user authorization. Appended after + # ``reply_expected`` for positional compat (see the field-order rule above). + inherit_channel_pin: bool = False # Process-local admission receipt, never routing metadata or execution acknowledgement. _gateway_accepted: bool = field(default=False, init=False, repr=False, compare=False) diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index 32a20b1bbc81d..031f4e33bd01c 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -628,7 +628,10 @@ def _pinned_session_context_prompt( source rebuilt from the persisted origin, without chat_name/user_name/message_id. Rendering from it re-keyed the pin, and the next human turn re-keyed it back (A→B→A), rewriting already-sent system bytes each time. An internal event is never a real metadata change, so - it reuses an existing pin verbatim; with no pin yet it renders and pins as usual.""" + it reuses an existing pin verbatim; with no pin yet it renders and pins as usual. Callers + pass ``event.internal or event.inherit_channel_pin`` (synthetic goal/heartbeat/resume + turns strip ``message_id``, which is also a ``_ephemeral_change_key`` input) so this pin + and ``_pinned_channel_inputs`` reuse on the same decision.""" _pin_state = self._peek_session_state(session_key) if session_key else None _eph_pin = _pin_state.conversation.ephemeral_pin if _pin_state else None if internal and _eph_pin is not None: diff --git a/gateway/run_turn.py b/gateway/run_turn.py index b2ea236be99a9..4153b44cdb1be 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -2076,7 +2076,8 @@ async def _hmwa_prepare_turn(self, event, source, session_entry, session_key, _q # The context prompt render is pinned per session, keyed by a hash of the renderer inputs, so # the system prompt cannot drift turn-over-turn; a miss (thread rename, /sethome) re-renders. context_prompt = self._pinned_session_context_prompt( - context, _redact_pii, session_key, internal=event.internal, + context, _redact_pii, session_key, + internal=event.internal or getattr(event, "inherit_channel_pin", False), ) # Per-turn notes ride the user message via the api_content sidecar, NOT context_prompt