Skip to content

feat: single gateway, multiple agents (MVP) - #25660

Open
02356abc wants to merge 7 commits into
NousResearch:mainfrom
02356abc:feat/single-gateway-multi-agent
Open

feat: single gateway, multiple agents (MVP)#25660
02356abc wants to merge 7 commits into
NousResearch:mainfrom
02356abc:feat/single-gateway-multi-agent

Conversation

@02356abc

@02356abc 02356abc commented May 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Enable a single hermes gateway run process to host N isolated AI agents,
routing inbound messages by platform/chat/thread/user metadata while keeping
each agent's memory, skills, SOUL.md, and model config fully separate.

Fixes the bottleneck behind #23735, #7517, #9514, and #12099.

Deployment scenario matrix

Scenario Gateways Agents Status
Single user, single personality 1 1 (main) Zero behavior change
Single user, multi personality 1 N All fields wired
Team multi-tenant 1 N All fields wired
HA / sharding N N/gateway Each gateway loads its own config subset
Environment separation N 1/gateway Different HERMES_HOME per gateway

Architecture (8 commits)

  1. Session identityagent_id in SessionSource/SessionEntry, build_session_key prefix, SQLite migration
  2. AgentProfile + ContextVar — per-agent filesystem root, model, toolsets; use_profile() propagates through async chains
  3. Declarative routingroutes: list with 9 match keys, first-match-wins; select_agent plugin hook override
  4. GatewayRunner wiring — registry loading, profile wrapping, _apply_profile_runtime_overrides, _apply_profile_toolsets
  5. Cron + Delivery propagationCronJob.agent_id, per-profile storage, DeliveryTarget.agent_id
  6. CLIhermes agent list/add/remove/show
  7. Documentation — DESIGN.md, scenario matrix, data flow diagram, config examples
  8. Attribution — AUTHOR_MAP entry

Precedence chain

Session /model override → Profile override → Gateway default

The default "main" profile is a no-op overlay; existing single-agent
installs see zero behavior change.

Migration Guide

Existing single-agent users (no action required)

No configuration changes needed. The default default_agent: main ensures
all existing behavior is preserved. Your existing ~/.hermes/ directory
continues to work as the main agent profile.

Adding a second agent

# 1. Create the agent profile
hermes agent add coder --model anthropic/claude-opus-4-6

# 2. (Optional) Clone from existing profile
hermes agent add coder --from-profile main --model anthropic/claude-opus-4-6

# 3. Configure routing in ~/.hermes/config.yaml
agents:
  main: {}
  coder:
    model: anthropic/claude-opus-4-6
    home_dir: ~/.hermes/profiles/coder
routes:
  - match: { platform: telegram, chat_id: "-1001234" }
    agent: coder

# 4. Create SOUL.md for the new agent
mkdir -p ~/.hermes/profiles/coder
cp ~/.hermes/SOUL.md ~/.hermes/profiles/coder/SOUL.md
# Edit profiles/coder/SOUL.md to define coder's personality

# 5. Restart gateway
hermes gateway run

Consolidating multiple gateway processes

Before this PR: hermes -p coder gateway run + hermes -p research gateway run

After this PR:

  1. Stop all gateway processes
  2. Move profile directories to ~/.hermes/profiles/<name>/
  3. Configure routes in a single config.yaml
  4. Start one gateway process

Performance Impact

Metric Single-agent baseline Multi-agent (3 agents) Delta
ContextVar read N/A ~50ns Negligible
_agent_cache 128 slots for 1 agent 128 slots shared across N agents May hit cap sooner; LRU handles eviction
Session key length agent:main:... (+9 chars) agent:<id>:... Minimal memory impact
Routing resolution Direct to main Routes table + hook chain ~0.1ms per message (cached)

No measurable throughput regression for single-agent configs.

Tests

File Count Coverage
tests/agent/test_profile_contextvar.py 25 AgentProfile, ContextVar, async isolation
tests/gateway/test_agent_routing.py 25 Route matching, declaration order, invalid routes
tests/gateway/test_session.py 12 build_session_key with agent_id across all chat types
tests/gateway/test_profile_overrides.py 12 Runtime and toolset override helpers
tests/hermes_cli/test_agent_cli.py 24 hermes agent list/show/add/remove commands
tests/gateway/test_session_boundary_hooks.py updated Hook agent_id assertions
tests/test_model_tools.py updated Hook call signatures with agent_id

Multi-agent suite: 181 passed
Full regression: 22677 passed / 38 failed (pre-existing env issues) / 105 skipped

E2E Validation

Matrix → code agent routing validated with local Dendrite homeserver:

  • Matrix DM/room messages correctly route to code agent
  • Weixin/WeCom regression tests pass (continue routing to main/wecom-agent)
  • Session isolation verified: agent:code:matrix:dm:... session keys

Full report: docs/plans/2026-05-15-multi-agent-matrix-e2e-report.md

Non-goals (future PRs)

Verification commands

pytest tests/gateway/test_agent_routing.py -v
pytest tests/agent/test_profile_contextvar.py -v
pytest tests/gateway/test_session.py -v
pytest tests/gateway/test_profile_overrides.py -v
pytest tests/hermes_cli/test_agent_cli.py -v

Manual smoke checklist

  1. Message to unmatched chat → routes to main, session_key agent:main:...
  2. Message to Telegram forum topic 42 → routes to coder, session_key agent:coder:...
  3. Say "I'm Alice" in topic 42, "I'm Bob" in another → each agent remembers only its own name
  4. Different SOUL.md per profile → responses match respective personalities
  5. Enable filesystem toolset for coder only → research agent cannot access filesystem
  6. /new in topic 42 → on_session_finalize receives agent_id="coder"
  7. Create cron job in coder profile → file lands in profiles/coder/cron/jobs.json
  8. Trigger delivery from coder session → executes in coder context
  9. Plugin returns "research" from select_agent hook → overrides route match
  10. Restart gateway, message previous chat → restores session with correct agent_id
  11. Delete agents: and routes: from config → all messages route to main
  12. Old sessions.db auto-migrates, old rows backfill to "main"

@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management platform/telegram Telegram bot adapter platform/discord Discord bot adapter platform/slack Slack app adapter platform/feishu Feishu / Lark adapter platform/wecom WeCom / WeChat Work adapter platform/matrix Matrix adapter (E2EE) labels May 14, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Note: This supersedes #25008 (closed). Same feature scope — single gateway, multiple agents MVP. Related feature requests: #7517, #9514, #12099.

@discolotus

Copy link
Copy Markdown

Tracked follow-up technical debt from this PR:

@02356abc

Copy link
Copy Markdown
Contributor Author

CI Test Failure Analysis

The test job failure is entirely due to pre-existing environment issues in the CI runner, not caused by this PR.

Verified locally

All tests related to this PR pass locally (313+ tests):

Test File Status
tests/agent/test_profile_contextvar.py 25 passed
tests/gateway/test_agent_routing.py 25 passed
tests/gateway/test_session.py 12 passed
tests/gateway/test_profile_overrides.py 12 passed
tests/cli/test_session_boundary_hooks.py 4 passed
tests/cron/test_scheduler.py 121 passed
tests/cron/test_file_permissions.py 8 passed
tests/test_model_tools.py passed

CI failure breakdown (all pre-existing)

Category Files Root Cause
Missing dependencies test_bedrock_adapter.py, test_bedrock_integration.py, test_bedrock_model_picker.py, test_transcription.py botocore, faster_whisper not installed in CI
OpenSSL/cryptography version test_wecom_callback.py, test_weixin.py, test_platform_http_client_limits.py cffi / cryptography API mismatch
Environment/mock limitations test_auxiliary_client.py, test_dingtalk.py, test_feishu_bot_admission.py, test_matrix.py CI sandbox restrictions
Model/provider config test_provider_parity.py, test_compression_feasibility.py, test_switch_model_context.py No runtime provider configured
Plugin/tool registry drift test_plugin_discovery.py, test_registry.py New providers added since test was written
Signal handling test_mcp_stability.py SIGKILL not available in container
Module import test_tts_kittentts.py Import error in test module

None of these failures are related to the multi-agent changes introduced in this PR.

@02356abc

Copy link
Copy Markdown
Contributor Author

@discolotus Thanks for tracking these follow-ups! All four items are already documented in the DESIGN.md file under the "Non-Goals (Future PRs)" section with the same issue numbers you listed. The design doc explicitly scopes them out of this MVP to keep the PR reviewable.

@02356abc

Copy link
Copy Markdown
Contributor Author

E2E Test Report — Multi-Agent Routing Validation

We completed end-to-end validation of the multi-agent routing feature. Here is the summary:

Test Matrix

Scenario Status Evidence
Matrix → code agent PASS Session key: agent:code:matrix:dm:!u00jd7u1b1WqHly1:localhost
Weixin → main (regression) PASS No agent:code prefix in weixin logs
WeCom → wecom-agent (regression) PASS Route preserved, no errors
Profile isolation (sessions, memory, SOUL) PASS Unit tests + config verified
Gateway restart resilience PASS Routing config persisted after restart
pytest automation 38/38 passed tests/gateway/test_agent_routing.py

Configuration Used

default_agent: main
agents:
  main: {}
  wecom-agent:
    home_dir: /root/.hermes/profiles/wecom-agent
  code:
    model: kimi-for-coding
    provider: moonshot
    home_dir: /root/.hermes/profiles/code
routes:
  - match: { platform: wecom }
    agent: wecom-agent
  - match: { platform: matrix }
    agent: code

Kanban Subsystem Impact Analysis

The Kanban subsystem requires zero code changes. Key findings:

  1. Shared data layerkanban_home() uses get_default_hermes_root(), not get_hermes_home(). The board DB is cross-profile by design.
  2. Worker isolation via subprocess — Dispatcher spawns hermes -p <assignee> as independent OS processes with their own HERMES_HOME, fully isolated from the gateway's ContextVar.
  3. Background tasks are agent-agnostic_kanban_notifier_watcher and _kanban_dispatcher_watcher never consult agent_id or the active ContextVar.

Configuration convention: Kanban task assignee must name a valid Hermes profile or agents: key. If unresolvable, the dispatcher records spawn failures and auto-blocks after failure_limit retries.

Full details: docs/plans/2026-05-15-multi-agent-matrix-e2e-report.md and DESIGN.md "Interaction with Existing Subsystems" section.

@02356abc

Copy link
Copy Markdown
Contributor Author

@alt-glitch This PR is ready for review. Here's a summary of what's been addressed since the initial submission:

Changes since last review

  1. CI failures verified as pre-existing — The test and e2e failures are identical on main branch (same 7 tests: test_provider_parity ×3 + test_discord_adapter ×4). All multi-agent tests pass locally (181/181).

  2. E2E validation completed — Matrix → code agent routing tested with local Dendrite homeserver. Session keys correctly show agent:code:matrix:dm:.... Weixin/WeCom regression clean.

  3. CLI tests added — 24 tests for hermes agent list/show/add/remove, including profile clone and route cleanup validation.

  4. Bug fixcmd_agent_add --from-profile no longer copies entire HERMES_HOME when cloning from main.

  5. Documentation updated — DESIGN.md now includes:

    • Migration Guide (single-agent → multi-agent, process consolidation)
    • Performance Impact analysis
    • Kanban subsystem interaction section
    • E2E test report

Key design decisions for reviewer attention

  • ContextVar propagationuse_profile() wraps the entire message handling path. Verified with asyncio.gather isolation tests.
  • Session key formatagent:<id>:<platform>:... preserves backward compat (default "main" produces same keys as before).
  • Cache safety_agent_cache keyed by session_key naturally supports multi-agent without code changes.
  • Hook payload — All invoke_hook calls include agent_id= so plugins can branch on the active agent.

Please let me know if you'd like any section expanded or if there are specific areas you'd like me to walk through.

@02356abc
02356abc force-pushed the feat/single-gateway-multi-agent branch 3 times, most recently from 673123a to 48894d4 Compare May 17, 2026 07:23
@02356abc

Copy link
Copy Markdown
Contributor Author

Force-pushed: rewrote commit history from 11 commits to 7 focused commits.

Line count breakdown by category

Category Lines Share
Production code 1,422 46.4%
Tests 1,350 44.0%
Docs / Config / Attribution 294 9.6%
Total 3,066

Key point: tests + docs together account for 53.6% of the diff.
The actual production code change is ~1,400 lines across 7 atomic commits.

Production code surface (1,422 lines)

Only 16 files contain production code changes; the rest are tests, docs, or config:

Area Files What changed
New modules (3 files) agent/profile.py, gateway/agent_routing.py, hermes_cli/agent.py 3 new small modules (~520 LOC total)
Gateway core (4 files) gateway/run.py, gateway/session.py, gateway/config.py, gateway/platforms/base.py Registry loading, profile binding, routing injection, session key prefixing
Platform adapters (7 files) telegram, discord, slack, matrix, feishu, wecom, yuanbao Single _attach_agent_id() call each (~3–11 LOC)
Cron + Delivery (3 files) cron/jobs.py, cron/scheduler.py, gateway/delivery.py use_profile() boundary wrapping
Hook propagation (4 files) model_tools.py, tools/approval.py, tools/terminal_tool.py, tools/delegate_tool.py agent_id= kwarg on invoke_hook
Path getters (1 file) hermes_constants.py ContextVar read before env fallback
CLI wiring (2 files) cli.py, tui_gateway/server.py Hook agent_id= kwarg
Plugin hook (1 file) hermes_cli/plugins.py select_agent hook registration

