Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1dd9693
feat(agent): add AgentProfile + ContextVar for per-agent paths
02356abc May 17, 2026
f3959e7
feat(session): thread agent_id through session identity & DB schema
02356abc May 17, 2026
45256e6
feat(gateway): route inbound messages via routes table + select_agent…
02356abc May 17, 2026
54fa0f6
feat(gateway): GatewayRunner loads registry, binds profile, propagate…
02356abc May 17, 2026
1c3a62e
feat(cron+delivery): propagate agent_id through scheduled jobs & deli…
02356abc May 17, 2026
df1d7b8
feat(cli): add hermes agent subcommand for multi-agent management
02356abc May 17, 2026
8f809f6
docs: multi-agent routing guide + sample config
02356abc May 17, 2026
b27fb79
fix(cron): resolve stale cron-store path leak under profile-dynamic g…
jethac Jul 12, 2026
ce5978e
test(cron): expect agent_id on delivery targets after multi-agent rebase
jethac Jul 12, 2026
4a72c08
fix(gateway): default _default_agent_id at class level for partial-in…
jethac Jul 12, 2026
ac252cd
test(cli): expect agent_id on session-finalize hook after multi-agent…
jethac Jul 12, 2026
b9d0d08
fix(gateway): resolve profile api_key_env through the secret scope
jethac Jul 12, 2026
51b7c1b
fix(cli): anchor agent --from-profile clones at the profile root
jethac Jul 12, 2026
51bef4b
feat(gateway): wire api_server adapter to multi-agent routing
davidgut1982 Jul 12, 2026
bbdb8e6
fix(gateway): scope profile credentials in api_server routed runs
jethac Jul 12, 2026
f97d267
test(multi-agent): cover agent_id routing, isolation & persistence
jethac Jul 12, 2026
3314149
docs(gateway): fix _attach_agent_id precedence docstring
jethac Jul 12, 2026
7dfe383
fix(gateway): persist routed agent_id on api_server session creation
davidgut1982 Jul 12, 2026
d56b264
feat(gateway): run stateful api_server session turns under the sessio…
davidgut1982 Jul 12, 2026
8b3a689
test(multi-agent): end-to-end integration suite for single-gateway ro…
jethac Jul 16, 2026
6ce6d11
test(multi-agent): session identity cannot be hijacked by a header
jethac Jul 16, 2026
c258a30
test(multi-agent): per-agent memory store isolation
jethac Jul 16, 2026
ac3f13a
test(multi-agent): full-process container smoke for gateway routing
jethac Jul 16, 2026
8c22d97
test(multi-agent): survive sys.modules nukes in profile ContextVar suite
jethac Jul 19, 2026
8b5e33e
fix(gateway): guard /v1/runs cleanup when agent construction fails
jethac Aug 5, 2026
8374a7c
fix(hooks): only tag lifecycle payloads with agent_id when a profile …
jethac Aug 5, 2026
2d23d08
fix(cron): include agent_id consistently in all delivery-target branches
jethac Aug 12, 2026
a40331e
test(cron): fix last stale delivery-target assertion missed by 71c8f14
jethac Aug 20, 2026
2e8cc7d
fix(gateway): run profile-route rejection gate before AgentProfile bi…
jethac Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenR
<tr><td><b>A closed learning loop</b></td><td>Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. <a href="https://github.com/plastic-labs/honcho">Honcho</a> dialectic user modeling. Compatible with the <a href="https://agentskills.io">agentskills.io</a> open standard.</td></tr>
<tr><td><b>Scheduled automations</b></td><td>Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended.</td></tr>
<tr><td><b>Delegates and parallelizes</b></td><td>Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns.</td></tr>
<tr><td><b>Multi-agent routing</b></td><td>Run multiple isolated agents from a single gateway process — each with its own model, personality, memory, and skills. Route by platform, chat, thread, or user. A coding agent with Opus, a research agent with Sonnet, a creative agent with GPT-5, all in one process.</td></tr>
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
<tr><td><b>Research-ready</b></td><td>Batch trajectory generation, trajectory compression for training the next generation of tool-calling models.</td></tr>
</table>
Expand Down
9 changes: 9 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -990,11 +990,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# to initialise session-scoped state (e.g. warm a memory cache).
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_agent_id = None
try:
from agent.profile import get_active_profile
_p = get_active_profile()
if _p:
_agent_id = _p.id
except Exception:
pass
_invoke_hook(
"on_session_start",
session_id=agent.session_id,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
agent_id=_agent_id,
)
except Exception as exc:
logger.warning("on_session_start hook failed: %s", exc)
Expand Down
193 changes: 193 additions & 0 deletions agent/profile.py
Original file line number Diff line number Diff line change
@@ -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,
)
9 changes: 9 additions & 0 deletions agent/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,14 @@ def build_turn_context(
plugin_user_context = ""
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_agent_id = None
try:
from agent.profile import get_active_profile
_p = get_active_profile()
if _p:
_agent_id = _p.id
except Exception:
pass
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
Expand All @@ -1257,6 +1265,7 @@ def build_turn_context(
platform=getattr(agent, "platform", None) or "",
parent_session_id=getattr(agent, "_parent_session_id", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
agent_id=_agent_id,
)
_ctx_parts: list[str] = []
# Spill oversized per-hook context to disk so a runaway plugin
Expand Down
18 changes: 18 additions & 0 deletions agent/turn_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,14 @@ def finalize_turn(
if final_response and not interrupted:
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_agent_id = None
try:
from agent.profile import get_active_profile
_p = get_active_profile()
if _p:
_agent_id = _p.id
except Exception:
pass
_invoke_hook(
"post_llm_call",
session_id=agent.session_id,
Expand All @@ -629,6 +637,7 @@ def finalize_turn(
conversation_history=list(messages),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
agent_id=_agent_id,
)
except Exception as exc:
logger.warning("post_llm_call hook failed: %s", exc)
Expand Down Expand Up @@ -815,6 +824,14 @@ def finalize_turn(
# Plugins can use this for cleanup, flushing buffers, etc.
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_agent_id = None
try:
from agent.profile import get_active_profile
_p = get_active_profile()
if _p:
_agent_id = _p.id
except Exception:
pass
_invoke_hook(
"on_session_end",
session_id=agent.session_id,
Expand All @@ -826,6 +843,7 @@ def finalize_turn(
turn_exit_reason=_turn_exit_reason,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
agent_id=_agent_id,
)
except Exception as exc:
logger.warning("on_session_end hook failed: %s", exc)
Expand Down
55 changes: 55 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -1695,6 +1695,61 @@ display:
# provider: custom
# base_url: "https://ollama.com/v1"

# =============================================================================
# Multi-Agent Routing (Single Gateway, Multiple Personalities)
# =============================================================================
# Run multiple isolated agents from a single gateway process. Each agent has
# its own model, system prompt (SOUL.md), memory, skills, and sessions.
# Messages are routed by matching metadata (platform, chat, thread, user).
#
# Quick start:
# hermes agent add coder --model anthropic/claude-opus-4-6
# hermes agent add research --model anthropic/claude-sonnet-4-6
# # Then edit this file to add routes below
#
# The CLI `hermes agent` subcommand manages agents and warns about orphaned
# routes when removing an agent. See `hermes agent --help` for details.
# =============================================================================

# default_agent: main # Fallback when no route matches (default: "main")

# agents:
# main:
# # Inherits gateway defaults — no overrides needed
# coder:
# model: "anthropic/claude-opus-4-6"
# provider: "anthropic"
# # home_dir: "~/.hermes/profiles/coder" # Optional: isolate memory/skills/SOUL.md
# enabled_toolsets: [filesystem, terminal, web, skills]
# research:
# model: "anthropic/claude-sonnet-4-6"
# # home_dir: "~/.hermes/profiles/research"
# enabled_toolsets: [web, browser, vision, skills]
# creative:
# model: "openai/gpt-5"
# provider: "openrouter"

# Routes are evaluated in declaration order — first match wins.
# Supported match keys: platform, chat_id, thread_id, user_id, user_id_alt,
# guild_id, parent_chat_id. All comparisons are exact string equality.
#
# routes:
# # Route a specific Telegram forum topic to the coder agent
# - match: { platform: telegram, chat_id: "-1001234567890", thread_id: "42" }
# agent: coder
# # Route the rest of that group to research
# - match: { platform: telegram, chat_id: "-1001234567890" }
# agent: research
# # Route a specific Slack workspace to coder
# - match: { platform: slack, guild_id: "T0ABC123" }
# agent: coder
# # Route a specific Discord channel to creative
# - match: { platform: discord, chat_id: "123456789012345678" }
# agent: creative
# # Route a specific user everywhere to research
# - match: { platform: telegram, user_id: "123456789" }
# agent: research

# =============================================================================
# Privacy
# =============================================================================
Expand Down
Loading
Loading