Skip to content

feat(hooks): add message:pre_route event + multi-role-router reference hook - #78326

Open
raulvidis wants to merge 10 commits into
NousResearch:mainfrom
raulvidis:fix/multi-role-router-blockers
Open

feat(hooks): add message:pre_route event + multi-role-router reference hook#78326
raulvidis wants to merge 10 commits into
NousResearch:mainfrom
raulvidis:fix/multi-role-router-blockers

Conversation

@raulvidis

@raulvidis raulvidis commented Aug 4, 2026

Copy link
Copy Markdown

Superseded — closed in favor of #74272. This PR builds directly on
@ceverson70's implementation (same message:pre_route hook + reference
router). Closing to keep the original authorship intact; the review-fix
delta (first-route isolation, agent-cache eviction, config-read guard,
doc/attribution corrections) is offered on #74272.

Summary

Adds a message:pre_route gateway hook event that fires after session resolution but before the turn-lease is claimed, letting a hook redirect a turn to a different session based on the inbound message. Ships a reference hook (optional-skills/multi-role-router/) that uses a cheap auxiliary LLM classifier to route each message to the best-suited worker role (code / knowledge / ml / ops), keeping short continuations in the current session via a regex fast-path.

Closes #5143 (Multi-Role Auto-Routing via Gateway Hooks).

What's included

  • gateway/hooks.py — document the message:pre_route event + context contract (platform, user_id, chat_id, thread_id, chat_type, session_id, session_key, message) and the return contract ({"decision":"switch_session","session_id":"<id>"}).
  • gateway/run.py — emit message:pre_route after session resolution, honor a switch_session decision, and evict the cached agent after a switch so the next turn rebinds to the new session (mirrors /resume).
  • optional-skills/multi-role-router/ — reference hook: handler.py, HOOK.yaml, SKILL.md, README.md.
  • Tests — 45 tests for the hook + 13 for the gateway pre_route block.

Design decisions

  • First-route isolation: when the classifier picks a role with no saved session, the hook generates a fresh session id, records it in the state file, and returns a switch decision — so even the first message for a role lands in its own session (not the shared inbound one). The gateway's switch_session creates the SessionEntry for a new target id.
  • Slash-command safety: recognized slash commands dispatch before the agent path is entered, so they never reach the classifier. Only unrecognized /foo text falls through (acceptable — it's just text).
  • Async classifier: calls the auxiliary LLM over non-blocking async HTTP (httpx.AsyncClient) so the hook never blocks the gateway event loop; the triage_specifier auxiliary slot is used for the classifier (matching the config read).

Notes for maintainers

This addresses the reviewer feedback on the earlier proposal: the config-read guard is satisfied (canonical load_config_readonly()), the first-route isolation gap is closed, and dead state-management code was removed rather than shipped.

There is a known routing-contract overlap with #69693 (pre_agent_dispatch plugin hook) and #72942 (pre_gateway_dispatch action) — three proposals at different layers. This PR implements the message:pre_route layer (after session resolution, before turn lease). Happy to align with whichever contract the maintainers decide on.

Newest commit: configurable message:pre_route timeout (aa368a6)

The base branch shipped a hard asyncio.wait_for(..., timeout=5.0) around the message:pre_route emit — but the multi-role-router classifier makes an auxiliary LLM call that routinely takes 15–120s, so the hook timed out on every invocation ("message:pre_route hook timed out after 5s" in gateway logs).

  • gateway/run.py — resolve the timeout from config hooks.pre_route_timeout (default 30s, clamped 1–120s, fallback to default on config errors). Logs the effective value once at first resolution and on change, not per message.
  • agent/shell_hooks.py — reserve pre_route_timeout as a non-event sub-key under hooks: so the shell-hook parser skips it silently (no "unknown hook event" warnings on every gateway start).
  • hermes_cli/config_defaults.py — document the new hooks.pre_route_timeout: 30.0 default.
  • tests/gateway/test_message_pre_route_hook.py — 6 new unit tests (default, custom value, clamp to 120 max / 1 min, invalid-value fallback, load-failure fallback). 19/19 pass.

No change to the hook handler or classifier logic. A wedged hook still can't stall the gateway loop (120s hard cap).

Agent and others added 8 commits August 4, 2026 09:29
…e hook

Closes NousResearch#5143

Adds a new `message:pre_route` hook event that fires after session
resolution but before the turn-lease is acquired. Hooks can return
{"decision": "switch_session", "session_id": "<id>"} to transparently
redirect the message to a different session (worker profile) before
the agent begins processing. The user sees no friction — they just talk.

Core changes (~27 lines, 2 files):
- gateway/hooks.py: document message:pre_route event with full context
  spec and return-value contract
- gateway/run.py: insert emit_collect("message:pre_route", ...) block
  in _handle_message_with_agent after session resolution, before
  turn-lease acquisition; applies switch_session on decisive results

Reference hook (optional-skills/multi-role-router/):
- HOOK.yaml: manifest declaring the message:pre_route subscription
- handler.py (~420 lines): classifier-based router using the existing
  auxiliary LLM slot (triage_specifier → compression fallback); stateless
  from the gateway's perspective; continuation fast-path skips the LLM
  on short acknowledgements; role config in config.yaml with sane defaults
  matching the bundled worker profiles
- README.md: install, config snippet, /role slash commands, troubleshooting

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- handler.py: atomic meta.yaml writes with threading.Lock + os.replace
- handler.py: null-safe multi_role_router and auxiliary config reads
- handler.py: user-defined roles replace defaults (not merge)
- handler.py: fuzzy role match longest-first with word boundaries
- run.py: wrap emit_collect in try/except, fix break placement in loop

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Fix: wrap _call_auxiliary_llm in try/except in _classify_message so
  LLM exceptions don't propagate through handle() to the gateway
- Fix: CONTINUATION_RE now correctly matches multi-word acks (ok thanks,
  got it, makes sense, sounds good) using alternation with word boundaries
- tests/test_multi_role_router.py: 45 tests covering fast-path, role
  config, meta.yaml atomicity, LLM response parsing, handle() integration
- tests/gateway/test_message_pre_route_hook.py: 10 tests covering the
  emit_collect block in run.py (exception handling, switch_session
  trigger conditions, break placement)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- handler.py: update meta.yaml on first-route-to-new-role so next turn
  has correct role context; log clearly instead of silent None return
- optional-skills/multi-role-router/SKILL.md: add required SKILL.md so
  OptionalSkillSource discovers the hook reference (skills_hub.py:3175)
- gateway/run.py: document agent-cache isolation limitation in pre-route
  block; full eviction requires earlier insertion point (out of scope)

CodeRabbit major findings applied:
- handler.py: restrict CONTINUATION_RE — remove what/how/why/when/where/
  which as standalone openers so topic questions reach the classifier
- handler.py: use META_FILE.parent in mkstemp dir (cross-filesystem safety)
- handler.py: wrap _classify_message call in try/except — fail open to
  current_role on any classification exception
- handler.py: protect meta load/mutate/save in handle() with _META_LOCK,
  matching _update_meta_session locking; reload inside lock for freshest state

Skipped (out of scope):
- async refactor of _classify_message/_call_auxiliary_llm (major restructure)
- test infra improvements (HOOK_DIR monkeypatch, xfail assertion cleanup)
- pending_role semantic (conflicts with PR blocker #1 design intent)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…it_collect

Mirrors the timeout guard added in the TUI path (tui_gateway/server.py).
Prevents a hanging hook handler from holding the turn-lease window open
indefinitely inside GatewayRunner._handle_message_with_agent.
On top of the NousResearch#74272 implementation (cherry-picked/rebased with original
authorship preserved):

- handler.py: read config via canonical load_config_readonly() instead of
  raw yaml.safe_load — satisfies tests/hermes_cli/test_config_read_guard.py
  and honors managed-scope overlay, env expansion, and profile paths.
- handler.py: classifier uses async_call_llm() (triage_specifier slot)
  instead of the sync call_llm() — a sync call inside the async handler
  blocks the gateway event loop for every classified message.
- handler.py: remove dead _update_meta_session() — it appends a history
  entry with an assistant response, but message:pre_route fires before the
  agent responds, so it could never be wired correctly.
- gateway/hooks.py: fix the message:pre_route chat_type contract to the
  real MessageSource values (dm|group|channel|thread|webhook).
- README: drop the /role slash-command table — those commands are not
  implemented in this PR or on main; document the config.yaml controls
  (multi_role_router.auto) that actually work.
- SKILL.md: description to the <=60 char one-sentence standard.
- HOOK.yaml: credit the original implementation (NousResearch#74272) explicitly.
- tests: align with load_config_readonly/async_call_llm paths.
@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery tool/skills Skills system (list, view, manage) area/sessions Session lifecycle, resume, persistence, history P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation 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 duplicate This issue or pull request already exists labels Aug 4, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Duplicate of #74272: this preserves the same message:pre_route routing contract and reference hook. The additional review-fix delta may be useful when maintainers choose the canonical vehicle.

The history-recording path was dead: nothing ever appended to
meta.yaml's 'history', so the classifier prompt always showed
'(no prior context)' and the continuation-context feature never worked.

Fix by recording each classified exchange inside the existing decision
lock block — one atomic meta.yaml write, no second lock acquisition
(hence no deadlock risk), and no early returns:

- history entries store the user message + target role; the assistant
  response is unavailable at message:pre_route time and stays empty
- the target role's session mapping is preserved on switch — the turn
  is delivered to the TARGET session, so recording the exchange must
  not overwrite sessions[target_role] with the inbound session id
  (regression test added)
- history is trimmed to HISTORY_WINDOW * 2 entries
- drop the dead CONTINUATION_PATTERNS list (only CONTINUATION_RE is
  used)

Tests: 48 hook tests + 13 gateway tests pass via scripts/run_tests.sh,
ruff clean.
@raulvidis

Copy link
Copy Markdown
Author

Expected flag — this intentionally preserves #74272's message:pre_route contract (the base commits are cherry-picked from it with authorship intact; see the PR body). @ceverson70 and I are coordinating the remaining deltas on #74272; the canonical-vehicle choice is the maintainers' call, same as the routing-contract question raised there.

The pre_route hook fires on every inbound message before the turn-lease is
claimed. The multi-role-router classifier makes an LLM call that routinely
takes 15-120s, but the 5s hard timeout killed it on every invocation
("message:pre_route hook timed out after 5s").

- gateway/run.py: resolve timeout from config hooks.pre_route_timeout
  (default 30s, clamped 1-120s), log effective value once + on change
- agent/shell_hooks.py: reserve pre_route_timeout under hooks: so the
  shell-hook parser skips it silently (no unknown-event warnings)
- hermes_cli/config_defaults.py: document hooks.pre_route_timeout default
- tests: 6 new unit tests for resolution/clamping/fallback behavior
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/gateway Gateway runner, session dispatch, delivery duplicate This issue or pull request already exists needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have 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 tool/skills Skills system (list, view, manage) type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Multi-Role Auto-Routing via Gateway Hooks

2 participants