Skip to content

feat(gateway): add message:received and message:processed hook events - #3769

Open
jeremiahrthompson wants to merge 2 commits into
NousResearch:mainfrom
sumofagents:feat/message-lifecycle-hooks
Open

feat(gateway): add message:received and message:processed hook events#3769
jeremiahrthompson wants to merge 2 commits into
NousResearch:mainfrom
sumofagents:feat/message-lifecycle-hooks

Conversation

@jeremiahrthompson

Copy link
Copy Markdown

What does this PR do?

Adds two gateway hook events — message:received and message:processed — that fire in BasePlatformAdapter for all 12+ platform adapters without modifying any adapter code.

The Problem

Multiple open PRs (#3539, #2764, #3434) independently patch platform adapter source files to answer the same question: "Should the bot respond to this message?" Each creates merge conflicts and maintenance burden. There is no extension point between "adapter receives message" and "agent processes message."

The Solution

Two new hook events in BasePlatformAdapter.handle_message():

message:received — fires before session registration. Hooks can inspect the message and set context["should_process"] = False to silently drop it. Use cases: ambient-mode classifiers, rate limiting, content filtering, analytics.

message:processed — fires after the agent response is sent (or after an error). Includes response text and success/error state. Use cases: thread participation tracking, response analytics, feedback collection, audit logging.

Because both hooks are in BasePlatformAdapter (the universal funnel), they cover Discord, Telegram, Slack, WhatsApp, Signal, Matrix, Email, Mattermost, SMS, HomeAssistant, DingTalk, and Webhook automatically — including any future adapters.

Example hook

# ~/.hermes/hooks/my-classifier/HOOK.yaml
name: my-classifier
description: ML-based response gating
events: ["message:received"]
# ~/.hermes/hooks/my-classifier/handler.py
import aiohttp

CLASSIFIER_URL = "http://localhost:8000/classify"
AMBIENT_CHANNELS = {"123456789", "987654321"}

async def handle(event_type, context):
    if context["metadata"]["chat_id"] not in AMBIENT_CHANNELS:
        return  # allow by default

    async with aiohttp.ClientSession() as session:
        resp = await session.post(CLASSIFIER_URL, json={
            "text": context["event"].text,
            "channel": context["metadata"]["chat_id"],
        })
        result = await resp.json()

    if not result.get("should_respond"):
        context["should_process"] = False

Design Decisions

Decision Choice Rationale
Hook location handle_message() in BasePlatformAdapter Covers all 12 adapters with zero adapter changes
Drop mechanism context["should_process"] = False Mutable dict, consistent with existing hook pattern
Failure mode Fail-open (allow on error/timeout) Hooks should never break message delivery
Timeout 5s default, configurable via HERMES_HOOK_TIMEOUT Prevents stuck hooks from blocking pipeline
Identity check is False not truthiness Prevents accidental drops from None/0/""
All hooks run No short-circuit on first drop Allows observability hooks to see every message
Zero overhead Skip hook logic when no handlers registered Attribute + dict check only when unused

Related Issue

Relates to #3539 (Telegram wake-word gating), #2764 (Slack auto-respond threads), #3434 (Discord channel routing) — all solve variants of the same problem by patching adapter source.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/platforms/base.py — Add _hooks attribute, set_hooks() method, _build_hook_metadata() method, message:received hook call in handle_message(), message:processed hook call in _process_message_background()
  • gateway/hooks.py — Documentation update only (new event descriptions in docstring)
  • gateway/run.py — Call adapter.set_hooks(self.hooks) at both adapter initialization sites (lines ~1023, ~1255)
  • tests/gateway/test_message_lifecycle_hooks.pyNew file: ~350 lines covering normal flow, drop, timeout, error, multiple hooks, edge cases

No adapter files modified. All 12 adapters inherit hook support automatically via BasePlatformAdapter. Uses existing self.platform.value for platform name string.

How to Test

# Run new tests
pytest tests/gateway/test_message_lifecycle_hooks.py -v

# Run existing hook tests (verify no regression)
pytest tests/gateway/test_hooks.py -v

# Run existing base adapter tests (verify no regression)
pytest tests/gateway/test_platform_base.py -v

# Full suite
pytest tests/ -q

Manual test:

  1. Create ~/.hermes/hooks/test-gate/HOOK.yaml with events: ["message:received"]
  2. Create handler.py that prints context and optionally sets should_process = False
  3. Start gateway, send message, verify hook fires in logs
  4. Set should_process = False, verify message silently dropped
  5. Clean up: rm -rf ~/.hermes/hooks/test-gate/

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15.x (Apple Silicon, Darwin)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — hooks.py docstring updated
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (HERMES_HOOK_TIMEOUT is env-only, optional)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A (hooks architecture unchanged, new events only)
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — No platform-specific code; uses only asyncio and dict operations
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

@jeremiahrthompson

Copy link
Copy Markdown
Author

Hey @teknium1 — this PR adds two new lifecycle hooks (message:received and message:processed) to BasePlatformAdapter so all 12 platform adapters get hook support without per-adapter modifications. References issues #3539, #2764, #3434. 30 tests added, all pass. 6822/6834 tests pass (12 pre-existing failures in test_hooks.py unrelated to these changes). Happy to address any feedback. Thanks!

@jeremiahrthompson
jeremiahrthompson force-pushed the feat/message-lifecycle-hooks branch from 3333367 to c6af03b Compare March 30, 2026 13:16
@jeremiahrthompson

Copy link
Copy Markdown
Author

PR updated — rebased on latest main (0b0c1b3) with 2 clean commits:

  • feat: message:received + message:processed hook infrastructure (base.py, hooks.py, run.py)
  • test: 30 comprehensive tests (all pass)

Test results: 7165 passed / 7 failed (pre-existing failures in unrelated files: test_api_key_providers.py, test_slack.py, test_cli_tools_command.py, test_delegate.py). Our changes add zero new failures.

Rebased cleanly with no adapter modifications — all 12 platforms inherit the hooks automatically.

@jeremiahrthompson
jeremiahrthompson force-pushed the feat/message-lifecycle-hooks branch 2 times, most recently from 3ca2db5 to 11e0522 Compare March 31, 2026 02:46
Jeremiah Thompson added 2 commits April 1, 2026 10:16
Add two new hook events that fire in BasePlatformAdapter for ALL platform
adapters without requiring per-adapter modification:

- message:received: fires before session registration, hooks can set
  context['should_process']=False to drop the message silently. Uses
  asyncio.wait_for with configurable 5s timeout (HERMES_HOOK_TIMEOUT).
  Fail-open on timeout and exception (hooks never break message delivery).

- message:processed: fires after agent response is sent (or on error),
  includes response text, success flag, and error string.

Both hooks use the existing HookRegistry. set_hooks() added to
BasePlatformAdapter to wire the registry from GatewayRunner. No
adapter files modified — all 12 adapters inherit hook support via
BasePlatformAdapter.

Ref: NousResearch#3539 NousResearch#2764 NousResearch#3434
Add 30 comprehensive tests covering:
- message:received: basic flow, drop, context fields, multiple hooks,
  should_process identity check, async/sync handlers, fail-open
- message:processed: success, error, drop coordination, context fields
- Integration: photo batching, concurrent hooks, set_hooks after connect,
  None hooks passthrough
@jeremiahrthompson
jeremiahrthompson force-pushed the feat/message-lifecycle-hooks branch from 11e0522 to d073ea8 Compare April 1, 2026 14:17
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery labels May 2, 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 identifying the shared gateway interception point and adding focused lifecycle coverage.

Problems

  • The inbound gating use case is now covered by the supported plugin hook: gateway/run.py:8885-8924 invokes pre_gateway_dispatch before auth/dispatch, and it supports silent skip and rewrite. This landed in 1ef1e4c66989bf409de31bdbe94a0f18f98ac31c.
  • In this PR, gateway/platforms/base.py:1180 sets message:processed.success immediately after the handler returns, before sending the response. A failed delivery would still report success. Current main tracks actual delivery at gateway/platforms/base.py:4810-4820 and derives completion success at 5151-5157.
  • gateway/platforms/base.py:1059 adds HERMES_HOOK_TIMEOUT; AGENTS.md:102-107 requires behavioral settings such as timeouts to live in config.yaml.

Suggested changes

  • Re-scope the inbound piece around pre_gateway_dispatch unless a separate directory-hook surface has a distinct need.
  • If a post-delivery event is retained, rebuild it on the current adapter pipeline using actual delivery outcomes and cover delivery failure, cancellation, and current direct-dispatch paths.

Automated hermes-sweeper review.

Comment thread gateway/platforms/base.py
try:
await asyncio.wait_for(
self._hooks.emit("message:received", hook_ctx),
timeout=float(os.getenv("HERMES_HOOK_TIMEOUT", "5")),

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.

HERMES_HOOK_TIMEOUT is a new user-facing behavioral timeout. Repository policy requires non-secret timeouts to be configured through config.yaml; please add a config setting and use it for both lifecycle waits.

Comment thread gateway/platforms/base.py
# Call the handler (this can take a while with tool calls)
response = await self._message_handler(event)
_hook_response = response
_hook_success = True

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 marks the event successful before any response delivery. If _send_with_retry later returns an unsuccessful result, message:processed still reports success=True; derive this field from the final delivery outcome instead.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state 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 12, 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 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-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants