Skip to content
Open
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
11 changes: 9 additions & 2 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,8 +969,15 @@ def _merge_platform_map(source_platforms: Any) -> None:
bridged["group_allow_admin_from"] = platform_cfg["group_allow_admin_from"]
if "group_user_allowed_commands" in platform_cfg:
bridged["group_user_allowed_commands"] = platform_cfg["group_user_allowed_commands"]
if plat in {Platform.DISCORD, Platform.SLACK} and "channel_skill_bindings" in platform_cfg:
bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"]
if plat in {Platform.DISCORD, Platform.SLACK}:
if "channel_skill_bindings" in platform_cfg:
bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"]
if "channel_skills" in platform_cfg:
channel_skills = platform_cfg["channel_skills"]
if isinstance(channel_skills, dict):
bridged["channel_skills"] = {str(k): v for k, v in channel_skills.items()}
else:
bridged["channel_skills"] = channel_skills
if "channel_prompts" in platform_cfg:
channel_prompts = platform_cfg["channel_prompts"]
if isinstance(channel_prompts, dict):
Expand Down
72 changes: 44 additions & 28 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1726,51 +1726,67 @@ def resolve_channel_skills(
) -> 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::
Preferred 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

Legacy/dict alias also accepted::

channel_skills:
"C0123": ["skill-a", "skill-b"]
"D0ABCDE": "solo-skill"

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:

def _normalize_skills(value: Any) -> list[str] | None:
if isinstance(value, str):
skill = value.strip()
return [skill] if skill else None
if isinstance(value, list):
seen: list[str] = []
for name in value:
if not isinstance(name, str):
continue
normalized = name.strip()
if normalized and normalized not in seen:
seen.append(normalized)
return seen or None
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))

ids_to_check: list[str] = []
for key in (channel_id, parent_id):
if key:
normalized = str(key)
if normalized not in ids_to_check:
ids_to_check.append(normalized)
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

bindings = config_extra.get("channel_skill_bindings") or []
if isinstance(bindings, list):
for entry in bindings:
if not isinstance(entry, dict):
continue
entry_id = str(entry.get("id", ""))
if entry_id in ids_to_check:
return _normalize_skills(entry.get("skills") or entry.get("skill"))

channel_skills = config_extra.get("channel_skills") or {}
if isinstance(channel_skills, dict):
for key in ids_to_check:
if key in channel_skills:
return _normalize_skills(channel_skills.get(key))

return None


Expand Down
132 changes: 97 additions & 35 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -7690,6 +7690,96 @@ async def _prepare_inbound_message_text(

return message_text

def _inject_auto_skills_for_new_session(
self,
event,
session_entry,
task_id: str,
session_key: str,
*,
is_new_session: bool | None = None,
) -> bool:
"""Inject channel-bound skill or bundle payloads into a fresh session.

Topic/channel bindings historically loaded individual skills only. Keep
that behavior, but also accept bundle slash names such as ``/ops-core``
so config can reuse the same grouping mechanism as user-invoked bundle
commands.
"""
if is_new_session is None:
is_new_session = (
getattr(session_entry, "created_at", None)
== getattr(session_entry, "updated_at", None)
or getattr(session_entry, "was_auto_reset", False)
or getattr(session_entry, "is_fresh_reset", False)
)
auto = getattr(event, "auto_skill", None)
if not is_new_session or not auto:
return False

skill_names = [auto] if isinstance(auto, str) else list(auto)
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
resolve_bundle_command_key,
)
from agent.skill_commands import _build_skill_message, _load_skill_payload

combined_parts: list[str] = []
loaded_names: list[str] = []
for raw_name in skill_names:
skill_name = str(raw_name or "").strip()
if not skill_name:
continue

bundle_key = resolve_bundle_command_key(skill_name.lstrip("/"))
if bundle_key:
bundle = build_bundle_invocation_message(
bundle_key,
task_id=task_id,
)
if bundle:
bundle_message, _bundle_loaded, bundle_missing = bundle
combined_parts.append(bundle_message)
loaded_names.append(bundle_key)
if bundle_missing:
logger.warning(
"[Gateway] Auto-bundle '%s' skipped missing skill(s): %s",
bundle_key,
bundle_missing,
)
continue

