feat(hooks): add message:pre_route event + multi-role-router reference hook - #78326
Open
raulvidis wants to merge 10 commits into
Open
feat(hooks): add message:pre_route event + multi-role-router reference hook#78326raulvidis wants to merge 10 commits into
raulvidis wants to merge 10 commits into
Conversation
…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.
…viction after session swap
…ging, document TOCTOU assumption
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.
This was referenced Aug 4, 2026
Collaborator
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.
Author
|
Expected flag — this intentionally preserves #74272's |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
message:pre_routegateway 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 themessage:pre_routeevent + 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— emitmessage:pre_routeafter session resolution, honor aswitch_sessiondecision, 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.Design decisions
switch_sessioncreates theSessionEntryfor a new target id./footext falls through (acceptable — it's just text).httpx.AsyncClient) so the hook never blocks the gateway event loop; thetriage_specifierauxiliary 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_dispatchplugin hook) and #72942 (pre_gateway_dispatchaction) — three proposals at different layers. This PR implements themessage:pre_routelayer (after session resolution, before turn lease). Happy to align with whichever contract the maintainers decide on.Newest commit: configurable
message:pre_routetimeout (aa368a6)The base branch shipped a hard
asyncio.wait_for(..., timeout=5.0)around themessage:pre_routeemit — 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 confighooks.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— reservepre_route_timeoutas a non-event sub-key underhooks:so the shell-hook parser skips it silently (no "unknown hook event" warnings on every gateway start).hermes_cli/config_defaults.py— document the newhooks.pre_route_timeout: 30.0default.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).