fix(gateway): key Slack capability gate into the prompt pin; defer positive note to tool schemas

Follow-up to the #68627 cherry-pick (cluster C15 — Slack platform
capability-note accuracy; earliest report/fix: #6545 by @daikeren):

1. Session/prompt stability: the pinned session-context render
   (_pinned_session_context_prompt) is keyed by _ephemeral_change_key,
   whose contract requires every rendered input to appear in the key.
   The new _slack_tools_loaded() gate reads config + the live MCP
   registration map, so its state is now hashed into the key exactly
   like the existing Discord gate — a gate flip re-renders ONCE (a
   legitimate bust); within a session the note stays byte-stable for
   the life of the conversation (A/B: the new parity test fails with
   this key change reverted, passes with it).

2. Derived, non-overpromising positive note: rather than hardcoding a
   capability list that can drift stale again (the original bug class),
   the tools-present note tells the agent to consult the actual loaded
   Slack tool schemas for supported operations — the schemas ARE the
   source of truth, so the note cannot overclaim ops a given Slack
   toolset/MCP server doesn't expose (e.g. a read-only history server).

3. Tests: parity test proving a gate flip changes both render and key;
   byte-stability test proving three consecutive turns in one Slack
   session return the identical pinned object (sha256-equal); autouse
   fixture pins the new gate so key<->render parity is env-independent.
This commit is contained in:
Teknium 2026-07-23 07:47:33 -07:00
parent 96f21e8a54
commit e027f43f70
3 changed files with 58 additions and 4 deletions

View file

@ -18641,6 +18641,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"1" if src.message_id else "0",
)
# Slack renders a capability-aware platform note gated on
# _slack_tools_loaded() — the gate state must appear in the key
# (same parity contract as the Discord gate above) so a config /
# MCP-registration flip re-renders once instead of serving a
# stale pinned note for the rest of the session.
slack_tools = ""
if src.platform == Platform.SLACK:
from gateway.session import _slack_tools_loaded
slack_tools = "1" if _slack_tools_loaded() else "0"
try:
from hermes_constants import display_hermes_home
@ -18661,6 +18672,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
bool(context.shared_multi_user_session),
discord_ids,
discord_tools,
slack_tools,
tuple(p.value for p in context.connected_platforms),
tuple(
(

View file

@ -572,9 +572,11 @@ def build_session_context_prompt(
lines.append("")
lines.append(
"**Platform notes:** You are running inside Slack and have access "
"to Slack-specific tools. You CAN search channel history, post "
"messages, react to messages, and perform other Slack operations "
"via the available Slack toolset."
"to Slack-specific tools this session. Consult the available Slack "
"tool schemas for the exact operations supported (e.g. channel "
"history and thread lookups, posting, reactions) — use those tools "
"for Slack-specific requests, and do not promise Slack actions "
"beyond what the loaded tools actually expose."
)
else:
lines.append("")

View file

@ -99,9 +99,10 @@ def _make_context(
@pytest.fixture(autouse=True)
def _stable_discord_tools(monkeypatch):
"""Pin the config/env-dependent renderer gate so key<->render parity is
"""Pin the config/env-dependent renderer gates so key<->render parity is
evaluated on the same footing in every environment."""
monkeypatch.setattr("gateway.session._discord_tools_loaded", lambda: True)
monkeypatch.setattr("gateway.session._slack_tools_loaded", lambda: False)
def _key(runner, context, redact_pii=False):
@ -192,6 +193,45 @@ class TestEphemeralChangeKeyParity:
assert _render(ctx) != render_on
assert _key(runner, ctx) != key_on
def test_slack_tools_gate_flip_changes_key(self, monkeypatch):
"""The Slack capability note is gated on _slack_tools_loaded(); the
gate state must be part of the change key (same parity contract as
the Discord gate) or a config/MCP flip would serve a stale pinned
note forever."""
runner = _make_runner()
ctx = _make_context(
platform=Platform.SLACK,
chat_id="C123",
thread_id=None,
parent_chat_id=None,
guild_id=None,
)
render_off, key_off = _render(ctx), _key(runner, ctx)
monkeypatch.setattr("gateway.session._slack_tools_loaded", lambda: True)
assert _render(ctx) != render_off
assert _key(runner, ctx) != key_off
def test_slack_note_byte_stable_across_turns_in_one_session(self):
"""Within one session (gate state constant), the Slack platform note
must be byte-stable turn over turn the pin returns the identical
object, so the composed system prompt cannot drift mid-conversation."""
runner = _make_runner()
def _slack_ctx():
return _make_context(
platform=Platform.SLACK,
chat_id="C123",
thread_id=None,
parent_chat_id=None,
guild_id=None,
)
t1 = runner._pinned_session_context_prompt(_slack_ctx(), False, "sk-slack") # noqa: SLF001
t2 = runner._pinned_session_context_prompt(_slack_ctx(), False, "sk-slack") # noqa: SLF001
t3 = runner._pinned_session_context_prompt(_slack_ctx(), False, "sk-slack") # noqa: SLF001
assert t2 is t1 and t3 is t1
assert hashlib.sha256(t1.encode()).hexdigest() == hashlib.sha256(t3.encode()).hexdigest()
def test_message_id_value_change_is_not_a_bust(self):
"""Only message-id PRESENCE renders (the id itself rides the user
message) a new id every turn must not re-render."""