diff --git a/AGENTS.md b/AGENTS.md index d8ba934c52197..58e6ca4443b46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -782,6 +782,97 @@ The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets `HERMES_HOME` before any module imports. All `get_hermes_home()` references automatically scope to the active profile. +### Multi-Agent Gateway (one gateway, many agents) + +In addition to the process-wide profile binding above, a single gateway +process can serve **multiple agents (profiles) in parallel**. Each turn +runs inside a per-turn ``HERMES_HOME`` override so memory / skills / +SOUL.md / config load from the right profile without rebinding the +process env var. + +Layered model: + +``` +process-level HERMES_HOME → the host profile (gateway config, sessions DB) + contextvar override → per-turn agent profile (memory, skills, soul) +``` + +Resolution order in ``hermes_constants.get_hermes_home()``: + +1. ``gateway.agent_context._AGENT_HOME`` contextvar (per-turn). +2. ``HERMES_HOME`` env var (process-level / legacy). +3. ``~/.hermes`` fallback. + +Switching agents in a chat: + +| User input | Effect | +|---------------------------|-----------------------------------------------------| +| ``/profile`` | Show the session's active profile + host info | +| ``/profile ls`` | List all available profiles | +| ``/profile coder`` | Bind this session to the ``coder`` profile | +| ``/profile default`` | Reset to the default profile | +| ``@coder fix this`` | Route just this turn to ``coder``; binding intact | + +``/profile`` is intentionally the single multi-agent entry point — +naming a separate ``/agent`` command would collide with the existing +``/agents`` (plural, "list running agent tasks") only by an ``s``. + +Persistence: the chat's bound agent is stored in +``SessionStore._chat_bindings`` (persisted to +``sessions/chat_bindings.json``) and survives gateway restarts. Inline +``@`` mentions are per-turn and never mutate the binding, but +they DO write into the @-target's own session. + +Session isolation: each ``(chat, agent)`` pair owns an independent +``session_id`` and transcript. ``/profile coder`` after talking to +``default`` doesn't extend default's history — it starts (or resumes) +coder's own session. Switching back to ``default`` restores its prior +transcript. This matches the "two independent Telegram bots" mental +model rather than "one bot wearing different hats". The default agent +keeps the legacy ``agent:main:...`` session_key shape so existing +``state.db`` rows and ``sessions.json`` entries continue to work with +zero migration. + +Implementation choke points: + +* ``gateway/agent_context.py`` — contextvar + ``agent_home_scope`` +* ``gateway/agent_registry.py`` — enumerate available profiles +* ``gateway/agent_mention.py`` — parse ``@ `` +* ``gateway/agent_response.py`` — prepend ``[] `` to replies + (toggle via ``gateway.show_agent_name`` in ``config.yaml``) +* ``GatewayRunner._resolve_turn_agent`` (in ``gateway/run.py``) — + one function decides which profile runs this turn +* ``GatewayRunner._run_agent`` wraps the executor dispatch in + ``agent_home_scope`` so all profile-aware path reads inside the AI + agent thread resolve to the right home. +* ``GatewayRunner._handle_profile_command`` (in ``gateway/run.py``) + implements the bare / ``ls`` / ```` / ``default`` forms. + +Cache invariants: + +* AIAgent cache signature includes the active profile name (via the + ``agent.profile`` cache-bust key), and the cache key (``session_key``) + is itself per-agent — so different agents on the same chat occupy + distinct cache slots and never reuse each other's frozen system + prompt / tool schemas. +* ``/profile `` evicts the OLD binding agent's cache slot (the + new agent's slot is independent and untouched). + +What stays profile-bound (NOT lifted to root): + +* Gateway config (``gateway:`` section of ``config.yaml``), platform + tokens, sessions DB. These live in the **host** profile — the one + the gateway process was started under. Memory / skills / soul are + the only things that swap per-turn. + +Authoring tips: + +* If you add a code path that reads profile data, use + ``get_hermes_home()`` so the contextvar override applies automatically. +* If you add a slash command that mutates per-session state and that + state depends on the active agent, evict the agent cache the same + way ``_handle_agent_command`` does. + ### Rules for profile-safe code 1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`. diff --git a/cli.py b/cli.py index da2f32954ba5a..fcb1d42020cd2 100644 --- a/cli.py +++ b/cli.py @@ -5346,17 +5346,75 @@ def show_toolsets(self): print(" Example: python cli.py --toolsets web,terminal") print() - def _handle_profile_command(self): - """Display active profile name and home directory.""" + def _handle_profile_command(self, cmd_original: str = "/profile"): + """Display the active profile, list profiles, or hint at switching. + + Forms: + /profile → show active profile + home directory + /profile ls → list every available profile + /profile → CLI cannot switch live (process-wide + HERMES_HOME is fixed); print the + correct relaunch command instead + + Live profile switching is a multi-agent **gateway** feature + (per-turn ``agent_home_scope``); the CLI process is bound to one + profile at startup via ``-p `` so an in-place swap would + leave half the agent's state pointing at the old home. + """ from hermes_constants import display_hermes_home from hermes_cli.profiles import get_active_profile_name + from gateway.agent_registry import default_registry + registry = default_registry() + registry.refresh() + active = get_active_profile_name() display = display_hermes_home() - profile_name = get_active_profile_name() + parts = (cmd_original or "/profile").split(maxsplit=1) + arg = parts[1].strip() if len(parts) > 1 else "" + + if not arg: + # Bare /profile — show active + home + target = registry.get(active) or registry.default() + print() + print(f" Profile: {active}") + if target.description: + print(f" {target.description}") + print(f" Home: {display}") + print() + print(" Use `/profile ls` to list profiles.") + print() + return + + if arg.casefold() in {"ls", "list"}: + profiles = registry.list() + print() + print(" Available profiles:") + for p in profiles: + marker = "→" if p.name == active else " " + star = " (default)" if p.is_default else "" + desc = f" — {p.description}" if p.description else "" + print(f" {marker} {p.name}{star}{desc}") + print() + print(f" Active: {active}") + print(" Switch in CLI: relaunch with `hermes -p `") + print(" Switch in gateway: send `/profile ` to the bot") + print() + return + + # /profile — CLI can't switch live + target = registry.get(arg) print() - print(f" Profile: {profile_name}") - print(f" Home: {display}") + if target is None: + print(f" Unknown profile {arg!r}.") + available = ", ".join(registry.names()) or "(none)" + print(f" Available: {available}") + else: + print(f" The CLI is bound to one profile per process.") + print(f" To use profile {target.name!r}, relaunch:") + print(f" hermes -p {target.name}") + print() + print(f" (Live switching is a gateway feature: send `/profile {target.name}` to the bot.)") print() def show_config(self): @@ -7281,7 +7339,7 @@ def process_command(self, command: str) -> bool: elif canonical == "help": self.show_help() elif canonical == "profile": - self._handle_profile_command() + self._handle_profile_command(cmd_original) elif canonical == "tools": self._handle_tools_command(cmd_original) elif canonical == "toolsets": diff --git a/gateway/__init__.py b/gateway/__init__.py index 140cc32fc5a10..89ee1a3c8c33e 100644 --- a/gateway/__init__.py +++ b/gateway/__init__.py @@ -14,7 +14,9 @@ SessionContext, SessionStore, SessionResetPolicy, + build_chat_key, build_session_context_prompt, + build_session_key, ) from .delivery import DeliveryRouter, DeliveryTarget @@ -28,7 +30,9 @@ "SessionContext", "SessionStore", "SessionResetPolicy", + "build_chat_key", "build_session_context_prompt", + "build_session_key", # Delivery "DeliveryRouter", "DeliveryTarget", diff --git a/gateway/agent_context.py b/gateway/agent_context.py new file mode 100644 index 0000000000000..6d3c91c39b91c --- /dev/null +++ b/gateway/agent_context.py @@ -0,0 +1,78 @@ +""" +Per-turn agent context — lets a single gateway process serve multiple +profiles (memory/skills/soul) without rebinding the process-wide +``HERMES_HOME`` env var. + +The mechanism is a single ``ContextVar[Optional[Path]]``. The gateway +runtime sets this contextvar before invoking each ``AIAgent``, and +``hermes_constants.get_hermes_home()`` consults it first so that every +profile-aware path resolution (memory, skills, soul, sessions if scoped, +…) automatically picks up the active agent's home directory. + +Design constraints: + +- **Import-safe.** No transitive imports of anything that calls + ``get_hermes_home()`` at module load time, otherwise the override path + in ``hermes_constants`` would create a circular import. +- **Thread/Executor safe.** ``contextvars`` propagate through + ``asyncio.to_thread`` / ``run_in_executor`` automatically when the + caller uses ``contextvars.copy_context()`` (which the gateway already + does in its background work paths). Bare ``threading.Thread`` does NOT + propagate; callers spawning raw threads must capture and re-set via + ``current_agent_home()`` themselves. +- **Backward compatible.** When the contextvar is unset (the default), + ``get_hermes_home()`` falls back to the existing ``HERMES_HOME`` env + var path — so single-profile gateways and CLI invocations behave + exactly as before. +""" + +from __future__ import annotations + +import logging +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path +from typing import Iterator, Optional + +logger = logging.getLogger(__name__) + + +_AGENT_HOME: ContextVar[Optional[Path]] = ContextVar( + "hermes_agent_home", default=None +) + + +def current_agent_home() -> Optional[Path]: + """Return the active agent's HERMES_HOME, or ``None`` if unset. + + Consulted from ``hermes_constants.get_hermes_home()`` to override + the process-wide env var when a gateway turn is running on behalf + of a specific profile. + """ + return _AGENT_HOME.get() + + +@contextmanager +def agent_home_scope(home: Path) -> Iterator[Path]: + """Run a block with ``current_agent_home()`` set to ``home``. + + Restores the previous value on exit (supports nesting, e.g. an + orchestrator agent that delegates to a sub-agent on a different + profile). + """ + resolved = Path(home) + token = _AGENT_HOME.set(resolved) + try: + yield resolved + finally: + _AGENT_HOME.reset(token) + + +def reset_agent_home() -> None: + """Clear the active agent home (back to env-var fallback). + + Intended for tests and gateway shutdown. Production callers should + use ``agent_home_scope`` so the previous value is restored + automatically. + """ + _AGENT_HOME.set(None) diff --git a/gateway/agent_mention.py b/gateway/agent_mention.py new file mode 100644 index 0000000000000..c2b8116381e6a --- /dev/null +++ b/gateway/agent_mention.py @@ -0,0 +1,94 @@ +""" +``@ `` inline routing for the multi-agent gateway. + +A user types ``@coder fix this bug`` in any chat. The gateway routes +that single turn to the ``coder`` agent (profile) without changing the +session's persistent ``active_agent``. The user's *next* unprefixed +message reverts to whatever the session is bound to via ``/agent``. + +The parser is deliberately strict: + +* Only the literal ``@`` at message start counts — ``email@host.com`` + references mid-sentence don't trigger routing. +* The target must be a registered agent name (canonicalised via + ``AgentRegistry``). Unknown ``@foo`` mentions are left untouched — + the message proceeds as a normal user message so the agent can + decide how to interpret it. This means users can still address + external people / handles in chat without the gateway eating their + message. + +Wire-up: callers do + parsed = parse_agent_mention(event.text, registry) + if parsed.target_agent: + event.text = parsed.stripped_text + # run this turn with agent_home_scope(registry.get(...).home) +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Optional + +from gateway.agent_registry import AgentProfile, AgentRegistry + + +# `^@\s+` — name limited to the on-disk profile id alphabet. +# Anchored at start so URLs / handles mid-message can't accidentally +# trigger routing. Non-greedy match for whitespace separator. +_MENTION_RE = re.compile( + r"^@([a-z0-9][a-z0-9_-]{0,63})\s+(.+)$", + re.IGNORECASE | re.DOTALL, +) + + +@dataclass(frozen=True) +class ParsedMention: + """Result of parsing a user message for an ``@`` prefix. + + ``target_agent`` is ``None`` when no mention was found OR when the + mention referenced an unknown agent — both cases pass the original + text through untouched. + """ + + target_agent: Optional[AgentProfile] + stripped_text: str + raw_mention: Optional[str] = None # the literal token "@coder" if matched + + +def parse_agent_mention(text: str, registry: AgentRegistry) -> ParsedMention: + """Parse ``text`` for a leading ``@`` route hint. + + Always returns a ``ParsedMention`` — never raises. Callers can + treat ``target_agent is None`` as "no routing applied". + """ + if not isinstance(text, str) or not text: + return ParsedMention(target_agent=None, stripped_text=text or "") + + # Cheap early-exit: must start with literal '@' after optional whitespace + # is stripped — but only the leading whitespace, not the body. + leading_ws_match = re.match(r"^(\s*)(@)", text) + if not leading_ws_match: + return ParsedMention(target_agent=None, stripped_text=text) + + leading_ws = leading_ws_match.group(1) + body = text[len(leading_ws):] + + m = _MENTION_RE.match(body) + if not m: + return ParsedMention(target_agent=None, stripped_text=text) + + candidate = m.group(1) + rest = m.group(2) + + agent = registry.get(candidate) + if agent is None: + # Mention syntax matched, but target unknown — pass through. + # This is the "user wrote @alice in a normal sentence" case. + return ParsedMention(target_agent=None, stripped_text=text) + + return ParsedMention( + target_agent=agent, + stripped_text=rest.strip(), + raw_mention=f"@{candidate}", + ) diff --git a/gateway/agent_registry.py b/gateway/agent_registry.py new file mode 100644 index 0000000000000..f3e1fd3d4c44a --- /dev/null +++ b/gateway/agent_registry.py @@ -0,0 +1,274 @@ +""" +Multi-agent gateway: registry of available agents. + +In a multi-agent gateway, every Hermes profile is exposed as a distinct +"agent" that gateway sessions can switch to (``/agent ``) or +target inline (``@ ``). Each agent is backed by an +isolated ``HERMES_HOME`` directory — its own memory, skills, soul, +sessions, and config. + +This module is the gateway-facing thin wrapper around the existing +``hermes_cli.profiles`` machinery. It hides the filesystem detail and +exposes a small, side-effect-free API: + +* ``AgentProfile`` — gateway-relevant metadata for one agent +* ``AgentRegistry`` — list / get / default / refresh + +Kept deliberately small to avoid pulling the CLI's ``ProfileInfo`` +(which scans gateway PIDs, alias scripts, distribution manifests, …) +into hot gateway code paths. Refreshing is cheap and lazy. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from threading import RLock +from typing import Dict, List, Optional + +from hermes_constants import get_default_hermes_root + +logger = logging.getLogger(__name__) + + +_PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") + +# Default agent name when no per-session override is set and no profile +# is explicitly bound. Maps to the default ``~/.hermes`` directory. +DEFAULT_AGENT_NAME = "default" + + +@dataclass(frozen=True) +class AgentProfile: + """Gateway-relevant view of one Hermes profile. + + Frozen for use in cache keys and dict identity comparisons. + """ + + name: str + home: Path + display_name: str + description: str = "" + is_default: bool = False + + def to_dict(self) -> Dict[str, object]: + return { + "name": self.name, + "home": str(self.home), + "display_name": self.display_name, + "description": self.description, + "is_default": self.is_default, + } + + +class AgentRegistry: + """Thread-safe enumeration of agent profiles available to the gateway. + + Reads the on-disk profile layout (```` + ``/profiles/*``) + and caches the result. Call :meth:`refresh` after creating / + deleting profiles so the gateway picks up the change without a + restart. + """ + + def __init__(self) -> None: + self._lock = RLock() + self._cache: Optional[Dict[str, AgentProfile]] = None + + # -- Public API ------------------------------------------------------ + + def list(self) -> List[AgentProfile]: + """Return all available agents, default first then alphabetical.""" + cache = self._ensure_cache() + rest = sorted( + (p for p in cache.values() if not p.is_default), + key=lambda p: p.name, + ) + default = [p for p in cache.values() if p.is_default] + return default + rest + + def get(self, name: str) -> Optional[AgentProfile]: + """Look up an agent by name; case-insensitive for ``default``.""" + if not isinstance(name, str) or not name.strip(): + return None + canon = self._canonicalize(name) + if canon is None: + return None + cache = self._ensure_cache() + return cache.get(canon) + + def default(self) -> AgentProfile: + """Return the default agent (always present — backed by ``~/.hermes``).""" + cache = self._ensure_cache() + default = cache.get(DEFAULT_AGENT_NAME) + if default is None: + # Should never happen — the default profile always exists on + # disk by virtue of being the hermes root. If it doesn't, + # synthesize an entry pointing at the configured root so + # callers don't crash. + root = get_default_hermes_root() + return AgentProfile( + name=DEFAULT_AGENT_NAME, + home=root, + display_name=DEFAULT_AGENT_NAME, + is_default=True, + ) + return default + + def names(self) -> List[str]: + """Return all agent names (for tab-completion / help).""" + return [p.name for p in self.list()] + + def refresh(self) -> None: + """Force a rescan on the next read.""" + with self._lock: + self._cache = None + + # -- Internal -------------------------------------------------------- + + @staticmethod + def _canonicalize(name: str) -> Optional[str]: + """Normalize a user-typed name to the on-disk profile id.""" + stripped = name.strip() + if not stripped: + return None + if stripped.casefold() == DEFAULT_AGENT_NAME: + return DEFAULT_AGENT_NAME + canon = stripped.lower() + if not _PROFILE_ID_RE.match(canon): + return None + return canon + + def _ensure_cache(self) -> Dict[str, AgentProfile]: + with self._lock: + if self._cache is not None: + return self._cache + cache: Dict[str, AgentProfile] = {} + + root = get_default_hermes_root() + + # Default profile — root directory itself. Always present + # in the registry, even when the directory hasn't been + # initialised yet (a fresh install in profile mode). + cache[DEFAULT_AGENT_NAME] = AgentProfile( + name=DEFAULT_AGENT_NAME, + home=root, + display_name=_read_display_name(root, DEFAULT_AGENT_NAME), + description=_read_description(root), + is_default=True, + ) + + # Named profiles. + profiles_dir = root / "profiles" + if profiles_dir.is_dir(): + try: + entries = sorted(profiles_dir.iterdir()) + except OSError as exc: + logger.warning( + "AgentRegistry: cannot scan %s: %s", profiles_dir, exc + ) + entries = [] + for entry in entries: + if not entry.is_dir(): + continue + name = entry.name + if not _PROFILE_ID_RE.match(name): + continue + cache[name] = AgentProfile( + name=name, + home=entry, + display_name=_read_display_name(entry, name), + description=_read_description(entry), + is_default=False, + ) + + self._cache = cache + return cache + + +# --------------------------------------------------------------------------- +# Disk readers — kept module-level so they can be unit-tested independently. +# --------------------------------------------------------------------------- + + +def _read_display_name(profile_home: Path, fallback: str) -> str: + """Return the agent's human-readable name. + + Resolution order: + 1. ``gateway.agent_display_name`` in the profile's ``config.yaml`` + 2. ``branding.agent_name`` in the skin (if configured) + 3. ``fallback`` (the canonical profile id) + + Reading is best-effort — any failure falls back so a corrupt + profile config can't take the entire registry down. + """ + config_path = profile_home / "config.yaml" + if config_path.is_file(): + try: + import yaml # imported here so the registry has no import-time cost when unused + + with config_path.open("r", encoding="utf-8") as fh: + cfg = yaml.safe_load(fh) or {} + gateway_cfg = cfg.get("gateway") or {} + display = gateway_cfg.get("agent_display_name") + if isinstance(display, str) and display.strip(): + return display.strip() + branding = (cfg.get("display") or {}).get("branding") or {} + agent_name = branding.get("agent_name") + if isinstance(agent_name, str) and agent_name.strip(): + return agent_name.strip() + except Exception as exc: # noqa: BLE001 — config errors must not break registry + logger.debug( + "AgentRegistry: failed to read display name from %s: %s", + config_path, + exc, + ) + return fallback + + +def _read_description(profile_home: Path) -> str: + """Return a one-line description for the agent, or ''. + + Source of truth: the first non-empty, non-heading line of SOUL.md. + Limited to 240 chars to keep ``/agent`` listings compact. + """ + soul = profile_home / "SOUL.md" + if not soul.is_file(): + return "" + try: + text = soul.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + if line.startswith("#"): + continue + return line[:240] + return "" + + +# Module-level singleton — gateway code grabs this rather than passing +# a registry instance around. Lazy: nothing happens until the first +# call to a public method. +_default_registry: Optional[AgentRegistry] = None +_default_registry_lock = RLock() + + +def default_registry() -> AgentRegistry: + """Return the gateway-wide ``AgentRegistry`` singleton.""" + global _default_registry + if _default_registry is None: + with _default_registry_lock: + if _default_registry is None: + _default_registry = AgentRegistry() + return _default_registry + + +def reset_default_registry() -> None: + """Drop the cached singleton (for tests).""" + global _default_registry + with _default_registry_lock: + _default_registry = None diff --git a/gateway/agent_response.py b/gateway/agent_response.py new file mode 100644 index 0000000000000..7e4094d8fb957 --- /dev/null +++ b/gateway/agent_response.py @@ -0,0 +1,105 @@ +""" +Format gateway responses so the user can see which agent answered. + +In a multi-agent gateway, the same chat may receive replies from +several profiles — one might be the ``coder`` agent, the next a +one-shot ``@data-sci`` turn. Without a label, the user can't tell +them apart. This module owns the labelling. + +Design: + +* The prefix is computed once, in one place, and injected before the + final response is handed to the adapter ``send()`` chain. We do not + touch every adapter or every streaming delta — that would duplicate + prefixes in multi-chunk replies. +* The prefix is configurable via ``gateway.show_agent_name`` in + ``config.yaml`` (default: ``true``). +* Format is intentionally plain-text (``[name] content``) so it + renders consistently across every platform. Per-platform rich + formatting (bold for Telegram MarkdownV2, etc.) can layer on top + later without breaking this contract. +* Empty or whitespace-only responses are passed through unchanged so + we don't surface a bare prefix when the agent had nothing to say. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +from utils import is_truthy_value + + +_DEFAULT_SHOW_AGENT_NAME = True + + +def show_agent_name_enabled(user_config: Optional[Mapping[str, Any]]) -> bool: + """Return whether the agent-name prefix is enabled in config. + + Defaults to True when the key is absent, matching the user's + request that the prefix is shown "by default". + """ + if not isinstance(user_config, Mapping): + return _DEFAULT_SHOW_AGENT_NAME + gateway_cfg = user_config.get("gateway") + if not isinstance(gateway_cfg, Mapping): + return _DEFAULT_SHOW_AGENT_NAME + raw = gateway_cfg.get("show_agent_name", _DEFAULT_SHOW_AGENT_NAME) + return is_truthy_value(raw, default=_DEFAULT_SHOW_AGENT_NAME) + + +def format_agent_response( + content: str, + agent_name: Optional[str], + *, + enabled: bool = True, +) -> str: + """Prepend ``[] `` to ``content`` when conditions hold. + + * Returns ``content`` unchanged when ``enabled`` is False or + ``agent_name`` is empty / None. + * Returns ``content`` unchanged when it is empty / whitespace-only, + so we never produce a "bare prefix" reply. + * Idempotent: if the content already starts with the same agent + prefix (e.g. the agent itself echoed it, or it was pre-formatted + upstream), the prefix is NOT doubled. + + The first newline is preserved if present so multi-paragraph + responses keep their original layout. + """ + if not enabled: + return content + if not agent_name or not isinstance(agent_name, str): + return content + name = agent_name.strip() + if not name: + return content + if not isinstance(content, str) or not content.strip(): + return content + prefix = f"[{name}] " + if content.startswith(prefix): + return content + return prefix + content + + +def apply_agent_prefix_to_result( + result: Optional[dict], + agent_name: Optional[str], + user_config: Optional[Mapping[str, Any]], +) -> Optional[dict]: + """Mutate ``result['final_response']`` in place to carry the agent prefix. + + Returns the same dict so callers can chain. Safe with ``None`` + (returns None) and with dicts that don't have ``final_response``. + """ + if not isinstance(result, dict): + return result + enabled = show_agent_name_enabled(user_config) + if not enabled: + return result + raw = result.get("final_response") + if not isinstance(raw, str): + return result + result["final_response"] = format_agent_response( + raw, agent_name, enabled=enabled + ) + return result diff --git a/gateway/run.py b/gateway/run.py index 46c508e4bde00..f52315d1fd65f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1653,10 +1653,23 @@ def exit_code(self) -> Optional[int]: return self._exit_code def _session_key_for_source(self, source: SessionSource) -> str: - """Resolve the current session key for a source, honoring gateway config when available.""" + """Resolve the current session key for a source. + + Multi-agent gateway: the chat's currently bound agent determines + which session_key we resolve to. ``get_chat_agent`` defaults to + ``"default"`` when the chat has never run ``/profile ``, + so single-agent callers see unchanged behaviour. + """ + agent_name = "default" if hasattr(self, "session_store") and self.session_store is not None: try: - session_key = self.session_store._generate_session_key(source) + agent_name = self.session_store.get_chat_agent(source) or "default" + except Exception: + agent_name = "default" + try: + session_key = self.session_store._generate_session_key( + source, agent_name=agent_name + ) if isinstance(session_key, str) and session_key: return session_key except Exception: @@ -1666,6 +1679,7 @@ def _session_key_for_source(self, source: SessionSource) -> str: source, group_sessions_per_user=getattr(config, "group_sessions_per_user", True), thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False), + agent_name=agent_name, ) def _telegram_topic_mode_enabled(self, source: SessionSource) -> bool: @@ -6976,8 +6990,30 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g source.chat_id or "unknown", _msg_preview, ) - # Get or create session - session_entry = self.session_store.get_or_create_session(source) + # ------------------------------------------------------------------ + # Multi-agent gateway: resolve the agent (Hermes profile) that + # owns this turn BEFORE touching the session store, so: + # * get_or_create_session lands the transcript in the right + # (chat, agent) lane + # * @ mentions create / reuse the @-target's own session + # * the chat binding (set by /profile ) routes the + # unprefixed default case + # The resolver also strips the leading ``@`` token from + # event.text when present so downstream handlers see only the + # actual user message. + # ------------------------------------------------------------------ + _turn_agent_name, _turn_agent_home, _resolved_text = self._resolve_turn_agent( + event.text or "", source + ) + if _resolved_text != (event.text or ""): + # @ stripped — replace event.text so command dispatch + # and downstream history seeding don't echo the mention. + event.text = _resolved_text + + # Get or create session for THIS turn's agent. + session_entry = self.session_store.get_or_create_session( + source, agent_name=_turn_agent_name + ) session_key = session_entry.session_key self._cache_session_source(session_key, source) if self._is_telegram_topic_lane(source): @@ -7549,7 +7585,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g } await self.hooks.emit("agent:start", hook_ctx) - # Run the agent + # Run the agent — pass the resolved (chat, agent) tuple so + # _run_agent doesn't have to re-resolve and reload the session. agent_result = await self._run_agent( message=message_text, context_prompt=context_prompt, @@ -7560,6 +7597,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g run_generation=run_generation, event_message_id=self._reply_anchor_for_event(event), channel_prompt=event.channel_prompt, + agent_name=_turn_agent_name, + agent_home=_turn_agent_home, ) # Stop persistent typing indicator now that the agent is done @@ -8239,19 +8278,116 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer return EphemeralReply(f"{header}{_tip_line}") async def _handle_profile_command(self, event: MessageEvent) -> str: - """Handle /profile — show active profile name and home directory.""" + """Handle ``/profile`` — display, list, or switch the session's profile. + + Forms: + /profile → show the session's active profile + host info + /profile ls → list all available profiles + /profile → bind this session to 's profile + (memory, skills, soul switch on the next turn) + /profile default → reset to the default profile + + Persistence: the binding is stored on ``SessionEntry.active_agent`` + so a gateway restart lands the user back on the same profile. The + per-turn ``@ `` mention syntax routes a single turn + elsewhere without mutating this binding. + """ from hermes_constants import display_hermes_home from hermes_cli.profiles import get_active_profile_name + from gateway.agent_registry import default_registry - display = display_hermes_home() - profile_name = get_active_profile_name() + registry = default_registry() + registry.refresh() # pick up profiles created since gateway start - lines = [ - t("gateway.profile.header", profile=profile_name), - t("gateway.profile.home", home=display), - ] + source = event.source + current = self.session_store.get_chat_agent(source) + host_profile = get_active_profile_name() + host_home = display_hermes_home() - return "\n".join(lines) + # Parse argument from "/profile " + text = (event.text or "").strip() + arg = "" + if text.startswith("/"): + parts = text.split(maxsplit=1) + if len(parts) > 1: + arg = parts[1].strip() + + # Bare /profile → display current binding + host info + if not arg: + target = registry.get(current) or registry.default() + lines = [ + f"Active profile (this session): {current}", + ] + if target.description: + lines.append(f" {target.description}") + lines.append(f"Home: {target.home}") + if host_profile != current: + lines.append("") + lines.append(f"Gateway host profile: {host_profile} ({host_home})") + lines.append("") + lines.append("Use `/profile ls` to list profiles, `/profile ` to switch.") + return "\n".join(lines) + + # /profile ls (or list) → enumerate available profiles + if arg.casefold() in {"ls", "list"}: + profiles = registry.list() + lines = ["Available profiles:"] + for p in profiles: + marker = "→" if p.name == current else " " + star = " (default)" if p.is_default else "" + desc = f" — {p.description}" if p.description else "" + lines.append(f" {marker} {p.name}{star}{desc}") + lines.append("") + lines.append(f"Active: {current}") + lines.append("Switch with: /profile ") + return "\n".join(lines) + + # /profile → switch + target = registry.get(arg) + if target is None: + available = ", ".join(registry.names()) or "(none)" + return ( + f"Unknown profile {arg!r}. Available: {available}.\n" + f"Run /profile ls to see descriptions." + ) + + if target.name == current: + return f"Already on profile {target.name!r}." + + # Bind this chat to the new agent. No need to touch any + # session: the next inbound message resolves the agent via + # get_chat_agent, which then drives session_key + session_id + # construction for the new (chat, agent) lane in + # _handle_message_with_agent. Switching back to a previously + # used agent naturally restores its prior transcript. + ok = self.session_store.set_chat_agent(source, target.name) + if not ok: + return ( + f"Could not bind chat to profile {target.name!r} " + f"(invalid agent name)." + ) + + # Best-effort: drop any cached AIAgent keyed by the OLD agent's + # session_key for this chat so a stale construction doesn't + # linger. The new agent's session_key is different, so its + # cache slot is independent and untouched. + try: + stale_session_key = self.session_store._generate_session_key( + source, agent_name=current + ) + cache_lock = getattr(self, "_agent_cache_lock", None) + cache = getattr(self, "_agent_cache", None) + if cache_lock is not None and cache is not None: + with cache_lock: + cache.pop(stale_session_key, None) + except Exception as exc: # noqa: BLE001 — cache eviction is best-effort + logger.debug("Agent cache eviction failed on /profile switch: %s", exc) + + suffix = f" — {target.description}" if target.description else "" + return ( + f"Switched profile to: {target.name}{suffix}\n" + f"Next message uses this profile's own session, memory, skills, and soul." + ) def _check_slash_access( @@ -14140,6 +14276,44 @@ def _run_still_current() -> bool: # ------------------------------------------------------------------ + def _resolve_turn_agent( + self, message: str, source: SessionSource + ) -> tuple[str, Path, str]: + """Resolve which agent (Hermes profile) runs this turn. + + Priority: + 1. ``@`` mention at the start of ``message`` (per-turn). + Returns the message with the mention stripped. + 2. The chat's persisted binding (set by ``/profile ``). + 3. The default agent (backed by ``~/.hermes``). + + Returns ``(agent_name, agent_home, message_for_agent)``. + + Always returns a usable triple — unknown stored names fall back + to the default agent, and an unknown ``@mention`` is treated as + plain text per ``parse_agent_mention``'s pass-through semantics. + + NB: session_key is intentionally NOT a parameter here. Under + the multi-agent model, the session_key depends on which agent + owns the turn — so it must be COMPUTED from the resolved agent, + not consumed as input. + """ + from gateway.agent_mention import parse_agent_mention + from gateway.agent_registry import default_registry + + registry = default_registry() + parsed = parse_agent_mention(message, registry) + if parsed.target_agent is not None: + return ( + parsed.target_agent.name, + parsed.target_agent.home, + parsed.stripped_text, + ) + + stored = self.session_store.get_chat_agent(source) + resolved = registry.get(stored) or registry.default() + return resolved.name, resolved.home, message + async def _run_agent( self, message: str, @@ -14152,6 +14326,8 @@ async def _run_agent( _interrupt_depth: int = 0, event_message_id: Optional[str] = None, channel_prompt: Optional[str] = None, + agent_name: Optional[str] = None, + agent_home: Optional[Path] = None, ) -> Dict[str, Any]: """ Run the agent with the given message and context. @@ -14181,6 +14357,25 @@ async def _run_agent( from run_agent import AIAgent import queue + # ------------------------------------------------------------------ + # Multi-agent gateway: agent_name / agent_home come from the caller + # (``_handle_message_with_agent`` resolves them at the top so the + # right session_key / session_id are loaded before we get here). + # As a safety net for legacy callers that don't pass these, fall + # back to per-turn resolution on the message itself. + # ------------------------------------------------------------------ + if agent_name is None or agent_home is None: + agent_name, agent_home, message = self._resolve_turn_agent( + message, source + ) + # Recompute session_key from this agent so transcript I/O + # targets the right (chat, agent) lane. + session_key = self.session_store._generate_session_key( + source, agent_name=agent_name + ) + _turn_agent_name = agent_name + _turn_agent_home = agent_home + def _run_still_current() -> bool: if run_generation is None or not session_key: return True @@ -14861,12 +15056,21 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: # Check agent cache — reuse the AIAgent from the previous message # in this session to preserve the frozen system prompt and tool # schemas for prompt cache hits. + # + # Bust on agent change: when /agent or @ selects a + # different Hermes profile for this turn than the one the + # cached AIAgent was built under, we MUST rebuild so the + # frozen system prompt (memory + soul + skills) reflects the + # new profile. Mixing them into the cache_keys dict keeps + # the existing busting plumbing as the single chokepoint. + _cache_keys = self._extract_cache_busting_config(user_config) + _cache_keys["agent.profile"] = _turn_agent_name _sig = self._agent_config_signature( turn_route["model"], turn_route["runtime"], enabled_toolsets, combined_ephemeral, - cache_keys=self._extract_cache_busting_config(user_config), + cache_keys=_cache_keys, ) agent = None _cache_lock = getattr(self, "_agent_cache_lock", None) @@ -15328,6 +15532,21 @@ def _approval_notify_sync(approval_data: dict) -> None: _run_message = message result = agent.run_conversation(_run_message, conversation_history=agent_history, task_id=session_id) + + # Multi-agent gateway: label the final response with the agent + # that produced it so the user can tell who answered when + # /agent or @ routes a session across profiles. The + # helper is idempotent and respects gateway.show_agent_name. + try: + from gateway.agent_response import apply_agent_prefix_to_result + apply_agent_prefix_to_result( + result, _turn_agent_name, user_config + ) + except Exception as _agent_prefix_exc: # noqa: BLE001 + logger.debug( + "Agent-name prefix application failed: %s", + _agent_prefix_exc, + ) finally: unregister_gateway_notify(_approval_session_key) # Cancel any pending clarify entries so blocked agent @@ -15662,8 +15881,22 @@ async def _notify_long_running(): _agent_warning_raw = _float_env("HERMES_AGENT_TIMEOUT_WARNING", 900) _agent_warning = _agent_warning_raw if _agent_warning_raw > 0 else None _warning_fired = False + + # Wrap run_sync in agent_home_scope so every get_hermes_home() + # call inside the executor thread resolves to the turn's agent + # profile. agent_home_scope must run INSIDE the executor's + # copied context — setting it in the async frame here would + # leak past _run_agent's return, since the awaiting coroutine + # and the executor share the same logical context until the + # next copy_context() boundary. + from gateway.agent_context import agent_home_scope as _agent_home_scope + + def _scoped_run_sync(): + with _agent_home_scope(_turn_agent_home): + return run_sync() + _executor_task = asyncio.ensure_future( - self._run_in_executor_with_context(run_sync) + self._run_in_executor_with_context(_scoped_run_sync) ) _inactivity_timeout = False diff --git a/gateway/session.py b/gateway/session.py index ac6f95eec63c6..443382dad5858 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -491,6 +491,12 @@ class SessionEntry: resume_reason: Optional[str] = None # e.g. "restart_timeout" last_resume_marked_at: Optional[datetime] = None + # Multi-agent gateway: name of the agent (= Hermes profile) currently + # bound to this session. Set by ``/agent ``; per-turn ``@`` + # mentions don't mutate this field. Defaults to ``"default"`` so + # legacy sessions and single-profile gateways behave unchanged. + active_agent: str = "default" + def to_dict(self) -> Dict[str, Any]: result = { "session_key": self.session_key, @@ -518,6 +524,7 @@ def to_dict(self) -> Dict[str, Any]: else None ), "is_fresh_reset": self.is_fresh_reset, + "active_agent": self.active_agent, } if self.origin: result["origin"] = self.origin.to_dict() @@ -567,6 +574,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry": resume_reason=data.get("resume_reason"), last_resume_marked_at=last_resume_marked_at, is_fresh_reset=data.get("is_fresh_reset", False), + active_agent=str(data.get("active_agent") or "default"), ) @@ -595,11 +603,18 @@ def build_session_key( source: SessionSource, group_sessions_per_user: bool = True, thread_sessions_per_user: bool = False, + agent_name: str = "default", ) -> str: """Build a deterministic session key from a message source. This is the single source of truth for session key construction. + Multi-agent semantics: the second key component is the agent (Hermes + profile) name, so two agents in the same chat get independent + sessions / transcripts. The ``default`` agent keeps the legacy + ``agent:main:...`` prefix to preserve zero-migration compatibility + with existing ``sessions.json`` and ``state.db`` rows. + DM rules: - DMs include chat_id when present, so each private conversation is isolated. - thread_id further differentiates threaded DMs within the same DM chat. @@ -619,6 +634,10 @@ def build_session_key( shared session per chat. - Without identifiers, messages fall back to one session per platform/chat_type. """ + # Agent-namespace prefix. ``default`` keeps the legacy ``agent:main`` + # prefix so existing on-disk sessions continue to resolve unchanged. + prefix = "agent:main" if agent_name == "default" else f"agent:{agent_name}" + platform = source.platform.value if source.chat_type == "dm": dm_chat_id = source.chat_id @@ -627,11 +646,11 @@ def build_session_key( if dm_chat_id: if source.thread_id: - return f"agent:main:{platform}:dm:{dm_chat_id}:{source.thread_id}" - return f"agent:main:{platform}:dm:{dm_chat_id}" + return f"{prefix}:{platform}:dm:{dm_chat_id}:{source.thread_id}" + return f"{prefix}:{platform}:dm:{dm_chat_id}" if source.thread_id: - return f"agent:main:{platform}:dm:{source.thread_id}" - return f"agent:main:{platform}:dm" + return f"{prefix}:{platform}:dm:{source.thread_id}" + return f"{prefix}:{platform}:dm" participant_id = source.user_id_alt or source.user_id if participant_id and source.platform == Platform.WHATSAPP: @@ -639,7 +658,7 @@ def build_session_key( # single group member gets two isolated per-user sessions when the # bridge reshuffles alias forms. participant_id = canonical_whatsapp_identifier(str(participant_id)) or participant_id - key_parts = ["agent:main", platform, source.chat_type] + key_parts = [prefix, platform, source.chat_type] if source.chat_id: key_parts.append(source.chat_id) @@ -659,6 +678,40 @@ def build_session_key( return ":".join(key_parts) +def build_chat_key(source: SessionSource) -> str: + """Build a chat-level key (no agent namespace) for binding lookups. + + Two messages with different active agents but same chat origin share + the same chat_key — that's the level at which ``SessionStore`` + tracks "which agent is currently bound to this chat". + + Format: ``chat:{platform}:{chat_type}:{chat_id_or_thread}:{participant}`` + Stable across agents; **DO NOT** persist gateway sessions under this + key — use ``build_session_key`` for that. + """ + platform = source.platform.value + if source.chat_type == "dm": + dm_chat_id = source.chat_id + if source.platform == Platform.WHATSAPP: + dm_chat_id = canonical_whatsapp_identifier(source.chat_id) + parts = ["chat", platform, "dm"] + if dm_chat_id: + parts.append(dm_chat_id) + if source.thread_id: + parts.append(source.thread_id) + return ":".join(parts) + + parts = ["chat", platform, source.chat_type] + if source.chat_id: + parts.append(source.chat_id) + if source.thread_id: + parts.append(source.thread_id) + participant_id = source.user_id_alt or source.user_id + if participant_id: + parts.append(str(participant_id)) + return ":".join(parts) + + class SessionStore: """ Manages session storage and retrieval. @@ -675,6 +728,15 @@ def __init__(self, sessions_dir: Path, config: GatewayConfig, self._loaded = False self._lock = threading.Lock() self._has_active_processes_fn = has_active_processes_fn + # Chat-level agent bindings. Key: chat_key (no agent namespace). + # Value: the agent (Hermes profile) name that ``/profile `` + # most recently bound to this chat. Persisted to + # ``chat_bindings.json``. Read at the top of every inbound + # message in _handle_message_with_agent to decide which agent + # owns the turn (and therefore which session_key + transcript + # to use). + self._chat_bindings: Dict[str, str] = {} + self._chat_bindings_loaded = False # Initialize SQLite session database self._db = None @@ -735,13 +797,109 @@ def _save(self) -> None: logger.debug("Could not remove temp file %s: %s", tmp_path, e) raise - def _generate_session_key(self, source: SessionSource) -> str: - """Generate a session key from a source.""" + def _generate_session_key( + self, source: SessionSource, agent_name: str = "default" + ) -> str: + """Generate a session key from a source + active agent. + + Defaults to the ``"default"`` agent so legacy callers that don't + know about multi-agent semantics still resolve to the same + ``agent:main:...`` key shape they always have. Callers that DO + care should pass the agent name resolved via + :meth:`get_chat_agent` or ``GatewayRunner._resolve_turn_agent``. + """ return build_session_key( source, group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), + agent_name=agent_name, + ) + + # ------------------------------------------------------------------ + # Chat-level agent bindings (multi-agent gateway) + # ------------------------------------------------------------------ + + def _chat_bindings_path(self) -> Path: + return self.sessions_dir / "chat_bindings.json" + + def _ensure_chat_bindings_loaded_locked(self) -> None: + """Load ``chat_bindings.json`` once. Must hold ``self._lock``.""" + if self._chat_bindings_loaded: + return + path = self._chat_bindings_path() + if path.is_file(): + try: + with open(path, "r", encoding="utf-8") as f: + raw = json.load(f) + if isinstance(raw, dict): + self._chat_bindings = { + str(k): str(v) for k, v in raw.items() if v + } + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to load chat_bindings.json: %s", exc) + self._chat_bindings_loaded = True + + def _persist_chat_bindings_locked(self) -> None: + """Atomically rewrite ``chat_bindings.json``. Must hold ``self._lock``.""" + import tempfile + + self.sessions_dir.mkdir(parents=True, exist_ok=True) + path = self._chat_bindings_path() + fd, tmp = tempfile.mkstemp( + dir=str(self.sessions_dir), suffix=".tmp", prefix=".chat_bindings_" ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(self._chat_bindings, f, indent=2) + f.flush() + os.fsync(f.fileno()) + atomic_replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + def get_chat_agent(self, source: SessionSource) -> str: + """Return the agent (Hermes profile) currently bound to ``source``'s chat. + + Defaults to ``"default"`` when no ``/profile`` switch has been + made in this chat yet. ``@`` mentions go through a + separate per-turn path and never call this method. + """ + chat_key = build_chat_key(source) + with self._lock: + self._ensure_chat_bindings_loaded_locked() + return self._chat_bindings.get(chat_key, "default") + + def set_chat_agent(self, source: SessionSource, agent_name: str) -> bool: + """Bind a chat to a specific agent (Hermes profile). + + Returns True when the binding was written (also when it was + already the same value — idempotent). Returns False only for + invalid input (empty / non-string agent name). + """ + if not isinstance(agent_name, str) or not agent_name.strip(): + return False + normalized = agent_name.strip() + chat_key = build_chat_key(source) + with self._lock: + self._ensure_chat_bindings_loaded_locked() + current = self._chat_bindings.get(chat_key) + if normalized == "default": + # Default agent doesn't need a row — remove the binding so + # the file stays compact. get_chat_agent falls back to + # ``"default"`` for missing keys. + if current is not None: + self._chat_bindings.pop(chat_key, None) + self._persist_chat_bindings_locked() + return True + if current == normalized: + return True + self._chat_bindings[chat_key] = normalized + self._persist_chat_bindings_locked() + return True def _is_session_expired(self, entry: SessionEntry) -> bool: """Check if a session has expired based on its reset policy. @@ -850,15 +1008,21 @@ def has_any_sessions(self) -> bool: def get_or_create_session( self, source: SessionSource, - force_new: bool = False + force_new: bool = False, + agent_name: str = "default", ) -> SessionEntry: """ Get an existing session or create a new one. Evaluates reset policy to determine if the existing session is stale. Creates a session record in SQLite when a new session starts. + + Multi-agent gateway: the ``agent_name`` argument is folded into + the session key so each agent owns an independent transcript per + chat. Defaults to ``"default"`` to preserve single-agent + semantics for callers that don't yet pass the active agent. """ - session_key = self._generate_session_key(source) + session_key = self._generate_session_key(source, agent_name=agent_name) now = _now() # SQLite calls are made outside the lock to avoid holding it during I/O. @@ -923,6 +1087,7 @@ def get_or_create_session( was_auto_reset=was_auto_reset, auto_reset_reason=auto_reset_reason, reset_had_activity=reset_had_activity, + active_agent=agent_name, ) self._entries[session_key] = entry @@ -964,6 +1129,46 @@ def update_session( entry.last_prompt_tokens = last_prompt_tokens self._save() + def set_active_agent(self, session_key: str, agent_name: str) -> bool: + """Bind a session to a specific agent (Hermes profile). + + Returns True when the session exists and the value was changed (or + re-confirmed). Returns False when the session is unknown — the + caller can decide whether to surface that as a user-facing error + or create the session first. + + Persisting this lets a gateway restart land users back on the + same agent they were chatting with, without a fresh ``/agent`` + prompt. ``@`` mentions go through a separate path and + never call this method. + """ + if not isinstance(agent_name, str) or not agent_name.strip(): + return False + normalized = agent_name.strip() + with self._lock: + self._ensure_loaded_locked() + entry = self._entries.get(session_key) + if entry is None: + return False + entry.active_agent = normalized + self._save() + return True + + def get_active_agent(self, session_key: str) -> str: + """Return the agent name bound to ``session_key``. + + Defaults to ``"default"`` for unknown sessions and for legacy + sessions persisted before this field existed. Never raises; + callers can treat the return value as a free-floating profile id + suitable for ``AgentRegistry.get(...)``. + """ + with self._lock: + self._ensure_loaded_locked() + entry = self._entries.get(session_key) + if entry is None: + return "default" + return entry.active_agent or "default" + def suspend_session(self, session_key: str) -> bool: """Mark a session as suspended so it auto-resets on next access. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 56a62c85a0a47..b0a7396ca6297 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -106,7 +106,11 @@ class CommandDef: args_hint="[text | pause | resume | clear | status]"), CommandDef("status", "Show session info", "Session"), CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), - CommandDef("profile", "Show active profile name and home directory", "Info"), + CommandDef("profile", + "Show active profile / list profiles / switch the session's profile", + "Info", + args_hint="[name|ls|default]", + subcommands=("ls", "list", "default")), CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)), CommandDef("resume", "Resume a previously-named session", "Session", diff --git a/hermes_constants.py b/hermes_constants.py index bdb8dc9114f82..ce54fdc4a5f12 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -14,7 +14,16 @@ def get_hermes_home() -> Path: """Return the Hermes home directory (default: ~/.hermes). - Reads HERMES_HOME env var, falls back to ~/.hermes. + Resolution order: + + 1. **Per-turn contextvar override** (``gateway.agent_context``). + Set by the multi-agent gateway runtime so a single gateway + process can serve multiple profiles in parallel — each turn + runs inside an ``agent_home_scope`` that swaps in the active + agent's HERMES_HOME without mutating the process env var. + 2. ``HERMES_HOME`` env var (the historical single-profile mode). + 3. ``~/.hermes`` fallback. + This is the single source of truth — all other copies should import this. When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates @@ -27,6 +36,21 @@ 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) Per-turn agent home override. Guarded import so cli/subprocess + # boot paths that don't pull in the gateway still work (and so the + # gateway module itself can import hermes_constants without circular + # bootstrap). + try: + from gateway.agent_context import current_agent_home as _cur + + override = _cur() + if override is not None: + return override + except Exception: + # ImportError (gateway not installed in this venv), or any failure + # in the contextvar plumbing — must NEVER prevent path resolution. + pass + val = os.environ.get("HERMES_HOME", "").strip() if val: return Path(val) diff --git a/tests/e2e/test_multi_agent.py b/tests/e2e/test_multi_agent.py new file mode 100644 index 0000000000000..042b15988e646 --- /dev/null +++ b/tests/e2e/test_multi_agent.py @@ -0,0 +1,251 @@ +"""End-to-end tests for the multi-agent gateway. + +These tests drive messages through the full async pipeline: + + adapter.handle_message(event) + → BasePlatformAdapter._process_message_background() + → GatewayRunner._handle_message() / ._handle_message_with_agent() + → command dispatch OR agent execution + → adapter.send() (captured for assertions) + +Scope (per the refactor brief): Telegram only, since the multi-agent +plumbing lives in the gateway-level code path that is platform-agnostic. + +What is and isn't covered: + +* ``/profile`` (bare/ls/switch) — full pipeline. No LLM needed. +* ``@ `` inline routing — verified by stubbing + ``AIAgent`` so ``_run_agent`` actually runs and the resolver + + ``agent_home_scope`` are exercised, then asserting on the captured + ``HERMES_HOME`` at construction time and the message text that + reached ``run_conversation``. +* ``[] ...`` response prefix — same stub; we assert on the + final ``adapter.send`` payload. + +We do NOT spin up a real provider/model. Memory/skills/soul real +isolation is covered by ``tests/gateway/test_multi_agent_isolation.py`` +at the path level. +""" + +from __future__ import annotations + +import asyncio +from collections import OrderedDict +from datetime import datetime +from pathlib import Path +from threading import Lock +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.agent_registry import reset_default_registry +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import SendResult +from gateway.session import SessionEntry, SessionSource, SessionStore, build_session_key +from tests.e2e.conftest import ( + _ensure_telegram_mock, + make_event, + make_source, + send_and_capture, +) + + +# --------------------------------------------------------------------------- +# Telegram-only adapter + runner factory with a REAL SessionStore so the +# session_key / active_agent persistence is exercised end-to-end. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_root(tmp_path, monkeypatch): + """Three on-disk profiles: default + coder + data-sci.""" + root = tmp_path / ".hermes" + coder = root / "profiles" / "coder" + sci = root / "profiles" / "data-sci" + for home in (root, coder, sci): + home.mkdir(parents=True, exist_ok=True) + (home / "memories").mkdir() + (home / "skills").mkdir() + (coder / "SOUL.md").write_text("# Coder agent\nI write Python.\n", encoding="utf-8") + (sci / "SOUL.md").write_text("# Data scientist\nI analyse data.\n", encoding="utf-8") + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + reset_default_registry() + yield root, coder, sci + reset_default_registry() + + +@pytest.fixture +def telegram_runner(fake_root, tmp_path): + """A GatewayRunner stub wired with a REAL SessionStore. + + The existing e2e ``make_runner`` fixture mocks SessionStore entirely, + which doesn't suffice for the multi-agent commands — they read and + write ``active_agent`` on session entries. This fixture borrows the + rest of the stub but swaps in a real store so we can assert on + persisted state. + """ + _ensure_telegram_mock() + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + Platform.TELEGRAM: PlatformConfig(enabled=True, token="e2e-test-token") + } + ) + runner.adapters = {} + runner._voice_mode = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + + runner.session_store = SessionStore(tmp_path / "sessions", runner.config) + + runner._running_agents = {} + runner._running_agents_ts = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._shutdown_event = asyncio.Event() + runner._exit_reason = None + runner._exit_code = None + runner._background_tasks = set() + runner._draining = False + runner._restart_requested = False + runner._restart_task_started = False + runner._restart_detached = False + runner._restart_via_service = False + from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT + + runner._restart_drain_timeout = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT + runner._stop_task = None + runner._busy_input_mode = "interrupt" + runner._pending_model_notes = {} + runner._update_prompt_pending = {} + runner._session_db = None + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + + runner._agent_cache: OrderedDict = OrderedDict() + runner._agent_cache_lock = Lock() + + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._handle_message_with_agent = AsyncMock(return_value="agent-handled-default") + runner._should_send_voice_reply = lambda *_a, **_kw: False + runner._send_voice_reply = AsyncMock() + runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None + runner._emit_gateway_run_progress = AsyncMock() + runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}} + + runner.pairing_store = MagicMock() + runner.pairing_store._is_rate_limited = MagicMock(return_value=False) + runner.pairing_store.generate_code = MagicMock(return_value="ABC123") + + return runner + + +@pytest.fixture +def telegram_adapter(telegram_runner): + from gateway.platforms.telegram import TelegramAdapter + + config = PlatformConfig(enabled=True, token="e2e-test-token") + adapter = TelegramAdapter(config) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1")) + adapter.send_typing = AsyncMock() + adapter.set_message_handler(telegram_runner._handle_message) + telegram_runner.adapters[Platform.TELEGRAM] = adapter + return adapter + + +def _send_response_text(adapter) -> str | None: + if not adapter.send.called: + return None + return adapter.send.call_args[1].get("content") or adapter.send.call_args[0][1] + + +# =========================================================================== +# /profile end-to-end +# =========================================================================== + + +class TestProfileCommandE2E: + """``/profile`` exercised through adapter → runner → dispatch → send.""" + + @pytest.mark.asyncio + async def test_bare_profile_shows_active(self, telegram_adapter): + send = await send_and_capture(telegram_adapter, "/profile", Platform.TELEGRAM) + send.assert_called() + text = _send_response_text(telegram_adapter) + assert "default" in text + assert "Active profile" in text or "Active" in text + # Hint to discover more + assert "/profile ls" in text + + @pytest.mark.asyncio + async def test_profile_ls_lists_all_profiles(self, telegram_adapter): + send = await send_and_capture(telegram_adapter, "/profile ls", Platform.TELEGRAM) + send.assert_called() + text = _send_response_text(telegram_adapter) + assert "Available profiles" in text + assert "default" in text + assert "coder" in text + assert "data-sci" in text + + @pytest.mark.asyncio + async def test_profile_switch_persists_to_store( + self, telegram_adapter, telegram_runner + ): + send = await send_and_capture( + telegram_adapter, "/profile coder", Platform.TELEGRAM + ) + send.assert_called() + text = _send_response_text(telegram_adapter) + assert "Switched profile to: coder" in text + + # Verify persisted at the chat-binding level (new multi-agent + # model — each (chat, agent) pair has its own session, so the + # binding lives separately from any specific SessionEntry). + source = make_source(Platform.TELEGRAM) + assert telegram_runner.session_store.get_chat_agent(source) == "coder" + + @pytest.mark.asyncio + async def test_profile_switch_evicts_default_agent_cache( + self, telegram_adapter, telegram_runner + ): + # Seed the cache with a sentinel keyed by the DEFAULT agent's + # session_key — that's what /profile coder evicts (the prior + # binding's slot). The new agent's session_key is independent. + source = make_source(Platform.TELEGRAM) + default_key = build_session_key(source, agent_name="default") + await send_and_capture(telegram_adapter, "/profile", Platform.TELEGRAM) + telegram_runner._agent_cache[default_key] = ("sentinel-agent", "sig-xyz") + + await send_and_capture(telegram_adapter, "/profile coder", Platform.TELEGRAM) + assert default_key not in telegram_runner._agent_cache + + @pytest.mark.asyncio + async def test_profile_switch_unknown_name(self, telegram_adapter): + send = await send_and_capture( + telegram_adapter, "/profile nonexistent-agent", Platform.TELEGRAM + ) + send.assert_called() + text = _send_response_text(telegram_adapter) + assert "Unknown profile" in text + assert "nonexistent-agent" in text + # Lists alternatives + assert "coder" in text + + @pytest.mark.asyncio + async def test_profile_switch_then_bare_shows_new_binding( + self, telegram_adapter + ): + await send_and_capture( + telegram_adapter, "/profile coder", Platform.TELEGRAM + ) + send = await send_and_capture(telegram_adapter, "/profile", Platform.TELEGRAM) + send.assert_called() + text = _send_response_text(telegram_adapter) + assert "coder" in text diff --git a/tests/gateway/test_agent_context.py b/tests/gateway/test_agent_context.py new file mode 100644 index 0000000000000..44e859824853a --- /dev/null +++ b/tests/gateway/test_agent_context.py @@ -0,0 +1,167 @@ +"""Tests for gateway.agent_context — per-turn HERMES_HOME override. + +These tests cover the contextvar plumbing and verify that +``hermes_constants.get_hermes_home()`` consults it before falling back +to the env var. Critical for the multi-agent gateway where a single +process serves multiple profiles in parallel. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextvars +import threading +from pathlib import Path + +import pytest + +from gateway.agent_context import ( + agent_home_scope, + current_agent_home, + reset_agent_home, +) +from hermes_constants import get_hermes_home + + +class TestAgentContextVar: + """ContextVar primitive — set / read / reset.""" + + def test_default_is_none(self): + """Without any scope, current_agent_home returns None.""" + reset_agent_home() + assert current_agent_home() is None + + def test_scope_sets_and_restores(self, tmp_path: Path): + """agent_home_scope sets inside, restores on exit.""" + reset_agent_home() + assert current_agent_home() is None + with agent_home_scope(tmp_path) as h: + assert h == tmp_path + assert current_agent_home() == tmp_path + assert current_agent_home() is None + + def test_scope_nesting(self, tmp_path: Path): + """Nested scopes save/restore the previous value (orchestrator → sub-agent).""" + reset_agent_home() + outer = tmp_path / "outer" + inner = tmp_path / "inner" + with agent_home_scope(outer): + assert current_agent_home() == outer + with agent_home_scope(inner): + assert current_agent_home() == inner + assert current_agent_home() == outer + assert current_agent_home() is None + + def test_scope_restores_on_exception(self, tmp_path: Path): + """The previous value is restored even when the block raises.""" + reset_agent_home() + with pytest.raises(RuntimeError): + with agent_home_scope(tmp_path): + assert current_agent_home() == tmp_path + raise RuntimeError("boom") + assert current_agent_home() is None + + def test_string_coerced_to_path(self, tmp_path: Path): + """Passing a str is accepted and round-tripped as a Path.""" + reset_agent_home() + with agent_home_scope(tmp_path) as h: + assert isinstance(h, Path) + assert current_agent_home() == tmp_path + + +class TestGetHermesHomeIntegration: + """get_hermes_home() consults the contextvar before the env var.""" + + def test_contextvar_overrides_env(self, tmp_path: Path, monkeypatch): + """ContextVar wins over HERMES_HOME env var.""" + reset_agent_home() + env_path = tmp_path / "env" + ctx_path = tmp_path / "ctx" + env_path.mkdir() + ctx_path.mkdir() + monkeypatch.setenv("HERMES_HOME", str(env_path)) + assert get_hermes_home() == env_path + with agent_home_scope(ctx_path): + assert get_hermes_home() == ctx_path + assert get_hermes_home() == env_path + + def test_no_override_falls_back_to_env(self, tmp_path: Path, monkeypatch): + """When the contextvar is unset, env var is honoured (legacy path).""" + reset_agent_home() + env_path = tmp_path / "env" + env_path.mkdir() + monkeypatch.setenv("HERMES_HOME", str(env_path)) + assert get_hermes_home() == env_path + + def test_no_override_no_env_falls_back_to_home(self, tmp_path: Path, monkeypatch): + """With neither contextvar nor env, falls back to ~/.hermes.""" + reset_agent_home() + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + assert get_hermes_home() == tmp_path / ".hermes" + + +class TestExecutorPropagation: + """ContextVar must propagate through copy_context() (which the gateway uses).""" + + def test_copy_context_carries_value(self, tmp_path: Path): + """copy_context() snapshots the current value; running a callable in + that context sees the captured agent home — even when the executor + thread itself doesn't have it set.""" + reset_agent_home() + target = tmp_path / "captured" + + def _worker() -> Path | None: + return current_agent_home() + + with agent_home_scope(target): + ctx = contextvars.copy_context() + # Outside the scope, the contextvar is back to default in this thread + assert current_agent_home() is None + # But ctx.run sees the captured value + result = ctx.run(_worker) + assert result == target + + def test_to_thread_inherits_via_copy_context(self, tmp_path: Path): + """asyncio.to_thread inherits the contextvar from the awaiting coroutine.""" + reset_agent_home() + target = tmp_path / "async" + + async def _main() -> Path | None: + with agent_home_scope(target): + return await asyncio.to_thread(current_agent_home) + + result = asyncio.run(_main()) + assert result == target + + def test_bare_thread_does_not_inherit(self, tmp_path: Path): + """Raw threading.Thread does NOT inherit contextvars. + + This documents the behaviour callers must work around (use + copy_context() or set the contextvar inside the thread). + """ + reset_agent_home() + target = tmp_path / "raw-thread" + captured: list[Path | None] = [] + + def _worker(): + captured.append(current_agent_home()) + + with agent_home_scope(target): + t = threading.Thread(target=_worker) + t.start() + t.join() + # Raw thread did not see the parent's contextvar + assert captured == [None] + + def test_executor_with_copy_context_inherits(self, tmp_path: Path): + """ThreadPoolExecutor.submit + copy_context() propagates correctly.""" + reset_agent_home() + target = tmp_path / "pool" + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + with agent_home_scope(target): + ctx = contextvars.copy_context() + future = pool.submit(ctx.run, current_agent_home) + assert future.result() == target diff --git a/tests/gateway/test_agent_mention.py b/tests/gateway/test_agent_mention.py new file mode 100644 index 0000000000000..65a2f66b8e680 --- /dev/null +++ b/tests/gateway/test_agent_mention.py @@ -0,0 +1,124 @@ +"""Tests for ``gateway.agent_mention`` — parse ``@`` inline routing.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from gateway.agent_mention import parse_agent_mention +from gateway.agent_registry import AgentRegistry, reset_default_registry + + +@pytest.fixture +def registry(tmp_path, monkeypatch): + root = tmp_path / ".hermes" + root.mkdir() + (root / "profiles").mkdir() + (root / "profiles" / "coder").mkdir() + (root / "profiles" / "data-sci").mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + reset_default_registry() + yield AgentRegistry() + reset_default_registry() + + +class TestPlainMessage: + def test_no_mention(self, registry): + result = parse_agent_mention("hello world", registry) + assert result.target_agent is None + assert result.stripped_text == "hello world" + + def test_empty(self, registry): + result = parse_agent_mention("", registry) + assert result.target_agent is None + assert result.stripped_text == "" + + def test_none_input(self, registry): + result = parse_agent_mention(None, registry) # type: ignore[arg-type] + assert result.target_agent is None + assert result.stripped_text == "" + + def test_email_inside_message_not_a_mention(self, registry): + """Mid-sentence ``@`` (e.g. email addresses, handles) must not trigger routing.""" + text = "ping alice@host.com about coder please" + result = parse_agent_mention(text, registry) + assert result.target_agent is None + assert result.stripped_text == text + + +class TestKnownMention: + def test_routes_to_known_agent(self, registry): + result = parse_agent_mention("@coder fix this bug", registry) + assert result.target_agent is not None + assert result.target_agent.name == "coder" + assert result.stripped_text == "fix this bug" + assert result.raw_mention == "@coder" + + def test_case_insensitive_target(self, registry): + result = parse_agent_mention("@Coder fix this", registry) + assert result.target_agent is not None + assert result.target_agent.name == "coder" + + def test_strips_only_leading_whitespace(self, registry): + result = parse_agent_mention(" @coder fix this", registry) + assert result.target_agent is not None + assert result.stripped_text == "fix this" + + def test_handles_hyphenated_name(self, registry): + result = parse_agent_mention("@data-sci analyze foo.csv", registry) + assert result.target_agent is not None + assert result.target_agent.name == "data-sci" + assert result.stripped_text == "analyze foo.csv" + + def test_default_agent_via_mention(self, registry): + result = parse_agent_mention("@default hello", registry) + assert result.target_agent is not None + assert result.target_agent.name == "default" + + +class TestUnknownMention: + def test_unknown_agent_passes_through(self, registry): + """Mention syntax matched but target not registered — keep original text. + + Important: users still write ``@alice`` to address a person, and + we mustn't eat that message. + """ + text = "@alice hello" + result = parse_agent_mention(text, registry) + assert result.target_agent is None + assert result.stripped_text == text + + def test_partial_match_not_a_mention(self, registry): + # ``@`` alone, no name, no space → not a mention + result = parse_agent_mention("@", registry) + assert result.target_agent is None + + def test_at_with_no_separator(self, registry): + # ``@coder`` with no following whitespace+body → not a routing + # mention (we'd otherwise eat the entire token as an agent name) + result = parse_agent_mention("@coder", registry) + assert result.target_agent is None + + +class TestEdgeCases: + def test_multiline_message_after_mention(self, registry): + text = "@coder fix\nplease\nthank you" + result = parse_agent_mention(text, registry) + assert result.target_agent is not None + assert result.target_agent.name == "coder" + assert "fix" in result.stripped_text + assert "please" in result.stripped_text + + def test_tab_separator_between_name_and_body(self, registry): + result = parse_agent_mention("@coder\tdo it", registry) + assert result.target_agent is not None + assert result.stripped_text == "do it" + + def test_only_first_token_consumed(self, registry): + # Only the first @ is parsed; subsequent @s in the body + # are normal text. + result = parse_agent_mention("@coder ping @data-sci with results", registry) + assert result.target_agent.name == "coder" + assert result.stripped_text == "ping @data-sci with results" diff --git a/tests/gateway/test_agent_registry.py b/tests/gateway/test_agent_registry.py new file mode 100644 index 0000000000000..6fb4c87ecbf46 --- /dev/null +++ b/tests/gateway/test_agent_registry.py @@ -0,0 +1,220 @@ +"""Tests for gateway.agent_registry — multi-agent profile enumeration.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from gateway.agent_registry import ( + DEFAULT_AGENT_NAME, + AgentProfile, + AgentRegistry, + default_registry, + reset_default_registry, +) + + +@pytest.fixture +def fake_root(tmp_path, monkeypatch): + """Lay out a fake hermes root with default + 2 named profiles.""" + root = tmp_path / ".hermes" + root.mkdir() + (root / "profiles").mkdir() + (root / "profiles" / "coder").mkdir() + (root / "profiles" / "data-sci").mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + reset_default_registry() + yield root + reset_default_registry() + + +class TestAgentRegistryBasics: + def test_lists_default_first(self, fake_root): + r = AgentRegistry() + names = [p.name for p in r.list()] + assert names[0] == DEFAULT_AGENT_NAME + assert set(names) == {DEFAULT_AGENT_NAME, "coder", "data-sci"} + + def test_named_profiles_sorted(self, fake_root): + # add another profile out of alphabetical order + (fake_root / "profiles" / "alpha").mkdir() + r = AgentRegistry() + named = [p.name for p in r.list() if not p.is_default] + assert named == sorted(named) + + def test_default_always_present(self, tmp_path, monkeypatch): + """Even when ~/.hermes is empty, default agent is in the registry.""" + empty = tmp_path / ".hermes" + empty.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + reset_default_registry() + try: + r = AgentRegistry() + d = r.default() + assert d.name == DEFAULT_AGENT_NAME + assert d.is_default is True + assert d.home == empty + finally: + reset_default_registry() + + def test_get_returns_named_profile(self, fake_root): + r = AgentRegistry() + p = r.get("coder") + assert p is not None + assert p.name == "coder" + assert p.is_default is False + assert p.home == fake_root / "profiles" / "coder" + + def test_get_default_alias(self, fake_root): + r = AgentRegistry() + for variant in ("default", "Default", "DEFAULT", " default "): + p = r.get(variant) + assert p is not None, variant + assert p.name == DEFAULT_AGENT_NAME + + def test_get_unknown_returns_none(self, fake_root): + r = AgentRegistry() + assert r.get("nonexistent") is None + assert r.get("") is None + assert r.get(" ") is None + + def test_get_invalid_id_returns_none(self, fake_root): + r = AgentRegistry() + # Capitals / special chars are not valid profile ids — must reject + assert r.get("Bad/Name") is None + assert r.get("@coder") is None + + def test_get_case_insensitive_for_named(self, fake_root): + r = AgentRegistry() + # The on-disk dir is lowercase; user-typed "Coder" should canonicalise. + p = r.get("Coder") + assert p is not None + assert p.name == "coder" + + def test_names(self, fake_root): + r = AgentRegistry() + names = r.names() + assert names[0] == DEFAULT_AGENT_NAME + assert "coder" in names + + +class TestAgentRegistryRefresh: + def test_refresh_picks_up_new_profile(self, fake_root): + r = AgentRegistry() + assert r.get("newbie") is None + (fake_root / "profiles" / "newbie").mkdir() + # Without refresh, cached list does not see the new profile + assert r.get("newbie") is None + r.refresh() + assert r.get("newbie") is not None + + def test_refresh_picks_up_deletion(self, fake_root): + r = AgentRegistry() + assert r.get("coder") is not None + # Simulate deletion + import shutil + + shutil.rmtree(fake_root / "profiles" / "coder") + # Without refresh, still cached + assert r.get("coder") is not None + r.refresh() + assert r.get("coder") is None + + +class TestDisplayNameAndDescription: + def test_display_name_falls_back_to_id(self, fake_root): + r = AgentRegistry() + coder = r.get("coder") + assert coder.display_name == "coder" + + def test_display_name_from_gateway_config(self, fake_root): + (fake_root / "profiles" / "coder" / "config.yaml").write_text( + "gateway:\n agent_display_name: Coder Bot\n", + encoding="utf-8", + ) + r = AgentRegistry() + coder = r.get("coder") + assert coder.display_name == "Coder Bot" + + def test_display_name_from_branding_fallback(self, fake_root): + (fake_root / "profiles" / "coder" / "config.yaml").write_text( + "display:\n branding:\n agent_name: Coder Bot\n", + encoding="utf-8", + ) + r = AgentRegistry() + coder = r.get("coder") + assert coder.display_name == "Coder Bot" + + def test_description_from_soul(self, fake_root): + (fake_root / "profiles" / "coder" / "SOUL.md").write_text( + "# Coder\n\nI write Python.\n", encoding="utf-8" + ) + r = AgentRegistry() + coder = r.get("coder") + assert coder.description == "I write Python." + + def test_description_empty_when_no_soul(self, fake_root): + r = AgentRegistry() + assert r.get("coder").description == "" + + def test_bad_yaml_does_not_crash(self, fake_root): + (fake_root / "profiles" / "coder" / "config.yaml").write_text( + ": : : not valid yaml at all : :", encoding="utf-8" + ) + r = AgentRegistry() + coder = r.get("coder") + assert coder is not None + assert coder.display_name == "coder" # falls back + + +class TestProfileFiltering: + def test_ignores_non_directory_entries(self, fake_root): + (fake_root / "profiles" / "notes.txt").write_text("ignore me") + r = AgentRegistry() + assert r.get("notes.txt") is None + + def test_ignores_invalid_profile_names(self, fake_root): + # Profile names must match [a-z0-9][a-z0-9_-]{0,63}. + (fake_root / "profiles" / "Bad-Caps").mkdir() + (fake_root / "profiles" / "1starts-numeric").mkdir() # valid + (fake_root / "profiles" / "_starts-underscore").mkdir() # invalid + r = AgentRegistry() + names = r.names() + assert "1starts-numeric" in names + assert "Bad-Caps" not in names + assert "_starts-underscore" not in names + + +class TestDefaultRegistry: + def test_singleton_identity(self, fake_root): + r1 = default_registry() + r2 = default_registry() + assert r1 is r2 + + def test_reset_creates_new_instance(self, fake_root): + r1 = default_registry() + reset_default_registry() + r2 = default_registry() + assert r1 is not r2 + + +class TestAgentProfile: + def test_to_dict(self): + p = AgentProfile( + name="x", home=Path("/tmp/x"), display_name="X", description="y", is_default=False + ) + assert p.to_dict() == { + "name": "x", + "home": str(Path("/tmp/x")), + "display_name": "X", + "description": "y", + "is_default": False, + } + + def test_frozen(self): + p = AgentProfile(name="x", home=Path("/tmp/x"), display_name="X") + with pytest.raises(Exception): # FrozenInstanceError + p.name = "y" # type: ignore[misc] diff --git a/tests/gateway/test_agent_response.py b/tests/gateway/test_agent_response.py new file mode 100644 index 0000000000000..383c3b43abef0 --- /dev/null +++ b/tests/gateway/test_agent_response.py @@ -0,0 +1,110 @@ +"""Tests for ``gateway.agent_response`` — gateway-level agent prefix.""" + +from __future__ import annotations + +import pytest + +from gateway.agent_response import ( + apply_agent_prefix_to_result, + format_agent_response, + show_agent_name_enabled, +) + + +class TestShowAgentNameEnabled: + def test_default_true(self): + assert show_agent_name_enabled(None) is True + assert show_agent_name_enabled({}) is True + + def test_explicit_true(self): + assert show_agent_name_enabled({"gateway": {"show_agent_name": True}}) + assert show_agent_name_enabled({"gateway": {"show_agent_name": "yes"}}) + assert show_agent_name_enabled({"gateway": {"show_agent_name": 1}}) + + def test_explicit_false(self): + assert not show_agent_name_enabled({"gateway": {"show_agent_name": False}}) + assert not show_agent_name_enabled({"gateway": {"show_agent_name": "no"}}) + assert not show_agent_name_enabled({"gateway": {"show_agent_name": 0}}) + + def test_non_dict_gateway_section_falls_back_to_default(self): + assert show_agent_name_enabled({"gateway": "weird"}) + + +class TestFormatAgentResponse: + def test_prefixes(self): + assert format_agent_response("hello", "coder") == "[coder] hello" + + def test_disabled_returns_content(self): + assert format_agent_response("hello", "coder", enabled=False) == "hello" + + def test_empty_name_returns_content(self): + assert format_agent_response("hello", None) == "hello" + assert format_agent_response("hello", "") == "hello" + assert format_agent_response("hello", " ") == "hello" + + def test_empty_content_returns_content(self): + # Never surface a bare prefix + assert format_agent_response("", "coder") == "" + assert format_agent_response(" ", "coder") == " " + + def test_idempotent(self): + once = format_agent_response("hi", "coder") + twice = format_agent_response(once, "coder") + assert once == twice == "[coder] hi" + + def test_does_not_strip_user_content(self): + # Leading whitespace and newlines preserved (just prepend) + original = "first line\nsecond line" + assert format_agent_response(original, "coder") == "[coder] " + original + + def test_strips_name_whitespace(self): + assert format_agent_response("hi", " coder ") == "[coder] hi" + + def test_non_string_content_returns_content(self): + # Defensive — caller might pass a non-str final_response + assert format_agent_response(None, "coder") is None # type: ignore[arg-type] + + +class TestApplyAgentPrefixToResult: + def test_mutates_final_response(self): + result = {"final_response": "hi"} + out = apply_agent_prefix_to_result(result, "coder", {}) + assert out is result # mutates in place + assert result["final_response"] == "[coder] hi" + + def test_disabled_does_not_mutate(self): + result = {"final_response": "hi"} + apply_agent_prefix_to_result( + result, "coder", {"gateway": {"show_agent_name": False}} + ) + assert result["final_response"] == "hi" + + def test_no_final_response_key(self): + result = {"messages": []} + out = apply_agent_prefix_to_result(result, "coder", {}) + assert out is result + assert "final_response" not in result + + def test_none_result(self): + assert apply_agent_prefix_to_result(None, "coder", {}) is None + + def test_idempotent_via_dict(self): + result = {"final_response": "[coder] hi"} + apply_agent_prefix_to_result(result, "coder", {}) + assert result["final_response"] == "[coder] hi" + + def test_empty_final_response_not_prefixed(self): + result = {"final_response": ""} + apply_agent_prefix_to_result(result, "coder", {}) + assert result["final_response"] == "" + + def test_default_config_prefixes(self): + """When config is absent, default behaviour is to prefix.""" + result = {"final_response": "hi"} + apply_agent_prefix_to_result(result, "coder", None) + assert result["final_response"] == "[coder] hi" + + def test_non_string_final_response_unchanged(self): + result = {"final_response": 42} # type: ignore[dict-item] + apply_agent_prefix_to_result(result, "coder", {}) + assert result["final_response"] == 42 diff --git a/tests/gateway/test_multi_agent_isolation.py b/tests/gateway/test_multi_agent_isolation.py new file mode 100644 index 0000000000000..12aee12115b25 --- /dev/null +++ b/tests/gateway/test_multi_agent_isolation.py @@ -0,0 +1,200 @@ +"""Integration tests: ``agent_home_scope`` actually isolates profile data. + +These tests don't spin up a full AIAgent (that has dozens of +dependencies that would bloat the test). Instead, they exercise the +profile-aware path functions that AIAgent uses internally — memory, +skills, soul — and verify each one resolves to the *active agent's* +home directory when wrapped in ``agent_home_scope``. + +If a future refactor introduces a path resolver that bypasses +``get_hermes_home()``, these tests will fail — that's the design +intent. ``get_hermes_home()`` is the single chokepoint that makes the +multi-agent gateway possible. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from gateway.agent_context import agent_home_scope, reset_agent_home +from hermes_constants import ( + get_hermes_home, + get_config_path, + get_env_path, + get_skills_dir, +) + + +@pytest.fixture +def two_profiles(tmp_path, monkeypatch): + """Lay out two fully-populated agent homes side by side.""" + root = tmp_path / ".hermes" + coder = root / "profiles" / "coder" + sci = root / "profiles" / "data-sci" + for home in (root, coder, sci): + home.mkdir(parents=True, exist_ok=True) + (home / "memories").mkdir() + (home / "skills").mkdir() + (home / "SOUL.md").write_text(f"# Soul of {home.name}\n", encoding="utf-8") + (home / "memories" / "MEMORY.md").write_text( + f"memory_for_{home.name}\n", encoding="utf-8" + ) + (home / "config.yaml").write_text( + f"# config for {home.name}\n", encoding="utf-8" + ) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + reset_agent_home() + yield root, coder, sci + reset_agent_home() + + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + + +class TestPathFunctionsRespectScope: + def test_get_hermes_home_default(self, two_profiles): + root, _coder, _sci = two_profiles + assert get_hermes_home() == root + + def test_get_hermes_home_inside_scope(self, two_profiles): + _root, coder, _sci = two_profiles + with agent_home_scope(coder): + assert get_hermes_home() == coder + + def test_get_config_path_inside_scope(self, two_profiles): + _root, coder, _sci = two_profiles + with agent_home_scope(coder): + assert get_config_path() == coder / "config.yaml" + + def test_get_env_path_inside_scope(self, two_profiles): + _root, coder, _sci = two_profiles + with agent_home_scope(coder): + assert get_env_path() == coder / ".env" + + def test_get_skills_dir_inside_scope(self, two_profiles): + _root, coder, _sci = two_profiles + with agent_home_scope(coder): + assert get_skills_dir() == coder / "skills" + + def test_switching_between_scopes(self, two_profiles): + _root, coder, sci = two_profiles + with agent_home_scope(coder): + assert get_hermes_home() == coder + with agent_home_scope(sci): + assert get_hermes_home() == sci + assert get_hermes_home() == coder + + +# --------------------------------------------------------------------------- +# Memory isolation +# --------------------------------------------------------------------------- + + +class TestMemoryIsolation: + """tools.memory_tool reads ``get_hermes_home() / 'memories'``.""" + + def test_memory_dir_inside_scope(self, two_profiles): + _root, coder, sci = two_profiles + from tools.memory_tool import get_memory_dir + + with agent_home_scope(coder): + assert get_memory_dir() == coder / "memories" + with agent_home_scope(sci): + assert get_memory_dir() == sci / "memories" + + def test_memory_content_isolation(self, two_profiles): + """Two agents see distinct MEMORY.md content.""" + _root, coder, sci = two_profiles + from tools.memory_tool import get_memory_dir + + with agent_home_scope(coder): + coder_mem = (get_memory_dir() / "MEMORY.md").read_text(encoding="utf-8") + with agent_home_scope(sci): + sci_mem = (get_memory_dir() / "MEMORY.md").read_text(encoding="utf-8") + assert "coder" in coder_mem + assert "data-sci" in sci_mem + assert coder_mem != sci_mem + + +# --------------------------------------------------------------------------- +# Soul isolation +# --------------------------------------------------------------------------- + + +class TestSoulIsolation: + """The agent's SOUL.md is read from get_hermes_home() / 'SOUL.md'. + + See ``agent/prompt_builder.py``. We do the read directly rather + than invoking the full prompt builder so the test stays focused + on the path resolution. + """ + + def _read_soul(self) -> str: + return (get_hermes_home() / "SOUL.md").read_text(encoding="utf-8") + + def test_soul_inside_scope(self, two_profiles): + _root, coder, sci = two_profiles + with agent_home_scope(coder): + assert "coder" in self._read_soul() + with agent_home_scope(sci): + assert "data-sci" in self._read_soul() + + +# --------------------------------------------------------------------------- +# Skills isolation +# --------------------------------------------------------------------------- + + +class TestSkillsIsolation: + """tools/skill_usage.py and similar callers read ``get_hermes_home() / 'skills'``.""" + + def test_skills_dir_inside_scope(self, two_profiles): + _root, coder, sci = two_profiles + with agent_home_scope(coder): + assert get_skills_dir() == coder / "skills" + with agent_home_scope(sci): + assert get_skills_dir() == sci / "skills" + + def test_skills_content_isolation(self, two_profiles): + """Each profile sees only its own installed skills.""" + _root, coder, sci = two_profiles + # Plant a SKILL.md in coder but NOT in data-sci + (coder / "skills" / "coder-only").mkdir() + (coder / "skills" / "coder-only" / "SKILL.md").write_text( + "---\nname: coder-only\n---\n", encoding="utf-8" + ) + + with agent_home_scope(coder): + coder_skills = sorted(p.name for p in get_skills_dir().iterdir()) + with agent_home_scope(sci): + sci_skills = sorted(p.name for p in get_skills_dir().iterdir()) + + assert "coder-only" in coder_skills + assert "coder-only" not in sci_skills + + +# --------------------------------------------------------------------------- +# Negative: env var alone (no contextvar) still resolves to the env-var path +# so single-profile gateways behave exactly as before. +# --------------------------------------------------------------------------- + + +class TestEnvVarBackwardCompat: + def test_env_var_path_when_no_scope(self, two_profiles, monkeypatch): + _root, coder, _sci = two_profiles + monkeypatch.setenv("HERMES_HOME", str(coder)) + assert get_hermes_home() == coder + + def test_scope_wins_over_env_var(self, two_profiles, monkeypatch): + _root, coder, sci = two_profiles + monkeypatch.setenv("HERMES_HOME", str(coder)) + with agent_home_scope(sci): + assert get_hermes_home() == sci + # Restored to env-var path + assert get_hermes_home() == coder diff --git a/tests/gateway/test_profile_command.py b/tests/gateway/test_profile_command.py new file mode 100644 index 0000000000000..cdb41be62e65c --- /dev/null +++ b/tests/gateway/test_profile_command.py @@ -0,0 +1,177 @@ +"""Integration tests for the gateway ``/profile`` slash command. + +After the agent-vs-profile reconciliation, ``/profile`` is the single +multi-agent entry point: + + /profile → show the session's active profile + host info + /profile ls → list every available profile + /profile → bind this session to + /profile default → reset to the default profile + +It replaces the read-only ``/profile`` and the separate ``/agent`` +command that existed during early refactor iterations. +""" + +from __future__ import annotations + +import asyncio +from collections import OrderedDict +from pathlib import Path +from threading import Lock + +import pytest + +from gateway.agent_registry import reset_default_registry +from gateway.config import GatewayConfig, Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource, SessionStore + + +@pytest.fixture +def fake_root(tmp_path, monkeypatch): + """Lay out ~/.hermes with default + coder + data-sci.""" + root = tmp_path / ".hermes" + root.mkdir() + (root / "profiles").mkdir() + (root / "profiles" / "coder").mkdir() + (root / "profiles" / "data-sci").mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + reset_default_registry() + yield root + reset_default_registry() + + +def _make_event(text: str) -> MessageEvent: + src = SessionSource( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + user_id="user1", + user_name="alice", + ) + return MessageEvent(text=text, source=src) + + +class _RunnerStub: + """Minimal stub of GatewayRunner exposing only what _handle_profile_command needs.""" + + def __init__(self, sessions_dir: Path): + self.config = GatewayConfig() + self.session_store = SessionStore(sessions_dir, self.config) + self._agent_cache: "OrderedDict[str, tuple]" = OrderedDict() + self._agent_cache_lock = Lock() + + +def _call_handler(runner: _RunnerStub, event: MessageEvent) -> str: + from gateway.run import GatewayRunner + + handler = GatewayRunner._handle_profile_command + return asyncio.get_event_loop().run_until_complete( + handler(runner, event) # type: ignore[arg-type] + ) + + +@pytest.fixture +def runner(fake_root, tmp_path): + return _RunnerStub(tmp_path / "sessions") + + +class TestRegistry: + def test_profile_registered(self): + from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command + + cmd = resolve_command("profile") + assert cmd is not None + assert cmd.name == "profile" + assert "profile" in GATEWAY_KNOWN_COMMANDS + + def test_agent_command_removed(self): + """The standalone /agent command was merged into /profile.""" + from hermes_cli.commands import resolve_command + + # /agent should no longer resolve — only /agents (plural, running tasks) + cmd = resolve_command("agent") + assert cmd is None or cmd.name != "agent" + + +class TestBareProfile: + def test_bare_shows_default(self, runner): + result = _call_handler(runner, _make_event("/profile")) + assert "Active profile" in result + assert "default" in result + # Hint to discover more options + assert "/profile ls" in result + + def test_bare_shows_current_after_switch(self, runner): + # Initialise the session record first. + _call_handler(runner, _make_event("/profile")) + _call_handler(runner, _make_event("/profile coder")) + result = _call_handler(runner, _make_event("/profile")) + assert "coder" in result + # Default is no longer the active marker + assert "Active profile (this session): coder" in result + + +class TestProfileList: + def test_ls_lists_all(self, runner): + result = _call_handler(runner, _make_event("/profile ls")) + assert "Available profiles:" in result + assert "default" in result + assert "coder" in result + assert "data-sci" in result + assert "Active: default" in result + + def test_list_alias_same_as_ls(self, runner): + result = _call_handler(runner, _make_event("/profile list")) + assert "Available profiles:" in result + assert "coder" in result + + +class TestProfileSwitch: + def test_switch_to_known_profile(self, runner): + _call_handler(runner, _make_event("/profile")) + result = _call_handler(runner, _make_event("/profile coder")) + assert "Switched profile to: coder" in result + + source = _make_event("ignored").source + # New multi-agent model: the binding lives at chat-level so + # next-message resolution finds it regardless of session_key. + assert runner.session_store.get_chat_agent(source) == "coder" + + def test_switch_invalidates_old_agent_cache_slot(self, runner): + # The cache key is per (chat, agent) now — switching from + # default to coder evicts default's slot (the prior binding), + # not coder's (which is independent and fresh). + _call_handler(runner, _make_event("/profile")) + source = _make_event("ignored").source + default_key = runner.session_store._generate_session_key( + source, agent_name="default" + ) + runner._agent_cache[default_key] = ("sentinel-agent", "sig-123") + + _call_handler(runner, _make_event("/profile coder")) + assert default_key not in runner._agent_cache, ( + "switching away from default must evict its cache slot so a " + "later switch back doesn't reuse a stale construction" + ) + + def test_switch_to_unknown_profile(self, runner): + result = _call_handler(runner, _make_event("/profile nope")) + assert "Unknown profile" in result + assert "nope" in result + assert "coder" in result # available list referenced + + def test_already_on_target_short_circuits(self, runner): + _call_handler(runner, _make_event("/profile")) + _call_handler(runner, _make_event("/profile coder")) + result = _call_handler(runner, _make_event("/profile coder")) + assert "Already on profile 'coder'" in result + + def test_switch_to_default_resets(self, runner): + _call_handler(runner, _make_event("/profile")) + _call_handler(runner, _make_event("/profile coder")) + result = _call_handler(runner, _make_event("/profile default")) + assert "Switched profile to: default" in result + source = _make_event("ignored").source + assert runner.session_store.get_chat_agent(source) == "default" diff --git a/tests/gateway/test_resolve_turn_agent.py b/tests/gateway/test_resolve_turn_agent.py new file mode 100644 index 0000000000000..4cff4a3c7226c --- /dev/null +++ b/tests/gateway/test_resolve_turn_agent.py @@ -0,0 +1,137 @@ +"""Tests for ``GatewayRunner._resolve_turn_agent``. + +Phase 6 of the multi-agent gateway refactor; revised in the +chat-level-binding follow-up. This is the choke point that decides +which agent (Hermes profile) handles a given turn — it must honour +``@`` mentions, fall back to the chat's persisted binding (set +by ``/profile ``), and finally to the default agent. + +The signature is ``(message, source)`` — session_key is intentionally +NOT a parameter because under the multi-agent model the session_key +depends on the agent resolved here. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from gateway.agent_registry import reset_default_registry +from gateway.config import GatewayConfig, Platform +from gateway.run import GatewayRunner +from gateway.session import SessionSource, SessionStore + + +@pytest.fixture +def fake_root(tmp_path, monkeypatch): + root = tmp_path / ".hermes" + root.mkdir() + (root / "profiles").mkdir() + (root / "profiles" / "coder").mkdir() + (root / "profiles" / "data-sci").mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + reset_default_registry() + yield root + reset_default_registry() + + +@pytest.fixture +def runner_stub(fake_root, tmp_path): + """Lightweight stand-in for GatewayRunner. + + The real __init__ has ~60 parameters and pulls in adapters / SQLite / + hooks. We only need ``session_store`` so we can exercise the resolver + in isolation. Borrowing the unbound method keeps coverage on the + production code path. + """ + + class _Stub: + pass + + stub = _Stub() + stub.config = GatewayConfig() + stub.session_store = SessionStore(tmp_path / "sessions", stub.config) + return stub + + +def _resolve(runner_stub, message: str, source): + """Invoke the production method bound to the stub.""" + return GatewayRunner._resolve_turn_agent(runner_stub, message, source) + + +def _source() -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + user_id="user1", + user_name="alice", + ) + + +class TestNoMentionFallsBackToChatBinding: + def test_default_when_chat_has_no_binding(self, runner_stub): + name, _home, msg = _resolve(runner_stub, "hello", _source()) + assert name == "default" + assert msg == "hello" + + def test_falls_back_to_chat_binding(self, runner_stub): + runner_stub.session_store.set_chat_agent(_source(), "coder") + name, _home, msg = _resolve(runner_stub, "do the thing", _source()) + assert name == "coder" + assert msg == "do the thing" + + def test_unknown_stored_agent_falls_back_to_default(self, runner_stub): + # Force an invalid value via direct mutation — simulates a profile + # that was deleted while the binding persisted its name. + from gateway.session import build_chat_key + + chat_key = build_chat_key(_source()) + runner_stub.session_store._chat_bindings_loaded = True + runner_stub.session_store._chat_bindings[chat_key] = "ghost" + name, _home, msg = _resolve(runner_stub, "hi", _source()) + assert name == "default" + assert msg == "hi" + + +class TestMentionOverridesStored: + def test_mention_takes_priority(self, runner_stub): + runner_stub.session_store.set_chat_agent(_source(), "coder") + name, _home, msg = _resolve( + runner_stub, "@data-sci analyze X", _source() + ) + assert name == "data-sci" + assert msg == "analyze X" + + def test_mention_does_not_mutate_binding(self, runner_stub): + runner_stub.session_store.set_chat_agent(_source(), "coder") + _resolve(runner_stub, "@data-sci ping", _source()) + # Chat binding is unchanged — @ is per-turn only + assert runner_stub.session_store.get_chat_agent(_source()) == "coder" + + def test_unknown_mention_passes_through(self, runner_stub): + runner_stub.session_store.set_chat_agent(_source(), "coder") + name, _home, msg = _resolve( + runner_stub, "@nonexistent hello", _source() + ) + # Unknown mentions are NOT routed — text passes through and the + # chat binding wins. + assert name == "coder" + assert msg == "@nonexistent hello" + + +class TestHomePath: + def test_default_home_is_hermes_root(self, runner_stub, fake_root): + _name, home, _msg = _resolve(runner_stub, "hi", _source()) + assert home == fake_root + + def test_named_agent_home_is_profile_dir(self, runner_stub, fake_root): + runner_stub.session_store.set_chat_agent(_source(), "coder") + _name, home, _msg = _resolve(runner_stub, "hi", _source()) + assert home == fake_root / "profiles" / "coder" + + def test_mentioned_agent_home(self, runner_stub, fake_root): + _name, home, _msg = _resolve(runner_stub, "@data-sci foo", _source()) + assert home == fake_root / "profiles" / "data-sci" diff --git a/tests/gateway/test_session_active_agent.py b/tests/gateway/test_session_active_agent.py new file mode 100644 index 0000000000000..64f9ec0c92fb9 --- /dev/null +++ b/tests/gateway/test_session_active_agent.py @@ -0,0 +1,169 @@ +"""Tests for SessionEntry.active_agent and SessionStore.set/get_active_agent. + +Phase 3 of the multi-agent gateway refactor: each gateway session +remembers which agent (Hermes profile) it is currently bound to. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +import pytest + +from gateway.config import GatewayConfig, Platform +from gateway.session import SessionEntry, SessionSource, SessionStore + + +def _make_source() -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + user_id="user1", + user_name="alice", + ) + + +@pytest.fixture +def store(tmp_path: Path) -> SessionStore: + cfg = GatewayConfig() + return SessionStore(sessions_dir=tmp_path, config=cfg) + + +class TestSessionEntryActiveAgent: + def test_default_value(self): + now = datetime.now() + e = SessionEntry( + session_key="k", session_id="s1", created_at=now, updated_at=now + ) + assert e.active_agent == "default" + + def test_to_dict_includes_active_agent(self): + now = datetime.now() + e = SessionEntry( + session_key="k", + session_id="s1", + created_at=now, + updated_at=now, + active_agent="coder", + ) + d = e.to_dict() + assert d["active_agent"] == "coder" + + def test_from_dict_legacy_session_uses_default(self): + now = datetime.now() + # Simulate a session persisted before the field existed. + d = { + "session_key": "k", + "session_id": "s1", + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + } + e = SessionEntry.from_dict(d) + assert e.active_agent == "default" + + def test_from_dict_round_trip(self): + now = datetime.now() + original = SessionEntry( + session_key="k", + session_id="s1", + created_at=now, + updated_at=now, + active_agent="coder", + ) + restored = SessionEntry.from_dict(original.to_dict()) + assert restored.active_agent == "coder" + + def test_from_dict_null_falls_back_to_default(self): + now = datetime.now() + d = { + "session_key": "k", + "session_id": "s1", + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + "active_agent": None, + } + e = SessionEntry.from_dict(d) + assert e.active_agent == "default" + + +class TestSessionStoreSetActiveAgent: + def test_set_unknown_session_returns_false(self, store): + assert store.set_active_agent("nonexistent", "coder") is False + + def test_set_known_session(self, store): + source = _make_source() + entry = store.get_or_create_session(source) + ok = store.set_active_agent(entry.session_key, "coder") + assert ok is True + # Round-trip via the public getter + assert store.get_active_agent(entry.session_key) == "coder" + + def test_default_for_new_session(self, store): + source = _make_source() + entry = store.get_or_create_session(source) + assert store.get_active_agent(entry.session_key) == "default" + + def test_get_unknown_session_returns_default(self, store): + assert store.get_active_agent("nope") == "default" + + def test_empty_and_whitespace_rejected(self, store): + source = _make_source() + entry = store.get_or_create_session(source) + assert store.set_active_agent(entry.session_key, "") is False + assert store.set_active_agent(entry.session_key, " ") is False + # Original value preserved + assert store.get_active_agent(entry.session_key) == "default" + + def test_value_is_stripped(self, store): + source = _make_source() + entry = store.get_or_create_session(source) + store.set_active_agent(entry.session_key, " coder ") + assert store.get_active_agent(entry.session_key) == "coder" + + +class TestPersistence: + def test_set_is_persisted_to_disk(self, tmp_path): + cfg = GatewayConfig() + # Round 1: set the value + store1 = SessionStore(tmp_path, cfg) + source = _make_source() + entry = store1.get_or_create_session(source) + store1.set_active_agent(entry.session_key, "coder") + + # Round 2: fresh store reads from disk + store2 = SessionStore(tmp_path, cfg) + assert store2.get_active_agent(entry.session_key) == "coder" + + def test_persisted_json_contains_active_agent(self, tmp_path): + cfg = GatewayConfig() + store = SessionStore(tmp_path, cfg) + source = _make_source() + entry = store.get_or_create_session(source) + store.set_active_agent(entry.session_key, "coder") + + sessions_file = tmp_path / "sessions.json" + data = json.loads(sessions_file.read_text(encoding="utf-8")) + assert data[entry.session_key]["active_agent"] == "coder" + + def test_legacy_sessions_json_reads_as_default(self, tmp_path): + """A sessions.json written before this field existed must still load.""" + cfg = GatewayConfig() + now = datetime.now() + legacy = { + "agent:main:telegram:dm:12345": { + "session_key": "agent:main:telegram:dm:12345", + "session_id": "old", + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + } + } + (tmp_path / "sessions.json").write_text( + json.dumps(legacy), encoding="utf-8" + ) + store = SessionStore(tmp_path, cfg) + assert ( + store.get_active_agent("agent:main:telegram:dm:12345") == "default" + )