diff --git a/gateway/config.py b/gateway/config.py index 1819665a63..d402e70eb8 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -598,7 +598,7 @@ def load_gateway_config() -> GatewayConfig: bridged["group_policy"] = platform_cfg["group_policy"] if "group_allow_from" in platform_cfg: bridged["group_allow_from"] = platform_cfg["group_allow_from"] - if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg: + if plat in (Platform.DISCORD, Platform.SLACK) and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] if "channel_prompts" in platform_cfg: channel_prompts = platform_cfg["channel_prompts"] diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 610cebdd2e..3604809dd9 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -990,6 +990,61 @@ def resolve_channel_prompt( return None +def resolve_channel_skills( + config_extra: dict, + channel_id: str, + parent_id: str | None = None, +) -> list[str] | None: + """Resolve auto-loaded skill(s) for a channel/thread from platform config. + + Looks up ``channel_skill_bindings`` in the adapter's ``config.extra`` dict. + + Config format:: + + channel_skill_bindings: + - id: "C0123" # Slack channel ID or Discord channel/forum ID + skills: ["skill-a", "skill-b"] + - id: "D0ABCDE" + skill: "solo-skill" # single string also accepted + + Prefers an exact match on *channel_id*; falls back to *parent_id* + (useful for forum threads / Slack threads inheriting the parent channel's + binding). + + Returns a deduplicated list of skill names (order preserved), or None if + no match is found. + """ + bindings = config_extra.get("channel_skill_bindings") or [] + if not isinstance(bindings, list) or not bindings: + return None + ids_to_check: set[str] = set() + if channel_id: + ids_to_check.add(str(channel_id)) + if parent_id: + ids_to_check.add(str(parent_id)) + if not ids_to_check: + return None + for entry in bindings: + if not isinstance(entry, dict): + continue + entry_id = str(entry.get("id", "")) + if entry_id in ids_to_check: + skills = entry.get("skills") or entry.get("skill") + if isinstance(skills, str): + s = skills.strip() + return [s] if s else None + if isinstance(skills, list) and skills: + seen: list[str] = [] + for name in skills: + if not isinstance(name, str): + continue + nm = name.strip() + if nm and nm not in seen: + seen.append(nm) + return seen or None + return None + + class BasePlatformAdapter(ABC): """ Base class for platform adapters. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index b4018c6df6..0816fb93a0 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2679,21 +2679,8 @@ class DiscordAdapter(BasePlatformAdapter): skills: ["skill-a", "skill-b"] Also checks parent_id so forum threads inherit the forum's bindings. """ - bindings = self.config.extra.get("channel_skill_bindings", []) - if not bindings: - return None - ids_to_check = {channel_id} - if parent_id: - ids_to_check.add(parent_id) - for entry in bindings: - entry_id = str(entry.get("id", "")) - if entry_id in ids_to_check: - skills = entry.get("skills") or entry.get("skill") - if isinstance(skills, str): - return [skills] - if isinstance(skills, list) and skills: - return list(dict.fromkeys(skills)) # dedup, preserve order - return None + from gateway.platforms.base import resolve_channel_skills + return resolve_channel_skills(self.config.extra, channel_id, parent_id) def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) -> str | None: """Resolve a Discord per-channel prompt, preferring the exact channel over its parent.""" diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index b4c6ddfe6a..fc92d11443 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1759,10 +1759,13 @@ class SlackAdapter(BasePlatformAdapter): ) # Per-channel ephemeral prompt - from gateway.platforms.base import resolve_channel_prompt + from gateway.platforms.base import resolve_channel_prompt, resolve_channel_skills _channel_prompt = resolve_channel_prompt( self.config.extra, channel_id, None, ) + _auto_skill = resolve_channel_skills( + self.config.extra, channel_id, None, + ) # Extract reply context if this message is a thread reply. # Mirrors the Telegram/Discord implementations so that gateway.run @@ -1791,6 +1794,7 @@ class SlackAdapter(BasePlatformAdapter): reply_to_message_id=thread_ts if thread_ts != ts else None, channel_prompt=_channel_prompt, reply_to_text=reply_to_text, + auto_skill=_auto_skill, ) # Only react when bot is directly addressed (DM or @mention). diff --git a/tests/gateway/test_slack_channel_skills.py b/tests/gateway/test_slack_channel_skills.py new file mode 100644 index 0000000000..6f5987a2e5 --- /dev/null +++ b/tests/gateway/test_slack_channel_skills.py @@ -0,0 +1,133 @@ +"""Tests for Slack channel_skill_bindings auto-skill resolution.""" +from unittest.mock import MagicMock + + +def _make_adapter(extra=None): + """Create a minimal SlackAdapter stub with the given ``config.extra``.""" + from gateway.platforms.slack import SlackAdapter + adapter = object.__new__(SlackAdapter) + adapter.config = MagicMock() + adapter.config.extra = extra or {} + return adapter + + +def _resolve(adapter, channel_id, parent_id=None): + from gateway.platforms.base import resolve_channel_skills + return resolve_channel_skills(adapter.config.extra, channel_id, parent_id) + + +class TestSlackResolveChannelSkills: + def test_no_bindings_returns_none(self): + adapter = _make_adapter() + assert _resolve(adapter, "D0ABC") is None + + def test_match_by_dm_channel_id(self): + """The primary use case: binding a skill to a Slack DM channel.""" + adapter = _make_adapter({ + "channel_skill_bindings": [ + {"id": "D0ATH9TQ0G6", "skills": ["german-flashcards"]}, + ] + }) + assert _resolve(adapter, "D0ATH9TQ0G6") == ["german-flashcards"] + + def test_match_by_parent_id_for_thread(self): + """Slack threads inherit the parent channel's binding.""" + adapter = _make_adapter({ + "channel_skill_bindings": [ + {"id": "C0PARENT", "skills": ["parent-skill"]}, + ] + }) + assert _resolve(adapter, "thread-ts-123", parent_id="C0PARENT") == ["parent-skill"] + + def test_no_match_returns_none(self): + adapter = _make_adapter({ + "channel_skill_bindings": [ + {"id": "D0AAA", "skills": ["skill-a"]}, + ] + }) + assert _resolve(adapter, "D0BBB") is None + + def test_single_skill_string(self): + adapter = _make_adapter({ + "channel_skill_bindings": [ + {"id": "D0ATH9TQ0G6", "skill": "german-flashcards"}, + ] + }) + assert _resolve(adapter, "D0ATH9TQ0G6") == ["german-flashcards"] + + def test_dedup_preserves_order(self): + adapter = _make_adapter({ + "channel_skill_bindings": [ + {"id": "D0ATH9TQ0G6", "skills": ["a", "b", "a", "c", "b"]}, + ] + }) + assert _resolve(adapter, "D0ATH9TQ0G6") == ["a", "b", "c"] + + def test_multiple_bindings_pick_correct(self): + adapter = _make_adapter({ + "channel_skill_bindings": [ + {"id": "D0AAA", "skills": ["skill-a"]}, + {"id": "D0BBB", "skills": ["skill-b"]}, + {"id": "D0CCC", "skills": ["skill-c"]}, + ] + }) + assert _resolve(adapter, "D0BBB") == ["skill-b"] + + def test_malformed_entry_skipped(self): + """Non-dict entries should be ignored, not raise.""" + adapter = _make_adapter({ + "channel_skill_bindings": [ + "not-a-dict", + {"id": "D0ABC", "skills": ["good"]}, + ] + }) + assert _resolve(adapter, "D0ABC") == ["good"] + + def test_empty_skills_list_returns_none(self): + adapter = _make_adapter({ + "channel_skill_bindings": [ + {"id": "D0ABC", "skills": []}, + ] + }) + assert _resolve(adapter, "D0ABC") is None + + def test_empty_skill_string_returns_none(self): + adapter = _make_adapter({ + "channel_skill_bindings": [ + {"id": "D0ABC", "skill": ""}, + ] + }) + assert _resolve(adapter, "D0ABC") is None + + +class TestSlackMessageEventAutoSkill: + """Integration-style test: verify auto_skill propagates to MessageEvent.""" + + def test_message_event_carries_auto_skill(self): + """Simulate the handler wiring: resolve + attach to MessageEvent.""" + from gateway.platforms.base import MessageEvent, MessageType, Platform, SessionSource, resolve_channel_skills + + config_extra = { + "channel_skill_bindings": [ + {"id": "D0ATH9TQ0G6", "skills": ["german-flashcards"]}, + ] + } + auto_skill = resolve_channel_skills(config_extra, "D0ATH9TQ0G6", None) + + source = SessionSource( + platform=Platform.SLACK, + chat_id="D0ATH9TQ0G6", + chat_name="Mats", + chat_type="dm", + user_id="U0ABC", + user_name="Mats", + ) + event = MessageEvent( + text="work", + message_type=MessageType.TEXT, + source=source, + raw_message={}, + message_id="123.456", + auto_skill=auto_skill, + ) + assert event.auto_skill == ["german-flashcards"] diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index 696f4e065e..72e22db232 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -510,6 +510,34 @@ slack: Keys are Slack channel IDs (find them via channel details → "About" → scroll to bottom). All messages in the matching channel get the prompt injected as an ephemeral system instruction. +## Per-Channel Skill Bindings + +Auto-load a skill whenever a new session starts in a specific channel or DM. Unlike per-channel prompts (which are injected on every turn), skill bindings inject the skill content as a user message at **session start** — it becomes part of the conversation history and does not need to be reloaded on subsequent turns. + +This is ideal for DMs or channels with a dedicated purpose (flashcards, a domain-specific Q&A bot, a support triage channel, etc.) where you don't want the model's own skill selector to decide whether to load on every short reply. + +```yaml +slack: + channel_skill_bindings: + # DM channel — always runs in "german-flashcards" mode + - id: "D0ATH9TQ0G6" + skills: + - german-flashcards + # Research channel — preload multiple skills in order + - id: "C01RESEARCH" + skills: + - arxiv + - writing-plans + # Short form: single skill as a string + - id: "C02SUPPORT" + skill: hubspot-on-demand +``` + +Notes: +- The binding matches by channel ID. For threaded messages in a bound channel, the thread inherits the parent channel's binding. +- The skill is loaded only at session start (new session or after auto-reset). If you change the binding, run `/new` or wait for the session to auto-reset for it to take effect. +- Combine with `channel_prompts` for per-channel tone/constraints on top of the skill's instructions. + ## Troubleshooting | Problem | Solution |