What was removed vs the previous 11-commit version

  • DESIGN.md and docs/plans/* — development artifacts, not for merge
  • Standalone fix: CI test failures commit — folded into the commits that introduced the issues
  • cron/jobs.py JSON agent_id field and cross-profile fallback block — simplified to directory-only identity

Verification

  • Full pytest suite: 22,498 passed (failures are env-only: missing acp/textual/voice hardware)
  • Matrix E2E: 6/6 assertions passed — DM→coder, group→main, same-room key isolation, ContextVar path isolation, route resolution, legacy fallback

@02356abc
02356abc force-pushed the feat/single-gateway-multi-agent branch from 48894d4 to 730d92c Compare May 17, 2026 08:04
@02356abc

Copy link
Copy Markdown
Contributor Author

Force-pushed (rebased onto latest main + fixed run_agent.py refactor migration).

What changed in this push

Rebased onto latest mainmain had 17 refactor commits that extracted run_conversation from run_agent.py into agent/conversation_loop.py. Our agent_id hook injections have been migrated to the new file.

agent/conversation_loop.py (+63 lines) — 6 invoke_hook call sites now include agent_id kwarg:

  • on_session_start
  • pre_llm_call
  • pre_api_request
  • post_api_request
  • transform_llm_output
  • post_llm_call
  • on_session_end

run_agent.py is now a thin forwarder (no hook calls), so no injection needed there.

Line count breakdown by category

Category Lines Share
Production code ~1,400 46%
Tests ~1,350 44%
Docs / Config / Attribution ~294 10%
Total 2,984

PR status

  • mergeable_state: unstabletrue (mergeable, CI pending)
  • commits: 7
  • changed_files: 46
  • Zero file conflicts vs latest main

@02356abc

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main (519657a) and resolved conflicts from the run_agent.py refactor.

What changed since last push:

  • Migrated agent_id hook injections from run_agent.py to the new agent/conversation_loop.py (extracted in mainline)
  • All 6 invoke_hook call sites in conversation_loop.py now propagate agent_id via ContextVar
  • Zero behavior change for single-agent users; all session keys still default to agent:main: prefix

Test results after rebase:

Test suite Result
tests/gateway/test_agent_routing.py 38 passed
tests/gateway/test_session.py 92 passed
tests/run_agent/test_tool_executor_contextvar_propagation.py 5 passed
tests/cron/test_scheduler.py 121 passed
tests/gateway/test_matrix.py 147 passed
Total core multi-agent tests 403 passed

Commit breakdown (7 commits):

  1. bedf57a5d feat(agent): add AgentProfile + ContextVar for per-agent paths
  2. ad1d79521 feat(session): thread agent_id through session identity & DB schema
  3. ec1efc564 feat(gateway): route inbound messages via routes table + select_agent hook
  4. c4bedd48e feat(gateway): GatewayRunner loads registry, binds profile, propagates agent_id to hooks
  5. 9492a0104 feat(cron+delivery): propagate agent_id through scheduled jobs & deliveries
  6. 42979189e feat(cli): add hermes agent subcommand for multi-agent management
  7. 730d92ccd docs: multi-agent routing guide + sample config + AUTHOR_MAP

Ready for review.

davidgut1982 added a commit to davidgut1982/hermes-agent that referenced this pull request Jun 2, 2026
The OpenAI-compatible HTTP adapter was the one inbound surface from
PR NousResearch#25660 that never called ``_attach_agent_id`` — every
``/v1/chat/completions``, ``/v1/responses``, and ``/v1/runs`` request
fell through to ``default_agent`` regardless of the configured routes,
silently undermining the multi-agent guarantee on any deployment that
exposes the API server.

Add a single routing entry point, ``_resolve_agent_profile``, that:

  * Reads ``X-Hermes-Chat-Id`` / ``X-Hermes-User-Id`` / ``X-Hermes-Thread-Id``
    from the request (sanitised through the same length + control-char
    caps as the existing ``X-Hermes-Session-Id`` / ``X-Hermes-Session-Key``).
  * Builds a synthetic ``SessionSource(platform=API_SERVER, …)`` and
    pipes it through the shared ``_attach_agent_id`` hook so declarative
    routes *and* the ``select_agent`` plugin hook fire identically to
    every other adapter.
  * Looks up the resolved ``agent_id`` in
    ``self._gateway_ref._agent_registry`` and returns the matching
    ``AgentProfile`` (or ``None`` for legacy single-agent installs).

The three agent-invoking handlers (chat completions, responses, runs)
now resolve the profile up front and bind it via ``use_profile`` for
the duration of the run.  Binding happens twice — once on the asyncio
side and once inside the executor thread — because asyncio's default
executor does not propagate ContextVars.

Behaviour is fully backward compatible: requests with no routing
headers (the existing OpenAI-API contract) resolve to
``default_agent``, exactly the current behaviour.

New tests in ``tests/gateway/test_api_server_routing.py`` cover:

  * Header sanitisation (CRLF rejection, length caps, whitespace).
  * Route resolution: matching, no-header fall-through, unmatched
    header fall-through, ``platform``-only catch-all, ``user_id`` and
    ``thread_id`` routes, route-order precedence.
  * Resilience: missing gateway reference, empty registry.
  * ContextVar isolation under ``asyncio.gather`` so two concurrent
    HTTP requests with different chat_ids stay isolated.

Refs: PR NousResearch#25660 (single-gateway multi-agent).
davidgut1982 added a commit to davidgut1982/hermes-agent that referenced this pull request Jun 2, 2026
The OpenAI-compatible HTTP adapter was the one inbound surface from
PR NousResearch#25660 that never called ``_attach_agent_id`` — every
``/v1/chat/completions``, ``/v1/responses``, and ``/v1/runs`` request
fell through to ``default_agent`` regardless of the configured routes,
silently undermining the multi-agent guarantee on any deployment that
exposes the API server.

Add a single routing entry point, ``_resolve_agent_profile``, that:

  * Reads ``X-Hermes-Chat-Id`` / ``X-Hermes-User-Id`` / ``X-Hermes-Thread-Id``
    from the request (sanitised through the same length + control-char
    caps as the existing ``X-Hermes-Session-Id`` / ``X-Hermes-Session-Key``).
  * Builds a synthetic ``SessionSource(platform=API_SERVER, …)`` and
    pipes it through the shared ``_attach_agent_id`` hook so declarative
    routes *and* the ``select_agent`` plugin hook fire identically to
    every other adapter.
  * Looks up the resolved ``agent_id`` in
    ``self._gateway_ref._agent_registry`` and returns the matching
    ``AgentProfile`` (or ``None`` for legacy single-agent installs).

The three agent-invoking handlers (chat completions, responses, runs)
now resolve the profile up front and bind it via ``use_profile`` for
the duration of the run.  Binding happens twice — once on the asyncio
side and once inside the executor thread — because asyncio's default
executor does not propagate ContextVars.

Behaviour is fully backward compatible: requests with no routing
headers (the existing OpenAI-API contract) resolve to
``default_agent``, exactly the current behaviour.

New tests in ``tests/gateway/test_api_server_routing.py`` cover:

  * Header sanitisation (CRLF rejection, length caps, whitespace).
  * Route resolution: matching, no-header fall-through, unmatched
    header fall-through, ``platform``-only catch-all, ``user_id`` and
    ``thread_id`` routes, route-order precedence.
  * Resilience: missing gateway reference, empty registry.
  * ContextVar isolation under ``asyncio.gather`` so two concurrent
    HTTP requests with different chat_ids stay isolated.

Refs: PR NousResearch#25660 (single-gateway multi-agent).
davidgut1982 added a commit to davidgut1982/hermes-agent that referenced this pull request Jun 2, 2026
The OpenAI-compatible HTTP adapter was the one inbound surface from
PR NousResearch#25660 that never called ``_attach_agent_id`` — every
``/v1/chat/completions``, ``/v1/responses``, and ``/v1/runs`` request
fell through to ``default_agent`` regardless of the configured routes,
silently undermining the multi-agent guarantee on any deployment that
exposes the API server.

Add a single routing entry point, ``_resolve_agent_profile``, that:

  * Reads ``X-Hermes-Chat-Id`` / ``X-Hermes-User-Id`` / ``X-Hermes-Thread-Id``
    from the request (sanitised through the same length + control-char
    caps as the existing ``X-Hermes-Session-Id`` / ``X-Hermes-Session-Key``).
  * Builds a synthetic ``SessionSource(platform=API_SERVER, …)`` and
    pipes it through the shared ``_attach_agent_id`` hook so declarative
    routes *and* the ``select_agent`` plugin hook fire identically to
    every other adapter.
  * Looks up the resolved ``agent_id`` in
    ``self._gateway_ref._agent_registry`` and returns the matching
    ``AgentProfile`` (or ``None`` for legacy single-agent installs).

The three agent-invoking handlers (chat completions, responses, runs)
now resolve the profile up front and bind it via ``use_profile`` for
the duration of the run.  Binding happens twice — once on the asyncio
side and once inside the executor thread — because asyncio's default
executor does not propagate ContextVars.

Behaviour is fully backward compatible: requests with no routing
headers (the existing OpenAI-API contract) resolve to
``default_agent``, exactly the current behaviour.

New tests in ``tests/gateway/test_api_server_routing.py`` cover:

  * Header sanitisation (CRLF rejection, length caps, whitespace).
  * Route resolution: matching, no-header fall-through, unmatched
    header fall-through, ``platform``-only catch-all, ``user_id`` and
    ``thread_id`` routes, route-order precedence.
  * Resilience: missing gateway reference, empty registry.
  * ContextVar isolation under ``asyncio.gather`` so two concurrent
    HTTP requests with different chat_ids stay isolated.

Refs: PR NousResearch#25660 (single-gateway multi-agent).
davidgut1982 added a commit to davidgut1982/hermes-agent that referenced this pull request Jun 3, 2026
The OpenAI-compatible HTTP adapter was the one inbound surface from
PR NousResearch#25660 that never called ``_attach_agent_id`` — every
``/v1/chat/completions``, ``/v1/responses``, and ``/v1/runs`` request
fell through to ``default_agent`` regardless of the configured routes,
silently undermining the multi-agent guarantee on any deployment that
exposes the API server.

Add a single routing entry point, ``_resolve_agent_profile``, that:

  * Reads ``X-Hermes-Chat-Id`` / ``X-Hermes-User-Id`` / ``X-Hermes-Thread-Id``
    from the request (sanitised through the same length + control-char
    caps as the existing ``X-Hermes-Session-Id`` / ``X-Hermes-Session-Key``).
  * Builds a synthetic ``SessionSource(platform=API_SERVER, …)`` and
    pipes it through the shared ``_attach_agent_id`` hook so declarative
    routes *and* the ``select_agent`` plugin hook fire identically to
    every other adapter.
  * Looks up the resolved ``agent_id`` in
    ``self._gateway_ref._agent_registry`` and returns the matching
    ``AgentProfile`` (or ``None`` for legacy single-agent installs).

The three agent-invoking handlers (chat completions, responses, runs)
now resolve the profile up front and bind it via ``use_profile`` for
the duration of the run.  Binding happens twice — once on the asyncio
side and once inside the executor thread — because asyncio's default
executor does not propagate ContextVars.

Behaviour is fully backward compatible: requests with no routing
headers (the existing OpenAI-API contract) resolve to
``default_agent``, exactly the current behaviour.

New tests in ``tests/gateway/test_api_server_routing.py`` cover:

  * Header sanitisation (CRLF rejection, length caps, whitespace).
  * Route resolution: matching, no-header fall-through, unmatched
    header fall-through, ``platform``-only catch-all, ``user_id`` and
    ``thread_id`` routes, route-order precedence.
  * Resilience: missing gateway reference, empty registry.
  * ContextVar isolation under ``asyncio.gather`` so two concurrent
    HTTP requests with different chat_ids stay isolated.

Refs: PR NousResearch#25660 (single-gateway multi-agent).
davidgut1982 added a commit to davidgut1982/hermes-agent that referenced this pull request Jun 3, 2026
The OpenAI-compatible HTTP adapter was the one inbound surface from
PR NousResearch#25660 that never called ``_attach_agent_id`` — every
``/v1/chat/completions``, ``/v1/responses``, and ``/v1/runs`` request
fell through to ``default_agent`` regardless of the configured routes,
silently undermining the multi-agent guarantee on any deployment that
exposes the API server.

Add a single routing entry point, ``_resolve_agent_profile``, that:

  * Reads ``X-Hermes-Chat-Id`` / ``X-Hermes-User-Id`` / ``X-Hermes-Thread-Id``
    from the request (sanitised through the same length + control-char
    caps as the existing ``X-Hermes-Session-Id`` / ``X-Hermes-Session-Key``).
  * Builds a synthetic ``SessionSource(platform=API_SERVER, …)`` and
    pipes it through the shared ``_attach_agent_id`` hook so declarative
    routes *and* the ``select_agent`` plugin hook fire identically to
    every other adapter.
  * Looks up the resolved ``agent_id`` in
    ``self._gateway_ref._agent_registry`` and returns the matching
    ``AgentProfile`` (or ``None`` for legacy single-agent installs).

The three agent-invoking handlers (chat completions, responses, runs)
now resolve the profile up front and bind it via ``use_profile`` for
the duration of the run.  Binding happens twice — once on the asyncio
side and once inside the executor thread — because asyncio's default
executor does not propagate ContextVars.

Behaviour is fully backward compatible: requests with no routing
headers (the existing OpenAI-API contract) resolve to
``default_agent``, exactly the current behaviour.

New tests in ``tests/gateway/test_api_server_routing.py`` cover:

  * Header sanitisation (CRLF rejection, length caps, whitespace).
  * Route resolution: matching, no-header fall-through, unmatched
    header fall-through, ``platform``-only catch-all, ``user_id`` and
    ``thread_id`` routes, route-order precedence.
  * Resilience: missing gateway reference, empty registry.
  * ContextVar isolation under ``asyncio.gather`` so two concurrent
    HTTP requests with different chat_ids stay isolated.

Refs: PR NousResearch#25660 (single-gateway multi-agent).
@vdruts

vdruts commented Jun 4, 2026

Copy link
Copy Markdown

+1 — strongly in favor of this landing. Adding a real-world data point:

I've been running exactly this architecture in OpenClaw for months: a single gateway process hosting 8 agents, each with its own Telegram bot token, personality, model config, and isolated memory. One process polls all 8 bots, routes inbound by bot/chat, and operationally it's one daemon to install, watch, and restart instead of eight.

I've started building agents in Hermes and want to migrate fully — but the one-gateway-per-profile model is the blocker. Recreating my setup today means 8 separate gateway services, 8 restart paths, and 8 chances for the PID/launchd races already reported elsewhere in the tracker. That's a hard sell when the single-gateway model demonstrably works at this scale day-to-day.

The design here (per-agent profile + declarative routes, zero behavior change for existing single-agent installs) maps 1:1 to how I'd consolidate. Happy to test this MVP against a real 8-bot Telegram fleet if useful.

@alt-glitch alt-glitch 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 labels Jun 29, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: supersedes #25008 (closed, same single-gateway/multi-agent MVP scope) and #34741 (closed rebase of this PR onto v0.15.0). Addresses feature requests #7517, #9514, #12099, #23735. Tracked follow-up tech debt from this PR: per-agent token bucket #25695, filesystem isolation guards #25696, per-agent process supervision #25697. Not a duplicate (the prior MVP PRs are closed); this is the active version. Maintainer to review as the canonical multi-agent gateway PR.

@alt-glitch alt-glitch added comp/tools Tool registry, model_tools, toolsets comp/tui Terminal UI (ui-tui/ + tui_gateway/) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jun 29, 2026
@jethac

jethac commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

@02356abc @davidgut1982 — opened #62944: your 7 commits rebased onto current main (the ~6.5k-commit drift resolved onto main's newer structure), authorship preserved, plus a cron path-resolution fix the rebase surfaced. This is the "rebased-base follow-on" from the May thread — @davidgut1982, saw you were going to take that one; it'd gone quiet so I put a version up, but glad to defer or collaborate if you're still on it. Multi-agent suite passes (~1.6k tests across the touched modules); details in the PR.

@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 substantial multi-agent implementation. The current main branch has since gained a different multiplexing architecture, but it deliberately requires separate credentials per polling profile (gateway/run.py:8566-8580), so it does not subsume this PR's metadata-routing use case.

Problems

  • cron/jobs.py:1131 unconditionally scans every profile's due jobs. When cloned/profile-local schedules share an explicit delivery target, each copy is executed and delivered; this matches the duplicate-delivery report in this PR's discussion.

Suggested changes

  • Establish an explicit cron execution-owner rule before aggregating profile jobs, and add a multi-profile fixed-target delivery regression test.

Automated hermes-sweeper review.

Comment thread cron/jobs.py
This is the multi-agent equivalent of ``get_due_jobs()``.
"""
all_due: List[Dict[str, Any]] = []
for agent_id, profile in registry.items():

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 scans and returns due jobs for every profile. If profile cron files contain equivalent schedules with the same explicit deliver: target, the scheduler runs and sends every copy. Define an execution-owner/deduplication rule before aggregation and cover the fixed-target multi-profile case.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 13, 2026
jethac added a commit to jethac/hermes-agent that referenced this pull request Jul 22, 2026
…pi_server_routing.py

This PR-authored test file ("ported from David Gutowsky's original
NousResearch#25660-era commit") predates two things upstream/main has since changed:

1. All 6 failing tests patched `adapter._ensure_session_db = lambda: ...`,
   but every real request handler calls the async `_ensure_session_db_async()`
   instead, whose actual test-override hook is the `_session_db` attribute
   (checked first, independently of the sync method). The patch was
   silently a no-op, so handlers fell through to opening a real on-disk
   SessionDB. Fixed by setting `adapter._session_db = mock_db` (or a real
   SessionDB, see below) instead of monkeypatching the sync method.

2. The two session-CREATE tests additionally assumed `_handle_create_session`
   calls `db.create_session(...)` directly (matching an older implementation).
   It no longer does: upstream rewrote the endpoint to a single atomic
   check-insert-title SQL block (`_execute_write`) to close a TOCTOU window
   on concurrent same-ID creates -- a real, deliberate safety improvement
   that must be kept, not reverted to make the test pass. A MagicMock can't
   meaningfully observe a raw SQL INSERT, so both tests now use a real
   temp-file SessionDB and assert on the persisted row's agent_id via
   db.get_session() instead of asserting call_args on a mocked method.

All 31 tests in the file pass; the fork and session-chat tests needed only
fix NousResearch#1, since _handle_fork_session/_handle_session_chat still call
db.create_session()/db.get_session() directly and were never affected by
the atomic-INSERT rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@GottZ GottZ left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Summary

Six PRs address the single-gateway multi-agent request: #25660 and its rebase #34741 implement metadata-based routing with isolated agent state, while #37497, #37498, #37500, and #37502 decompose the same cumulative feature into routing/runtime, CLI, cron/API, and documentation layers. The route-table design directly targets same-credential metadata routing, but current main now provides a different profile-multiplexing lifecycle and the remaining diffs require integration with its validated profile and credential scopes.

Related pull requests

  • #25660 related — (+2983/-82) — keep open for targeted salvage, not merge-ready: implements the original metadata-routing cause across sessions, profiles, platforms, hooks, cron, delivery, CLI, and docs, a use case the keep_open review says current credential-per-profile multiplexing does not subsume. Its unconditional cross-profile cron scan can duplicate fixed-target deliveries, so an execution-owner rule and regression test are required before any merge.
  • #34741 [closed] duplicate — (+3518/-125) — superseded reference implementation: this closed rebase carries essentially #25660 plus API-server header routing and executor-thread profile propagation, so it remains useful as the most complete historical implementation. It was explicitly superseded by the six-part chain ending in #37502 and should not be reopened.
  • #37497 related — (+1749/-31) — do not merge as-is: adds route-table selection, session namespacing, profile binding, runtime overrides, and hook propagation, but selected IDs are not validated before session-key construction, allowing an unknown agent namespace to execute with main-agent state. Despite the keep_open review on #37497, current main's multiplex-profile lifecycle means this diff should be mined for metadata-selection behavior rather than merged directly.
  • #37498 [closed] related — (+2489/-32) — implemented on main / superseded: adds the cumulative routing base plus a separate hermes agent management CLI, but current main already exposes profile management through hermes profile and multiplexed gateways. This closed PR remains relevant as the operator-UX portion of the abandoned route-table series, not as a candidate to reopen.
  • #37500 related — (+3225/-113) — targeted salvage only: extends the cumulative series with per-agent cron and delivery propagation plus API-server header routing, directly covering scheduled and HTTP ingress. Despite the keep_open review on #37500, the diff captures the local output directory before entering the selected profile and omits current main's credential scope, so only missing API-routing behavior should be ported onto the multiplex-profile boundary.
  • #37502 related — (+3528/-117) — documentation salvage only: represents the full cumulative chain and adds configuration docs plus broader use_profile scope, but documents obsolete top-level agents/routes/default_agent semantics and introduces a second profile ContextVar without current main's fail-closed secret scope. Despite the keep_open review on #37502, it should not merge as-is; rewrite any reusable documentation and ingress behavior against gateway.multiplex_profiles, SessionSource.profile, and _profile_runtime_scope.

Duplicates

#34741 is a rebased and extended duplicate of #25660; #37497, #37498, #37500, and #37502 are cumulative slices of that same implementation, with #37502 containing the complete chain rather than an independent solution.

Suggested consolidation

Do not merge any listed PR as-is. Keep #25660 as the canonical metadata-routing salvage tracker because its keep_open review establishes that same-credential metadata routing remains distinct, but first address the blocking cron execution-owner defect; port only the still-missing metadata/API-routing delta into current main's validated multiplex-profile and credential scope. #34741 and #37498 can remain closed, and #37497, #37500, and #37502 can be closed as superseded duplicates once that current-main-based replacement preserves their relevant tests and documentation.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    subgraph Dup25660 ["PRs duplicating each other"]
        P25660["PR #25660 (open)"]
        P34741["PR #34741 (closed)"]
        P37502["PR #37502 (open)"]
    end
    class P25660 open
    class P34741 closed
    class P37502 open
    class P25660 target
    click P25660 "https://github.com/NousResearch/hermes-agent/pull/25660"
    click P34741 "https://github.com/NousResearch/hermes-agent/pull/34741"
    click P37502 "https://github.com/NousResearch/hermes-agent/pull/37502"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed or no verify verdict yet (state tag in the node label).

Cross-PR triage: Reviewed 6 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 1064 kB of PR diffs, 12 kB of issue/PR text, 22 kB of discussion (25 comments), 2 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@alt-glitch alt-glitch added needs-decision Awaiting maintainer decision before any implementation blocked Waiting on external dependency or decision comp/tui Terminal UI (ui-tui/ + tui_gateway/) area/config Config system, migrations, profiles and removed comp/tui Terminal UI (ui-tui/ + tui_gateway/) sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jul 29, 2026
@net592

net592 commented Aug 17, 2026

Copy link
Copy Markdown

I really need this and hope a PR will be created for this feature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles blocked Waiting on external dependency or decision comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery comp/tools Tool registry, model_tools, toolsets comp/tui Terminal UI (ui-tui/ + tui_gateway/) needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists platform/discord Discord bot adapter platform/feishu Feishu / Lark adapter platform/matrix Matrix adapter (E2EE) platform/slack Slack app adapter platform/telegram Telegram bot adapter platform/wecom WeCom / WeChat Work adapter sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users 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.

10 participants