Skip to content
Merged
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
2 changes: 1 addition & 1 deletion gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
55 changes: 55 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 2 additions & 15 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -2679,21 +2679,8 @@ def _resolve_channel_skills(self, channel_id: str, parent_id: str | None = None)
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."""
Expand Down
6 changes: 5 additions & 1 deletion gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1759,10 +1759,13 @@ async def _handle_slack_message(self, event: dict) -> None:
)

# 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
Expand Down Expand Up @@ -1791,6 +1794,7 @@ async def _handle_slack_message(self, event: dict) -> None:
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).
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"10774721+kunlabs@users.noreply.github.com": "kunlabs",
"110560187+Wang-tianhao@users.noreply.github.com": "Wang-tianhao",
"170458616+ghostmfr@users.noreply.github.com": "ghostmfr",
"1848670+mewwts@users.noreply.github.com": "mewwts",
"nocoo@users.noreply.github.com": "nocoo",
"30841158+n-WN@users.noreply.github.com": "n-WN",
"tsuijinglei@gmail.com": "hiddenpuppy",
Expand Down
133 changes: 133 additions & 0 deletions tests/gateway/test_slack_channel_skills.py
Original file line number Diff line number Diff line change
@@ -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"]
28 changes: 28 additions & 0 deletions website/docs/user-guide/messaging/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading