From 1dd9693831043b043ef4823d3abbce89f7dcc4d9 Mon Sep 17 00:00:00 2001 From: 02356abc <198679067+02356abc@users.noreply.github.com> Date: Sun, 17 May 2026 19:13:41 +0800 Subject: [PATCH 01/29] feat(agent): add AgentProfile + ContextVar for per-agent paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce AgentProfile dataclass and a ContextVar (_current_agent_profile) that lets path getters (get_hermes_home, get_skills_dir, get_memory_dir) resolve to the active agent's home directory under asyncio. - agent/profile.py: AgentProfile, use_profile() context manager, load_agent_registry() from GatewayConfig - hermes_constants.py: get_hermes_home() reads ContextVar before env fallback - tests/agent/test_profile_contextvar.py: ContextVar isolation under asyncio.gather, nested contexts, registry loading Single-agent installs see zero change — no profile bound means fallback to HERMES_HOME env var as before. --- agent/profile.py | 193 ++++++++++++++++++++ hermes_constants.py | 23 ++- tests/agent/test_profile_contextvar.py | 232 +++++++++++++++++++++++++ 3 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 agent/profile.py create mode 100644 tests/agent/test_profile_contextvar.py 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/hermes_constants.py b/hermes_constants.py index e7af1883970e6..1337ccd29372d 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -114,10 +114,14 @@ def _warn_profile_fallback_once() -> None: def get_hermes_home() -> Path: """Return the Hermes home directory (default: platform-native path). - Resolution order: context-local override (see - :func:`set_hermes_home_override`) → ``HERMES_HOME`` env var → the - platform-native default. This is the single source of truth — all other - copies should import this. + Resolution order: + 1. Active ``AgentProfile`` in the current async context (multi-agent + gateway routes per-message to a profile via ContextVar). + 2. Context-local override (see :func:`set_hermes_home_override`). + 3. ``HERMES_HOME`` env var. + 4. The platform-native default. + + This is the single source of truth — all other copies should import this. When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates a non-default profile is active, logs a loud one-shot warning to @@ -129,6 +133,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 HERMES_HOME override (test/subprocess scoping). override = get_hermes_home_override() if override: return Path(override) 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" From f3959e76c19e4c6e6575521a24a2bfb6e92257a2 Mon Sep 17 00:00:00 2001 From: 02356abc <198679067+02356abc@users.noreply.github.com> Date: Sun, 17 May 2026 19:13:41 +0800 Subject: [PATCH 02/29] feat(session): thread agent_id through session identity & DB schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add agent_id field to SessionSource and SessionEntry, prefix session keys with agent:: in build_session_key. Default "main" preserves every historical key string for single-agent installs. - gateway/session.py: SessionSource.agent_id, SessionEntry.agent_id, build_session_key prefixing - hermes_state.py: sessions table migration (agent_id TEXT DEFAULT 'main'), new idx_sessions_agent index - tests/gateway/test_session.py: build_session_key prefixing for all chat_type × agent_id combinations - tests/*/test_session_boundary_hooks.py: hook payload agent_id kwarg [rebase note 2026-07-30: upstream 21c7ae856 split SessionDB into mixins; the sessions-table agent_id column now lands in hermes_state_common.py (SCHEMA_SQL) and the idx_sessions_agent creation in hermes_state_schema.py. Semantics unchanged.] --- gateway/session.py | 33 +++++++++- hermes_state.py | 7 ++- hermes_state_common.py | 1 + hermes_state_schema.py | 9 +++ tests/gateway/test_session.py | 111 ++++++++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 3 deletions(-) diff --git a/gateway/session.py b/gateway/session.py index be74201c9fc34..4a3bdf8afa659 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -177,6 +177,7 @@ class SessionSource: 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) role_authorized: bool = False # True when adapter granted access via role (not user ID) + agent_id: Optional[str] = None # Resolved agent identity (None == default "main") # Profile this inbound message is routed to in a multiplexing gateway # (from the /p// URL prefix or per-credential adapter ownership). # None => the gateway's active/default profile. Drives both session-key @@ -279,6 +280,8 @@ def to_dict(self) -> Dict[str, Any]: d["message_id"] = self.message_id if self.profile: d["profile"] = self.profile + if self.agent_id: + d["agent_id"] = self.agent_id if self.auto_thread_created: d["auto_thread_created"] = True if self.auto_thread_initial_name: @@ -306,6 +309,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": parent_chat_id=data.get("parent_chat_id"), message_id=data.get("message_id"), profile=data.get("profile"), + agent_id=data.get("agent_id"), auto_thread_created=bool(data.get("auto_thread_created", False)), auto_thread_initial_name=data.get("auto_thread_initial_name"), prospective_thread_id=data.get("prospective_thread_id"), @@ -797,6 +801,11 @@ class SessionEntry: # (e.g. Slack thread-context watermarks). Survives gateway restarts via # the routing index; must stay small and JSON-serializable. metadata: Dict[str, Any] = field(default_factory=dict) + + # 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 @@ -881,6 +890,7 @@ def to_dict(self) -> Dict[str, Any]: "platform": self.platform.value if self.platform else None, "chat_type": self.chat_type, "metadata": self.metadata, + "agent_id": self.agent_id, "input_tokens": self.input_tokens, "output_tokens": self.output_tokens, "cache_read_tokens": self.cache_read_tokens, @@ -982,6 +992,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry": platform=platform, chat_type=data.get("chat_type", "dm"), metadata=dict(data.get("metadata") or {}), + 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), @@ -1124,8 +1135,23 @@ 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. """ - ns = _session_key_namespace(profile) + # Namespace prefix: the multi-agent routing identity (``source.agent_id``, + # set by inbound routing) takes precedence when resolved to a non-default + # agent; otherwise fall back to the profile-based namespace param. Both + # collapse to ``agent:main`` in the single-agent default, keeping legacy + # keys byte-identical. + agent_id = getattr(source, "agent_id", None) + if agent_id and agent_id != "main": + ns = f"agent:{agent_id}" + else: + ns = _session_key_namespace(profile) platform = source.platform.value slack_scope_id = ( str(source.scope_id) @@ -2838,6 +2864,7 @@ def _get_or_create_session_impl( 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, @@ -2881,6 +2908,7 @@ def _get_or_create_session_impl( if prev_session_id else None ), + "agent_id": source.agent_id or "main", } if _needs_save: @@ -3362,6 +3390,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, ) @@ -3388,6 +3417,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) -> "display_name": old_entry.display_name, "parent_session_id": db_end_session_id, "model_config": {"_reset_from": db_end_session_id}, + "agent_id": old_entry.agent_id or "main", } if self._db and db_end_session_id: @@ -3504,6 +3534,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_state.py b/hermes_state.py index 60e63ba8ee073..a94c8fd6ba8fd 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4821,6 +4821,7 @@ def _insert_session_row( git_repo_root: str = None, origin_json: str = None, display_name: str = None, + agent_id: str = "main", ) -> None: """Insert a session row, enriching NULL metadata on conflict. @@ -4861,12 +4862,12 @@ def _do(conn): system_prompt_hash = self._store_system_prompt(conn, system_prompt) conn.execute( """INSERT INTO sessions ( - id, source, user_id, session_key, chat_id, chat_type, thread_id, + id, source, agent_id, user_id, session_key, chat_id, chat_type, thread_id, model, model_config, system_prompt, system_prompt_hash, parent_session_id, cwd, profile_name, git_repo_root, origin_json, display_name, started_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET model = COALESCE(sessions.model, excluded.model), model_config = CASE @@ -4899,6 +4900,7 @@ def _do(conn): ELSE sessions.system_prompt END, session_key = COALESCE(sessions.session_key, excluded.session_key), + agent_id = COALESCE(sessions.agent_id, excluded.agent_id), chat_id = COALESCE(sessions.chat_id, excluded.chat_id), chat_type = COALESCE(sessions.chat_type, excluded.chat_type), thread_id = COALESCE(sessions.thread_id, excluded.thread_id), @@ -4911,6 +4913,7 @@ def _do(conn): ( session_id, source, + agent_id or "main", user_id, session_key, chat_id, diff --git a/hermes_state_common.py b/hermes_state_common.py index 2bc6572810df0..cb772cd3237ef 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -345,6 +345,7 @@ def _sql_session_last_active_by_id(session_id_expr: str) -> str: CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, source TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT 'main', user_id TEXT, session_key TEXT, chat_id TEXT, diff --git a/hermes_state_schema.py b/hermes_state_schema.py index ac996758df09f..2b403d938df5d 100644 --- a/hermes_state_schema.py +++ b/hermes_state_schema.py @@ -1227,6 +1227,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 diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 2defd546b7a7d..6d6ffa3006bb7 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -1644,3 +1644,114 @@ def test_write_sessions_json_false_stops_producing_file(self, tmp_path): restarted._db.close() +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 From 45256e69937d040c76b07c439b1386f0e0ab949c Mon Sep 17 00:00:00 2001 From: 02356abc <198679067+02356abc@users.noreply.github.com> Date: Sun, 17 May 2026 19:13:42 +0800 Subject: [PATCH 03/29] feat(gateway): route inbound messages via routes table + select_agent hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add declarative routing (routes: match → agent) and a select_agent plugin hook. _attach_agent_id injects the resolved agent_id into event.source before build_session_key. Seven platform adapters get pre-injection for batching paths; the rest inherit it from base.py. - gateway/agent_routing.py: resolve_agent_id(), _route_matches() - gateway/config.py: agents, routes, default_agent schema - gateway/platforms/base.py: _attach_agent_id(), set_routing_context() - gateway/platforms/{telegram,discord,slack,matrix,feishu,wecom,yuanbao}.py: pre-batch injection - hermes_cli/plugins.py: select_agent hook registration - tests/gateway/test_agent_routing.py: declared-order matching, hook chain, default fallback, profile isolation --- gateway/agent_routing.py | 93 +++++++ gateway/config.py | 36 +++ gateway/platforms/base.py | 92 ++++++- gateway/platforms/yuanbao.py | 11 + hermes_cli/plugins.py | 20 ++ plugins/platforms/discord/adapter.py | 4 + plugins/platforms/feishu/adapter.py | 3 + plugins/platforms/matrix/adapter.py | 3 + plugins/platforms/slack/adapter.py | 11 + plugins/platforms/telegram/adapter.py | 8 + plugins/platforms/wecom/adapter.py | 5 + tests/gateway/test_agent_routing.py | 364 ++++++++++++++++++++++++++ 12 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 gateway/agent_routing.py create mode 100644 tests/gateway/test_agent_routing.py 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 fece329595bef..06090d663b9ac 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1002,6 +1002,12 @@ class GatewayConfig: # different profiles. See gateway/profile_routing.py. Each entry is a # dict with: name, platform, profile, and optional guild_id/chat_id/thread_id. profile_routes: list = field(default_factory=list) + # ── 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 __post_init__(self) -> None: self.multiplex_profile_allowlist = _normalize_multiplex_profile_allowlist( @@ -1131,6 +1137,9 @@ def to_dict(self) -> Dict[str, Any]: asdict(r) if is_dataclass(r) and not isinstance(r, type) else r for r in self.profile_routes ], + "agents": self.agents, + "routes": self.routes, + "default_agent": self.default_agent, } @classmethod @@ -1248,6 +1257,21 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": from gateway.profile_routing import parse_profile_routes profile_routes = parse_profile_routes(data.get("profile_routes") or []) + # 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, @@ -1274,6 +1298,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": streaming=StreamingConfig.from_dict(data.get("streaming", {})), session_store_max_age_days=session_store_max_age_days, profile_routes=profile_routes, + agents=agents, + routes=routes, + default_agent=default_agent.strip(), ) def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str: @@ -1479,6 +1506,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/platforms/base.py b/gateway/platforms/base.py index 6bae26aa052bf..ef03997efc099 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -3096,6 +3096,12 @@ def __init__(self, config: PlatformConfig, platform: Platform): # mitigating indirect prompt injection from third parties in a shared # thread/channel. self._authorization_check: Optional[Callable[[str, Optional[str], Optional[str]], 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``: @@ -3753,7 +3759,7 @@ def _is_sender_authorized( 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). @@ -3810,7 +3816,82 @@ def _session_key_profile(self, source: Optional[Any] = None) -> Optional[str]: if isinstance(resolved, str) and resolved.strip(): return resolved return None - + + 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, self._gateway_routes, 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=self._gateway_ref, + 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 self._default_agent_id 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) + def _history_media_paths_for_session(self, session_key: str) -> Optional[set]: """Return media paths already delivered in prior turns of this session. @@ -6067,6 +6148,13 @@ async def handle_message(self, event: MessageEvent) -> None: if needs_topic_recovery: await asyncio.to_thread(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 rules see the corrected lane. + self._attach_agent_id(event) + + _sk_store = getattr(self, "_session_store", None) 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/yuanbao.py b/gateway/platforms/yuanbao.py index 5c1a02401cd36..20f48ac099be2 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2952,6 +2952,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/hermes_cli/plugins.py b/hermes_cli/plugins.py index 2493d8f21eddc..726d7d67e4ada 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -231,6 +231,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 an approval decision -- fires for CLI-interactive prompts, # gateway/ACP approvals, and smart-mode auxiliary-LLM decisions. @@ -6044,6 +6055,14 @@ def _get_pre_tool_call_directive_details( from hermes_cli.lifecycle import invoke_hook as invoke_lifecycle_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_lifecycle_hook( "pre_tool_call", tool_name=tool_name, @@ -6054,6 +6073,7 @@ def _get_pre_tool_call_directive_details( turn_id=turn_id, api_request_id=api_request_id, middleware_trace=list(middleware_trace or []), + agent_id=_agent_id, ) block_msg: Optional[str] = None diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index ad1a6ab1191f2..bca4b5ee5ac05 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -8533,6 +8533,10 @@ async def _handle_message( 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 live plain text messages use split-message batching. Recovery # candidates are already complete historical messages; coalescing them # would lose constituent IDs and make later restarts replay them. diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index d2f3352657cb1..9842efc2a199a 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -3403,6 +3403,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/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 0da6f37c962cc..da64d955c7154 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -3544,6 +3544,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/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index e017838fce82a..dee4ffda6484a 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -8346,6 +8346,17 @@ def _build_thread_session_key( scope_id=team_id or None, ) + # 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/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 1642ee039e238..a14dd863ad1d8 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -9602,6 +9602,9 @@ async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAU event.text = self._clean_bot_trigger_text(event.text) await self._cache_replied_media(msg, event) 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: @@ -9927,8 +9930,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 @@ -10056,8 +10061,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 @@ -10154,6 +10161,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/plugins/platforms/wecom/adapter.py b/plugins/platforms/wecom/adapter.py index 0a5fa168bbd08..b6d4154340fad 100644 --- a/plugins/platforms/wecom/adapter.py +++ b/plugins/platforms/wecom/adapter.py @@ -590,6 +590,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/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 From 54fa0f6eb9d6ff4a11790422cd5bbebb279a408c Mon Sep 17 00:00:00 2001 From: 02356abc <198679067+02356abc@users.noreply.github.com> Date: Sun, 17 May 2026 19:13:42 +0800 Subject: [PATCH 04/29] feat(gateway): GatewayRunner loads registry, binds profile, propagates agent_id to hooks GatewayRunner loads the agent registry at init and wraps every inbound message in use_profile(). AIAgent accepts an optional profile= kwarg. All invoke_hook call sites gain agent_id= kwarg. _handle_message is split into _handle_message (ContextVar plumbing) + _handle_message_inner (legacy logic) so tests that grep the source body continue to work. - gateway/run.py: registry loading, use_profile() wrapping, hook kwargs - run_agent.py: AIAgent(profile=), profile-aware model/toolset resolution - model_tools.py, tools/{approval,terminal,delegate}.py: hook agent_id - cli.py, tui_gateway/server.py: session boundary hook agent_id - tests/gateway/test_profile_overrides.py: per-agent model/toolset overrides - tests/test_model_tools.py: hook payload verification - tests/gateway/test_{update,title,reasoning}_command.py: adapt to _handle_message split --- cli.py | 29 ++++ cron/scheduler_provider.py | 8 + gateway/run.py | 178 +++++++++++++++++++++- model_tools.py | 18 +++ tests/gateway/test_profile_overrides.py | 193 ++++++++++++++++++++++++ tests/gateway/test_update_command.py | 9 +- tools/approval.py | 10 +- tools/delegate_tool.py | 10 ++ tools/terminal_tool.py | 9 ++ tui_gateway/server.py | 10 ++ 10 files changed, 463 insertions(+), 11 deletions(-) create mode 100644 tests/gateway/test_profile_overrides.py diff --git a/cli.py b/cli.py index b53906386ac3f..a59fe7833059d 100644 --- a/cli.py +++ b/cli.py @@ -1239,6 +1239,8 @@ def _run_cleanup(*, notify_session_finalize: bool = True): if notify_session_finalize: cleanup_session_id = _active_agent_ref.session_id if _active_agent_ref else None if _should_emit_cleanup_session_finalize(cleanup_session_id): + # _notify_session_finalize resolves and forwards agent_id from the + # active profile, so the multi-agent identity reaches plugins. _notify_session_finalize( session_id=cleanup_session_id, platform="cli", @@ -1309,10 +1311,19 @@ def _notify_session_finalize( ) -> None: try: from hermes_cli.lifecycle import finalize_session + _agent_id = None + try: + from agent.profile import get_active_profile + _p = get_active_profile() + if _p: + _agent_id = _p.id + except Exception: + pass finalize_session( session_id=session_id, platform=platform, reason=reason, + agent_id=_agent_id, ) except Exception: pass @@ -9705,6 +9716,14 @@ def _notify_session_boundary(self, event_type: str) -> None: try: from hermes_cli.lifecycle import finalize_session, 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 context = { "session_id": self.agent.session_id if self.agent else None, "platform": getattr(self, "platform", None) or "cli", @@ -9713,6 +9732,7 @@ def _notify_session_boundary(self, event_type: str) -> None: if event_type == "on_session_reset" else "session_boundary" ), + "agent_id": _agent_id, } if event_type == "on_session_finalize": finalize_session(**context) @@ -20710,6 +20730,14 @@ def new_event_loop(self): if self.agent and getattr(self, '_agent_running', False): try: from hermes_cli.lifecycle 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, @@ -20718,6 +20746,7 @@ def new_event_loop(self): model=getattr(self.agent, 'model', None), platform=getattr(self.agent, 'platform', None) or "cli", reason="shutdown", + agent_id=_agent_id, ) except Exception: pass diff --git a/cron/scheduler_provider.py b/cron/scheduler_provider.py index c78549d785653..89be72267dcee 100644 --- a/cron/scheduler_provider.py +++ b/cron/scheduler_provider.py @@ -96,6 +96,7 @@ def start( adapters: Any = None, loop: Any = None, interval: int = 60, + registry: Any = None, ) -> None: """Begin firing due jobs. @@ -103,6 +104,11 @@ def start( (it is run inside a daemon thread by the caller, exactly as today). An external provider may register a schedule/webhook and return immediately; in that case it must still honor stop_event for teardown. + + ``registry`` is the multi-agent ``AgentProfile`` registry; when + present, due jobs are collected across ALL agent profiles and each + runs under its own profile context. Providers that don't support + multi-agent cron may ignore it. """ def stop(self) -> None: @@ -511,6 +517,7 @@ def start( interval=60, can_dispatch=None, profile_homes=None, + registry=None, ): import logging from cron.scheduler import tick as cron_tick @@ -569,6 +576,7 @@ def start( loop=loop, sync=False, can_dispatch=can_dispatch, + registry=registry, ) ok = True except BaseException as e: diff --git a/gateway/run.py b/gateway/run.py index 9e9adaf5a5921..d525b36ace31a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6911,7 +6911,12 @@ def __init__(self, config: Optional[GatewayConfig] = None): # Sync helpers keep using ``session_store`` directly; async gateway # handlers call this facade and await every operation. self._async_session_store = AsyncSessionStore(self.session_store) - 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() @@ -8153,6 +8158,13 @@ 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). Applied before the + # empty-model safety net so a profile-pinned model is what gets cached. + 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 @@ -8190,6 +8202,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. @@ -11044,10 +11144,13 @@ async def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None # multi-day 4.7G session — heartbeats froze and the process was # SIGKILLed mid-export. Same class as the memory-provider hang # below (#53175). + _profile = getattr(agent, "_profile", None) + _agent_id = _profile.id if _profile else None await self._finalize_session_off_loop( session_id=getattr(agent, "session_id", None), platform="gateway", reason="shutdown", + agent_id=_agent_id, ) # Off-loop + bounded: a wedged memory provider here used to hang # the whole shutdown so SIGTERM never completed (#53175). @@ -12855,6 +12958,11 @@ async def start(self) -> bool: adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform)) adapter.set_platform_event_handler(self._primary_platform_event_handler()) adapter._busy_text_mode = self._busy_text_mode + adapter.set_routing_context( + routes=self.config.routes, + default_agent=self.config.default_agent, + gateway=self, + ) _pending_connects.append((platform, platform_config, adapter)) if await self._abort_startup_if_shutdown_requested(): @@ -13777,10 +13885,12 @@ async def _session_expiry_watcher(self, interval: int = 300): # Off-loop + bounded: plugin finalize hooks can # block arbitrarily (see _finalize_session_off_loop) # and this watcher runs on the gateway event loop. + _agent_id = _parts[1] if len(_parts) > 1 else None await self._finalize_session_off_loop( session_id=entry.session_id, platform=_platform, reason="session_expired", + agent_id=_agent_id, ) except Exception: pass @@ -14322,6 +14432,11 @@ async def _platform_reconnect_watcher(self) -> None: adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform)) adapter.set_platform_event_handler(self._primary_platform_event_handler()) adapter._busy_text_mode = self._busy_text_mode + adapter.set_routing_context( + routes=self.config.routes, + default_agent=self.config.default_agent, + gateway=self, + ) # Reconnect after an outage: preserve the platform's # server-side update queue so messages sent while the bot @@ -16462,7 +16577,7 @@ async def _busy_loop_command(self, event: MessageEvent, quick_key: str, source): 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.) @@ -16472,6 +16587,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 # 🔴 Cross-session leak guard. This handler runs inside a per-message @@ -16566,6 +16703,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if not is_internal: try: from hermes_cli.lifecycle 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, @@ -16574,6 +16712,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # object.__new__ without __init__ (pitfall #17), and the # hook must not fail dispatch over a missing attribute. session_store=getattr(self, "session_store", None), + agent_id=_agent_id, ) except Exception as _hook_exc: logger.warning("pre_gateway_dispatch invocation failed: %s", _hook_exc) @@ -17992,9 +18131,24 @@ async def _do_undo(): try: 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 + ) except TurnLeaseTimeoutError as exc: # This is a rejected message, not a completed agent turn. Return # before the /goal judge below so it cannot consume the resend @@ -20978,8 +21132,6 @@ def _format_session_info(self) -> str: return "\n".join(lines) - - def _check_slash_access( self, source: SessionSource, canonical_cmd: str ) -> Optional[str]: @@ -22475,6 +22627,9 @@ async def _run_background_task_inner( from agent.skill_utils import parse_config_string_list disabled_toolsets = parse_config_string_list(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 = _current_max_iterations() @@ -28106,6 +28261,9 @@ def _run_still_current() -> bool: from agent.skill_utils import parse_config_string_list disabled_toolsets = parse_config_string_list(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): @@ -30772,7 +30930,11 @@ def restart_signal_handler(): resolve_cron_scheduler(), multiplex_profiles=multiplex_cron, ) - cron_start_kwargs: Dict[str, Any] = {"adapters": runner.adapters, "loop": asyncio.get_running_loop()} + cron_start_kwargs: Dict[str, Any] = { + "adapters": runner.adapters, + "loop": asyncio.get_running_loop(), + "registry": getattr(runner, "_agent_registry", None), + } # Multiplex profiles: tell the built-in ticker which profile homes to # tick so secondary-profile cron jobs actually fire (#69377). diff --git a/model_tools.py b/model_tools.py index 0a5216bbb8ece..bb55193c192ba 100644 --- a/model_tools.py +++ b/model_tools.py @@ -1169,6 +1169,14 @@ def _emit_post_tool_call_hook( function_name, 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 invoke_hook( "post_tool_call", tool_name=function_name, @@ -1184,6 +1192,7 @@ def _emit_post_tool_call_hook( error_type=error_type, error_message=error_message, middleware_trace=list(middleware_trace or []), + agent_id=_agent_id, ) except Exception as _hook_err: logger.debug("post_tool_call hook error: %s", _hook_err) @@ -1561,6 +1570,14 @@ def _dispatch(next_args: Dict[str, Any]) -> Any: function_name, 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 hook_results = invoke_hook( "transform_tool_result", tool_name=function_name, @@ -1575,6 +1592,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/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_update_command.py b/tests/gateway/test_update_command.py index 22cc9cd419b5c..aa208b546bbba 100644 --- a/tests/gateway/test_update_command.py +++ b/tests/gateway/test_update_command.py @@ -504,11 +504,16 @@ class TestUpdateInHelp: 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 class TestWatchUpdateProgress: diff --git a/tools/approval.py b/tools/approval.py index 1ecda55ec452f..910057846438a 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -130,7 +130,15 @@ def _fire_approval_hook(hook_name: str, **kwargs) -> None: _session_id = _approval_session_id.get() if _session_id: kwargs.setdefault("session_id", _session_id) - 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 8699edb984707..7026fa167554e 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -3435,6 +3435,15 @@ def _finalize_child_results( 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 children_cost_total = 0.0 for entry in results: @@ -3462,6 +3471,7 @@ def _finalize_child_results( entry.get("tool_trace") ), 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 0d90fe559226a..9f72aed7fc9b5 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -3440,6 +3440,14 @@ def _read_script_in_env(script_path: str) -> Optional[str]: # The hook is fail-open, and the first valid string return wins. try: from hermes_cli.lifecycle 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, @@ -3447,6 +3455,7 @@ def _read_script_in_env(script_path: str) -> Optional[str]: 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 48d8039c53e77..b65fcaa89082a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -572,16 +572,26 @@ def _notify_session_boundary( try: from hermes_cli.lifecycle import finalize_session, 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 event_type == "on_session_finalize": finalize_session( session_id=session_id, platform=_resolve_agent_platform(platform), + agent_id=_agent_id, ) else: invoke_hook( event_type, session_id=session_id, platform=_resolve_agent_platform(platform), + agent_id=_agent_id, ) except Exception: pass From 1c3a62e8ec9e6dd50f9a4d24eb942b97b9e8eeb3 Mon Sep 17 00:00:00 2001 From: 02356abc <198679067+02356abc@users.noreply.github.com> Date: Sun, 17 May 2026 19:13:42 +0800 Subject: [PATCH 05/29] feat(cron+delivery): propagate agent_id through scheduled jobs & deliveries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cron tick and delivery routing now bind the correct profile before execution. jobs.py does NOT persist agent_id in JSON — the directory is the identity. Delivery uses nullcontext() for the unrouted case. - cron/jobs.py: in-memory agent_id stamping at read time, directory-based identity (no JSON field) - cron/scheduler.py: use_profile() wrapper in tick path - gateway/delivery.py: use_profile() wrapper per delivery target - tests/cron/test_scheduler.py: agent_id propagation in delivery targets --- cron/__init__.py | 6 +- cron/jobs.py | 114 +++++++++++++++++++++++++++++++++-- cron/scheduler.py | 82 +++++++++++++++++++------ gateway/delivery.py | 38 ++++++++---- tests/cron/test_scheduler.py | 4 ++ 5 files changed, 206 insertions(+), 38 deletions(-) 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 b13393f108145..1c94706a40e9e 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -117,6 +117,38 @@ def _ensure_croniter() -> bool: 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" + + +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" + @dataclass(frozen=True) class _CronStorePaths: @@ -148,11 +180,15 @@ def _current_cron_store() -> _CronStorePaths: OUTPUT_DIR no longer match their import-time values, someone chose the documented process-wide compatibility surface; honor it; 3. the ACTIVE profile home, resolved fresh via get_hermes_home() - (context-local override, then the HERMES_HOME env var) — so a test - or embedder that re-points HERMES_HOME after this module was - imported reads/writes ITS OWN store, not whatever jobs.json the - import happened to freeze (the filed incident: fixtures that patched - the env too late silently rewrote the user's real jobs file); + (active ``AgentProfile`` ContextVar, then the context-local override, + then the HERMES_HOME env var) — so a test or embedder that re-points + HERMES_HOME after this module was imported reads/writes ITS OWN + store, not whatever jobs.json the import happened to freeze (the + filed incident: fixtures that patched the env too late silently + rewrote the user's real jobs file). This also makes the multi-agent + ticker — which fires each job inside ``use_profile(profile)`` rather + than ``use_cron_store`` — resolve to the job's own profile store for + ``load_jobs``/``save_jobs``/``mark_job_run`` and lock paths; 4. the import-time constants (home unchanged since import — the common path, returned unchanged). """ @@ -3609,6 +3645,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 19cbcbc537741..97a32851724eb 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2314,12 +2314,17 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d if deliver_value == "local": return None + _agent_id = job.get("agent_id") + # bot-chat[:] — checked before the generic platform:chat_id # split below so the profile-name argument is never misparsed as a # chat_id on an unknown platform. bot_chat_profile = parse_bot_chat_deliver_token(deliver_value) if bot_chat_profile is not None: - return _resolve_bot_chat_target(job, bot_chat_profile) + target = _resolve_bot_chat_target(job, bot_chat_profile) + if target is not None: + target["agent_id"] = _agent_id or (origin.get("agent_id") if origin else None) + return target if deliver_value == "origin": if origin: @@ -2327,6 +2332,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d "platform": origin["platform"], "chat_id": str(origin["chat_id"]), "thread_id": _origin_delivery_thread(origin), + "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. @@ -2342,6 +2348,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 @@ -2386,6 +2393,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 @@ -2401,6 +2409,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): @@ -2413,6 +2422,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, } @@ -7203,19 +7213,23 @@ def tick( sync: bool = True, *, can_dispatch=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 can_dispatch: Optional synchronous gate; false leaves due jobs untouched for the next allowed tick + 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) @@ -7313,20 +7327,24 @@ def tick( except Exception as _reap_exc: logger.debug("Dead-owner execution reclaim failed: %s", _reap_exc) - 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() # Bound the in-flight set BEFORE the dedup guard is consulted, so a # leaked claim is force-released in-cycle rather than silently eating # every subsequent fire until the gateway process restarts. Skips the # extra load_jobs when there are no in-flight claims (the common idle - # tick) and reuses due_jobs when they already cover the in-flight set - # (get_due_jobs calls load_jobs internally, so this avoids a redundant - # second file read on every active tick). + # tick) and reuses all_jobs when they already cover the in-flight set + # (get_due_jobs/get_all_due_jobs call load_jobs internally, so this + # avoids a redundant second file read on every active tick). if _running_job_ids: - _sweep_jobs = due_jobs + _sweep_jobs = all_jobs try: _inflight_ids = set(_running_job_ids) - _due_ids = {j.get("id") for j in due_jobs if isinstance(j, dict)} + _due_ids = {j.get("id") for j in all_jobs if isinstance(j, dict)} if not _inflight_ids <= _due_ids: from cron.jobs import load_jobs as _load_all_jobs @@ -7338,7 +7356,7 @@ def tick( except Exception as e: logger.warning("Stale in-flight sweep failed: %s", e) - if not due_jobs: + if not all_jobs: # Idle tick: skip config load + pool partitioning entirely # (#33612 — the gateway ticker calls tick(verbose=False) every # 60s, so idle ticks previously fell through to load_config()). @@ -7355,19 +7373,33 @@ def tick( 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 parallel jobs that are already running, the advance keeps # bumping next_run_at forward so the grace window never expires. # mark_job_run() overwrites next_run_at on completion. - # Batched: one load + one save for the whole due set, not one per job. + # Batched: one load + one save per profile for the whole due set, not + # one per job. In multi-agent mode the advance must run in each job's + # profile context so it writes back to the correct jobs.json. # Composes with the claim-time advance in claim_job_for_fire: for # cron-kind jobs both compute the same next occurrence; interval jobs # re-anchor from their own "now" at claim time (harmless for # at-most-once — mark_job_run re-anchors at completion regardless). - advance_next_runs([job["id"] for job in due_jobs]) + if registry: + from agent.profile import use_profile + _ids_by_agent = {} + for job in all_jobs: + _ids_by_agent.setdefault(job.get("agent_id", "main"), []).append(job["id"]) + for _job_agent_id, _job_ids in _ids_by_agent.items(): + if _job_agent_id in registry: + with use_profile(registry[_job_agent_id]): + advance_next_runs(_job_ids) + else: + advance_next_runs(_job_ids) + else: + advance_next_runs([job["id"] for job in all_jobs]) # Resolve max parallel workers: env var > config.yaml > unbounded. # Set HERMES_CRON_MAX_PARALLEL=1 to restore old serial behaviour. @@ -7392,7 +7424,7 @@ def tick( 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", ) @@ -7400,7 +7432,10 @@ def _process_job(job: dict) -> bool: """Run one due job end-to-end. Thin wrapper around the shared module-level ``run_one_job`` so ``tick`` and external providers (Chronos ``fire_due``) use the identical execute→save→deliver→mark - body.""" + body. In multi-agent mode the whole body runs inside the job's + ``AgentProfile`` context so ``run_one_job``'s internal path getters, + secret scope, output save, delivery, and ``mark_job_run`` all + resolve to the correct agent's home dir / jobs.json.""" # Acquire the durable claim only when this worker actually starts, # not while it may wait behind other work in an executor queue. # This prevents a queued lease from expiring before execution. @@ -7417,6 +7452,17 @@ def _process_job(job: dict) -> bool: # compatible; real callers using return_job=True never take it. claimed_job = dict(claimed) if isinstance(claimed, dict) else dict(job) claimed_job["execution_id"] = job["execution_id"] + _job_agent_id = job.get("agent_id", "main") + _profile = registry.get(_job_agent_id) if registry else None + if _profile is not None: + from agent.profile import use_profile + with use_profile(_profile): + return run_one_job( + claimed_job, + adapters=adapters, + loop=loop, + verbose=verbose, + ) return run_one_job( claimed_job, adapters=adapters, @@ -7430,8 +7476,8 @@ def _process_job(job: dict) -> bool: # That alone only keeps workdir jobs from overlapping EACH OTHER; # run_job's _terminal_cwd_lock is what additionally stops a concurrently # firing workdir-less parallel-pool job from observing the override. - sequential_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()] - parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()] + sequential_jobs = [j for j in all_jobs if (j.get("workdir") or "").strip()] + parallel_jobs = [j for j in all_jobs if not (j.get("workdir") or "").strip()] _results: list = [] _all_futures: list = [] diff --git a/gateway/delivery.py b/gateway/delivery.py index fa43db6d0f92e..35bec5bd8360b 100644 --- a/gateway/delivery.py +++ b/gateway/delivery.py @@ -214,7 +214,7 @@ def _classify_dead_from_error_text(error_text: Optional[str]) -> Optional[str]: class DeliveryTarget: """ A single delivery target. - + Represents where a message should be sent: - "origin" → back to source - "local" → save to local files @@ -226,6 +226,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": @@ -248,6 +249,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 @@ -294,26 +296,28 @@ 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, - dead_targets: Optional[DeadTargetRegistry] = None): + dead_targets: Optional[DeadTargetRegistry] = None, registry=None): """ Initialize the delivery router. - + Args: config: Gateway configuration adapters: Dict mapping platforms to their adapter instances dead_targets: Optional shared registry of confirmed-unreachable targets. When omitted, a profile-local registry is created. + 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.dead_targets = dead_targets or DeadTargetRegistry() + self._registry = registry or {} async def deliver( self, @@ -361,14 +365,24 @@ async def deliver( } continue 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) - # Successful platform delivery — clear any stale dead flag. - if target.chat_id and not _send_result_failed(result): - self.dead_targets.clear(target.platform.value, target.chat_id) - + 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) + # Successful platform delivery — clear any stale dead flag. + if target.chat_id and not _send_result_failed(result): + self.dead_targets.clear(target.platform.value, target.chat_id) + results[target.to_string()] = { "success": True, "result": result diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 02278a1e4c660..f23d1f5f7df77 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -194,6 +194,7 @@ def test_origin_delivery_preserves_thread_id(self): "platform": "telegram", "chat_id": "-1001", "thread_id": "17585", + "agent_id": None, } @@ -254,6 +255,7 @@ def test_human_friendly_label_resolved_via_channel_directory(self): "platform": "whatsapp", "chat_id": "12345678901234@lid", "thread_id": None, + "agent_id": None, } @@ -269,6 +271,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_unresolved_target_still_delivered_as_written(self): @@ -308,6 +311,7 @@ def test_list_form_deliver_is_normalized(self, monkeypatch): "platform": "telegram", "chat_id": "-4004", "thread_id": None, + "agent_id": None, } From df1d7b8bcb5bf453a9a36b6a0135989bb72063c4 Mon Sep 17 00:00:00 2001 From: 02356abc <198679067+02356abc@users.noreply.github.com> Date: Sun, 17 May 2026 19:13:42 +0800 Subject: [PATCH 06/29] feat(cli): add hermes agent subcommand for multi-agent management New hermes agent subcommand group: list, show, add, remove. Manages agent profiles and routing config in ~/.hermes/config.yaml. - hermes_cli/agent.py: cmd_agent_list, cmd_agent_show, cmd_agent_add, cmd_agent_remove with profile cloning and route cleanup - hermes_cli/main.py: parser registration - tests/hermes_cli/test_agent_cli.py: list/show/add/remove coverage, route orphan warnings, SOUL summarization --- agent/conversation_loop.py | 9 + agent/turn_context.py | 9 + agent/turn_finalizer.py | 18 ++ hermes_cli/agent.py | 236 +++++++++++++++++ hermes_cli/main.py | 39 ++- tests/hermes_cli/test_agent_cli.py | 403 +++++++++++++++++++++++++++++ 6 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 hermes_cli/agent.py create mode 100644 tests/hermes_cli/test_agent_cli.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 2d49501518e91..00b4fe391cf42 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -990,11 +990,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.lifecycle 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) diff --git a/agent/turn_context.py b/agent/turn_context.py index f9bc38f126bf5..4850716202c36 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -1245,6 +1245,14 @@ def build_turn_context( plugin_user_context = "" try: from hermes_cli.lifecycle 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, @@ -1257,6 +1265,7 @@ def build_turn_context( platform=getattr(agent, "platform", None) or "", parent_session_id=getattr(agent, "_parent_session_id", None) or "", sender_id=getattr(agent, "_user_id", None) or "", + agent_id=_agent_id, ) _ctx_parts: list[str] = [] # Spill oversized per-hook context to disk so a runaway plugin diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index fbdf19cd91b40..4ec98a81f8417 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -619,6 +619,14 @@ def finalize_turn( if final_response and not interrupted: try: from hermes_cli.lifecycle 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, @@ -629,6 +637,7 @@ def finalize_turn( 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) @@ -815,6 +824,14 @@ def finalize_turn( # Plugins can use this for cleanup, flushing buffers, etc. try: from hermes_cli.lifecycle 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, @@ -826,6 +843,7 @@ def finalize_turn( turn_exit_reason=_turn_exit_reason, 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/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 448261c6c2003..e3c36f2a0dcc0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11648,7 +11648,7 @@ def _build_provider_choices() -> list[str]: # to parse. _BUILTIN_SUBCOMMANDS = frozenset( { - "acp", "approvals", "auth", "backup", "bundles", "checkpoints", "claw", "completion", + "acp", "agent", "approvals", "auth", "backup", "bundles", "checkpoints", "claw", "completion", "computer-use", "config", "console", "cron", "curator", "dashboard", "serve", "debug", "doctor", "dump", "egress", "fallback", "gateway", "hooks", "import", "import-agent", "insights", @@ -12778,6 +12778,43 @@ def _dispatch_egress(args): # noqa: ANN001 # ========================================================================= build_pause_parser(subparsers) + # ========================================================================= + # 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) + # ========================================================================= # cron command (parser built in hermes_cli/subcommands/cron.py) # ========================================================================= 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 From 8f809f6c43776d3c3f7cadae238f1f5bdcabe4ca Mon Sep 17 00:00:00 2001 From: 02356abc <198679067+02356abc@users.noreply.github.com> Date: Sun, 17 May 2026 19:13:42 +0800 Subject: [PATCH 07/29] docs: multi-agent routing guide + sample config --- README.md | 1 + cli-config.yaml.example | 55 ++++ website/docs/user-guide/messaging/index.md | 1 + .../docs/user-guide/messaging/multi-agent.md | 236 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 website/docs/user-guide/messaging/multi-agent.md diff --git a/README.md b/README.md index c05112266746f..630dbe2900955 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenR A closed learning loopAgent-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 automationsBuilt-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. Delegates and parallelizesSpawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. +Multi-agent routingRun 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 laptopSeven 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-readyBatch trajectory generation, trajectory compression for training the next generation of tool-calling models. diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 5e1b325be9bb2..72fcd5288a365 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1695,6 +1695,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/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index fb6098e003806..2b2c4ee35d9d0 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -773,6 +773,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 +``` From b27fb7934b3edffda6f6afce784d2b3a044e2f43 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Sun, 12 Jul 2026 10:03:22 +0900 Subject: [PATCH 08/29] fix(cron): resolve stale cron-store path leak under profile-dynamic get_hermes_home _get_cron_dir() detected a test monkeypatch of the module-level CRON_DIR by comparing it to a live `get_hermes_home() / "cron"`. That held when get_hermes_home() was static, but the multi-agent change makes it profile-/ HERMES_HOME-dynamic, so any active-profile or env change made the comparison mistake CRON_DIR for monkeypatched and return the stale import-time path. Compare against a frozen import-time default (_CRON_DIR_IMPORT_DEFAULT) instead, so monkeypatch detection is stable and the dynamic profile path is used otherwise. Fixes an order-dependent failure in the cron test suite. Co-Authored-By: Claude Opus 4.8 --- cron/jobs.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 1c94706a40e9e..45cbe86e64f65 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -117,12 +117,11 @@ def _ensure_croniter() -> bool: 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" +# Frozen snapshot of CRON_DIR's import-time default. Used to detect a test +# monkeypatch of the module-level CRON_DIR *without* re-evaluating the now +# profile-dynamic get_hermes_home() — that live comparison would mistake any +# active-profile / HERMES_HOME change for a monkeypatch and leak a stale path. +_CRON_DIR_IMPORT_DEFAULT = CRON_DIR def _get_cron_dir() -> Path: @@ -130,13 +129,11 @@ def _get_cron_dir() -> Path: 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). + monkey-patched by tests — detected by mismatch with its frozen import-time + default (``_CRON_DIR_IMPORT_DEFAULT``), never a live get_hermes_home() re-eval. """ - try: - if CRON_DIR != get_hermes_home() / "cron": - return CRON_DIR - except Exception: - pass + if CRON_DIR != _CRON_DIR_IMPORT_DEFAULT: + return CRON_DIR return get_hermes_home() / "cron" From ce5978e28a7a811f98f9c74c8ba4b5e9f01882fb Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Sun, 12 Jul 2026 10:03:23 +0900 Subject: [PATCH 09/29] test(cron): expect agent_id on delivery targets after multi-agent rebase _resolve_delivery_target now carries an agent_id field. Four TELEGRAM_CRON_THREAD_ID delivery-target assertions added upstream after this PR's base still asserted the pre-multi-agent dict shape; add the agent_id key. Co-Authored-By: Claude Opus 4.8 --- tests/cron/test_scheduler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index f23d1f5f7df77..90d12320a2fd8 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -227,6 +227,7 @@ def test_telegram_cron_thread_id_overrides_home_thread_id(self, monkeypatch): "platform": "telegram", "chat_id": "-1001234567890", "thread_id": "42", + "agent_id": None, } @@ -239,6 +240,7 @@ def test_explicit_telegram_topic_target_overrides_cron_thread_id(self, monkeypat "platform": "telegram", "chat_id": "-1003724596514", "thread_id": "17", + "agent_id": None, } From 4a72c08b7d66f2f1647f3c4a3bf0ee879abd4bc2 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Sun, 12 Jul 2026 12:27:19 +0900 Subject: [PATCH 10/29] fix(gateway): default _default_agent_id at class level for partial-init adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The routing change (route inbound messages via routes table + select_agent hook) reads self._default_agent_id in _attach_agent_id, but the attribute was only set in __init__ and set_routing_context. Adapters and test doubles built via object.__new__ (bypassing __init__) hit an AttributeError at dispatch — gateway/platforms/base.py:2936 — surfacing as failures across gateway tests (_DummyAdapter / TelegramAdapter has no attribute '_default_agent_id'). Declare it as a class-level default of "main", matching how _attach_agent_id already tolerates a missing _gateway_routes/_gateway_ref (both read inside try/except). Single-agent installs resolve to the legacy "main" agent exactly as before; instances still override it in __init__ and set_routing_context. Co-Authored-By: Claude Opus 4.8 --- gateway/platforms/base.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index ef03997efc099..d5cd591208f27 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2898,6 +2898,14 @@ class BasePlatformAdapter(ABC): - Handling media """ + # Multi-agent routing default. ``__init__`` sets this per-instance and + # ``set_routing_context`` overrides it, but it is declared here as a class + # attribute so ``_attach_agent_id`` resolves to the legacy "main" agent even + # on adapters or test doubles whose ``__init__`` was bypassed or partially + # run — matching how that method already tolerates a missing + # ``_gateway_routes``/``_gateway_ref``. Single-agent installs never touch it. + _default_agent_id: str = "main" + # Whether this platform renders triple-backtick fenced code blocks (i.e. # ``format_message`` translates/preserves markdown fences into a real code # block). Capability flag for markdown-aware presentation choices. From ac252cd46fdf6b205e2b51c0a513f42aec38852e Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Sun, 12 Jul 2026 12:27:19 +0900 Subject: [PATCH 11/29] test(cli): expect agent_id on session-finalize hook after multi-agent rebase _notify_session_finalize now resolves and forwards agent_id into the on_session_finalize plugin hook (single-agent installs pass None). Update the two single-query finalize assertions to include "agent_id": None, matching the delivery-target test sync done for cron. Co-Authored-By: Claude Opus 4.8 --- tests/cli/test_single_query_session_finalize.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/cli/test_single_query_session_finalize.py b/tests/cli/test_single_query_session_finalize.py index d754c6ae91eff..82895d40f38af 100644 --- a/tests/cli/test_single_query_session_finalize.py +++ b/tests/cli/test_single_query_session_finalize.py @@ -76,6 +76,7 @@ def invoke_hook(name, **kwargs): "session_id": "agent-session", "platform": "cli", "reason": "shutdown", + "agent_id": None, }, ) ] From b9d0d08ac947a611515c97003c994f185cec153a Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Sun, 12 Jul 2026 13:03:15 +0900 Subject: [PATCH 12/29] fix(gateway): resolve profile api_key_env through the secret scope _apply_profile_runtime_overrides read the profile's pinned credential with os.getenv, bypassing the fail-closed scoped secret reads that multiplexing relies on (agent/secret_scope.py). Under gateway.multiplex_profiles the process environment may hold another profile's value, so the bypass risked handing one agent a different profile's credential. Route the read through agent.secret_scope.get_secret: scoped turns resolve from their own secret scope, unscoped reads under multiplexing raise UnscopedSecretError instead of leaking, and single-profile installs keep the legacy os.getenv behavior. Add regression tests covering two multiplexed profiles resolving distinct keys and the unscoped fail-closed path. Co-Authored-By: Claude Fable 5 --- gateway/run.py | 12 ++++-- tests/gateway/test_profile_overrides.py | 54 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index d525b36ace31a..365f2e9520958 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8231,9 +8231,15 @@ def _apply_profile_runtime_overrides( 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 - ) + # Resolve the pinned key through the secret scope, not os.environ: + # under gateway.multiplex_profiles the process environment may hold + # another profile's credential, and an unscoped read must fail closed + # (UnscopedSecretError) rather than leak it. Single-profile installs + # keep the legacy os.getenv behavior. + explicit_api_key = None + if profile.api_key_env: + from agent.secret_scope import get_secret + explicit_api_key = get_secret(profile.api_key_env) try: from hermes_cli.runtime_provider import resolve_runtime_provider new_runtime = resolve_runtime_provider( diff --git a/tests/gateway/test_profile_overrides.py b/tests/gateway/test_profile_overrides.py index 19bfe5981d0c4..3b6a5cfa3476e 100644 --- a/tests/gateway/test_profile_overrides.py +++ b/tests/gateway/test_profile_overrides.py @@ -171,6 +171,60 @@ def fake_resolve(*, requested, explicit_api_key, explicit_base_url, target_model assert captured["explicit_api_key"] == "sk-coder-secret" + def test_api_key_env_honors_secret_scope_two_multiplexed_profiles(self, monkeypatch): + """Under gateway.multiplex_profiles, ``api_key_env`` resolves through + the profile's secret scope — never ``os.environ``, which may hold + another profile's credential.""" + from agent import secret_scope as ss + + 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 + ) + # A process-global value that must NOT leak into either profile. + monkeypatch.setenv("AGENT_API_KEY", "sk-global-leak") + + runner = MagicMock(spec=GatewayRunner) + fn = _bound_method("_apply_profile_runtime_overrides") + ss.set_multiplex_active(True) + try: + for agent_id, key in (("coder", "sk-coder"), ("research", "sk-research")): + profile = AgentProfile( + id=agent_id, provider="anthropic", api_key_env="AGENT_API_KEY" + ) + tok = ss.set_secret_scope({"AGENT_API_KEY": key}) + try: + with use_profile(profile): + _, runtime = fn(runner, "m", {}) + finally: + ss.reset_secret_scope(tok) + assert captured["explicit_api_key"] == key + assert runtime["api_key"] == key + finally: + ss.set_multiplex_active(False) + + def test_api_key_env_fails_closed_when_unscoped_multiplexed(self, monkeypatch): + """An unscoped ``api_key_env`` read in multiplex mode raises instead of + silently reading another profile's process-global value.""" + from agent import secret_scope as ss + + monkeypatch.setenv("AGENT_API_KEY", "sk-global-leak") + runner = MagicMock(spec=GatewayRunner) + fn = _bound_method("_apply_profile_runtime_overrides") + profile = AgentProfile(id="coder", provider="anthropic", api_key_env="AGENT_API_KEY") + ss.set_multiplex_active(True) + try: + with use_profile(profile): + with pytest.raises(ss.UnscopedSecretError): + fn(runner, "m", {}) + finally: + ss.set_multiplex_active(False) + 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.""" From 51b7c1b5aa435bd529242a5556c76f7d854440b6 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Sun, 12 Jul 2026 13:03:27 +0900 Subject: [PATCH 13/29] fix(cli): anchor agent --from-profile clones at the profile root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes agent add --from-profile resolved both source and destination as get_hermes_home() / "profiles" / . When the command runs inside a named profile (hermes -p agent add), HERMES_HOME is itself /profiles/, so the clone landed nested at /profiles//profiles/ — where the root-anchored profile enumeration (hermes_cli/profiles.py) never looks. Delegate cloning to the existing root-anchored API, hermes_cli.profiles.create_profile(clone_from=..., clone_all=True, no_alias=True), which also brings name validation, the default-profile source guard, runtime-file stripping, and .env permission tightening for free. Add a regression test invoking the command from a named profile and asserting the clone lands under the root profiles/ dir. Co-Authored-By: Claude Fable 5 --- hermes_cli/agent.py | 31 +++++++++--------- tests/hermes_cli/test_agent_cli.py | 50 +++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/hermes_cli/agent.py b/hermes_cli/agent.py index 07853f723cae6..53a5771ec6db2 100644 --- a/hermes_cli/agent.py +++ b/hermes_cli/agent.py @@ -7,7 +7,6 @@ hermes agent remove Remove an agent (warns about orphaned routes) """ -import shutil from pathlib import Path from typing import Dict, List, Optional, Any @@ -176,26 +175,26 @@ def cmd_agent_add(args) -> int: if args.enabled_toolsets: spec["enabled_toolsets"] = args.enabled_toolsets.split(",") - # If cloning from an existing profile, copy directory + # If cloning from an existing profile, delegate to the root-anchored + # profile APIs. get_hermes_home() may itself point at a named profile + # (when invoked via `hermes -p agent add`), so anchoring + # ``profiles/`` below it would nest the clone where profile + # enumeration (hermes_cli.profiles) never looks. 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 + from hermes_cli.profiles import create_profile 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: + dst = create_profile( + agent_id, + clone_from=args.from_profile, + clone_all=True, + no_alias=True, + ) + except (ValueError, FileExistsError, FileNotFoundError) as e: print(_red(f"Failed to clone profile: {e}")) return 1 + spec["home_dir"] = str(dst) + print(_green(f"Cloned profile from '{args.from_profile}' to {dst}")) cfg["agents"][agent_id] = spec save_config(cfg) diff --git a/tests/hermes_cli/test_agent_cli.py b/tests/hermes_cli/test_agent_cli.py index abf1bbf5675cf..21c3cdfb6dc83 100644 --- a/tests/hermes_cli/test_agent_cli.py +++ b/tests/hermes_cli/test_agent_cli.py @@ -289,7 +289,55 @@ def test_clone_missing_source(self, fresh_home, monkeypatch, capsys): )) assert rv == 1 out = capsys.readouterr().out - assert "not found" in out + assert "does not exist" in out + + def test_clone_is_root_anchored_from_named_profile(self, fresh_home, monkeypatch, capsys): + """``--from-profile`` resolves against the profile ROOT even when the + command runs inside a named profile (``hermes -p coder agent add``, + i.e. HERMES_HOME=/profiles/coder). The pre-fix behavior nested + the clone at /profiles/coder/profiles/, where profile + enumeration never looks.""" + src = fresh_home / "profiles" / "main" + src.mkdir(parents=True) + (src / "SOUL.md").write_text("# Main SOUL") + + # Simulate running from a named profile: HERMES_HOME is the profile dir. + active = fresh_home / "profiles" / "coder" + active.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(active)) + try: + import hermes_constants + hermes_constants._cached_hermes_home = None # type: ignore[attr-defined] + except Exception: + pass + + saved = {"agents": {}, "routes": [], "default_agent": "main"} + + def fake_load(): + return saved.copy() + + def fake_save(cfg): + nonlocal saved + saved = cfg + + 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 + root_dst = fresh_home / "profiles" / "cloned" + assert (root_dst / "SOUL.md").exists() + assert saved["agents"]["cloned"]["home_dir"] == str(root_dst) + # The old bug: profiles/ nested below the active profile's home. + assert not (active / "profiles").exists() # --------------------------------------------------------------------------- From 51bef4b8e44407f5a76372a1d627435f3d71b31d Mon Sep 17 00:00:00 2001 From: David Gutowsky Date: Sun, 12 Jul 2026 13:04:29 +0000 Subject: [PATCH 14/29] feat(gateway): wire api_server adapter to multi-agent routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports David Gutowsky's original multi-agent routing wiring (commit 643bbbf5a, written against v0.17.0) onto jethac's rebased feat/single-gateway-multi-agent-rebased branch. Changes: - Import MessageEvent, MessageType from gateway.platforms.base and SessionSource from gateway.session (already present in the current tree; v0.17.0 had them in a different location). - Add _AGENT_CHAT_ID_HEADER / _USER_ID_HEADER / _THREAD_ID_HEADER class-level constants on APIServerAdapter. - Add _read_routing_header(): same CRLF/length guards as the session key header. - Add _resolve_agent_profile(): builds a synthetic SessionSource from the three routing headers, stamps agent_id via the shared _attach_agent_id hook (declarative routes + select_agent plugin), and looks up the AgentProfile in _gateway_ref._agent_registry. - Add agent_profile: Optional[Any] parameter to _run_agent(); wraps the _create_agent + run_conversation block with use_profile() inside the executor thread (ContextVars do not cross the thread boundary automatically). - Wire _resolve_agent_profile + agent_profile kwarg into all three agent-serving handlers: _handle_chat_completions (both streaming and non-streaming paths), _handle_responses (both paths), and _handle_runs (_run_and_close + _run_sync, the latter also rebinding inside the executor thread). Deviations from original: - _run_agent now has a route param (added after v0.17.0 for model_routes); agent_profile is appended after it. - The _run_and_close / _run_sync refactor inside _handle_runs was already present in the rebased base; the use_profile wrapping is applied at the same points David used on the v0.17.0 shape. - _bind_api_server_session / clear_session_vars were added after v0.17.0; they are preserved in _run_agent, with use_profile wrapping only the inner _create_agent + run_conversation block. Tests: tests/gateway/test_api_server_routing.py — 20 tests covering header sanitisation, route matching (chat_id/user_id/thread_id, platform-only catch-all, specificity, CRLF taint, absent headers, empty registry), ContextVar task isolation, and backward compat. All 20 pass; existing test_api_server.py (196 tests) and test_api_server_runs.py (23 tests) remain green. --- gateway/platforms/api_server.py | 422 +++++++++++++++-------- tests/gateway/test_api_server_routing.py | 372 ++++++++++++++++++++ 2 files changed, 649 insertions(+), 145 deletions(-) create mode 100644 tests/gateway/test_api_server_routing.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 25402eba027a6..f757b93807ae8 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -117,10 +117,13 @@ def _approval_event_choices(*, smart_denied: bool, allow_permanent: bool) -> lis from gateway.platforms.base import ( MEDIA_TAG_CLEANUP_RE, BasePlatformAdapter, + MessageEvent, + MessageType, SendResult, is_network_accessible, validate_media_delivery_path, ) +from gateway.session import SessionSource from agent.redact import redact_sensitive_text from agent.interrupt_compat import request_hard_interrupt from gateway.readiness import collect_runtime_readiness @@ -2306,6 +2309,92 @@ 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``. + + Why: Reuses the same control-character and length caps as the + session headers so a malicious caller cannot inject CRLF or burn + memory by passing a multi-kilobyte chat id. + What: Strips whitespace, rejects CRLF/NUL, caps at + ``_MAX_SESSION_HEADER_LEN``. + Test: Pass headers with CRLF, overlong values, and valid values; + assert None / stripped string returned respectively. + """ + 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*. + + Why: Bridges the inbound X-Hermes-* routing headers to the shared + ``_attach_agent_id`` hook (declarative routes + ``select_agent`` + plugin) and the gateway's AgentProfile registry so every HTTP + endpoint can route to the correct per-agent home directory. + What: Builds a synthetic ``SessionSource`` from the routing + headers, stamps ``agent_id`` via ``_attach_agent_id``, then looks + up the resolved id in ``_gateway_ref._agent_registry``. Returns + ``(profile, agent_id)``. When no profile is registered (legacy + single-agent install) ``profile`` is ``None`` — callers should + simply skip the ``use_profile`` wrapper. + Test: Pass a request with ``X-Hermes-Chat-Id: calendar-propose`` + where a route maps that chat_id → "calendar-propose" and the + registry has an AgentProfile for that id; assert the returned + profile is the expected object and agent_id matches. + """ + 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 # ------------------------------------------------------------------ @@ -4969,6 +5058,11 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons if limited is not None: return limited + # 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() @@ -5195,6 +5289,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul gateway_session_key=gateway_session_key, **agent_overrides, route=route, + agent_profile=agent_profile, )) # Ensure SSE drain loops can terminate without relying on polling # agent_task.done(), which can race with queue timeout checks. @@ -5216,6 +5311,7 @@ async def _compute_completion(): gateway_session_key=gateway_session_key, **agent_overrides, route=route, + agent_profile=agent_profile, ) idempotency_key = request.headers.get("Idempotency-Key") @@ -6136,6 +6232,10 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response": if key_err is not None: return key_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) + # Parse request body try: body = await request.json() @@ -6306,6 +6406,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul gateway_session_key=gateway_session_key, **agent_overrides, route=route, + agent_profile=agent_profile, )) # Ensure SSE drain loops can terminate without relying on polling # agent_task.done(), which can race with queue timeout checks. @@ -6341,6 +6442,7 @@ async def _compute_response(): gateway_session_key=gateway_session_key, **agent_overrides, route=route, + agent_profile=agent_profile, ) idempotency_key = request.headers.get("Idempotency-Key") @@ -7141,6 +7243,7 @@ async def _run_agent( requested_runtime: Optional[Dict[str, Any]] = None, route_source: str = "global", confirmed_runtime_lock: bool = False, + agent_profile: Optional[Any] = None, ) -> tuple: """ Create an agent and run a conversation in a thread executor. @@ -7170,6 +7273,13 @@ async def _run_agent( If *active_run_id* is supplied, the same live agent is registered in ``_active_run_agents`` while the turn is running so API clients can call run-scoped control endpoints such as ``/v1/runs/{run_id}/steer``. + + 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() # Capture before hopping to the executor — ContextVars do not follow @@ -7184,6 +7294,7 @@ async def _run_agent( ) def _run(): + from agent.profile import use_profile from gateway.session_context import clear_session_vars with self._profile_scope(request_profile): @@ -7198,133 +7309,140 @@ def _run(): ) agent = None try: - 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, - requested_model=requested_model, - requested_provider=requested_provider, - model_options=model_options, - route=route, - session_model=session_model, - confirmed_runtime_lock=confirmed_runtime_lock, - ) - if agent_ref is not None: - agent_ref[0] = agent - if active_run_id: - self._active_run_agents[active_run_id] = agent - effective_task_id = session_id or str(uuid.uuid4()) - # Baseline for selective background-process reaping on - # SSE client disconnect — mirrors gateway/run.py's - # gateway-turn cleanup (#76115); this API-server surface - # runs its own agent lifecycle and doesn't go through - # TurnRunner, so it needs its own baseline. - _publish_turn_process_ownership(agent, effective_task_id) - # Shutdown interrupt coverage (#63529). Registering here, - # once, covers every _run_agent() caller — the same reason - # the _ProviderAuthResolutionError handler below lives here - # rather than in each route. Only two callers pass - # ``agent_ref``, and only /v1/runs has a run_id, so neither - # is a usable hook for the rest. - self._shutdown_interruptible_agents[id(agent)] = agent - 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 - # Signal whether context compression occurred during this turn - # so _build_response_conversation_history can skip the - # prior-concatenation path and store the compressed transcript - # directly. Rotation mode changes agent.session_id; in-place - # mode sets _last_compaction_in_place (see #38763). - _compacted_in_place = bool(getattr(agent, "_last_compaction_in_place", False)) - _session_rotated = ( - isinstance(_eff_sid, str) and isinstance(session_id, str) - and _eff_sid != session_id - ) - if _compacted_in_place or _session_rotated: - result["_compressed"] = True - include_runtime = bool( - requested_runtime - or route - or confirmed_runtime_lock - or (route_source and route_source != "global") - ) - if include_runtime: - runtime = dict(getattr(agent, "_hermes_api_runtime", {}) or {}) - raw_provider = getattr(agent, "provider", "") - raw_model = getattr(agent, "model", "") - actual_provider = ( - self._clean_runtime_id(raw_provider, max_len=80) - if isinstance(raw_provider, str) - else "" + # 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. Nested inside the + # profile scope (which selects the multiplex profile home / + # #61276 default) so the agent scope refines it. + 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, + requested_model=requested_model, + requested_provider=requested_provider, + model_options=model_options, + route=route, + session_model=session_model, + confirmed_runtime_lock=confirmed_runtime_lock, ) - actual_model = ( - self._clean_runtime_id(raw_model) - if isinstance(raw_model, str) - else "" + if agent_ref is not None: + agent_ref[0] = agent + if active_run_id: + self._active_run_agents[active_run_id] = agent + effective_task_id = session_id or str(uuid.uuid4()) + # Baseline for selective background-process reaping on + # SSE client disconnect — mirrors gateway/run.py's + # gateway-turn cleanup (#76115); this API-server surface + # runs its own agent lifecycle and doesn't go through + # TurnRunner, so it needs its own baseline. + _publish_turn_process_ownership(agent, effective_task_id) + # Shutdown interrupt coverage (#63529). Registering here, + # once, covers every _run_agent() caller — the same reason + # the _ProviderAuthResolutionError handler below lives here + # rather than in each route. Only two callers pass + # ``agent_ref``, and only /v1/runs has a run_id, so neither + # is a usable hook for the rest. + self._shutdown_interruptible_agents[id(agent)] = agent + result = agent.run_conversation( + user_message=user_message, + conversation_history=conversation_history, + task_id=effective_task_id, ) - if actual_provider: - runtime["provider"] = actual_provider - else: - runtime.setdefault("provider", "") - if actual_model: - runtime["model"] = actual_model - else: - runtime.setdefault("model", "") - if confirmed_runtime_lock: - expected_provider = self._clean_runtime_id( - (route or {}).get("provider") - or (requested_runtime or {}).get("provider"), - max_len=80, - ) - expected_model = self._clean_runtime_id( - (route or {}).get("model") - or (requested_runtime or {}).get("model") + 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 + # Signal whether context compression occurred during this turn + # so _build_response_conversation_history can skip the + # prior-concatenation path and store the compressed transcript + # directly. Rotation mode changes agent.session_id; in-place + # mode sets _last_compaction_in_place (see #38763). + _compacted_in_place = bool(getattr(agent, "_last_compaction_in_place", False)) + _session_rotated = ( + isinstance(_eff_sid, str) and isinstance(session_id, str) + and _eff_sid != session_id + ) + if _compacted_in_place or _session_rotated: + result["_compressed"] = True + include_runtime = bool( + requested_runtime + or route + or confirmed_runtime_lock + or (route_source and route_source != "global") + ) + if include_runtime: + runtime = dict(getattr(agent, "_hermes_api_runtime", {}) or {}) + raw_provider = getattr(agent, "provider", "") + raw_model = getattr(agent, "model", "") + actual_provider = ( + self._clean_runtime_id(raw_provider, max_len=80) + if isinstance(raw_provider, str) + else "" ) - mismatched = ( - (expected_provider and actual_provider != expected_provider) - or (expected_model and actual_model != expected_model) + actual_model = ( + self._clean_runtime_id(raw_model) + if isinstance(raw_model, str) + else "" ) - if mismatched: - raise RuntimeError( - "confirmed model lock runtime mismatch: " - f"expected provider={expected_provider or ''} " - f"model={expected_model or ''}; " - f"actual provider={actual_provider or ''} " - f"model={actual_model or ''}" + if actual_provider: + runtime["provider"] = actual_provider + else: + runtime.setdefault("provider", "") + if actual_model: + runtime["model"] = actual_model + else: + runtime.setdefault("model", "") + if confirmed_runtime_lock: + expected_provider = self._clean_runtime_id( + (route or {}).get("provider") + or (requested_runtime or {}).get("provider"), + max_len=80, ) - if requested_runtime: - runtime["requested"] = { - "provider": self._clean_runtime_id((requested_runtime or {}).get("provider"), max_len=80), - "model": self._clean_runtime_id((requested_runtime or {}).get("model")), - } - runtime["route_source"] = route_source or runtime.get("route_source") or "global" - runtime = self._sanitize_runtime_metadata( - runtime=runtime, - requested_runtime=requested_runtime, - route_source=route_source or "global", - model_lock=("confirmed" if confirmed_runtime_lock else ""), - ) - if isinstance(result, dict): - result["runtime"] = runtime - usage["runtime"] = runtime - return result, usage + expected_model = self._clean_runtime_id( + (route or {}).get("model") + or (requested_runtime or {}).get("model") + ) + mismatched = ( + (expected_provider and actual_provider != expected_provider) + or (expected_model and actual_model != expected_model) + ) + if mismatched: + raise RuntimeError( + "confirmed model lock runtime mismatch: " + f"expected provider={expected_provider or ''} " + f"model={expected_model or ''}; " + f"actual provider={actual_provider or ''} " + f"model={actual_model or ''}" + ) + if requested_runtime: + runtime["requested"] = { + "provider": self._clean_runtime_id((requested_runtime or {}).get("provider"), max_len=80), + "model": self._clean_runtime_id((requested_runtime or {}).get("model")), + } + runtime["route_source"] = route_source or runtime.get("route_source") or "global" + runtime = self._sanitize_runtime_metadata( + runtime=runtime, + requested_runtime=requested_runtime, + route_source=route_source or "global", + model_lock=("confirmed" if confirmed_runtime_lock else ""), + ) + if isinstance(result, dict): + result["runtime"] = runtime + usage["runtime"] = runtime + return result, usage except _ProviderAuthResolutionError as exc: # Only _ProviderAuthResolutionError — raised exclusively # where _resolve_runtime_agent_kwargs() is called inside @@ -7499,6 +7617,10 @@ async def _handle_runs(self, request: "web.Request") -> "web.Response": if key_err is not None: return key_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) + # Enforce concurrency limit (shared across all agent-serving # endpoints; configurable via gateway.api_server.max_concurrent_runs). limited = self._concurrency_limited_response() @@ -7638,6 +7760,11 @@ def _text_cb(delta: Optional[str]) -> None: async def _run_and_close(): try: self._set_run_status(run_id, "running") + # Upstream early-cancel guard: if the client already asked to + # stop this run before it started, emit run.cancelled and bail + # before spinning up an agent. Agent creation itself now lives + # inside _run_sync (below) so it can run under the routed agent + # profile in the executor thread. if run_id in self._stopping_run_ids: _put_event_if_active({ "event": "run.cancelled", @@ -7650,19 +7777,6 @@ async def _run_and_close(): last_event="run.cancelled", ) return - with self._profile_scope(request_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, - requested_model=agent_overrides.get("requested_model"), - requested_provider=agent_overrides.get("requested_provider"), - model_options=agent_overrides.get("model_options"), - route=route, - ) - self._active_run_agents[run_id] = agent def _approval_notify(approval_data: Dict[str, Any]) -> None: event = dict(approval_data or {}) @@ -7694,6 +7808,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 from tools.approval import ( register_gateway_notify, @@ -7731,16 +7846,33 @@ def _run_sync(): ), ) register_gateway_notify(approval_session_key, _approval_notify) - # /v1/runs runs its own agent lifecycle (no - # TurnRunner, no _run_agent) — record turn process - # ownership so stop/cancel can reap only the - # background processes this run created (#76115). - _publish_turn_process_ownership(agent, effective_task_id) - r = agent.run_conversation( - user_message=user_message, - conversation_history=conversation_history, - task_id=effective_task_id, - ) + # Bind the routed agent profile inside the executor thread + # (asyncio's default executor does not copy ContextVars). + # Nested inside the profile scope so the agent scope refines + # the multiplex profile home / #61276 default. + with _use_profile_thread(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, + requested_model=agent_overrides.get("requested_model"), + requested_provider=agent_overrides.get("requested_provider"), + model_options=agent_overrides.get("model_options"), + route=route, + ) + self._active_run_agents[run_id] = agent + # /v1/runs runs its own agent lifecycle (no + # TurnRunner, no _run_agent) — record turn process + # ownership so stop/cancel can reap only the + # background processes this run created (#76115). + _publish_turn_process_ownership(agent, effective_task_id) + r = agent.run_conversation( + user_message=user_message, + conversation_history=conversation_history, + task_id=effective_task_id, + ) finally: # Worker finished (interrupted or complete) — # clear turn ownership immediately so a later diff --git a/tests/gateway/test_api_server_routing.py b/tests/gateway/test_api_server_routing.py new file mode 100644 index 0000000000000..15d6f7a11f8dc --- /dev/null +++ b/tests/gateway/test_api_server_routing.py @@ -0,0 +1,372 @@ +"""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``. + +Ported from David Gutowsky's original #25660-era commit (643bbbf5a) onto +jethac's rebased single-gateway-multi-agent branch. Session-context +plumbing and ``_bind_api_server_session`` were added after v0.17.0; the +routing methods themselves are unchanged. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +from agent.profile import AgentProfile, DEFAULT_AGENT_ID, get_active_profile +from gateway.config import 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*. + + Why: Mirrors what ``GatewayRunner`` does at startup — calls + ``set_routing_context`` with a fake gateway that exposes + ``_agent_registry`` — so routing tests don't need a live server. + What: Constructs adapter, fakes the gateway ref with a registry, + and calls ``set_routing_context``. + Test: All tests in this module use this fixture; assert the returned + adapter has the expected routing context set up. + """ + 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. + + Why: Avoids standing up a full aiohttp server for unit tests. + What: Returns a MagicMock whose ``headers`` attribute behaves like a + dict (``get`` works, key access works). + Test: Pass ``{"X-Hermes-Chat-Id": "val"}``; assert + ``req.headers.get("X-Hermes-Chat-Id")`` returns "val". + """ + 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" From bbdb8e6dc0b217774202365e82ffc2706d852cbd Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Mon, 13 Jul 2026 00:23:23 +0900 Subject: [PATCH 15/29] fix(gateway): scope profile credentials in api_server routed runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api_server routed-run path bound the agent profile's HOME (via use_profile) but not its fail-closed credential scope. Under gateway.multiplex_profiles the routed agent's LLM/provider key — resolved through credential_pool -> get_secret — would then fail closed (UnscopedSecretError) or read another profile's process-global value, since provider keys are profile-scoped, not global env. Install the profile's secret scope alongside its home in both executor threads, mirroring the base adapter's _profile_runtime_scope, via a shared _use_profile_and_secret_scope helper (None profile = home no-op). Tests: TestSecretScopeBinding (helper installs fail-closed scope, two profiles resolve their own key, None is a no-op) and TestRunAgentInstallsScope (the run path itself enters the scope at agent-creation time — mutation-verified to fail if the fix is reverted). Co-Authored-By: Claude Opus 4.8 --- gateway/platforms/api_server.py | 58 +++++++-- tests/gateway/test_api_server_routing.py | 154 ++++++++++++++++++++++- 2 files changed, 198 insertions(+), 14 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index f757b93807ae8..f7913213f7d4b 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -42,6 +42,7 @@ import asyncio import concurrent.futures +import contextlib import errno import hashlib import hmac @@ -1456,6 +1457,39 @@ class _ProviderAuthResolutionError(RuntimeError): """ +@contextlib.contextmanager +def _use_profile_and_secret_scope(profile): + """Bind a routed agent profile's HOME **and** its fail-closed credential scope for one run. + + ``use_profile`` alone routes the home (``get_hermes_home`` reads the profile ContextVar) but + does NOT install the credential scope. Under ``gateway.multiplex_profiles`` that leaves + ``get_secret`` — reached in the agent run via ``credential_pool`` when resolving the LLM/provider + key — *unscoped*, so it fails closed (``UnscopedSecretError``) or reads another profile's + process-global value. This mirrors the base adapter's ``_profile_runtime_scope``: install the + profile's ``.env`` secret scope alongside the home binding. ``None`` is a no-op, so single-agent + installs pay nothing. Bind inside the executor thread — ContextVars don't cross the boundary. + """ + from agent.profile import use_profile + + if profile is None: + with use_profile(None): + yield + return + + from agent.secret_scope import ( + build_profile_secret_scope, + reset_secret_scope, + set_secret_scope, + ) + + with use_profile(profile): + secret_token = set_secret_scope(build_profile_secret_scope(profile.resolved_home)) + try: + yield + finally: + reset_secret_scope(secret_token) + + class APIServerAdapter(BasePlatformAdapter): """ OpenAI-compatible HTTP API server adapter. @@ -7294,7 +7328,6 @@ async def _run_agent( ) def _run(): - from agent.profile import use_profile from gateway.session_context import clear_session_vars with self._profile_scope(request_profile): @@ -7309,13 +7342,13 @@ def _run(): ) agent = None try: - # 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. Nested inside the - # profile scope (which selects the multiplex profile home / + # Bind the routed agent profile inside the executor thread so + # path getters (get_hermes_home, skills_dir, …) AND credential + # reads (get_secret via credential_pool) resolve to this agent. + # Home + secret scope together; None is a no-op. Nested inside + # the profile scope (which selects the multiplex profile home / # #61276 default) so the agent scope refines it. - with use_profile(agent_profile): + with _use_profile_and_secret_scope(agent_profile): agent = self._create_agent( ephemeral_system_prompt=ephemeral_system_prompt, session_id=session_id, @@ -7808,7 +7841,6 @@ 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 from tools.approval import ( register_gateway_notify, @@ -7846,11 +7878,11 @@ def _run_sync(): ), ) register_gateway_notify(approval_session_key, _approval_notify) - # Bind the routed agent profile inside the executor thread - # (asyncio's default executor does not copy ContextVars). - # Nested inside the profile scope so the agent scope refines - # the multiplex profile home / #61276 default. - with _use_profile_thread(agent_profile): + # Bind the routed agent profile (home + fail-closed secret scope) + # inside the executor thread — asyncio's default executor does not + # copy ContextVars. Nested inside the profile scope so the agent + # scope refines the multiplex profile home / #61276 default. + with _use_profile_and_secret_scope(agent_profile): agent = self._create_agent( ephemeral_system_prompt=ephemeral_system_prompt, session_id=session_id, diff --git a/tests/gateway/test_api_server_routing.py b/tests/gateway/test_api_server_routing.py index 15d6f7a11f8dc..d5b6ee3066d19 100644 --- a/tests/gateway/test_api_server_routing.py +++ b/tests/gateway/test_api_server_routing.py @@ -19,9 +19,14 @@ import asyncio from unittest.mock import MagicMock +import pytest + from agent.profile import AgentProfile, DEFAULT_AGENT_ID, get_active_profile from gateway.config import PlatformConfig -from gateway.platforms.api_server import APIServerAdapter +from gateway.platforms.api_server import ( + APIServerAdapter, + _use_profile_and_secret_scope, +) # --------------------------------------------------------------------------- @@ -370,3 +375,150 @@ def test_legacy_no_routes_no_agents_returns_main(self): def test_default_agent_id_constant_is_main(self): """Anchor the default-agent contract.""" assert DEFAULT_AGENT_ID == "main" + + +class TestSecretScopeBinding: + """Regression coverage for the credential-scope half of profile routing. + + The gap this guards against (found in architectural review of the api_server routing): + ``use_profile`` alone routes the *home* (``get_hermes_home`` → SOUL / memory / skills) but + leaves ``get_secret`` **unscoped**. Under ``gateway.multiplex_profiles`` the routed agent's + LLM/provider key — resolved in the run via ``credential_pool`` → ``get_secret`` — would then + fail closed (``UnscopedSecretError``) or read another profile's process-global value. The + api_server path must therefore install the profile's fail-closed secret scope alongside the + home, mirroring the base adapter's ``_profile_runtime_scope``. + """ + + def test_profile_guard_installs_fail_closed_secret_scope(self, tmp_path, monkeypatch): + from agent import secret_scope as ss + + home = tmp_path / "coder" + home.mkdir() + (home / ".env").write_text("AGENT_API_KEY=sk-coder-scoped\n") + profile = AgentProfile(id="coder", home_dir=home, api_key_env="AGENT_API_KEY") + + # A process-global value that must NEVER leak into the scoped read. + monkeypatch.setenv("AGENT_API_KEY", "sk-global-leak") + monkeypatch.setattr(ss, "_MULTIPLEX_ACTIVE", True) + + # Before entering the guard: exactly the failure the gap produced — + # multiplex on + no scope → fail closed rather than leak the global. + with pytest.raises(ss.UnscopedSecretError): + ss.get_secret("AGENT_API_KEY") + + # Inside the api_server profile guard: resolves the PROFILE's scoped key + # (from its .env), never the process-global leak, and never fail-closed — + # while the home binding (get_active_profile) is simultaneously in place. + with _use_profile_and_secret_scope(profile): + assert ss.get_secret("AGENT_API_KEY") == "sk-coder-scoped" + assert get_active_profile() is profile + + # Scope is torn down on exit — no leakage past the run. + with pytest.raises(ss.UnscopedSecretError): + ss.get_secret("AGENT_API_KEY") + assert get_active_profile() is None + + def test_two_profiles_resolve_their_own_scoped_key(self, tmp_path, monkeypatch): + """Sequential runs for different agents each see only their own credential — + the cross-profile isolation that multiplexing exists to guarantee.""" + from agent import secret_scope as ss + + monkeypatch.setattr(ss, "_MULTIPLEX_ACTIVE", True) + monkeypatch.setenv("AGENT_API_KEY", "sk-global-leak") + + seen = {} + for name, key in (("coder", "sk-coder"), ("research", "sk-research")): + home = tmp_path / name + home.mkdir() + (home / ".env").write_text(f"AGENT_API_KEY={key}\n") + profile = AgentProfile(id=name, home_dir=home, api_key_env="AGENT_API_KEY") + with _use_profile_and_secret_scope(profile): + seen[name] = ss.get_secret("AGENT_API_KEY") + + assert seen == {"coder": "sk-coder", "research": "sk-research"} + + def test_none_profile_is_noop_no_scope_installed(self, monkeypatch): + """Single-agent path (no routed profile): no scope is installed, so legacy + ``os.environ`` behavior is preserved and callers pay nothing.""" + from agent import secret_scope as ss + + # Even with multiplex flag on, a None profile must not install a scope + # (there is no profile home to scope to) — it is a pure home no-op. + monkeypatch.setattr(ss, "_MULTIPLEX_ACTIVE", False) + monkeypatch.setenv("SOME_KEY", "from-env") + + with _use_profile_and_secret_scope(None): + assert ss.current_secret_scope() is None + assert ss.get_secret("SOME_KEY") == "from-env" + assert get_active_profile() is None + + +class TestRunAgentInstallsScope: + """The *wiring* regression — proves ``_run_agent`` itself enters the profile's + credential scope, not merely that the helper works in isolation. + + This is the test that would have caught the original gap. Before the fix the + executor closure bound ``use_profile(agent_profile)`` alone, so ``_create_agent`` + (and the ``run_conversation`` it drives) ran with the *home* routed but the + *secret scope* absent — under ``multiplex_profiles`` the agent's LLM key would + fail closed or read another profile's process-global value. Here we spy on + ``_create_agent`` and assert that, at the moment the agent is built inside the + executor thread, the profile's scoped credential resolves and the process-global + leak does not. + """ + + @pytest.mark.asyncio + async def test_run_agent_creates_agent_inside_profile_secret_scope( + self, tmp_path, monkeypatch + ): + from agent import secret_scope as ss + + home = tmp_path / "coder" + home.mkdir() + (home / ".env").write_text("AGENT_API_KEY=sk-coder-scoped\n") + profile = AgentProfile( + id="coder", home_dir=home, api_key_env="AGENT_API_KEY" + ) + + # Multiplex on + a process-global value that must NOT leak into the run. + monkeypatch.setattr(ss, "_MULTIPLEX_ACTIVE", True) + monkeypatch.setenv("AGENT_API_KEY", "sk-global-leak") + # clear_session_vars is imported inside the executor closure; neutralise it. + monkeypatch.setattr( + "gateway.session_context.clear_session_vars", lambda tokens: None + ) + + adapter = _make_adapter(registry={"coder": profile}) + adapter._bind_api_server_session = lambda **kwargs: None + + # Capture the credential/profile state AT AGENT-CREATION TIME (executor thread). + seen = {} + + def _spy_create_agent(**kwargs): + seen["scope"] = ss.current_secret_scope() + seen["profile"] = get_active_profile() + try: + seen["key"] = ss.get_secret("AGENT_API_KEY") + except ss.UnscopedSecretError as exc: # the bug's signature + seen["key"] = exc + agent = MagicMock() + agent.run_conversation.return_value = {} + return agent + + adapter._create_agent = _spy_create_agent + + await adapter._run_agent( + user_message="hi", + conversation_history=[], + agent_profile=profile, + ) + + # The scope was live when the agent was built — not fail-closed, not leaked. + assert seen["profile"] is profile + assert seen["scope"] is not None + assert seen["key"] == "sk-coder-scoped" + + # And it is torn down once the run returns. + assert get_active_profile() is None + with pytest.raises(ss.UnscopedSecretError): + ss.get_secret("AGENT_API_KEY") From f97d2677bc3d39620cd9d7f15593b9ab4d1f1962 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Mon, 13 Jul 2026 00:23:23 +0900 Subject: [PATCH 16/29] test(multi-agent): cover agent_id routing, isolation & persistence Fill coverage gaps found auditing the single-gateway/multi-agent PR against its base. Tests only; no source changes. - delivery: DeliveryRouter.deliver runs each target inside its routed AgentProfile; unknown/absent agent_id falls back to nullcontext (cross-agent leak class). - base adapter: _attach_agent_id resolution order, idempotency, select_agent-hook override, and fail-open on resolver/hook/replace errors (the routing linchpin, previously untested). - cron: load_all_jobs/get_all_due_jobs per-profile agent_id stamping (main default, one bad profile does not starve siblings); _resolve_single_delivery_target propagates job.agent_id onto targets; cron-dir stays profile-dynamic while honoring a patched CRON_DIR. - config/state: GatewayConfig.from_dict agents/routes/default_agent parsing + malformed fallbacks; SessionDB.create_session agent_id persistence, legacy-column reconcile, first-writer-wins on conflict. - session: SessionEntry agent_id roundtrip + legacy default; build_session_key agent_id-over-profile precedence. - hooks: post_tool_call / transform_tool_result carry the active profile's agent_id (populated path, not just None). Co-Authored-By: Claude Opus 4.8 --- tests/cron/test_jobs.py | 207 +++++++++ tests/cron/test_scheduler.py | 112 ++++- tests/gateway/test_attach_agent_id.py | 394 ++++++++++++++++++ tests/gateway/test_delivery.py | 97 +++++ .../gateway/test_gateway_config_multiagent.py | 118 ++++++ tests/gateway/test_session.py | 85 ++++ tests/test_hermes_state.py | 126 ++++++ tests/test_model_tools.py | 32 ++ 8 files changed, 1170 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_attach_agent_id.py create mode 100644 tests/gateway/test_gateway_config_multiagent.py diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 8f195ae9ef207..75a46903d6d27 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -1382,3 +1382,210 @@ def test_recurring_jobs_unaffected_by_retention_change(self, tmp_cron_dir): assert updated["enabled"] is True assert updated["state"] == "scheduled" assert updated["next_run_at"] is not None + + +# ========================================================================= +# Multi-agent job loading (load_all_jobs / get_all_due_jobs) — per-profile +# agent_id stamping for the gateway-wide tick. +# ========================================================================= + +class TestLoadAllJobs: + """load_all_jobs stamps each job with the agent_id of the profile it came + from, iterating every profile in the registry under use_profile().""" + + def test_single_profile_backcompat_defaults_agent_id_main(self, tmp_cron_dir): + """registry=None (legacy single-agent path): loads the current profile's + jobs and defaults a MISSING agent_id to 'main' while leaving any + pre-existing agent_id untouched.""" + from cron.jobs import load_all_jobs + + save_jobs([ + {"id": "j1", "name": "no-agent-id", + "schedule": {"kind": "interval", "minutes": 5}}, + {"id": "j2", "name": "preset-agent-id", "agent_id": "custom", + "schedule": {"kind": "interval", "minutes": 5}}, + ]) + + jobs = load_all_jobs() # registry defaults to None + by_id = {j["id"]: j for j in jobs} + assert by_id["j1"]["agent_id"] == "main" + assert by_id["j2"]["agent_id"] == "custom" + + def test_multi_profile_stamps_each_job_with_its_profile_agent_id(self, tmp_path): + """A multi-profile registry: each profile's jobs.json is loaded under + use_profile(profile) and every job is stamped with THAT profile's + agent_id (the registry key), not another profile's.""" + from agent.profile import AgentProfile, use_profile + from cron.jobs import load_all_jobs + + pa = AgentProfile(id="alpha", home_dir=tmp_path / "alpha") + pb = AgentProfile(id="beta", home_dir=tmp_path / "beta") + registry = {"alpha": pa, "beta": pb} + + with use_profile(pa): + save_jobs([{"id": "a1", "name": "a1", + "schedule": {"kind": "interval", "minutes": 5}}]) + with use_profile(pb): + save_jobs([ + {"id": "b1", "name": "b1", + "schedule": {"kind": "interval", "minutes": 5}}, + # A job that already carries an agent_id must be preserved, + # not overwritten with the profile key. + {"id": "b2", "name": "b2", "agent_id": "preset", + "schedule": {"kind": "interval", "minutes": 5}}, + ]) + + all_jobs = load_all_jobs(registry) + by_id = {j["id"]: j for j in all_jobs} + + # Every profile's jobs came back, isolated to their own store. + assert set(by_id) == {"a1", "b1", "b2"} + assert by_id["a1"]["agent_id"] == "alpha" + assert by_id["b1"]["agent_id"] == "beta" + assert by_id["b2"]["agent_id"] == "preset" + + def test_one_agent_load_failure_does_not_starve_siblings( + self, tmp_path, monkeypatch, caplog + ): + """Resilience (the except -> logger.warning branch): if loading one + agent's jobs raises, the other agents' jobs still come back.""" + import cron.jobs as jobs_mod + from agent.profile import AgentProfile, use_profile, get_active_profile + from cron.jobs import load_all_jobs + + pg = AgentProfile(id="good", home_dir=tmp_path / "good") + pb = AgentProfile(id="bad", home_dir=tmp_path / "bad") + registry = {"good": pg, "bad": pb} + + def fake_load_jobs(): + prof = get_active_profile() + if prof is not None and prof.id == "bad": + raise RuntimeError("boom loading bad agent") + return [{"id": "g1", "name": "g1", + "schedule": {"kind": "interval", "minutes": 5}}] + + monkeypatch.setattr(jobs_mod, "load_jobs", fake_load_jobs) + + with caplog.at_level("WARNING"): + all_jobs = load_all_jobs(registry) + + ids = {j["id"] for j in all_jobs} + assert ids == {"g1"}, "the healthy sibling's jobs were starved by the failing agent" + assert all(j["agent_id"] == "good" for j in all_jobs) + assert any("bad" in rec.getMessage() for rec in caplog.records) + + +class TestGetAllDueJobs: + """get_all_due_jobs iterates every profile, runs get_due_jobs() inside its + context, and stamps the combined list with each job's agent_id.""" + + def test_multi_profile_stamps_due_jobs_per_profile(self, tmp_path): + from agent.profile import AgentProfile, use_profile + from cron.jobs import get_all_due_jobs + + pa = AgentProfile(id="alpha", home_dir=tmp_path / "alpha") + pb = AgentProfile(id="beta", home_dir=tmp_path / "beta") + registry = {"alpha": pa, "beta": pb} + + past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat() + with use_profile(pa): + save_jobs([{"id": "a-due", "name": "a", "enabled": True, + "schedule": {"kind": "interval", "minutes": 5}, + "next_run_at": past}]) + with use_profile(pb): + save_jobs([{"id": "b-due", "name": "b", "enabled": True, + "schedule": {"kind": "interval", "minutes": 5}, + "next_run_at": past}]) + + due = get_all_due_jobs(registry) + by_id = {j["id"]: j for j in due} + assert set(by_id) == {"a-due", "b-due"} + assert by_id["a-due"]["agent_id"] == "alpha" + assert by_id["b-due"]["agent_id"] == "beta" + + def test_one_agent_failure_does_not_starve_siblings( + self, tmp_path, monkeypatch, caplog + ): + """The get_all_due_jobs except -> logger.warning branch: one agent's + get_due_jobs() raising must not drop the other agents' due jobs.""" + import cron.jobs as jobs_mod + from agent.profile import AgentProfile, get_active_profile + from cron.jobs import get_all_due_jobs + + pg = AgentProfile(id="good", home_dir=tmp_path / "good") + pb = AgentProfile(id="bad", home_dir=tmp_path / "bad") + registry = {"good": pg, "bad": pb} + + def fake_get_due_jobs(): + prof = get_active_profile() + if prof is not None and prof.id == "bad": + raise RuntimeError("boom due for bad agent") + return [{"id": "g-due", "name": "g", + "schedule": {"kind": "interval", "minutes": 5}}] + + monkeypatch.setattr(jobs_mod, "get_due_jobs", fake_get_due_jobs) + + with caplog.at_level("WARNING"): + due = get_all_due_jobs(registry) + + assert {j["id"] for j in due} == {"g-due"} + assert all(j["agent_id"] == "good" for j in due) + assert any("bad" in rec.getMessage() for rec in caplog.records) + + +# ========================================================================= +# Profile-dynamic cron-dir resolution (_get_cron_dir / _CRON_DIR_IMPORT_DEFAULT) +# ========================================================================= + +class TestCronDirResolution: + """The dynamic cron-dir getters resolve via the active profile's + get_hermes_home(), but still honour a test-monkeypatched module-level + CRON_DIR (detected by mismatch with its frozen import-time default).""" + + def test_no_override_resolves_via_active_profile_home(self, tmp_path): + from agent.profile import AgentProfile, use_profile + from cron.jobs import ( + _get_cron_dir, _get_jobs_file, _get_output_dir, + CRON_DIR, _CRON_DIR_IMPORT_DEFAULT, + ) + + # Precondition for the dynamic branch: the module constant must still + # equal its frozen import-time snapshot (i.e. not monkeypatched). + assert CRON_DIR == _CRON_DIR_IMPORT_DEFAULT + + home = tmp_path / "profhome" + with use_profile(AgentProfile(id="p", home_dir=home)): + assert _get_cron_dir() == home / "cron" + assert _get_jobs_file() == home / "cron" / "jobs.json" + assert _get_output_dir() == home / "cron" / "output" + + # A different active profile resolves dynamically to its own home — + # proving the getter re-evaluates the profile, not a frozen path. + home2 = tmp_path / "otherhome" + with use_profile(AgentProfile(id="p2", home_dir=home2)): + assert _get_cron_dir() == home2 / "cron" + + def test_monkeypatched_cron_dir_wins_over_active_profile(self, tmp_path, monkeypatch): + """A monkeypatched module-level CRON_DIR (differing from the frozen + _CRON_DIR_IMPORT_DEFAULT) is honoured as the test-override path — it is + NOT overridden by a live get_hermes_home() re-eval of the active + profile.""" + import cron.jobs as jobs_mod + from agent.profile import AgentProfile, use_profile + + patched = tmp_path / "patched" / "cron" + monkeypatch.setattr(jobs_mod, "CRON_DIR", patched) + # Sanity: the patch really differs from the frozen default, so the + # mismatch-detection branch is what we are exercising. + assert jobs_mod.CRON_DIR != jobs_mod._CRON_DIR_IMPORT_DEFAULT + + # Even under an active profile whose home is elsewhere, the test + # override wins (a live get_hermes_home() would have returned the + # profile home / cron and leaked past the patch). + with use_profile(AgentProfile(id="p", home_dir=tmp_path / "profhome")): + assert jobs_mod._get_cron_dir() == patched + assert jobs_mod._get_jobs_file() == patched / "jobs.json" + assert jobs_mod._get_output_dir() == patched / "output" + + # And outside any profile too. + assert jobs_mod._get_cron_dir() == patched diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 90d12320a2fd8..b82146e2f2871 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -2803,7 +2803,6 @@ def test_dedupes_on_duplicate_title(self): - class TestFailureStreakNudge: """Poke-inspired repeated-failure review nudge (_failure_streak_nudge).""" @@ -2853,3 +2852,114 @@ def test_config_load_failure_falls_back(self): from cron.scheduler import _failure_streak_nudge with patch("cron.scheduler.load_config", side_effect=RuntimeError("boom")): assert "failed 3 runs" in _failure_streak_nudge(self._job(2)) + + +class TestDeliveryTargetAgentIdPropagation: + """The agent_id this PR exists to propagate: _resolve_single_delivery_target + must stamp each concrete target with the routing agent_id — preferring + ``job.agent_id`` and only then falling back to ``origin.get('agent_id')``. + + The pre-existing suite only ever asserts ``agent_id: None`` (jobs with no + agent_id), so these cover the non-None propagation the PR added. + """ + + def _resolve(self, job, deliver_value): + from cron.scheduler import _resolve_single_delivery_target + return _resolve_single_delivery_target(job, deliver_value) + + def test_origin_target_carries_job_agent_id(self): + job = { + "deliver": "origin", + "agent_id": "coder", + "origin": {"platform": "telegram", "chat_id": "-1001", "thread_id": "7"}, + } + target = self._resolve(job, "origin") + assert target == { + "platform": "telegram", + "chat_id": "-1001", + "thread_id": "7", + "agent_id": "coder", + } + + def test_origin_target_prefers_job_agent_id_over_origin_agent_id(self): + """job.agent_id wins when BOTH the job and the origin carry one.""" + job = { + "deliver": "origin", + "agent_id": "coder", + "origin": {"platform": "telegram", "chat_id": "-1001", "agent_id": "legacy"}, + } + target = self._resolve(job, "origin") + assert target["agent_id"] == "coder" + + def test_origin_target_falls_back_to_origin_agent_id(self): + """With no top-level job.agent_id, the origin's agent_id is used.""" + job = { + "deliver": "origin", + "origin": {"platform": "telegram", "chat_id": "-1001", "agent_id": "legacy"}, + } + target = self._resolve(job, "origin") + assert target["agent_id"] == "legacy" + + def test_origin_missing_home_channel_fallback_carries_job_agent_id(self, monkeypatch): + """deliver=origin with no origin falls back to a home channel, and that + target still carries the job's agent_id.""" + for var in ( + "MATRIX_HOME_ROOM", "MATRIX_HOME_CHANNEL", "DISCORD_HOME_CHANNEL", + "SLACK_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL", + "SMS_HOME_CHANNEL", "EMAIL_HOME_ADDRESS", "DINGTALK_HOME_CHANNEL", + "FEISHU_HOME_CHANNEL", "WECOM_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL", + "BLUEBUBBLES_HOME_CHANNEL", "QQBOT_HOME_CHANNEL", "QQ_HOME_CHANNEL", + "WHATSAPP_HOME_CHANNEL", "WHATSAPP_CLOUD_HOME_CHANNEL", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-4004") + + job = {"deliver": "origin", "agent_id": "coder", "origin": None} + target = self._resolve(job, "origin") + assert target["platform"] == "telegram" + assert target["chat_id"] == "-4004" + assert target["agent_id"] == "coder" + + def test_explicit_platform_target_carries_job_agent_id(self): + """deliver='telegram:chat:thread' explicit target propagates agent_id.""" + job = {"agent_id": "coder", "deliver": "telegram:-1002:9"} + target = self._resolve(job, "telegram:-1002:9") + assert target == { + "platform": "telegram", + "chat_id": "-1002", + "thread_id": "9", + "agent_id": "coder", + } + + def test_bare_platform_origin_match_carries_job_agent_id(self): + """A bare platform that matches the origin's platform propagates agent_id.""" + job = { + "agent_id": "coder", + "deliver": "telegram", + "origin": {"platform": "telegram", "chat_id": "-1001", "thread_id": "7"}, + } + target = self._resolve(job, "telegram") + assert target["agent_id"] == "coder" + assert target["chat_id"] == "-1001" + + def test_bare_platform_home_channel_carries_job_agent_id(self, monkeypatch): + """A bare platform falling back to its home channel propagates agent_id.""" + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-3003") + job = {"agent_id": "coder", "deliver": "telegram", "origin": None} + target = self._resolve(job, "telegram") + assert target == { + "platform": "telegram", + "chat_id": "-3003", + "thread_id": None, + "agent_id": "coder", + } + + def test_absent_agent_id_still_resolves_to_none(self): + """Regression guard: a job with no agent_id still yields agent_id=None + (the pre-PR behaviour must not change for single-agent jobs).""" + job = { + "deliver": "origin", + "origin": {"platform": "telegram", "chat_id": "-1001"}, + } + target = self._resolve(job, "origin") + assert target["agent_id"] is None diff --git a/tests/gateway/test_attach_agent_id.py b/tests/gateway/test_attach_agent_id.py new file mode 100644 index 0000000000000..b041e1a7c90a1 --- /dev/null +++ b/tests/gateway/test_attach_agent_id.py @@ -0,0 +1,394 @@ +"""Tests for ``BasePlatformAdapter._attach_agent_id`` — the multi-agent +routing linchpin every platform adapter depends on. + +``_attach_agent_id`` runs once per inbound message (from +``BasePlatformAdapter``'s dispatch path, after topic recovery) and stamps +``event.source.agent_id`` so ``build_session_key``, cron creation, hooks +and delivery all agree on which agent owns the turn. + +Resolution precedence (from the source, ``base.py``):: + + agent_id = hook_pick or route_match or self._default_agent_id or "main" + +i.e. the ``select_agent`` plugin hook is ALWAYS consulted and OVERRIDES a +declarative route match; a route match only wins when the hook returns +nothing; ``default_agent_id`` (normalised to at least ``"main"`` by +``set_routing_context``) is the final fallback. The whole thing is +idempotent (a pre-set ``agent_id`` short-circuits everything) and +fail-open (a resolver / hook / ``dataclasses.replace`` blow-up must never +break message delivery). +""" + +import dataclasses + +import pytest + +import hermes_cli.plugins as plugins_module +import gateway.agent_routing as agent_routing_module +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + Platform, + PlatformConfig, + SessionSource, +) + + +# --------------------------------------------------------------------------- +# Minimal concrete adapter (no real network) — mirrors the stub in +# tests/gateway/test_send_retry.py. +# --------------------------------------------------------------------------- + +class _StubAdapter(BasePlatformAdapter): + def __init__(self, platform: Platform = Platform.TELEGRAM): + super().__init__(PlatformConfig(), platform) + + async def send(self, chat_id, content, reply_to=None, metadata=None, **kwargs): + return None + + async def connect(self, *, is_reconnect: bool = False) -> bool: + return True + + async def disconnect(self) -> None: + pass + + async def send_typing(self, chat_id, metadata=None) -> None: + pass + + async def get_chat_info(self, chat_id): + return {"name": "test", "type": "direct", "chat_id": chat_id} + + +def _event(**source_kwargs) -> MessageEvent: + src = SessionSource( + platform=source_kwargs.pop("platform", Platform.TELEGRAM), + chat_id=source_kwargs.pop("chat_id", "chat-1"), + **source_kwargs, + ) + return MessageEvent(text="hello", source=src) + + +@pytest.fixture +def no_plugins(monkeypatch): + """Neutralise the ``select_agent`` hook so tests that only care about + the routes/default path aren't perturbed by ambient plugins.""" + monkeypatch.setattr(plugins_module, "invoke_hook", lambda name, **kw: []) + return monkeypatch + + +def _stub_hook(monkeypatch, return_value): + monkeypatch.setattr( + plugins_module, "invoke_hook", lambda name, **kw: return_value, + ) + + +# --------------------------------------------------------------------------- +# 1. Resolution order: route match -> hook -> default_agent -> "main" +# --------------------------------------------------------------------------- + +class TestResolutionOrder: + def test_route_match_wins_over_default(self, no_plugins): + """A matching declarative route beats the configured default. + + Non-tautological: default is deliberately ``"fallback"``, so if the + route were ignored the stamped id would be ``"fallback"`` and this + assertion would fail. + """ + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "telegram"}, "agent": "coder"}], + default_agent="fallback", + ) + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "coder" + + def test_no_route_falls_to_default(self, no_plugins): + """When no route matches (and no hook), the default is used. + + Non-tautological: the route matches *discord*, the source is + *telegram*, so a broken 'match everything' resolver would stamp + ``"coder"`` instead of ``"fallback"``. + """ + adapter = _StubAdapter(Platform.TELEGRAM) + adapter.set_routing_context( + [{"match": {"platform": "discord"}, "agent": "coder"}], + default_agent="fallback", + ) + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "fallback" + + def test_no_route_no_hook_defaults_to_main(self, no_plugins): + """Empty routes + default_agent 'main' (the single-agent install).""" + adapter = _StubAdapter() + adapter.set_routing_context([], default_agent="main") + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "main" + + def test_blank_default_agent_normalised_to_main(self, no_plugins): + """``set_routing_context`` coerces a blank default to 'main', so a + no-route message still resolves to 'main' rather than an empty id.""" + adapter = _StubAdapter() + adapter.set_routing_context([], default_agent=" ") + assert adapter._default_agent_id == "main" + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "main" + + def test_first_matching_route_wins(self, no_plugins): + """Declaration order is preserved (more specific route listed first).""" + adapter = _StubAdapter() + adapter.set_routing_context( + [ + {"match": {"platform": "telegram", "chat_id": "chat-1"}, "agent": "specific"}, + {"match": {"platform": "telegram"}, "agent": "general"}, + ], + default_agent="main", + ) + event = _event(chat_id="chat-1") + adapter._attach_agent_id(event) + assert event.source.agent_id == "specific" + + +# --------------------------------------------------------------------------- +# 2. Idempotency: a pre-set agent_id is never overwritten. +# --------------------------------------------------------------------------- + +class TestIdempotency: + def test_preset_agent_id_not_overwritten(self, no_plugins): + """If ``source.agent_id`` is already set upstream, it stands. + + Non-tautological: the route would otherwise stamp ``"coder"``; the + assertion demands the pre-set ``"preset"`` survive. + """ + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "telegram"}, "agent": "coder"}], + default_agent="fallback", + ) + event = _event(agent_id="preset") + adapter._attach_agent_id(event) + assert event.source.agent_id == "preset" + + def test_preset_agent_id_short_circuits_resolver(self, monkeypatch): + """A pre-set id returns before the resolver / hook are ever consulted.""" + calls = {"resolve": 0, "hook": 0} + + def _boom_resolve(*a, **k): + calls["resolve"] += 1 + raise AssertionError("resolver must not run when agent_id is preset") + + def _boom_hook(*a, **k): + calls["hook"] += 1 + raise AssertionError("hook must not run when agent_id is preset") + + monkeypatch.setattr(agent_routing_module, "resolve_agent_id", _boom_resolve) + monkeypatch.setattr(plugins_module, "invoke_hook", _boom_hook) + + adapter = _StubAdapter() + adapter.set_routing_context([], default_agent="fallback") + original_source = SessionSource( + platform=Platform.TELEGRAM, chat_id="chat-1", agent_id="preset", + ) + event = MessageEvent(text="hi", source=original_source) + adapter._attach_agent_id(event) + + assert calls == {"resolve": 0, "hook": 0} + # Source object is left untouched (not even re-stamped with the same id). + assert event.source is original_source + assert event.source.agent_id == "preset" + + +# --------------------------------------------------------------------------- +# 3. select_agent hook override semantics. +# --------------------------------------------------------------------------- + +class TestHookOverride: + def test_hook_overrides_route_match(self, monkeypatch): + """The hook is consulted even when a route matches, and its truthy + result WINS over the route. + + Non-tautological & discriminating: the route would give ``"coder"``; + if precedence were ``route or hook`` this test would see ``"coder"``. + """ + _stub_hook(monkeypatch, ["hookpick"]) + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "telegram"}, "agent": "coder"}], + default_agent="fallback", + ) + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "hookpick" + + def test_hook_used_when_no_route_matches(self, monkeypatch): + """With no matching route, the hook result supplants the default.""" + _stub_hook(monkeypatch, ["hookpick"]) + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "discord"}, "agent": "coder"}], + default_agent="fallback", + ) + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "hookpick" + + def test_empty_hook_result_defers_to_route(self, monkeypatch): + """A hook returning nothing/blank does NOT override; the route wins.""" + _stub_hook(monkeypatch, ["", " ", None]) + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "telegram"}, "agent": "coder"}], + default_agent="fallback", + ) + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "coder" + + def test_hook_first_truthy_string_wins_and_is_stripped(self, monkeypatch): + """The first non-blank string result is taken, stripped of whitespace.""" + _stub_hook(monkeypatch, ["", " picked ", "runner-up"]) + adapter = _StubAdapter() + adapter.set_routing_context([], default_agent="fallback") + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "picked" + + +# --------------------------------------------------------------------------- +# 4. Fail-open: a resolver / hook / replace blow-up must not break dispatch. +# --------------------------------------------------------------------------- + +class TestFailOpen: + def test_resolver_exception_does_not_crash(self, monkeypatch): + """A raising ``resolve_agent_id`` is swallowed; delivery continues.""" + monkeypatch.setattr( + agent_routing_module, "resolve_agent_id", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("routing bug")), + ) + monkeypatch.setattr(plugins_module, "invoke_hook", lambda name, **kw: []) + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "telegram"}, "agent": "coder"}], + default_agent="fallback", + ) + event = _event() + adapter._attach_agent_id(event) # must not raise + # route_match could not be computed -> falls through to default. + assert event.source.agent_id == "fallback" + + def test_hook_exception_does_not_crash(self, monkeypatch): + """A raising ``select_agent`` hook is swallowed; the route still wins.""" + monkeypatch.setattr( + plugins_module, "invoke_hook", + lambda name, **kw: (_ for _ in ()).throw(RuntimeError("plugin bug")), + ) + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "telegram"}, "agent": "coder"}], + default_agent="fallback", + ) + event = _event() + adapter._attach_agent_id(event) # must not raise + assert event.source.agent_id == "coder" + + def test_replace_failure_does_not_crash(self, no_plugins): + """If ``dataclasses.replace`` blows up, the event survives unmutated. + + A non-dataclass source makes ``dataclasses.replace`` raise + ``TypeError`` inside the stamping try/except. The message must keep + flowing and ``event.source`` must be left exactly as it was. + """ + class _NotADataclass: + agent_id = None + platform = Platform.TELEGRAM + chat_id = "chat-1" + + original = _NotADataclass() + event = MessageEvent(text="hi", source=original) + adapter = _StubAdapter() + adapter.set_routing_context([], default_agent="fallback") + adapter._attach_agent_id(event) # must not raise + assert event.source is original + assert event.source.agent_id is None + + def test_missing_source_is_a_noop(self, no_plugins): + """An event with ``source=None`` is tolerated (no crash, no stamp).""" + adapter = _StubAdapter() + adapter.set_routing_context([], default_agent="fallback") + event = MessageEvent(text="hi", source=None) + adapter._attach_agent_id(event) # must not raise + assert event.source is None + + +# --------------------------------------------------------------------------- +# 5. Stamping mechanism: dataclasses.replace produces a NEW SessionSource. +# --------------------------------------------------------------------------- + +class TestStamping: + def test_replace_produces_new_source_object(self, no_plugins): + """The stamp is a fresh ``SessionSource`` (immutable-style replace), + not an in-place mutation of the original.""" + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "telegram"}, "agent": "coder"}], + default_agent="main", + ) + original = SessionSource(platform=Platform.TELEGRAM, chat_id="chat-1") + event = MessageEvent(text="hi", source=original) + adapter._attach_agent_id(event) + + assert event.source is not original + assert isinstance(event.source, SessionSource) + assert event.source.agent_id == "coder" + # The original object handed in is not mutated. + assert original.agent_id is None + + def test_replace_preserves_other_source_fields(self, no_plugins): + """Every non-agent_id field carries over onto the stamped copy.""" + adapter = _StubAdapter() + adapter.set_routing_context( + [{"match": {"platform": "telegram"}, "agent": "coder"}], + default_agent="main", + ) + original = SessionSource( + platform=Platform.TELEGRAM, + chat_id="chat-1", + chat_type="group", + user_id="user-9", + thread_id="42", + message_id="m-7", + ) + event = MessageEvent(text="hi", source=original) + adapter._attach_agent_id(event) + + stamped = event.source + assert stamped.agent_id == "coder" + assert stamped.platform == Platform.TELEGRAM + assert stamped.chat_id == "chat-1" + assert stamped.chat_type == "group" + assert stamped.user_id == "user-9" + assert stamped.thread_id == "42" + assert stamped.message_id == "m-7" + + +# --------------------------------------------------------------------------- +# 6. Robustness with a partially-constructed adapter (class-attribute +# fallback documented in base.py). +# --------------------------------------------------------------------------- + +class TestBypassedInit: + def test_class_attr_default_resolves_to_main(self, no_plugins): + """An adapter whose ``__init__`` never ran (so ``_gateway_routes`` / + ``_gateway_ref`` are absent) still resolves to the class-level + ``_default_agent_id == "main"`` — as long as ``self.platform`` (used + by ``self.name`` in the debug logging) is present. + """ + adapter = object.__new__(_StubAdapter) + adapter.platform = Platform.TELEGRAM # needed by the `name` property + assert adapter._default_agent_id == "main" # class attribute + event = _event() + adapter._attach_agent_id(event) + assert event.source.agent_id == "main" diff --git a/tests/gateway/test_delivery.py b/tests/gateway/test_delivery.py index c8991fd5d1585..57f86942fbd6d 100644 --- a/tests/gateway/test_delivery.py +++ b/tests/gateway/test_delivery.py @@ -402,3 +402,100 @@ def test_local_delivery_writes_non_ascii_on_windows_codepage(tmp_path, monkeypat written = Path(result["path"]).read_text(encoding="utf-8") assert "完了 ✅ café" in written assert "日次レポート" in written + + +# --------------------------------------------------------------------------- +# Multi-agent delivery context (PR #62944 — single gateway, multiple agents) +# --------------------------------------------------------------------------- + + +class TestDeliveryActivatesAgentProfile: + """``DeliveryRouter.deliver`` must run each target's send inside that target's + routed ``AgentProfile`` so path getters, adapter context, and the credential + scope resolve to the right agent's home. A dropped/incorrect binding here is a + cross-agent leak class: agent A's output delivered from agent B's context. + """ + + def _profile(self, tmp_path, name): + from agent.profile import AgentProfile + + home = tmp_path / name + home.mkdir() + return AgentProfile(id=name, home_dir=home) + + @pytest.mark.asyncio + async def test_deliver_local_target_activates_routed_agent_profile(self, tmp_path): + """A LOCAL target stamped with ``agent_id`` delivers with that profile + active in the ContextVar.""" + from agent.profile import get_active_profile + + coder = self._profile(tmp_path, "coder") + router = DeliveryRouter(GatewayConfig(), adapters={}, registry={"coder": coder}) + + seen = {} + + def _spy_local(content, job_id, job_name, metadata): + seen["profile"] = get_active_profile() + return {"path": "/tmp/x"} + + router._deliver_local = _spy_local + + target = DeliveryTarget(platform=Platform.LOCAL, agent_id="coder") + await router.deliver("hello", [target]) + + # The routed agent's profile was live for the send... + assert seen["profile"] is coder + # ...and it does not leak past the delivery loop. + assert get_active_profile() is None + + @pytest.mark.asyncio + async def test_deliver_unknown_agent_id_falls_back_to_nullcontext(self, tmp_path): + """An ``agent_id`` absent from the registry must not crash — it delivers + under no profile (nullcontext), preserving single-agent behavior.""" + from agent.profile import get_active_profile + + coder = self._profile(tmp_path, "coder") + router = DeliveryRouter(GatewayConfig(), adapters={}, registry={"coder": coder}) + + seen = {} + + def _spy_local(content, job_id, job_name, metadata): + seen["profile"] = get_active_profile() + return {"path": "/tmp/x"} + + router._deliver_local = _spy_local + + target = DeliveryTarget(platform=Platform.LOCAL, agent_id="ghost") + result = await router.deliver("hello", [target]) + + assert seen["profile"] is None # nullcontext — no scope installed + assert result[target.to_string()]["success"] is True + + @pytest.mark.asyncio + async def test_deliver_no_registry_is_single_agent_noop(self, tmp_path): + """No registry at all (the default single-agent install) delivers under + no profile and never raises.""" + from agent.profile import get_active_profile + + router = DeliveryRouter(GatewayConfig(), adapters={}) + + seen = {} + + def _spy_local(content, job_id, job_name, metadata): + seen["profile"] = get_active_profile() + return {"path": "/tmp/x"} + + router._deliver_local = _spy_local + + target = DeliveryTarget(platform=Platform.LOCAL, agent_id="coder") + await router.deliver("hi", [target]) + assert seen["profile"] is None + + def test_origin_target_copies_agent_id_from_source(self): + """``DeliveryTarget.parse('origin', origin=...)`` carries the source's + ``agent_id`` so a reply routes back under the same agent.""" + origin = SessionSource( + platform=Platform.TELEGRAM, chat_id="789", agent_id="coder" + ) + target = DeliveryTarget.parse("origin", origin=origin) + assert target.agent_id == "coder" diff --git a/tests/gateway/test_gateway_config_multiagent.py b/tests/gateway/test_gateway_config_multiagent.py new file mode 100644 index 0000000000000..10cc5d0e592d3 --- /dev/null +++ b/tests/gateway/test_gateway_config_multiagent.py @@ -0,0 +1,118 @@ +"""Regression tests for multi-agent config parsing in GatewayConfig. + +Covers the defensive branches added by the single-gateway-multi-agent PR: +malformed ``agents`` / ``routes`` / ``default_agent`` values must degrade to +safe defaults rather than mis-routing every inbound message. + +``GatewayConfig.from_dict`` is the config-validation chokepoint for the whole +feature, so each malformed-input branch is exercised directly. +""" + +from gateway.config import GatewayConfig + + +class TestMultiAgentAgentsField: + def test_agents_non_dict_becomes_empty_dict(self): + # A list where a mapping is expected must not leak through. + cfg = GatewayConfig.from_dict({"agents": ["research", "main"]}) + assert cfg.agents == {} + + def test_agents_string_becomes_empty_dict(self): + cfg = GatewayConfig.from_dict({"agents": "research"}) + assert cfg.agents == {} + + def test_agents_missing_defaults_to_empty_dict(self): + cfg = GatewayConfig.from_dict({}) + assert cfg.agents == {} + + +class TestMultiAgentRoutesField: + def test_routes_non_list_becomes_empty_list(self): + # A dict (non-list) where a list is expected degrades to []. + cfg = GatewayConfig.from_dict({"routes": {"match": "x"}}) + assert cfg.routes == [] + + def test_routes_string_becomes_empty_list(self): + cfg = GatewayConfig.from_dict({"routes": "research"}) + assert cfg.routes == [] + + def test_non_dict_route_entries_filtered_out(self): + # Only dict entries survive; scalars/None/lists inside the list drop. + cfg = GatewayConfig.from_dict( + { + "routes": [ + {"match": "keyword: research", "agent": "research"}, + "not-a-dict", + None, + ["also", "not", "a", "dict"], + {"match": "keyword: ops", "agent": "ops"}, + ] + } + ) + assert cfg.routes == [ + {"match": "keyword: research", "agent": "research"}, + {"match": "keyword: ops", "agent": "ops"}, + ] + + def test_routes_missing_defaults_to_empty_list(self): + cfg = GatewayConfig.from_dict({}) + assert cfg.routes == [] + + +class TestMultiAgentDefaultAgentField: + def test_blank_default_agent_falls_back_to_main(self): + cfg = GatewayConfig.from_dict({"default_agent": " "}) + assert cfg.default_agent == "main" + + def test_empty_default_agent_falls_back_to_main(self): + cfg = GatewayConfig.from_dict({"default_agent": ""}) + assert cfg.default_agent == "main" + + def test_non_str_default_agent_falls_back_to_main(self): + cfg = GatewayConfig.from_dict({"default_agent": 123}) + assert cfg.default_agent == "main" + + def test_missing_default_agent_is_main(self): + cfg = GatewayConfig.from_dict({}) + assert cfg.default_agent == "main" + + def test_valid_default_agent_is_stripped(self): + cfg = GatewayConfig.from_dict({"default_agent": " research "}) + assert cfg.default_agent == "research" + + +class TestMultiAgentHappyPath: + def test_wellformed_agents_routes_default_parse(self): + data = { + "agents": { + "main": {"model": "anthropic/claude-opus-4.8"}, + "research": {"model": "anthropic/claude-opus-4.8", "toolset": "web"}, + }, + "routes": [ + {"match": "keyword: research", "agent": "research"}, + {"match": "channel: 12345", "agent": "main"}, + ], + "default_agent": "research", + } + cfg = GatewayConfig.from_dict(data) + + assert cfg.agents == { + "main": {"model": "anthropic/claude-opus-4.8"}, + "research": {"model": "anthropic/claude-opus-4.8", "toolset": "web"}, + } + assert cfg.routes == [ + {"match": "keyword: research", "agent": "research"}, + {"match": "channel: 12345", "agent": "main"}, + ] + assert cfg.default_agent == "research" + + def test_happy_path_survives_to_dict_round_trip(self): + data = { + "agents": {"research": {"model": "m"}}, + "routes": [{"match": "keyword: x", "agent": "research"}], + "default_agent": "research", + } + restored = GatewayConfig.from_dict(GatewayConfig.from_dict(data).to_dict()) + assert restored.agents == {"research": {"model": "m"}} + assert restored.routes == [{"match": "keyword: x", "agent": "research"}] + assert restored.default_agent == "research" diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 6d6ffa3006bb7..c38d099398ff4 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -1755,3 +1755,88 @@ def test_session_source_roundtrip_without_agent_id(self): assert "agent_id" not in d restored = SessionSource.from_dict(d) assert restored.agent_id is None + + +class TestSessionEntryAgentIdSerialization: + """SessionEntry.agent_id must persist through save→load and default to + ``"main"`` for legacy entries written before the multi-agent field shipped. + """ + + def _entry(self, **overrides): + from gateway.session import SessionEntry + from datetime import datetime + base = dict( + session_key="agent:coder:telegram:dm:123", + session_id="s1", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + base.update(overrides) + return SessionEntry(**base) + + def test_non_main_agent_id_survives_roundtrip(self): + """A non-"main" agent_id must survive to_dict→from_dict unchanged.""" + from gateway.session import SessionEntry + entry = self._entry(agent_id="coder") + d = entry.to_dict() + assert d["agent_id"] == "coder" + restored = SessionEntry.from_dict(d) + assert restored.agent_id == "coder" + + def test_default_agent_id_is_main(self): + """A freshly constructed entry defaults agent_id to "main".""" + entry = self._entry() + assert entry.agent_id == "main" + assert entry.to_dict()["agent_id"] == "main" + + def test_legacy_dict_without_agent_id_defaults_to_main(self): + """A persisted entry dict predating the agent_id field (no key) + must deserialize back to "main" for back-compat.""" + from gateway.session import SessionEntry + data = { + "session_key": "agent:main:telegram:dm:123", + "session_id": "s1", + "created_at": "2026-01-01T00:00:00", + "updated_at": "2026-01-01T00:00:00", + # No agent_id key — legacy format. + } + assert "agent_id" not in data + restored = SessionEntry.from_dict(data) + assert restored.agent_id == "main" + + +class TestBuildSessionKeyAgentIdProfilePrecedence: + """When both a non-main ``source.agent_id`` and a ``profile`` namespace arg + are supplied, the routing agent_id wins and the profile namespace is + ignored (the new branch in build_session_key).""" + + def test_agent_id_takes_precedence_over_profile(self): + source = SessionSource( + platform=Platform.TELEGRAM, chat_id="123", chat_type="dm", + ) + source.agent_id = "coder" + # A profile namespace is ALSO supplied — it must be ignored because + # agent_id is a non-main routing identity. + key = build_session_key(source, profile="teamB") + assert key == "agent:coder:telegram:dm:123" + # Non-tautological guard: the profile namespace must NOT appear. + assert "teamB" not in key + + def test_main_agent_id_falls_back_to_profile_namespace(self): + """With the default "main" agent_id, the profile namespace param is + honored — confirming precedence only fires for a non-main agent.""" + source = SessionSource( + platform=Platform.TELEGRAM, chat_id="123", chat_type="dm", + ) + source.agent_id = "main" + key = build_session_key(source, profile="teamB") + assert key == "agent:teamB:telegram:dm:123" + + def test_unset_agent_id_falls_back_to_profile_namespace(self): + """agent_id unset (None) also defers to the profile namespace.""" + source = SessionSource( + platform=Platform.TELEGRAM, chat_id="123", chat_type="dm", + ) + assert source.agent_id is None + key = build_session_key(source, profile="teamB") + assert key == "agent:teamB:telegram:dm:123" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 6f03a945b6cc4..404ed9e8751c1 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -5040,3 +5040,129 @@ def test_percent_preserved_for_cjk_query(self): # text; keep % intact there (pre-existing contract). sanitized = self._sanitize("完成50%") assert "%" in sanitized + + +# ========================================================================= +# Multi-agent routing: sessions.agent_id persistence (single-gateway-multi-agent) +# ========================================================================= + +class TestSessionAgentIdPersistence: + def test_create_session_persists_routed_agent_id(self, db): + """A routed non-main agent_id must round-trip back out of the row.""" + db.create_session(session_id="s1", source="telegram", agent_id="research") + assert db.get_session("s1")["agent_id"] == "research" + + def test_create_session_defaults_agent_id_to_main(self, db): + """Omitting agent_id stores 'main' (the ``agent_id or 'main'`` bind).""" + db.create_session(session_id="s1", source="cli") + assert db.get_session("s1")["agent_id"] == "main" + + def test_create_session_none_agent_id_defaults_to_main(self, db): + """Passing agent_id=None must not violate the NOT NULL column — the + ``agent_id or 'main'`` bind coerces it to 'main'.""" + db.create_session(session_id="s1", source="cli", agent_id=None) + assert db.get_session("s1")["agent_id"] == "main" + + def test_create_session_blank_agent_id_defaults_to_main(self, db): + """An empty string is falsy, so ``agent_id or 'main'`` yields 'main'.""" + db.create_session(session_id="s1", source="cli", agent_id="") + assert db.get_session("s1")["agent_id"] == "main" + + def test_upsert_preserves_existing_agent_id(self, db): + """Re-creating an existing session must NOT null or overwrite its + agent_id: ON CONFLICT uses COALESCE(sessions.agent_id, excluded.agent_id) + and the column is NOT NULL, so the first-set routed id always wins. + """ + db.create_session(session_id="s1", source="telegram", agent_id="research") + # A later bare/default re-create (e.g. gateway metadata enrichment) + # must not clobber the routed agent_id. + db.create_session(session_id="s1", source="telegram", model="m") + assert db.get_session("s1")["agent_id"] == "research" + + # Even an explicit different id does not overwrite (one row, one agent). + db.create_session(session_id="s1", source="telegram", agent_id="ops") + assert db.get_session("s1")["agent_id"] == "research" + + def test_upsert_backfills_agent_id_onto_main_row(self, db): + """The mirror of the preserve case: because the column defaults to + 'main', a session first created without an agent_id stays 'main' even + when a later create_session supplies a routed id (COALESCE keeps the + existing non-NULL 'main'). Documents the actual first-writer-wins + behaviour so callers know routing must be decided at creation. + """ + db.create_session(session_id="s1", source="cli") # -> 'main' + db.create_session(session_id="s1", source="cli", agent_id="research") + assert db.get_session("s1")["agent_id"] == "main" + + def test_legacy_db_without_agent_id_column_reconciles(self, tmp_path): + """A pre-v20 database whose sessions table lacks agent_id must have the + column added on open (declarative _reconcile_columns) and must accept + an agent-routed insert without raising. The idx_sessions_agent index + is created only after the column exists. + """ + db_path = tmp_path / "legacy_state.db" + conn = sqlite3.connect(db_path) + # Pre-v20 sessions table: identical to the current schema minus the + # agent_id column (and minus the columns added at/after v20). + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER); + INSERT INTO schema_version VALUES (19); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + session_key TEXT, + chat_id TEXT, + chat_type TEXT, + thread_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + cwd TEXT, + title TEXT + ); + CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT); + """ + ) + # A legacy row that predates the column entirely. + conn.execute( + "INSERT INTO sessions (id, source, started_at) VALUES ('old', 'cli', 1.0)" + ) + conn.commit() + conn.close() + + session_db = SessionDB(db_path=db_path) + try: + # The reconciler added agent_id ... + cols = { + row[1] + for row in session_db._conn.execute( + "PRAGMA table_info(sessions)" + ).fetchall() + } + assert "agent_id" in cols + + # ... with a working NOT NULL DEFAULT 'main' backfilling old rows. + assert session_db.get_session("old")["agent_id"] == "main" + + # ... the agent index was created after the column existed. + idx = session_db._conn.execute( + "SELECT name FROM sqlite_master " + "WHERE type='index' AND name='idx_sessions_agent'" + ).fetchone() + assert idx is not None + + # ... and a routed insert now succeeds and round-trips. + session_db.create_session( + session_id="new", source="telegram", agent_id="research" + ) + assert session_db.get_session("new")["agent_id"] == "research" + finally: + session_db.close() diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index a967f615759a5..ebebff2592a2d 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -412,6 +412,38 @@ def test_normal_numbers_still_coerce(self): assert _coerce_number("3.14") == 3.14 assert _coerce_number("1e3") == 1000 +class TestHookAgentIdPropagation: + """post_tool_call / transform_tool_result must forward the active agent + profile's id as ``agent_id``. The existing hook-plumbing test covers the + no-profile path (agent_id=None); this covers the populated path.""" + + def test_hooks_receive_active_profile_agent_id(self, tmp_path): + from pathlib import Path + from agent.profile import AgentProfile, use_profile + + with ( + patch("model_tools.registry.dispatch", return_value='{"ok":true}'), + patch("hermes_cli.plugins.has_hook", return_value=True), + patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook, + ): + with use_profile(AgentProfile(id="coder", home_dir=Path(tmp_path))): + result = handle_function_call( + "web_search", + {"q": "test"}, + task_id="task-1", + tool_call_id="call-1", + session_id="session-1", + ) + + assert result == '{"ok":true}' + kwargs_by_hook = { + c.args[0]: c.kwargs for c in mock_invoke_hook.call_args_list + } + # The two hooks the multi-agent routing PR wired agent_id into. + assert kwargs_by_hook["post_tool_call"]["agent_id"] == "coder" + assert kwargs_by_hook["transform_tool_result"]["agent_id"] == "coder" + + class TestDisabledToolsetsPlatformBundle: """Regression test for #33924: disabling a platform bundle (hermes-*) must not remove core tools from other enabled toolsets.""" From 33141499ede540859ad87732ebca390eded037f0 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Mon, 13 Jul 2026 00:27:38 +0900 Subject: [PATCH 17/29] docs(gateway): fix _attach_agent_id precedence docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstring listed "declarative routes -> select_agent hook -> default" as the resolution order, which reads as routes-first fallback. The code is `hook_pick or route_match or default or "main"` — the select_agent hook is always consulted, is handed the route match, and a truthy hook result OVERRIDES the route. This is the intended design, as stated in set_routing_context's own docstring ("the select_agent plugin hook ... overriding the route result"). Only the _attach_agent_id docstring was stale; correct it to match. Co-Authored-By: Claude Opus 4.8 --- gateway/platforms/base.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d5cd591208f27..765685cd864b2 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -3847,9 +3847,11 @@ def set_routing_context( 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. + Precedence (highest first): the ``select_agent`` plugin hook — + which is handed the route match and may override it — then the + declarative ``routes`` table, then ``default_agent_id``, then + "main" (``hook_pick or route_match or default``). Idempotent: if + ``agent_id`` was already set upstream it is left untouched. Imported lazily so single-agent installs that never call ``set_routing_context`` avoid loading the resolver / plugin From 7dfe38334ae8b1820a85b19c05103c27a3d1ba2f Mon Sep 17 00:00:00 2001 From: David Gutowsky Date: Sun, 12 Jul 2026 17:18:16 +0000 Subject: [PATCH 18/29] fix(gateway): persist routed agent_id on api_server session creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _insert_session_row uses COALESCE(sessions.agent_id, excluded.agent_id) on a NOT NULL DEFAULT 'main' column — first-writer-wins. Both create_session call sites in api_server (POST /api/sessions and the fork path) omitted agent_id, so new rows were always written with 'main', permanently locking out the correct routed value for any later read (e.g. build_session_key's agent_id-over-profile precedence). Fix: - _handle_create_session: resolve the routed agent via _resolve_agent_profile (already available on request) and pass agent_id= on the create_session call. - _handle_fork_session: inherit agent_id from the source session dict (not re-resolved from routing headers, which are absent on the fork endpoint — re-resolving would fall back to 'main' and break agent isolation for forked conversations). The COALESCE semantics and first-writer-wins behavior are intentional (jethac has a test pinning them); this commit does not alter _insert_session_row. Tests (TestSessionCreationPersistsAgentId): - test_create_session_persists_routed_agent_id: routed create → agent_id='coder' - test_create_session_defaults_to_main_without_routing_header: no header → 'main' - test_fork_session_inherits_source_agent_id: fork → inherits parent agent_id Mutation-verified: removing the agent_id= kwarg in _handle_create_session causes the first two tests to fail with AssertionError (None != 'coder'/'main'). --- gateway/platforms/api_server.py | 17 ++- tests/gateway/test_api_server_routing.py | 170 ++++++++++++++++++++++- 2 files changed, 184 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index f7913213f7d4b..4c3ff296102b7 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -4408,6 +4408,11 @@ async def _handle_create_session(self, request: "web.Request") -> "web.Response" "updated_at": time.time(), } } + # Resolve the routed agent at session creation time so the persisted + # agent_id is correct from the first write. The first-writer-wins + # COALESCE in _insert_session_row means a later backfill cannot fix + # a row that was created with the DEFAULT 'main'. + _, resolved_agent_id = self._resolve_agent_profile(request) title = body.get("title") # Run the entire check-insert-title sequence inside a single @@ -4425,14 +4430,15 @@ def _atomic(conn): import time as _time conn.execute( """INSERT INTO sessions ( - id, source, model, model_config, system_prompt, started_at - ) VALUES (?, ?, ?, ?, ?, ?)""", + id, source, model, model_config, system_prompt, agent_id, started_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)""", ( session_id, source, model_name, json.dumps(model_config) if model_config else None, system_prompt, + resolved_agent_id, _time.time(), ), ) @@ -4620,6 +4626,12 @@ async def _handle_fork_session(self, request: "web.Request") -> "web.Response": # create a child session that carries the transcript forward. This uses # SessionDB's native parent_session_id/end_reason visibility model rather # than inventing a parallel fork store. + # + # agent_id is inherited from the source session, not re-resolved from + # routing headers. A fork is a continuation of the parent conversation + # lineage and was already routed to a specific agent when it was first + # created; the fork endpoint carries no routing headers, so re-resolving + # would fall back to the default ('main') and break agent isolation. await asyncio.to_thread(db.end_session, source_id, "branched") await asyncio.to_thread(db.create_session, fork_id, @@ -4627,6 +4639,7 @@ async def _handle_fork_session(self, request: "web.Request") -> "web.Response": model=source.get("model"), system_prompt=source.get("system_prompt"), parent_session_id=source_id, + agent_id=source.get("agent_id") or "main", ) messages = await asyncio.to_thread(db.get_messages, source_id) await asyncio.to_thread(db.replace_messages, fork_id, messages) diff --git a/tests/gateway/test_api_server_routing.py b/tests/gateway/test_api_server_routing.py index d5b6ee3066d19..c0449c07ae001 100644 --- a/tests/gateway/test_api_server_routing.py +++ b/tests/gateway/test_api_server_routing.py @@ -17,7 +17,7 @@ from __future__ import annotations import asyncio -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -522,3 +522,171 @@ def _spy_create_agent(**kwargs): assert get_active_profile() is None with pytest.raises(ss.UnscopedSecretError): ss.get_secret("AGENT_API_KEY") + + +# --------------------------------------------------------------------------- +# Session creation persists the routed agent_id +# --------------------------------------------------------------------------- + + +def _make_routed_request(headers: dict, body: dict) -> MagicMock: + """Build a mock aiohttp.web.Request with headers and an async json() body. + + Why: _handle_create_session calls both request.headers.get (for auth and + routing) and await request.json() (via _read_json_body). This stubs both + without standing up a full aiohttp server. + What: Returns a MagicMock whose headers dict supports .get() and whose + json coroutine returns the provided body dict. + Test: Feed to _handle_create_session and assert on the db.create_session + call kwargs. + """ + req = MagicMock() + req.headers = headers + req.json = AsyncMock(return_value=body) + return req + + +class TestSessionCreationPersistsAgentId: + """Routing decisions must be stamped on the session row AT CREATION TIME. + + Why: _insert_session_row uses COALESCE(sessions.agent_id, excluded.agent_id) + on a NOT NULL DEFAULT 'main' column. The first writer wins — a later + backfill call can never override 'main' once it is written. Therefore the + resolved agent_id must be passed to create_session on the initial write. + + Each test constructs the adapter with routing rules, wires a mock SessionDB, + calls the handler directly, then inspects the captured create_session kwargs. + """ + + @pytest.mark.asyncio + async def test_create_session_persists_routed_agent_id(self, tmp_path): + """POST /api/sessions with X-Hermes-Chat-Id → routed agent persisted. + + Why: The core regression guard. Before the fix, the atomic + check-insert-title write (_handle_create_session's TOCTOU-safe + rewrite of the old direct create_session call) never bound + agent_id, so every row defaulted to 'main' regardless of routing. + What: Routes 'coder' chat_id to the 'coder' agent against a real + SessionDB (the atomic path runs a raw INSERT via db._execute_write, + so a MagicMock can't observe it meaningfully); asserts the persisted + row's agent_id via db.get_session. + Test: Revert the resolved_agent_id bind in the INSERT and this test + fails because the persisted row's agent_id reverts to 'main'. + """ + from hermes_state import SessionDB + + coder = AgentProfile(id="coder", home_dir=tmp_path / "coder") + main = AgentProfile(id="main") + registry = {"main": main, "coder": coder} + routes = [ + {"match": {"platform": "api_server", "chat_id": "coder"}, "agent": "coder"}, + ] + adapter = _make_adapter(routes=routes, registry=registry) + + real_db = SessionDB(db_path=tmp_path / "state.db") + adapter._session_db = real_db + + req = _make_routed_request( + headers={"X-Hermes-Chat-Id": "coder"}, + body={"id": "sess-coder-001"}, + ) + + try: + resp = await adapter._handle_create_session(req) + assert resp.status == 201 + assert real_db.get_session("sess-coder-001")["agent_id"] == "coder", ( + "agent_id must be 'coder' — the routed agent resolved from X-Hermes-Chat-Id. " + "If this fails, the fix was not applied or was reverted." + ) + finally: + real_db.close() + + @pytest.mark.asyncio + async def test_create_session_defaults_to_main_without_routing_header(self, tmp_path): + """POST /api/sessions with no routing header → agent_id persisted as 'main'. + + Why: Regression guard for the default path. No routing header means no + agent match; the default agent_id ('main') must be written explicitly + (not just relying on the column default) so callers can see it in + db.get_session(). + What: No X-Hermes-Chat-Id supplied; assert the persisted row's + agent_id is 'main' via a real SessionDB (see the sibling test above + for why a MagicMock can't observe the atomic INSERT path). + Test: Pass a header that matches and assert this test fails to prove + test sensitivity. + """ + from hermes_state import SessionDB + + main = AgentProfile(id="main") + registry = {"main": main} + adapter = _make_adapter(routes=[], registry=registry) + + real_db = SessionDB(db_path=tmp_path / "state.db") + adapter._session_db = real_db + + req = _make_routed_request( + headers={}, # no routing headers + body={"id": "sess-main-001"}, + ) + + try: + resp = await adapter._handle_create_session(req) + assert resp.status == 201 + assert real_db.get_session("sess-main-001")["agent_id"] == "main" + finally: + real_db.close() + + @pytest.mark.asyncio + async def test_fork_session_inherits_source_agent_id(self, tmp_path): + """POST /api/sessions/{id}/fork → fork row inherits parent's agent_id. + + Why: A fork is a continuation of the parent lineage. The fork endpoint + carries no routing headers, so re-resolving routing would fall back to + 'main'. Inheriting the source agent_id is the only correct semantics. + What: Source session has agent_id='coder'; fork call must persist + agent_id='coder' on the new row, not 'main'. + Test: Set source agent_id='coder'; assert fork create_session receives + agent_id='coder'. + """ + coder = AgentProfile(id="coder", home_dir=tmp_path / "coder") + main = AgentProfile(id="main") + registry = {"main": main, "coder": coder} + adapter = _make_adapter(routes=[], registry=registry) + + source_session = { + "id": "sess-parent", + "agent_id": "coder", + "model": "claude-3", + "system_prompt": None, + "title": "my conv", + } + + mock_db = MagicMock() + # _get_existing_session_or_404 calls db.get_session(source_id) + # then fork check calls db.get_session(fork_id) + mock_db.get_session.side_effect = lambda sid: ( + source_session if sid == "sess-parent" else None + ) + mock_db.create_session.side_effect = lambda sid, *args, **kwargs: sid + mock_db.get_messages.return_value = [] + mock_db.replace_messages.return_value = None + mock_db.get_next_title_in_lineage.return_value = "my conv fork" + mock_db.set_session_title.return_value = None + adapter._session_db = mock_db + + req = _make_routed_request( + headers={}, # fork endpoint: no routing headers + body={"id": "sess-fork-001"}, + ) + # Inject source_id into match_info as the route does + req.match_info = {"session_id": "sess-parent"} + + resp = await adapter._handle_fork_session(req) + + assert resp.status == 201 + mock_db.create_session.assert_called_once() + call_kwargs = mock_db.create_session.call_args + assert call_kwargs.args[0] == "sess-fork-001" + assert call_kwargs.kwargs.get("agent_id") == "coder", ( + "Fork must inherit the parent session's agent_id='coder', not 'main'." + ) From d56b264d2e6b3a6018721691b4e87a71cbeea43a Mon Sep 17 00:00:00 2001 From: David Gutowsky Date: Sun, 12 Jul 2026 18:43:28 +0000 Subject: [PATCH 19/29] feat(gateway): run stateful api_server session turns under the session's agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route → persist → consume: _handle_create_session now writes the resolved agent_id to the session row (453a79f); this commit closes the loop by reading that persisted agent_id at chat time and binding the matching AgentProfile for every turn. Changes: - Add _profile_for_agent_id(agent_id) helper: mirrors the registry lookup in _resolve_agent_profile but takes an explicit id instead of re-running header-based route resolution. Returns None for missing/None ids and for legacy single-agent installs (no-op sentinel for _run_agent). - _handle_session_chat: capture session from _get_existing_session_or_404 (was discarded with _), read session["agent_id"], resolve AgentProfile via the new helper, pass agent_profile= to _run_agent. - _handle_session_chat_stream: same pattern in the outer handler; the resolved profile is closed over by _run_and_signal and passed to _run_agent there. - _run_agent already accepts agent_profile and wraps it with _use_profile_and_secret_scope (jethac's credential-scope fix) — this commit does not re-wrap; it only supplies the value that was missing. Backward compatibility: sessions with agent_id=None or agent_id not in the registry yield profile=None → _use_profile_and_secret_scope is a no-op → existing default-agent behaviour is fully preserved. --- gateway/platforms/api_server.py | 31 ++++ tests/gateway/test_api_server_routing.py | 207 +++++++++++++++++++++++ 2 files changed, 238 insertions(+) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 4c3ff296102b7..8b2063bc950ea 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -2429,6 +2429,26 @@ def _resolve_agent_profile(self, request: "web.Request"): ) return profile, agent_id + def _profile_for_agent_id(self, agent_id: Optional[str]) -> Optional[Any]: + """Look up an AgentProfile by *agent_id* from the gateway registry. + + Why: Stateful session turns need to resolve the persisted agent_id to + its AgentProfile so _run_agent can apply the correct home dir and + credential scope — without re-running header-based route resolution + (which would silently fall back to 'main' on requests with no headers). + What: Returns the AgentProfile registered under *agent_id*, or None if + the id is absent/None, not in the registry, or the registry is + unavailable (legacy single-agent install). None is the no-op sentinel + that _run_agent/_use_profile_and_secret_scope treats as "use defaults". + Test: Pass a known agent_id with a wired registry and assert the + correct AgentProfile is returned; pass None or an unknown id and + assert None is returned. + """ + if not agent_id: + return None + registry = getattr(self._gateway_ref, "_agent_registry", None) if self._gateway_ref else None + return registry.get(agent_id) if registry else None + # ------------------------------------------------------------------ # Session DB helper # ------------------------------------------------------------------ @@ -4727,6 +4747,11 @@ async def _handle_session_chat(self, request: "web.Request") -> "web.Response": ) if selection_error: return web.json_response(_openai_error(selection_error), status=400) + # Use the agent this session was originally routed to (first-writer-wins + # model set at creation time). Re-resolving from request headers would + # silently fall back to 'main' when no routing headers are present. + session_agent_id = (session or {}).get("agent_id") if isinstance(session, dict) else None + agent_profile = self._profile_for_agent_id(session_agent_id) history = await self._conversation_history_for_session(session_id) result, usage = await self._run_agent( user_message=user_message, @@ -4740,6 +4765,7 @@ async def _handle_session_chat(self, request: "web.Request") -> "web.Response": route_source=runtime_request.get("route_source") or "global", confirmed_runtime_lock=lock_active, **agent_overrides, + agent_profile=agent_profile, ) effective_session_id = result.get("session_id") if isinstance(result, dict) else session_id final_response = _resolve_media_to_data_urls(result.get("final_response", "") if isinstance(result, dict) else "") @@ -4842,6 +4868,10 @@ async def _handle_session_chat_stream(self, request: "web.Request") -> "web.Stre route_source=runtime_request.get("route_source") or "global", model_lock=("accepted" if lock_active else ""), ) + # Resolve the session's persisted agent so streaming turns honour the + # same routing decision made at session creation (first-writer-wins). + session_agent_id = (session or {}).get("agent_id") if isinstance(session, dict) else None + agent_profile = self._profile_for_agent_id(session_agent_id) loop = asyncio.get_running_loop() queue: "asyncio.Queue[Optional[tuple[str, Dict[str, Any]]]]" = asyncio.Queue() @@ -4913,6 +4943,7 @@ async def _run_and_signal() -> None: route_source=runtime_request.get("route_source") or "global", confirmed_runtime_lock=lock_active, **agent_overrides, + agent_profile=agent_profile, ) final_response = _resolve_media_to_data_urls(result.get("final_response", "") if isinstance(result, dict) else "") effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id diff --git a/tests/gateway/test_api_server_routing.py b/tests/gateway/test_api_server_routing.py index c0449c07ae001..837104eb60a6c 100644 --- a/tests/gateway/test_api_server_routing.py +++ b/tests/gateway/test_api_server_routing.py @@ -690,3 +690,210 @@ async def test_fork_session_inherits_source_agent_id(self, tmp_path): assert call_kwargs.kwargs.get("agent_id") == "coder", ( "Fork must inherit the parent session's agent_id='coder', not 'main'." ) + + +# --------------------------------------------------------------------------- +# Stateful session turns run under the session's persisted agent +# --------------------------------------------------------------------------- + + +def _make_session_chat_request(session_id: str, body: dict) -> MagicMock: + """Build a mock aiohttp.web.Request for session-chat endpoints. + + Why: _handle_session_chat and _handle_session_chat_stream read + match_info["session_id"], headers (for auth + session key), and + await request.json() — this stubs all three without a live server. + What: Returns a MagicMock with no auth header (so _check_auth passes + when the adapter has no auth key configured) and the given body. + Test: Feed to _handle_session_chat; assert _run_agent receives the + expected agent_profile kwarg. + """ + req = MagicMock() + req.headers = {} # no auth header → _check_auth returns None + req.match_info = {"session_id": session_id} + req.json = AsyncMock(return_value=body) + return req + + +class TestSessionChatRunsUnderSessionAgent: + """Session chat turns must use the agent the session was created for. + + Why: _handle_session_chat and _handle_session_chat_stream previously + called _run_agent without agent_profile, so every turn silently ran + under the default agent regardless of the session's persisted agent_id. + The fix reads session.agent_id, resolves the profile via + _profile_for_agent_id, and passes it as agent_profile=. + + All tests mock _run_agent to avoid a live executor thread and inspect + the agent_profile kwarg directly. + """ + + @pytest.mark.asyncio + async def test_session_chat_uses_session_agent_profile(self, tmp_path): + """_handle_session_chat passes the session's AgentProfile to _run_agent. + + Why: Core regression guard — proves the fix is wired end-to-end. + What: Adapter has a 'coder' profile; session row has agent_id='coder'; + assert _run_agent receives agent_profile == coder profile. + Test: Remove the agent_profile= kwarg from _handle_session_chat and + this test fails because agent_profile would be None. + """ + coder = AgentProfile(id="coder", home_dir=tmp_path / "coder") + main = AgentProfile(id="main") + registry = {"main": main, "coder": coder} + adapter = _make_adapter(routes=[], registry=registry) + + coder_session = {"id": "sess-coder", "agent_id": "coder"} + mock_db = MagicMock() + mock_db.get_session.return_value = coder_session + mock_db.get_messages_as_conversation.return_value = [] + adapter._session_db = mock_db + + captured = {} + + async def _mock_run_agent(**kwargs): + captured["agent_profile"] = kwargs.get("agent_profile") + return {"final_response": "ok", "session_id": "sess-coder"}, {} + + adapter._run_agent = _mock_run_agent + + req = _make_session_chat_request( + session_id="sess-coder", + body={"message": "hello"}, + ) + + resp = await adapter._handle_session_chat(req) + + assert resp.status == 200 + assert captured["agent_profile"] is coder, ( + "_run_agent must receive agent_profile=coder for a session routed to 'coder'. " + "If this fails, the agent_profile= kwarg was not passed in _handle_session_chat." + ) + + @pytest.mark.asyncio + async def test_session_chat_stream_uses_session_agent_profile(self, tmp_path): + """_handle_session_chat_stream passes the session's AgentProfile to _run_agent. + + Why: Stream path has a nested _run_and_signal coroutine; the profile + must be captured in the outer handler scope and closed over. + What: Same setup as the sync test; assert agent_profile== coder profile. + Test: Remove agent_profile= from the _run_agent call in _run_and_signal + and this test fails. + """ + coder = AgentProfile(id="coder", home_dir=tmp_path / "coder") + main = AgentProfile(id="main") + registry = {"main": main, "coder": coder} + adapter = _make_adapter(routes=[], registry=registry) + + coder_session = {"id": "sess-coder-stream", "agent_id": "coder"} + mock_db = MagicMock() + mock_db.get_session.return_value = coder_session + mock_db.get_messages_as_conversation.return_value = [] + adapter._session_db = mock_db + + captured = {} + + async def _mock_run_agent(**kwargs): + captured["agent_profile"] = kwargs.get("agent_profile") + return {"final_response": "streamed ok", "session_id": "sess-coder-stream"}, {} + + adapter._run_agent = _mock_run_agent + + req = _make_session_chat_request( + session_id="sess-coder-stream", + body={"message": "hello stream"}, + ) + + # _handle_session_chat_stream returns a StreamResponse; we don't need to + # drain the SSE queue — _run_and_signal will complete before we inspect. + import aiohttp + from unittest.mock import patch + + with patch("aiohttp.web.StreamResponse") as MockStream: + mock_stream = AsyncMock() + mock_stream.write = AsyncMock() + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=False) + MockStream.return_value = mock_stream + + resp = await adapter._handle_session_chat_stream(req) + + # Allow the background task (_run_and_signal) to complete. + await asyncio.sleep(0.05) + + assert captured.get("agent_profile") is coder, ( + "_run_agent must receive agent_profile=coder for a streaming session turn. " + "If this fails, agent_profile= was not passed in _run_and_signal." + ) + + @pytest.mark.asyncio + async def test_session_chat_legacy_main_session_passes_none_profile(self): + """A legacy session with agent_id='main' gives agent_profile=None to _run_agent. + + Why: Backward-compatibility guard. None is the no-op sentinel that lets + _run_agent fall through to default behaviour (no profile wrapper). + What: Session has agent_id='main'; registry has a 'main' profile to + confirm the lookup works — but the expected result is still the resolved + profile, not None, for 'main'. Separately verify that a missing + agent_id also resolves gracefully. + Test: Change expected to the main profile and assert this fails when + agent_profile is None to prove the check is live. + """ + main = AgentProfile(id="main") + registry = {"main": main} + adapter = _make_adapter(routes=[], registry=registry) + + # Session with no agent_id (truly legacy / pre-migration row) + legacy_session = {"id": "sess-legacy", "agent_id": None} + mock_db = MagicMock() + mock_db.get_session.return_value = legacy_session + mock_db.get_messages_as_conversation.return_value = [] + adapter._session_db = mock_db + + captured = {} + + async def _mock_run_agent(**kwargs): + captured["agent_profile"] = kwargs.get("agent_profile") + return {"final_response": "ok", "session_id": "sess-legacy"}, {} + + adapter._run_agent = _mock_run_agent + + req = _make_session_chat_request( + session_id="sess-legacy", + body={"message": "hello legacy"}, + ) + + resp = await adapter._handle_session_chat(req) + + assert resp.status == 200 + # agent_id=None → _profile_for_agent_id returns None → no-op default path. + assert captured["agent_profile"] is None, ( + "A session with agent_id=None must pass agent_profile=None to _run_agent " + "so the default (no profile wrapper) behaviour is preserved." + ) + + @pytest.mark.asyncio + async def test_profile_for_agent_id_helper(self, tmp_path): + """_profile_for_agent_id returns the registered profile or None. + + Why: Exercises the helper in isolation to confirm it mirrors the + registry lookup in _resolve_agent_profile without duplication. + What: Known id → profile; unknown id → None; None id → None; + no registry → None. + Test: Change the expected profile to a different object and assert + the comparison fails to prove the test is live. + """ + coder = AgentProfile(id="coder", home_dir=tmp_path / "coder") + main = AgentProfile(id="main") + registry = {"main": main, "coder": coder} + adapter = _make_adapter(routes=[], registry=registry) + + assert adapter._profile_for_agent_id("coder") is coder + assert adapter._profile_for_agent_id("main") is main + assert adapter._profile_for_agent_id("unknown") is None + assert adapter._profile_for_agent_id(None) is None + assert adapter._profile_for_agent_id("") is None + + # No registry (legacy single-agent install) + adapter2 = _make_adapter(routes=[], registry=None) + assert adapter2._profile_for_agent_id("coder") is None From 8b3a68951b4d68ff018470442367b1d4fd05e10d Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Fri, 17 Jul 2026 01:01:17 +0900 Subject: [PATCH 20/29] test(multi-agent): end-to-end integration suite for single-gateway routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing coverage is unit-level: resolve_agent_id, _attach_agent_id, load_agent_registry, the secret-scope mechanism, _resolve_agent_profile — each tested in isolation with mocks. None prove a real inbound request flowing through a running api_server adapter, routed to an agent, executed under that agent's isolated home + credential scope, with no cross-agent contamination under load. This suite drives real HTTP through APIServerAdapter (real routing, real profile+secret scope, real per-agent home/credential resolution) and stubs ONLY the LLM turn via a spy _create_agent that captures, inside the executor thread, what the run actually saw: active agent_id, resolved home, SOUL, and the resolved provider key. Credential resolution itself stays real, so a broken scope surfaces as the wrong key/home, not a passing mock. Scenarios: - C credential isolation: each agent resolves its own home-.env key; a keyless agent under multiplex never leaks the process-global value. - B profile isolation: home getters + SOUL resolve to the routed agent. - A routing: header→agent, default fall-through, unmatched→default. - E concurrency invariant (P0): K agents × M interleaved concurrent requests, every response echoes its own agent/home/key — the ContextVar-across- executor-threads guarantee the whole feature rests on. - H back-compat: a bare (no agents/routes) install runs as main at the root home reading the root .env — legacy single-agent behavior unchanged. Mutation-verified non-tautological: neutering the agent-scope binder fails the isolation + concurrency tests. Stacked on the feature branch (imports the routing code not yet on main); retarget to main once #62944 lands. Co-Authored-By: Claude Opus 4.8 --- tests/integration/multi_agent/conftest.py | 232 ++++++++++++++++++ .../multi_agent/test_backcompat.py | 33 +++ .../multi_agent/test_concurrency.py | 50 ++++ .../integration/multi_agent/test_isolation.py | 72 ++++++ 4 files changed, 387 insertions(+) create mode 100644 tests/integration/multi_agent/conftest.py create mode 100644 tests/integration/multi_agent/test_backcompat.py create mode 100644 tests/integration/multi_agent/test_concurrency.py create mode 100644 tests/integration/multi_agent/test_isolation.py diff --git a/tests/integration/multi_agent/conftest.py b/tests/integration/multi_agent/conftest.py new file mode 100644 index 0000000000000..fed9979a8aca9 --- /dev/null +++ b/tests/integration/multi_agent/conftest.py @@ -0,0 +1,232 @@ +"""Integration harness for single-gateway / multi-agent routing (PR #62944). + +Unlike the unit suites (which mock ``resolve_agent_id`` / ``_attach_agent_id`` / +the registry in isolation), these tests drive a **real inbound HTTP request** +through the actual ``APIServerAdapter`` — real routing, real profile/secret +scope, real per-agent credential + home resolution — and observe what the run +*actually saw*. + +Observation model +----------------- +The only thing stubbed is the LLM network turn. A spy ``_create_agent`` runs +INSIDE the real profile+agent scope (installed by ``_use_profile_and_secret_scope`` +nested in ``_profile_scope``) and captures, at that moment: + +* ``get_active_profile().id`` — which agent the run is executing as +* ``get_hermes_home()`` — which per-agent home path getters resolve to +* the SOUL first line — proves memory/skills/SOUL come from that home +* the resolved LLM api_key — proves per-agent credential isolation + +It records these keyed by a per-request nonce (embedded in the user message) so +concurrent, interleaved requests can be correlated to their own response. +Credential resolution itself is REAL (``_resolve_runtime_agent_kwargs`` under the +scope), so a broken scope surfaces as the wrong key/home, not a passing mock. +""" +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +import pytest_asyncio +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway.config import PlatformConfig +from gateway.platforms.api_server import APIServerAdapter + +API_KEY = "sk-test-caller" # global caller bearer (API_SERVER_KEY), not a provider key + + +# -------------------------------------------------------------------------- +# Per-agent home + config builders +# -------------------------------------------------------------------------- +def _write_profile(home: Path, agent_id: str, provider_key: str) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "SOUL.md").write_text(f"I am {agent_id.upper()}. Scope: {agent_id}.\n") + # distinct provider key per agent home .env — the credential-isolation discriminator + (home / ".env").write_text( + f"OPENROUTER_API_KEY={provider_key}\n" + f"CUSTOM_API_KEY={provider_key}\n" + ) + + +def build_multi_agent_home(root: Path, agents: dict, *, default_agent="main", + multiplex=True) -> dict: + """Create a HERMES_HOME with per-agent profiles + a multi-agent config. + + *agents*: ``{agent_id: provider_key}``. Returns the parsed config dict. + """ + profiles_root = root / "profiles" + cfg_agents = {} + routes = [] + for aid, key in agents.items(): + home = profiles_root / aid + _write_profile(home, aid, key) + cfg_agents[aid] = {"home_dir": str(home)} + routes.append({"match": {"platform": "api_server", "chat_id": aid}, "agent": aid}) + # The root/process-global value. Two roles: it must NEVER leak into a + # scoped per-agent read (C), and it IS what a legacy single-agent install + # legitimately reads (H). + (root / ".env").write_text("OPENROUTER_API_KEY=sk-ROOT-env\nCUSTOM_API_KEY=sk-ROOT-env\n") + + config = { + "model": {"default": "echo-model", "provider": "openrouter", + "base_url": "http://127.0.0.1:1/v1", "max_tokens": 32}, + "default_agent": default_agent, + "agents": cfg_agents, + "routes": routes, + "gateway": {"multiplex_profiles": multiplex, + "api_server": {"max_concurrent_runs": 256}}, + } + return config + + +# -------------------------------------------------------------------------- +# The aiohttp app (mirrors gateway.platforms.api_server route wiring) +# -------------------------------------------------------------------------- +def make_app(adapter: APIServerAdapter) -> web.Application: + app = web.Application() + app["api_server_adapter"] = adapter + app.router.add_get("/v1/models", adapter._handle_models) + app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions) + return app + + +# -------------------------------------------------------------------------- +# Spy agent: captures the live scope context, returns a canned one-turn result +# -------------------------------------------------------------------------- +class _SpyAgent: + def __init__(self, captures: list): + self._captures = captures + self.session_id = None + self.session_prompt_tokens = 1 + self.session_completion_tokens = 1 + self.session_total_tokens = 2 + + def run_conversation(self, user_message=None, conversation_history=None, + task_id=None, **kw): + # Executed INSIDE the real profile+agent scope, in the executor thread. + from agent.profile import get_active_profile + from agent.secret_scope import get_secret + from hermes_constants import get_hermes_home + + prof = get_active_profile() + home = str(get_hermes_home()) + soul = "" + soul_path = Path(home) / "SOUL.md" + if soul_path.exists(): + soul = soul_path.read_text().splitlines()[0] + try: + key = get_secret("OPENROUTER_API_KEY") + except Exception as e: # e.g. UnscopedSecretError + key = f"<{type(e).__name__}>" + obs = { + "nonce": (user_message or "").strip(), + "agent_id": getattr(prof, "id", None), + "home": home, + "soul_first_line": soul, + "resolved_key": key, + } + self._captures.append(obs) + return {"final_response": json.dumps(obs), "session_id": task_id} + + +@pytest_asyncio.fixture +async def integ(tmp_path, monkeypatch): + """Factory: build an adapter wired to a multi-agent home, driving real HTTP + through ONE shared TestClient (so concurrent requests share the adapter, as + a real server does). + + Usage:: + + env = integ({"coder": "sk-coder", "research": "sk-research"}) + resp = await env.post("coder", "hello") + assert resp["agent_id"] == "coder" + """ + from unittest.mock import MagicMock + + from agent.secret_scope import is_multiplex_active, set_multiplex_active + + envs: list = [] + # set_multiplex_active flips a process-global flag; capture it here and + # restore it in teardown so a multiplexed env built by this factory cannot + # leak fail-closed get_secret() semantics into unrelated tests that run + # after this suite (they would start raising UnscopedSecretError). + prior_multiplex = is_multiplex_active() + + class _Env: + def __init__(self, agents, *, default_agent="main", multiplex=True): + self.captures: list = [] + self.config = build_multi_agent_home( + tmp_path, agents, default_agent=default_agent, multiplex=multiplex) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from agent.secret_scope import set_multiplex_active + set_multiplex_active(multiplex) + + pcfg = PlatformConfig(enabled=True, extra={"key": API_KEY}) + self.adapter = APIServerAdapter(pcfg) + + # Wire the real routing context + agent registry from the config. + from agent.profile import load_agent_registry + from gateway.config import GatewayConfig + registry = load_agent_registry(GatewayConfig.from_dict(self.config)) + fake_gw = MagicMock() + fake_gw._agent_registry = registry + self.adapter.set_routing_context( + routes=self.config["routes"], + default_agent=default_agent, + gateway=fake_gw, + ) + # Stub ONLY the agent build/LLM turn; routing + scope + credential + # resolution around it stay real. + self.adapter._create_agent = lambda **kw: _SpyAgent(self.captures) + # Disable the concurrent-run admission limit (read from on-disk + # config.yaml, which this in-process harness doesn't write) so the + # concurrency invariant can stress many simultaneous runs. 0 = off. + self.adapter._max_concurrent_runs = 0 + self._client = None + self._lock = asyncio.Lock() + + async def _cli(self): + # Guard against the gather() race where many concurrent posts would + # each create a client; one shared TestClient serves all requests. + async with self._lock: + if self._client is None: + self._client = TestClient(TestServer(make_app(self.adapter))) + await self._client.start_server() + return self._client + + async def post(self, chat_id, message, *, extra_headers=None): + headers = {"Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json"} + if chat_id is not None: + headers["X-Hermes-Chat-Id"] = chat_id + if extra_headers: + headers.update(extra_headers) + cli = await self._cli() + r = await cli.post("/v1/chat/completions", headers=headers, json={ + "model": "echo-model", + "messages": [{"role": "user", "content": message}], + }) + body = await r.json() + content = body.get("choices", [{}])[0].get("message", {}).get("content", "") + try: + return json.loads(content) + except Exception: + return {"raw": body, "status": r.status} + + async def aclose(self): + if self._client is not None: + await self._client.close() + + def factory(agents, **kw): + env = _Env(agents, **kw) + envs.append(env) + return env + + yield factory + set_multiplex_active(prior_multiplex) + for env in envs: + await env.aclose() diff --git a/tests/integration/multi_agent/test_backcompat.py b/tests/integration/multi_agent/test_backcompat.py new file mode 100644 index 0000000000000..d3ec43aafe78d --- /dev/null +++ b/tests/integration/multi_agent/test_backcompat.py @@ -0,0 +1,33 @@ +"""H — backward compatibility: a gateway with NO multi-agent config must behave +exactly like a legacy single-agent install. The "we didn't break existing +deployments" guard for PR #62944. + +A bare install (``agents: {}``, ``routes: []``) resolves the synthetic ``main`` +profile with no per-agent home — the run executes at the ROOT ``HERMES_HOME`` and +reads the ROOT ``.env``, exactly as before the feature existed. +""" +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_bare_install_runs_at_root_home_reading_root_env(integ): + """No agents/routes: request runs as 'main' at the root home, resolving the + root .env key — legacy single-agent behavior, unchanged.""" + env = integ({}, default_agent="main", multiplex=False) # bare: no agents + res = await env.post(None, "n1") # no routing header + + assert res["agent_id"] == "main" + # Home is the root HERMES_HOME, NOT a profiles/ subdir. + assert "/profiles/" not in res["home"] + # Reads the root .env (legacy os.environ-style path), no fail-close. + assert res["resolved_key"] == "sk-ROOT-env" + + +async def test_bare_install_ignores_stray_routing_header(integ): + """A stray X-Hermes-Chat-Id on a non-multi-agent gateway must not break the + request — no route matches, so it stays the default 'main'.""" + env = integ({}, default_agent="main", multiplex=False) + res = await env.post("some-agent", "n1") + assert res["agent_id"] == "main" + assert "/profiles/" not in res["home"] diff --git a/tests/integration/multi_agent/test_concurrency.py b/tests/integration/multi_agent/test_concurrency.py new file mode 100644 index 0000000000000..93ffc8250a6fc --- /dev/null +++ b/tests/integration/multi_agent/test_concurrency.py @@ -0,0 +1,50 @@ +"""P0 — the concurrency isolation invariant. + +The highest-risk untested seam: ContextVars (profile + secret scope) crossing +``run_in_executor`` threads. If binding leaks across concurrently-running agents, +a response comes back with the wrong agent's home/credential. This drives many +interleaved requests through the ONE adapter and asserts, for every response, +that it executed as its OWN agent with its OWN key — the invariant the whole +"multiple agents, one gateway" promise rests on. +""" +import asyncio + +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_interleaved_requests_never_cross_contaminate(integ): + """K agents × M concurrent requests: every response echoes its own + agent_id + home-.env key. Zero cross-talk.""" + agents = {f"agent{i}": f"sk-agent{i}-key" for i in range(4)} + env = integ(agents, multiplex=True) + + # Fire many requests, interleaved across agents, concurrently through one adapter. + plan = [(aid, f"{aid}-req{m}") for m in range(6) for aid in agents] + results = await asyncio.gather(*(env.post(aid, nonce) for aid, nonce in plan)) + + assert len(results) == len(plan) + for (aid, nonce), res in zip(plan, results): + # The response for THIS request must reflect THIS request's agent. + assert res["agent_id"] == aid, f"{nonce}: ran as {res['agent_id']}, expected {aid}" + assert res["resolved_key"] == f"sk-{aid}-key", ( + f"{nonce}: key {res['resolved_key']}, expected sk-{aid}-key") + assert res["home"].endswith(f"/profiles/{aid}") + assert res["nonce"] == nonce # response correlates to its own request + + +async def test_concurrent_same_two_agents_stay_isolated(integ): + """Tight interleave of exactly two agents, high concurrency — the classic + A/B cross-contamination shape.""" + env = integ({"alpha": "sk-alpha", "beta": "sk-beta"}, multiplex=True) + + plan = [] + for i in range(20): + plan.append(("alpha", f"a{i}")) + plan.append(("beta", f"b{i}")) + results = await asyncio.gather(*(env.post(a, n) for a, n in plan)) + + for (aid, _), res in zip(plan, results): + assert res["agent_id"] == aid + assert res["resolved_key"] == f"sk-{aid}" diff --git a/tests/integration/multi_agent/test_isolation.py b/tests/integration/multi_agent/test_isolation.py new file mode 100644 index 0000000000000..4b8499d537192 --- /dev/null +++ b/tests/integration/multi_agent/test_isolation.py @@ -0,0 +1,72 @@ +"""P0/P1 integration: per-agent isolation through a real api_server request. + +These drive a real HTTP request through ``APIServerAdapter`` and assert what the +run actually executed as — proving routing → profile+secret scope → per-agent +home/credential end-to-end, not via mocks. +""" +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_two_agents_resolve_their_own_credential(integ): + """C — credential isolation: each routed agent's run resolves its OWN + home-.env key, never the other's and never the process-global leak.""" + env = integ({"coder": "sk-coder-key", "research": "sk-research-key"}, multiplex=True) + + coder = await env.post("coder", "n1") + research = await env.post("research", "n2") + + assert coder["agent_id"] == "coder" + assert coder["resolved_key"] == "sk-coder-key" + assert research["agent_id"] == "research" + assert research["resolved_key"] == "sk-research-key" + # neither leaked the process-global value + assert "sk-ROOT-env" not in (coder["resolved_key"], research["resolved_key"]) + + +async def test_two_agents_resolve_their_own_home_and_soul(integ): + """B — profile isolation: home getters + SOUL resolve to the routed agent's + own directory, not the other's.""" + env = integ({"coder": "sk-c", "research": "sk-r"}, multiplex=True) + + coder = await env.post("coder", "n1") + research = await env.post("research", "n2") + + assert coder["home"].endswith("/profiles/coder") + assert coder["soul_first_line"] == "I am CODER. Scope: coder." + assert research["home"].endswith("/profiles/research") + assert research["soul_first_line"] == "I am RESEARCH. Scope: research." + assert coder["home"] != research["home"] + + +async def test_header_routes_to_agent_and_default_falls_through(integ): + """A — routing: header selects the agent; absent header → default_agent.""" + env = integ({"main": "sk-main", "coder": "sk-coder"}, default_agent="main") + + routed = await env.post("coder", "n1") + defaulted = await env.post(None, "n2") # no X-Hermes-Chat-Id + + assert routed["agent_id"] == "coder" + assert defaulted["agent_id"] == "main" + + +async def test_unknown_header_falls_through_to_default(integ): + """A — an unmatched routing header is not an error; falls to default_agent.""" + env = integ({"main": "sk-main", "coder": "sk-coder"}, default_agent="main") + res = await env.post("ghost-agent", "n1") + assert res["agent_id"] == "main" + + +async def test_agent_without_key_does_not_leak_process_global(integ): + """C2 — SECURITY: under multiplex, an agent whose scoped home .env has no + usable provider key must NOT fall back to the process-global value. The + scoped read yields a falsy value (empty/None), never sk-GLOBAL-leak — the + fail-closed-vs-leak property the secret scope exists to guarantee.""" + env = integ({"nokey": ""}, default_agent="nokey", multiplex=True) + res = await env.post("nokey", "n1") + + assert res["agent_id"] == "nokey" + # The one thing that must never happen: leaking the process-global key. + assert res["resolved_key"] != "sk-ROOT-env" + assert not res["resolved_key"] # falsy: scoped-but-absent, not leaked From 6ce6d113775b0303bf4a1befff022a9afc1068c9 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Fri, 17 Jul 2026 01:56:20 +0900 Subject: [PATCH 21/29] test(multi-agent): session identity cannot be hijacked by a header Extend the integration harness to wire the REAL /api/sessions/* route table (create/get/fork/chat) through the shared TestClient, and add test_session_identity.py proving the security-adjacent invariant of the stateful session tier: - create persists the routed agent_id (first-writer-wins) on the row; - a session BOUND to agent A runs every turn as A even when a conflicting X-Hermes-Chat-Id: B header is re-sent (header cannot hijack the session); - a header-less chat still runs as the persisted agent (no default fallback); - fork inherits the parent's agent_id; - a legacy no-profile session runs at the root home reading the root .env. Drives real routing + profile/secret scope + SessionDB (state.db); only the LLM turn is stubbed via the existing spy. Mutation-verified: making _handle_session_chat re-resolve the agent from the request header instead of the persisted session flips the hijack/fork tests red. Co-Authored-By: Claude Opus 4.8 --- tests/integration/multi_agent/conftest.py | 72 ++++++++++++- .../multi_agent/test_session_identity.py | 101 ++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tests/integration/multi_agent/test_session_identity.py diff --git a/tests/integration/multi_agent/conftest.py b/tests/integration/multi_agent/conftest.py index fed9979a8aca9..6905ecbf5ba5d 100644 --- a/tests/integration/multi_agent/conftest.py +++ b/tests/integration/multi_agent/conftest.py @@ -89,8 +89,12 @@ def build_multi_agent_home(root: Path, agents: dict, *, default_agent="main", def make_app(adapter: APIServerAdapter) -> web.Application: app = web.Application() app["api_server_adapter"] = adapter - app.router.add_get("/v1/models", adapter._handle_models) - app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions) + # Register the REAL route table the adapter exposes in production + # (``connect()`` uses the same ``_http_route_table()``), not just the two + # chat endpoints — the session-identity tests need the /api/sessions + # create/fork/get/chat routes wired exactly as the live server wires them. + for method, path, handler in adapter._http_route_table(): + app.router.add_route(method, path, handler) return app @@ -159,6 +163,7 @@ async def integ(tmp_path, monkeypatch): class _Env: def __init__(self, agents, *, default_agent="main", multiplex=True): self.captures: list = [] + self.home = tmp_path # root HERMES_HOME; state.db lives here self.config = build_multi_agent_home( tmp_path, agents, default_agent=default_agent, multiplex=multiplex) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) @@ -217,6 +222,69 @@ async def post(self, chat_id, message, *, extra_headers=None): except Exception: return {"raw": body, "status": r.status} + # --- Session resource API helpers (D — session identity) ----------- + # These drive the REAL /api/sessions/* routes wired in make_app so the + # persisted-agent invariant can be exercised end-to-end. + async def _session_request(self, method, path, *, chat_id=None, + extra_headers=None, json_body=None): + headers = {"Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json"} + if chat_id is not None: + headers["X-Hermes-Chat-Id"] = chat_id + if extra_headers: + headers.update(extra_headers) + cli = await self._cli() + r = await cli.request(method, path, headers=headers, + json=json_body if json_body is not None else {}) + return r, await r.json() + + async def create_session(self, chat_id, *, session_id=None, + extra_headers=None): + """POST /api/sessions with an optional routing header. Returns the + client-safe session dict (agent_id is NOT exposed there — read it + with ``persisted_agent_id``).""" + body = {"id": session_id} if session_id else {} + r, data = await self._session_request( + "POST", "/api/sessions", chat_id=chat_id, + extra_headers=extra_headers, json_body=body) + return (data.get("session") or {}), r.status + + async def get_session(self, session_id): + r, data = await self._session_request( + "GET", f"/api/sessions/{session_id}") + return (data.get("session") or {}), r.status + + async def fork_session(self, session_id, *, new_id=None): + body = {"id": new_id} if new_id else {} + r, data = await self._session_request( + "POST", f"/api/sessions/{session_id}/fork", json_body=body) + return (data.get("session") or {}), r.status + + async def session_chat(self, session_id, message, *, + chat_id_header=None, extra_headers=None): + """POST /api/sessions/{id}/chat. ``chat_id_header`` lets a caller + send a CONFLICTING X-Hermes-Chat-Id to prove it cannot hijack the + session's persisted agent. Returns the spy's captured run context.""" + r, data = await self._session_request( + "POST", f"/api/sessions/{session_id}/chat", + chat_id=chat_id_header, extra_headers=extra_headers, + json_body={"message": message}) + content = ((data.get("message") or {}).get("content", "") + if isinstance(data, dict) else "") + try: + return json.loads(content) + except Exception: + return {"raw": data, "status": r.status} + + def persisted_agent_id(self, session_id): + """Read the agent_id persisted on the session row directly from the + real SessionDB (state.db under HERMES_HOME) — the ground truth the + client-safe session view intentionally does not expose.""" + from hermes_state import SessionDB + db = SessionDB(db_path=Path(self.home) / "state.db") + row = db.get_session(session_id) or {} + return row.get("agent_id") + async def aclose(self): if self._client is not None: await self._client.close() diff --git a/tests/integration/multi_agent/test_session_identity.py b/tests/integration/multi_agent/test_session_identity.py new file mode 100644 index 0000000000000..280708f516773 --- /dev/null +++ b/tests/integration/multi_agent/test_session_identity.py @@ -0,0 +1,101 @@ +"""D — session identity: a session BOUND to agent A must not be hijackable to +agent B via a request header. + +The security-adjacent invariant of PR #62944's stateful session tier. A session +persists the agent it was routed to at creation time (first-writer-wins); every +later turn on that session runs under the PERSISTED agent, never a header the +caller re-sends. These drive the REAL /api/sessions/* routes through the actual +APIServerAdapter + a real SessionDB (state.db under HERMES_HOME) and observe, via +the spy, what the run actually executed as. +""" +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_create_session_persists_routed_agent_id(integ): + """(1) POST /api/sessions with X-Hermes-Chat-Id: coder persists + agent_id=coder on the session row.""" + env = integ({"coder": "sk-coder-key", "research": "sk-research-key"}, + default_agent="main", multiplex=True) + + session, status = await env.create_session("coder", session_id="s-coder") + assert status == 201 + assert session["id"] == "s-coder" + # Ground truth: the persisted row carries the routed agent. + assert env.persisted_agent_id("s-coder") == "coder" + + +async def test_session_chat_runs_as_persisted_agent_not_header(integ): + """(2) THE CORE INVARIANT. A coder-bound session, chatted with a CONFLICTING + X-Hermes-Chat-Id: research header, executes as CODER (persisted agent), and + resolves CODER's credential — the header cannot hijack it.""" + env = integ({"coder": "sk-coder-key", "research": "sk-research-key"}, + default_agent="main", multiplex=True) + + _, status = await env.create_session("coder", session_id="s-hijack") + assert status == 201 + assert env.persisted_agent_id("s-hijack") == "coder" + + # Attacker re-sends a research routing header on the coder-bound session. + run = await env.session_chat("s-hijack", "hello", chat_id_header="research") + + assert run["agent_id"] == "coder", ( + f"session hijacked: ran as {run['agent_id']} via header, expected coder") + assert run["resolved_key"] == "sk-coder-key", ( + f"leaked wrong credential: {run['resolved_key']}") + assert run["home"].endswith("/profiles/coder") + # And definitely not research's identity/credential. + assert run["resolved_key"] != "sk-research-key" + assert "sk-ROOT-env" not in run["resolved_key"] + + +async def test_session_chat_without_header_still_runs_as_persisted_agent(integ): + """(2b) The mirror case: NO header on the chat must not fall back to + default 'main'; it still runs as the session's persisted agent.""" + env = integ({"coder": "sk-coder-key", "research": "sk-research-key"}, + default_agent="main", multiplex=True) + + await env.create_session("research", session_id="s-research") + run = await env.session_chat("s-research", "hi") # no routing header at all + + assert run["agent_id"] == "research" + assert run["resolved_key"] == "sk-research-key" + + +async def test_fork_inherits_parent_agent_id(integ): + """(3) A fork inherits the parent's persisted agent_id (fork carries no + routing headers; re-resolving would wrongly fall back to default).""" + env = integ({"coder": "sk-coder-key", "research": "sk-research-key"}, + default_agent="main", multiplex=True) + + await env.create_session("coder", session_id="s-parent") + fork, status = await env.fork_session("s-parent", new_id="s-fork") + assert status == 201 + assert fork["id"] == "s-fork" + assert fork["parent_session_id"] == "s-parent" + # Inherited on the row... + assert env.persisted_agent_id("s-fork") == "coder" + # ...and honoured at chat time. + run = await env.session_chat("s-fork", "continue") + assert run["agent_id"] == "coder" + assert run["resolved_key"] == "sk-coder-key" + + +async def test_legacy_no_agent_session_runs_as_default(integ): + """(4) A session created with NO routing header on a gateway whose default + 'main' has no registered profile runs at the root home reading the root + .env (None profile), unchanged from legacy single-agent behaviour.""" + # 'main' is the default_agent but is NOT in the agent registry, so + # _profile_for_agent_id('main') -> None (the legacy no-profile path). + env = integ({"coder": "sk-coder-key"}, default_agent="main", multiplex=True) + + session, status = await env.create_session(None, session_id="s-legacy") + assert status == 201 + # Defaulted to 'main' at creation (first-writer-wins default). + assert env.persisted_agent_id("s-legacy") == "main" + + run = await env.session_chat("s-legacy", "hello") + assert run["agent_id"] == "main" + assert "/profiles/" not in run["home"] # root home, not a per-agent dir + assert run["resolved_key"] == "sk-ROOT-env" # legacy root .env read From c258a300cd415c0f36579b91b837a3a7ee2a2248 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Fri, 17 Jul 2026 01:58:26 +0900 Subject: [PATCH 22/29] test(multi-agent): per-agent memory store isolation Close the loop on the persistent per-agent surface. The existing suite proves HOME + SOUL + credential isolation; this adds the memory store. Exercise the REAL tools.memory_tool.MemoryStore (whose get_memory_dir() resolves through get_hermes_home()) under the SAME _use_profile_and_secret_scope wrapper that _run_agent enters in the executor thread: - a memory written as coder lands under profiles/coder/memories/MEMORY.md; - a research-scoped read never surfaces coder's entry, and vice-versa; - filesystem ground truth: coder's MEMORY.md exists under its own home and no such file exists under research's home. Mutation-verified: pointing get_memory_dir() at an unscoped shared dir (so the profile scope no longer redirects the store) flips both tests red. Co-Authored-By: Claude Opus 4.8 --- .../multi_agent/test_memory_isolation.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/integration/multi_agent/test_memory_isolation.py diff --git a/tests/integration/multi_agent/test_memory_isolation.py b/tests/integration/multi_agent/test_memory_isolation.py new file mode 100644 index 0000000000000..32a5fa522041c --- /dev/null +++ b/tests/integration/multi_agent/test_memory_isolation.py @@ -0,0 +1,94 @@ +"""B (memory half) — per-agent MEMORY isolation. + +The existing suite proves HOME + SOUL + credential isolation. This closes the +loop on the *persistent* per-agent surface: the memory store. A memory written +while running as agent A must land under A's own home (``profiles/A/memories``) +and must NOT be readable by a run scoped to agent B. + +We exercise the REAL memory subsystem (``tools.memory_tool.MemoryStore``, whose +``get_memory_dir()`` resolves through ``get_hermes_home()``) under the SAME +profile+secret scope a real run enters (``_use_profile_and_secret_scope``, the +wrapper ``APIServerAdapter._run_agent`` uses inside the executor thread). So the +redirection under test is the production one, not a stand-in. +""" +from pathlib import Path + +import pytest + +from gateway.platforms.api_server import _use_profile_and_secret_scope +from tools.memory_tool import MemoryStore, get_memory_dir + +CODER_MEMO = "Coder private note alpha-7: the widget build lives in module X." +RESEARCH_MEMO = "Research private note beta-3: the survey cohort is N=42." + + +def _write_memory(text: str) -> Path: + """Add a MEMORY.md entry under the currently-scoped agent home; return the + resolved memory dir.""" + store = MemoryStore() + result = store.add("memory", text) + assert result.get("success"), f"memory write failed: {result}" + return get_memory_dir() + + +def _read_memory_entries() -> list: + store = MemoryStore() + store.load_from_disk() + return list(store.memory_entries) + + +def test_memory_written_as_A_is_isolated_from_B(integ): + """A memory written as coder lands in coder's home and is absent from a + research-scoped read; and vice-versa — no cross-agent leakage.""" + env = integ({"coder": "sk-coder-key", "research": "sk-research-key"}, + multiplex=True) + coder = env.adapter._profile_for_agent_id("coder") + research = env.adapter._profile_for_agent_id("research") + assert coder is not None and research is not None + + # --- write as coder ------------------------------------------------ + with _use_profile_and_secret_scope(coder): + coder_dir = _write_memory(CODER_MEMO) + # Artifact physically lands under coder's OWN home. + assert coder_dir == coder.resolved_home / "memories" + coder_file = coder_dir / "MEMORY.md" + assert coder_file.exists() + assert CODER_MEMO in coder_file.read_text() + + # --- write as research (its own distinct memo) --------------------- + with _use_profile_and_secret_scope(research): + research_dir = _write_memory(RESEARCH_MEMO) + assert research_dir == research.resolved_home / "memories" + assert research_dir != coder_dir + + # --- read as research: coder's memo must NOT surface --------------- + with _use_profile_and_secret_scope(research): + research_entries = _read_memory_entries() + assert CODER_MEMO not in research_entries, ( + "coder's memory leaked into research's scope") + assert RESEARCH_MEMO in research_entries # research sees its own + + # --- read as coder: research's memo must NOT surface --------------- + with _use_profile_and_secret_scope(coder): + coder_entries = _read_memory_entries() + assert RESEARCH_MEMO not in coder_entries, ( + "research's memory leaked into coder's scope") + assert CODER_MEMO in coder_entries # coder sees its own + + +def test_agent_memory_file_absent_under_other_agent_home(integ): + """Filesystem ground truth: coder's MEMORY.md exists under profiles/coder + and there is no such file under profiles/research (research never wrote).""" + env = integ({"coder": "sk-coder-key", "research": "sk-research-key"}, + multiplex=True) + coder = env.adapter._profile_for_agent_id("coder") + research = env.adapter._profile_for_agent_id("research") + + with _use_profile_and_secret_scope(coder): + _write_memory(CODER_MEMO) + + coder_file = coder.resolved_home / "memories" / "MEMORY.md" + research_file = research.resolved_home / "memories" / "MEMORY.md" + assert coder_file.exists() and CODER_MEMO in coder_file.read_text() + # research wrote nothing → no file, and certainly not coder's content. + assert not research_file.exists() From ac3f13a66344c9bb6c1d7fbed23d69d20e60999d Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Fri, 17 Jul 2026 02:02:31 +0900 Subject: [PATCH 23/29] test(multi-agent): full-process container smoke for gateway routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ONE T2 smoke that exercises single-gateway multi-agent routing across a REAL OS process + executor-thread boundary, which the in-process integration tier (shared event loop) cannot. Two agents are routed through the real APIServerAdapter over real aiohttp HTTP inside a real container process; the spy run persists a run_dump.json under get_hermes_home(), and the host asserts each agent's dump landed under its OWN scoped home (profiles//) with its own agent_id + credential, and nothing leaked to the root home. Approach: Docker lane (docker available + cached hermes-agent-harness:latest), chosen over driving a live gateway for reliability — no multi-minute cold start, no real port binding. Because the cached image predates this feature, the worktree is mounted read-only at /host_repo and prepended to sys.path so the container runs the feature code under test. Skips automatically when no Docker daemon is present (tests/docker conftest policy). Mutation-verified: neutering _use_profile_and_secret_scope (never bind the per-agent scope) makes the containerized run report agent_id=None and the smoke goes red; revert restores green. Co-Authored-By: Claude Opus 4.8 --- tests/docker/multi_agent_smoke_probe.py | 147 ++++++++++++++++++ .../docker/test_multi_agent_gateway_smoke.py | 107 +++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 tests/docker/multi_agent_smoke_probe.py create mode 100644 tests/docker/test_multi_agent_gateway_smoke.py diff --git a/tests/docker/multi_agent_smoke_probe.py b/tests/docker/multi_agent_smoke_probe.py new file mode 100644 index 0000000000000..0b8d622713cb5 --- /dev/null +++ b/tests/docker/multi_agent_smoke_probe.py @@ -0,0 +1,147 @@ +"""In-container probe for the multi-agent single-gateway smoke (T2). + +Run as a REAL separate OS process INSIDE the container (``docker exec python3 +/host_repo/tests/docker/multi_agent_smoke_probe.py``). The worktree is mounted +at ``/host_repo`` and prepended to ``sys.path`` so this exercises the FEATURE +code under test (the cached image predates it), not the image's baked-in copy. + +What it proves across the real process boundary +----------------------------------------------- +Two agents (``coder``/``research``) are routed through the REAL +``APIServerAdapter`` over REAL aiohttp HTTP. Only the LLM turn is stubbed: the +spy runs INSIDE the real profile+secret scope, in the real ``run_in_executor`` +thread, and WRITES a ``run_dump.json`` to ``get_hermes_home()``. Because the +scope redirects the home per agent, coder's dump lands under +``profiles/coder/`` and research's under ``profiles/research/`` — an on-disk +artifact proving the ContextVar profile+secret scope propagated across the real +executor-thread boundary in a real process. The host test reads those files. + +Exit 0 on success; non-zero (with a diagnostic on stderr) otherwise. +""" +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path + +# The mounted worktree (feature code) must win over the image's /opt/hermes. +HOST_REPO = os.environ.get("HOST_REPO", "/host_repo") +sys.path.insert(0, HOST_REPO) + +HOME = Path(os.environ["HERMES_HOME"]) +API_KEY = "sk-smoke-caller" +AGENTS = {"coder": "sk-coder-smoke", "research": "sk-research-smoke"} + + +def _build_home() -> dict: + profiles_root = HOME / "profiles" + cfg_agents, routes = {}, [] + for aid, key in AGENTS.items(): + home = profiles_root / aid + home.mkdir(parents=True, exist_ok=True) + (home / "SOUL.md").write_text(f"I am {aid.upper()}. Scope: {aid}.\n") + (home / ".env").write_text(f"OPENROUTER_API_KEY={key}\nCUSTOM_API_KEY={key}\n") + cfg_agents[aid] = {"home_dir": str(home)} + routes.append({"match": {"platform": "api_server", "chat_id": aid}, "agent": aid}) + (HOME / ".env").write_text("OPENROUTER_API_KEY=sk-ROOT-env\nCUSTOM_API_KEY=sk-ROOT-env\n") + return { + "model": {"default": "echo-model", "provider": "openrouter", + "base_url": "http://127.0.0.1:1/v1", "max_tokens": 32}, + "default_agent": "main", + "agents": cfg_agents, + "routes": routes, + "gateway": {"multiplex_profiles": True, + "api_server": {"max_concurrent_runs": 256}}, + } + + +class _SpyAgent: + """Captures the live scope and persists it to the scoped home on disk.""" + + def __init__(self): + self.session_id = None + self.session_prompt_tokens = 1 + self.session_completion_tokens = 1 + self.session_total_tokens = 2 + + def run_conversation(self, user_message=None, conversation_history=None, + task_id=None, **kw): + from agent.profile import get_active_profile + from agent.secret_scope import get_secret + from hermes_constants import get_hermes_home + + prof = get_active_profile() + home = Path(get_hermes_home()) + try: + key = get_secret("OPENROUTER_API_KEY") + except Exception as e: # noqa: BLE001 + key = f"<{type(e).__name__}>" + soul_path = home / "SOUL.md" + soul = soul_path.read_text().splitlines()[0] if soul_path.exists() else "" + obs = { + "nonce": (user_message or "").strip(), + "agent_id": getattr(prof, "id", None), + "home": str(home), + "soul_first_line": soul, + "resolved_key": key, + } + # The on-disk artifact under the SCOPED home — the thing the host asserts. + (home / "run_dump.json").write_text(json.dumps(obs)) + return {"final_response": json.dumps(obs), "session_id": task_id} + + +async def _amain() -> int: + from unittest.mock import MagicMock + + from aiohttp import web + from aiohttp.test_utils import TestClient, TestServer + + from agent.profile import load_agent_registry + from agent.secret_scope import set_multiplex_active + from gateway.config import GatewayConfig, PlatformConfig + from gateway.platforms.api_server import APIServerAdapter + + config = _build_home() + set_multiplex_active(True) + + adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"key": API_KEY})) + registry = load_agent_registry(GatewayConfig.from_dict(config)) + fake_gw = MagicMock() + fake_gw._agent_registry = registry + adapter.set_routing_context(routes=config["routes"], default_agent="main", + gateway=fake_gw) + adapter._create_agent = lambda **kw: _SpyAgent() + adapter._max_concurrent_runs = 0 + + app = web.Application() + for method, path, handler in adapter._http_route_table(): + app.router.add_route(method, path, handler) + + client = TestClient(TestServer(app)) + await client.start_server() + try: + for aid in AGENTS: + r = await client.post( + "/v1/chat/completions", + headers={"Authorization": f"Bearer {API_KEY}", + "X-Hermes-Chat-Id": aid, + "Content-Type": "application/json"}, + json={"model": "echo-model", + "messages": [{"role": "user", "content": f"{aid}-nonce"}]}, + ) + body = await r.json() + content = body.get("choices", [{}])[0].get("message", {}).get("content", "") + obs = json.loads(content) + if obs.get("agent_id") != aid: + print(f"PROBE-FAIL: {aid} ran as {obs.get('agent_id')}", file=sys.stderr) + return 2 + finally: + await client.close() + print("PROBE-OK") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(_amain())) diff --git a/tests/docker/test_multi_agent_gateway_smoke.py b/tests/docker/test_multi_agent_gateway_smoke.py new file mode 100644 index 0000000000000..22350fecc08fc --- /dev/null +++ b/tests/docker/test_multi_agent_gateway_smoke.py @@ -0,0 +1,107 @@ +"""T2 — one full-process smoke for single-gateway multi-agent routing. + +The in-process integration tier (``tests/integration/multi_agent/``) shares the +pytest event loop, so it cannot prove the mechanism survives a real OS process / +executor-thread boundary. This adds exactly ONE test that does: it drives two +routed agents through the REAL ``APIServerAdapter`` over REAL aiohttp HTTP inside +a REAL separate container process, and asserts — via on-disk artifacts the run +wrote under each agent's SCOPED home — that per-agent ROUTING + per-agent HOME + +per-agent CREDENTIAL reached the run across that boundary. + +Why the worktree is mounted +--------------------------- +The cached harness image (``hermes-agent-harness:latest``) predates PR #62944, so +its baked-in ``/opt/hermes`` lacks the feature (``set_routing_context`` / +``_use_profile_and_secret_scope`` / persisted-agent session scope). Baking a fresh +image per run costs minutes. Instead we mount the worktree read-only at +``/host_repo`` and prepend it to ``sys.path`` in the probe, so the container +process runs the FEATURE code under test while reusing the image's Python + deps. +The probe writes ``run_dump.json`` under ``get_hermes_home()``; because the real +profile+secret scope redirects the home per agent, coder's dump lands under +``profiles/coder/`` and research's under ``profiles/research/`` — the observable +proof the ContextVar scope propagated across the real executor thread. + +Skips automatically unless a Docker daemon is available (see conftest's +``pytest_collection_modifyitems``). +""" +from __future__ import annotations + +import json +import os +import subprocess + +from tests.docker.conftest import docker_exec + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +_HOME = "/tmp/smoke_home" +_PROBE = "/host_repo/tests/docker/multi_agent_smoke_probe.py" + +_EXPECTED = { + "coder": {"key": "sk-coder-smoke", "soul": "I am CODER. Scope: coder."}, + "research": {"key": "sk-research-smoke", "soul": "I am RESEARCH. Scope: research."}, +} + + +def _start_mounted_container(image: str, name: str) -> None: + """Start a bare container with the worktree mounted read-only. + + We deliberately bypass ``start_container`` (which boots the full s6/cont-init + tree): this smoke needs only a real process host running the mounted feature + code, not the service supervisor. + """ + subprocess.run( + ["docker", "run", "-d", "--name", name, + "-v", f"{REPO_ROOT}:/host_repo:ro", + "--entrypoint", "sleep", image, "300"], + check=True, capture_output=True, timeout=60, + ) + + +def test_multi_agent_routing_scope_crosses_real_process_boundary( + built_image: str, container_name: str, +) -> None: + _start_mounted_container(built_image, container_name) + + # Run the probe as the unprivileged hermes user — a real, separate OS + # process executing the feature code over real aiohttp HTTP. + r = docker_exec( + container_name, "python3", _PROBE, + user="hermes", timeout=120, + extra_docker_args=("-e", f"HERMES_HOME={_HOME}", "-e", "HOST_REPO=/host_repo"), + ) + assert "PROBE-OK" in r.stdout, ( + f"probe failed: rc={r.returncode}\nstdout={r.stdout!r}\nstderr={r.stderr!r}" + ) + + # Ground truth: each agent's run wrote its dump under its OWN scoped home. + dumps: dict[str, dict] = {} + for aid in _EXPECTED: + cat = docker_exec( + container_name, "cat", f"{_HOME}/profiles/{aid}/run_dump.json", + user="hermes", timeout=10, + ) + assert cat.returncode == 0, ( + f"no run_dump for {aid} under its scoped home: {cat.stderr!r}") + dumps[aid] = json.loads(cat.stdout) + + for aid, exp in _EXPECTED.items(): + d = dumps[aid] + # Routed to the right agent... + assert d["agent_id"] == aid, f"{aid}: ran as {d['agent_id']}" + # ...reached that agent's per-agent home... + assert d["home"].endswith(f"/profiles/{aid}"), f"{aid}: home={d['home']}" + assert d["soul_first_line"] == exp["soul"] + # ...and resolved that agent's OWN credential, never the other's or root. + assert d["resolved_key"] == exp["key"], f"{aid}: key={d['resolved_key']}" + assert d["resolved_key"] != "sk-ROOT-env" + + # Cross-isolation: the two runs did not share home or credential. + assert dumps["coder"]["home"] != dumps["research"]["home"] + assert dumps["coder"]["resolved_key"] != dumps["research"]["resolved_key"] + + # And the process-global root home saw no run dump (no leakage to root). + root = docker_exec( + container_name, "test", "-f", f"{_HOME}/run_dump.json", + user="hermes", timeout=10, + ) + assert root.returncode != 0, "a run leaked its dump to the root home" From 8c22d9710c03e17857fc5fdbe68df06a7ffaf4c0 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Sun, 19 Jul 2026 10:36:31 +0900 Subject: [PATCH 24/29] test(multi-agent): survive sys.modules nukes in profile ContextVar suite Some suites (e.g. test_empty_tool_name_loop_dampening) delete every cached agent.* module from sys.modules to force fresh imports. After that nuke, agent.profile re-imports with a brand-new ContextVar while this file's import-time bindings still reference the old module, so set/reset tokens cross ContextVar instances (ValueError) and an AgentProfile leaks into every subsequent test in the class. Re-bind the module's agent.profile symbols to the live module before each test, and clear the active profile on teardown so a mid-test assertion failure can never leak state into later tests. Co-Authored-By: Claude Fable 5 --- tests/agent/test_profile_contextvar.py | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/agent/test_profile_contextvar.py b/tests/agent/test_profile_contextvar.py index a2a84f061af8f..368c96bade24b 100644 --- a/tests/agent/test_profile_contextvar.py +++ b/tests/agent/test_profile_contextvar.py @@ -16,6 +16,37 @@ ) +@pytest.fixture(autouse=True) +def _rebind_live_profile_module(): + """Re-bind this module's agent.profile symbols to the LIVE module. + + Some suites (e.g. test_empty_tool_name_loop_dampening) drop every cached + ``agent.*`` entry from ``sys.modules`` to force fresh imports. After such + a nuke, ``agent.profile`` is re-imported with a brand-new ContextVar, while + this module's import-time bindings still point at the OLD module — so + ``set_active_profile`` (old var) and ``_current_agent_profile`` fetched + inside a test (new var) silently disagree, and dataclass equality across + the two AgentProfile classes is always False. Re-binding at test start + keeps every symbol self-consistent regardless of suite order. + """ + import agent.profile as _live + + g = globals() + for name in ( + "AgentProfile", + "get_active_profile", + "set_active_profile", + "use_profile", + "load_agent_registry", + "DEFAULT_AGENT_ID", + ): + g[name] = getattr(_live, name) + yield + # Hygiene: never leak an active profile into later tests, even if an + # assertion tripped mid-test before its own cleanup ran. + _live._current_agent_profile.set(None) + + class TestAgentProfile: def test_default_profile(self): p = AgentProfile() From 8b5e33e1e8e78c3adb10a05475f64e43506f6747 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Wed, 5 Aug 2026 19:28:04 +0900 Subject: [PATCH 25/29] fix(gateway): guard /v1/runs cleanup when agent construction fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-agent work moved _create_agent() from the async _run_and_close() scope into _run_sync(), where it runs under the routed profile's ContextVars (asyncio's default executor does not copy them). That made `agent` a local of _run_sync, so when _create_agent() raised before binding it -- exactly what a provider auth/credential failure does -- the `finally` block's _clear_turn_process_ownership(agent) raised UnboundLocalError and REPLACED the original exception. The consequence is user-visible: /v1/runs reported "cannot access local variable 'agent' where it is not associated with a value" instead of the distinguished "⚠️ Provider authentication failed: ..." message that the _ProviderAuthResolutionError handler already produces for this endpoint. Initialize `agent = None` and guard the cleanup, mirroring what the sibling _run() executor path in this same file already does. Regression covered by the existing tests/gateway/test_api_server_runs.py::TestRunsProviderAuthFailure, which was red on this branch and is green again. Co-Authored-By: Claude Opus 5 (1M context) --- gateway/platforms/api_server.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 8b2063bc950ea..400151e28297a 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -7896,6 +7896,7 @@ def _run_sync(): effective_task_id = session_id or run_id approval_token = None session_tokens = [] + agent = None with self._profile_scope(request_profile): try: # Bind approval/session identity for this API run via @@ -7955,7 +7956,13 @@ def _run_sync(): # stop/cancel can't reap background work this # run deliberately left running (same race-window # guard as gateway/run.py and _run_agent above). - _clear_turn_process_ownership(agent) + # _create_agent() now runs inside this thread (it + # needs the routed profile's ContextVars) and can + # raise before `agent` is bound — guard it so the + # cleanup can't mask a provider auth failure with + # an UnboundLocalError. + if agent is not None: + _clear_turn_process_ownership(agent) try: unregister_gateway_notify(approval_session_key) finally: From 8374a7c97a965ca086a0af6a10a6c1568ef01f49 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Wed, 5 Aug 2026 19:28:13 +0900 Subject: [PATCH 26/29] fix(hooks): only tag lifecycle payloads with agent_id when a profile is bound pre_tool_call and subagent_stop were passing agent_id unconditionally, so every install -- including single-agent ones with no routed profile -- started receiving an extra `agent_id: None` kwarg. That is a silent contract change for existing observers and plugins, and it broke two payload-shape assertions in the tree (tests/hermes_cli/test_plugins.py's first-party observer test and tests/agent/test_subagent_lifecycle.py's host-aggregation test). Add the kwarg only when an agent profile is actually bound. Multi-agent deployments still get the tag they need; single-agent payloads are byte- identical to before, which is the backward-compatibility promise the rest of this PR makes for sessions, cron jobs, and state. Adds tests/agent/test_hook_agent_id_tagging.py pinning both directions for both hooks (the tagging had no coverage of its own before), and documents the conditional presence in the multi-agent backward-compatibility section. Co-Authored-By: Claude Opus 5 (1M context) --- hermes_cli/plugins.py | 7 +- tests/agent/test_hook_agent_id_tagging.py | 97 +++++++++++++++++++ tools/delegate_tool.py | 5 +- .../docs/user-guide/messaging/multi-agent.md | 3 + 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 tests/agent/test_hook_agent_id_tagging.py diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 726d7d67e4ada..941e11baa30b8 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -6063,6 +6063,11 @@ def _get_pre_tool_call_directive_details( _agent_id = _p.id except Exception: pass + # Only add agent_id when an agent profile is actually bound. A + # single-agent install has no routed profile, and observers there keep + # receiving the exact payload they always have — no `agent_id: None` + # appearing in every hook kwarg set. + _agent_kwargs = {"agent_id": _agent_id} if _agent_id else {} hook_results = invoke_lifecycle_hook( "pre_tool_call", tool_name=tool_name, @@ -6073,7 +6078,7 @@ def _get_pre_tool_call_directive_details( turn_id=turn_id, api_request_id=api_request_id, middleware_trace=list(middleware_trace or []), - agent_id=_agent_id, + **_agent_kwargs, ) block_msg: Optional[str] = None diff --git a/tests/agent/test_hook_agent_id_tagging.py b/tests/agent/test_hook_agent_id_tagging.py new file mode 100644 index 0000000000000..a4e8301b5af9d --- /dev/null +++ b/tests/agent/test_hook_agent_id_tagging.py @@ -0,0 +1,97 @@ +"""Lifecycle hooks carry ``agent_id`` only when an agent profile is bound. + +Multi-agent installs want to know *which* agent fired a ``pre_tool_call`` or +``subagent_stop`` hook. Single-agent installs have no routed profile, and +their hook payloads must stay byte-identical to what observers and plugins +already receive — an ``agent_id: None`` key on every payload is a silent +contract change for every existing consumer. +""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from agent.profile import AgentProfile, use_profile + + +@pytest.fixture(autouse=True) +def _no_leaked_profile(): + """Never leak an active profile into later tests in this session.""" + import agent.profile as _live + + yield + _live._current_agent_profile.set(None) + + +class TestPreToolCallAgentId: + def _observed_payload(self, monkeypatch): + from hermes_cli import observability + from hermes_cli.plugins import get_pre_tool_call_directive + + observed = [] + monkeypatch.setattr( + observability, + "observe_lifecycle", + lambda hook_name, **kwargs: observed.append((hook_name, kwargs)), + ) + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [], + ) + get_pre_tool_call_directive( + "write_file", + {"path": "README.md"}, + task_id="task-1", + session_id="session-1", + tool_call_id="call-1", + ) + assert len(observed) == 1 + return observed[0][1] + + def test_no_profile_bound_omits_agent_id(self, monkeypatch): + assert "agent_id" not in self._observed_payload(monkeypatch) + + def test_bound_profile_tags_payload(self, monkeypatch): + with use_profile(AgentProfile(id="coder")): + payload = self._observed_payload(monkeypatch) + assert payload["agent_id"] == "coder" + + +class TestSubagentStopAgentId: + def _invoke(self, monkeypatch): + from tools import delegate_tool + + hook = Mock() + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", hook) + parent = SimpleNamespace( + session_id="parent-1", + _current_turn_id="turn-1", + _memory_manager=None, + ) + child = SimpleNamespace(session_id="child-1") + delegate_tool._finalize_child_results( + [ + { + "task_index": 0, + "status": "completed", + "summary": "done", + "duration_seconds": 0.25, + "_child_role": "leaf", + "_child_cost_usd": 0.0, + } + ], + [{"goal": "do a thing"}], + [(0, {"goal": "do a thing"}, child)], + parent, + ) + hook.assert_called_once() + return hook.call_args.kwargs + + def test_no_profile_bound_omits_agent_id(self, monkeypatch): + assert "agent_id" not in self._invoke(monkeypatch) + + def test_bound_profile_tags_payload(self, monkeypatch): + with use_profile(AgentProfile(id="researcher")): + kwargs = self._invoke(monkeypatch) + assert kwargs["agent_id"] == "researcher" diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 7026fa167554e..f99fa1586d76a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -3444,6 +3444,9 @@ def _finalize_child_results( agent_id = _p.id except Exception: pass + # Only tag the hook payload when a routed agent profile is bound, so + # single-agent installs keep the exact subagent_stop kwargs they had. + agent_kwargs = {"agent_id": agent_id} if agent_id else {} children_cost_total = 0.0 for entry in results: @@ -3471,7 +3474,7 @@ def _finalize_child_results( entry.get("tool_trace") ), duration_ms=int((entry.get("duration_seconds") or 0) * 1000), - agent_id=agent_id, + **agent_kwargs, ) except Exception: logger.debug("subagent_stop hook invocation failed", exc_info=True) diff --git a/website/docs/user-guide/messaging/multi-agent.md b/website/docs/user-guide/messaging/multi-agent.md index ccee15b618672..ec82987cdedce 100644 --- a/website/docs/user-guide/messaging/multi-agent.md +++ b/website/docs/user-guide/messaging/multi-agent.md @@ -167,6 +167,9 @@ Existing single-agent installs require **zero changes**: - 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"` +- Lifecycle hooks (`pre_tool_call`, `subagent_stop`) gain an `agent_id` kwarg **only** when a routed + agent profile is bound. With no profile bound, observers and plugins receive exactly the payload + they always have ## Limitations (MVP) From 2d23d08643042dbdb77bc4d4c4cf4c77f4a50c43 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Wed, 12 Aug 2026 22:46:29 +0900 Subject: [PATCH 27/29] fix(cron): include agent_id consistently in all delivery-target branches _resolve_single_delivery_target() built its returned dict in 4 places; 3 of the 4 already threaded the per-agent agent_id field through, but the origin-platform-matches-with-configured-home-channel branch didn't, leaving delivery targets resolved that way silently missing agent routing info. Add it there too, matching every other branch, and update the two tests that asserted the old (inconsistent) shape. Co-Authored-By: Claude Fable 5 --- cron/scheduler.py | 1 + tests/cron/test_cron_relay_delivery_guards.py | 2 +- tests/cron/test_relay_fronted_delivery.py | 1 + tests/cron/test_scheduler.py | 1 + tests/tools/test_send_message_plugin_extensibility.py | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 97a32851724eb..52a5662a70423 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2404,6 +2404,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 { "platform": platform_name, diff --git a/tests/cron/test_cron_relay_delivery_guards.py b/tests/cron/test_cron_relay_delivery_guards.py index 838e3cd33b1c9..cd307931c6e93 100644 --- a/tests/cron/test_cron_relay_delivery_guards.py +++ b/tests/cron/test_cron_relay_delivery_guards.py @@ -46,7 +46,7 @@ def test_origin_thread_dropped_when_chat_is_home(self, monkeypatch): "thread_id": SYNTH}} target = _resolve_single_delivery_target(job, "origin") assert target == {"platform": "slack", "chat_id": "D0BJTDCSR7C", - "thread_id": None} + "thread_id": None, "agent_id": None} def test_origin_thread_kept_when_chat_not_home(self, monkeypatch): """A non-home Slack origin thread may be a genuine working thread: keep it.""" diff --git a/tests/cron/test_relay_fronted_delivery.py b/tests/cron/test_relay_fronted_delivery.py index eb5f0834989c4..b8b5792eb0b99 100644 --- a/tests/cron/test_relay_fronted_delivery.py +++ b/tests/cron/test_relay_fronted_delivery.py @@ -106,6 +106,7 @@ def test_deliver_discord_resolves_via_config_home(self, monkeypatch): "platform": "discord", "chat_id": "1517373704248758474", "thread_id": None, + "agent_id": None, }] diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index b82146e2f2871..636524baeaf50 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -215,6 +215,7 @@ def test_bare_platform_delivery_uses_home_root_instead_of_origin_thread(self, mo "platform": "discord", "chat_id": "home-parent", "thread_id": None, + "agent_id": None, } def test_telegram_cron_thread_id_overrides_home_thread_id(self, monkeypatch): diff --git a/tests/tools/test_send_message_plugin_extensibility.py b/tests/tools/test_send_message_plugin_extensibility.py index e1acf11636d4f..e37530d2343ba 100644 --- a/tests/tools/test_send_message_plugin_extensibility.py +++ b/tests/tools/test_send_message_plugin_extensibility.py @@ -184,6 +184,7 @@ def test_cli_and_cron_share_plugin_target_normalization(plugin_platform, monkeyp "platform": name, "chat_id": "@alice@example.com", "thread_id": None, + "agent_id": None, } From a40331e2934e967f27fa1c5481fa54c69893608d Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Thu, 20 Aug 2026 18:26:40 +0900 Subject: [PATCH 28/29] test(cron): fix last stale delivery-target assertion missed by 71c8f14 _resolve_delivery_target carries agent_id on every branch (7f6f4b70a0), but test_unresolved_target_still_delivered_as_written predates this PR's base and wasn't touched by 71c8f14357's earlier sweep of the same class of stale assertion -- caught by the CI run after this rebase. Co-Authored-By: Claude Sonnet 5 --- tests/cron/test_scheduler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 636524baeaf50..c2c63564f8c0c 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -293,6 +293,7 @@ def test_unresolved_target_still_delivered_as_written(self): "platform": "telegram", "chat_id": "ops-room", "thread_id": None, + "agent_id": None, } From 2e8cc7d85649fc3bae5a184ca8267824e65e3f07 Mon Sep 17 00:00:00 2001 From: Jetha Chan Date: Fri, 21 Aug 2026 01:08:36 +0900 Subject: [PATCH 29/29] fix(gateway): run profile-route rejection gate before AgentProfile binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _handle_message's wrapper/_handle_message_inner split (this PR's own 9723fe800a) moved the cross-session leak guard + profile_route_rejected fail-closed gate into _handle_message_inner, one call-frame below where main's later profile-routing work (added after this PR's original base) expects them to run. The rebase merged both cleanly with no textual conflict, but the result silently regressed the gate: _handle_message unconditionally delegated to _handle_message_inner before ever checking profile_route_rejected, so a rejected route still acquired an AgentProfile binding and reached the real dispatch — caught by CI's tests/gateway/test_profile_resolution.py, not by the rebase itself. Move the leak guard + rejection gate back into _handle_message, ahead of the AgentProfile ContextVar binding, matching main's documented intent ("shared fail-closed ingress gate before authorization, hooks, or session side effects") and restoring 13/13 on the affected test file. Co-Authored-By: Claude Sonnet 5 --- gateway/run.py | 52 ++++++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 365f2e9520958..107de7f264af8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16593,28 +16593,6 @@ 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 # 🔴 Cross-session leak guard. This handler runs inside a per-message @@ -16637,7 +16615,9 @@ async def _handle_message_inner(self, event: MessageEvent) -> Optional[str]: # Most adapters resolve profile routes in build_source(), before they # hand us the event. A few internal/voice paths construct SessionSource # directly, so resolve those here as the shared fail-closed ingress gate - # before authorization, hooks, or session side effects. + # before authorization, hooks, or session side effects — including the + # AgentProfile ContextVar binding just below, which a rejected route + # has no business acquiring. if ( getattr(getattr(self, "config", None), "multiplex_profiles", False) and not getattr(source, "profile", None) @@ -16660,6 +16640,32 @@ async def _handle_message_inner(self, event: MessageEvent) -> Optional[str]: ) return None + # 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, and so the cross-session leak guard + profile-route + # rejection gate run before that binding (and before any other + # side effects). The "update" command and the rest of the + # _known_commands set live here. + source = event.source + # Internal events (e.g. background-process completion notifications) # are system-generated and must skip user authorization. is_internal = bool(getattr(event, "internal", False))