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