Skip to content

feat(slack): add per-channel no_agent script handlers (channel_handlers) - #45025

Open
splashkes wants to merge 1 commit into
NousResearch:mainfrom
splashkes:feat/slack-channel-handlers
Open

feat(slack): add per-channel no_agent script handlers (channel_handlers)#45025
splashkes wants to merge 1 commit into
NousResearch:mainfrom
splashkes:feat/slack-channel-handlers

Conversation

@splashkes

Copy link
Copy Markdown

What does this PR do?

Adds slack.channel_handlers — a config map from Slack channel ID to a no_agent handler script. On every plain user message in a mapped channel, the raw Slack event JSON is piped to the script on stdin and run as a fire-and-forget subprocess. It is the Slack analogue of the webhook adapter's deliver_only contract.

Why: Today every reacted-to Slack message necessarily spawns a full agent session, and strict_mention simply drops non-mention messages — there is no non-agent hook point for a channel. Deterministic, real-time per-channel handling (e.g. spam triage in a #contact channel, intake routing, logging) is therefore only possible via a polling cron or a separate sidecar Socket Mode app (token duplication + event load-balancing hazard). This adds the missing dispatch point so the gateway, which already receives these messages in real time over Socket Mode, can run a deterministic handler with zero LLM cost.

Motivating case: a deployment currently runs an every-2h cron poll purely to triage #contact spam, only because the gateway has no non-agent dispatch for inbound channel messages.

Related Issue

No existing issue/PR — searched issues and PRs for "channel handler", "slack script", "deliver_only slack", "no_agent slack", "channel automation", and channel_handlers. The nearest neighbors are different concerns: #22262 routes channels to different agent profiles (still the agent path), #26474 is cron structured payloads, #22714 is a Matrix per-request LLM-dispatcher hook, and #10572 is a webhook script param. None provide a Slack-channel no_agent subprocess dispatch.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • gateway/platforms/slack.py:
    • Dispatch in _handle_slack_message before the mention/allowed-channel gate, after the existing bot/self, bot_message, message_changed/message_deleted, and self thread_broadcast skips. The handler runs in addition to normal processing — execution falls through to the existing gates, so @mentions in a mapped channel still reach the agent. Handler-only channels are achieved via the existing strict_mention behavior (agent-path behavior is unchanged).
    • _slack_channel_handler_for() (config normalization; accepts a dict {script, timeout} or a bare script-name string), _resolve_handler_script_path() (mirrors cron's contract: relative under HERMES_HOME/scripts/, traversal blocked), _dispatch_channel_handler() (fire-and-forget task with reference-holding + done-callback), and _run_channel_handler() (async subprocess; raw event JSON on stdin; .sh/.bash via bash else the active Python; bounded timeout default 60s; stdout/stderr captured to the gateway log at DEBUG with an INFO one-liner: channel/script/exit/duration; secrets redacted; all failures/timeouts swallowed so the adapter is never delayed or crashed).
  • website/docs/user-guide/messaging/slack.md: new "Per-channel script handlers (channel_handlers)" section.
  • cli-config.yaml.example: documented slack.channel_handlers block.
  • tests/gateway/test_slack_channel_handlers.py: 22 tests.

How to Test

scripts/run_tests.sh tests/gateway/test_slack_channel_handlers.py

Manual: add to config.yaml

slack:
  channel_handlers:
    C0123456789:
      script: my_handler.py   # ~/.hermes/scripts/my_handler.py, reads event JSON on stdin
      timeout: 120

Post a non-mention message in that channel → the script runs (gateway log shows [Slack] channel_handler channel=... script=... exit=0 duration=...). @mention the bot in the same channel → the agent still responds.

Coverage added:

  • mapped channel dispatches; unmapped channel untouched
  • bot/self messages skipped; bot_message / message_changed / message_deleted / self thread_broadcast skipped
  • handler missing-script / non-zero-exit / timeout do not raise and do not break message handling
  • a dispatch exception is swallowed and the agent path still runs
  • @mention in a mapped channel still reaches the agent; a plain non-mention message reaches only the handler (not the agent) under default mention gating
  • config normalization (dict + bare-string forms, defaults) and script-path traversal blocking

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this feature
  • I've run the tests and all pass (22 new + 307 existing slack tests green via scripts/run_tests.sh)
  • I've added tests for my changes
  • I've tested on my platform: Linux / WSL2 (Ubuntu, Python 3)

Documentation & Housekeeping

  • I've updated relevant documentation (docs/, docstrings)
  • I've updated cli-config.yaml.example for the new config key
  • N/A — no architecture/workflow change
  • I've considered cross-platform impact: interpreter resolution mirrors cron (shutil.which("bash") fallback), pathlib throughout, async subprocess — no Unix-only assumptions
  • N/A — no tool behavior change

Add slack.channel_handlers config mapping channel IDs to no_agent handler
scripts. On every plain user message in a mapped channel, the raw Slack
event JSON is piped to the script on stdin and run as a fire-and-forget
subprocess — the Slack analogue of the webhook deliver_only contract.

This enables real-time deterministic channel automation (e.g. spam triage)
without spawning an agent session and without a sidecar Socket Mode app.
Previously, non-mention channel messages had no non-agent hook point and
such handling was only possible via polling crons.

- Dispatch in _handle_slack_message before the mention/allowed-channel gate,
  after the bot/self, bot_message, message_changed/deleted, and self
  thread-broadcast skips. The handler runs IN ADDITION to normal processing;
  @mentions in a mapped channel still fall through to the agent path.
  Handler-only channels are achieved via existing strict_mention.
- Script resolution mirrors cron no_agent scripts (relative under
  HERMES_HOME/scripts/, traversal blocked); .sh/.bash via bash, else the
  active Python. Bounded timeout (default 60s); stdout/stderr captured to
  the gateway log at DEBUG with an INFO one-liner (channel/script/exit/
  duration). Handler crash/timeout never delays or breaks the adapter.
- Bridge slack.channel_handlers from the top-level slack: config block into
  PlatformConfig.extra in load_gateway_config (alongside channel_prompts /
  channel_skill_bindings), so the adapter actually sees the mapping.
- Docs (messaging/slack.md), cli-config.yaml.example, and tests covering
  dispatch, skips, unmapped channels, mention-still-reaches-agent, config
  bridging, and subprocess timeout/failure isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@splashkes
splashkes force-pushed the feat/slack-channel-handlers branch from a735f28 to 0d63c3a Compare June 12, 2026 15:52
@splashkes

Copy link
Copy Markdown
Author

Updated: also bridge slack.channel_handlers from the top-level slack: config block into PlatformConfig.extra in load_gateway_config (alongside channel_prompts / channel_skill_bindings), since the adapter reads the mapping from config.extra. Added a config-bridging test. Verified locally end-to-end against the deployed gateway: a synthetic plain-user event in a mapped channel runs the handler subprocess with the raw event JSON on stdin.

@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery platform/slack Slack app adapter P3 Low — cosmetic, nice to have labels Jun 12, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the focused Slack automation feature and its coverage. The dispatch point is still absent on current main, but this branch needs a port and two safety fixes before it can be salvaged.

Problems

  • The production adapter moved from gateway/platforms/slack.py to plugins/platforms/slack/adapter.py in 5600105478ffde29d7566b45421b100eaa29c4ef; current dispatch starts at plugins/platforms/slack/adapter.py:2589. The PR and tests/gateway/test_slack_channel_handlers.py:15 still target the removed module.
  • gateway/platforms/slack.py:3862 spawns the handler without a sanitized env. Current script execution uses _sanitize_subprocess_env in cron/scheduler.py:2096-2107, which strips SLACK_BOT_TOKEN and SLACK_APP_TOKEN (tools/environments/local.py:444-448).
  • gateway/platforms/slack.py:3793 creates one subprocess task per inbound mapped event with no concurrency bound.

Suggested changes

  • Port the implementation and tests into the Slack bundled plugin and its YAML bridge (plugins/platforms/slack/adapter.py:4485-4519).
  • Sanitize child environments and test credential removal.
  • Bound concurrent handler processes and test burst behavior.

Automated hermes-sweeper review.

``_handle_slack_message`` keeps the seam tight against production behaviour.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Current main migrated the Slack adapter to plugins/platforms/slack/adapter.py in 5600105478ffde29d7566b45421b100eaa29c4ef; this import targets a removed legacy module. Port this test with the production implementation so it exercises the active adapter.

stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(path.parent),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pass a sanitized child environment here. cron/scheduler.py:2096-2107 uses _sanitize_subprocess_env(os.environ.copy()), which strips Slack gateway credentials including SLACK_BOT_TOKEN and SLACK_APP_TOKEN; this inbound-triggered handler must not inherit them.

Exceptions inside the task are logged, never raised — a misbehaving
handler must not crash the gateway.
"""
task = asyncio.create_task(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This schedules an uncapped subprocess task for every mapped inbound event. The per-task timeout does not bound process count during a burst; add a per-adapter concurrency limit and define/log the overflow behavior.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have platform/slack Slack app adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants