diff --git a/README.md b/README.md
index bda0c5ed3cf1a..45cd7fc57c5ef 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,8 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
| A closed learning loop | Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. |
| Scheduled automations | Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. |
| Delegates and parallelizes | Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. |
-| Runs anywhere, not just your laptop | Six terminal backends — local, Docker, SSH, Singularity, Modal, and Daytona. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
+| Multi-agent routing | Run multiple isolated agents from a single gateway process — each with its own model, personality, memory, and skills. Route by platform, chat, thread, or user. A coding agent with Opus, a research agent with Sonnet, a creative agent with GPT-5, all in one process. |
+| Runs anywhere, not just your laptop | Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
| Research-ready | Batch trajectory generation, trajectory compression for training the next generation of tool-calling models. |
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index 0cc84228522b4..19663884d962f 100644
--- a/agent/conversation_loop.py
+++ b/agent/conversation_loop.py
@@ -292,11 +292,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# to initialise session-scoped state (e.g. warm a memory cache).
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
_invoke_hook(
"on_session_start",
session_id=agent.session_id,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
+ agent_id=_agent_id,
)
except Exception as exc:
logger.warning("on_session_start hook failed: %s", exc)
@@ -702,6 +711,14 @@ def run_conversation(
_plugin_user_context = ""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
@@ -713,6 +730,7 @@ def run_conversation(
model=agent.model,
platform=getattr(agent, "platform", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
+ agent_id=_agent_id,
)
_ctx_parts: list[str] = []
for r in _pre_results:
@@ -1231,6 +1249,14 @@ def run_conversation(
has_hook,
invoke_hook as _invoke_hook,
)
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
if has_hook("pre_api_request"):
request_messages = api_kwargs.get("messages")
if not isinstance(request_messages, list):
@@ -1278,6 +1304,7 @@ def run_conversation(
max_tokens=agent.max_tokens,
started_at=api_start_time,
request=_request_payload,
+ agent_id=_agent_id,
)
except Exception:
pass
@@ -3571,6 +3598,14 @@ def _perform_api_call(next_api_kwargs):
has_hook,
invoke_hook as _invoke_hook,
)
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
if has_hook("post_api_request"):
_assistant_tool_calls = (
getattr(assistant_message, "tool_calls", None) or []
@@ -3604,6 +3639,7 @@ def _perform_api_call(next_api_kwargs):
assistant_message=assistant_message,
assistant_content_chars=len(_assistant_text),
assistant_tool_call_count=len(_assistant_tool_calls),
+ agent_id=_agent_id,
)
except Exception:
pass
@@ -4673,12 +4709,21 @@ def _perform_api_call(next_api_kwargs):
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
_transform_results = _invoke_hook(
"transform_llm_output",
response_text=final_response,
session_id=agent.session_id or "",
model=agent.model,
platform=getattr(agent, "platform", None) or "",
+ agent_id=_agent_id,
)
for _hook_result in _transform_results:
if isinstance(_hook_result, str) and _hook_result:
@@ -4695,6 +4740,14 @@ def _perform_api_call(next_api_kwargs):
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
_invoke_hook(
"post_llm_call",
session_id=agent.session_id,
@@ -4705,6 +4758,7 @@ def _perform_api_call(next_api_kwargs):
conversation_history=list(messages),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
+ agent_id=_agent_id,
)
except Exception as exc:
logger.warning("post_llm_call hook failed: %s", exc)
@@ -4816,6 +4870,14 @@ def _perform_api_call(next_api_kwargs):
# Plugins can use this for cleanup, flushing buffers, etc.
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
_invoke_hook(
"on_session_end",
session_id=agent.session_id,
@@ -4825,6 +4887,7 @@ def _perform_api_call(next_api_kwargs):
interrupted=interrupted,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
+ agent_id=_agent_id,
)
except Exception as exc:
logger.warning("on_session_end hook failed: %s", exc)
diff --git a/agent/profile.py b/agent/profile.py
new file mode 100644
index 0000000000000..0fb90df49efec
--- /dev/null
+++ b/agent/profile.py
@@ -0,0 +1,193 @@
+"""Agent profile + ContextVar plumbing for single-gateway-multi-agent.
+
+An ``AgentProfile`` bundles every piece of per-agent state that used to be
+process-scoped via ``HERMES_HOME``:
+
+* home directory (governs SOUL.md, memory dir, skills dir, sessions.json)
+* model / provider / api_key_env
+* enabled / disabled toolsets
+* free-form config overrides
+
+The active profile is propagated through async code via a ``ContextVar``.
+Path getters (``get_hermes_home``, ``get_skills_dir``, ``get_memory_dir``,
+SOUL.md reader, etc.) honor the ContextVar **first**, falling back to the
+``HERMES_HOME`` env var when no profile is set — so single-profile installs
+see zero behavior change.
+
+This module is import-safe: it has no module-level side effects and depends
+only on stdlib + ``hermes_constants``.
+"""
+
+from __future__ import annotations
+
+import logging
+from contextlib import contextmanager
+from contextvars import ContextVar
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Dict, Iterator, List, Optional
+
+from hermes_constants import get_hermes_home
+
+logger = logging.getLogger(__name__)
+
+
+DEFAULT_AGENT_ID = "main"
+
+
+@dataclass
+class AgentProfile:
+ """A named agent with its own filesystem root, model, and toolset config.
+
+ ``id`` is the routing key (matches ``SessionSource.agent_id``).
+ ``home_dir`` is the root that replaces ``HERMES_HOME`` when this profile
+ is active in the current ContextVar. When ``home_dir`` is None the
+ profile inherits the process-wide ``HERMES_HOME``, which is the right
+ default for the legacy "main" agent.
+ """
+
+ id: str = DEFAULT_AGENT_ID
+ home_dir: Optional[Path] = None
+ model: Optional[str] = None
+ provider: Optional[str] = None
+ base_url: Optional[str] = None
+ api_key_env: Optional[str] = None
+ enabled_toolsets: Optional[List[str]] = None
+ disabled_toolsets: Optional[List[str]] = None
+ config_overrides: Dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def resolved_home(self) -> Path:
+ """Return the effective home directory for this profile.
+
+ Falls back to the process-wide ``HERMES_HOME`` when ``home_dir``
+ is unset. This keeps the default profile a thin shell over the
+ existing env-driven layout.
+ """
+ if self.home_dir is not None:
+ return Path(self.home_dir).expanduser()
+ return get_hermes_home()
+
+ @property
+ def soul_md_path(self) -> Path:
+ return self.resolved_home / "SOUL.md"
+
+ @property
+ def memory_dir(self) -> Path:
+ return self.resolved_home / "memories"
+
+ @property
+ def skills_dir(self) -> Path:
+ return self.resolved_home / "skills"
+
+ @property
+ def sessions_path(self) -> Path:
+ return self.resolved_home / "sessions.json"
+
+
+_current_agent_profile: ContextVar[Optional[AgentProfile]] = ContextVar(
+ "hermes_current_agent_profile", default=None
+)
+
+
+def get_active_profile() -> Optional[AgentProfile]:
+ """Return the profile bound to the current async context, or None.
+
+ None means "no profile bound" — callers should fall back to
+ ``HERMES_HOME`` env-var behavior, which is exactly what the legacy
+ single-profile install expects.
+ """
+ return _current_agent_profile.get()
+
+
+def set_active_profile(profile: Optional[AgentProfile]):
+ """Bind *profile* to the current ContextVar; returns the reset token.
+
+ Prefer ``use_profile()`` (context manager) over this raw setter.
+ """
+ return _current_agent_profile.set(profile)
+
+
+@contextmanager
+def use_profile(profile: Optional[AgentProfile]) -> Iterator[Optional[AgentProfile]]:
+ """Bind *profile* for the duration of the ``with`` block.
+
+ Uses ContextVar.set/reset so the binding propagates through ``await``
+ and ``asyncio.gather``, but does **not** leak to sibling tasks unless
+ they are spawned with ``copy_context()`` from within the block.
+
+ Passing ``None`` is a no-op restoration: callers that hit a route
+ without a profile (legacy single-agent path) can simply skip the
+ ``with``.
+ """
+ if profile is None:
+ yield None
+ return
+ token = _current_agent_profile.set(profile)
+ try:
+ yield profile
+ finally:
+ _current_agent_profile.reset(token)
+
+
+def load_agent_registry(config: Any) -> Dict[str, AgentProfile]:
+ """Build an ``id -> AgentProfile`` registry from a ``GatewayConfig``.
+
+ Reads ``config.agents`` (a dict of id -> kwargs) and constructs an
+ ``AgentProfile`` for each. Always returns at least the default
+ profile under ``DEFAULT_AGENT_ID`` so single-agent installs work
+ without any config changes.
+
+ Unknown kwargs in the agent dicts are forwarded to ``config_overrides``
+ so future per-agent settings don't require a registry update.
+ """
+ registry: Dict[str, AgentProfile] = {}
+
+ raw_agents: Dict[str, Any] = {}
+ if config is not None:
+ raw_agents = getattr(config, "agents", None) or {}
+
+ for agent_id, raw in (raw_agents or {}).items():
+ if not isinstance(raw, dict):
+ logger.warning(
+ "agents.%s ignored: expected dict, got %s",
+ agent_id, type(raw).__name__,
+ )
+ continue
+ profile = _build_profile(agent_id, raw)
+ registry[agent_id] = profile
+
+ if DEFAULT_AGENT_ID not in registry:
+ registry[DEFAULT_AGENT_ID] = AgentProfile(id=DEFAULT_AGENT_ID)
+
+ return registry
+
+
+def _build_profile(agent_id: str, raw: Dict[str, Any]) -> AgentProfile:
+ """Construct an AgentProfile from a raw config dict.
+
+ Known keys are extracted; everything else lands in ``config_overrides``
+ so downstream code can pick up custom keys without touching this file.
+ """
+ raw = dict(raw) # Copy so pops don't mutate the caller's dict.
+ home_dir_raw = raw.pop("home_dir", None)
+ home_dir = Path(home_dir_raw).expanduser() if home_dir_raw else None
+
+ model = raw.pop("model", None)
+ provider = raw.pop("provider", None)
+ base_url = raw.pop("base_url", None)
+ api_key_env = raw.pop("api_key_env", None)
+ enabled_toolsets = raw.pop("enabled_toolsets", None)
+ disabled_toolsets = raw.pop("disabled_toolsets", None)
+
+ return AgentProfile(
+ id=agent_id,
+ home_dir=home_dir,
+ model=model,
+ provider=provider,
+ base_url=base_url,
+ api_key_env=api_key_env,
+ enabled_toolsets=enabled_toolsets,
+ disabled_toolsets=disabled_toolsets,
+ config_overrides=raw,
+ )
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index fb6912642ae93..c40b7e24cffa3 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -1076,6 +1076,61 @@ display:
# provider: custom
# base_url: "https://ollama.com/v1"
+# =============================================================================
+# Multi-Agent Routing (Single Gateway, Multiple Personalities)
+# =============================================================================
+# Run multiple isolated agents from a single gateway process. Each agent has
+# its own model, system prompt (SOUL.md), memory, skills, and sessions.
+# Messages are routed by matching metadata (platform, chat, thread, user).
+#
+# Quick start:
+# hermes agent add coder --model anthropic/claude-opus-4-6
+# hermes agent add research --model anthropic/claude-sonnet-4-6
+# # Then edit this file to add routes below
+#
+# The CLI `hermes agent` subcommand manages agents and warns about orphaned
+# routes when removing an agent. See `hermes agent --help` for details.
+# =============================================================================
+
+# default_agent: main # Fallback when no route matches (default: "main")
+
+# agents:
+# main:
+# # Inherits gateway defaults — no overrides needed
+# coder:
+# model: "anthropic/claude-opus-4-6"
+# provider: "anthropic"
+# # home_dir: "~/.hermes/profiles/coder" # Optional: isolate memory/skills/SOUL.md
+# enabled_toolsets: [filesystem, terminal, web, skills]
+# research:
+# model: "anthropic/claude-sonnet-4-6"
+# # home_dir: "~/.hermes/profiles/research"
+# enabled_toolsets: [web, browser, vision, skills]
+# creative:
+# model: "openai/gpt-5"
+# provider: "openrouter"
+
+# Routes are evaluated in declaration order — first match wins.
+# Supported match keys: platform, chat_id, thread_id, user_id, user_id_alt,
+# guild_id, parent_chat_id. All comparisons are exact string equality.
+#
+# routes:
+# # Route a specific Telegram forum topic to the coder agent
+# - match: { platform: telegram, chat_id: "-1001234567890", thread_id: "42" }
+# agent: coder
+# # Route the rest of that group to research
+# - match: { platform: telegram, chat_id: "-1001234567890" }
+# agent: research
+# # Route a specific Slack workspace to coder
+# - match: { platform: slack, guild_id: "T0ABC123" }
+# agent: coder
+# # Route a specific Discord channel to creative
+# - match: { platform: discord, chat_id: "123456789012345678" }
+# agent: creative
+# # Route a specific user everywhere to research
+# - match: { platform: telegram, user_id: "123456789" }
+# agent: research
+
# =============================================================================
# Privacy
# =============================================================================
diff --git a/cli.py b/cli.py
index 598e6d3b583c3..12427739306e1 100644
--- a/cli.py
+++ b/cli.py
@@ -969,10 +969,19 @@ def _run_cleanup():
# session boundary — NOT per-turn inside run_conversation().
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
_invoke_hook(
"on_session_finalize",
session_id=_active_agent_ref.session_id if _active_agent_ref else None,
platform="cli",
+ agent_id=_agent_id,
reason="shutdown",
)
except Exception:
@@ -6566,11 +6575,20 @@ def _notify_session_boundary(self, event_type: str) -> None:
lifecycle point (shutdown, /new, /reset).
"""
try:
- from hermes_cli.plugins import invoke_hook as _invoke_hook
- _invoke_hook(
+ import hermes_cli.plugins as _plugins
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
+ _plugins.invoke_hook(
event_type,
session_id=self.agent.session_id if self.agent else None,
platform=getattr(self, "platform", None) or "cli",
+ agent_id=_agent_id,
reason="new_session" if event_type == "on_session_reset" else "session_boundary",
)
except Exception:
@@ -15319,6 +15337,14 @@ def new_event_loop(self):
if self.agent and getattr(self, '_agent_running', False):
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
_invoke_hook(
"on_session_end",
session_id=self.agent.session_id,
@@ -15326,6 +15352,7 @@ def new_event_loop(self):
interrupted=True,
model=getattr(self.agent, 'model', None),
platform=getattr(self.agent, 'platform', None) or "cli",
+ agent_id=_agent_id,
reason="shutdown",
)
except Exception:
diff --git a/cron/__init__.py b/cron/__init__.py
index 2c44cabf6b81f..e3c05ca9c5bc9 100644
--- a/cron/__init__.py
+++ b/cron/__init__.py
@@ -24,13 +24,13 @@
pause_job,
resume_job,
trigger_job,
- JOBS_FILE,
+ _get_jobs_file,
)
from cron.scheduler import tick
__all__ = [
"create_job",
- "get_job",
+ "get_job",
"list_jobs",
"remove_job",
"update_job",
@@ -38,5 +38,5 @@
"resume_job",
"trigger_job",
"tick",
- "JOBS_FILE",
+ "_get_jobs_file",
]
diff --git a/cron/jobs.py b/cron/jobs.py
index 866dacc41dfc1..3d179bd705d2e 100644
--- a/cron/jobs.py
+++ b/cron/jobs.py
@@ -34,17 +34,19 @@
# Configuration
# =============================================================================
-HERMES_DIR = get_hermes_home().resolve()
-CRON_DIR = HERMES_DIR / "cron"
-JOBS_FILE = CRON_DIR / "jobs.json"
-
# In-process lock protecting load_jobs→modify→save_jobs cycles.
# Required when tick() runs jobs in parallel threads — without this,
# concurrent mark_job_run / advance_next_run calls can clobber each other.
_jobs_file_lock = threading.Lock()
-OUTPUT_DIR = CRON_DIR / "output"
ONESHOT_GRACE_SECONDS = 120
+# Backwards-compatible module-level paths.
+# Tests monkeypatch these; production code should use _get_cron_dir().
+HERMES_DIR = get_hermes_home().resolve()
+CRON_DIR = HERMES_DIR / "cron"
+JOBS_FILE = CRON_DIR / "jobs.json"
+OUTPUT_DIR = CRON_DIR / "output"
+
# Fields on a cron job that must never change after creation. ``id`` is used
# as a filesystem path component under ``OUTPUT_DIR``; allowing it to be
# updated lets an unsafe value (``../escape``, absolute path, nested) leak
@@ -52,6 +54,31 @@
_IMMUTABLE_JOB_FIELDS = frozenset({"id"})
+def _get_cron_dir() -> Path:
+ """Resolve cron directory dynamically via get_hermes_home so per-agent
+ ContextVar overrides are honoured.
+
+ Falls back to the module-level CRON_DIR constant when it has been
+ monkey-patched by tests (detected by mismatch with the default).
+ """
+ try:
+ if CRON_DIR != get_hermes_home() / "cron":
+ return CRON_DIR
+ except Exception:
+ pass
+ return get_hermes_home() / "cron"
+
+
+def _get_jobs_file() -> Path:
+ """Resolve jobs.json path dynamically."""
+ return _get_cron_dir() / "jobs.json"
+
+
+def _get_output_dir() -> Path:
+ """Resolve cron output directory dynamically."""
+ return _get_cron_dir() / "output"
+
+
def _job_output_dir(job_id: str) -> Path:
"""Resolve a job's output directory, rejecting any path-escape attempt.
@@ -65,7 +92,7 @@ def _job_output_dir(job_id: str) -> Path:
raise ValueError(f"Invalid cron job id for output path: {job_id!r}")
if Path(text).is_absolute() or Path(text).drive:
raise ValueError(f"Invalid cron job id for output path: {job_id!r}")
- return OUTPUT_DIR / text
+ return _get_output_dir() / text
def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
@@ -175,10 +202,12 @@ def _secure_file(path: Path):
def ensure_dirs():
"""Ensure cron directories exist with secure permissions."""
- CRON_DIR.mkdir(parents=True, exist_ok=True)
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- _secure_dir(CRON_DIR)
- _secure_dir(OUTPUT_DIR)
+ cron_dir = _get_cron_dir()
+ output_dir = _get_output_dir()
+ cron_dir.mkdir(parents=True, exist_ok=True)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ _secure_dir(cron_dir)
+ _secure_dir(output_dir)
# =============================================================================
@@ -426,19 +455,20 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None
def load_jobs() -> List[Dict[str, Any]]:
"""Load all jobs from storage."""
ensure_dirs()
- if not JOBS_FILE.exists():
+ jobs_file = _get_jobs_file()
+ if not jobs_file.exists():
return []
_strict_retry = False # track whether we used the strict=False fallback
try:
- with open(JOBS_FILE, 'r', encoding='utf-8') as f:
+ with open(jobs_file, 'r', encoding='utf-8') as f:
data = json.load(f)
except json.JSONDecodeError:
# Retry with strict=False to handle bare control chars in string values
_strict_retry = True
try:
- with open(JOBS_FILE, 'r', encoding='utf-8') as f:
+ with open(jobs_file, 'r', encoding='utf-8') as f:
data = json.loads(f.read(), strict=False)
except Exception as e:
logger.error("Failed to auto-repair jobs.json: %s", e)
@@ -474,14 +504,15 @@ def load_jobs() -> List[Dict[str, Any]]:
def save_jobs(jobs: List[Dict[str, Any]]):
"""Save all jobs to storage."""
ensure_dirs()
- fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_')
+ jobs_file = _get_jobs_file()
+ fd, tmp_path = tempfile.mkstemp(dir=str(jobs_file.parent), suffix='.tmp', prefix='.jobs_')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2)
f.flush()
os.fsync(f.fileno())
- atomic_replace(tmp_path, JOBS_FILE)
- _secure_file(JOBS_FILE)
+ atomic_replace(tmp_path, jobs_file)
+ _secure_file(jobs_file)
except BaseException:
try:
os.unlink(tmp_path)
@@ -669,6 +700,7 @@ def create_job(
prompt_text = _coerce_job_text(prompt)
label_source = (prompt_text or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job"
+
job = {
"id": job_id,
"name": name or label_source[:50].strip(),
@@ -901,6 +933,7 @@ def remove_job(job_id: str) -> bool:
job_output_dir = _job_output_dir(canonical_id)
save_jobs(jobs)
# Clean up output directory to prevent orphaned dirs accumulating
+ job_output_dir = _job_output_dir(canonical_id)
if job_output_dir.exists():
shutil.rmtree(job_output_dir)
return True
@@ -1139,6 +1172,74 @@ def save_job_output(job_id: str, output: str):
return output_file
+# =============================================================================
+# Multi-agent job loading (gateway-wide tick)
+# =============================================================================
+
+def load_all_jobs(registry: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
+ """Load jobs from ALL agent profiles for a gateway-wide tick.
+
+ When the gateway runs multiple agents, each profile has its own
+ cron/jobs.json. This function visits every profile directory,
+ temporarily switches the ContextVar, loads that profile's jobs,
+ stamps each with its agent_id, and returns the combined list.
+
+ Args:
+ registry: Optional agent registry (Dict[str, AgentProfile]).
+ If omitted, falls back to loading from the current
+ profile only (backward-compatible single-agent mode).
+
+ Returns:
+ Combined list of job dicts, each with an ``agent_id`` key.
+ """
+ if registry is None:
+ # Backward-compatible single-profile path
+ jobs = load_jobs()
+ for j in jobs:
+ if "agent_id" not in j:
+ j["agent_id"] = "main"
+ return jobs
+
+ all_jobs: List[Dict[str, Any]] = []
+ for agent_id, profile in registry.items():
+ try:
+ from agent.profile import use_profile
+ with use_profile(profile):
+ jobs = load_jobs()
+ for j in jobs:
+ if "agent_id" not in j:
+ j["agent_id"] = agent_id
+ all_jobs.extend(jobs)
+ except Exception as e:
+ logger.warning("Failed to load cron jobs for agent '%s': %s", agent_id, e)
+ return all_jobs
+
+
+def get_all_due_jobs(registry: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Get due jobs from ALL agent profiles.
+
+ Iterates every profile, switches the ContextVar, and calls
+ ``get_due_jobs()`` (which handles grace windows, fast-forward,
+ and file-locking) inside that profile's context. Returns the
+ combined list of due jobs with ``agent_id`` already stamped.
+
+ This is the multi-agent equivalent of ``get_due_jobs()``.
+ """
+ all_due: List[Dict[str, Any]] = []
+ for agent_id, profile in registry.items():
+ try:
+ from agent.profile import use_profile
+ with use_profile(profile):
+ due = get_due_jobs()
+ for j in due:
+ if "agent_id" not in j:
+ j["agent_id"] = agent_id
+ all_due.extend(due)
+ except Exception as e:
+ logger.warning("Failed to get due jobs for agent '%s': %s", agent_id, e)
+ return all_due
+
+
# =============================================================================
# Skill reference rewriting (curator integration)
# =============================================================================
diff --git a/cron/scheduler.py b/cron/scheduler.py
index 91671b46e5ba7..78e155849bc18 100644
--- a/cron/scheduler.py
+++ b/cron/scheduler.py
@@ -391,12 +391,15 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
if deliver_value == "local":
return None
+ _agent_id = job.get("agent_id")
+
if deliver_value == "origin":
if origin:
return {
"platform": origin["platform"],
"chat_id": str(origin["chat_id"]),
"thread_id": origin.get("thread_id"),
+ "agent_id": _agent_id or origin.get("agent_id"),
}
# Origin missing (e.g. job created via API/script) — try each
# platform's home channel as a fallback instead of silently dropping.
@@ -412,6 +415,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
"platform": platform_name,
"chat_id": chat_id,
"thread_id": _get_home_target_thread_id(platform_name),
+ "agent_id": _agent_id,
}
return None
@@ -446,6 +450,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
"platform": platform_name,
"chat_id": chat_id,
"thread_id": thread_id,
+ "agent_id": _agent_id,
}
platform_name = deliver_value
@@ -454,6 +459,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
"platform": platform_name,
"chat_id": str(origin["chat_id"]),
"thread_id": origin.get("thread_id"),
+ "agent_id": _agent_id,
}
if not _is_known_delivery_platform(platform_name):
@@ -466,6 +472,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
"platform": platform_name,
"chat_id": chat_id,
"thread_id": _get_home_target_thread_id(platform_name),
+ "agent_id": _agent_id,
}
@@ -1045,7 +1052,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
# Inject output from referenced cron jobs as context.
context_from = job.get("context_from")
if context_from:
- from cron.jobs import OUTPUT_DIR
+ from cron.jobs import _get_output_dir
if isinstance(context_from, str):
context_from = [context_from]
for source_job_id in context_from:
@@ -1060,7 +1067,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
)
continue
try:
- job_output_dir = OUTPUT_DIR / source_job_id
+ job_output_dir = _get_output_dir() / source_job_id
if not job_output_dir.exists():
continue # silent skip — no output yet
output_files = sorted(
@@ -1888,18 +1895,21 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
logger.debug("Job '%s': failed to reap stale auxiliary clients: %s", job_id, e)
-def tick(verbose: bool = True, adapters=None, loop=None) -> int:
+def tick(verbose: bool = True, adapters=None, loop=None, registry=None) -> int:
"""
Check and run all due jobs.
-
+
Uses a file lock so only one tick runs at a time, even if the gateway's
in-process ticker and a standalone daemon or manual tick overlap.
-
+
Args:
verbose: Whether to print status messages
adapters: Optional dict mapping Platform → live adapter (from gateway)
loop: Optional asyncio event loop (from gateway) for live adapter sends
-
+ registry: Optional agent registry (Dict[str, AgentProfile]) for multi-
+ agent gateway mode. When provided, jobs are loaded from ALL
+ agent profiles and each job runs under its profile's context.
+
Returns:
Number of jobs executed (0 if another tick is already running)
"""
@@ -1921,19 +1931,31 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
return 0
try:
- due_jobs = get_due_jobs()
+ if registry is not None:
+ from cron.jobs import get_all_due_jobs
+ all_jobs = get_all_due_jobs(registry)
+ else:
+ all_jobs = get_due_jobs()
- if verbose and not due_jobs:
+ if verbose and not all_jobs:
logger.info("%s - No jobs due", _hermes_now().strftime('%H:%M:%S'))
return 0
if verbose:
- logger.info("%s - %s job(s) due", _hermes_now().strftime('%H:%M:%S'), len(due_jobs))
+ logger.info("%s - %s job(s) due", _hermes_now().strftime('%H:%M:%S'), len(all_jobs))
# Advance next_run_at for all recurring jobs FIRST, under the file lock,
# before any execution begins. This preserves at-most-once semantics.
- for job in due_jobs:
- advance_next_run(job["id"])
+ # For multi-agent mode, advance_next_run must run in the job's profile
+ # context so it writes back to the correct jobs.json.
+ for job in all_jobs:
+ _job_agent_id = job.get("agent_id", "main")
+ if registry and _job_agent_id in registry:
+ from agent.profile import use_profile
+ with use_profile(registry[_job_agent_id]):
+ advance_next_run(job["id"])
+ else:
+ advance_next_run(job["id"])
# Resolve max parallel workers: env var > config.yaml > unbounded.
# Set HERMES_CRON_MAX_PARALLEL=1 to restore old serial behaviour.
@@ -1958,16 +1980,29 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
if verbose:
logger.info(
"Running %d job(s) in parallel (max_workers=%s)",
- len(due_jobs),
+ len(all_jobs),
_max_workers if _max_workers else "unbounded",
)
def _process_job(job: dict) -> bool:
"""Run one due job end-to-end: execute, save, deliver, mark."""
+ _job_agent_id = job.get("agent_id", "main")
+ _profile = registry.get(_job_agent_id) if registry else None
try:
- success, output, final_response, error = run_job(job)
+ if _profile is not None:
+ from agent.profile import use_profile
+ with use_profile(_profile):
+ success, output, final_response, error = run_job(job)
+ else:
+ success, output, final_response, error = run_job(job)
- output_file = save_job_output(job["id"], output)
+ # Save output under the job's profile directory
+ if _profile is not None:
+ from agent.profile import use_profile
+ with use_profile(_profile):
+ output_file = save_job_output(job["id"], output)
+ else:
+ output_file = save_job_output(job["id"], output)
if verbose:
logger.info("Output saved to: %s", output_file)
@@ -1986,7 +2021,12 @@ def _process_job(job: dict) -> bool:
delivery_error = None
if should_deliver:
try:
- delivery_error = _deliver_result(job, deliver_content, adapters=adapters, loop=loop)
+ if _profile is not None:
+ from agent.profile import use_profile
+ with use_profile(_profile):
+ delivery_error = _deliver_result(job, deliver_content, adapters=adapters, loop=loop)
+ else:
+ delivery_error = _deliver_result(job, deliver_content, adapters=adapters, loop=loop)
except Exception as de:
delivery_error = str(de)
logger.error("Delivery failed for job %s: %s", job["id"], de)
@@ -1998,12 +2038,26 @@ def _process_job(job: dict) -> bool:
success = False
error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
- mark_job_run(job["id"], success, error, delivery_error=delivery_error)
+ # mark_job_run must write back to the correct profile's jobs.json
+ if _profile is not None:
+ from agent.profile import use_profile
+ with use_profile(_profile):
+ mark_job_run(job["id"], success, error, delivery_error=delivery_error)
+ else:
+ mark_job_run(job["id"], success, error, delivery_error=delivery_error)
return True
except Exception as e:
logger.error("Error processing job %s: %s", job['id'], e)
- mark_job_run(job["id"], False, str(e))
+ try:
+ if _profile is not None:
+ from agent.profile import use_profile
+ with use_profile(_profile):
+ mark_job_run(job["id"], False, str(e))
+ else:
+ mark_job_run(job["id"], False, str(e))
+ except Exception:
+ pass
return False
# Partition due jobs: jobs with a per-job workdir and/or profile touch
@@ -2014,11 +2068,11 @@ def _process_job(job: dict) -> bool:
# sequentially to avoid corrupting each other. Jobs without either field
# stay parallel-safe.
sequential_jobs = [
- j for j in due_jobs
+ j for j in all_jobs
if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip()
]
parallel_jobs = [
- j for j in due_jobs
+ j for j in all_jobs
if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip())
]
diff --git a/gateway/agent_routing.py b/gateway/agent_routing.py
new file mode 100644
index 0000000000000..a8f2c9e8d6daa
--- /dev/null
+++ b/gateway/agent_routing.py
@@ -0,0 +1,93 @@
+"""Inbound-message -> agent_id resolver.
+
+The gateway calls ``resolve_agent_id(source, routes, default)`` once per
+inbound message before ``build_session_key``. It walks the declarative
+routes list (first match wins) and returns the matched ``agent`` value,
+or ``default`` if nothing matches.
+
+A separate plugin hook (``select_agent``) can override the route result;
+that wiring lives in the adapter layer (see
+``BasePlatformAdapter._attach_agent_id``).
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from .session import SessionSource
+
+logger = logging.getLogger(__name__)
+
+
+# Keys we know how to match against on the source. Adding a new key is
+# safe — it simply gets ignored on sources that lack the attribute.
+_SUPPORTED_KEYS = (
+ "platform",
+ "chat_id",
+ "thread_id",
+ "user_id",
+ "user_id_alt",
+ "chat_type",
+ "guild_id",
+ "parent_chat_id",
+ "topic_id",
+)
+
+
+def _source_value(source: SessionSource, key: str) -> Optional[str]:
+ """Read *key* from *source*, normalised to a string for comparison."""
+ if key == "platform":
+ return source.platform.value if source.platform is not None else None
+ val = getattr(source, key, None)
+ if val is None:
+ return None
+ return str(val)
+
+
+def _route_matches(match: Dict[str, Any], source: SessionSource) -> bool:
+ """Return True iff every key in *match* equals the corresponding
+ attribute on *source*. Unknown match keys cause the route to be
+ skipped (logged at DEBUG) — silently ignoring would let typos bind
+ every message."""
+ if not match:
+ return False # An empty match block matches nothing — guard rail.
+ for key, expected in match.items():
+ if key not in _SUPPORTED_KEYS:
+ logger.debug(
+ "agent_routing: ignoring route with unsupported match key %r", key,
+ )
+ return False
+ actual = _source_value(source, key)
+ if actual is None:
+ return False
+ if str(expected) != actual:
+ return False
+ return True
+
+
+def resolve_agent_id(
+ source: SessionSource,
+ routes: List[Dict[str, Any]],
+ default: Optional[str] = None,
+) -> Optional[str]:
+ """Walk *routes* in declared order; first match wins.
+
+ Each route is a dict ``{"match": {...}, "agent": ""}``. Returns
+ the matched agent id, or *default* if no route matched. Returns
+ ``None`` when nothing matched and *default* is ``None`` — the caller
+ (adapter) can then fall back to a separate plugin hook or its own
+ default.
+ """
+ for route in routes or []:
+ if not isinstance(route, dict):
+ continue
+ match = route.get("match")
+ agent = route.get("agent")
+ if not isinstance(agent, str) or not agent.strip():
+ continue
+ if not isinstance(match, dict):
+ continue
+ if _route_matches(match, source):
+ return agent.strip()
+ return default
diff --git a/gateway/config.py b/gateway/config.py
index a1b61fed5628c..c6ede29690911 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -509,6 +509,13 @@ class GatewayConfig:
# fresh session exactly as if the reset policy had fired. 0 = disabled.
session_store_max_age_days: int = 90
+ # ── Multi-agent (single-gateway-multi-agent) ─────────────────────────
+ # Empty defaults preserve legacy single-agent behavior: the runtime
+ # always synthesizes a {"main": AgentProfile()} registry on top of this.
+ agents: Dict[str, Any] = field(default_factory=dict)
+ routes: List[Dict[str, Any]] = field(default_factory=list)
+ default_agent: str = "main"
+
def get_connected_platforms(self) -> List[Platform]:
"""Return list of platforms that are enabled and configured."""
connected = []
@@ -603,6 +610,9 @@ def to_dict(self) -> Dict[str, Any]:
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
"session_store_max_age_days": self.session_store_max_age_days,
+ "agents": self.agents,
+ "routes": self.routes,
+ "default_agent": self.default_agent,
}
@classmethod
@@ -656,6 +666,21 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
except (TypeError, ValueError):
session_store_max_age_days = 90
+ # Multi-agent config (optional; empty defaults preserve legacy
+ # single-agent behavior).
+ agents = data.get("agents") or {}
+ if not isinstance(agents, dict):
+ agents = {}
+ routes_raw = data.get("routes") or []
+ routes: List[Dict[str, Any]] = []
+ if isinstance(routes_raw, list):
+ for r in routes_raw:
+ if isinstance(r, dict):
+ routes.append(r)
+ default_agent = data.get("default_agent") or "main"
+ if not isinstance(default_agent, str) or not default_agent.strip():
+ default_agent = "main"
+
return cls(
platforms=platforms,
default_reset_policy=default_policy,
@@ -674,6 +699,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
+ agents=agents,
+ routes=routes,
+ default_agent=default_agent.strip(),
)
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
@@ -780,6 +808,15 @@ def load_gateway_config() -> GatewayConfig:
"filter_silence_narration"
]
+ if "agents" in yaml_cfg:
+ gw_data["agents"] = yaml_cfg["agents"]
+
+ if "routes" in yaml_cfg:
+ gw_data["routes"] = yaml_cfg["routes"]
+
+ if "default_agent" in yaml_cfg:
+ gw_data["default_agent"] = yaml_cfg["default_agent"]
+
if "unauthorized_dm_behavior" in yaml_cfg:
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
yaml_cfg.get("unauthorized_dm_behavior"),
diff --git a/gateway/delivery.py b/gateway/delivery.py
index 8afab431c3687..67223bcba5a5d 100644
--- a/gateway/delivery.py
+++ b/gateway/delivery.py
@@ -95,7 +95,7 @@ def _is_thread_not_found_delivery_error(result: Any) -> bool:
class DeliveryTarget:
"""
A single delivery target.
-
+
Represents where a message should be sent:
- "origin" → back to source
- "local" → save to local files
@@ -107,6 +107,7 @@ class DeliveryTarget:
thread_id: Optional[str] = None
is_origin: bool = False
is_explicit: bool = False # True if chat_id was explicitly specified
+ agent_id: Optional[str] = None # Agent profile for delivery context
@classmethod
def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "DeliveryTarget":
@@ -129,6 +130,7 @@ def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "Delivery
chat_id=origin.chat_id,
thread_id=origin.thread_id,
is_origin=True,
+ agent_id=origin.agent_id,
)
else:
# Fallback to local if no origin
@@ -175,22 +177,24 @@ def to_string(self) -> str:
class DeliveryRouter:
"""
Routes messages to appropriate destinations.
-
+
Handles the logic of resolving delivery targets and dispatching
messages to the right platform adapters.
"""
-
- def __init__(self, config: GatewayConfig, adapters: Dict[Platform, Any] = None):
+
+ def __init__(self, config: GatewayConfig, adapters: Dict[Platform, Any] = None, registry=None):
"""
Initialize the delivery router.
-
+
Args:
config: Gateway configuration
adapters: Dict mapping platforms to their adapter instances
+ registry: Optional Dict[str, AgentProfile] for multi-agent context.
"""
self.config = config
self.adapters = adapters or {}
self.output_dir = get_hermes_home() / "cron" / "output"
+ self._registry = registry or {}
async def deliver(
self,
@@ -217,11 +221,21 @@ async def deliver(
for target in targets:
try:
- if target.platform == Platform.LOCAL:
- result = self._deliver_local(content, job_id, job_name, metadata)
+ # Set the active profile for this delivery target so path getters
+ # and adapter context resolve to the correct agent's home dir.
+ _profile = self._registry.get(target.agent_id or "main")
+ if _profile is not None:
+ from agent.profile import use_profile
+ _ctx = use_profile(_profile)
else:
- result = await self._deliver_to_platform(target, content, metadata)
-
+ from contextlib import nullcontext
+ _ctx = nullcontext()
+ with _ctx:
+ if target.platform == Platform.LOCAL:
+ result = self._deliver_local(content, job_id, job_name, metadata)
+ else:
+ result = await self._deliver_to_platform(target, content, metadata)
+
results[target.to_string()] = {
"success": True,
"result": result
diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py
index 22bf199b3b069..6a5c301d2f8df 100644
--- a/gateway/platforms/api_server.py
+++ b/gateway/platforms/api_server.py
@@ -55,9 +55,12 @@
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
SendResult,
is_network_accessible,
)
+from gateway.session import SessionSource
logger = logging.getLogger(__name__)
@@ -931,6 +934,83 @@ def _parse_session_key_header(
return raw, None
+ # ------------------------------------------------------------------
+ # Multi-agent routing
+ # ------------------------------------------------------------------
+
+ # Routing identity headers. These are optional and fully backward
+ # compatible — when absent, every request resolves to ``default_agent``
+ # just like before. Header names mirror ``X-Hermes-Session-Id`` /
+ # ``X-Hermes-Session-Key`` already used by this adapter.
+ _AGENT_CHAT_ID_HEADER = "X-Hermes-Chat-Id"
+ _AGENT_USER_ID_HEADER = "X-Hermes-User-Id"
+ _AGENT_THREAD_ID_HEADER = "X-Hermes-Thread-Id"
+
+ def _read_routing_header(
+ self, request: "web.Request", name: str,
+ ) -> Optional[str]:
+ """Return a sanitised routing header value or ``None``.
+
+ Applies the same control-character and length caps as the session
+ headers so a malicious caller can't inject CRLF or burn memory by
+ passing a multi-kilobyte "chat id".
+ """
+ raw = request.headers.get(name, "").strip()
+ if not raw:
+ return None
+ if re.search(r'[\r\n\x00]', raw):
+ return None
+ if len(raw) > self._MAX_SESSION_HEADER_LEN:
+ return None
+ return raw
+
+ def _resolve_agent_profile(self, request: "web.Request"):
+ """Resolve the routed ``AgentProfile`` for *request*.
+
+ Builds a synthetic ``SessionSource`` from the ``X-Hermes-*``
+ routing headers, runs it through the shared ``_attach_agent_id``
+ hook (declarative routes + ``select_agent`` plugin), and looks up
+ the resulting ``agent_id`` in the gateway's registry.
+
+ Returns ``(profile, agent_id)``. When no profile is registered
+ (single-agent install) ``profile`` is ``None`` and callers should
+ simply skip the ``use_profile`` wrapper — that is exactly the
+ legacy ``HERMES_HOME`` path.
+ """
+ chat_id = self._read_routing_header(request, self._AGENT_CHAT_ID_HEADER)
+ user_id = self._read_routing_header(request, self._AGENT_USER_ID_HEADER)
+ thread_id = self._read_routing_header(request, self._AGENT_THREAD_ID_HEADER)
+
+ source = SessionSource(
+ platform=Platform.API_SERVER,
+ # chat_id is non-optional on SessionSource; fall back to the
+ # empty string when no header was supplied so the resolver can
+ # still apply ``platform: api_server`` style routes.
+ chat_id=chat_id or "",
+ chat_type="dm",
+ user_id=user_id,
+ thread_id=thread_id,
+ )
+ event = MessageEvent(text="", message_type=MessageType.TEXT, source=source)
+ try:
+ self._attach_agent_id(event)
+ except Exception as exc: # never break dispatch on a routing bug
+ logger.debug("[%s] route resolution failed: %s", self.name, exc)
+
+ agent_id = getattr(event.source, "agent_id", None) or (
+ self._default_agent_id or "main"
+ )
+
+ profile = None
+ registry = getattr(self._gateway_ref, "_agent_registry", None) if self._gateway_ref else None
+ if registry is not None:
+ profile = registry.get(agent_id)
+ logger.info(
+ "[%s] routed to agent: %s (chat_id=%r user_id=%r thread_id=%r)",
+ self.name, agent_id, chat_id, user_id, thread_id,
+ )
+ return profile, agent_id
+
# ------------------------------------------------------------------
# Session DB helper
# ------------------------------------------------------------------
@@ -1674,6 +1754,11 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons
if auth_err:
return auth_err
+ # Resolve the routed agent profile from X-Hermes-Chat-Id / -User-Id /
+ # -Thread-Id headers. Absent headers fall through to ``default_agent``,
+ # preserving backward compatibility for existing OpenAI-API callers.
+ agent_profile, _agent_id = self._resolve_agent_profile(request)
+
# Parse request body
try:
body = await request.json()
@@ -1868,6 +1953,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul
tool_complete_callback=_on_tool_complete,
agent_ref=agent_ref,
gateway_session_key=gateway_session_key,
+ agent_profile=agent_profile,
))
# Ensure SSE drain loops can terminate without relying on polling
# agent_task.done(), which can race with queue timeout checks.
@@ -1887,6 +1973,7 @@ async def _compute_completion():
ephemeral_system_prompt=system_prompt,
session_id=session_id,
gateway_session_key=gateway_session_key,
+ agent_profile=agent_profile,
)
idempotency_key = request.headers.get("Idempotency-Key")
@@ -2743,6 +2830,10 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response":
if auth_err:
return auth_err
+ # Resolve the routed agent profile from X-Hermes-Chat-Id headers
+ # (see _resolve_agent_profile). No header → default_agent.
+ agent_profile, _agent_id = self._resolve_agent_profile(request)
+
# Long-term memory scope header (see chat_completions for details).
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
@@ -2900,6 +2991,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul
tool_complete_callback=_on_tool_complete,
agent_ref=agent_ref,
gateway_session_key=gateway_session_key,
+ agent_profile=agent_profile,
))
# Ensure SSE drain loops can terminate without relying on polling
# agent_task.done(), which can race with queue timeout checks.
@@ -2933,6 +3025,7 @@ async def _compute_response():
ephemeral_system_prompt=instructions,
session_id=session_id,
gateway_session_key=gateway_session_key,
+ agent_profile=agent_profile,
)
idempotency_key = request.headers.get("Idempotency-Key")
@@ -3435,6 +3528,7 @@ async def _run_agent(
tool_complete_callback=None,
agent_ref: Optional[list] = None,
gateway_session_key: Optional[str] = None,
+ agent_profile: Optional[Any] = None,
) -> tuple:
"""
Create an agent and run a conversation in a thread executor.
@@ -3446,39 +3540,52 @@ async def _run_agent(
at ``agent_ref[0]`` before ``run_conversation`` begins. This allows
callers (e.g. the SSE writer) to call ``agent.interrupt()`` from
another thread to stop in-progress LLM calls.
+
+ When *agent_profile* is set, the AgentProfile ContextVar is bound
+ for the duration of the run so SOUL.md, memory, skills, and
+ toolset resolution all resolve to the per-agent home directory.
+ Binding happens inside the executor thread because asyncio's
+ default executor does not propagate ContextVars across the
+ thread boundary.
"""
loop = asyncio.get_running_loop()
def _run():
- agent = self._create_agent(
- ephemeral_system_prompt=ephemeral_system_prompt,
- session_id=session_id,
- stream_delta_callback=stream_delta_callback,
- tool_progress_callback=tool_progress_callback,
- tool_start_callback=tool_start_callback,
- tool_complete_callback=tool_complete_callback,
- gateway_session_key=gateway_session_key,
- )
- if agent_ref is not None:
- agent_ref[0] = agent
- effective_task_id = session_id or str(uuid.uuid4())
- result = agent.run_conversation(
- user_message=user_message,
- conversation_history=conversation_history,
- task_id=effective_task_id,
- )
- usage = {
- "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
- "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
- "total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
- }
- # Include the effective session ID in the result so callers
- # (e.g. X-Hermes-Session-Id header) can track compression-
- # triggered session rotations. (#16938)
- _eff_sid = getattr(agent, "session_id", session_id)
- if isinstance(_eff_sid, str) and _eff_sid:
- result["session_id"] = _eff_sid
- return result, usage
+ # Re-bind the active agent profile inside the executor thread
+ # so path getters (get_hermes_home, skills_dir, …) see the
+ # per-agent home. ``use_profile(None)`` is a no-op, so
+ # single-agent installs pay nothing.
+ from agent.profile import use_profile
+ with use_profile(agent_profile):
+ agent = self._create_agent(
+ ephemeral_system_prompt=ephemeral_system_prompt,
+ session_id=session_id,
+ stream_delta_callback=stream_delta_callback,
+ tool_progress_callback=tool_progress_callback,
+ tool_start_callback=tool_start_callback,
+ tool_complete_callback=tool_complete_callback,
+ gateway_session_key=gateway_session_key,
+ )
+ if agent_ref is not None:
+ agent_ref[0] = agent
+ effective_task_id = session_id or str(uuid.uuid4())
+ result = agent.run_conversation(
+ user_message=user_message,
+ conversation_history=conversation_history,
+ task_id=effective_task_id,
+ )
+ usage = {
+ "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
+ "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
+ "total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
+ }
+ # Include the effective session ID in the result so callers
+ # (e.g. X-Hermes-Session-Id header) can track compression-
+ # triggered session rotations. (#16938)
+ _eff_sid = getattr(agent, "session_id", session_id)
+ if isinstance(_eff_sid, str) and _eff_sid:
+ result["session_id"] = _eff_sid
+ return result, usage
return await loop.run_in_executor(None, _run)
@@ -3557,6 +3664,10 @@ async def _handle_runs(self, request: "web.Request") -> "web.Response":
if auth_err:
return auth_err
+ # Resolve the routed agent profile from X-Hermes-Chat-Id headers
+ # (see _resolve_agent_profile). No header → default_agent.
+ agent_profile, _agent_id = self._resolve_agent_profile(request)
+
# Long-term memory scope header (see chat_completions for details).
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
@@ -3665,16 +3776,26 @@ def _text_cb(delta: Optional[str]) -> None:
)
async def _run_and_close():
+ from agent.profile import use_profile
+
try:
self._set_run_status(run_id, "running")
- agent = self._create_agent(
- ephemeral_system_prompt=ephemeral_system_prompt,
- session_id=session_id,
- stream_delta_callback=_text_cb,
- tool_progress_callback=event_cb,
- gateway_session_key=gateway_session_key,
- )
- self._active_run_agents[run_id] = agent
+ # Bind the routed agent profile so per-agent home dir,
+ # SOUL.md, memory, skills, and toolset resolution all see
+ # the right per-agent paths during agent construction.
+ with use_profile(agent_profile):
+ agent = self._create_agent(
+ ephemeral_system_prompt=ephemeral_system_prompt,
+ session_id=session_id,
+ stream_delta_callback=_text_cb,
+ tool_progress_callback=event_cb,
+ gateway_session_key=gateway_session_key,
+ )
+ # Register the agent while the profile context is still active
+ # so any post-construction lazy property access on the asyncio
+ # thread sees the correct per-agent paths. The executor thread
+ # re-binds the profile independently in _run_sync below.
+ self._active_run_agents[run_id] = agent
def _approval_notify(approval_data: Dict[str, Any]) -> None:
event = dict(approval_data or {})
@@ -3695,6 +3816,7 @@ def _approval_notify(approval_data: Dict[str, Any]) -> None:
pass
def _run_sync():
+ from agent.profile import use_profile as _use_profile_thread
from gateway.session_context import clear_session_vars, set_session_vars
from tools.approval import (
register_gateway_notify,
@@ -3716,11 +3838,14 @@ def _run_sync():
session_key=approval_session_key,
)
register_gateway_notify(approval_session_key, _approval_notify)
- r = agent.run_conversation(
- user_message=user_message,
- conversation_history=conversation_history,
- task_id=effective_task_id,
- )
+ # Re-bind the agent profile inside the executor thread
+ # (asyncio's default executor does not copy ContextVars).
+ with _use_profile_thread(agent_profile):
+ r = agent.run_conversation(
+ user_message=user_message,
+ conversation_history=conversation_history,
+ task_id=effective_task_id,
+ )
finally:
try:
unregister_gateway_notify(approval_session_key)
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index 89806a739312f..553cff83da681 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -1819,6 +1819,12 @@ def __init__(self, config: PlatformConfig, platform: Platform):
self._post_delivery_callbacks: Dict[str, Any] = {}
self._expected_cancelled_tasks: set[asyncio.Task] = set()
self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None
+ # Multi-agent routing context. Populated by GatewayRunner via
+ # ``set_routing_context``; left empty in single-agent installs so
+ # every message resolves to the legacy "main" agent.
+ self._gateway_routes: List[Dict[str, Any]] = []
+ self._default_agent_id: str = "main"
+ self._gateway_ref: Optional[Any] = None
# Auto-TTS on voice input: ``_auto_tts_default`` is the global default
# (``voice.auto_tts`` in config.yaml, pushed by GatewayRunner on connect).
# Per-chat overrides live in two sets populated from ``_voice_mode``:
@@ -2177,12 +2183,92 @@ def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str
def set_session_store(self, session_store: Any) -> None:
"""
Set the session store for checking active sessions.
-
+
Used by adapters that need to check if a thread/conversation
has an active session before processing messages (e.g., Slack
thread replies without explicit mentions).
"""
self._session_store = session_store
+
+ def set_routing_context(
+ self,
+ routes: Optional[List[Dict[str, Any]]],
+ default_agent: str = "main",
+ gateway: Optional[Any] = None,
+ ) -> None:
+ """Inject the multi-agent routing table.
+
+ Called once by GatewayRunner during adapter wire-up. Single-agent
+ installs leave *routes* empty, so every inbound message resolves
+ to ``default_agent`` (which itself defaults to ``"main"``).
+
+ Passing the gateway reference enables the ``select_agent`` plugin
+ hook to access shared state when overriding the route result.
+ """
+ self._gateway_routes = list(routes or [])
+ self._default_agent_id = (default_agent or "main").strip() or "main"
+ self._gateway_ref = gateway
+
+ def _attach_agent_id(self, event: "MessageEvent") -> None:
+ """Resolve and stamp ``event.source.agent_id`` for downstream dispatch.
+
+ Resolution order: declarative routes → ``select_agent`` plugin
+ hook → ``default_agent_id`` → "main". Idempotent: if a route or
+ plugin upstream already set ``agent_id`` it is left untouched.
+
+ Imported lazily so single-agent installs that never call
+ ``set_routing_context`` avoid loading the resolver / plugin
+ machinery on every message.
+ """
+ try:
+ source = event.source
+ except AttributeError:
+ return
+ if source is None:
+ return
+ if getattr(source, "agent_id", None):
+ return # Already resolved upstream.
+
+ route_match: Optional[str] = None
+ try:
+ from gateway.agent_routing import resolve_agent_id # lazy
+ route_match = resolve_agent_id(
+ source, getattr(self, "_gateway_routes", []) or [], default=None,
+ )
+ except Exception as exc: # never break dispatch on a routing bug
+ logger.debug("[%s] route resolution failed: %s", self.name, exc)
+
+ hook_pick: Optional[str] = None
+ try:
+ from hermes_cli.plugins import invoke_hook # lazy
+ results = invoke_hook(
+ "select_agent",
+ event=event,
+ gateway=getattr(self, "_gateway_ref", None),
+ route_match=route_match,
+ # Note: intentionally NOT passing agent_id — this hook's
+ # purpose is to DECIDE the agent_id. The resolved value
+ # will be stamped on event.source.agent_id after this call.
+ )
+ for r in results or []:
+ if isinstance(r, str) and r.strip():
+ hook_pick = r.strip()
+ break
+ except Exception as exc:
+ logger.debug("[%s] select_agent hook failed: %s", self.name, exc)
+
+ agent_id = (
+ hook_pick
+ or route_match
+ or getattr(self, "_default_agent_id", None)
+ or "main"
+ )
+ try:
+ import dataclasses
+ event.source = dataclasses.replace(source, agent_id=agent_id)
+ except Exception as exc:
+ logger.debug("[%s] could not stamp agent_id=%r: %s",
+ self.name, agent_id, exc)
@abstractmethod
async def connect(self) -> bool:
@@ -3787,6 +3873,12 @@ async def handle_message(self, event: MessageEvent) -> None:
# downstream delivery all agree on the same lane.
self._apply_topic_recovery(event)
+ # Resolve which agent should handle this message and stamp the
+ # decision onto ``event.source.agent_id`` so build_session_key,
+ # cron creation, hooks and delivery all see a consistent identity.
+ # Runs after topic recovery so routing sees the normalized source.
+ self._attach_agent_id(event)
+
session_key = build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py
index 12ad62b5a7e94..4d4a881030f05 100644
--- a/gateway/platforms/feishu.py
+++ b/gateway/platforms/feishu.py
@@ -3114,6 +3114,9 @@ async def _process_inbound_message(
async def _dispatch_inbound_event(self, event: MessageEvent) -> None:
"""Apply Feishu-specific burst protection before entering the base adapter."""
+ # Stamp agent_id before batching so batch keys reflect the routed
+ # agent (idempotent — handle_message will skip re-stamping).
+ self._attach_agent_id(event)
if event.message_type == MessageType.TEXT and not event.is_command():
await self._enqueue_text_event(event)
return
diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py
index cccb4d70e1835..1717f1f093256 100644
--- a/gateway/platforms/matrix.py
+++ b/gateway/platforms/matrix.py
@@ -1940,6 +1940,9 @@ async def _handle_text_message(
)
if msg_type == MessageType.TEXT and self._text_batch_delay_seconds > 0:
+ # Stamp agent_id before batching so the batch key reflects the
+ # routed agent (idempotent — handle_message will skip re-stamping).
+ self._attach_agent_id(msg_event)
self._enqueue_text_event(msg_event)
else:
await self.handle_message(msg_event)
diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py
index 13564f1e6e2a7..e735c57b3ba02 100644
--- a/gateway/platforms/slack.py
+++ b/gateway/platforms/slack.py
@@ -3315,6 +3315,17 @@ def _has_active_session_for_thread(
thread_id=thread_ts,
)
+ # Resolve agent_id from routes/plugins so the lookup uses the
+ # same key prefix the writing path stamped (otherwise we'd
+ # always check the agent:main bucket and miss sessions
+ # belonging to other agents).
+ try:
+ synthetic_event = MessageEvent(text="", source=source)
+ self._attach_agent_id(synthetic_event)
+ source = synthetic_event.source
+ except Exception:
+ pass
+
# Read session isolation settings from the store's config
store_cfg = getattr(session_store, "config", None)
gspu = (
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index fa55f2db0042d..a287c5499c07f 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -5199,6 +5199,9 @@ async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAU
event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id)
event.text = self._clean_bot_trigger_text(event.text)
event = self._apply_telegram_group_observe_attribution(event)
+ # Stamp agent_id before batching so the batch key reflects the
+ # routed agent (idempotent — handle_message will skip re-stamping).
+ self._attach_agent_id(event)
self._enqueue_text_event(event)
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -5459,8 +5462,10 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
logger.info("[Telegram] Cached user photo at %s", cached_path)
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
+ self._attach_agent_id(event)
await self._queue_media_group_event(str(media_group_id), event)
else:
+ self._attach_agent_id(event)
batch_key = self._photo_batch_key(event, msg)
self._enqueue_photo_event(batch_key, event)
return
@@ -5566,8 +5571,10 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
+ self._attach_agent_id(event)
await self._queue_media_group_event(str(media_group_id), event)
else:
+ self._attach_agent_id(event)
batch_key = self._photo_batch_key(event, msg)
self._enqueue_photo_event(batch_key, event)
return
@@ -5645,6 +5652,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
+ self._attach_agent_id(event)
await self._queue_media_group_event(str(media_group_id), event)
return
diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py
index 5bec5baca9209..4d660353ea499 100644
--- a/gateway/platforms/wecom.py
+++ b/gateway/platforms/wecom.py
@@ -559,6 +559,11 @@ async def _on_message(self, payload: Dict[str, Any]) -> None:
timestamp=datetime.now(tz=timezone.utc),
)
+ # Stamp agent_id before batching so the batch key (and its
+ # corresponding log line) reflect the routed agent, and so two
+ # agents that distinguish on sender don't share a batch buffer.
+ self._attach_agent_id(event)
+
# Only batch plain text messages — commands, media, etc. dispatch
# immediately since they won't be split by the WeCom client.
if message_type == MessageType.TEXT and self._text_batch_delay_seconds > 0:
diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py
index 6dc54dbcd502f..60d71b7bd8c17 100644
--- a/gateway/platforms/yuanbao.py
+++ b/gateway/platforms/yuanbao.py
@@ -2567,6 +2567,17 @@ class DispatchMiddleware(InboundMiddleware):
async def handle(self, ctx: InboundContext, next_fn) -> None:
adapter = ctx.adapter
+ # Stamp agent_id on ctx.source before building the session key so
+ # the key prefix matches the routed agent (idempotent — the base
+ # adapter's handle_message will skip re-stamping later).
+ try:
+ from gateway.platforms.base import MessageEvent as _MsgEvt
+ _se = _MsgEvt(text=ctx.raw_text or "", source=ctx.source)
+ adapter._attach_agent_id(_se)
+ ctx.source = _se.source
+ except Exception:
+ pass
+
_sk = build_session_key(
ctx.source,
group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True),
diff --git a/gateway/run.py b/gateway/run.py
index df0d76ed384c3..5e1c98b01fefe 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -1860,7 +1860,12 @@ def __init__(self, config: Optional[GatewayConfig] = None):
self.config.sessions_dir, self.config,
has_active_processes_fn=lambda key: process_registry.has_active_for_session(key),
)
- self.delivery_router = DeliveryRouter(self.config)
+ # Build the AgentProfile registry from config.agents. Always returns
+ # at least {"main": AgentProfile()}, so single-agent installs see
+ # zero behavior change.
+ from agent.profile import load_agent_registry
+ self._agent_registry = load_agent_registry(self.config)
+ self.delivery_router = DeliveryRouter(self.config, registry=self._agent_registry)
self._running = False
self._gateway_loop: Optional[asyncio.AbstractEventLoop] = None
self._shutdown_event = asyncio.Event()
@@ -2647,6 +2652,12 @@ def _resolve_session_agent_runtime(
except Exception:
pass
+ # Per-agent profile overrides: if the active profile pins a model /
+ # provider / base_url / api_key_env, those win over the gateway-wide
+ # defaults but still lose to an explicit session /model override
+ # (which already returned above when complete).
+ model, runtime_kwargs = self._apply_profile_runtime_overrides(model, runtime_kwargs)
+
# Final safety net (#35314): if resolution still produced an empty
# model — e.g. a transient config-cache miss during a post-interrupt
# recovery turn returned an empty user_config — reuse the last model we
@@ -2675,6 +2686,94 @@ def _resolve_session_agent_runtime(
return model, runtime_kwargs
+ def _apply_profile_runtime_overrides(
+ self, model: str, runtime_kwargs: dict
+ ) -> tuple[str, dict]:
+ """Layer the active AgentProfile's model/provider on top of gateway defaults.
+
+ The default ("main") profile carries None for these fields and is a
+ no-op. Non-default profiles with explicit values re-resolve the
+ provider via ``resolve_runtime_provider`` so api_mode / base_url /
+ api_key fields stay consistent with the chosen provider.
+ """
+ try:
+ from agent.profile import get_active_profile, DEFAULT_AGENT_ID
+ except Exception:
+ return model, runtime_kwargs
+
+ profile = get_active_profile()
+ if profile is None or profile.id == DEFAULT_AGENT_ID:
+ return model, runtime_kwargs
+
+ # Model: profile wins over gateway default.
+ if profile.model:
+ model = profile.model
+
+ # Provider / base_url / api_key_env: only re-resolve if the profile
+ # actually pins one. Skip when all are None to preserve the gateway's
+ # resolved runtime (env-derived credentials).
+ if not (profile.provider or profile.base_url or profile.api_key_env):
+ return model, runtime_kwargs
+
+ explicit_api_key = (
+ os.getenv(profile.api_key_env) if profile.api_key_env else None
+ )
+ try:
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ new_runtime = resolve_runtime_provider(
+ requested=profile.provider,
+ explicit_api_key=explicit_api_key,
+ explicit_base_url=profile.base_url,
+ target_model=model,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Profile %s provider resolution failed (%s); falling back to gateway runtime",
+ profile.id, exc,
+ )
+ return model, runtime_kwargs
+
+ runtime_kwargs = {
+ "api_key": new_runtime.get("api_key") or runtime_kwargs.get("api_key"),
+ "base_url": new_runtime.get("base_url") or runtime_kwargs.get("base_url"),
+ "provider": new_runtime.get("provider") or runtime_kwargs.get("provider"),
+ "api_mode": new_runtime.get("api_mode") or runtime_kwargs.get("api_mode"),
+ "command": new_runtime.get("command") or runtime_kwargs.get("command"),
+ "args": list(new_runtime.get("args") or runtime_kwargs.get("args") or []),
+ "credential_pool": new_runtime.get("credential_pool") or runtime_kwargs.get("credential_pool"),
+ }
+ logger.debug(
+ "Profile %s runtime override: model=%s provider=%s base_url=%s",
+ profile.id, model, runtime_kwargs.get("provider"), runtime_kwargs.get("base_url"),
+ )
+ return model, runtime_kwargs
+
+ def _apply_profile_toolsets(
+ self,
+ enabled_toolsets: Optional[list],
+ disabled_toolsets: Optional[list],
+ ) -> tuple[Optional[list], Optional[list]]:
+ """Override gateway-default toolsets with the active profile's.
+
+ Returns the inputs unchanged when no profile is active or when the
+ profile carries None for the relevant field — preserving the legacy
+ single-agent path.
+ """
+ try:
+ from agent.profile import get_active_profile, DEFAULT_AGENT_ID
+ except Exception:
+ return enabled_toolsets, disabled_toolsets
+
+ profile = get_active_profile()
+ if profile is None or profile.id == DEFAULT_AGENT_ID:
+ return enabled_toolsets, disabled_toolsets
+
+ if profile.enabled_toolsets is not None:
+ enabled_toolsets = sorted(profile.enabled_toolsets)
+ if profile.disabled_toolsets is not None:
+ disabled_toolsets = list(profile.disabled_toolsets)
+ return enabled_toolsets, disabled_toolsets
+
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
"""Build the effective model/runtime config for a single turn.
@@ -3800,10 +3899,13 @@ def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None:
for agent in active_agents.values():
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _profile = getattr(agent, "_profile", None)
+ _agent_id = _profile.id if _profile else None
_invoke_hook(
"on_session_finalize",
session_id=getattr(agent, "session_id", None),
platform="gateway",
+ agent_id=_agent_id,
reason="shutdown",
)
except Exception:
@@ -4505,7 +4607,12 @@ async def start(self) -> bool:
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
adapter._busy_text_mode = self._busy_text_mode
-
+ adapter.set_routing_context(
+ routes=self.config.routes,
+ default_agent=self.config.default_agent,
+ gateway=self,
+ )
+
# Try to connect
logger.info("Connecting to %s...", platform.value)
self._update_platform_runtime_status(
@@ -5033,10 +5140,12 @@ async def _session_expiry_watcher(self, interval: int = 300):
from hermes_cli.plugins import invoke_hook as _invoke_hook
_parts = key.split(":")
_platform = _parts[2] if len(_parts) > 2 else ""
+ _agent_id = _parts[1] if len(_parts) > 1 else None
_invoke_hook(
"on_session_finalize",
session_id=entry.session_id,
platform=_platform,
+ agent_id=_agent_id,
reason="session_expired",
)
except Exception:
@@ -6248,6 +6357,11 @@ async def _platform_reconnect_watcher(self) -> None:
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
adapter._busy_text_mode = self._busy_text_mode
+ adapter.set_routing_context(
+ routes=self.config.routes,
+ default_agent=self.config.default_agent,
+ gateway=self,
+ )
success = await self._connect_adapter_with_timeout(adapter, platform)
if success:
@@ -7289,7 +7403,7 @@ async def _deliver_platform_notice(self, source, content: str) -> None:
async def _handle_message(self, event: MessageEvent) -> Optional[str]:
"""
Handle an incoming message from any platform.
-
+
This is the core message processing pipeline:
1. Check user authorization
2. Check for commands (/new, /reset, etc.)
@@ -7299,6 +7413,28 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
6. Run agent conversation
7. Return response
"""
+ # Bind the per-message AgentProfile into the ContextVar so every
+ # downstream path-getter (SOUL.md, memory dir, skills dir, sessions
+ # dir) honors the routed agent. Falls back to "main" when the
+ # adapter didn't stamp an agent_id (legacy code path). Uses
+ # ``getattr`` for the registry so tests that build a stripped-down
+ # GatewayRunner without going through ``__init__`` still work.
+ from agent.profile import _current_agent_profile as _hermes_agent_cv
+ _hermes_agent_id = getattr(event.source, "agent_id", None) or "main"
+ _hermes_registry = getattr(self, "_agent_registry", None) or {}
+ _hermes_profile = _hermes_registry.get(_hermes_agent_id) or _hermes_registry.get("main")
+ _hermes_profile_token = _hermes_agent_cv.set(_hermes_profile) if _hermes_profile else None
+ try:
+ return await self._handle_message_inner(event)
+ finally:
+ if _hermes_profile_token is not None:
+ _hermes_agent_cv.reset(_hermes_profile_token)
+
+ async def _handle_message_inner(self, event: MessageEvent) -> Optional[str]:
+ # Body of the legacy _handle_message — wrapped by _handle_message
+ # above so the AgentProfile ContextVar is bound for the duration
+ # of the call. The "update" command and the rest of the
+ # _known_commands set live here.
source = event.source
# Internal events (e.g. background-process completion notifications)
@@ -7315,11 +7451,13 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
if not is_internal:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _agent_id = getattr(getattr(event, "source", None), "agent_id", None)
_hook_results = _invoke_hook(
"pre_gateway_dispatch",
event=event,
gateway=self,
session_store=self.session_store,
+ agent_id=_agent_id,
)
except Exception as _hook_exc:
logger.warning("pre_gateway_dispatch invocation failed: %s", _hook_exc)
@@ -8407,7 +8545,19 @@ async def _do_undo():
_run_generation = self._begin_session_run_generation(_quick_key)
try:
- _agent_result = await self._handle_message_with_agent(event, source, _quick_key, _run_generation)
+ # Set the active agent profile for this message so all downstream
+ # path getters (SOUL.md, memory, skills, cron jobs) resolve to the
+ # correct per-agent directory. The profile is looked up from the
+ # registry by source.agent_id (set by adapter _attach_agent_id).
+ _agent_id = getattr(source, "agent_id", None) or "main"
+ _registry = getattr(self, "_agent_registry", None)
+ _profile = _registry.get(_agent_id) if _registry is not None else None
+ if _profile is not None:
+ from agent.profile import use_profile
+ with use_profile(_profile):
+ _agent_result = await self._handle_message_with_agent(event, source, _quick_key, _run_generation)
+ else:
+ _agent_result = await self._handle_message_with_agent(event, source, _quick_key, _run_generation)
# Goal continuation: after the agent returns a final response
# for this turn, check any standing /goal — the judge will
# either mark it done, pause it (budget), or enqueue a
@@ -10006,6 +10156,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer
"on_session_finalize",
session_id=_old_sid,
platform=source.platform.value if source.platform else "",
+ agent_id=getattr(source, "agent_id", None),
reason="new_session",
old_session_id=_old_sid,
new_session_id=new_entry.session_id if new_entry else None,
@@ -10082,6 +10233,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer
"on_session_reset",
session_id=_new_sid,
platform=source.platform.value if source.platform else "",
+ agent_id=getattr(source, "agent_id", None),
reason="new_session",
old_session_id=_old_sid,
new_session_id=_new_sid,
@@ -12499,6 +12651,9 @@ async def _run_background_task(
enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
agent_cfg = user_config.get("agent") or {}
disabled_toolsets = agent_cfg.get("disabled_toolsets") or None
+ enabled_toolsets, disabled_toolsets = self._apply_profile_toolsets(
+ enabled_toolsets, disabled_toolsets
+ )
pr = self._provider_routing
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
@@ -16842,6 +16997,9 @@ def _run_still_current() -> bool:
enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
agent_cfg_local = user_config.get("agent") or {}
disabled_toolsets = agent_cfg_local.get("disabled_toolsets") or None
+ enabled_toolsets, disabled_toolsets = self._apply_profile_toolsets(
+ enabled_toolsets, disabled_toolsets
+ )
display_config = user_config.get("display", {})
if not isinstance(display_config, dict):
@@ -19185,16 +19343,20 @@ def _run_planned_stop_watcher(
stop_event.wait(poll_interval)
-def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60):
+def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60, registry=None):
"""
Background thread that ticks the cron scheduler at a regular interval.
-
+
Runs inside the gateway process so cronjobs fire automatically without
needing a separate `hermes cron daemon` or system cron entry.
When ``adapters`` and ``loop`` are provided, passes them through to the
cron delivery path so live adapters can be used for E2EE rooms.
+ ``registry`` is the agent profile registry for multi-agent mode; when
+ present, cron jobs are loaded from ALL agent profiles and each job runs
+ under its own profile context.
+
Also refreshes the channel directory every 5 minutes and prunes the
image/audio/document cache + expired ``hermes debug share`` pastes
once per hour.
@@ -19212,7 +19374,7 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in
tick_count = 0
while not stop_event.is_set():
try:
- cron_tick(verbose=False, adapters=adapters, loop=loop)
+ cron_tick(verbose=False, adapters=adapters, loop=loop, registry=registry)
except Exception as e:
logger.debug("Cron tick error: %s", e)
@@ -19643,7 +19805,11 @@ def restart_signal_handler():
cron_thread = threading.Thread(
target=_start_cron_ticker,
args=(cron_stop,),
- kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()},
+ kwargs={
+ "adapters": runner.adapters,
+ "loop": asyncio.get_running_loop(),
+ "registry": getattr(runner, "_agent_registry", None),
+ },
daemon=True,
name="cron-ticker",
)
diff --git a/gateway/session.py b/gateway/session.py
index 4d3f4f42f94bb..86625a9dcb899 100644
--- a/gateway/session.py
+++ b/gateway/session.py
@@ -91,6 +91,7 @@ class SessionSource:
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)
+ agent_id: Optional[str] = None # Resolved agent identity (None == default "main")
@property
def description(self) -> str:
@@ -134,6 +135,8 @@ def to_dict(self) -> Dict[str, Any]:
d["parent_chat_id"] = self.parent_chat_id
if self.message_id:
d["message_id"] = self.message_id
+ if self.agent_id:
+ d["agent_id"] = self.agent_id
return d
@classmethod
@@ -152,6 +155,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
guild_id=data.get("guild_id"),
parent_chat_id=data.get("parent_chat_id"),
message_id=data.get("message_id"),
+ agent_id=data.get("agent_id"),
)
@@ -440,6 +444,11 @@ class SessionEntry:
display_name: Optional[str] = None
platform: Optional[Platform] = None
chat_type: str = "dm"
+
+ # Agent identity for this session. Defaults to ``"main"`` so single-agent
+ # installs keep behaving identically; multi-agent installs set it to the
+ # agent_id resolved by the routes table at message-dispatch time.
+ agent_id: str = "main"
# Token tracking
input_tokens: int = 0
@@ -500,6 +509,7 @@ def to_dict(self) -> Dict[str, Any]:
"display_name": self.display_name,
"platform": self.platform.value if self.platform else None,
"chat_type": self.chat_type,
+ "agent_id": self.agent_id,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cache_read_tokens": self.cache_read_tokens,
@@ -556,6 +566,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry":
display_name=data.get("display_name"),
platform=platform,
chat_type=data.get("chat_type", "dm"),
+ agent_id=data.get("agent_id", "main"),
input_tokens=data.get("input_tokens", 0),
output_tokens=data.get("output_tokens", 0),
cache_read_tokens=data.get("cache_read_tokens", 0),
@@ -624,7 +635,15 @@ def build_session_key(
- Without participant identifiers, or when isolation is disabled, messages fall back to one
shared session per chat.
- Without identifiers, messages fall back to one session per platform/chat_type.
+
+ Agent identity:
+ - When ``source.agent_id`` is set (via routing in the adapter), the
+ key is prefixed with ``agent::``. Unset (the single-agent default)
+ produces ``agent:main:``, preserving every key string generated before
+ the multi-agent feature shipped.
"""
+ agent_id = getattr(source, "agent_id", None) or "main"
+ agent_prefix = f"agent:{agent_id}"
platform = source.platform.value
if source.chat_type == "dm":
dm_chat_id = source.chat_id
@@ -633,11 +652,11 @@ def build_session_key(
if dm_chat_id:
if source.thread_id:
- return f"agent:main:{platform}:dm:{dm_chat_id}:{source.thread_id}"
- return f"agent:main:{platform}:dm:{dm_chat_id}"
+ return f"{agent_prefix}:{platform}:dm:{dm_chat_id}:{source.thread_id}"
+ return f"{agent_prefix}:{platform}:dm:{dm_chat_id}"
if source.thread_id:
- return f"agent:main:{platform}:dm:{source.thread_id}"
- return f"agent:main:{platform}:dm"
+ return f"{agent_prefix}:{platform}:dm:{source.thread_id}"
+ return f"{agent_prefix}:{platform}:dm"
participant_id = source.user_id_alt or source.user_id
if participant_id and source.platform == Platform.WHATSAPP:
@@ -645,7 +664,7 @@ def build_session_key(
# single group member gets two isolated per-user sessions when the
# bridge reshuffles alias forms.
participant_id = canonical_whatsapp_identifier(str(participant_id)) or participant_id
- key_parts = ["agent:main", platform, source.chat_type]
+ key_parts = [agent_prefix, platform, source.chat_type]
if source.chat_id:
key_parts.append(source.chat_id)
@@ -926,6 +945,7 @@ def get_or_create_session(
display_name=source.chat_name,
platform=source.platform,
chat_type=source.chat_type,
+ agent_id=source.agent_id or "main",
was_auto_reset=was_auto_reset,
auto_reset_reason=auto_reset_reason,
reset_had_activity=reset_had_activity,
@@ -937,6 +957,7 @@ def get_or_create_session(
"session_id": session_id,
"source": source.platform.value,
"user_id": source.user_id,
+ "agent_id": source.agent_id or "main",
}
# SQLite operations outside the lock
@@ -1154,6 +1175,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->
display_name=display_name if display_name is not None else old_entry.display_name,
platform=old_entry.platform,
chat_type=old_entry.chat_type,
+ agent_id=old_entry.agent_id or "main",
is_fresh_reset=True,
)
@@ -1163,6 +1185,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->
"session_id": session_id,
"source": old_entry.platform.value if old_entry.platform else "unknown",
"user_id": old_entry.origin.user_id if old_entry.origin else None,
+ "agent_id": old_entry.agent_id or "main",
}
if self._db and db_end_session_id:
@@ -1215,6 +1238,7 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S
display_name=old_entry.display_name,
platform=old_entry.platform,
chat_type=old_entry.chat_type,
+ agent_id=old_entry.agent_id or "main",
)
self._entries[session_key] = new_entry
diff --git a/hermes_cli/agent.py b/hermes_cli/agent.py
new file mode 100644
index 0000000000000..07853f723cae6
--- /dev/null
+++ b/hermes_cli/agent.py
@@ -0,0 +1,236 @@
+"""
+hermes agent — Manage multi-agent profiles and routing.
+
+ hermes agent list Show all agents, models, and route counts
+ hermes agent show Display agent details (paths, routes, SOUL)
+ hermes agent add Add a new agent to config.yaml
+ hermes agent remove Remove an agent (warns about orphaned routes)
+"""
+
+import shutil
+from pathlib import Path
+from typing import Dict, List, Optional, Any
+
+import yaml
+
+from hermes_cli.colors import color, Colors
+from hermes_cli.config import load_config, save_config
+from hermes_constants import get_hermes_home
+
+
+def _load_config() -> Dict[str, Any]:
+ cfg = load_config()
+ if cfg is None:
+ cfg = {}
+ if not isinstance(cfg, dict):
+ cfg = {}
+ return cfg
+
+
+def _ensure_agent_section(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ if "agents" not in cfg:
+ cfg["agents"] = {}
+ if not isinstance(cfg["agents"], dict):
+ cfg["agents"] = {}
+ if "routes" not in cfg:
+ cfg["routes"] = []
+ if not isinstance(cfg["routes"], list):
+ cfg["routes"] = []
+ if "default_agent" not in cfg:
+ cfg["default_agent"] = "main"
+ return cfg
+
+
+def _count_routes_for_agent(cfg: Dict[str, Any], agent_id: str) -> int:
+ routes = cfg.get("routes", [])
+ return sum(1 for r in routes if isinstance(r, dict) and r.get("agent") == agent_id)
+
+
+def _routes_for_agent(cfg: Dict[str, Any], agent_id: str) -> List[Dict[str, Any]]:
+ routes = cfg.get("routes", [])
+ return [r for r in routes if isinstance(r, dict) and r.get("agent") == agent_id]
+
+
+def _summarize_soul(path: Path, max_lines: int = 8) -> str:
+ if not path.exists():
+ return "(no SOUL.md)"
+ lines = path.read_text(encoding="utf-8").splitlines()
+ non_empty = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#")]
+ preview = " ".join(non_empty[:max_lines])
+ if len(preview) > 200:
+ preview = preview[:200] + "..."
+ return preview or "(empty SOUL.md)"
+
+
+def _green(text: str) -> str:
+ return color(text, Colors.GREEN)
+
+
+def _red(text: str) -> str:
+ return color(text, Colors.RED)
+
+
+def _yellow(text: str) -> str:
+ return color(text, Colors.YELLOW)
+
+
+def cmd_agent_list(args) -> int:
+ """List all agents with model, home dir, and route count."""
+ cfg = _ensure_agent_section(_load_config())
+ agents = cfg.get("agents", {})
+ default_agent = cfg.get("default_agent", "main")
+
+ if not agents:
+ print("No agents configured. Run 'hermes agent add ' to create one.")
+ return 0
+
+ # Determine column widths
+ id_width = max(len(str(aid)) for aid in agents.keys())
+ id_width = max(id_width, 6)
+
+ header = f"{'ID':<{id_width}} {'Model':<28} {'Routes':>6} {'Home Dir'}"
+ print(color(header, Colors.BOLD))
+ print("-" * (id_width + 2 + 28 + 1 + 6 + 2 + 10))
+
+ for aid, spec in agents.items():
+ if not isinstance(spec, dict):
+ spec = {}
+ model = spec.get("model", "(default)")
+ home = spec.get("home_dir", "(default)")
+ route_count = _count_routes_for_agent(cfg, aid)
+ marker = " *" if aid == default_agent else " "
+ print(f"{marker}{aid:<{id_width}} {model:<28} {route_count:>6} {home}")
+
+ print(f"\n* = default agent ({default_agent})")
+ return 0
+
+
+def cmd_agent_show(args) -> int:
+ """Show detailed info for a single agent."""
+ cfg = _ensure_agent_section(_load_config())
+ agent_id = args.agent_id
+ agents = cfg.get("agents", {})
+
+ if agent_id not in agents:
+ print(_red(f"Agent '{agent_id}' not found."))
+ print("Run 'hermes agent list' to see available agents.")
+ return 1
+
+ spec = agents[agent_id]
+ if not isinstance(spec, dict):
+ spec = {}
+
+ home_dir = spec.get("home_dir")
+ if home_dir:
+ home_path = Path(home_dir).expanduser()
+ else:
+ home_path = get_hermes_home()
+
+ print(color(f"Agent: {agent_id}", Colors.BOLD))
+ print(f" Model: {spec.get('model', '(default)')}")
+ print(f" Provider: {spec.get('provider', '(default)')}")
+ print(f" Home Dir: {home_path}")
+ print(f" Memory: {home_path / 'memories'}")
+ print(f" Skills: {home_path / 'skills'}")
+ print(f" Sessions: {home_path / 'sessions.json'}")
+
+ routes = _routes_for_agent(cfg, agent_id)
+ print(f"\n Routes ({len(routes)}):")
+ for r in routes:
+ match = r.get("match", {})
+ parts = []
+ for k in ("platform", "chat_type", "chat_id", "thread_id", "topic_id",
+ "user_id", "user_id_alt", "guild_id", "parent_chat_id"):
+ v = match.get(k)
+ if v:
+ parts.append(f"{k}={v}")
+ print(f" → {' '.join(parts) or '(any)'}")
+
+ soul_path = home_path / "SOUL.md"
+ print(f"\n SOUL.md Preview:")
+ print(f" {_summarize_soul(soul_path)}")
+ return 0
+
+
+def cmd_agent_add(args) -> int:
+ """Add a new agent to config.yaml."""
+ cfg = _ensure_agent_section(_load_config())
+ agent_id = args.agent_id
+
+ if not agent_id or not agent_id.replace("-", "").replace("_", "").isalnum():
+ print(_red(f"Invalid agent ID '{agent_id}'. Use alphanumeric, hyphens, underscores only."))
+ return 1
+
+ if agent_id in cfg.get("agents", {}):
+ print(_red(f"Agent '{agent_id}' already exists."))
+ return 1
+
+ spec: Dict[str, Any] = {}
+
+ if args.model:
+ spec["model"] = args.model
+ if args.provider:
+ spec["provider"] = args.provider
+ if args.home_dir:
+ spec["home_dir"] = args.home_dir
+ if args.enabled_toolsets:
+ spec["enabled_toolsets"] = args.enabled_toolsets.split(",")
+
+ # If cloning from an existing profile, copy directory
+ if args.from_profile:
+ src = get_hermes_home() / "profiles" / args.from_profile
+ dst = get_hermes_home() / "profiles" / agent_id
+
+ if not src.exists():
+ print(_red(f"Source profile '{args.from_profile}' not found at {src}"))
+ return 1
+
+ if dst.exists():
+ print(_red(f"Destination already exists: {dst}"))
+ return 1
+
+ try:
+ shutil.copytree(src, dst, ignore=shutil.ignore_patterns("*.pyc", "__pycache__"))
+ spec["home_dir"] = str(dst)
+ print(_green(f"Cloned profile from '{args.from_profile}' to {dst}"))
+ except Exception as e:
+ print(_red(f"Failed to clone profile: {e}"))
+ return 1
+
+ cfg["agents"][agent_id] = spec
+ save_config(cfg)
+ print(_green(f"Agent '{agent_id}' added."))
+ print(f" Run 'hermes agent show {agent_id}' for details.")
+ return 0
+
+
+def cmd_agent_remove(args) -> int:
+ """Remove an agent from config.yaml."""
+ cfg = _ensure_agent_section(_load_config())
+ agent_id = args.agent_id
+
+ if agent_id == "main":
+ print(_red("Cannot remove the 'main' agent."))
+ return 1
+
+ if agent_id not in cfg.get("agents", {}):
+ print(_red(f"Agent '{agent_id}' not found."))
+ return 1
+
+ routes = _routes_for_agent(cfg, agent_id)
+ if routes and not args.yes:
+ print(_yellow(f"Warning: {len(routes)} route(s) reference agent '{agent_id}':"))
+ for r in routes:
+ print(f" - {r}")
+ print("Use --yes to confirm removal.")
+ return 1
+
+ # Clean up routes
+ cfg["routes"] = [r for r in cfg.get("routes", []) if not (isinstance(r, dict) and r.get("agent") == agent_id)]
+
+ del cfg["agents"][agent_id]
+ save_config(cfg)
+ print(_green(f"Agent '{agent_id}' removed."))
+ if routes:
+ print(f" {len(routes)} orphaned route(s) cleaned up.")
+ return 0
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 57dbaaf7c2dd8..17b88eb22de71 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -11867,7 +11867,7 @@ def cmd_logs(args):
# to parse.
_BUILTIN_SUBCOMMANDS = frozenset(
{
- "acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
+ "acp", "agent", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
"computer-use",
"config", "cron", "curator", "dashboard", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
@@ -13096,6 +13096,43 @@ def _dispatch_secrets(args): # noqa: ANN001
_add_accept_hooks_flag(cron_parser)
cron_parser.set_defaults(func=cmd_cron)
+ # =========================================================================
+ # agent command
+ # =========================================================================
+ from hermes_cli.agent import cmd_agent_list, cmd_agent_show, cmd_agent_add, cmd_agent_remove
+
+ agent_parser = subparsers.add_parser(
+ "agent",
+ help="Manage multi-agent profiles and routing",
+ description="List, inspect, add, and remove agent profiles.",
+ )
+ agent_subparsers = agent_parser.add_subparsers(dest="agent_command")
+
+ # agent list
+ agent_list = agent_subparsers.add_parser("list", help="List all agents")
+ agent_list.set_defaults(func=cmd_agent_list)
+
+ # agent show
+ agent_show = agent_subparsers.add_parser("show", help="Show agent details")
+ agent_show.add_argument("agent_id", help="Agent ID")
+ agent_show.set_defaults(func=cmd_agent_show)
+
+ # agent add
+ agent_add = agent_subparsers.add_parser("add", help="Add a new agent")
+ agent_add.add_argument("agent_id", help="Unique agent identifier")
+ agent_add.add_argument("--from-profile", help="Clone an existing profile directory")
+ agent_add.add_argument("--model", help="Default model for this agent")
+ agent_add.add_argument("--provider", help="Provider override")
+ agent_add.add_argument("--home-dir", help="Custom home directory")
+ agent_add.add_argument("--enabled-toolsets", help="Comma-separated toolset names")
+ agent_add.set_defaults(func=cmd_agent_add)
+
+ # agent remove
+ agent_remove = agent_subparsers.add_parser("remove", help="Remove an agent")
+ agent_remove.add_argument("agent_id", help="Agent ID to remove")
+ agent_remove.add_argument("--yes", action="store_true", help="Skip confirmation")
+ agent_remove.set_defaults(func=cmd_agent_remove)
+
# =========================================================================
# webhook command
# =========================================================================
diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py
index fd449fc27a43b..6917619065ad2 100644
--- a/hermes_cli/plugins.py
+++ b/hermes_cli/plugins.py
@@ -153,6 +153,17 @@ def _install_plugin_debug_handler(force: bool = False) -> None:
# {"action": "allow"} / None -> normal dispatch
# Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store.
"pre_gateway_dispatch",
+ # Agent selection hook (single-gateway-multi-agent). Fired once per
+ # inbound MessageEvent after the declarative routes table has been
+ # consulted but BEFORE the message dispatches to an AIAgent. Plugins
+ # return a string (the agent_id) to bind the message to that agent,
+ # or None/"" to defer. First non-empty string wins. When all hooks
+ # defer, the runtime falls back to the routes-table result, then to
+ # ``config.default_agent``, then to ``"main"``.
+ #
+ # Kwargs: event: MessageEvent, gateway: GatewayRunner,
+ # route_match: Optional[str] (what the routes table resolved to)
+ "select_agent",
# Approval lifecycle hooks. Fired by tools/approval.py when a dangerous
# command needs user approval -- fires BOTH for CLI-interactive prompts
# and for gateway/ACP approvals (Telegram, Discord, Slack, TUI, etc.).
@@ -1700,6 +1711,14 @@ def get_pre_tool_call_block_message(
fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied")
return fmt.format(tool_name=tool_name)
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
hook_results = invoke_hook(
"pre_tool_call",
tool_name=tool_name,
@@ -1709,6 +1728,7 @@ def get_pre_tool_call_block_message(
tool_call_id=tool_call_id,
turn_id=turn_id,
api_request_id=api_request_id,
+ agent_id=_agent_id,
)
for result in hook_results:
diff --git a/hermes_constants.py b/hermes_constants.py
index 3ec977441e1f3..b026b129e7d09 100644
--- a/hermes_constants.py
+++ b/hermes_constants.py
@@ -43,7 +43,12 @@ def get_hermes_home_override() -> str | None:
def get_hermes_home() -> Path:
"""Return the Hermes home directory (default: ~/.hermes).
- Reads HERMES_HOME env var, falls back to ~/.hermes.
+ Resolution order:
+ 1. Active ``AgentProfile`` in the current async context (multi-agent
+ gateway routes per-message to a profile via ContextVar).
+ 2. ``HERMES_HOME`` env var.
+ 3. Fallback ``~/.hermes``.
+
This is the single source of truth — all other copies should import this.
When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates
@@ -56,6 +61,17 @@ def get_hermes_home() -> Path:
template in ``hermes_cli/gateway.py`` and the kanban dispatcher in
``hermes_cli/kanban_db.py``). See https://github.com/NousResearch/hermes-agent/issues/18594.
"""
+ # 1. ContextVar — active AgentProfile wins when present. Lazy import to
+ # avoid a circular dependency (agent.profile imports this module).
+ try:
+ from agent.profile import get_active_profile # noqa: WPS433 (lazy)
+ profile = get_active_profile()
+ except ImportError:
+ profile = None
+ if profile is not None and profile.home_dir is not None:
+ return Path(profile.home_dir).expanduser()
+
+ # 2. Context-local override (set_hermes_home_override).
override = get_hermes_home_override()
if override:
return Path(override)
diff --git a/hermes_state.py b/hermes_state.py
index f08acdce295ba..6d54bfaf76e00 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -234,6 +234,7 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
+ agent_id TEXT NOT NULL DEFAULT 'main',
user_id TEXT,
model TEXT,
model_config TEXT,
@@ -884,6 +885,15 @@ def _init_schema(self):
except sqlite3.OperationalError:
pass # Index already exists
+ # agent_id index — created after _reconcile_columns has ensured the
+ # column exists on legacy databases.
+ try:
+ cursor.execute(
+ "CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id)"
+ )
+ except sqlite3.OperationalError:
+ pass
+
if fts5_available:
# FTS5 setup. Run the DDL even when the virtual table exists so
# CREATE TRIGGER IF NOT EXISTS repairs trigger-only degradation from
@@ -917,16 +927,18 @@ def _insert_session_row(
user_id: str = None,
parent_session_id: str = None,
cwd: str = None,
+ agent_id: str = "main",
) -> None:
"""Shared INSERT OR IGNORE for session rows."""
def _do(conn):
conn.execute(
- """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
+ """INSERT OR IGNORE INTO sessions (id, source, agent_id, user_id, model, model_config,
system_prompt, parent_session_id, cwd, started_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
source,
+ agent_id or "main",
user_id,
model,
json.dumps(model_config) if model_config else None,
diff --git a/model_tools.py b/model_tools.py
index c3a9c98c60c7b..ca14d26fd355d 100644
--- a/model_tools.py
+++ b/model_tools.py
@@ -823,6 +823,7 @@ def _emit_post_tool_call_hook(
status: Optional[str] = None,
error_type: Optional[str] = None,
error_message: Optional[str] = None,
+ agent_id: Optional[str] = None,
) -> None:
"""Emit the ``post_tool_call`` observer hook.
@@ -853,6 +854,7 @@ def _emit_post_tool_call_hook(
status=status,
error_type=error_type,
error_message=error_message,
+ agent_id=agent_id,
)
except Exception as _hook_err:
logger.debug("post_tool_call hook error: %s", _hook_err)
@@ -1091,6 +1093,14 @@ def _dispatch(next_args: Dict[str, Any]) -> Any:
pass
duration_ms = int((time.monotonic() - _dispatch_start) * 1000)
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
_emit_post_tool_call_hook(
function_name=function_name,
function_args=function_args,
@@ -1101,6 +1111,7 @@ def _dispatch(next_args: Dict[str, Any]) -> Any:
turn_id=turn_id,
api_request_id=api_request_id,
duration_ms=duration_ms,
+ agent_id=_agent_id,
)
# Generic tool-result canonicalization seam: plugins receive the
@@ -1114,6 +1125,14 @@ def _dispatch(next_args: Dict[str, Any]) -> Any:
try:
from hermes_cli.plugins import has_hook, invoke_hook
if has_hook("transform_tool_result"):
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
status, error_type, error_message = _tool_result_observer_fields(result)
hook_results = invoke_hook(
"transform_tool_result",
@@ -1129,6 +1148,7 @@ def _dispatch(next_args: Dict[str, Any]) -> Any:
status=status,
error_type=error_type,
error_message=error_message,
+ agent_id=_agent_id,
)
for hook_result in hook_results:
if isinstance(hook_result, str):
diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py
index 12cf05c38c9e8..dac6e5e0116c6 100644
--- a/plugins/platforms/discord/adapter.py
+++ b/plugins/platforms/discord/adapter.py
@@ -4871,6 +4871,10 @@ async def _handle_message(self, message: DiscordMessage) -> None:
if thread_id:
self._threads.mark(thread_id)
+ # Stamp agent_id before batching so the batch key reflects the
+ # routed agent (idempotent — handle_message will skip re-stamping).
+ self._attach_agent_id(event)
+
# Only batch plain text messages — commands, media, etc. dispatch
# immediately since they won't be split by the Discord client.
if msg_type == MessageType.TEXT and self._text_batch_delay_seconds > 0:
diff --git a/tests/agent/test_profile_contextvar.py b/tests/agent/test_profile_contextvar.py
new file mode 100644
index 0000000000000..a2a84f061af8f
--- /dev/null
+++ b/tests/agent/test_profile_contextvar.py
@@ -0,0 +1,232 @@
+"""Tests for agent/profile.py — AgentProfile + ContextVar isolation."""
+
+import asyncio
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from agent.profile import (
+ AgentProfile,
+ get_active_profile,
+ set_active_profile,
+ use_profile,
+ load_agent_registry,
+ DEFAULT_AGENT_ID,
+)
+
+
+class TestAgentProfile:
+ def test_default_profile(self):
+ p = AgentProfile()
+ assert p.id == DEFAULT_AGENT_ID
+ assert p.home_dir is None
+ assert p.model is None
+
+ def test_resolved_home_fallback(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ p = AgentProfile()
+ assert p.resolved_home == tmp_path
+
+ def test_resolved_home_explicit(self, tmp_path):
+ p = AgentProfile(home_dir=tmp_path / "profiles" / "coder")
+ assert p.resolved_home == tmp_path / "profiles" / "coder"
+
+ def test_resolved_home_expands_tilde(self, monkeypatch):
+ monkeypatch.setenv("HOME", "/home/testuser")
+ p = AgentProfile(home_dir="~/.hermes/profiles/coder")
+ assert p.resolved_home == Path("/home/testuser/.hermes/profiles/coder")
+
+ def test_soul_md_path(self, tmp_path):
+ p = AgentProfile(home_dir=tmp_path)
+ assert p.soul_md_path == tmp_path / "SOUL.md"
+
+ def test_memory_dir(self, tmp_path):
+ p = AgentProfile(home_dir=tmp_path)
+ assert p.memory_dir == tmp_path / "memories"
+
+ def test_skills_dir(self, tmp_path):
+ p = AgentProfile(home_dir=tmp_path)
+ assert p.skills_dir == tmp_path / "skills"
+
+ def test_sessions_path(self, tmp_path):
+ p = AgentProfile(home_dir=tmp_path)
+ assert p.sessions_path == tmp_path / "sessions.json"
+
+ def test_config_overrides(self):
+ p = AgentProfile(config_overrides={"foo": "bar"})
+ assert p.config_overrides == {"foo": "bar"}
+
+
+class TestContextVar:
+ def test_get_active_profile_default_none(self):
+ """No profile set → None."""
+ assert get_active_profile() is None
+
+ def test_set_active_profile(self):
+ p = AgentProfile(id="coder")
+ token = set_active_profile(p)
+ assert get_active_profile() == p
+ # Cleanup
+ from agent.profile import _current_agent_profile
+ _current_agent_profile.reset(token)
+
+ def test_use_profile_context_manager(self):
+ p = AgentProfile(id="coder")
+ assert get_active_profile() is None
+ with use_profile(p):
+ assert get_active_profile() == p
+ assert get_active_profile() is None
+
+ def test_use_profile_none_is_noop(self):
+ """Passing None to use_profile is a no-op."""
+ with use_profile(None):
+ assert get_active_profile() is None
+
+ def test_use_profile_nested(self):
+ outer = AgentProfile(id="outer")
+ inner = AgentProfile(id="inner")
+ with use_profile(outer):
+ assert get_active_profile() == outer
+ with use_profile(inner):
+ assert get_active_profile() == inner
+ assert get_active_profile() == outer
+ assert get_active_profile() is None
+
+ def test_use_profile_exception_cleanup(self):
+ p = AgentProfile(id="coder")
+ with pytest.raises(ValueError):
+ with use_profile(p):
+ assert get_active_profile() == p
+ raise ValueError("boom")
+ assert get_active_profile() is None
+
+
+class TestContextVarAsyncIsolation:
+ """ContextVar must propagate through await but NOT leak to sibling tasks."""
+
+ @pytest.mark.asyncio
+ async def test_async_propagation(self):
+ p = AgentProfile(id="coder")
+ with use_profile(p):
+ # await should keep the profile
+ await asyncio.sleep(0)
+ assert get_active_profile() == p
+ assert get_active_profile() is None
+
+ @pytest.mark.asyncio
+ async def test_gather_isolation(self):
+ """asyncio.gather with copy_context preserves per-task profiles."""
+ async def task_a():
+ with use_profile(AgentProfile(id="a")):
+ await asyncio.sleep(0.01)
+ return get_active_profile().id if get_active_profile() else None
+
+ async def task_b():
+ with use_profile(AgentProfile(id="b")):
+ await asyncio.sleep(0.01)
+ return get_active_profile().id if get_active_profile() else None
+
+ # Tasks spawned from clean context — each sets its own profile
+ results = await asyncio.gather(task_a(), task_b())
+ assert set(results) == {"a", "b"}
+
+ @pytest.mark.asyncio
+ async def test_sibling_tasks_dont_leak(self):
+ """A task spawned before profile is set should not see the profile."""
+ barrier = asyncio.Event()
+
+ async def child():
+ barrier.set()
+ await asyncio.sleep(0.05)
+ return get_active_profile()
+
+ task = asyncio.create_task(child())
+ await barrier.wait()
+
+ with use_profile(AgentProfile(id="parent")):
+ await asyncio.sleep(0.01)
+ assert get_active_profile().id == "parent"
+
+ result = await task
+ # Child was created before profile was set → should not see it
+ assert result is None
+
+
+class TestLoadAgentRegistry:
+ def test_empty_config_returns_main(self):
+ registry = load_agent_registry(None)
+ assert "main" in registry
+ assert registry["main"].id == "main"
+ assert registry["main"].home_dir is None
+
+ def test_single_agent(self):
+ class FakeConfig:
+ agents = {
+ "coder": {"model": "claude-opus", "provider": "anthropic"},
+ }
+
+ registry = load_agent_registry(FakeConfig())
+ assert "main" in registry
+ assert "coder" in registry
+ assert registry["coder"].model == "claude-opus"
+ assert registry["coder"].provider == "anthropic"
+
+ def test_multiple_agents(self):
+ class FakeConfig:
+ agents = {
+ "coder": {"model": "claude-opus"},
+ "research": {"model": "claude-sonnet"},
+ }
+
+ registry = load_agent_registry(FakeConfig())
+ assert len(registry) == 3 # main + coder + research
+ assert registry["coder"].model == "claude-opus"
+ assert registry["research"].model == "claude-sonnet"
+
+ def test_home_dir_expansion(self, monkeypatch):
+ monkeypatch.setenv("HOME", "/home/test")
+
+ class FakeConfig:
+ agents = {
+ "coder": {"home_dir": "~/.hermes/profiles/coder"},
+ }
+
+ registry = load_agent_registry(FakeConfig())
+ assert registry["coder"].resolved_home == Path("/home/test/.hermes/profiles/coder")
+
+ def test_config_overrides(self):
+ class FakeConfig:
+ agents = {
+ "coder": {"model": "claude-opus", "custom_key": "custom_value"},
+ }
+
+ registry = load_agent_registry(FakeConfig())
+ assert registry["coder"].config_overrides == {"custom_key": "custom_value"}
+
+ def test_non_dict_agent_ignored(self):
+ class FakeConfig:
+ agents = {
+ "coder": "not-a-dict",
+ "research": {"model": "claude-sonnet"},
+ }
+
+ registry = load_agent_registry(FakeConfig())
+ assert "coder" not in registry
+ assert "research" in registry
+
+ def test_main_always_present(self):
+ class FakeConfig:
+ agents = {"coder": {"model": "claude-opus"}}
+
+ registry = load_agent_registry(FakeConfig())
+ assert "main" in registry
+ assert registry["main"].id == "main"
+ assert registry["main"].home_dir is None
+
+ def test_main_can_be_overridden(self):
+ class FakeConfig:
+ agents = {"main": {"model": "claude-opus"}}
+
+ registry = load_agent_registry(FakeConfig())
+ assert registry["main"].model == "claude-opus"
diff --git a/tests/cli/test_session_boundary_hooks.py b/tests/cli/test_session_boundary_hooks.py
index 52c64c01c2d80..aecf1a1d750ae 100644
--- a/tests/cli/test_session_boundary_hooks.py
+++ b/tests/cli/test_session_boundary_hooks.py
@@ -25,6 +25,7 @@ def test_session_finalize_on_reset(mock_invoke_hook):
c.args == ("on_session_finalize",)
and c.kwargs["session_id"] == "test-session-id"
and c.kwargs["platform"] == "cli"
+ and c.kwargs["agent_id"] is None
for c in mock_invoke_hook.call_args_list
)
# Check if on_session_reset was called for the new session
@@ -32,6 +33,7 @@ def test_session_finalize_on_reset(mock_invoke_hook):
c.args == ("on_session_reset",)
and c.kwargs["session_id"] == cli.session_id
and c.kwargs["platform"] == "cli"
+ and c.kwargs["agent_id"] is None
for c in mock_invoke_hook.call_args_list
)
@@ -52,6 +54,7 @@ def test_session_finalize_on_cleanup(mock_invoke_hook):
c.args == ("on_session_finalize",)
and c.kwargs["session_id"] == "cleanup-session-id"
and c.kwargs["platform"] == "cli"
+ and c.kwargs["agent_id"] is None
and c.kwargs["reason"] == "shutdown"
for c in mock_invoke_hook.call_args_list
)
diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py
index 38da3fe408758..939d0f7a6c49a 100644
--- a/tests/cron/test_scheduler.py
+++ b/tests/cron/test_scheduler.py
@@ -85,6 +85,7 @@ def test_origin_delivery_preserves_thread_id(self):
"platform": "telegram",
"chat_id": "-1001",
"thread_id": "17585",
+ "agent_id": None,
}
@pytest.mark.parametrize(
@@ -129,6 +130,7 @@ def test_origin_delivery_without_origin_falls_back_to_supported_home_channels(
"platform": platform,
"chat_id": chat_id,
"thread_id": None,
+ "agent_id": None,
}
def test_bare_matrix_delivery_uses_matrix_home_room(self, monkeypatch):
@@ -139,6 +141,7 @@ def test_bare_matrix_delivery_uses_matrix_home_room(self, monkeypatch):
"platform": "matrix",
"chat_id": "!room123:example.org",
"thread_id": None,
+ "agent_id": None,
}
def test_bare_platform_delivery_preserves_home_thread_id(self, monkeypatch):
@@ -149,6 +152,7 @@ def test_bare_platform_delivery_preserves_home_thread_id(self, monkeypatch):
"platform": "discord",
"chat_id": "parent-42",
"thread_id": "topic-7",
+ "agent_id": None,
}
def test_telegram_cron_thread_id_overrides_home_thread_id(self, monkeypatch):
@@ -161,6 +165,7 @@ def test_telegram_cron_thread_id_overrides_home_thread_id(self, monkeypatch):
"platform": "telegram",
"chat_id": "-1001234567890",
"thread_id": "42",
+ "agent_id": None,
}
def test_telegram_cron_thread_id_sets_thread_when_home_thread_unset(self, monkeypatch):
@@ -173,6 +178,7 @@ def test_telegram_cron_thread_id_sets_thread_when_home_thread_unset(self, monkey
"platform": "telegram",
"chat_id": "-1001234567890",
"thread_id": "42",
+ "agent_id": None,
}
def test_telegram_cron_thread_id_does_not_leak_to_other_platforms(self, monkeypatch):
@@ -185,6 +191,7 @@ def test_telegram_cron_thread_id_does_not_leak_to_other_platforms(self, monkeypa
"platform": "discord",
"chat_id": "parent-42",
"thread_id": "topic-7",
+ "agent_id": None,
}
def test_explicit_telegram_topic_target_overrides_cron_thread_id(self, monkeypatch):
@@ -196,6 +203,7 @@ def test_explicit_telegram_topic_target_overrides_cron_thread_id(self, monkeypat
"platform": "telegram",
"chat_id": "-1003724596514",
"thread_id": "17",
+ "agent_id": None,
}
def test_explicit_telegram_topic_target_with_thread_id(self):
@@ -207,6 +215,7 @@ def test_explicit_telegram_topic_target_with_thread_id(self):
"platform": "telegram",
"chat_id": "-1003724596514",
"thread_id": "17",
+ "agent_id": None,
}
def test_explicit_telegram_topic_thread_survives_bare_directory_match(self):
@@ -223,6 +232,7 @@ def test_explicit_telegram_topic_thread_survives_bare_directory_match(self):
"platform": "telegram",
"chat_id": "-1003724596514",
"thread_id": "17",
+ "agent_id": None,
}
def test_explicit_telegram_chat_id_without_thread_id(self):
@@ -234,6 +244,7 @@ def test_explicit_telegram_chat_id_without_thread_id(self):
"platform": "telegram",
"chat_id": "-1003724596514",
"thread_id": None,
+ "agent_id": None,
}
def test_human_friendly_label_resolved_via_channel_directory(self):
@@ -249,6 +260,7 @@ def test_human_friendly_label_resolved_via_channel_directory(self):
"platform": "whatsapp",
"chat_id": "12345678901234@lid",
"thread_id": None,
+ "agent_id": None,
}
def test_human_friendly_label_without_suffix_resolved(self):
@@ -263,6 +275,7 @@ def test_human_friendly_label_without_suffix_resolved(self):
"platform": "telegram",
"chat_id": "-1009999",
"thread_id": None,
+ "agent_id": None,
}
def test_human_friendly_topic_label_preserves_thread_id(self):
@@ -277,6 +290,7 @@ def test_human_friendly_topic_label_preserves_thread_id(self):
"platform": "telegram",
"chat_id": "-1009999",
"thread_id": "17585",
+ "agent_id": None,
}
def test_raw_id_not_mangled_when_directory_returns_none(self):
@@ -291,6 +305,7 @@ def test_raw_id_not_mangled_when_directory_returns_none(self):
"platform": "whatsapp",
"chat_id": "12345@lid",
"thread_id": None,
+ "agent_id": None,
}
def test_bare_platform_uses_matching_origin_chat(self):
@@ -307,6 +322,7 @@ def test_bare_platform_uses_matching_origin_chat(self):
"platform": "telegram",
"chat_id": "-1001",
"thread_id": "17585",
+ "agent_id": None,
}
def test_bare_platform_falls_back_to_home_channel(self, monkeypatch):
@@ -323,6 +339,7 @@ def test_bare_platform_falls_back_to_home_channel(self, monkeypatch):
"platform": "telegram",
"chat_id": "-2002",
"thread_id": None,
+ "agent_id": None,
}
def test_explicit_discord_topic_target_with_thread_id(self):
@@ -334,6 +351,7 @@ def test_explicit_discord_topic_target_with_thread_id(self):
"platform": "discord",
"chat_id": "-1001234567890",
"thread_id": "17585",
+ "agent_id": None,
}
def test_explicit_discord_chat_id_without_thread_id(self):
@@ -345,6 +363,7 @@ def test_explicit_discord_chat_id_without_thread_id(self):
"platform": "discord",
"chat_id": "9876543210",
"thread_id": None,
+ "agent_id": None,
}
def test_explicit_discord_channel_without_thread(self):
@@ -357,6 +376,7 @@ def test_explicit_discord_channel_without_thread(self):
"platform": "discord",
"chat_id": "1001234567890",
"thread_id": None,
+ "agent_id": None,
}
def test_list_form_deliver_is_normalized(self, monkeypatch):
@@ -377,6 +397,7 @@ def test_list_form_deliver_is_normalized(self, monkeypatch):
"platform": "telegram",
"chat_id": "-4004",
"thread_id": None,
+ "agent_id": None,
}
def test_list_form_multiple_platforms_normalized(self, monkeypatch):
diff --git a/tests/gateway/test_active_session_text_merge.py b/tests/gateway/test_active_session_text_merge.py
index 05e7a36fd6bce..7c8dd7ecbd5b9 100644
--- a/tests/gateway/test_active_session_text_merge.py
+++ b/tests/gateway/test_active_session_text_merge.py
@@ -108,6 +108,11 @@ def _make_adapter() -> BasePlatformAdapter:
adapter._auto_tts_enabled_chats = set()
adapter._auto_tts_disabled_chats = set()
adapter._typing_paused = set()
+ # Multi-agent routing context (set by the real __init__ which this
+ # helper bypasses). handle_message() -> _attach_agent_id() reads these.
+ adapter._gateway_routes = []
+ adapter._default_agent_id = "main"
+ adapter._gateway_ref = None
return adapter
diff --git a/tests/gateway/test_agent_routing.py b/tests/gateway/test_agent_routing.py
new file mode 100644
index 0000000000000..c72e18eee65b4
--- /dev/null
+++ b/tests/gateway/test_agent_routing.py
@@ -0,0 +1,364 @@
+"""Tests for gateway/agent_routing.py — inbound message -> agent_id resolver."""
+
+import pytest
+from gateway.config import Platform
+from gateway.session import SessionSource
+from gateway.agent_routing import resolve_agent_id, _route_matches
+
+
+class TestRouteMatches:
+ def test_platform_match(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert _route_matches({"platform": "telegram"}, source) is True
+
+ def test_platform_mismatch(self):
+ source = SessionSource(platform=Platform.DISCORD, chat_id="123")
+ assert _route_matches({"platform": "telegram"}, source) is False
+
+ def test_chat_id_match(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert _route_matches({"chat_id": "123"}, source) is True
+
+ def test_chat_id_mismatch(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert _route_matches({"chat_id": "456"}, source) is False
+
+ def test_multiple_keys_all_match(self):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="-1001234",
+ thread_id="42",
+ )
+ assert _route_matches(
+ {"platform": "telegram", "chat_id": "-1001234", "thread_id": "42"},
+ source,
+ ) is True
+
+ def test_multiple_keys_one_mismatch(self):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="-1001234",
+ thread_id="42",
+ )
+ assert _route_matches(
+ {"platform": "telegram", "chat_id": "-1001234", "thread_id": "99"},
+ source,
+ ) is False
+
+ def test_empty_match_returns_false(self):
+ """Empty match dict should not match everything — it's a guard rail."""
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert _route_matches({}, source) is False
+
+ def test_unknown_match_key_returns_false(self):
+ """Unknown keys are rejected to catch typos."""
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert _route_matches({"platfrom": "telegram"}, source) is False
+
+ def test_missing_source_attribute_returns_false(self):
+ """If source lacks the attribute, route doesn't match."""
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert _route_matches({"user_id": "456"}, source) is False
+
+ def test_guild_id_match(self):
+ source = SessionSource(
+ platform=Platform.SLACK,
+ chat_id="C123",
+ guild_id="T0ABC",
+ )
+ assert _route_matches({"guild_id": "T0ABC"}, source) is True
+
+ def test_user_id_match(self):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="123",
+ user_id="456",
+ )
+ assert _route_matches({"user_id": "456"}, source) is True
+
+ def test_numeric_chat_id_coerced(self):
+ """Match values are stringified for comparison."""
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345")
+ assert _route_matches({"chat_id": 12345}, source) is True
+
+
+class TestResolveAgentId:
+ def test_no_routes_returns_default(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert resolve_agent_id(source, [], default="main") == "main"
+
+ def test_no_routes_no_default_returns_none(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert resolve_agent_id(source, [], default=None) is None
+
+ def test_first_match_wins(self):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="-1001234",
+ thread_id="42",
+ )
+ routes = [
+ {"match": {"platform": "telegram", "chat_id": "-1001234", "thread_id": "42"}, "agent": "coder"},
+ {"match": {"platform": "telegram", "chat_id": "-1001234"}, "agent": "research"},
+ ]
+ assert resolve_agent_id(source, routes) == "coder"
+
+ def test_falls_through_to_less_specific(self):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="-1001234",
+ thread_id="99",
+ )
+ routes = [
+ {"match": {"platform": "telegram", "chat_id": "-1001234", "thread_id": "42"}, "agent": "coder"},
+ {"match": {"platform": "telegram", "chat_id": "-1001234"}, "agent": "research"},
+ ]
+ assert resolve_agent_id(source, routes) == "research"
+
+ def test_no_match_returns_default(self):
+ source = SessionSource(platform=Platform.DISCORD, chat_id="123")
+ routes = [
+ {"match": {"platform": "telegram"}, "agent": "coder"},
+ ]
+ assert resolve_agent_id(source, routes, default="main") == "main"
+
+ def test_routes_none_treated_as_empty(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert resolve_agent_id(source, None, default="main") == "main"
+
+ def test_invalid_route_entries_skipped(self):
+ """Non-dict routes, missing agent, or missing match are skipped."""
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ routes = [
+ "not-a-dict",
+ {"match": {"platform": "telegram"}}, # missing agent
+ {"agent": "coder"}, # missing match
+ {"match": {"platform": "telegram"}, "agent": "coder"},
+ ]
+ assert resolve_agent_id(source, routes) == "coder"
+
+ def test_empty_agent_string_skipped(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ routes = [
+ {"match": {"platform": "telegram"}, "agent": " "},
+ {"match": {"platform": "telegram"}, "agent": "coder"},
+ ]
+ assert resolve_agent_id(source, routes) == "coder"
+
+ def test_agent_id_stripped(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ routes = [
+ {"match": {"platform": "telegram"}, "agent": " coder "},
+ ]
+ assert resolve_agent_id(source, routes) == "coder"
+
+ def test_slack_workspace_route(self):
+ source = SessionSource(
+ platform=Platform.SLACK,
+ chat_id="C123",
+ guild_id="T0ABC123",
+ )
+ routes = [
+ {"match": {"platform": "slack", "guild_id": "T0ABC123"}, "agent": "coder"},
+ ]
+ assert resolve_agent_id(source, routes) == "coder"
+
+ def test_user_id_route(self):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="123",
+ user_id="999",
+ )
+ routes = [
+ {"match": {"platform": "telegram", "user_id": "999"}, "agent": "vip"},
+ {"match": {"platform": "telegram"}, "agent": "standard"},
+ ]
+ assert resolve_agent_id(source, routes) == "vip"
+
+ def test_declaration_order_matters(self):
+ """More specific routes must be declared before general ones."""
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="-1001234",
+ thread_id="42",
+ )
+ # Wrong order: general first
+ bad_routes = [
+ {"match": {"platform": "telegram", "chat_id": "-1001234"}, "agent": "research"},
+ {"match": {"platform": "telegram", "chat_id": "-1001234", "thread_id": "42"}, "agent": "coder"},
+ ]
+ assert resolve_agent_id(source, bad_routes) == "research"
+
+
+class TestMatrixRouting:
+ """Matrix -> code agent routing (E2E scenario TC-01 / TC-02)."""
+
+ def test_matrix_dm_routes_to_code(self):
+ source = SessionSource(
+ platform=Platform.MATRIX,
+ chat_id="!u00jd7u1b1WqHly1:localhost",
+ chat_type="dm",
+ user_id="@testuser:localhost",
+ )
+ routes = [
+ {"match": {"platform": "wecom"}, "agent": "wecom-agent"},
+ {"match": {"platform": "matrix"}, "agent": "code"},
+ ]
+ assert resolve_agent_id(source, routes, default="main") == "code"
+
+ def test_matrix_room_routes_to_code(self):
+ source = SessionSource(
+ platform=Platform.MATRIX,
+ chat_id="#test-room:localhost",
+ chat_type="channel",
+ user_id="@testuser:localhost",
+ )
+ routes = [
+ {"match": {"platform": "matrix"}, "agent": "code"},
+ ]
+ assert resolve_agent_id(source, routes, default="main") == "code"
+
+ def test_weixin_still_routes_to_main(self):
+ """Regression: weixin must continue using default main agent."""
+ source = SessionSource(
+ platform=Platform.WEIXIN,
+ chat_id="wx123456",
+ chat_type="dm",
+ user_id="wxuser_abc",
+ )
+ routes = [
+ {"match": {"platform": "wecom"}, "agent": "wecom-agent"},
+ {"match": {"platform": "matrix"}, "agent": "code"},
+ ]
+ assert resolve_agent_id(source, routes, default="main") == "main"
+
+ def test_wecom_still_routes_to_wecom_agent(self):
+ """Regression: explicit wecom route must remain intact."""
+ source = SessionSource(
+ platform=Platform.WECOM,
+ chat_id="wc123456",
+ chat_type="dm",
+ user_id="wcuser_abc",
+ )
+ routes = [
+ {"match": {"platform": "wecom"}, "agent": "wecom-agent"},
+ {"match": {"platform": "matrix"}, "agent": "code"},
+ ]
+ assert resolve_agent_id(source, routes, default="main") == "wecom-agent"
+
+ def test_matrix_user_id_specific_route(self):
+ """VIP user on Matrix gets premium agent, others get code."""
+ routes = [
+ {"match": {"platform": "matrix", "user_id": "@boss:localhost"}, "agent": "premium"},
+ {"match": {"platform": "matrix"}, "agent": "code"},
+ ]
+ vip = SessionSource(
+ platform=Platform.MATRIX,
+ chat_id="!room:localhost",
+ user_id="@boss:localhost",
+ )
+ regular = SessionSource(
+ platform=Platform.MATRIX,
+ chat_id="!room:localhost",
+ user_id="@peon:localhost",
+ )
+ assert resolve_agent_id(vip, routes) == "premium"
+ assert resolve_agent_id(regular, routes) == "code"
+
+
+class TestProfileIsolation:
+ """AgentProfile home_dir / ContextVar / session path isolation."""
+
+ def test_agent_profile_resolved_home(self, tmp_path):
+ from agent.profile import AgentProfile
+ profile = AgentProfile(id="code", home_dir=tmp_path / "code")
+ assert profile.resolved_home == tmp_path / "code"
+ assert profile.soul_md_path == tmp_path / "code" / "SOUL.md"
+ assert profile.memory_dir == tmp_path / "code" / "memories"
+ assert profile.skills_dir == tmp_path / "code" / "skills"
+ assert profile.sessions_path == tmp_path / "code" / "sessions.json"
+
+ def test_main_profile_falls_back_to_process_home(self, monkeypatch):
+ from agent.profile import AgentProfile, get_hermes_home
+ monkeypatch.setenv("HERMES_HOME", "/fake/hermes")
+ profile = AgentProfile(id="main")
+ assert profile.resolved_home == get_hermes_home()
+
+ def test_contextvar_scopes_profile(self):
+ from agent.profile import AgentProfile, use_profile, get_active_profile
+ code_profile = AgentProfile(id="code", home_dir="/tmp/code")
+ main_profile = AgentProfile(id="main", home_dir="/tmp/main")
+
+ # Default is None
+ assert get_active_profile() is None
+
+ with use_profile(code_profile):
+ assert get_active_profile() == code_profile
+ # Nested override
+ with use_profile(main_profile):
+ assert get_active_profile() == main_profile
+ assert get_active_profile() == code_profile
+
+ assert get_active_profile() is None
+
+ def test_load_agent_registry_from_config(self):
+ from agent.profile import load_agent_registry, AgentProfile, DEFAULT_AGENT_ID
+
+ class FakeConfig:
+ agents = {
+ "main": {},
+ "code": {
+ "model": "kimi-for-coding",
+ "provider": "moonshot",
+ "home_dir": "/root/.hermes/profiles/code",
+ },
+ "wecom-agent": {
+ "home_dir": "/root/.hermes/profiles/wecom-agent",
+ },
+ }
+
+ registry = load_agent_registry(FakeConfig())
+ assert set(registry.keys()) == {"main", "code", "wecom-agent"}
+ assert registry["code"].model == "kimi-for-coding"
+ assert registry["code"].provider == "moonshot"
+ assert str(registry["code"].home_dir) == "/root/.hermes/profiles/code"
+
+ def test_load_agent_registry_ensures_default(self):
+ from agent.profile import load_agent_registry, DEFAULT_AGENT_ID
+
+ class FakeConfig:
+ agents = {"code": {}}
+
+ registry = load_agent_registry(FakeConfig())
+ assert DEFAULT_AGENT_ID in registry
+
+ def test_load_agent_registry_skips_non_dict_entries(self):
+ from agent.profile import load_agent_registry
+
+ class FakeConfig:
+ agents = {"bad": "not-a-dict", "good": {}}
+
+ registry = load_agent_registry(FakeConfig())
+ assert "bad" not in registry
+ assert "good" in registry
+ assert "main" in registry
+
+
+class TestBackwardCompatibility:
+ """Legacy single-agent installs must see zero behavioral change."""
+
+ def test_no_routes_no_agents_returns_main(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ assert resolve_agent_id(source, [], default="main") == "main"
+
+ def test_session_source_agent_id_field_exists(self):
+ """SessionSource must carry agent_id for downstream session key building."""
+ source = SessionSource(platform=Platform.MATRIX, chat_id="!room:localhost")
+ assert hasattr(source, "agent_id")
+ assert source.agent_id is None # Default before routing
+
+ def test_empty_config_registry_has_main(self):
+ from agent.profile import load_agent_registry, DEFAULT_AGENT_ID
+ registry = load_agent_registry(None)
+ assert DEFAULT_AGENT_ID in registry
+ assert registry[DEFAULT_AGENT_ID].home_dir is None
diff --git a/tests/gateway/test_api_server_routing.py b/tests/gateway/test_api_server_routing.py
new file mode 100644
index 0000000000000..6a0f2cf33f27b
--- /dev/null
+++ b/tests/gateway/test_api_server_routing.py
@@ -0,0 +1,358 @@
+"""Tests for the api_server adapter's multi-agent routing wiring.
+
+Covers ``APIServerAdapter._resolve_agent_profile`` — the bridge between
+inbound ``X-Hermes-Chat-Id`` (etc.) headers and the shared
+``_attach_agent_id`` resolver / ``AgentProfile`` registry.
+
+These tests construct the adapter in-process (no HTTP listener) and feed
+it ``MagicMock``-backed ``aiohttp.web.Request`` stand-ins, mirroring the
+style used elsewhere in ``test_api_server.py``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import MagicMock
+
+import pytest
+
+from agent.profile import AgentProfile, DEFAULT_AGENT_ID, get_active_profile
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.api_server import APIServerAdapter
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_adapter(
+ *,
+ routes=None,
+ registry=None,
+ default_agent: str = "main",
+) -> APIServerAdapter:
+ """Build an APIServerAdapter wired to *routes* and *registry*.
+
+ Mirrors what ``GatewayRunner`` does at startup: calls
+ ``set_routing_context`` with a fake gateway that exposes
+ ``_agent_registry``.
+ """
+ cfg = PlatformConfig()
+ adapter = APIServerAdapter(cfg)
+
+ fake_gateway = MagicMock()
+ fake_gateway._agent_registry = registry or {}
+ adapter.set_routing_context(
+ routes=routes or [],
+ default_agent=default_agent,
+ gateway=fake_gateway,
+ )
+ return adapter
+
+
+def _request_with_headers(headers: dict) -> MagicMock:
+ """Stub aiohttp.web.Request — only ``.headers.get`` is exercised."""
+ req = MagicMock()
+ req.headers = headers
+ return req
+
+
+# ---------------------------------------------------------------------------
+# Header sanitisation
+# ---------------------------------------------------------------------------
+
+
+class TestReadRoutingHeader:
+ def test_returns_value_when_present(self):
+ adapter = _make_adapter()
+ req = _request_with_headers({"X-Hermes-Chat-Id": "calendar-propose"})
+ assert adapter._read_routing_header(req, "X-Hermes-Chat-Id") == "calendar-propose"
+
+ def test_returns_none_when_absent(self):
+ adapter = _make_adapter()
+ req = _request_with_headers({})
+ assert adapter._read_routing_header(req, "X-Hermes-Chat-Id") is None
+
+ def test_strips_whitespace(self):
+ adapter = _make_adapter()
+ req = _request_with_headers({"X-Hermes-Chat-Id": " coder "})
+ assert adapter._read_routing_header(req, "X-Hermes-Chat-Id") == "coder"
+
+ def test_rejects_crlf_injection(self):
+ adapter = _make_adapter()
+ req = _request_with_headers({"X-Hermes-Chat-Id": "ok\r\nX-Injected: yes"})
+ assert adapter._read_routing_header(req, "X-Hermes-Chat-Id") is None
+
+ def test_rejects_overlong_value(self):
+ adapter = _make_adapter()
+ req = _request_with_headers({"X-Hermes-Chat-Id": "x" * 1024})
+ assert adapter._read_routing_header(req, "X-Hermes-Chat-Id") is None
+
+
+# ---------------------------------------------------------------------------
+# _resolve_agent_profile — core routing wiring
+# ---------------------------------------------------------------------------
+
+
+class TestResolveAgentProfile:
+ def test_header_match_routes_to_specified_agent(self, tmp_path):
+ calendar = AgentProfile(id="calendar-propose", home_dir=tmp_path / "calendar")
+ main = AgentProfile(id="main")
+ registry = {"main": main, "calendar-propose": calendar}
+ routes = [
+ {"match": {"platform": "api_server", "chat_id": "calendar-propose"}, "agent": "calendar-propose"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+ req = _request_with_headers({"X-Hermes-Chat-Id": "calendar-propose"})
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ assert agent_id == "calendar-propose"
+ assert profile is calendar
+
+ def test_no_header_falls_through_to_default_agent(self):
+ main = AgentProfile(id="main")
+ calendar = AgentProfile(id="calendar-propose")
+ registry = {"main": main, "calendar-propose": calendar}
+ routes = [
+ {"match": {"platform": "api_server", "chat_id": "calendar-propose"}, "agent": "calendar-propose"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+ req = _request_with_headers({})
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ assert agent_id == "main"
+ assert profile is main
+
+ def test_unmatched_header_falls_through_to_default(self):
+ main = AgentProfile(id="main")
+ calendar = AgentProfile(id="calendar-propose")
+ registry = {"main": main, "calendar-propose": calendar}
+ routes = [
+ {"match": {"platform": "api_server", "chat_id": "calendar-propose"}, "agent": "calendar-propose"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+ req = _request_with_headers({"X-Hermes-Chat-Id": "unknown-agent"})
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ assert agent_id == "main"
+ assert profile is main
+
+ def test_platform_only_route_matches_any_request(self):
+ """A bare ``platform: api_server`` route is the catch-all."""
+ coder = AgentProfile(id="coder")
+ main = AgentProfile(id="main")
+ registry = {"main": main, "coder": coder}
+ routes = [
+ {"match": {"platform": "api_server"}, "agent": "coder"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+ # No headers — platform alone matches.
+ req = _request_with_headers({})
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ assert agent_id == "coder"
+ assert profile is coder
+
+ def test_user_id_route_matches(self):
+ vip = AgentProfile(id="vip")
+ main = AgentProfile(id="main")
+ registry = {"main": main, "vip": vip}
+ routes = [
+ {"match": {"platform": "api_server", "user_id": "alice"}, "agent": "vip"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+ req = _request_with_headers({"X-Hermes-User-Id": "alice"})
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ assert agent_id == "vip"
+ assert profile is vip
+
+ def test_thread_id_route_matches(self):
+ thread_agent = AgentProfile(id="thread-agent")
+ main = AgentProfile(id="main")
+ registry = {"main": main, "thread-agent": thread_agent}
+ routes = [
+ {"match": {"platform": "api_server", "thread_id": "T-42"}, "agent": "thread-agent"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+ req = _request_with_headers({"X-Hermes-Thread-Id": "T-42"})
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ assert agent_id == "thread-agent"
+ assert profile is thread_agent
+
+ def test_more_specific_route_wins_when_declared_first(self):
+ coder = AgentProfile(id="coder")
+ general = AgentProfile(id="general")
+ main = AgentProfile(id="main")
+ registry = {"main": main, "coder": coder, "general": general}
+ routes = [
+ {"match": {"platform": "api_server", "chat_id": "code", "user_id": "alice"}, "agent": "coder"},
+ {"match": {"platform": "api_server", "chat_id": "code"}, "agent": "general"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+ req = _request_with_headers({
+ "X-Hermes-Chat-Id": "code",
+ "X-Hermes-User-Id": "alice",
+ })
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+ assert agent_id == "coder"
+ assert profile is coder
+
+ def test_empty_registry_returns_none_profile(self):
+ """Legacy single-agent install: registry empty → profile is None."""
+ adapter = _make_adapter(routes=[], registry={})
+ req = _request_with_headers({})
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ assert agent_id == "main"
+ assert profile is None
+
+ def test_crlf_header_is_treated_as_absent(self):
+ """A header with control chars must not bypass routing."""
+ main = AgentProfile(id="main")
+ calendar = AgentProfile(id="calendar-propose")
+ registry = {"main": main, "calendar-propose": calendar}
+ routes = [
+ {"match": {"platform": "api_server", "chat_id": "calendar-propose"}, "agent": "calendar-propose"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+ req = _request_with_headers({
+ "X-Hermes-Chat-Id": "calendar-propose\r\nX-Injected: yes",
+ })
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ # Tainted header dropped → no match → default agent.
+ assert agent_id == "main"
+ assert profile is main
+
+ def test_gateway_ref_missing_does_not_raise(self):
+ """No gateway wired (single-agent install never calls
+ set_routing_context with a gateway): profile resolution must
+ still succeed and return ``None``."""
+ cfg = PlatformConfig()
+ adapter = APIServerAdapter(cfg)
+ # No set_routing_context call: routes and gateway_ref remain unset.
+ req = _request_with_headers({"X-Hermes-Chat-Id": "anything"})
+
+ profile, agent_id = adapter._resolve_agent_profile(req)
+
+ # No registry → no profile, default agent_id falls back to "main".
+ assert profile is None
+ assert agent_id == "main"
+
+
+# ---------------------------------------------------------------------------
+# ContextVar isolation under concurrency
+# ---------------------------------------------------------------------------
+
+
+class TestContextVarIsolation:
+ """Two concurrent requests with different chat_ids must route to
+ independent agents without leaking state."""
+
+ def test_concurrent_use_profile_does_not_cross_contaminate(self):
+ """Verify ``use_profile`` honours asyncio task isolation.
+
+ This is the foundation the api_server adapter relies on when
+ wrapping concurrent agent runs. If this test fails, the patch
+ does not actually isolate requests.
+ """
+ from agent.profile import use_profile
+
+ code = AgentProfile(id="code", home_dir="/tmp/code")
+ chat = AgentProfile(id="chat", home_dir="/tmp/chat")
+
+ observed = {"code": None, "chat": None}
+
+ async def _under_profile(name: str, profile: AgentProfile, hold: float) -> None:
+ with use_profile(profile):
+ # Yield to the other task — if profiles leak we'll see it.
+ await asyncio.sleep(hold)
+ observed[name] = get_active_profile()
+
+ async def _main() -> None:
+ await asyncio.gather(
+ _under_profile("code", code, 0.05),
+ _under_profile("chat", chat, 0.01),
+ )
+
+ asyncio.run(_main())
+
+ assert observed["code"] is code
+ assert observed["chat"] is chat
+ # Outer context is restored.
+ assert get_active_profile() is None
+
+ def test_concurrent_resolve_returns_independent_profiles(self, tmp_path):
+ """Run ``_resolve_agent_profile`` from two parallel tasks with
+ different headers and assert each task sees its own profile."""
+ code = AgentProfile(id="code", home_dir=tmp_path / "code")
+ chat = AgentProfile(id="chat", home_dir=tmp_path / "chat")
+ main = AgentProfile(id="main")
+ registry = {"main": main, "code": code, "chat": chat}
+ routes = [
+ {"match": {"platform": "api_server", "chat_id": "code"}, "agent": "code"},
+ {"match": {"platform": "api_server", "chat_id": "chat"}, "agent": "chat"},
+ ]
+ adapter = _make_adapter(routes=routes, registry=registry)
+
+ from agent.profile import use_profile
+
+ observed = {}
+
+ async def _one(label: str, header_value: str, hold: float) -> None:
+ req = _request_with_headers({"X-Hermes-Chat-Id": header_value})
+ profile, agent_id = adapter._resolve_agent_profile(req)
+ with use_profile(profile):
+ await asyncio.sleep(hold)
+ observed[label] = (agent_id, get_active_profile())
+
+ async def _main() -> None:
+ await asyncio.gather(
+ _one("a", "code", 0.05),
+ _one("b", "chat", 0.01),
+ )
+
+ asyncio.run(_main())
+
+ assert observed["a"] == ("code", code)
+ assert observed["b"] == ("chat", chat)
+
+
+# ---------------------------------------------------------------------------
+# Backward compatibility — adapter unchanged for legacy callers
+# ---------------------------------------------------------------------------
+
+
+class TestBackwardCompatibility:
+ def test_routing_constants_match_documented_headers(self):
+ """Header names are part of the public contract — guard against
+ silent renames."""
+ assert APIServerAdapter._AGENT_CHAT_ID_HEADER == "X-Hermes-Chat-Id"
+ assert APIServerAdapter._AGENT_USER_ID_HEADER == "X-Hermes-User-Id"
+ assert APIServerAdapter._AGENT_THREAD_ID_HEADER == "X-Hermes-Thread-Id"
+
+ def test_legacy_no_routes_no_agents_returns_main(self):
+ """Existing single-agent installs: no agents config, no routes →
+ every request resolves to ``main`` with a ``None`` profile (i.e.
+ the legacy ``HERMES_HOME`` env-driven path)."""
+ adapter = _make_adapter(routes=[], registry={})
+ req = _request_with_headers({"X-Hermes-Chat-Id": "ignored"})
+ profile, agent_id = adapter._resolve_agent_profile(req)
+ assert agent_id == "main"
+ assert profile is None
+
+ def test_default_agent_id_constant_is_main(self):
+ """Anchor the default-agent contract."""
+ assert DEFAULT_AGENT_ID == "main"
diff --git a/tests/gateway/test_profile_overrides.py b/tests/gateway/test_profile_overrides.py
new file mode 100644
index 0000000000000..19bfe5981d0c4
--- /dev/null
+++ b/tests/gateway/test_profile_overrides.py
@@ -0,0 +1,193 @@
+"""Tests for per-agent profile runtime / toolset overrides in GatewayRunner.
+
+These cover the wiring that lets ``AgentProfile.model / provider /
+enabled_toolsets / disabled_toolsets`` actually take effect at AIAgent
+construction time — without this wiring those fields are dead-letter.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from agent.profile import AgentProfile, use_profile
+from gateway.run import GatewayRunner
+
+
+def _bound_method(name):
+ """Return the unbound GatewayRunner method to invoke against a stub instance."""
+ return getattr(GatewayRunner, name)
+
+
+# =========================================================================
+# _apply_profile_toolsets
+# =========================================================================
+
+
+class TestApplyProfileToolsets:
+ def test_no_active_profile_is_passthrough(self):
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_toolsets")
+ enabled, disabled = fn(runner, ["a", "b"], ["c"])
+ assert enabled == ["a", "b"]
+ assert disabled == ["c"]
+
+ def test_default_main_profile_is_passthrough(self):
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_toolsets")
+ with use_profile(AgentProfile(id="main", enabled_toolsets=["x"])):
+ enabled, disabled = fn(runner, ["a"], ["b"])
+ # main is the default — never overrides, to preserve legacy behavior.
+ assert enabled == ["a"]
+ assert disabled == ["b"]
+
+ def test_non_main_profile_enabled_toolsets_overrides(self):
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_toolsets")
+ profile = AgentProfile(id="coder", enabled_toolsets=["filesystem", "terminal"])
+ with use_profile(profile):
+ enabled, disabled = fn(runner, ["a", "b"], ["c"])
+ assert enabled == ["filesystem", "terminal"]
+ assert disabled == ["c"] # untouched — profile only set enabled
+
+ def test_non_main_profile_disabled_toolsets_overrides(self):
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_toolsets")
+ profile = AgentProfile(id="research", disabled_toolsets=["terminal"])
+ with use_profile(profile):
+ enabled, disabled = fn(runner, ["a"], ["c"])
+ assert enabled == ["a"]
+ assert disabled == ["terminal"]
+
+ def test_profile_none_fields_preserve_defaults(self):
+ """Profile with all-None toolset fields is identity."""
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_toolsets")
+ with use_profile(AgentProfile(id="coder")):
+ enabled, disabled = fn(runner, ["a"], ["b"])
+ assert enabled == ["a"]
+ assert disabled == ["b"]
+
+ def test_empty_list_is_explicit_override(self):
+ """An empty list is an explicit choice, not None — must override."""
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_toolsets")
+ with use_profile(AgentProfile(id="coder", enabled_toolsets=[])):
+ enabled, _ = fn(runner, ["a", "b"], None)
+ assert enabled == [] # explicit empty wins
+
+
+# =========================================================================
+# _apply_profile_runtime_overrides
+# =========================================================================
+
+
+class TestApplyProfileRuntimeOverrides:
+ def test_no_active_profile_is_passthrough(self):
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_runtime_overrides")
+ model, runtime = fn(runner, "gateway-model", {"provider": "anthropic"})
+ assert model == "gateway-model"
+ assert runtime == {"provider": "anthropic"}
+
+ def test_default_main_profile_is_passthrough(self):
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_runtime_overrides")
+ with use_profile(AgentProfile(id="main", model="should-not-win")):
+ model, runtime = fn(runner, "gateway-model", {"provider": "anthropic"})
+ assert model == "gateway-model"
+
+ def test_profile_model_overrides_when_provider_unset(self):
+ """When the profile pins only ``model`` (no provider/key/base_url),
+ the model swaps but the gateway runtime credentials are preserved."""
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_runtime_overrides")
+ with use_profile(AgentProfile(id="coder", model="anthropic/claude-opus-4-7")):
+ model, runtime = fn(runner, "gateway-model", {"provider": "anthropic", "api_key": "k"})
+ assert model == "anthropic/claude-opus-4-7"
+ assert runtime == {"provider": "anthropic", "api_key": "k"}
+
+ def test_profile_provider_triggers_runtime_resolution(self, monkeypatch):
+ """Setting ``profile.provider`` re-resolves runtime via
+ ``resolve_runtime_provider`` so api_mode/base_url stay consistent."""
+ called_with = {}
+
+ def fake_resolve(*, requested, explicit_api_key, explicit_base_url, target_model):
+ called_with.update(
+ requested=requested,
+ explicit_api_key=explicit_api_key,
+ explicit_base_url=explicit_base_url,
+ target_model=target_model,
+ )
+ return {
+ "provider": "openai",
+ "api_key": "resolved-key",
+ "base_url": "https://api.openai.com",
+ "api_mode": "chat_completions",
+ }
+
+ monkeypatch.setattr(
+ "hermes_cli.runtime_provider.resolve_runtime_provider", fake_resolve
+ )
+
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_runtime_overrides")
+ profile = AgentProfile(
+ id="coder",
+ model="gpt-5",
+ provider="openai",
+ base_url="https://api.openai.com",
+ )
+ with use_profile(profile):
+ model, runtime = fn(runner, "gateway-model", {"provider": "anthropic", "api_key": "old"})
+
+ assert model == "gpt-5"
+ assert runtime["provider"] == "openai"
+ assert runtime["api_key"] == "resolved-key"
+ assert runtime["base_url"] == "https://api.openai.com"
+ assert runtime["api_mode"] == "chat_completions"
+ assert called_with["requested"] == "openai"
+ assert called_with["explicit_base_url"] == "https://api.openai.com"
+ assert called_with["target_model"] == "gpt-5"
+
+ def test_api_key_env_reads_from_environment(self, monkeypatch):
+ captured = {}
+
+ def fake_resolve(*, requested, explicit_api_key, explicit_base_url, target_model):
+ captured["explicit_api_key"] = explicit_api_key
+ return {"provider": "anthropic", "api_key": explicit_api_key, "base_url": None, "api_mode": None}
+
+ monkeypatch.setattr(
+ "hermes_cli.runtime_provider.resolve_runtime_provider", fake_resolve
+ )
+ monkeypatch.setenv("CODER_AGENT_KEY", "sk-coder-secret")
+
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_runtime_overrides")
+ profile = AgentProfile(id="coder", provider="anthropic", api_key_env="CODER_AGENT_KEY")
+ with use_profile(profile):
+ fn(runner, "m", {})
+
+ assert captured["explicit_api_key"] == "sk-coder-secret"
+
+ def test_resolve_failure_falls_back_to_gateway_runtime(self, monkeypatch):
+ """If provider resolution raises, keep the gateway runtime — never
+ return half-broken credentials to AIAgent."""
+
+ def boom(**_kw):
+ raise RuntimeError("auth pool empty")
+
+ monkeypatch.setattr(
+ "hermes_cli.runtime_provider.resolve_runtime_provider", boom
+ )
+
+ runner = MagicMock(spec=GatewayRunner)
+ fn = _bound_method("_apply_profile_runtime_overrides")
+ profile = AgentProfile(id="coder", model="gpt-5", provider="openai")
+ gateway_runtime = {"provider": "anthropic", "api_key": "gateway-key"}
+ with use_profile(profile):
+ model, runtime = fn(runner, "gw-model", gateway_runtime)
+ # Model already pinned by profile before resolve was attempted — keep it.
+ assert model == "gpt-5"
+ assert runtime is gateway_runtime # untouched fallback
diff --git a/tests/gateway/test_reasoning_command.py b/tests/gateway/test_reasoning_command.py
index f22704dedf672..d04608260d0f5 100644
--- a/tests/gateway/test_reasoning_command.py
+++ b/tests/gateway/test_reasoning_command.py
@@ -75,6 +75,9 @@ async def test_reasoning_in_help_output(self):
def test_reasoning_is_known_command(self):
source = inspect.getsource(gateway_run.GatewayRunner._handle_message)
+ inner = getattr(gateway_run.GatewayRunner, "_handle_message_inner", None)
+ if inner is not None:
+ source += inspect.getsource(inner)
assert '"reasoning"' in source
def test_parse_reasoning_command_args_accepts_ascii_and_smart_global_flags(self):
diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py
index 6e2c39f797277..8f3c5c55a4aca 100644
--- a/tests/gateway/test_session.py
+++ b/tests/gateway/test_session.py
@@ -1238,3 +1238,116 @@ def flaky_encode(cls, content):
"before user",
"before assistant",
]
+
+
+class TestBuildSessionKeyAgentId:
+ """build_session_key must prefix with agent: when source.agent_id is set."""
+
+ def test_default_agent_id_is_main(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ key = build_session_key(source)
+ assert key.startswith("agent:main:")
+
+ def test_explicit_agent_id(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ source.agent_id = "coder"
+ key = build_session_key(source)
+ assert key == "agent:coder:telegram:dm:123"
+
+ def test_agent_id_none_defaults_to_main(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ source.agent_id = None
+ key = build_session_key(source)
+ assert key == "agent:main:telegram:dm:123"
+
+ def test_agent_id_empty_string_defaults_to_main(self):
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ source.agent_id = ""
+ key = build_session_key(source)
+ assert key == "agent:main:telegram:dm:123"
+
+ def test_dm_with_agent_id(self):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="99",
+ chat_type="dm",
+ thread_id="topic-1",
+ )
+ source.agent_id = "coder"
+ key = build_session_key(source)
+ assert key == "agent:coder:telegram:dm:99:topic-1"
+
+ def test_group_with_agent_id(self):
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ chat_id="guild-123",
+ chat_type="group",
+ user_id="alice",
+ )
+ source.agent_id = "research"
+ key = build_session_key(source)
+ assert key == "agent:research:discord:group:guild-123:alice"
+
+ def test_group_thread_with_agent_id(self):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="-1002285219667",
+ chat_type="group",
+ thread_id="17585",
+ )
+ source.agent_id = "coder"
+ key = build_session_key(source)
+ assert key == "agent:coder:telegram:group:-1002285219667:17585"
+
+ def test_whatsapp_dm_with_agent_id(self):
+ source = SessionSource(
+ platform=Platform.WHATSAPP,
+ chat_id="15551234567@s.whatsapp.net",
+ chat_type="dm",
+ )
+ source.agent_id = "wecom-agent"
+ key = build_session_key(source)
+ assert key == "agent:wecom-agent:whatsapp:dm:15551234567"
+
+ def test_shared_group_with_agent_id(self):
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ chat_id="guild-123",
+ chat_type="group",
+ )
+ source.agent_id = "main"
+ key = build_session_key(source, group_sessions_per_user=False)
+ assert key == "agent:main:discord:group:guild-123"
+
+ def test_distinct_agents_same_chat_get_distinct_keys(self):
+ """Same chat, different agent_id → different session keys."""
+ base = SessionSource(platform=Platform.TELEGRAM, chat_id="123", chat_type="dm")
+ base.agent_id = "coder"
+ key_coder = build_session_key(base)
+
+ base.agent_id = "research"
+ key_research = build_session_key(base)
+
+ assert key_coder == "agent:coder:telegram:dm:123"
+ assert key_research == "agent:research:telegram:dm:123"
+ assert key_coder != key_research
+
+ def test_session_source_roundtrip_with_agent_id(self):
+ """agent_id should survive to_dict/from_dict roundtrip."""
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id="123",
+ agent_id="coder",
+ )
+ d = source.to_dict()
+ assert d["agent_id"] == "coder"
+ restored = SessionSource.from_dict(d)
+ assert restored.agent_id == "coder"
+
+ def test_session_source_roundtrip_without_agent_id(self):
+ """agent_id omitted from dict → restored as None."""
+ source = SessionSource(platform=Platform.TELEGRAM, chat_id="123")
+ d = source.to_dict()
+ assert "agent_id" not in d
+ restored = SessionSource.from_dict(d)
+ assert restored.agent_id is None
diff --git a/tests/gateway/test_session_boundary_hooks.py b/tests/gateway/test_session_boundary_hooks.py
index 9831e636c2025..d3e3cad77cdd2 100644
--- a/tests/gateway/test_session_boundary_hooks.py
+++ b/tests/gateway/test_session_boundary_hooks.py
@@ -85,6 +85,7 @@ async def test_reset_fires_finalize_hook(mock_invoke_hook):
c.args == ("on_session_finalize",)
and c.kwargs["session_id"] == "sess-old"
and c.kwargs["platform"] == "telegram"
+ and c.kwargs["agent_id"] is None
and c.kwargs["old_session_id"] == "sess-old"
and c.kwargs["new_session_id"] == "sess-new"
for c in mock_invoke_hook.call_args_list
@@ -103,6 +104,7 @@ async def test_reset_fires_reset_hook(mock_invoke_hook):
c.args == ("on_session_reset",)
and c.kwargs["session_id"] == "sess-new"
and c.kwargs["platform"] == "telegram"
+ and c.kwargs["agent_id"] is None
and c.kwargs["old_session_id"] == "sess-old"
and c.kwargs["new_session_id"] == "sess-new"
for c in mock_invoke_hook.call_args_list
diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py
index 17b6fbe710298..b0f0e94ebc556 100644
--- a/tests/gateway/test_title_command.py
+++ b/tests/gateway/test_title_command.py
@@ -205,6 +205,9 @@ def test_title_is_known_command(self):
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
+ inner = getattr(GatewayRunner, "_handle_message_inner", None)
+ if inner is not None:
+ source += inspect.getsource(inner)
assert '"title"' in source
diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py
index 6ff37c0fbed43..09402bb8b511c 100644
--- a/tests/gateway/test_update_command.py
+++ b/tests/gateway/test_update_command.py
@@ -707,9 +707,14 @@ async def test_update_in_help_output(self):
def test_update_is_known_command(self):
"""The /update command is in the help text (proxy for _known_commands)."""
- # _known_commands is local to _handle_message, so we verify by
- # checking the help output includes it.
+ # _known_commands is local to the message-handling pipeline; verify by
+ # grepping the message-handler bodies. Multi-agent split the body
+ # into ``_handle_message`` (ContextVar plumbing) and
+ # ``_handle_message_inner`` (legacy logic), so we accept either.
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
+ inner = getattr(GatewayRunner, "_handle_message_inner", None)
+ if inner is not None:
+ source += inspect.getsource(inner)
assert '"update"' in source
diff --git a/tests/hermes_cli/test_agent_cli.py b/tests/hermes_cli/test_agent_cli.py
new file mode 100644
index 0000000000000..abf1bbf5675cf
--- /dev/null
+++ b/tests/hermes_cli/test_agent_cli.py
@@ -0,0 +1,403 @@
+"""Tests for the ``hermes agent`` CLI subcommand surface.
+
+Covers ``list``, ``show``, ``add``, and ``remove`` operations on the
+``agents:`` and ``routes:`` sections of config.yaml.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+from typing import Any, Dict
+from unittest.mock import MagicMock
+
+import pytest
+
+_WORKTREE = Path(__file__).resolve().parents[2]
+if str(_WORKTREE) not in sys.path:
+ sys.path.insert(0, str(_WORKTREE))
+
+from hermes_cli import agent as agent_mod
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def fresh_home(tmp_path, monkeypatch):
+ """Isolated HERMES_HOME with no prior config state."""
+ home = tmp_path / "hermes_home"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ # Reset hermes_constants cache so get_hermes_home() re-reads.
+ try:
+ import hermes_constants
+ hermes_constants._cached_hermes_home = None # type: ignore[attr-defined]
+ except Exception:
+ pass
+ return home
+
+
+@pytest.fixture
+def sample_cfg() -> Dict[str, Any]:
+ return {
+ "default_agent": "main",
+ "agents": {
+ "main": {},
+ "coder": {
+ "model": "anthropic/claude-opus-4-6",
+ "home_dir": "~/.hermes/profiles/coder",
+ "enabled_toolsets": ["filesystem", "terminal"],
+ },
+ "research": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
+ },
+ "routes": [
+ {"match": {"platform": "telegram", "chat_id": "-1001234"}, "agent": "coder"},
+ {"match": {"platform": "slack", "guild_id": "T0ABC"}, "agent": "coder"},
+ {"match": {"platform": "matrix"}, "agent": "research"},
+ ],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _args(**kwargs) -> argparse.Namespace:
+ """Build a minimal argparse.Namespace from keyword args."""
+ return argparse.Namespace(**kwargs)
+
+
+# ---------------------------------------------------------------------------
+# cmd_agent_list
+# ---------------------------------------------------------------------------
+
+class TestAgentList:
+ def test_empty_config_shows_message(self, fresh_home, monkeypatch, capsys):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: {})
+ rv = agent_mod.cmd_agent_list(_args())
+ assert rv == 0
+ out = capsys.readouterr().out
+ assert "No agents configured" in out
+
+ def test_lists_all_agents(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg)
+ rv = agent_mod.cmd_agent_list(_args())
+ assert rv == 0
+ out = capsys.readouterr().out
+ assert "main" in out
+ assert "coder" in out
+ assert "research" in out
+ assert "anthropic/claude-opus-4-6" in out
+ assert "anthropic/claude-sonnet-4-6" in out
+
+ def test_default_marker(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg)
+ rv = agent_mod.cmd_agent_list(_args())
+ out = capsys.readouterr().out
+ # main is the default, so it should have the "*" marker
+ lines = out.splitlines()
+ main_line = [l for l in lines if l.strip().startswith("*") and "main" in l]
+ assert main_line, "default agent should be marked with *"
+
+ def test_route_counts(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg)
+ rv = agent_mod.cmd_agent_list(_args())
+ out = capsys.readouterr().out
+ # coder has 2 routes, research has 1, main has 0
+ lines = [l for l in out.splitlines() if "coder" in l or "research" in l]
+ coder_line = [l for l in lines if "coder" in l][0]
+ research_line = [l for l in lines if "research" in l][0]
+ assert "2" in coder_line
+ assert "1" in research_line
+
+
+# ---------------------------------------------------------------------------
+# cmd_agent_show
+# ---------------------------------------------------------------------------
+
+class TestAgentShow:
+ def test_existing_agent(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg)
+ rv = agent_mod.cmd_agent_show(_args(agent_id="coder"))
+ assert rv == 0
+ out = capsys.readouterr().out
+ assert "Agent: coder" in out
+ assert "anthropic/claude-opus-4-6" in out
+ assert "memories" in out
+ assert "skills" in out
+ assert "Sessions" in out
+
+ def test_shows_routes(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg)
+ rv = agent_mod.cmd_agent_show(_args(agent_id="coder"))
+ out = capsys.readouterr().out
+ assert "Routes (2)" in out
+ assert "platform=telegram" in out
+ assert "platform=slack" in out
+
+ def test_missing_agent(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg)
+ rv = agent_mod.cmd_agent_show(_args(agent_id="nonexistent"))
+ assert rv == 1
+ out = capsys.readouterr().out
+ assert "not found" in out
+
+ def test_uses_default_home_dir(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ """Agent without home_dir should show the default HERMES_HOME path."""
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg)
+ rv = agent_mod.cmd_agent_show(_args(agent_id="main"))
+ out = capsys.readouterr().out
+ # main has no home_dir, so it should show the default
+ assert str(fresh_home) in out
+
+
+# ---------------------------------------------------------------------------
+# cmd_agent_add
+# ---------------------------------------------------------------------------
+
+class TestAgentAdd:
+ def test_add_basic(self, fresh_home, monkeypatch, capsys):
+ saved = {}
+
+ def fake_load():
+ return saved.copy() if saved else {}
+
+ def fake_save(cfg):
+ nonlocal saved
+ saved = cfg
+
+ monkeypatch.setattr(agent_mod, "load_config", fake_load)
+ monkeypatch.setattr(agent_mod, "save_config", fake_save)
+
+ rv = agent_mod.cmd_agent_add(_args(
+ agent_id="reviewer",
+ model="gpt-4",
+ provider="openai",
+ home_dir=None,
+ enabled_toolsets=None,
+ from_profile=None,
+ ))
+ assert rv == 0
+ out = capsys.readouterr().out
+ assert "added" in out
+ assert saved["agents"]["reviewer"]["model"] == "gpt-4"
+ assert saved["agents"]["reviewer"]["provider"] == "openai"
+
+ def test_add_with_toolsets(self, fresh_home, monkeypatch, capsys):
+ saved = {}
+
+ def fake_load():
+ return saved.copy() if saved else {}
+
+ def fake_save(cfg):
+ nonlocal saved
+ saved = cfg
+
+ monkeypatch.setattr(agent_mod, "load_config", fake_load)
+ monkeypatch.setattr(agent_mod, "save_config", fake_save)
+
+ rv = agent_mod.cmd_agent_add(_args(
+ agent_id="ops",
+ model=None,
+ provider=None,
+ home_dir=None,
+ enabled_toolsets="terminal,k8s",
+ from_profile=None,
+ ))
+ assert rv == 0
+ assert saved["agents"]["ops"]["enabled_toolsets"] == ["terminal", "k8s"]
+
+ def test_rejects_invalid_id(self, fresh_home, monkeypatch, capsys):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: {})
+ rv = agent_mod.cmd_agent_add(_args(
+ agent_id="bad id!",
+ model=None,
+ provider=None,
+ home_dir=None,
+ enabled_toolsets=None,
+ from_profile=None,
+ ))
+ assert rv == 1
+ out = capsys.readouterr().out
+ assert "Invalid agent ID" in out
+
+ def test_rejects_duplicate(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg.copy())
+ rv = agent_mod.cmd_agent_add(_args(
+ agent_id="coder",
+ model=None,
+ provider=None,
+ home_dir=None,
+ enabled_toolsets=None,
+ from_profile=None,
+ ))
+ assert rv == 1
+ out = capsys.readouterr().out
+ assert "already exists" in out
+
+ def test_clone_from_profile(self, fresh_home, monkeypatch, capsys):
+ """Clone from an existing profile directory."""
+ # Create a source profile
+ src = fresh_home / "profiles" / "main"
+ src.mkdir(parents=True)
+ (src / "SOUL.md").write_text("# Main SOUL")
+
+ saved = {"agents": {}, "routes": [], "default_agent": "main"}
+
+ def fake_load():
+ return saved.copy()
+
+ def fake_save(cfg):
+ nonlocal saved
+ saved = cfg
+
+ # Patch the module-level load_config that _load_config() calls
+ import hermes_cli.config
+ monkeypatch.setattr(hermes_cli.config, "load_config", fake_load)
+ monkeypatch.setattr(agent_mod, "save_config", fake_save)
+
+ rv = agent_mod.cmd_agent_add(_args(
+ agent_id="cloned",
+ model=None,
+ provider=None,
+ home_dir=None,
+ enabled_toolsets=None,
+ from_profile="main",
+ ))
+ assert rv == 0
+ out = capsys.readouterr().out
+ assert "Cloned" in out
+ dst = fresh_home / "profiles" / "cloned"
+ assert dst.exists()
+ assert (dst / "SOUL.md").exists()
+ assert saved["agents"]["cloned"]["home_dir"] == str(dst)
+
+ def test_clone_missing_source(self, fresh_home, monkeypatch, capsys):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: {})
+ rv = agent_mod.cmd_agent_add(_args(
+ agent_id="cloned",
+ model=None,
+ provider=None,
+ home_dir=None,
+ enabled_toolsets=None,
+ from_profile="nonexistent",
+ ))
+ assert rv == 1
+ out = capsys.readouterr().out
+ assert "not found" in out
+
+
+# ---------------------------------------------------------------------------
+# cmd_agent_remove
+# ---------------------------------------------------------------------------
+
+class TestAgentRemove:
+ def test_remove_existing(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ saved = sample_cfg.copy()
+
+ def fake_load():
+ return saved.copy()
+
+ def fake_save(cfg):
+ nonlocal saved
+ saved = cfg
+
+ monkeypatch.setattr(agent_mod, "load_config", fake_load)
+ monkeypatch.setattr(agent_mod, "save_config", fake_save)
+
+ rv = agent_mod.cmd_agent_remove(_args(agent_id="research", yes=True))
+ assert rv == 0
+ out = capsys.readouterr().out
+ assert "removed" in out
+ assert "research" not in saved["agents"]
+ # Its route should also be cleaned up
+ assert not any(r.get("agent") == "research" for r in saved["routes"])
+
+ def test_cannot_remove_main(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg.copy())
+ rv = agent_mod.cmd_agent_remove(_args(agent_id="main", yes=True))
+ assert rv == 1
+ out = capsys.readouterr().out
+ assert "Cannot remove" in out
+
+ def test_warns_about_orphaned_routes(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg.copy())
+ # Without --yes, should warn and refuse
+ rv = agent_mod.cmd_agent_remove(_args(agent_id="coder", yes=False))
+ assert rv == 1
+ out = capsys.readouterr().out
+ assert "Warning" in out
+ assert "route(s) reference" in out
+ assert "Use --yes" in out
+
+ def test_missing_agent(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ monkeypatch.setattr(agent_mod, "load_config", lambda: sample_cfg.copy())
+ rv = agent_mod.cmd_agent_remove(_args(agent_id="ghost", yes=True))
+ assert rv == 1
+ out = capsys.readouterr().out
+ assert "not found" in out
+
+ def test_removal_cleans_routes(self, fresh_home, monkeypatch, capsys, sample_cfg):
+ saved = sample_cfg.copy()
+
+ def fake_load():
+ return saved.copy()
+
+ def fake_save(cfg):
+ nonlocal saved
+ saved = cfg
+
+ monkeypatch.setattr(agent_mod, "load_config", fake_load)
+ monkeypatch.setattr(agent_mod, "save_config", fake_save)
+
+ # coder has 2 routes; after removal they should be gone
+ assert sum(1 for r in saved["routes"] if r.get("agent") == "coder") == 2
+ rv = agent_mod.cmd_agent_remove(_args(agent_id="coder", yes=True))
+ assert rv == 0
+ assert not any(r.get("agent") == "coder" for r in saved["routes"])
+
+
+# ---------------------------------------------------------------------------
+# _ensure_agent_section
+# ---------------------------------------------------------------------------
+
+class TestEnsureAgentSection:
+ def test_creates_missing_keys(self):
+ cfg = {}
+ result = agent_mod._ensure_agent_section(cfg)
+ assert "agents" in result
+ assert "routes" in result
+ assert result["default_agent"] == "main"
+
+ def test_preserves_existing_values(self):
+ cfg = {"agents": {"x": {}}, "routes": [{"agent": "x"}], "default_agent": "x"}
+ result = agent_mod._ensure_agent_section(cfg)
+ assert result["default_agent"] == "x"
+ assert "x" in result["agents"]
+
+
+# ---------------------------------------------------------------------------
+# _summarize_soul
+# ---------------------------------------------------------------------------
+
+class TestSummarizeSoul:
+ def test_missing_file(self, tmp_path):
+ assert agent_mod._summarize_soul(tmp_path / "nope.md") == "(no SOUL.md)"
+
+ def test_extracts_content(self, tmp_path):
+ p = tmp_path / "SOUL.md"
+ p.write_text("# Title\n\nYou are a helpful assistant.\n\nMore text here.")
+ summary = agent_mod._summarize_soul(p)
+ assert "helpful assistant" in summary
+
+ def test_skips_comments_and_empty(self, tmp_path):
+ p = tmp_path / "SOUL.md"
+ p.write_text("# Header\n \n \nReal content here.")
+ summary = agent_mod._summarize_soul(p, max_lines=1)
+ assert "Real content" in summary
+ assert "#" not in summary
diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py
index f1a5b510cb226..193cc56ce77e6 100644
--- a/tests/test_model_tools.py
+++ b/tests/test_model_tools.py
@@ -64,6 +64,7 @@ def test_tool_hooks_receive_session_and_tool_call_ids(self):
tool_call_id="call-1",
turn_id="",
api_request_id="",
+ agent_id=None,
),
call(
"post_tool_call",
@@ -79,6 +80,7 @@ def test_tool_hooks_receive_session_and_tool_call_ids(self):
status="ok",
error_type=None,
error_message=None,
+ agent_id=None,
),
call(
"transform_tool_result",
@@ -94,6 +96,7 @@ def test_tool_hooks_receive_session_and_tool_call_ids(self):
status="ok",
error_type=None,
error_message=None,
+ agent_id=None,
),
]
diff --git a/tools/approval.py b/tools/approval.py
index f853b6b578869..de3e763762a85 100644
--- a/tools/approval.py
+++ b/tools/approval.py
@@ -65,7 +65,15 @@ def _fire_approval_hook(hook_name: str, **kwargs) -> None:
try:
kwargs.setdefault("turn_id", _approval_turn_id.get())
kwargs.setdefault("tool_call_id", _approval_tool_call_id.get())
- invoke_hook(hook_name, **kwargs)
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
+ invoke_hook(hook_name, agent_id=_agent_id, **kwargs)
except Exception as exc:
# invoke_hook() already swallows per-callback errors, so reaching here
# means the dispatch layer itself failed. Log and move on -- approval
diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py
index d696cab41a94c..567b92211f748 100644
--- a/tools/delegate_tool.py
+++ b/tools/delegate_tool.py
@@ -985,8 +985,10 @@ def _build_child_agent(
effective_model_for_cb = model or getattr(parent_agent, "model", None)
# Build progress callback to relay tool calls to parent display.
- # Identity kwargs thread the subagent_id through every emitted event so the
- # TUI can reconstruct the spawn tree and route per-branch controls.
+ # NOTE: `subagent_id` here is the TUI spawn-tree identity used to route
+ # per-branch controls in the CLI display. It is distinct from the
+ # `agent_id` kwarg used in invoke_hook() calls (PR #25660 multi-agent
+ # routing). Both are threaded through events but serve different consumers.
child_progress_cb = _build_child_progress_callback(
task_index,
goal,
@@ -2264,6 +2266,14 @@ def delegate_task(
from hermes_cli.plugins import invoke_hook as _invoke_hook
except Exception:
_invoke_hook = None
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
# Aggregate child spend here so the parent's footer/UI reflect the true
# cost of a subagent-heavy turn. Port of Kilo-Org/kilocode#9448. Each
# child's cost was captured in _run_single_child before its AIAgent was
@@ -2296,6 +2306,7 @@ def delegate_task(
child_summary=entry.get("summary"),
child_status=entry.get("status"),
duration_ms=int((entry.get("duration_seconds") or 0) * 1000),
+ agent_id=_agent_id,
)
except Exception:
logger.debug("subagent_stop hook invocation failed", exc_info=True)
diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py
index 14577b9bd8d26..fe257d630f37b 100644
--- a/tools/terminal_tool.py
+++ b/tools/terminal_tool.py
@@ -2300,6 +2300,14 @@ def terminal_tool(
# The hook is fail-open, and the first valid string return wins.
try:
from hermes_cli.plugins import invoke_hook
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
hook_results = invoke_hook(
"transform_terminal_output",
command=command,
@@ -2307,6 +2315,7 @@ def terminal_tool(
returncode=returncode,
task_id=effective_task_id or "",
env_type=env_type,
+ agent_id=_agent_id,
)
for hook_result in hook_results:
if isinstance(hook_result, str):
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index c9de380b63358..308c77f2ede6b 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -278,8 +278,15 @@ def _notify_session_boundary(event_type: str, session_id: str | None) -> None:
"""Fire session lifecycle hooks with CLI parity."""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
-
- _invoke_hook(event_type, session_id=session_id, platform="tui")
+ _agent_id = None
+ try:
+ from agent.profile import get_active_profile
+ _p = get_active_profile()
+ if _p:
+ _agent_id = _p.id
+ except Exception:
+ pass
+ _invoke_hook(event_type, session_id=session_id, platform="tui", agent_id=_agent_id)
except Exception:
pass
diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md
index ff40628544f3b..edf7928bd5678 100644
--- a/website/docs/user-guide/messaging/index.md
+++ b/website/docs/user-guide/messaging/index.md
@@ -552,6 +552,7 @@ Defaults to `false`. Only platforms whose adapter implements `delete_message` ho
## Next Steps
+- [Multi-Agent Routing](multi-agent.md) — Run multiple isolated agents from one gateway
- [Telegram Setup](telegram.md)
- [Discord Setup](discord.md)
- [Slack Setup](slack.md)
diff --git a/website/docs/user-guide/messaging/multi-agent.md b/website/docs/user-guide/messaging/multi-agent.md
new file mode 100644
index 0000000000000..ccee15b618672
--- /dev/null
+++ b/website/docs/user-guide/messaging/multi-agent.md
@@ -0,0 +1,236 @@
+---
+sidebar_position: 30
+title: "Multi-Agent Routing"
+description: "Run multiple isolated AI agents from a single gateway process — each with its own model, personality, memory, and skills"
+---
+
+# Multi-Agent Routing
+
+Run multiple isolated AI agents from a single gateway process. Each agent can have its own **model**, **system prompt** (SOUL.md), **memory**, **skills**, and **sessions** — all routed automatically based on where the message comes from.
+
+## Why Multi-Agent?
+
+- **Specialized personalities** — a coding agent with Opus, a research agent with Sonnet, a creative agent with GPT-5, all in one gateway
+- **Memory isolation** — each agent's MEMORY.md and USER.md are separate; they don't leak knowledge across personalities
+- **Skill isolation** — skills created by one agent don't clutter another's context
+- **Cost control** — route low-stakes chats to cheaper models, high-stakes threads to premium ones
+- **Workspace separation** — different teams/channels get different agents with different toolsets
+
+## Quick Start
+
+```bash
+# 1. Add agents
+hermes agent add coder --model anthropic/claude-opus-4-6
+hermes agent add research --model anthropic/claude-sonnet-4-6
+
+# 2. List agents and see their IDs
+hermes agent list
+
+# 3. Edit ~/.hermes/config.yaml to add routes (see below)
+
+# 4. Restart the gateway
+hermes gateway start
+```
+
+## Configuration
+
+Multi-agent config lives in `~/.hermes/config.yaml` under top-level keys `agents`, `routes`, and `default_agent`.
+
+### Agents
+
+Each entry under `agents:` defines an agent profile:
+
+```yaml
+default_agent: main
+
+agents:
+ main:
+ # Inherits everything from gateway defaults — no overrides needed
+
+ coder:
+ model: "anthropic/claude-opus-4-6"
+ provider: "anthropic"
+ home_dir: "~/.hermes/profiles/coder" # Optional: isolate memory/skills/SOUL.md
+ enabled_toolsets: [filesystem, terminal, web, skills]
+
+ research:
+ model: "anthropic/claude-sonnet-4-6"
+ home_dir: "~/.hermes/profiles/research"
+ enabled_toolsets: [web, browser, vision, skills]
+```
+
+| Field | Description |
+|-------|-------------|
+| `model` | Model string (same syntax as `hermes model`) |
+| `provider` | Inference provider (same as top-level `model.provider`) |
+| `home_dir` | Profile directory for SOUL.md, memory/, skills/, sessions/. Defaults to `~/.hermes` for `main`, or `~/.hermes/profiles/` for others. |
+| `enabled_toolsets` | List of toolset names this agent can use |
+| `disabled_toolsets` | List of toolset names to explicitly remove |
+
+### Routes
+
+Routes match incoming messages and assign them to an agent. Evaluated in **declaration order** — first match wins.
+
+```yaml
+routes:
+ # Route a specific Telegram forum topic to coder
+ - match: { platform: telegram, chat_id: "-1001234567890", thread_id: "42" }
+ agent: coder
+
+ # Route the rest of that group to research
+ - match: { platform: telegram, chat_id: "-1001234567890" }
+ agent: research
+
+ # Route a Slack workspace to coder
+ - match: { platform: slack, guild_id: "T0ABC123" }
+ agent: coder
+
+ # Route a Discord channel to creative
+ - match: { platform: discord, chat_id: "123456789012345678" }
+ agent: creative
+
+ # Route a specific user (everywhere) to research
+ - match: { platform: telegram, user_id: "123456789" }
+ agent: research
+```
+
+Supported match keys (all string equality, no globs/regex in MVP):
+
+| Key | Matches |
+|-----|---------|
+| `platform` | Platform name: `telegram`, `discord`, `slack`, `whatsapp`, etc. |
+| `chat_id` | Chat / channel / group ID |
+| `thread_id` | Thread / topic ID (Telegram forums, Slack threads, etc.) |
+| `user_id` | Sender's platform user ID |
+| `user_id_alt` | Alternative user identifier (platform-specific) |
+| `guild_id` | Workspace / server ID (Discord, Slack, etc.) |
+| `parent_chat_id` | Parent chat ID for nested contexts |
+
+### Resolution Order
+
+When a message arrives, the gateway resolves the agent in this order:
+
+1. **Routes match** — first matching route in declaration order
+2. **`select_agent` plugin hook** — plugins can override or supplement routing
+3. **`default_agent`** — fallback from config (default: `main`)
+4. **`"main"`** — hardcoded final fallback
+
+## CLI Management
+
+```bash
+hermes agent list # Show all agents with model, home dir, route count
+hermes agent show coder # Display full details: paths, routes, SOUL preview
+hermes agent add coder # Add a new agent (interactive or --flags)
+hermes agent add coder --from-profile main # Clone existing profile directory
+hermes agent remove coder # Remove agent (warns about orphaned routes)
+hermes agent remove coder --yes # Force removal without confirmation
+```
+
+## Profile Isolation
+
+Each agent with a distinct `home_dir` gets fully isolated storage:
+
+| What | Where | Shared? |
+|------|-------|---------|
+| SOUL.md | `/SOUL.md` | Per-agent |
+| MEMORY.md / USER.md | `/memory/` | Per-agent |
+| Skills | `/skills/` | Per-agent |
+| Sessions | `~/.hermes/state.db` (SQLite, `agent_id` column) | Shared DB, per-agent rows |
+| Cron jobs | `/cron/jobs.json` | Per-agent (directory isolation) |
+| Config | `~/.hermes/config.yaml` only | Shared |
+
+The `main` agent uses `~/.hermes` directly. Other agents default to `~/.hermes/profiles/` unless you set `home_dir` explicitly.
+
+## Plugin Hook: `select_agent`
+
+Plugins can implement custom routing logic beyond declarative routes:
+
+```python
+# In your plugin
+from hermes_cli.plugins import register_hook
+
+@register_hook("select_agent")
+def my_custom_router(event, gateway, route_match):
+ # route_match is the agent_id from declarative routes (or None)
+ if event.source.user_id == "my_boss":
+ return "coder" # Boss always gets the premium model
+ return None # Fall through to next hook or default
+```
+
+The first hook returning a non-None string wins. Hooks run after route matching, so `route_match` contains the declarative result (if any).
+
+## Backward Compatibility
+
+Existing single-agent installs require **zero changes**:
+
+- No `agents:` / `routes:` config → everything routes to `main` with existing `~/.hermes` home
+- Session keys default to `agent:main:...` — existing sessions continue uninterrupted
+- SQLite databases are migrated automatically with `agent_id` column defaulting to `"main"`
+- Cron jobs without `agent_id` default to `"main"`
+
+## Limitations (MVP)
+
+- Routes use exact string matching only — no globs, regex, or range matching
+- Route specificity is manual — declare more specific routes before general ones
+- Per-agent token budgets and priority queues are not yet implemented
+- Agents share the same Python process — no filesystem sandboxing guards
+- A2A (agent-to-agent) communication is not yet implemented
+
+## Examples
+
+### Team Channel Routing
+
+Route different Slack channels to different agents:
+
+```yaml
+agents:
+ dev:
+ model: "anthropic/claude-opus-4-6"
+ enabled_toolsets: [terminal, file, web, skills]
+ ops:
+ model: "anthropic/claude-sonnet-4-6"
+ enabled_toolsets: [terminal, web, cronjob]
+
+routes:
+ - match: { platform: slack, chat_id: "C1234567890" }
+ agent: dev
+ - match: { platform: slack, chat_id: "C0987654321" }
+ agent: ops
+```
+
+### Forum Topic Routing
+
+Route Telegram forum topics to specialized agents:
+
+```yaml
+agents:
+ support:
+ model: "anthropic/claude-sonnet-4-6"
+ sales:
+ model: "openai/gpt-5"
+ provider: "openrouter"
+
+routes:
+ - match: { platform: telegram, chat_id: "-1001234", thread_id: "1" }
+ agent: support
+ - match: { platform: telegram, chat_id: "-1001234", thread_id: "2" }
+ agent: sales
+```
+
+### Model Tier Routing
+
+Route VIP users to premium models, everyone else to standard:
+
+```yaml
+agents:
+ premium:
+ model: "anthropic/claude-opus-4-6"
+ standard:
+ model: "anthropic/claude-sonnet-4-6"
+
+routes:
+ - match: { platform: telegram, user_id: "123456789" }
+ agent: premium
+ - match: { platform: telegram }
+ agent: standard
+```