Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions gateway/platforms/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,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)
Expand Down
18 changes: 11 additions & 7 deletions gateway/run_agent_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -642,18 +645,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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the channel half only. _hmwa_prepare_turn still calls _pinned_session_context_prompt(..., internal=event.internal), and because _synthetic_prompt_event strips message_id (a _ephemeral_change_key input on Discord), the session-context pin still flips. Live on this head merged onto main: context bytes 1238 → 1122 → 1238 across human → synthetic → human while channel_prompt stays 'Channel hint.'. Pass the same internal or inherit_channel_pin decision to _pinned_session_context_prompt, and assert full ephemeral byte-equality in the test.

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)
Expand Down
5 changes: 4 additions & 1 deletion gateway/run_goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
12 changes: 8 additions & 4 deletions gateway/run_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2195,9 +2196,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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this reuses only the channel pin. Earlier in the same handler, _hmwa_prepare_turn() still calls _pinned_session_context_prompt(..., internal=event.internal). _synthetic_prompt_event() removes message_id but keeps the event non-internal, and Discord includes message-ID presence in _ephemeral_change_key(). The synthetic turn therefore rebuilds a shorter session-context block, and the next human turn changes it back. I added only assert eph[0] == eph[1] == eph[2] to this PR’s new real-handler test; it failed with 2 passing tests and 1 failure because the middle prompt lost the “Triggering message” line. Please apply the same inheritance signal to the session-context pin and keep that full byte-equality assertion. That closes the A→B→A invariant rather than only preserving channel_prompt.

)
agent_result = await self._run_agent(
message=message_text, context_prompt=prepared.context_prompt, history=history, source=_turn_source,
Expand Down Expand Up @@ -3867,7 +3870,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)
else:
Expand Down
1 change: 1 addition & 0 deletions gateway/slash_commands_goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions tests/gateway/test_internal_event_pin_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

import dataclasses
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock

Expand Down Expand Up @@ -303,3 +304,56 @@ async def test_event_backed_followup_overrides_inherited_channel_prompt(monkeypa

assert len(calls) == 1
assert calls[0]["channel_prompt"] == "Queued event prompt.", "event prompt must win over the inherited one"


@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. Also asserts full ephemeral byte-equality: the
synthetic turn strips ``message_id`` too, which is a ``_ephemeral_change_key`` input on
Discord, so without feeding the same ``inherit_channel_pin`` decision into
``_pinned_session_context_prompt`` the session-context block re-renders shorter on the
synthetic turn and back on the human turn after it (A->B->A on the full ephemeral prompt,
not just ``channel_prompt``)."""
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}"
)
# Full ephemeral byte-equality: the parent-chat override AND the session-context block must
# stay identical across human -> synthetic -> human, not just contain the same substrings.
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], "synthetic turn toggled the ephemeral prompt bytes"
Loading