feat(slack): add per-channel no_agent script handlers (channel_handlers) - #45025
feat(slack): add per-channel no_agent script handlers (channel_handlers)#45025splashkes wants to merge 1 commit into
Conversation
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>
a735f28 to
0d63c3a
Compare
|
Updated: also bridge |
teknium1
left a comment
There was a problem hiding this comment.
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.pytoplugins/platforms/slack/adapter.pyin5600105478ffde29d7566b45421b100eaa29c4ef; current dispatch starts atplugins/platforms/slack/adapter.py:2589. The PR andtests/gateway/test_slack_channel_handlers.py:15still target the removed module. gateway/platforms/slack.py:3862spawns the handler without a sanitizedenv. Current script execution uses_sanitize_subprocess_envincron/scheduler.py:2096-2107, which stripsSLACK_BOT_TOKENandSLACK_APP_TOKEN(tools/environments/local.py:444-448).gateway/platforms/slack.py:3793creates 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 |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
What does this PR do?
Adds
slack.channel_handlers— a config map from Slack channel ID to ano_agenthandler 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'sdeliver_onlycontract.Why: Today every reacted-to Slack message necessarily spawns a full agent session, and
strict_mentionsimply 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
Changes Made
gateway/platforms/slack.py:_handle_slack_messagebefore the mention/allowed-channel gate, after the existing bot/self,bot_message,message_changed/message_deleted, and selfthread_broadcastskips. The handler runs in addition to normal processing — execution falls through to the existing gates, so@mentionsin a mapped channel still reach the agent. Handler-only channels are achieved via the existingstrict_mentionbehavior (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 underHERMES_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/.bashvia 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: documentedslack.channel_handlersblock.tests/gateway/test_slack_channel_handlers.py: 22 tests.How to Test
Manual: add to
config.yamlPost a non-mention message in that channel → the script runs (gateway log shows
[Slack] channel_handler channel=... script=... exit=0 duration=...).@mentionthe bot in the same channel → the agent still responds.Coverage added:
bot_message/message_changed/message_deleted/ selfthread_broadcastskipped@mentionin a mapped channel still reaches the agent; a plain non-mention message reaches only the handler (not the agent) under default mention gatingChecklist
Code
scripts/run_tests.sh)Documentation & Housekeeping
docs/, docstrings)cli-config.yaml.examplefor the new config keyshutil.which("bash")fallback),pathlibthroughout, async subprocess — no Unix-only assumptions