loaded = _load_skill_payload(skill_name, task_id=task_id)
if loaded:
loaded_skill, skill_dir, display_name = loaded
note = (
f'[IMPORTANT: The "{display_name}" skill is auto-loaded. '
"Follow its instructions for this session.]"
)
part = _build_skill_message(loaded_skill, skill_dir, note)
if part:
combined_parts.append(part)
loaded_names.append(skill_name)
else:
logger.warning("[Gateway] Auto-skill/bundle '%s' not found", skill_name)

if not combined_parts:
return False

# Append the user's original text after all skill/bundle payloads.
combined_parts.append(event.text)
event.text = "\n\n".join(combined_parts)
logger.info(
"[Gateway] Auto-loaded skill(s)/bundle(s) %s for session %s",
loaded_names,
session_key,
)
return True
except Exception as e:
logger.warning("[Gateway] Failed to auto-load skill(s)/bundle(s) %s: %s", skill_names, e)
return False

def _consume_pending_native_image_paths(self, session_key: str) -> List[str]:
pending_native = getattr(self, "_pending_native_image_paths_by_session", None)
if not pending_native:
Expand Down Expand Up @@ -7926,41 +8016,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
session_entry.was_auto_reset = False
session_entry.auto_reset_reason = None

# Auto-load skill(s) for topic/channel bindings (Telegram DM Topics,
# Discord channel_skill_bindings). Supports a single name or ordered list.
# Only inject on NEW sessions — ongoing conversations already have the
# skill content in their conversation history from the first message.
_auto = getattr(event, "auto_skill", None)
if _is_new_session and _auto:
_skill_names = [_auto] if isinstance(_auto, str) else list(_auto)
try:
from agent.skill_commands import _load_skill_payload, _build_skill_message
_combined_parts: list[str] = []
_loaded_names: list[str] = []
for _sname in _skill_names:
_loaded = _load_skill_payload(_sname, task_id=_quick_key)
if _loaded:
_loaded_skill, _skill_dir, _display_name = _loaded
_note = (
f'[IMPORTANT: The "{_display_name}" skill is auto-loaded. '
f"Follow its instructions for this session.]"
)
_part = _build_skill_message(_loaded_skill, _skill_dir, _note)
if _part:
_combined_parts.append(_part)
_loaded_names.append(_sname)
else:
logger.warning("[Gateway] Auto-skill '%s' not found", _sname)
if _combined_parts:
# Append the user's original text after all skill payloads
_combined_parts.append(event.text)
event.text = "\n\n".join(_combined_parts)
logger.info(
"[Gateway] Auto-loaded skill(s) %s for session %s",
_loaded_names, session_key,
)
except Exception as e:
logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e)
self._inject_auto_skills_for_new_session(
event,
session_entry,
_quick_key,
session_key,
is_new_session=_is_new_session,
)

# Load conversation history from transcript
history = self.session_store.load_transcript(session_entry.session_id)
Expand Down
1 change: 1 addition & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3743,6 +3743,7 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes
message_type=msg_type,
source=source,
raw_message=interaction,
auto_skill=self._resolve_channel_skills(channel_id, parent_id or None),
channel_prompt=self._resolve_channel_prompt(channel_id, parent_id or None),
)

Expand Down
28 changes: 28 additions & 0 deletions tests/gateway/test_discord_channel_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Tests for gateway config bridging of channel auto-skill settings."""


def test_top_level_discord_channel_skills_are_bridged(monkeypatch, tmp_path):
from gateway.config import Platform, load_gateway_config

hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"""
discord:
enabled: true
token: token
channel_skills:
123:
- skill-a
- skill-b
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_TOKEN", raising=False)

config = load_gateway_config()

assert config.platforms[Platform.DISCORD].extra["channel_skills"] == {
"123": ["skill-a", "skill-b"]
}
Loading