Skip to content

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

Open
ceverson70 wants to merge 9 commits into
NousResearch:mainfrom
ceverson70:feat/message-pre-route-hook-v3
Open

feat(hooks): add message:pre_route event + multi-role-router reference hook#74272
ceverson70 wants to merge 9 commits into
NousResearch:mainfrom
ceverson70:feat/message-pre-route-hook-v3

Conversation

@ceverson70

@ceverson70 ceverson70 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #5143

Implements the multi-role auto-routing feature proposed in the RFC. Users talk naturally from the home session — a lightweight classifier transparently routes each message to the appropriate worker profile (code-worker, knowledge-worker, ml-worker, ops-worker) before the agent begins processing.

  • gateway/hooks.py — adds message:pre_route to the documented hook events with full context spec and return-value contract
  • gateway/run.py — inserts emit_collect("message:pre_route", ...) in _handle_message_with_agent after session resolution, before turn-lease acquisition; applies switch_session on decisive hook results (~27 lines, matches existing command:* pattern)
  • optional-skills/multi-role-router/ — reference hook users drop into ~/.hermes/hooks/: classifier via existing auxiliary.triage_specifier slot, continuation fast-path (skips LLM on short acks), atomic meta.yaml writes, /role slash commands, config-driven role definitions with sane defaults
  • tests/test_multi_role_router.py — 45 unit 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)

Two bugs found during testing and fixed:

  • _classify_message was not catching exceptions from _call_auxiliary_llm — exceptions would propagate through handle() to the gateway. Now caught with warning log + current_role fallback.
  • CONTINUATION_RE didn't match multi-word acks ("ok thanks", "got it", "makes sense"). Fixed with proper alternation and word boundaries.

Relationship to open routing proposals

Two open issues propose routing hooks at adjacent but distinct layers:

message:pre_route fires between these two: after session resolution (get_or_create, delegation pinning, topic tip-walk, auto-reset) is complete, and before the turn-lease is claimed. This is a distinct layer with different semantics — session context is fully available, but the turn has not yet been committed.

The three hooks are complementary, not competing. They cover different phases of the call stack:

gateway ingress → [#72942: pre_gateway_dispatch + route action]
  ↓  session auth + resolution
message:pre_route  ← this PR (after session, before turn-lease)
  ↓  turn-lease acquired
  ↓  agent selected
agent dispatch  → [#69693: pre_agent_dispatch]

A new event name is justified because the layer is different: pre_gateway_dispatch has no session context, pre_agent_dispatch has already committed a turn, and message:pre_route sits between them with full session state but no turn commitment — which is exactly the right place to make a session-switch decision.

Note for maintainers: message:pre_route could also serve as the implementation vehicle for #72942's route action if that is preferred — the switch_session primitive it already uses is equivalent to what #72942 describes. The hook contract could be extended to accept a {"action": "route", "profile": "..."} return value alongside the existing {"decision": "switch_session", "session_id": "..."} form. This PR makes no claim on that design choice and defers to maintainer preference.

Test plan

  • python -m pytest tests/test_multi_role_router.py tests/gateway/test_message_pre_route_hook.py -v — 58 tests, all pass
  • ruff check gateway/hooks.py gateway/run.py optional-skills/multi-role-router/handler.py — clean
  • python scripts/check-windows-footguns.py gateway/hooks.py gateway/run.py optional-skills/multi-role-router/handler.py — clean
  • Existing hook events (agent:start, command:*) unaffected — no regressions in hook dispatch
  • With reference hook installed: natural language message routes to correct worker profile
  • multi_role_router.auto: false in config disables routing without error

Platform tested

macOS 15.5 (Apple Silicon), Python 3.11.15, Hermes v0.19.0 (cfa43f5)

Notes for reviewers

  • The switch_session primitive used is the same one already called for Telegram topic binding and delegation routing — no new session APIs
  • Hook ships in optional-skills/ as a reference users copy to ~/.hermes/hooks/ — zero gateway changes required to use or remove it
  • Credit to the RFC authors in [Feature] Multi-Role Auto-Routing via Gateway Hooks #5143 for the contextual classifier design and the dual-logic history packing approach
  • emit_collect is now wrapped with asyncio.wait_for(timeout=5.0) to bound hook execution and prevent a hanging handler from holding the turn-lease window open

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery tool/skills Skills system (list, view, manage) area/sessions Session lifecycle, resume, persistence, history needs-decision Awaiting maintainer decision before any implementation 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 labels Jul 29, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #69693 and #72942 propose overlapping routing authority at different hook boundaries. This rebase is not a duplicate, but maintainers should choose the session-routing contract before merging.

@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 hook contract and reference implementation. The current patch needs correctness and integration work before it can provide the claimed role isolation.

Problems

  • optional-skills/multi-role-router/handler.py:456 returns None for a newly selected role with no saved session. The gateway already creates the current inbound session at gateway/run.py:15527, so that first role turn stays in the existing session rather than creating isolation.
  • Current main builds session context and tool session environment at gateway/run.py:15638-15641 before the proposed pre-lease insertion point. The new redirect only replaces session_entry; unlike /resume, it does not clear conversation scope or evict the cached agent (gateway/slash_commands.py:4430-4442).
  • optional-skills/multi-role-router/ has no SKILL.md, but OptionalSkillSource discovers optional skills only through SKILL.md (tools/skills_hub.py:3175).

Suggested changes

  • Resolve the routing-contract overlap noted for #69693 and #72942, then define the first-route session-creation behavior.
  • Rework the integration around final session resolution and add an end-to-end gateway test for transcript and cached-agent isolation.
  • Package the optional artifact as a valid optional skill, or relocate it to an appropriate reference-hook surface.

This is an automated hermes-sweeper review.

target_session_id = sessions.get(target_role, "")

if not target_session_id:
# No existing session for this role — let the gateway create a new one

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.

The gateway has already run get_or_create_session() before this hook fires, so returning None cannot create a role session; it dispatches this first target-role message in the current session. Create or otherwise prepare a distinct target session here before returning a route decision, or explicitly scope the feature to previously created sessions.

@teknium1 teknium1 added sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 30, 2026
@ceverson70

Copy link
Copy Markdown
Contributor Author

Mechanical fixes are in progress (first-route isolation, agent cache eviction after session swap, SKILL.md).

One thing we can't resolve without a maintainer call: the routing-contract conflict with #69693 and #72942 flagged by @alt-glitch. We've documented the three-layer stack in the PR description (our hook sits between pre_gateway_dispatch and pre_agent_dispatch), but if the preferred direction is to extend pre_gateway_dispatch with a route action instead, we're happy to pivot.

Could we get a direction on #5143 so we know which design to finalize?

raulvidis added a commit to raulvidis/hermes-agent that referenced this pull request Aug 4, 2026
- README: remove 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. Fix the troubleshooting
  entry that referenced /role auto off.
- SKILL.md: shorten description to the <=60 char one-sentence standard.
- HOOK.yaml: credit the original implementation (NousResearch#74272, Clark Everson)
  instead of 'community'; bump version to 1.0.1.
- gateway/hooks.py: fix the message:pre_route chat_type contract to the
  real MessageSource values (dm|group|channel|thread|webhook), not the
  Telegram-specific ones.
- handler.py: update the stale /role comment.
@raulvidis

raulvidis commented Aug 4, 2026

Copy link
Copy Markdown

Update to my earlier note: rather than only offering the delta, I've rebuilt it properly as #78326 — your seven #74272 commits cherry-picked/rebased onto current main verbatim (your authorship intact), with my review-fix delta as one commit on top. My earlier #78269 is dead (it had re-committed your base work under my name — wrong way to do it).

The delta commit in #78326 covers what I flagged before, in case you'd rather fold it into this PR instead:

  • config read via canonical load_config_readonly() (satisfies tests/hermes_cli/test_config_read_guard.py)
  • classifier via async_call_llm() (triage_specifier slot) — the sync call_llm() in the current branch blocks the gateway event loop per classified message
  • removed dead _update_meta_session() (expects an assistant response that doesn't exist at message:pre_route time)
  • message:pre_route chat_type contract fixed to real MessageSource values (dm|group|channel|thread|webhook)
  • README /role slash-command table removed (commands exist nowhere); SKILL.md description ≤60 chars; HOOK.yaml credits your original implementation

All 58 tests pass via scripts/run_tests.sh, ruff clean. Whichever vehicle maintainers prefer is fine with me — if this PR folds in the same fixes and merges first, #78326 closes.

@alt-glitch alt-glitch removed sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) labels Aug 4, 2026
raulvidis added a commit to raulvidis/hermes-agent that referenced this pull request Aug 4, 2026
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.
@ceverson70

Copy link
Copy Markdown
Contributor Author

Thanks @raulvidis for the thorough review and for standing up #78326 with the fixes properly attributed. The delta changes (async classifier via async_call_llm, dead _update_meta_session() removal, chat_type contract alignment to MessageSource values, config via load_config_readonly()) all look correct to me.

Happy to go either way — fold those fixes into this PR, or let #78326 be the merge vehicle. Whichever is cleaner for maintainers.

The blocking question remains: @alt-glitch, could we get a direction on #5143 re: the routing contract? Specifically, should message:pre_route be the canonical routing hook, or should we extend pre_gateway_dispatch with a route action per #72942? That decision unblocks both this PR and #74408 (TUI companion).

@ceverson70

Copy link
Copy Markdown
Contributor Author

Three bugs fixed in optional-skills/multi-role-router/handler.py (commit e7f96e442):

1. _update_meta_session() never called (HIGH)
_update_meta_session() was defined and tested but never invoked from handle(), so classifier history was always empty and the prompt always showed "(no prior context)". Added a call after the decision block so each classified message is recorded in history for future classifier context. The decision block was refactored to avoid early returns inside the lock (since _update_meta_session takes its own _META_LOCK and would deadlock with a non-reentrant threading.Lock).

2. Synchronous call_llm blocks event loop (MEDIUM)
In _call_auxiliary_llm, the gateway-internal call_llm was called synchronously inside an async def, blocking the event loop. Wrapped with asyncio.to_thread() so the synchronous call runs in a thread pool instead.

3. Removed dead code CONTINUATION_PATTERNS (LOW)
The CONTINUATION_PATTERNS list was defined but never referenced — only CONTINUATION_RE / _CONTINUATION_RE are used. Removed the unused list.

All 48 tests in tests/test_multi_role_router.py pass.

@raulvidis

Copy link
Copy Markdown

Thanks for the review of the delta and for e7f96e442 — two of the three are good catches we converged on, one needs a second look:

Bug 1 (history dead) — diagnosis correct, but the fix clobbers the session map. You're right that history was never populated — and to be transparent, my #78326 delta had the same gap: I removed _update_meta_session() as dead code without replacing it, which killed the feature rather than wiring it. So good catch.

But the wiring in e7f96e442 calls _update_meta_session(role=target_role, session_id=current_session_id, ...) — and that helper writes sessions[role] = session_id. Trace a switch: the decision block correctly sets sessions[target_role] = <target session>, then _update_meta_session overwrites it with the inbound session id. Next message classified as that role reads the stale mapping and "switches" back to the shared inbound session — the isolated session is orphaned and role contexts leak together. I've landed the atomic version in #78326 (eb6435c83): history is appended inside the existing decision lock (one atomic write, no second lock, no early returns), the target role's mapping survives the switch, and there's a regression test for exactly this trace. Cherry-pickable if you'd rather fold it in here.

Bug 2 (event-loop block) — asyncio.to_thread() fixes the block, but the slot mismatch remains: the internal call still uses task="compression" while _get_auxiliary_config() reads the triage_specifier slot — a user who configures auxiliary.triage_specifier won't get that model used for classification. The #78326 delta uses async_call_llm(task="triage_specifier"), which matches the config read.

Bug 3 (CONTINUATION_PATTERNS) — adopted in the same commit.

All 61 tests pass via scripts/run_tests.sh, ruff clean. On the routing-contract question for @alt-glitch: agreed that's the blocker — +1 to whichever of message:pre_route vs. a pre_gateway_dispatch route action maintainers pick; both PRs can retarget.

Agent and others added 3 commits August 7, 2026 10:09
…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>
Agent and others added 6 commits August 7, 2026 10:09
- 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 NousResearch#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.
- Call _update_meta_session() from handle() after classification so
  history is populated for future classifier prompts (was always empty)
- Wrap synchronous call_llm with asyncio.to_thread() to avoid blocking
  the event loop when called from async context
- Remove unused CONTINUATION_PATTERNS list (CONTINUATION_RE is used)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…aulvidis review)

Bug 1: _update_meta_session() was called after the decision block with
session_id=current_session_id, which wrote sessions[target_role] = inbound_id
and clobbered the target role's session mapping set by the decision block.
Fix: inline history recording inside the decision lock atomically so the
session map is never overwritten.

Bug 2: _call_auxiliary_llm() used task="compression" but _get_auxiliary_config()
reads the triage_specifier slot first. Users who configure
auxiliary.triage_specifier would not get that model used for classification.
Fix: changed to task="triage_specifier" to match the config read path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ceverson70
ceverson70 force-pushed the feat/message-pre-route-hook-v3 branch from e7f96e4 to 5d57120 Compare August 7, 2026 14:09
@ceverson70

Copy link
Copy Markdown
Contributor Author

Thanks @raulvidis for the thorough review — both bugs are now fixed and the branch has been rebased onto upstream/main.

Bug 1 (session-map clobber): The separate _update_meta_session() call after the decision block was writing sessions[target_role] = current_session_id, which overwrote the target role's session mapping and orphaned the isolated session. Fixed by inlining the history recording inside the existing _META_LOCK decision block — one atomic read-mutate-save, no second lock acquisition, no clobber.

Bug 2 (task="compression" vs triage_specifier): _call_auxiliary_llm() was using task="compression" while _get_auxiliary_config() reads the triage_specifier slot first. Users who configure auxiliary.triage_specifier would not get that model used for classification. Changed to task="triage_specifier" to match the config read path.

All 48 tests pass after the rebase. The _update_meta_session() function is retained (existing tests cover it directly) but is no longer called from handle() — the history logic is now inline in the decision block.

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 needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit 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

4 participants